diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityException.java
new file mode 100644
index 00000000000..6df5f9db285
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityException.java
@@ -0,0 +1,27 @@
+/*
+ * 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.aviator._common.exception;
+
+/**
+ * Aviator connection failure with a message that is safe to show.
+ *
+ * The transport cause is not attached: its text can contain proxy credentials or
+ * host details, and fcli prints and logs the cause chain of simple exceptions.
+ */
+public final class AviatorConnectivityException extends AviatorSimpleException {
+ private static final long serialVersionUID = 1L;
+
+ public AviatorConnectivityException(String message) {
+ super(message);
+ }
+}
\ No newline at end of file
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java
index 79e400cd0f1..04b9913b22a 100644
--- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java
@@ -56,6 +56,7 @@
import com.fortify.cli.aviator.fpr.processor.AuditProcessor;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.aviator.util.Constants;
import com.fortify.cli.aviator.util.ResourceUtil;
import com.fortify.cli.aviator.util.StringUtil;
@@ -169,7 +170,7 @@ public AuditOutcome performAudit(Map auditResponses) {
if (promptsToAudit.isEmpty()) {
options.getLogger().progress("Audit skipped - no issues to process after filtering.");
} else {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(options.getUrl(), options.getLogger(), DEFAULT_PING_INTERVAL_SECONDS)) {
+ try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(options.getUrl(), options.getLogger(), DEFAULT_PING_INTERVAL_SECONDS, AviatorRequestPolicy.WAIT_FOR_READY)) {
CompletableFuture> future =
client.processBatchRequests(promptsToAudit, projectName, fprInfo.getBuildId(), SSCApplicationName,
SSCApplicationVersion, options.getToken(), options.getFprHandle(),
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java
index 96ee570dfb7..cf75d1ae425 100644
--- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java
@@ -36,6 +36,7 @@
import com.fortify.cli.aviator._common.exception.AviatorBugException;
import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.common.http.ssl.trust.FcliTrustManager;
import io.grpc.Status;
@@ -140,7 +141,7 @@ private static AviatorTunnelResult.TlsSucceeded toTlsSucceeded(SSLSocket sslSock
@Override
public AviatorGrpcReachabilityResult probeGrpc(String url, int timeoutSeconds) throws Exception {
- try (var client = AviatorGrpcClientHelper.createClient(url)) {
+ try (var client = AviatorGrpcClientHelper.createClient(url, AviatorRequestPolicy.WAIT_FOR_READY)) {
client.probeGetDefaultQuota(timeoutSeconds);
return AviatorGrpcReachabilityResult.responseReceived("OK", "Default quota response received");
} catch (StatusRuntimeException e) {
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectionContext.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectionContext.java
new file mode 100644
index 00000000000..e2746ef4d64
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectionContext.java
@@ -0,0 +1,44 @@
+/*
+ * 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.aviator.grpc;
+
+import java.util.Objects;
+
+import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan;
+
+import io.grpc.CallOptions;
+
+/**
+ * Endpoints retained for error messages, without proxy credentials.
+ * @param proxy null for a direct connection
+ */
+record AviatorConnectionContext(AviatorEndpoint target, AviatorEndpoint proxy) {
+ // Present only on fail-fast unary stubs; absence preserves the existing error mapping.
+ static final CallOptions.Key CALL_OPTION = CallOptions.Key.create("aviator-connectivity");
+
+ AviatorConnectionContext {
+ Objects.requireNonNull(target, "target");
+ }
+
+ static AviatorConnectionContext of(AviatorConnectionPlan plan) {
+ var target = new AviatorEndpoint(plan.target().host(), plan.effectivePort());
+ var proxy = plan.proxyDescriptor()
+ .map(d -> new AviatorEndpoint(d.getProxyHost(), d.getProxyPort()))
+ .orElse(null);
+ return new AviatorConnectionContext(target, proxy);
+ }
+
+ boolean isProxied() {
+ return proxy!=null;
+ }
+}
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKind.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKind.java
new file mode 100644
index 00000000000..0bb534fe118
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKind.java
@@ -0,0 +1,124 @@
+/*
+ * 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.aviator.grpc;
+
+import java.net.ConnectException;
+import java.net.UnknownHostException;
+import java.nio.channels.UnresolvedAddressException;
+import java.security.cert.CertPathValidatorException;
+import java.security.cert.CertificateException;
+import java.util.Optional;
+
+import javax.net.ssl.SSLException;
+
+import org.apache.commons.lang3.exception.ExceptionUtils;
+
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import io.grpc.netty.shaded.io.netty.channel.ConnectTimeoutException;
+import io.grpc.netty.shaded.io.netty.handler.proxy.HttpProxyHandler.HttpProxyConnectException;
+import io.grpc.netty.shaded.io.netty.handler.proxy.ProxyConnectException;
+
+/** Shared classification and messages for ordinary unary request failures. */
+enum AviatorConnectivityFailureKind {
+ PROXY_REJECTED,
+ PROXY_NEGOTIATION_FAILED,
+ CONNECT_TIMEOUT,
+ PROXY_REFUSED,
+ DIRECT_REFUSED,
+ DNS_FAILURE,
+ TLS_FAILURE,
+ UNAVAILABLE,
+ DEADLINE_EXCEEDED;
+
+ /**
+ * Leaves certificate failures to the fcli PKIX trust-store guidance, and application
+ * statuses to the existing exception mapping.
+ */
+ static Optional classify(StatusRuntimeException e, AviatorConnectionContext context) {
+ var code = e.getStatus().getCode();
+ if ( code!=Status.Code.UNAVAILABLE && code!=Status.Code.DEADLINE_EXCEEDED ) {
+ return Optional.empty();
+ }
+ if ( isCertificateFailure(e) ) {
+ return Optional.empty();
+ }
+ if ( has(e, SSLException.class) ) {
+ return Optional.of(TLS_FAILURE);
+ }
+ return Optional.of(classifyTransport(e, context, code));
+ }
+
+ // JDK PKIX and host name failures always carry one of these causes; other
+ // handshake failures, such as timeouts or protocol mismatches, are TLS_FAILURE.
+ private static boolean isCertificateFailure(Throwable e) {
+ return has(e, CertificateException.class) || has(e, CertPathValidatorException.class);
+ }
+
+ // The order is significant: all Netty connect and proxy exceptions extend ConnectException.
+ private static AviatorConnectivityFailureKind classifyTransport(Throwable e, AviatorConnectionContext context, Status.Code code) {
+ var neutral = code==Status.Code.DEADLINE_EXCEEDED ? DEADLINE_EXCEEDED : UNAVAILABLE;
+ if ( has(e, ProxyConnectException.class) ) {
+ // A JVM or GRPC_PROXY_EXP proxy can be active without an fcli proxy; fcli cannot name it.
+ if ( !context.isProxied() ) { return neutral; }
+ return has(e, HttpProxyConnectException.class) ? PROXY_REJECTED : PROXY_NEGOTIATION_FAILED;
+ }
+ if ( has(e, ConnectTimeoutException.class) ) { return CONNECT_TIMEOUT; }
+ // With an HTTP CONNECT proxy, the proxy is the only TCP peer of the client;
+ // a target refusal arrives as HttpProxyConnectException instead.
+ if ( isConnectionRefused(e) ) { return context.isProxied() ? PROXY_REFUSED : DIRECT_REFUSED; }
+ if ( has(e, UnknownHostException.class) || has(e, UnresolvedAddressException.class) ) { return DNS_FAILURE; }
+ return neutral;
+ }
+
+ // Java has no refusal-specific exception type. Match complete known reasons, never
+ // substrings that could come from a host name; unknown/localized reasons stay neutral.
+ private static boolean isConnectionRefused(Throwable e) {
+ return ExceptionUtils.getThrowableList(e).stream()
+ .filter(ConnectException.class::isInstance)
+ .map(Throwable::getMessage)
+ .anyMatch(message -> "Connection refused".equalsIgnoreCase(message)
+ || "Connection refused: getsockopt".equalsIgnoreCase(message)
+ || "Connection refused: no further information".equalsIgnoreCase(message)
+ || "connect(..) failed: Connection refused".equalsIgnoreCase(message)
+ || "finishConnect(..) failed: Connection refused".equalsIgnoreCase(message));
+ }
+
+ private static boolean has(Throwable e, Class extends Throwable> type) {
+ return ExceptionUtils.indexOfType(e, type) >= 0;
+ }
+
+ String formatMessage(AviatorConnectionContext context) {
+ var server = "Aviator server " + context.target().display();
+ var proxy = context.isProxied() ? "proxy " + context.proxy().display() : null;
+ return switch (this) {
+ case PROXY_REJECTED -> "Proxy " + context.proxy().display() + " rejected the connection to " + server
+ + "; check the proxy credentials and access rules";
+ case PROXY_NEGOTIATION_FAILED -> "Could not connect to " + server + " through " + proxy
+ + "; check that the proxy allows HTTPS connections to the Aviator server";
+ case CONNECT_TIMEOUT -> "Timed out connecting to " + (proxy==null ? server : proxy)
+ + "; check network access and firewall rules";
+ case PROXY_REFUSED -> "Proxy " + context.proxy().display() + " refused the connection; check the proxy host and port";
+ case DIRECT_REFUSED -> server + " refused the connection; check the Aviator URL";
+ case DNS_FAILURE -> proxy==null
+ ? "Could not resolve the host name of " + server + "; check the Aviator URL"
+ : "Could not resolve the host name of " + proxy + " or " + server + "; check the proxy settings and the Aviator URL";
+ case TLS_FAILURE -> "Could not set up a TLS connection to " + server
+ + "; check that the Aviator URL uses the HTTPS port of the Aviator server";
+ case UNAVAILABLE -> server + " is unavailable or could not be reached"
+ + "; try again later, or check the Aviator URL, network access, and proxy settings";
+ case DEADLINE_EXCEEDED -> "The Aviator request timed out; try again later"
+ + " (the server may be busy, or network/proxy delays may be involved)";
+ };
+ }
+}
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorEndpoint.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorEndpoint.java
new file mode 100644
index 00000000000..234500c611e
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorEndpoint.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.aviator.grpc;
+
+/** Replaces control characters in host names before display. */
+record AviatorEndpoint(String host, int port) {
+ private static final String UNKNOWN_HOST = "unknown";
+
+ AviatorEndpoint {
+ host = sanitize(host);
+ }
+
+ String display() {
+ var isUnbracketedIpv6 = host.contains(":") && !host.startsWith("[");
+ return isUnbracketedIpv6 ? "[" + host + "]:" + port : host + ":" + port;
+ }
+
+ private static String sanitize(String host) {
+ if ( host==null || host.isBlank() ) {
+ return UNKNOWN_HOST;
+ }
+ var result = new StringBuilder(host.length());
+ host.codePoints().forEach(c -> result.appendCodePoint(Character.isISOControl(c) ? '?' : c));
+ return result.toString();
+ }
+}
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java
index 9903b9a72b6..b5ab582fe80 100644
--- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java
@@ -14,6 +14,7 @@
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Queue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
@@ -71,10 +72,7 @@
import com.fortify.grpc.token.TokenValidationResponse;
import com.fortify.grpc.token.ValidateUserTokenRequest;
-import io.grpc.CompressorRegistry;
-import io.grpc.DecompressorRegistry;
import io.grpc.ManagedChannel;
-import io.grpc.ManagedChannelBuilder;
public class AviatorGrpcClient implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(AviatorGrpcClient.class);
@@ -94,15 +92,26 @@ public class AviatorGrpcClient implements AutoCloseable {
private final java.util.concurrent.ScheduledExecutorService pingScheduler;
final AtomicBoolean isShutdown = new AtomicBoolean(false);
- public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) {
+ /** Fail-fast clients require a connection context for error messages. */
+ AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds,
+ AviatorRequestPolicy requestPolicy, AviatorConnectionContext connectionContext) {
LOG.debug("Initializing AviatorGrpcClient with ManagedChannel");
+ Objects.requireNonNull(requestPolicy, "requestPolicy");
this.logger = logger;
this.channel = channel;
this.asyncStub = AuditorServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady();
- this.blockingStub = ApplicationServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady();
- this.tokenServiceBlockingStub = TokenServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady();
- this.entitlementServiceBlockingStub = EntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady();
- this.dastEntitlementServiceBlockingStub = DastEntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady();
+ this.blockingStub = requestPolicy.configureNewStub(ApplicationServiceGrpc.newBlockingStub(channel)
+ .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE)
+ .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext);
+ this.tokenServiceBlockingStub = requestPolicy.configureNewStub(TokenServiceGrpc.newBlockingStub(channel)
+ .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE)
+ .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext);
+ this.entitlementServiceBlockingStub = requestPolicy.configureNewStub(EntitlementServiceGrpc.newBlockingStub(channel)
+ .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE)
+ .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext);
+ this.dastEntitlementServiceBlockingStub = requestPolicy.configureNewStub(DastEntitlementServiceGrpc.newBlockingStub(channel)
+ .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE)
+ .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext);
this.correlationAsyncStub = CorrelationServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady();
this.dastAuditAsyncStub = DastAuditServiceGrpc.newStub(channel)
.withCompression("gzip")
@@ -123,15 +132,6 @@ public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAv
});
}
- public AviatorGrpcClient(String host, int port, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) {
- this(ManagedChannelBuilder.forAddress(host, port).useTransportSecurity().maxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).keepAliveTime(30, TimeUnit.SECONDS).keepAliveTimeout(10, TimeUnit.SECONDS).keepAliveWithoutCalls(true).enableRetry().compressorRegistry(CompressorRegistry.getDefaultInstance()).decompressorRegistry(DecompressorRegistry.getDefaultInstance()).build(), defaultTimeoutSeconds, logger, pingIntervalSeconds);
- LOG.debug("Initialized AviatorGrpcClient - Host: {}, Port: {}", host, port);
- }
-
- public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger) {
- this(channel, defaultTimeoutSeconds, logger, 30);
- }
-
public CompletableFuture> processBatchRequests(Queue requests, String projectName,
String FPRBuildId, String SSCApplicationName, String SSCApplicationVersion, String token, FprHandle fprHandle,
List customPriorityOrder, ISourceDecoder sourceDecoder, FVDLMetadata fvdlMetadata) {
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java
index d01c9d04586..bace76628ea 100644
--- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java
@@ -15,6 +15,7 @@
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.URI;
+import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@@ -44,12 +45,20 @@ public class AviatorGrpcClientHelper {
private static final Logger LOG = LoggerFactory.getLogger(AviatorGrpcClientHelper.class);
private static final String AVIATOR_MODULE = "aviator";
- public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, long pingIntervalSeconds) throws AviatorSimpleException {
- if (url == null || url.trim().isEmpty()) {
- throw new AviatorSimpleException("Aviator URL cannot be null or empty.");
- }
+ /**
+ * Creates a client with an explicit unary request policy.
+ * Streaming calls retain their own recovery behavior regardless of this policy.
+ */
+ public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, long pingIntervalSeconds,
+ AviatorRequestPolicy policy) throws AviatorSimpleException {
+ Objects.requireNonNull(policy, "policy");
+ return createClient(createConnectionPlan(url), logger, pingIntervalSeconds, policy);
+ }
- var connectionPlan = createConnectionPlan(url);
+ /** Accepts an explicit route so callers need not resolve proxy settings again. */
+ static AviatorGrpcClient createClient(AviatorConnectionPlan connectionPlan, IAviatorLogger logger, long pingIntervalSeconds,
+ AviatorRequestPolicy policy) {
+ Objects.requireNonNull(policy, "policy");
var proxyDescriptor = connectionPlan.proxyDescriptor();
var target = connectionPlan.target();
var builder = target.port() == null
@@ -63,7 +72,8 @@ public static AviatorGrpcClient createClient(String url, IAviatorLogger logger,
}
var channel = configureBuilder(builder, proxyDescriptor).build();
- return new AviatorGrpcClient(channel, Constants.DEFAULT_TIMEOUT_SECONDS, logger, pingIntervalSeconds);
+ return new AviatorGrpcClient(channel, Constants.DEFAULT_TIMEOUT_SECONDS, logger, pingIntervalSeconds,
+ policy, AviatorConnectionContext.of(connectionPlan));
}
public static AviatorConnectionPlan createConnectionPlan(String url) {
@@ -79,27 +89,29 @@ public static AviatorConnectionPlan createConnectionPlan(String url) {
public static ParsedTarget parseTarget(String url) {
var normalizedUrl = normalizeUrl(url);
+ var safeUrl = toSafeUrlForDisplay(url);
URI uri;
try {
uri = URI.create(normalizedUrl);
- } catch (Exception e) {
- throw new AviatorSimpleException("Aviator URL format is invalid. Expected 'host:port' or a valid target string. Provided URL: " + url, e);
+ } catch (IllegalArgumentException e) {
+ // URI parsing errors include the original URL, which may contain credentials.
+ throw new AviatorSimpleException("Invalid Aviator URL; expected a host and optional port: " + safeUrl);
}
var host = uri.getHost();
if ( host==null || host.isBlank() ) {
- throw new AviatorSimpleException("Aviator URL is invalid: Host cannot be empty. Provided URL: " + url);
+ throw new AviatorSimpleException("Aviator URL is invalid: Host cannot be empty. Provided URL: " + safeUrl);
}
if ( uri.getPath()!=null && !uri.getPath().isBlank() && !"/".equals(uri.getPath()) ) {
- LOG.warn("WARN: URL contained a path ('{}'), using only the host/port part. Full URL: {}", uri.getPath(), url);
+ LOG.warn("WARN: Ignoring path in Aviator URL; using only the host and port");
}
var port = uri.getPort();
if ( port==-1 ) {
return new ParsedTarget(host, null);
} else if (port <= 0 || port > 65535) {
- throw new AviatorSimpleException("Aviator URL is invalid: Invalid port number '"+port+"'. Provided URL: " + url);
+ throw new AviatorSimpleException("Aviator URL is invalid: Invalid port number '"+port+"'. Provided URL: " + safeUrl);
} else {
return new ParsedTarget(host, port);
}
@@ -121,7 +133,7 @@ public static String normalizeUrl(String url) {
var schemeSeparator = trimmed.indexOf("://");
var scheme = trimmed.substring(0, schemeSeparator);
if ( !"https".equalsIgnoreCase(scheme) ) {
- throw new UnsupportedAviatorUrlSchemeException(scheme, url);
+ throw new UnsupportedAviatorUrlSchemeException(scheme, toSafeUrlForDisplay(url));
}
if ( "https".equals(scheme) ) {
return trimmed;
@@ -130,6 +142,27 @@ public static String normalizeUrl(String url) {
return "https"+trimmed.substring(schemeSeparator);
}
+ /**
+ * Returns only the URL scheme and authority, with user-info replaced by {@code ***}.
+ * Paths, queries, and fragments are omitted because they can contain credentials.
+ */
+ static String toSafeUrlForDisplay(String url) {
+ if ( url==null ) {
+ return null;
+ }
+ var schemeSeparator = url.indexOf("://");
+ var authorityStart = schemeSeparator<0 ? 0 : schemeSeparator+3;
+ var authorityEnd = authorityStart;
+ while ( authorityEnd proxyDescriptor) {
var configuredBuilder = builder
.sslContext(createSslContext())
@@ -171,8 +204,11 @@ static ProxiedSocketAddress toProxiedSocketAddress(SocketAddress targetAddress,
return builder.build();
}
- public static AviatorGrpcClient createClient(String url) throws AviatorSimpleException {
- return createClient(url, null, Constants.DEFAULT_PING_INTERVAL_SECONDS);
+ /**
+ * Creates a client with the default ping interval and an explicit unary request policy.
+ */
+ public static AviatorGrpcClient createClient(String url, AviatorRequestPolicy policy) throws AviatorSimpleException {
+ return createClient(url, null, Constants.DEFAULT_PING_INTERVAL_SECONDS, policy);
}
public record AviatorConnectionPlan(
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorRequestPolicy.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorRequestPolicy.java
new file mode 100644
index 00000000000..257a71b4165
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorRequestPolicy.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.aviator.grpc;
+
+import java.util.Objects;
+
+import io.grpc.stub.AbstractBlockingStub;
+
+/** Unary request behavior; audit and diagnostic clients use {@link #WAIT_FOR_READY}. */
+public enum AviatorRequestPolicy {
+ FAIL_FAST,
+ WAIT_FOR_READY;
+
+ boolean isWaitForReady() {
+ return this == WAIT_FOR_READY;
+ }
+
+ /** Requires a fresh stub: gRPC's default is fail-fast, and wait-for-ready cannot be unset through the stub API. */
+ > S configureNewStub(S stub, AviatorConnectionContext context) {
+ if ( isWaitForReady() ) {
+ return stub.withWaitForReady();
+ }
+ return stub.withOption(AviatorConnectionContext.CALL_OPTION, Objects.requireNonNull(context, "connectionContext"));
+ }
+
+}
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java
index 959c68a53f2..8482bb39788 100644
--- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java
@@ -87,6 +87,7 @@ class AviatorStreamProcessor implements AutoCloseable {
private RequestHandler requestHandler;
private ScheduledFuture> pingTask;
+ private volatile StreamInitializationTimeout initializationTimeout;
private final AtomicBoolean isPinging = new AtomicBoolean(false);
private final ConcurrentLinkedDeque processingQueue = new ConcurrentLinkedDeque<>();
@@ -191,14 +192,23 @@ private void startStreamWithRetry(Map responses, AtomicIn
this.streamLatch = new CountDownLatch(1);
CountDownLatch initLatch = new CountDownLatch(1);
+ var attemptHandler = requestHandler;
+ var initializationTimeout = new StreamInitializationTimeout(pingScheduler,
+ () -> attemptHandler.sendError(StreamInitializationTimeout.expiredError("Audit")));
+ this.initializationTimeout = initializationTimeout;
+ resultFuture.whenComplete((result, error) -> {
+ initializationTimeout.close();
+ initLatch.countDown();
+ });
ClientResponseObserver responseObserver = createResponseObserver(
- responses, processedRequests, resultFuture, this.streamLatch, initLatch);
+ responses, processedRequests, resultFuture, this.streamLatch, initLatch, initializationTimeout);
try {
asyncStub.processStream(responseObserver);
LOG.info("Sending initialization request for stream retry {}", currentStreamState.streamRetryCount);
sendInitRequest();
+ initializationTimeout.arm();
if (currentStreamState.streamRetryCount > 1) {
requestSemaphore.drainPermits();
@@ -208,9 +218,8 @@ private void startStreamWithRetry(Map responses, AtomicIn
processingExecutor.submit(() -> {
try { // Outer try to propagate to future
- if (!initLatch.await(30, TimeUnit.SECONDS)) {
- throw new AviatorTechnicalException("Stream initialization timed out");
- }
+ initLatch.await();
+ if (resultFuture.isDone() || !currentStreamState.isStreamInitialized) return;
if (currentStreamState.streamRetryCount > 1) {
reQueueUnprocessedRequests();
@@ -302,7 +311,7 @@ private void startStreamWithRetry(Map responses, AtomicIn
private ClientResponseObserver createResponseObserver(
Map responses, AtomicInteger processedRequests,
CompletableFuture> resultFuture,
- CountDownLatch streamLatch, CountDownLatch initLatch) {
+ CountDownLatch streamLatch, CountDownLatch initLatch, StreamInitializationTimeout initializationTimeout) {
return new ClientResponseObserver() {
private final AtomicBoolean isInitialized = new AtomicBoolean(false);
@@ -361,6 +370,7 @@ public void onNext(AuditorResponse response) {
if (completedWrapper == null) {
if (!isInitialized.get()) {
if ("SUCCESS".equals(response.getStatus())) {
+ if (!initializationTimeout.initialized()) return;
if (response.hasQuota()) {
currentStreamState.quota = response.getQuota();
if (response.hasQuotaLastUpdated()) {
@@ -428,7 +438,15 @@ public void onNext(AuditorResponse response) {
@Override
public void onError(Throwable t) {
+ boolean initializationExpired = initializationTimeout.isExpired();
+ initializationTimeout.close();
+ initLatch.countDown();
stopPingPong();
+ if (initializationExpired) {
+ resultFuture.completeExceptionally(StreamInitializationTimeout.expiredError("Audit"));
+ streamLatch.countDown();
+ return;
+ }
if (isRetryableError(t)) {
boolean infinite = isInfiniteRetryError(t);
@@ -512,6 +530,8 @@ public void onError(Throwable t) {
@Override
public void onCompleted() {
+ initializationTimeout.close();
+ initLatch.countDown();
stopPingPong();
logger.progress("Stream completed by server");
if (!resultFuture.isDone()) {
@@ -1068,6 +1088,7 @@ private void sendPing() {
@Override
public void close() {
+ if (initializationTimeout != null) initializationTimeout.close();
stopPingPong();
if (requestHandler != null && !requestHandler.isCompleted()) {
requestHandler.complete(); // Flush remaining requests
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java
index 4b2b27fb3e5..008b414cdd4 100644
--- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java
@@ -98,6 +98,7 @@ public class CorrelationStreamProcessor implements AutoCloseable {
private final Set pendingCorrelationRequestIds = ConcurrentHashMap.newKeySet();
private final ConcurrentHashMap validationRequestToDastId = new ConcurrentHashMap<>();
private volatile CompletableFuture resultFuture;
+ private volatile StreamInitializationTimeout initializationTimeout;
/**
* Keys of SAST–DAST pairs that were confirmed or rejected in a previous run.
@@ -164,6 +165,9 @@ public CompletableFuture processCorrelation(
this.resultFuture = resultFuture;
this.streamLatch = new CountDownLatch(1);
+ initializationTimeout = new StreamInitializationTimeout(pingScheduler,
+ () -> requestHandler.sendError(StreamInitializationTimeout.expiredError("Correlation")));
+ resultFuture.whenComplete((result, error) -> initializationTimeout.close());
startStream(resultFuture, scanGuid);
return resultFuture;
@@ -223,6 +227,7 @@ private void startStream(CompletableFuture resultFuture, Stri
requestHandler.sendRequest(
CorrelationClientMessage.newBuilder().setInit(initReq.build()).build()
);
+ initializationTimeout.arm();
}
// ─── Phase 1: Send correlation requests ────────────────────────────
@@ -308,6 +313,11 @@ public void onNext(CorrelationServerMessage message) {
@Override
public void onError(Throwable t) {
stopPingPong();
+ if (initializationTimeout.isExpired()) {
+ resultFuture.completeExceptionally(StreamInitializationTimeout.expiredError("Correlation"));
+ streamLatch.countDown();
+ return;
+ }
Status status = Status.fromThrowable(t);
LOG.error("Correlation stream error: {} - {}", status.getCode(), status.getDescription(), t);
resultFuture.completeExceptionally(
@@ -331,6 +341,7 @@ public void onCompleted() {
// ─── Response handlers ─────────────────────────────────────────────
private void handleInitResponse(CorrelationInitResponse resp) {
+ if (!initializationTimeout.initialized()) return;
LOG.info("Correlation stream initialized. Server stream ID: {}, Reserved quota: {}, Status: {}",
resp.getServerStreamId(), resp.getReservedQuota(), resp.getStatus());
@@ -717,6 +728,7 @@ public record CorrelationBucketData(
@Override
public void close() {
+ if (initializationTimeout != null) initializationTimeout.close();
stopPingPong();
if (requestHandler != null && !requestHandler.isCompleted()) {
requestHandler.complete();
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java
index c830ce7949c..e328483bfb9 100644
--- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java
@@ -84,6 +84,7 @@ public class DastAuditStreamProcessor implements AutoCloseable {
private final Map requestIdsByIssue = new ConcurrentHashMap<>();
private final Set completedRequestIds = ConcurrentHashMap.newKeySet();
private final Set pendingIssueIds = ConcurrentHashMap.newKeySet();
+ private volatile StreamInitializationTimeout initializationTimeout;
private int reservedQuota;
private int exceededCount;
private boolean unlimitedQuota;
@@ -118,6 +119,7 @@ public CompletableFuture process(
this.isClosed.set(false);
this.quotaMetadataInitialized = false;
initializeWorkItems(workItems);
+ resultFuture.whenComplete((result, error) -> closeInitializationTimeout());
startStream();
return resultFuture;
}
@@ -141,11 +143,16 @@ private void startStream() {
streamId, pendingIssueIds.size(), totalReportedIssues);
try {
+ var attemptHandler = requestHandler;
+ var attemptTimeout = new StreamInitializationTimeout(pingScheduler,
+ () -> attemptHandler.sendError(StreamInitializationTimeout.expiredError("DAST audit")));
+ initializationTimeout = attemptTimeout;
StreamObserver requestObserver =
- asyncStub.processDastAuditStream(new ResponseObserver(generation));
+ asyncStub.processDastAuditStream(new ResponseObserver(generation, attemptTimeout));
requestHandler.initialize(requestObserver);
startPingPong();
sendInit();
+ attemptTimeout.arm();
} catch (RuntimeException exception) {
handleStreamError(exception);
}
@@ -166,6 +173,7 @@ private void sendInit() {
}
private void handleInit(DastAuditInitResponse response) {
+ if (!initializationTimeout.initialized()) return;
if (!isSuccess(response.getStatus())) {
fail(new AviatorSimpleException("DAST audit initialization failed: " + response.getStatusMessage()));
return;
@@ -278,9 +286,11 @@ private void completeRequestsIfDone() {
private class ResponseObserver implements ClientResponseObserver {
private final long generation;
+ private final StreamInitializationTimeout attemptTimeout;
- private ResponseObserver(long generation) {
+ private ResponseObserver(long generation, StreamInitializationTimeout attemptTimeout) {
this.generation = generation;
+ this.attemptTimeout = attemptTimeout;
}
private boolean isCurrent() {
@@ -308,6 +318,11 @@ public void onNext(DastAuditServerMessage message) {
public void onError(Throwable throwable) {
if (!isCurrent()) return;
isRpcCompleted.set(true);
+ if (attemptTimeout.isExpired()) {
+ stopPingPong();
+ completeExceptionally(StreamInitializationTimeout.expiredError("DAST audit"));
+ return;
+ }
handleStreamError(throwable);
}
@@ -349,6 +364,7 @@ private void startPingPong() {
}
private void handleStreamError(Throwable throwable) {
+ closeInitializationTimeout();
stopPingPong();
if (resultFuture.isDone() || isClosed.get() || isRetryScheduled.get()) return;
@@ -468,6 +484,11 @@ private void stopPingPong() {
if (pingTask != null) pingTask.cancel(false);
}
+ private void closeInitializationTimeout() {
+ var timeout = initializationTimeout;
+ if (timeout != null) timeout.close();
+ }
+
private boolean isSuccess(String status) {
return "SUCCESS".equalsIgnoreCase(status) || "OK".equalsIgnoreCase(status);
}
@@ -479,6 +500,7 @@ private String value(String value) {
@Override
public void close() {
isClosed.set(true);
+ closeInitializationTimeout();
stopPingPong();
if (retryTask != null) retryTask.cancel(false);
if (!isRpcCompleted.get() && activeRequestStream != null) {
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java
index 1fc5785ca33..c3992cd289f 100644
--- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java
@@ -12,10 +12,13 @@
*/
package com.fortify.cli.aviator.grpc;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
+import org.apache.commons.lang3.exception.ExceptionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,6 +29,7 @@
import com.fortify.aviator.grpc.Fragment;
import com.fortify.aviator.grpc.IssueData;
import com.fortify.aviator.grpc.StackTraceElementList;
+import com.fortify.cli.aviator._common.exception.AviatorConnectivityException;
import com.fortify.cli.aviator._common.exception.AviatorSimpleException;
import com.fortify.cli.aviator._common.exception.AviatorTechnicalException;
import com.fortify.cli.aviator.audit.model.AuditResponse;
@@ -50,61 +54,17 @@ interface GrpcCall {
R call(S stub, T request) throws StatusRuntimeException;
}
+ /**
+ * Applies the 30-second deadline to each unary call.
+ * Fail-fast stubs carry {@link AviatorConnectionContext#CALL_OPTION} for safe connectivity classification.
+ * Stubs without that option retain the existing status mapping.
+ */
static , T, R> R executeGrpcCall(S stub, GrpcCall call, T request, String operation) {
try {
- S stubWithDeadline = stub.withDeadlineAfter(Constants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
- return call.call(stubWithDeadline, request);
+ return call.call(stub.withDeadlineAfter(Constants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS), request);
} catch (StatusRuntimeException e) {
- Status status = e.getStatus();
- String serverDescription = status.getDescription() != null && !status.getDescription().isBlank()
- ? status.getDescription()
- : "No additional details were provided by the server.";
-
- switch (status.getCode()) {
- case INVALID_ARGUMENT:
- throw new AviatorSimpleException(String.format("Invalid input for %s. The server reported: %s. Please check the provided arguments.", operation, serverDescription));
- case NOT_FOUND:
- throw new AviatorSimpleException(String.format("The requested resource was not found during %s. The server reported: %s. Please verify the name or ID is correct.", operation, serverDescription));
- case ALREADY_EXISTS:
- throw new AviatorSimpleException(String.format("Cannot perform %s because a resource with the same identifier already exists. The server reported: %s.", operation, serverDescription));
- case FAILED_PRECONDITION:
- throw new AviatorSimpleException(String.format("The %s operation could not be completed because a required condition was not met. The server reported: %s.", operation, serverDescription));
-
- case PERMISSION_DENIED:
- if (serverDescription.toLowerCase().contains("invalid signature")) {
- throw new AviatorSimpleException("Permission Denied: Invalid signature. Please verify the private key in your admin configuration is correct and corresponds to the public key registered with Aviator.");
- } else {
- throw new AviatorSimpleException(String.format("Permission Denied for %s. You may not have the required roles for this action. Server details: %s", operation, serverDescription));
- }
- case UNAUTHENTICATED:
- throw new AviatorSimpleException("Authentication Failed: The token or credentials used are invalid or expired. Please log in again using 'fcli aviator session login' or verify your admin configuration.");
-
- case RESOURCE_EXHAUSTED:
- throw new AviatorSimpleException(String.format("The server's resource limits were exceeded during %s. Please try again later or contact support. Server details: %s", operation, serverDescription));
- case DEADLINE_EXCEEDED:
- String timeoutMessage = String.format(
- "The %s operation timed out because the server did not respond in time.\n\n" +
- "Please check the following:\n" +
- " 1. The Aviator URL in your configuration is correct and reachable.\n" +
- " 2. Your network connection is stable and any firewalls or proxies are properly configured.\n\n" +
- "If the URL and network are correct, the server may be experiencing high load. Please try again later.",
- operation
- );
- throw new AviatorSimpleException(timeoutMessage);
-
- case CANCELLED:
- case UNKNOWN:
- case ABORTED:
- case UNIMPLEMENTED:
- case INTERNAL:
- case UNAVAILABLE:
- case DATA_LOSS:
- default:
- String techMessage = String.format("A technical error occurred on the server while performing the %s operation. Please check the fcli logs for details or contact support if the issue persists.", operation);
- String logMessage = String.format("gRPC call for '%s' failed with status %s (%s): %s", operation, status.getCode(), status.getCode().name(), serverDescription);
- LOG.error(logMessage, e);
- throw new AviatorTechnicalException(techMessage, e);
- }
+ throw toConnectivityException(e, stub.getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION))
+ .orElseGet(() -> toAviatorException(e, operation));
} catch (Exception e) {
String errorMessage = "An unexpected client-side error occurred during the " + operation + " operation.";
LOG.error(errorMessage, e);
@@ -112,6 +72,77 @@ static , T, R> R executeGrpcCall(S stub, GrpcC
}
}
+ private static Optional toConnectivityException(StatusRuntimeException e, AviatorConnectionContext context) {
+ if ( context == null ) {
+ return Optional.empty();
+ }
+ return AviatorConnectivityFailureKind.classify(e, context)
+ .map(kind -> {
+ // Cause messages can contain credentials, so log only the cause types.
+ LOG.debug("Aviator connectivity failure: kind={}, causes=[{}]", kind, getCauseTypes(e));
+ return new AviatorConnectivityException(kind.formatMessage(context));
+ });
+ }
+
+ private static String getCauseTypes(Throwable e) {
+ return ExceptionUtils.getThrowableList(e).stream()
+ .map(t -> t.getClass().getName())
+ .collect(Collectors.joining(" <- "));
+ }
+
+ private static RuntimeException toAviatorException(StatusRuntimeException e, String operation) {
+ Status status = e.getStatus();
+ String serverDescription = status.getDescription() != null && !status.getDescription().isBlank()
+ ? status.getDescription()
+ : "No additional details were provided by the server.";
+
+ switch (status.getCode()) {
+ case INVALID_ARGUMENT:
+ return new AviatorSimpleException(String.format("Invalid input for %s. The server reported: %s. Please check the provided arguments.", operation, serverDescription));
+ case NOT_FOUND:
+ return new AviatorSimpleException(String.format("The requested resource was not found during %s. The server reported: %s. Please verify the name or ID is correct.", operation, serverDescription));
+ case ALREADY_EXISTS:
+ return new AviatorSimpleException(String.format("Cannot perform %s because a resource with the same identifier already exists. The server reported: %s.", operation, serverDescription));
+ case FAILED_PRECONDITION:
+ return new AviatorSimpleException(String.format("The %s operation could not be completed because a required condition was not met. The server reported: %s.", operation, serverDescription));
+
+ case PERMISSION_DENIED:
+ if (serverDescription.toLowerCase().contains("invalid signature")) {
+ return new AviatorSimpleException("Permission Denied: Invalid signature. Please verify the private key in your admin configuration is correct and corresponds to the public key registered with Aviator.");
+ } else {
+ return new AviatorSimpleException(String.format("Permission Denied for %s. You may not have the required roles for this action. Server details: %s", operation, serverDescription));
+ }
+ case UNAUTHENTICATED:
+ return new AviatorSimpleException("Authentication Failed: The token or credentials used are invalid or expired. Please log in again using 'fcli aviator session login' or verify your admin configuration.");
+
+ case RESOURCE_EXHAUSTED:
+ return new AviatorSimpleException(String.format("Aviator server resource limits were exceeded during %s (server details: %s); try again later, and contact support if the problem continues", operation, serverDescription));
+ case DEADLINE_EXCEEDED:
+ String timeoutMessage = String.format(
+ "The %s operation timed out because the server did not respond in time.\n\n" +
+ "Please check the following:\n" +
+ " 1. The Aviator URL in your configuration is correct and reachable.\n" +
+ " 2. Your network connection is stable and any firewalls or proxies are properly configured.\n\n" +
+ "If the URL and network are correct, the server may be experiencing high load. Please try again later.",
+ operation
+ );
+ return new AviatorSimpleException(timeoutMessage);
+
+ case CANCELLED:
+ case UNKNOWN:
+ case ABORTED:
+ case UNIMPLEMENTED:
+ case INTERNAL:
+ case UNAVAILABLE:
+ case DATA_LOSS:
+ default:
+ String techMessage = String.format("The Aviator %s operation failed with status %s; check the Aviator URL and network access, run the command again with --log-level DEBUG to log details, and contact support if the problem continues", operation, status.getCode());
+ String logMessage = String.format("gRPC call for '%s' failed with status %s (%s): %s", operation, status.getCode(), status.getCode().name(), serverDescription);
+ LOG.error(logMessage, e);
+ return new AviatorTechnicalException(techMessage, e);
+ }
+ }
+
static AuditRequest convertToAuditRequest(UserPrompt userPrompt, String streamId, String requestId) {
List stackTraceElementLists = new ArrayList<>();
if (userPrompt.getStackTrace() != null) {
diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeout.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeout.java
new file mode 100644
index 00000000000..e72f3a34ddc
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeout.java
@@ -0,0 +1,86 @@
+/*
+ * 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.aviator.grpc;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.fortify.cli.aviator._common.exception.AviatorSimpleException;
+
+/**
+ * Bounds the wait for one stream attempt's initialization response. The expiry action must only
+ * cancel the RPC; the processor's own onError path then runs its normal terminal handling.
+ *
+ * TODO: Nothing bounds silence after initialization (pongs are only logged). Add an idle/no-progress
+ * timeout for the SAST, DAST and correlation streams.
+ */
+final class StreamInitializationTimeout implements AutoCloseable {
+ private static final long TIMEOUT_SECONDS = 30;
+
+ private enum State { IDLE, WAITING, INITIALIZED, EXPIRED, CLOSED }
+
+ private final ScheduledExecutorService scheduler;
+ private final Runnable cancelRpc;
+ private final AtomicReference state = new AtomicReference<>(State.IDLE);
+ private volatile ScheduledFuture> timeout;
+
+ StreamInitializationTimeout(ScheduledExecutorService scheduler, Runnable cancelRpc) {
+ this.scheduler = scheduler;
+ this.cancelRpc = cancelRpc;
+ }
+
+ static AviatorSimpleException expiredError(String operation) {
+ return new AviatorSimpleException(operation
+ + " initialization timed out. Check the Aviator URL, proxy settings, and server availability.");
+ }
+
+ /** Starts the countdown after the init request is queued. Does nothing without a scheduler. */
+ void arm() {
+ if (scheduler == null || !state.compareAndSet(State.IDLE, State.WAITING)) return;
+ timeout = scheduler.schedule(this::expire, TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ if (state.get() != State.WAITING) timeout.cancel(false);
+ }
+
+ /** Returns false if the timeout or a close won the race with the initialization response. */
+ boolean initialized() {
+ if (state.compareAndSet(State.WAITING, State.INITIALIZED)
+ || state.compareAndSet(State.IDLE, State.INITIALIZED)) {
+ cancelTimer();
+ }
+ return state.get() == State.INITIALIZED;
+ }
+
+ boolean isExpired() {
+ return state.get() == State.EXPIRED;
+ }
+
+ @Override
+ public void close() {
+ if (state.compareAndSet(State.WAITING, State.CLOSED) || state.compareAndSet(State.IDLE, State.CLOSED)) {
+ cancelTimer();
+ }
+ }
+
+ private void expire() {
+ if (state.compareAndSet(State.WAITING, State.EXPIRED)) {
+ cancelRpc.run();
+ }
+ }
+
+ private void cancelTimer() {
+ var scheduled = timeout;
+ if (scheduled != null) scheduled.cancel(false);
+ }
+}
\ No newline at end of file
diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityExceptionTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityExceptionTest.java
new file mode 100644
index 00000000000..0e7f83292aa
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityExceptionTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.aviator._common.exception;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
+import com.fortify.cli.common.exception.FcliExecutionExceptionHandler;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.classic.spi.ThrowableProxyUtil;
+import ch.qos.logback.core.read.ListAppender;
+import picocli.CommandLine;
+import picocli.CommandLine.Help.Ansi;
+import picocli.CommandLine.Model.CommandSpec;
+
+class AviatorConnectivityExceptionTest {
+ private static final String SECRET = "sentinel-secret-value";
+
+ private final Logger logger = (Logger)LoggerFactory.getLogger(FcliExecutionExceptionHandler.class);
+ private final ListAppender appender = new ListAppender<>();
+ private Level previousLevel;
+
+ @BeforeEach
+ void setUp() {
+ previousLevel = logger.getLevel();
+ logger.setLevel(Level.DEBUG);
+ appender.start();
+ logger.addAppender(appender);
+ }
+
+ @AfterEach
+ void tearDown() {
+ logger.detachAppender(appender);
+ logger.setLevel(previousLevel);
+ }
+
+ @Test
+ void handlerPrintsTheMessageWithoutCause() {
+ var err = new StringWriter();
+ var exception = new AviatorConnectivityException("Proxy localhost:33444 refused the connection; check the proxy host and port");
+
+ int exitCode = FcliExecutionExceptionHandler.INSTANCE.handleException(exception, createCommandLine(err));
+
+ assertEquals(1, exitCode);
+ assertNull(exception.getCause());
+ assertFalse(err.toString().contains("Caused by"));
+ }
+
+ @Test
+ void malformedUrlDoesNotExposeCredentialsInOutputOrLogs() {
+ var exception = assertThrows(AviatorSimpleException.class,
+ () -> AviatorGrpcClientHelper.parseTarget("https://user:" + SECRET + "@aviator.invalid/%zz"));
+ var err = new StringWriter();
+
+ assertEquals(1, FcliExecutionExceptionHandler.INSTANCE.handleException(exception, createCommandLine(err)));
+
+ assertNull(exception.getCause());
+ assertFalse(err.toString().contains(SECRET));
+ assertEquals(1, appender.list.size());
+ var event = appender.list.get(0);
+ assertFalse(event.getFormattedMessage().contains(SECRET));
+ assertFalse(ThrowableProxyUtil.asString(event.getThrowableProxy()).contains(SECRET));
+ }
+
+ private static CommandLine createCommandLine(StringWriter err) {
+ var commandLine = new CommandLine(CommandSpec.create());
+ commandLine.setErr(new PrintWriter(err));
+ commandLine.setColorScheme(CommandLine.Help.defaultColorScheme(Ansi.OFF));
+ return commandLine;
+ }
+}
\ No newline at end of file
diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectionContextTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectionContextTest.java
new file mode 100644
index 00000000000..1062082a3b4
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectionContextTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.aviator.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan;
+import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.ParsedTarget;
+import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor;
+
+class AviatorConnectionContextTest {
+ private static final String PROXY_PASSWORD = "sentinel-proxy-password";
+
+ @Test
+ void createsDirectContextFromPlan() {
+ var context = AviatorConnectionContext.of(plan(Optional.empty()));
+
+ assertFalse(context.isProxied());
+ assertNull(context.proxy());
+ assertEquals("aviator.example.com:443", context.target().display());
+ }
+
+ @Test
+ void createsProxiedContextWithoutCredentials() {
+ var proxy = ProxyDescriptor.builder()
+ .proxyHost("localhost").proxyPort(33444)
+ .proxyUser("sentinel-user").proxyPassword(PROXY_PASSWORD.toCharArray())
+ .build();
+
+ var context = AviatorConnectionContext.of(plan(Optional.of(proxy)));
+
+ assertTrue(context.isProxied());
+ assertEquals("localhost:33444", context.proxy().display());
+ assertFalse(context.toString().contains(PROXY_PASSWORD));
+ assertFalse(context.toString().contains("sentinel-user"));
+ }
+
+ @Test
+ void requiresTarget() {
+ assertThrows(NullPointerException.class, () -> new AviatorConnectionContext(null, null));
+ }
+
+ @Test
+ void replacesControlCharactersInHostNames() {
+ assertEquals("evil?host:1", new AviatorEndpoint("evil\u001bhost", 1).display());
+ assertEquals("unknown:1", new AviatorEndpoint(null, 1).display());
+ }
+
+ @Test
+ void displaysIpv6Hosts() {
+ assertEquals("[::1]:443", new AviatorEndpoint("::1", 443).display());
+ assertEquals("[::1]:443", new AviatorEndpoint("[::1]", 443).display());
+ }
+
+ private static AviatorConnectionPlan plan(Optional proxy) {
+ return new AviatorConnectionPlan("aviator.example.com", "https://aviator.example.com",
+ new ParsedTarget("aviator.example.com", null), 443, proxy);
+ }
+}
diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKindTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKindTest.java
new file mode 100644
index 00000000000..d7cef4fd268
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKindTest.java
@@ -0,0 +1,191 @@
+/*
+ * 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.aviator.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.ConnectException;
+import java.net.UnknownHostException;
+import java.nio.channels.UnresolvedAddressException;
+import java.security.cert.CertPathValidatorException;
+import java.security.cert.CertificateException;
+import java.util.Optional;
+
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLHandshakeException;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import io.grpc.netty.shaded.io.netty.channel.ConnectTimeoutException;
+import io.grpc.netty.shaded.io.netty.handler.codec.http.DefaultHttpHeaders;
+import io.grpc.netty.shaded.io.netty.handler.proxy.HttpProxyHandler.HttpProxyConnectException;
+import io.grpc.netty.shaded.io.netty.handler.proxy.ProxyConnectException;
+
+class AviatorConnectivityFailureKindTest {
+ private static final AviatorConnectionContext DIRECT =
+ new AviatorConnectionContext(new AviatorEndpoint("aviator.example.com", 443), null);
+ private static final AviatorConnectionContext PROXIED =
+ new AviatorConnectionContext(new AviatorEndpoint("aviator.example.com", 443), new AviatorEndpoint("localhost", 33444));
+
+ @Test
+ void classifiesProxyRejectionBeforeOtherConnectExceptions() {
+ var cause = new HttpProxyConnectException("status: 407", new DefaultHttpHeaders());
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.PROXY_REJECTED), classify(Status.UNAVAILABLE, cause, PROXIED));
+ }
+
+ @Test
+ void classifiesOtherProxyFailuresAsNegotiationFailure() {
+ var cause = new ProxyConnectException("timeout");
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.PROXY_NEGOTIATION_FAILED), classify(Status.UNAVAILABLE, cause, PROXIED));
+ }
+
+ @Test
+ void classifiesConnectTimeoutBeforeRefusal() {
+ var cause = new ConnectTimeoutException("connection timed out");
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.CONNECT_TIMEOUT), classify(Status.UNAVAILABLE, cause, DIRECT));
+ }
+
+ @Test
+ void classifiesConnectExceptionAsProxyRefusalWhenProxied() {
+ var cause = new ConnectException("Connection refused");
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.PROXY_REFUSED), classify(Status.UNAVAILABLE, cause, PROXIED));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"Connection refused", "Connection refused: getsockopt", "Connection refused: no further information"})
+ void classifiesConnectExceptionAsDirectRefusalWhenNotProxied(String reason) {
+ var cause = new ConnectException(reason);
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.DIRECT_REFUSED), classify(Status.UNAVAILABLE, cause, DIRECT));
+ }
+
+ @Test
+ void classifiesNameResolutionFailures() {
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.DNS_FAILURE),
+ classify(Status.UNAVAILABLE, new UnknownHostException("host"), DIRECT));
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.DNS_FAILURE),
+ classify(Status.UNAVAILABLE, new UnresolvedAddressException(), PROXIED));
+ }
+
+ @Test
+ void usesNeutralKindsWithoutTransportEvidence() {
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, null, DIRECT));
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.DEADLINE_EXCEEDED), classify(Status.DEADLINE_EXCEEDED, null, DIRECT));
+ }
+
+ @Test
+ void leavesCertificateFailuresToPkixGuidance() {
+ var pkix = new SSLHandshakeException("PKIX path building failed");
+ pkix.initCause(new CertificateException("untrusted"));
+ assertEquals(Optional.empty(), classify(Status.UNAVAILABLE, pkix, DIRECT));
+ assertEquals(Optional.empty(), classify(Status.UNAVAILABLE, new CertPathValidatorException("untrusted"), DIRECT));
+ }
+
+ @Test
+ void classifiesOtherTlsFailures() {
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.TLS_FAILURE),
+ classify(Status.UNAVAILABLE, new SSLException("Unsupported or unrecognized SSL message"), PROXIED));
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.TLS_FAILURE),
+ classify(Status.UNAVAILABLE, new SSLHandshakeException("handshake timed out"), DIRECT));
+ }
+
+ @Test
+ void usesNeutralKindForProxyFailuresWithoutFcliProxy() {
+ var cause = new HttpProxyConnectException("status: 407", new DefaultHttpHeaders());
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, cause, DIRECT));
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.DEADLINE_EXCEEDED),
+ classify(Status.DEADLINE_EXCEEDED, new ProxyConnectException("timeout"), DIRECT));
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = Status.Code.class, names = {"UNAVAILABLE", "DEADLINE_EXCEEDED"}, mode = EnumSource.Mode.EXCLUDE)
+ void ignoresOtherStatusCodes(Status.Code code) {
+ assertEquals(Optional.empty(), classify(Status.fromCode(code), new ConnectException("Connection refused"), DIRECT));
+ }
+
+ @Test
+ void terminatesOnCyclicCauseChain() {
+ var first = new RuntimeException("first");
+ var second = new RuntimeException("second", first);
+ first.initCause(second);
+
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, first, DIRECT));
+ }
+
+ @Test
+ void ignoresNestedCertificateFailures() {
+ var cause = new RuntimeException(new CertificateException("untrusted"));
+ assertEquals(Optional.empty(), classify(Status.UNAVAILABLE, cause, DIRECT));
+ }
+
+ @Test
+ void doesNotInferRefusalFromAnAmbiguousConnectException() {
+ var cause = new ConnectException("Connection refused: openssl-handshake_failure.example/10.0.0.1:443");
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, cause, DIRECT));
+ }
+
+ @Test
+ void doesNotMisreportNetworkOrUnknownConnectFailuresAsRefusal() {
+ for ( var reason : new String[] {"Network is unreachable", "Connection timed out", "Permission denied", null} ) {
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE),
+ classify(Status.UNAVAILABLE, new ConnectException(reason), DIRECT));
+ }
+ }
+
+ @Test
+ void recognizesRefusalInsideNettyAnnotatedCause() {
+ var cause = new ConnectException("Connection refused: openssl-handshake_failure.example/10.0.0.1:443");
+ cause.initCause(new ConnectException("Connection refused"));
+ assertEquals(Optional.of(AviatorConnectivityFailureKind.DIRECT_REFUSED), classify(Status.UNAVAILABLE, cause, DIRECT));
+ }
+
+ @ParameterizedTest
+ @EnumSource(AviatorConnectivityFailureKind.class)
+ void everyFailureHasASingleLineActionableMessage(AviatorConnectivityFailureKind kind) {
+ for ( var context : new AviatorConnectionContext[] {DIRECT, PROXIED} ) {
+ if ( !context.isProxied() && kind.name().startsWith("PROXY_") ) { continue; }
+ var message = kind.formatMessage(context);
+ assertEquals(1, message.lines().count(), message);
+ assertTrue(message.contains("; check ") || message.contains("; try "), message);
+ assertFalse(message.endsWith("."), message);
+ assertFalse(message.contains("null"), message);
+ }
+ }
+
+ @Test
+ void messagesNameTheFailingEndpoint() {
+ assertEquals("Proxy localhost:33444 refused the connection; check the proxy host and port",
+ AviatorConnectivityFailureKind.PROXY_REFUSED.formatMessage(PROXIED));
+ assertEquals("Aviator server aviator.example.com:443 refused the connection; check the Aviator URL",
+ AviatorConnectivityFailureKind.DIRECT_REFUSED.formatMessage(DIRECT));
+ assertEquals("Timed out connecting to proxy localhost:33444; check network access and firewall rules",
+ AviatorConnectivityFailureKind.CONNECT_TIMEOUT.formatMessage(PROXIED));
+ assertEquals("The Aviator request timed out; try again later"
+ + " (the server may be busy, or network/proxy delays may be involved)",
+ AviatorConnectivityFailureKind.DEADLINE_EXCEEDED.formatMessage(DIRECT));
+ assertEquals("Aviator server aviator.example.com:443 is unavailable or could not be reached; "
+ + "try again later, or check the Aviator URL, network access, and proxy settings",
+ AviatorConnectivityFailureKind.UNAVAILABLE.formatMessage(DIRECT));
+ }
+
+ private static Optional classify(Status status, Throwable cause, AviatorConnectionContext context) {
+ var exception = new StatusRuntimeException(cause==null ? status : status.withCause(cause));
+ return AviatorConnectivityFailureKind.classify(exception, context);
+ }
+}
diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientConnectivityTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientConnectivityTest.java
new file mode 100644
index 00000000000..47b5599425b
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientConnectivityTest.java
@@ -0,0 +1,420 @@
+/*
+ * 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.aviator.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import com.fortify.aviator.application.ApplicationByTenantName;
+import com.fortify.aviator.application.ApplicationList;
+import com.fortify.aviator.application.ApplicationServiceGrpc;
+import com.fortify.aviator.dastentitlement.DastEntitlementServiceGrpc;
+import com.fortify.aviator.dastentitlement.ListDastEntitlementsByTenantRequest;
+import com.fortify.aviator.dastentitlement.ListDastEntitlementsByTenantResponse;
+import com.fortify.aviator.entitlement.EntitlementServiceGrpc;
+import com.fortify.aviator.entitlement.ListEntitlementsByTenantRequest;
+import com.fortify.aviator.entitlement.ListEntitlementsByTenantResponse;
+import com.fortify.cli.aviator._common.exception.AviatorConnectivityException;
+import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan;
+import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.ParsedTarget;
+import com.fortify.cli.common.cli.util.FcliExecutionContextHolder;
+import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor;
+import com.fortify.grpc.token.ListTokensRequest;
+import com.fortify.grpc.token.ListTokensResponse;
+import com.fortify.grpc.token.TokenServiceGrpc;
+
+import io.grpc.CallOptions;
+import io.grpc.Channel;
+import io.grpc.ClientCall;
+import io.grpc.ClientInterceptor;
+import io.grpc.EquivalentAddressGroup;
+import io.grpc.ManagedChannel;
+import io.grpc.MethodDescriptor;
+import io.grpc.NameResolver;
+import io.grpc.Server;
+import io.grpc.Status;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
+import io.grpc.stub.AbstractBlockingStub;
+import io.grpc.stub.StreamObserver;
+
+/**
+ * Checks connectivity behavior against real Netty transports on the loopback interface.
+ */
+class AviatorGrpcClientConnectivityTest {
+ private static final Duration FAIL_FAST_LIMIT = Duration.ofSeconds(5);
+ private static final String PROXY_SECRET = "sentinel-proxy-secret";
+ private static final String TARGET_HOST = "aviator.example.invalid";
+
+ private ServerSocket proxyServer;
+ private Server grpcServer;
+ private ManagedChannel testChannel;
+
+ @BeforeEach
+ void setUp() {
+ FcliExecutionContextHolder.pushNew();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ try {
+ if ( proxyServer!=null ) { proxyServer.close(); }
+ if ( testChannel!=null ) { testChannel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); }
+ if ( grpcServer!=null ) { grpcServer.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); }
+ } finally {
+ FcliExecutionContextHolder.pop();
+ }
+ }
+
+ @Test
+ void directRefusalFailsFastWithDirectRefusedMessage() throws Exception {
+ assumeTrue(System.getenv("GRPC_PROXY_EXP")==null && System.getProperty("https.proxyHost")==null,
+ "This test requires a direct loopback route without a JVM or gRPC proxy");
+ int port = unusedPort();
+ var plan = new AviatorConnectionPlan("127.0.0.1:" + port, "https://127.0.0.1:" + port,
+ new ParsedTarget("127.0.0.1", port), port, Optional.empty());
+
+ var exception = callAndExpectConnectivityFailure(plan);
+
+ assertEquals("Aviator server 127.0.0.1:" + port + " refused the connection; check the Aviator URL",
+ exception.getMessage());
+ }
+
+ @ParameterizedTest
+ @EnumSource(AviatorRequestPolicy.class)
+ void publicClientFactoriesUseExplicitPolicy(AviatorRequestPolicy policy) throws Exception {
+ for ( boolean withLoggerAndPing : new boolean[] {false, true} ) {
+ try ( var client = withLoggerAndPing
+ ? AviatorGrpcClientHelper.createClient("127.0.0.1:1", null, 30, policy)
+ : AviatorGrpcClientHelper.createClient("127.0.0.1:1", policy) ) {
+ for ( var name : List.of("blockingStub", "tokenServiceBlockingStub",
+ "entitlementServiceBlockingStub", "dastEntitlementServiceBlockingStub") ) {
+ var field = AviatorGrpcClient.class.getDeclaredField(name);
+ field.setAccessible(true);
+ var stub = assertInstanceOf(AbstractBlockingStub.class, field.get(client));
+ assertEquals(policy.isWaitForReady(), stub.getCallOptions().isWaitForReady(), name);
+ assertEquals(policy == AviatorRequestPolicy.FAIL_FAST,
+ stub.getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION) != null, name);
+ }
+ }
+ }
+ }
+
+ @Test
+ void proxyRefusalFailsFastWithProxyRefusedMessage() throws Exception {
+ int proxyPort = unusedPort();
+
+ var exception = callAndExpectConnectivityFailure(proxiedPlan(proxyPort));
+
+ assertEquals("Proxy 127.0.0.1:" + proxyPort + " refused the connection; check the proxy host and port",
+ exception.getMessage());
+ }
+
+ @Test
+ void proxyAuthenticationRejectionIsReportedAsProxyRejection() throws Exception {
+ proxyServer = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
+ startRejectingProxy(proxyServer);
+
+ var exception = callAndExpectConnectivityFailure(proxiedPlan(proxyServer.getLocalPort()));
+
+ assertTrue(exception.getMessage().startsWith("Proxy 127.0.0.1:" + proxyServer.getLocalPort() + " rejected"),
+ exception.getMessage());
+ assertFalse(exception.getMessage().contains("sentinel-realm"));
+ }
+
+ @Test
+ @Timeout(20)
+ void stalledProxyNegotiationEndsBeforeRequestDeadline() throws Exception {
+ proxyServer = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
+ // Leave the TCP connection in the listen backlog without answering CONNECT. Netty's real
+ // proxy handshake timer must end the call, rather than the RPC deadline.
+ var started = System.nanoTime();
+ try ( var client = AviatorGrpcClientHelper.createClient(proxiedPlan(proxyServer.getLocalPort()),
+ null, 30, AviatorRequestPolicy.FAIL_FAST) ) {
+ var exception = assertThrows(AviatorConnectivityException.class,
+ () -> client.listApplication("tenant", "signature", "message"));
+ assertTrue(exception.getMessage().startsWith("Could not connect to Aviator server " + TARGET_HOST + ":443 through proxy"),
+ exception.getMessage());
+ }
+ assertTrue(Duration.ofNanos(System.nanoTime() - started).compareTo(Duration.ofSeconds(15)) < 0);
+ }
+
+ @ParameterizedTest
+ @EnumSource(AviatorRequestPolicy.class)
+ void healthyUnaryServicesUseSelectedPolicyAndStreamsKeepWaiting(AviatorRequestPolicy policy) throws Exception {
+ var captured = new AtomicReference();
+ try ( var client = createPlaintextClient(policy, captured) ) {
+ assertTrue(client.listApplication("tenant", "signature", "message").isEmpty());
+ assertCallPolicy(policy, captured.get());
+ assertEquals(ListTokensResponse.getDefaultInstance(), client.listTokens("email", "tenant", "signature", "message"));
+ assertCallPolicy(policy, captured.get());
+ assertTrue(client.listEntitlements("tenant", "signature", "message").isEmpty());
+ assertCallPolicy(policy, captured.get());
+ assertTrue(client.listDastEntitlements("tenant", "signature", "message").isEmpty());
+ assertCallPolicy(policy, captured.get());
+ assertTrue(client.getCorrelationAsyncStub().getCallOptions().isWaitForReady());
+ assertTrue(client.getDastAuditAsyncStub().getCallOptions().isWaitForReady());
+ assertNull(client.getCorrelationAsyncStub().getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION));
+ assertNull(client.getDastAuditAsyncStub().getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION));
+ }
+ }
+
+ @Test
+ @Timeout(15)
+ @SuppressWarnings("deprecation") // Per-channel resolver keeps the fixture out of the global registry.
+ void refusedFirstAddressFallsBackToHealthyAddress() throws Exception {
+ grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0))
+ .addService(new EmptyApplicationService()).build().start();
+ var refused = new InetSocketAddress("127.0.0.1", unusedPort());
+ var healthy = new InetSocketAddress("127.0.0.1", grpcServer.getPort());
+ testChannel = NettyChannelBuilder.forTarget("fixture:///localhost").usePlaintext()
+ .proxyDetector(address -> null)
+ .nameResolverFactory(new NameResolver.Factory() {
+ @Override
+ public String getDefaultScheme() { return "fixture"; }
+
+ @Override
+ public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) {
+ return new NameResolver() {
+ @Override
+ public String getServiceAuthority() { return "localhost"; }
+
+ @Override
+ public void start(Listener2 listener) {
+ listener.onResult(ResolutionResult.newBuilder().setAddresses(List.of(
+ new EquivalentAddressGroup(refused), new EquivalentAddressGroup(healthy))).build());
+ }
+
+ @Override
+ public void shutdown() {}
+ };
+ }
+ }).build();
+ var context = new AviatorConnectionContext(new AviatorEndpoint("localhost", grpcServer.getPort()), null);
+ try ( var client = new AviatorGrpcClient(testChannel, 30, null, 30, AviatorRequestPolicy.FAIL_FAST, context) ) {
+ assertTrue(client.listApplication("tenant", "signature", "message").isEmpty());
+ }
+ }
+
+ private static void assertCallPolicy(AviatorRequestPolicy policy, CallOptions options) {
+ assertEquals(policy.isWaitForReady(), options.isWaitForReady());
+ assertEquals(!policy.isWaitForReady(), options.getOption(AviatorConnectionContext.CALL_OPTION) != null);
+ assertDeadlineAtMostDefault(options);
+ }
+
+ @Test
+ void serverSentUnavailableUsesNeutralMessageWithoutServerDetails() throws Exception {
+ grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0))
+ .addService(new ApplicationServiceGrpc.ApplicationServiceImplBase() {
+ @Override
+ public void listApplications(ApplicationByTenantName request, StreamObserver observer) {
+ observer.onError(Status.UNAVAILABLE.withDescription("sentinel-maintenance-details").asRuntimeException());
+ }
+ }).build().start();
+ testChannel = NettyChannelBuilder.forAddress("127.0.0.1", grpcServer.getPort())
+ .proxyDetector(address -> null).usePlaintext().build();
+ var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", grpcServer.getPort()), null);
+ try ( var client = new AviatorGrpcClient(testChannel, 30, null, 30, AviatorRequestPolicy.FAIL_FAST, context) ) {
+ var e = assertThrows(AviatorConnectivityException.class,
+ () -> client.listApplication("tenant", "signature", "message"));
+
+ assertTrue(e.getMessage().contains("is unavailable or could not be reached"));
+ assertFalse(e.getMessage().contains("refused"));
+ assertFalse(e.getMessage().contains("sentinel-maintenance-details"));
+ assertNull(e.getCause());
+ }
+ }
+
+ @Test
+ @Timeout(15)
+ void serverSentDeadlineDoesNotClaimLocalDeadlineExpired() throws Exception {
+ grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0))
+ .addService(new ApplicationServiceGrpc.ApplicationServiceImplBase() {
+ @Override
+ public void listApplications(ApplicationByTenantName request, StreamObserver observer) {
+ observer.onError(Status.DEADLINE_EXCEEDED.withDescription("sentinel-server-deadline-details").asRuntimeException());
+ }
+ }).build().start();
+ testChannel = NettyChannelBuilder.forAddress("127.0.0.1", grpcServer.getPort())
+ .proxyDetector(address -> null).usePlaintext().build();
+ var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", grpcServer.getPort()), null);
+ try ( var client = new AviatorGrpcClient(testChannel, 30, null, 30, AviatorRequestPolicy.FAIL_FAST, context) ) {
+ var e = assertThrows(AviatorConnectivityException.class,
+ () -> client.listApplication("tenant", "signature", "message"));
+
+ assertTrue(e.getMessage().contains("request timed out"));
+ assertFalse(e.getMessage().contains("30 seconds"));
+ assertFalse(e.getMessage().contains("did not respond"));
+ assertFalse(e.getMessage().contains("Timed out connecting"));
+ assertFalse(e.getMessage().contains("refused"));
+ assertFalse(e.getMessage().contains("sentinel-server-deadline-details"));
+ assertNull(e.getCause());
+ }
+ }
+
+ private static AviatorConnectivityException callAndExpectConnectivityFailure(AviatorConnectionPlan plan) {
+ try ( var client = AviatorGrpcClientHelper.createClient(plan, null, 30, AviatorRequestPolicy.FAIL_FAST) ) {
+ long start = System.nanoTime();
+ var exception = assertThrows(AviatorConnectivityException.class,
+ () -> client.listApplication("tenant", "signature", "message"));
+ var elapsed = Duration.ofNanos(System.nanoTime() - start);
+ assertTrue(elapsed.compareTo(FAIL_FAST_LIMIT) < 0, "Call took " + elapsed);
+ assertNull(exception.getCause());
+ assertFalse(exception.getMessage().contains(PROXY_SECRET));
+ return exception;
+ }
+ }
+
+ private static AviatorConnectionPlan proxiedPlan(int proxyPort) {
+ var proxy = ProxyDescriptor.builder()
+ .proxyHost("127.0.0.1").proxyPort(proxyPort)
+ .proxyUser("sentinel-user").proxyPassword(PROXY_SECRET.toCharArray())
+ .build();
+ return new AviatorConnectionPlan(TARGET_HOST, "https://" + TARGET_HOST,
+ new ParsedTarget(TARGET_HOST, null), 443, Optional.of(proxy));
+ }
+
+ private AviatorGrpcClient createPlaintextClient(AviatorRequestPolicy policy, AtomicReference captured) throws IOException {
+ grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0))
+ .addService(new EmptyApplicationService())
+ .addService(new EmptyTokenService())
+ .addService(new EmptyEntitlementService())
+ .addService(new EmptyDastEntitlementService())
+ .build()
+ .start();
+ testChannel = NettyChannelBuilder.forAddress(InetAddress.getLoopbackAddress().getHostAddress(), grpcServer.getPort())
+ .proxyDetector(address -> null)
+ .usePlaintext()
+ .intercept(new CallOptionsCapture(captured))
+ .build();
+ var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", grpcServer.getPort()), null);
+ return new AviatorGrpcClient(testChannel, 30, null, 30, policy, context);
+ }
+
+ private static void assertDeadlineAtMostDefault(CallOptions callOptions) {
+ assertNotNull(callOptions.getDeadline());
+ long remainingSeconds = callOptions.getDeadline().timeRemaining(TimeUnit.SECONDS);
+ assertTrue(remainingSeconds <= 30 && remainingSeconds > 20, "Deadline remaining: " + remainingSeconds);
+ }
+
+ private static int unusedPort() throws IOException {
+ try ( var socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()) ) {
+ return socket.getLocalPort();
+ }
+ }
+
+ private static void startRejectingProxy(ServerSocket server) {
+ var thread = new Thread(() -> {
+ while ( !server.isClosed() ) {
+ try ( Socket socket = server.accept() ) {
+ readRequestHeaders(socket.getInputStream());
+ OutputStream out = socket.getOutputStream();
+ out.write(("HTTP/1.1 407 Proxy Authentication Required\r\n"
+ + "Proxy-Authenticate: Basic realm=\"sentinel-realm\"\r\n"
+ + "Content-Length: 0\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ } catch ( IOException e ) {
+ // Server socket closed at test end
+ }
+ }
+ }, "test-rejecting-proxy");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ private static void readRequestHeaders(InputStream in) throws IOException {
+ int matched = 0;
+ byte[] terminator = {'\r', '\n', '\r', '\n'};
+ int b;
+ while ( matched responseObserver) {
+ responseObserver.onNext(ApplicationList.getDefaultInstance());
+ responseObserver.onCompleted();
+ }
+ }
+
+ private static final class EmptyTokenService extends TokenServiceGrpc.TokenServiceImplBase {
+ @Override
+ public void listTokens(ListTokensRequest request, StreamObserver observer) {
+ observer.onNext(ListTokensResponse.getDefaultInstance());
+ observer.onCompleted();
+ }
+ }
+
+ private static final class EmptyEntitlementService extends EntitlementServiceGrpc.EntitlementServiceImplBase {
+ @Override
+ public void listEntitlementsByTenant(ListEntitlementsByTenantRequest request,
+ StreamObserver observer) {
+ observer.onNext(ListEntitlementsByTenantResponse.getDefaultInstance());
+ observer.onCompleted();
+ }
+ }
+
+ private static final class EmptyDastEntitlementService extends DastEntitlementServiceGrpc.DastEntitlementServiceImplBase {
+ @Override
+ public void listDastEntitlementsByTenant(ListDastEntitlementsByTenantRequest request,
+ StreamObserver observer) {
+ observer.onNext(ListDastEntitlementsByTenantResponse.getDefaultInstance());
+ observer.onCompleted();
+ }
+ }
+
+ private static final class CallOptionsCapture implements ClientInterceptor {
+ private final AtomicReference captured;
+
+ private CallOptionsCapture(AtomicReference captured) {
+ this.captured = captured;
+ }
+
+ @Override
+ public ClientCall interceptCall(MethodDescriptor method,
+ CallOptions callOptions, Channel next) {
+ captured.set(callOptions);
+ return next.newCall(method, callOptions);
+ }
+ }
+}
diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java
index 91ceb215f87..5407d8e9fdc 100644
--- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java
+++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java
@@ -13,6 +13,7 @@
package com.fortify.cli.aviator.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -20,17 +21,68 @@
import java.net.InetSocketAddress;
import java.net.SocketAddress;
+import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
+import org.slf4j.LoggerFactory;
+import com.fortify.cli.aviator._common.exception.AviatorSimpleException;
import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException;
import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
import io.grpc.HttpConnectProxiedSocketAddress;
class AviatorGrpcClientHelperTest {
+ private static final List URL_MARKERS =
+ List.of("sentinel-userinfo", "sentinel-path", "sentinel-query", "sentinel-fragment");
+
+ @Test
+ void shouldOmitIgnoredUrlDetailsFromWarnings() {
+ var logger = (Logger)LoggerFactory.getLogger(AviatorGrpcClientHelper.class);
+ var previousLevel = logger.getLevel();
+ var appender = new ListAppender();
+ appender.start();
+ logger.addAppender(appender);
+ logger.setLevel(Level.WARN);
+ try {
+ var target = AviatorGrpcClientHelper.parseTarget(
+ "https://user:sentinel-userinfo@aviator.invalid:8443/sentinel-path?token=sentinel-query#sentinel-fragment");
+
+ assertEquals("aviator.invalid", target.host());
+ assertEquals(8443, target.port());
+ assertEquals(1, appender.list.size());
+ var event = appender.list.get(0);
+ assertEquals(Level.WARN, event.getLevel());
+ assertTrue(event.getFormattedMessage().contains("Ignoring path"));
+ URL_MARKERS.forEach(marker -> assertFalse(event.getFormattedMessage().contains(marker), marker));
+ assertNull(event.getThrowableProxy());
+ } finally {
+ logger.detachAppender(appender);
+ logger.setLevel(previousLevel);
+ appender.stop();
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "https://user:sentinel-userinfo@aviator.invalid:99999/sentinel-path?token=sentinel-query#sentinel-fragment",
+ "https://user:sentinel-userinfo@aviator.invalid/sentinel-path/%zz?token=sentinel-query#sentinel-fragment",
+ "http://user:sentinel-userinfo@aviator.invalid/sentinel-path?token=sentinel-query#sentinel-fragment"
+ })
+ void shouldOmitUrlDetailsFromInvalidTargetMessages(String url) {
+ var exception = assertThrows(AviatorSimpleException.class, () -> AviatorGrpcClientHelper.parseTarget(url));
+
+ URL_MARKERS.forEach(marker -> assertFalse(exception.getMessage().contains(marker), marker));
+ assertNull(exception.getCause());
+ }
+
@Test
void shouldParseSchemeLessHostAndPort() {
var parsed = AviatorGrpcClientHelper.parseTarget("aviator.invalid:443");
@@ -118,4 +170,35 @@ void shouldIgnoreUnsupportedSocketAddressTypes() {
assertNull(proxied);
}
+
+ @ParameterizedTest
+ @CsvSource({
+ "https://user:sentinel-secret@aviator.invalid:443/path, https://***@aviator.invalid:443",
+ "user:sentinel-secret@aviator.invalid:443, ***@aviator.invalid:443",
+ "mttp://sentinel-secret@aviator.invalid, mttp://***@aviator.invalid",
+ "https://aviator.invalid:443/a@b, https://aviator.invalid:443",
+ "https://aviator.invalid?token=sentinel-query#sentinel-fragment, https://aviator.invalid",
+ "aviator.invalid:443, aviator.invalid:443"
+ })
+ void shouldRemoveUserInfoFromDisplayedUrl(String url, String expected) {
+ assertEquals(expected, AviatorGrpcClientHelper.toSafeUrlForDisplay(url));
+ }
+
+ @Test
+ void shouldNotExposeUserInfoInInvalidPortMessage() {
+ var ex = assertThrows(AviatorSimpleException.class,
+ () -> AviatorGrpcClientHelper.parseTarget("https://user:sentinel-secret@aviator.invalid:99999"));
+
+ assertFalse(ex.getMessage().contains("sentinel-secret"));
+ assertTrue(ex.getMessage().contains("***@aviator.invalid:99999"));
+ }
+
+ @Test
+ void shouldNotExposeUserInfoInUnsupportedSchemeException() {
+ var ex = assertThrows(UnsupportedAviatorUrlSchemeException.class,
+ () -> AviatorGrpcClientHelper.parseTarget("http://user:sentinel-secret@aviator.invalid"));
+
+ assertFalse(ex.getMessage().contains("sentinel-secret"));
+ assertFalse(ex.getProvidedUrl().contains("sentinel-secret"));
+ }
}
diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java
index 09285bae9e9..430c5a6a7e7 100644
--- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java
+++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java
@@ -13,20 +13,74 @@
package com.fortify.cli.aviator.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.net.ConnectException;
+import java.security.cert.CertificateException;
import java.util.List;
import java.util.Set;
+import javax.net.ssl.SSLHandshakeException;
+
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.slf4j.LoggerFactory;
+import com.fortify.aviator.application.ApplicationServiceGrpc;
import com.fortify.aviator.grpc.AuditorResponse;
+import com.fortify.cli.aviator._common.exception.AviatorConnectivityException;
+import com.fortify.cli.aviator._common.exception.AviatorSimpleException;
+import com.fortify.cli.aviator._common.exception.AviatorTechnicalException;
import com.fortify.cli.aviator.audit.model.AuditResponse;
import com.fortify.cli.aviator.audit.model.AuditResponse.AuditSkipReason;
import com.fortify.cli.aviator.audit.model.File;
import com.fortify.cli.aviator.audit.model.UserPrompt;
+import com.fortify.cli.common.exception.FcliExceptionHelper;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
class GrpcUtilTest {
+ @ParameterizedTest
+ @EnumSource(value = Status.Code.class, names = {"UNAVAILABLE", "DEADLINE_EXCEEDED"})
+ void causeFreeStatusesKeepSafeOutputAndLogs(Status.Code code) {
+ var secret = "sentinel-server-description";
+ var logger = (Logger)LoggerFactory.getLogger(GrpcUtil.class);
+ var previousLevel = logger.getLevel();
+ var appender = new ListAppender();
+ appender.start();
+ logger.addAppender(appender);
+ logger.setLevel(Level.DEBUG);
+ try {
+ var e = assertThrows(AviatorConnectivityException.class,
+ () -> execute(Status.fromCode(code).withDescription(secret), AviatorRequestPolicy.FAIL_FAST));
+
+ assertNull(e.getCause());
+ assertFalse(FcliExceptionHelper.formatException(e).contains(secret));
+ assertTrue(e.getMessage().contains(code==Status.Code.UNAVAILABLE
+ ? "is unavailable or could not be reached" : "server may be busy"));
+ assertFalse(e.getMessage().contains("refused"));
+ assertEquals(1, appender.list.size());
+ assertFalse(appender.list.get(0).getFormattedMessage().contains(secret));
+ assertNull(appender.list.get(0).getThrowableProxy());
+ } finally {
+ logger.detachAppender(appender);
+ logger.setLevel(previousLevel);
+ appender.stop();
+ }
+ }
+
@Test
void shouldPreserveResolvedProgrammingLanguages() {
File file = new File();
@@ -58,4 +112,71 @@ void shouldClassifySkippedServerResponseWithoutInspectingMessage() {
assertEquals(AuditSkipReason.SKIPPED_BY_AVIATOR, auditResponse.getAuditSkipReason());
}
+
+ @Test
+ void waitForReadyPolicyKeepsCurrentUnavailableMapping() {
+ var e = assertThrows(AviatorTechnicalException.class,
+ () -> execute(Status.UNAVAILABLE.withCause(new ConnectException("Connection refused")), AviatorRequestPolicy.WAIT_FOR_READY));
+
+ assertEquals("The Aviator test operation failed with status UNAVAILABLE; check the Aviator URL and network access, "
+ + "run the command again with --log-level DEBUG to log details, and contact support if the problem continues",
+ e.getMessage());
+ }
+
+ @Test
+ void failFastPolicyClassifiesUnavailable() {
+ var e = assertThrows(AviatorConnectivityException.class,
+ () -> execute(Status.UNAVAILABLE.withCause(new ConnectException("Connection refused")), AviatorRequestPolicy.FAIL_FAST));
+
+ assertEquals("Aviator server 127.0.0.1:1 refused the connection; check the Aviator URL", e.getMessage());
+ assertNull(e.getCause());
+ }
+
+ @Test
+ void failFastPolicyKeepsCertificateCauseForPkixGuidance() {
+ var e = assertThrows(AviatorTechnicalException.class,
+ () -> execute(Status.UNAVAILABLE.withCause(new SSLHandshakeException("PKIX path building failed")
+ .initCause(new CertificateException("untrusted"))), AviatorRequestPolicy.FAIL_FAST));
+
+ assertTrue(FcliExceptionHelper.formatException(e).contains("PKIX SSL certificate validation failed"));
+ }
+
+ @Test
+ void failFastPolicyKeepsUnimplementedContractForOlderServerLoginFallback() {
+ var e = assertThrows(AviatorTechnicalException.class,
+ () -> execute(Status.UNIMPLEMENTED, AviatorRequestPolicy.FAIL_FAST));
+
+ var cause = assertInstanceOf(StatusRuntimeException.class, e.getCause());
+ assertEquals(Status.Code.UNIMPLEMENTED, cause.getStatus().getCode());
+ }
+
+ @Test
+ void resourceExhaustedPutsRemediationBeforeSupport() {
+ var e = assertThrows(AviatorSimpleException.class,
+ () -> execute(Status.RESOURCE_EXHAUSTED.withDescription("quota"), AviatorRequestPolicy.FAIL_FAST));
+
+ assertEquals("Aviator server resource limits were exceeded during test (server details: quota); "
+ + "try again later, and contact support if the problem continues", e.getMessage());
+ }
+
+ @Test
+ void failFastPolicyKeepsApplicationStatusMapping() {
+ var e = assertThrows(AviatorSimpleException.class,
+ () -> execute(Status.NOT_FOUND.withDescription("app"), AviatorRequestPolicy.FAIL_FAST));
+
+ assertFalse(e instanceof AviatorConnectivityException);
+ assertTrue(e.getMessage().contains("not found"));
+ }
+
+ private static void execute(Status status, AviatorRequestPolicy policy) {
+ var channel = NettyChannelBuilder.forAddress("127.0.0.1", 1).usePlaintext().build();
+ try {
+ var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", 1), null);
+ GrpcUtil.executeGrpcCall(policy.configureNewStub(ApplicationServiceGrpc.newBlockingStub(channel), context),
+ (stub, request) -> { throw status.asRuntimeException(); },
+ "request", "test");
+ } finally {
+ channel.shutdownNow();
+ }
+ }
}
\ No newline at end of file
diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeoutTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeoutTest.java
new file mode 100644
index 00000000000..cb472d81366
--- /dev/null
+++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeoutTest.java
@@ -0,0 +1,299 @@
+/*
+ * 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.aviator.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.net.InetAddress;
+import java.net.ServerSocket;
+import java.util.ArrayDeque;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import com.fortify.aviator.dastaudit.DastAuditInitResponse;
+import com.fortify.aviator.dastaudit.DastAuditServerMessage;
+import com.fortify.aviator.dastaudit.DastAuditServiceGrpc;
+import com.fortify.aviator.grpc.AuditorServiceGrpc;
+import com.fortify.aviator.grpc.CorrelationInitResponse;
+import com.fortify.aviator.grpc.CorrelationServerMessage;
+import com.fortify.aviator.grpc.CorrelationServiceGrpc;
+import com.fortify.cli.aviator.audit.model.IssueData;
+import com.fortify.cli.aviator.audit.model.UserPrompt;
+import com.fortify.cli.aviator.config.IAviatorLogger;
+import com.fortify.cli.aviator.dast.DastIssue;
+import com.fortify.cli.aviator.dast.DastSession;
+import com.fortify.cli.aviator.fpr.Vulnerability;
+import com.fortify.cli.aviator.fpr.utils.SourceDecoders;
+
+import io.grpc.CallOptions;
+import io.grpc.Channel;
+import io.grpc.ClientCall;
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+import io.grpc.Status;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+
+class StreamInitializationTimeoutTest {
+ private static final IAviatorLogger LOGGER = new IAviatorLogger() {
+ public void progress(String format, Object... args) {}
+ public void info(String format, Object... args) {}
+ public void warn(String format, Object... args) {}
+ public void error(String format, Object... args) {}
+ };
+
+ @Test
+ void initializationWinsAgainstAnAlreadyQueuedTimeout() {
+ try (var scheduler = new ManualScheduler()) {
+ var expired = new AtomicBoolean();
+ var timeout = new StreamInitializationTimeout(scheduler, () -> expired.set(true));
+ timeout.arm();
+ assertTrue(timeout.initialized());
+ scheduler.fire();
+ assertFalse(expired.get());
+ }
+ }
+
+ @Test
+ void expiredInitializationRejectsLateResponses() {
+ try (var scheduler = new ManualScheduler()) {
+ var expired = new AtomicBoolean();
+ var timeout = new StreamInitializationTimeout(scheduler, () -> expired.set(true));
+ timeout.arm();
+ scheduler.fire();
+ assertTrue(expired.get());
+ assertTrue(timeout.isExpired());
+ assertFalse(timeout.initialized());
+ }
+ }
+
+ @Test
+ void closingCancelsInitializationTimeout() {
+ try (var scheduler = new ManualScheduler()) {
+ var expired = new AtomicBoolean();
+ var timeout = new StreamInitializationTimeout(scheduler, () -> expired.set(true));
+ timeout.arm();
+ timeout.close();
+ scheduler.fire();
+ assertFalse(expired.get());
+ assertFalse(timeout.initialized());
+ }
+ }
+
+ @Test
+ void timeoutIsNotScheduledUntilArmed() {
+ try (var scheduler = new ManualScheduler()) {
+ var timeout = new StreamInitializationTimeout(scheduler, () -> {});
+ assertFalse(scheduler.scheduled());
+ timeout.arm();
+ assertTrue(scheduler.scheduled());
+ }
+ }
+
+ @Test
+ void responseBeforeArmingDisablesTheTimeout() {
+ try (var scheduler = new ManualScheduler()) {
+ var timeout = new StreamInitializationTimeout(scheduler, () -> {});
+ assertTrue(timeout.initialized());
+ timeout.arm();
+ assertFalse(scheduler.scheduled());
+ }
+ }
+
+ @Test
+ void missingSchedulerDisablesTheTimeoutWithoutFailing() {
+ var timeout = new StreamInitializationTimeout(null, () -> {});
+ timeout.arm();
+ assertFalse(timeout.isExpired());
+ assertTrue(timeout.initialized());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void dastTimeoutOnlyAppliesBeforeInitialization(boolean initialize) {
+ try (var scheduler = new ManualScheduler()) {
+ var channel = new SilentChannel();
+ try (var processor = new DastAuditStreamProcessor(LOGGER,
+ DastAuditServiceGrpc.newStub(channel), scheduler, 0)) {
+ var issue = new DastIssue();
+ issue.setId("DAST-1");
+ var result = processor.process(DastAuditStreamConfig.builder()
+ .token("token").applicationName("app").build(),
+ List.of(new DastAuditWorkItem(new DastSession(), issue)), 1);
+ if (initialize) channel.respond(DastAuditServerMessage.newBuilder().setInit(
+ DastAuditInitResponse.newBuilder().setStatus("SUCCESS")).build());
+ scheduler.fire();
+ if (initialize) {
+ assertFalse(result.isDone());
+ assertFalse(channel.cancelled);
+ } else {
+ assertThrows(CompletionException.class, result::join);
+ assertTrue(channel.cancelled);
+ int sent = channel.messages;
+ channel.respond(DastAuditServerMessage.newBuilder().setInit(
+ DastAuditInitResponse.newBuilder().setStatus("SUCCESS")).build());
+ assertEquals(sent, channel.messages, "Late initialization must not submit findings");
+ }
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void correlationTimeoutOnlyAppliesBeforeInitialization(boolean initialize) {
+ try (var scheduler = new ManualScheduler()) {
+ var channel = new SilentChannel();
+ try (var processor = new CorrelationStreamProcessor(null, LOGGER,
+ CorrelationServiceGrpc.newStub(channel), scheduler, 0, 30)) {
+ var issue = new DastIssue();
+ issue.setId("DAST-1");
+ issue.setSessionUrl("https://example.com");
+ var bucket = new CorrelationStreamProcessor.CorrelationBucketData("SQL Injection",
+ List.of(Vulnerability.builder().instanceID("SAST-1").build()), List.of(issue));
+ var result = processor.processCorrelation(new CorrelationStreamConfig("token", "app", null, null, null), List.of(bucket), "scan", Set.of());
+ if (initialize) channel.respond(CorrelationServerMessage.newBuilder().setInit(
+ CorrelationInitResponse.newBuilder().setStatus("SUCCESS")).build());
+ scheduler.fire();
+ if (initialize) {
+ assertFalse(result.isDone());
+ assertFalse(channel.cancelled);
+ } else {
+ assertThrows(CompletionException.class, result::join);
+ assertTrue(channel.cancelled);
+ int sent = channel.messages;
+ channel.respond(CorrelationServerMessage.newBuilder().setInit(
+ CorrelationInitResponse.newBuilder().setStatus("SUCCESS")).build());
+ assertEquals(sent, channel.messages, "Late initialization must not submit findings");
+ }
+ }
+ }
+ }
+
+ @Test
+ void sastInitializationTimeoutReleasesCallingThreadAndCancelsRpc() throws Exception {
+ var workers = Executors.newFixedThreadPool(2);
+ var caller = Executors.newSingleThreadExecutor();
+ try (var scheduler = new ManualScheduler();
+ var client = AviatorGrpcClientHelper.createClient("127.0.0.1:1", AviatorRequestPolicy.WAIT_FOR_READY)) {
+ var channel = new SilentChannel();
+ try (var processor = new AviatorStreamProcessor(client, LOGGER,
+ AuditorServiceGrpc.newStub(channel), workers, scheduler, 30, 30,
+ null, SourceDecoders.defaults(), null)) {
+ var requests = new ArrayDeque();
+ requests.add(UserPrompt.builder().issueData(IssueData.builder().instanceID("SAST-1").build()).build());
+ var invocation = caller.submit(() -> processor.processBatchRequests(
+ requests, "app", "build", "ssc-app", "version", "token", List.of()));
+ assertTrue(channel.sent.await(5, TimeUnit.SECONDS));
+ assertTrue(scheduler.armed.await(5, TimeUnit.SECONDS));
+ assertFalse(invocation.isDone(), "The caller waits for initialization");
+ scheduler.fire();
+ var result = invocation.get(5, TimeUnit.SECONDS);
+ assertThrows(CompletionException.class, result::join);
+ assertTrue(channel.cancelled);
+ }
+ } finally {
+ caller.shutdownNow();
+ workers.shutdownNow();
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void sastInitializationTimeoutReleasesCallerOnRealChannel(boolean silentServer) throws Exception {
+ var workers = Executors.newFixedThreadPool(2);
+ var caller = Executors.newSingleThreadExecutor();
+ try (var scheduler = new ManualScheduler();
+ var silent = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
+ var client = AviatorGrpcClientHelper.createClient("127.0.0.1:1", AviatorRequestPolicy.WAIT_FOR_READY)) {
+ // Unreachable port: wait-for-ready keeps the call queued. Silent socket: connects, never answers.
+ int port = silentServer ? silent.getLocalPort() : 1;
+ var channel = NettyChannelBuilder.forAddress("127.0.0.1", port).usePlaintext().build();
+ try (var processor = new AviatorStreamProcessor(client, LOGGER,
+ AuditorServiceGrpc.newStub(channel).withWaitForReady(), workers, scheduler, 30, 30,
+ null, SourceDecoders.defaults(), null)) {
+ var requests = new ArrayDeque();
+ requests.add(UserPrompt.builder().issueData(IssueData.builder().instanceID("SAST-1").build()).build());
+ var invocation = caller.submit(() -> processor.processBatchRequests(
+ requests, "app", "build", "ssc-app", "version", "token", List.of()));
+ assertTrue(scheduler.armed.await(5, TimeUnit.SECONDS));
+ assertFalse(invocation.isDone(), "The caller waits for initialization");
+ scheduler.fire();
+ var result = invocation.get(10, TimeUnit.SECONDS);
+ var failure = assertThrows(CompletionException.class, result::join);
+ assertTrue(failure.getCause().getMessage().contains("initialization timed out"));
+ } finally {
+ channel.shutdownNow();
+ }
+ } finally {
+ caller.shutdownNow();
+ workers.shutdownNow();
+ }
+ }
+
+ private static final class ManualScheduler extends ScheduledThreadPoolExecutor implements AutoCloseable {
+ private volatile Runnable task;
+ private final CountDownLatch armed = new CountDownLatch(1);
+ ManualScheduler() { super(1); }
+ @Override
+ public ScheduledFuture> schedule(Runnable command, long delay, TimeUnit unit) {
+ assertEquals(30, unit.toSeconds(delay));
+ task = command;
+ armed.countDown();
+ return super.schedule(command, 1, TimeUnit.DAYS);
+ }
+ void fire() { task.run(); }
+ boolean scheduled() { return task != null; }
+ @Override
+ public void close() { shutdownNow(); }
+ }
+
+ private static final class SilentChannel extends Channel {
+ private ClientCall.Listener listener;
+ private volatile boolean cancelled;
+ private final CountDownLatch sent = new CountDownLatch(1);
+ private int messages;
+ @Override
+ public String authority() { return "test"; }
+ @Override
+ @SuppressWarnings("unchecked")
+ public ClientCall newCall(
+ MethodDescriptor method, CallOptions options) {
+ return new ClientCall<>() {
+ public void start(Listener responseListener, Metadata headers) {
+ listener = (Listener) responseListener;
+ }
+ public void request(int count) {}
+ public void cancel(String message, Throwable cause) {
+ cancelled = true;
+ listener.onClose(Status.CANCELLED.withCause(cause), new Metadata());
+ }
+ public void halfClose() {}
+ public void sendMessage(ReqT message) { messages++; sent.countDown(); }
+ };
+ }
+ void respond(Object response) { listener.onMessage(response); }
+ }
+}
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java
index d4ed177ca91..5e1b0d6ee82 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java
@@ -19,6 +19,7 @@
import com.fortify.cli.aviator._common.config.admin.cli.mixin.AviatorAdminConfigNameArgGroup;
import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor;
import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigHelper;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.cli.common.session.cli.cmd.AbstractSessionLoginCommand;
import com.fortify.cli.common.session.cli.mixin.ISessionNameSupplier;
@@ -47,6 +48,12 @@ public ISessionNameSupplier getSessionNameSupplier() {
protected void logoutBeforeNewLogin(String configName, AviatorAdminConfigDescriptor configDescriptor) {
}
+ @Override
+ protected boolean shouldReplaceExistingSessionBeforeLogin() {
+ // login() validates the configuration first; keep the existing configuration if validation fails
+ return false;
+ }
+
@Override
protected AviatorAdminConfigDescriptor login(String configName) {
String privateKeyContents = configCreateOptions.getPrivateKeyResolver().getPrivateKeyContents();
@@ -55,7 +62,7 @@ protected AviatorAdminConfigDescriptor login(String configName) {
configCreateOptions.getTenant(),
privateKeyContents);
LOG.info("Validating Aviator admin configuration with the Aviator server...");
- configHelper.validateConfig(configDescriptor);
+ configHelper.validateConfig(configDescriptor, AviatorRequestPolicy.FAIL_FAST);
LOG.info("Aviator admin configuration validated successfully.");
return configDescriptor;
}
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java
index e7039f7e94b..c79f5773294 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java
@@ -15,6 +15,7 @@
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.common.session.helper.AbstractSessionHelper;
public class AviatorAdminConfigHelper extends AbstractSessionHelper {
@@ -37,8 +38,8 @@ protected Class getSessionDescriptorType() {
return AviatorAdminConfigDescriptor.class;
}
- public void validateConfig(AviatorAdminConfigDescriptor configDescriptor) {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ public void validateConfig(AviatorAdminConfigDescriptor configDescriptor, AviatorRequestPolicy policy) {
+ try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl(), policy)) {
String[] messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(configDescriptor, configDescriptor.getTenant());
String message = messageAndSignature[0];
String signature = messageAndSignature[1];
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java
index 12ad4a13068..d198b37574e 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java
@@ -15,6 +15,9 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fortify.cli.aviator._common.config.admin.cli.mixin.AviatorAdminConfigDescriptorSupplier;
import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor;
+import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
+import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand;
import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier;
@@ -33,4 +36,9 @@ public final JsonNode getJsonNode() {
protected abstract JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor);
public abstract boolean isSingular();
+
+ /** Creates a fail-fast client for ordinary admin commands. The caller must close the client. */
+ protected final AviatorGrpcClient createGrpcClient(AviatorAdminConfigDescriptor configDescriptor) {
+ return AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl(), AviatorRequestPolicy.FAIL_FAST);
+ }
}
\ No newline at end of file
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java
index 42f93eb2c10..a6e2e3379d3 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java
@@ -25,6 +25,7 @@
import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor;
import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper;
import com.fortify.cli.aviator._common.util.AviatorJwtUtils;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.cli.common.session.cli.cmd.AbstractSessionLoginCommand;
@@ -49,6 +50,12 @@ public class AviatorUserSessionLoginCommand extends AbstractSessionLoginCommand<
@Override
protected void logoutBeforeNewLogin(String sessionName, AviatorUserSessionDescriptor sessionDescriptor) {}
+ @Override
+ protected boolean shouldReplaceExistingSessionBeforeLogin() {
+ // login() validates the token first; keep the existing session if validation fails
+ return false;
+ }
+
@Override
protected AviatorUserSessionDescriptor login(String sessionName) {
String resolvedToken = tokenResolver.getToken();
@@ -56,7 +63,8 @@ protected AviatorUserSessionDescriptor login(String sessionName) {
LOG.info("Validating Aviator user token with the server...");
try {
- var validationResponse = sessionHelper.validateToken(sessionLoginOptions.getAviatorUrl(), resolvedToken).response();
+ var validationResponse = sessionHelper.validateToken(sessionLoginOptions.getAviatorUrl(), resolvedToken,
+ AviatorRequestPolicy.FAIL_FAST).response();
if (!validationResponse.getValid()) {
String errorMsg = validationResponse.getErrorMessage();
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java
index dbed50341e3..0ef65f56498 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java
@@ -14,6 +14,7 @@
import com.fortify.cli.aviator._common.util.AviatorJwtUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.common.session.helper.AbstractSessionHelper;
import com.fortify.grpc.token.TokenValidationResponse;
@@ -37,13 +38,9 @@ protected Class getSessionDescriptorType() {
return AviatorUserSessionDescriptor.class;
}
- public AviatorUserTokenValidationResult validateToken(AviatorUserSessionDescriptor sessionDescriptor) {
- return validateToken(sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken());
- }
-
- public AviatorUserTokenValidationResult validateToken(String aviatorUrl, String token) {
+ public AviatorUserTokenValidationResult validateToken(String aviatorUrl, String token, AviatorRequestPolicy policy) {
var tenantName = AviatorJwtUtils.extractTenantNameFromToken(token);
- try (var client = AviatorGrpcClientHelper.createClient(aviatorUrl)) {
+ try (var client = AviatorGrpcClientHelper.createClient(aviatorUrl, policy)) {
var response = client.validateUserToken(token, tenantName);
return new AviatorUserTokenValidationResult(tenantName, response);
}
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java
index a3436bc9e94..5978d488555 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java
@@ -21,7 +21,6 @@
import com.fortify.cli.aviator._common.util.AviatorGrpcUtils;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.cli.common.output.transform.IActionCommandResultSupplier;
@@ -37,7 +36,7 @@ public class AviatorAppAddEntitlementCommand extends AbstractAviatorAdminSession
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(
configDescriptor,
configDescriptor.getTenant(),
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java
index 72380a8b9e5..a72e1d6bf33 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java
@@ -23,7 +23,6 @@
import com.fortify.cli.aviator._common.util.AviatorGrpcUtils;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.cli.common.output.transform.IActionCommandResultSupplier;
import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin;
@@ -42,7 +41,7 @@ public class AviatorAppCreateCommand extends AbstractAviatorAdminSessionOutputCo
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = createMessageAndSignature(configDescriptor);
Application createdApplication = createApplication(client, configDescriptor, messageAndSignature);
LOG.info("Application '{}' created successfully for tenant: {}", applicationName, configDescriptor.getTenant());
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java
index eb0c13fe327..fbcca2b56ca 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java
@@ -25,7 +25,6 @@
import com.fortify.cli.aviator._common.output.cli.cmd.AbstractAviatorAdminSessionOutputCommand;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import lombok.Getter;
@@ -41,7 +40,7 @@ public class AviatorAppDeleteCommand extends AbstractAviatorAdminSessionOutputCo
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = createMessageAndSignature(configDescriptor);
ApplicationResponseMessage response = deleteApplication(client, configDescriptor, messageAndSignature);
JsonNode result = processDeleteResponse(response);
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java
index b1f12d10984..006ea270168 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java
@@ -29,7 +29,6 @@
import com.fortify.cli.aviator._common.util.AviatorGrpcUtils;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import lombok.Getter;
@@ -46,7 +45,7 @@ public class AviatorAppGetCommand extends AbstractAviatorAdminSessionOutputComma
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = createMessageAndSignature(configDescriptor);
Application application = getApplication(client, configDescriptor, messageAndSignature);
JsonNode response = processGetApplicationResponse(AviatorGrpcUtils.grpcToJsonNode(application));
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java
index 094f3dc91b7..7595274cf9b 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java
@@ -30,7 +30,6 @@
import com.fortify.cli.aviator._common.util.AviatorGrpcUtils;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import lombok.Getter;
@@ -45,7 +44,7 @@ public class AviatorAppListCommand extends AbstractAviatorAdminSessionOutputComm
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = createMessageAndSignature(configDescriptor);
List applications = listApplications(client, configDescriptor, messageAndSignature);
return formatApplicationsArray(applications, configDescriptor.getTenant());
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java
index da0adf98f6c..7e7e034c0b5 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java
@@ -29,7 +29,6 @@
import com.fortify.cli.aviator._common.util.AviatorGrpcUtils;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.cli.common.output.transform.IActionCommandResultSupplier;
@@ -49,7 +48,7 @@ public class AviatorAppUpdateCommand extends AbstractAviatorAdminSessionOutputCo
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = createMessageAndSignature(configDescriptor);
Application updatedApplication = updateApplication(client, configDescriptor, messageAndSignature);
JsonNode response = processUpdateApplicationResponse(AviatorGrpcUtils.grpcToJsonNode(updatedApplication));
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java
index 0f66987d9d5..f309a869d5f 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java
@@ -24,6 +24,7 @@
import com.fortify.cli.aviator.diagnose.AviatorDiagnosticEvidence;
import com.fortify.cli.aviator.diagnose.AviatorDiagnosticReport;
import com.fortify.cli.aviator.diagnose.AviatorDiagnosticStageResult;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.common.exception.FcliBugException;
import lombok.RequiredArgsConstructor;
@@ -48,8 +49,9 @@ public class AviatorConnectionDiagnoseHelper {
public AviatorConnectionDiagnoseHelper() {
this(new AviatorConnectionDiagnostics(),
- (url, token) -> AviatorUserSessionHelper.instance().validateToken(url, token),
- descriptor -> AviatorAdminConfigHelper.instance().validateConfig(descriptor));
+ // Preserve diagnostic status mapping instead of ordinary fail-fast messages.
+ (url, token) -> AviatorUserSessionHelper.instance().validateToken(url, token, AviatorRequestPolicy.WAIT_FOR_READY),
+ descriptor -> AviatorAdminConfigHelper.instance().validateConfig(descriptor, AviatorRequestPolicy.WAIT_FOR_READY));
}
public DiagnoseRunResult diagnose(AviatorConnectionDiagnoseSource source, int timeoutSeconds) {
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java
index 5f9bfa4ea51..79500af4b48 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java
@@ -26,7 +26,6 @@
import com.fortify.cli.aviator._common.util.AviatorGrpcUtils;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
/**
* Abstract base class for SAST entitlement list commands. Provides the shared
@@ -38,7 +37,7 @@ public abstract class AbstractAviatorSastEntitlementListCommand extends Abstract
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
var messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(configDescriptor, configDescriptor.getTenant());
String message = messageAndSignature[0];
String signature = messageAndSignature[1];
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java
index 9e619dc1075..5cd8fe81fbd 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java
@@ -27,7 +27,6 @@
import com.fortify.cli.aviator._common.util.AviatorGrpcUtils;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import lombok.Getter;
import picocli.CommandLine.Command;
@@ -40,7 +39,7 @@ public class AviatorEntitlementListDastCommand extends AbstractAviatorAdminSessi
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
var messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(configDescriptor, configDescriptor.getTenant());
String message = messageAndSignature[0];
String signature = messageAndSignature[1];
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java
index acf07548bcd..d0ee2910495 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java
@@ -37,6 +37,7 @@
import com.fortify.cli.aviator.fpr.Vulnerability;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.aviator.grpc.CorrelationResult;
import com.fortify.cli.aviator.grpc.CorrelationStreamConfig;
import com.fortify.cli.aviator.grpc.CorrelationStreamProcessor;
@@ -278,7 +279,7 @@ private CorrelationResult correlateViaGrpc(List mixedBuckets,
av.getApplicationName(), av.getVersionName(), sastResult.buildId);
CorrelationResult result;
- try (var grpcClient = AviatorGrpcClientHelper.createClient(sessionDescriptor.getAviatorUrl(), logger, 30)) {
+ try (var grpcClient = AviatorGrpcClientHelper.createClient(sessionDescriptor.getAviatorUrl(), logger, 30, AviatorRequestPolicy.WAIT_FOR_READY)) {
result = performCorrelation(grpcClient, config, bucketData, sastResult.scanGuid, alreadyTriedKeys);
}
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java
index 7f01f08488f..ca3bc5f7be8 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java
@@ -34,6 +34,7 @@
import com.fortify.cli.aviator.config.IAviatorLogger;
import com.fortify.cli.aviator.config.TagMappingConfig;
import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.aviator.grpc.DastAuditStreamConfig;
import com.fortify.cli.aviator.grpc.DastAuditStreamProcessor;
import com.fortify.cli.aviator.ssc.helper.AviatorSSCAttributeHelper;
@@ -133,7 +134,7 @@ private DastAuditFprResult auditFpr(
.sscApplicationName(appVersion.getApplicationName())
.sscApplicationVersion(appVersion.getVersionName())
.build();
- try (var grpcClient = AviatorGrpcClientHelper.createClient(session.getAviatorUrl(), logger, 30);
+ try (var grpcClient = AviatorGrpcClientHelper.createClient(session.getAviatorUrl(), logger, 30, AviatorRequestPolicy.WAIT_FOR_READY);
var streamProcessor = new DastAuditStreamProcessor(
logger, grpcClient.getDastAuditAsyncStub(), grpcClient.getPingScheduler(),
grpcClient.getPingIntervalSeconds());
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java
index f52033ea53b..e4be2b669b7 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java
@@ -33,6 +33,7 @@
import com.fortify.cli.aviator.config.AviatorLoggerImpl;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
import com.fortify.cli.aviator.util.Constants;
import com.fortify.cli.common.exception.FcliSimpleException;
import com.fortify.cli.common.json.JsonHelper;
@@ -717,7 +718,7 @@ private static String getFolderFilter(boolean noFilterSet, SSCIssueFilterSetDesc
public static long getAvailableQuota(String aviatorUrl, String aviatorToken, String appName,
AviatorLoggerImpl logger) {
try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(aviatorUrl, logger,
- Constants.DEFAULT_PING_INTERVAL_SECONDS)) {
+ Constants.DEFAULT_PING_INTERVAL_SECONDS, AviatorRequestPolicy.WAIT_FOR_READY)) {
Application app = client.getApplicationByToken(aviatorToken, appName);
long quota = app.getQuota();
logger.progress("Status: Available Aviator quota for app '%s': %s", appName,
@@ -745,7 +746,7 @@ public static long getAvailableQuota(String aviatorUrl, String aviatorToken, Str
public static long getDefaultQuota(String aviatorUrl, String aviatorToken,
AviatorLoggerImpl logger) {
try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(aviatorUrl, logger,
- Constants.DEFAULT_PING_INTERVAL_SECONDS)) {
+ Constants.DEFAULT_PING_INTERVAL_SECONDS, AviatorRequestPolicy.WAIT_FOR_READY)) {
long quota = client.getDefaultQuota(aviatorToken);
logger.progress("Status: Default Aviator quota for new applications: %s",
quota < 0 ? "unlimited" : String.valueOf(quota));
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java
index dee46793140..33b924138aa 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java
@@ -32,7 +32,6 @@
import com.fortify.cli.aviator._common.util.AviatorGrpcUtils;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.aviator.util.FileUtil;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.cli.common.output.transform.IActionCommandResultSupplier;
@@ -57,7 +56,7 @@ public class AviatorTokenCreateCommand extends AbstractAviatorAdminSessionOutput
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = createMessageAndSignature(configDescriptor);
TokenGenerationResponse response = generateToken(client, configDescriptor, messageAndSignature);
return processTokenResponse(response);
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java
index aa2aa814b24..aef69d516b8 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java
@@ -25,7 +25,6 @@
import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserTokenResolverMixin;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.grpc.token.DeleteTokenResponse;
@@ -45,7 +44,7 @@ public class AviatorTokenDeleteCommand extends AbstractAviatorAdminSessionOutput
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
String tokenToDelete = tokenResolver.getToken();
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = createMessageAndSignature(configDescriptor, tokenToDelete);
DeleteTokenResponse response = deleteToken(client, configDescriptor, messageAndSignature, tokenToDelete);
return processDeleteResponse(response, tokenToDelete);
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java
index 08f450ebe20..c03df4cf811 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java
@@ -29,7 +29,6 @@
import com.fortify.cli.aviator._common.util.AviatorGrpcUtils;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.aviator.util.StringUtil;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.grpc.token.ListTokensResponse;
@@ -50,7 +49,7 @@ public class AviatorTokenListCommand extends AbstractAviatorAdminSessionOutputCo
@Override
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
ArrayNode tokensArray = fetchAllTokens(client, configDescriptor);
logTokenCount(tokensArray.size());
return tokensArray;
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java
index e85fc311334..258fa698b5c 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java
@@ -25,7 +25,6 @@
import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserTokenResolverMixin;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.grpc.token.RevokeTokenResponse;
@@ -45,7 +44,7 @@ public class AviatorTokenRevokeCommand extends AbstractAviatorAdminSessionOutput
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
String tokenToRevoke = tokenResolver.getToken();
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = createMessageAndSignature(configDescriptor, tokenToRevoke);
RevokeTokenResponse response = revokeToken(client, configDescriptor, messageAndSignature, tokenToRevoke);
return processRevokeResponse(response, tokenToRevoke);
diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java
index 5b682f3af97..84c798d42ee 100644
--- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java
+++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java
@@ -22,7 +22,6 @@
import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserTokenResolverMixin;
import com.fortify.cli.aviator._common.util.AviatorSignatureUtils;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
-import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.grpc.token.TokenValidationResponse;
@@ -39,7 +38,7 @@ public class AviatorTokenValidateCommand extends AbstractAviatorAdminSessionOutp
protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException {
String tokenToValidate = tokenResolver.getToken();
- try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) {
+ try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) {
String[] messageAndSignature = createMessageAndSignature(configDescriptor, tokenToValidate);
TokenValidationResponse response = validateToken(client, configDescriptor, messageAndSignature, tokenToValidate);
return createResponseNode(response);
diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/output/cli/cmd/AviatorOrdinaryCommandConnectivityTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/output/cli/cmd/AviatorOrdinaryCommandConnectivityTest.java
new file mode 100644
index 00000000000..080e58a62cb
--- /dev/null
+++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/output/cli/cmd/AviatorOrdinaryCommandConnectivityTest.java
@@ -0,0 +1,136 @@
+/*
+ * 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.aviator._common.output.cli.cmd;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Base64;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor;
+import com.fortify.cli.aviator._common.exception.AviatorConnectivityException;
+import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper;
+import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
+import com.fortify.cli.aviator.grpc.AviatorRequestPolicy;
+import com.fortify.cli.common.cli.util.FcliExecutionContextHolder;
+import com.fortify.cli.common.output.cli.mixin.IOutputHelper;
+
+/**
+ * Checks that fail-fast Aviator commands fail fast with the shared connectivity messages.
+ * Each case targets a closed loopback port, so no Aviator server is needed.
+ */
+class AviatorOrdinaryCommandConnectivityTest {
+ private static final String FCLI_DATA_DIR_PROPERTY = "fcli.env.FCLI_DATA_DIR";
+ private static final Duration FAIL_FAST_LIMIT = Duration.ofSeconds(5);
+
+ // Static, because some fcli helpers cache paths below the fcli data directory in static fields
+ @TempDir static Path fcliDataDir;
+ private static String previousFcliDataDir;
+ private String closedPortUrl;
+
+ @BeforeAll
+ static void setUpFcliDataDir() {
+ previousFcliDataDir = System.getProperty(FCLI_DATA_DIR_PROPERTY);
+ System.setProperty(FCLI_DATA_DIR_PROPERTY, fcliDataDir.toString());
+ }
+
+ @AfterAll
+ static void restoreFcliDataDir() {
+ if ( previousFcliDataDir==null ) {
+ System.clearProperty(FCLI_DATA_DIR_PROPERTY);
+ } else {
+ System.setProperty(FCLI_DATA_DIR_PROPERTY, previousFcliDataDir);
+ }
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ FcliExecutionContextHolder.pushNew();
+ try ( var socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()) ) {
+ closedPortUrl = "127.0.0.1:" + socket.getLocalPort();
+ }
+ assumeTrue(AviatorGrpcClientHelper.createConnectionPlan(closedPortUrl).proxyDescriptor().isEmpty(),
+ "This test requires a direct loopback route (configure NO_PROXY for 127.0.0.1)");
+ }
+
+ @AfterEach
+ void tearDown() {
+ FcliExecutionContextHolder.pop();
+ }
+
+ @Test
+ void adminCommandBaseClassCreatesFailFastClient() {
+ var descriptor = new AviatorAdminConfigDescriptor(closedPortUrl, "tenant", "unused-private-key");
+ var command = new TestAdminCommand();
+
+ assertFailsFast(() -> {
+ try ( var client = command.createGrpcClient(descriptor) ) {
+ client.listApplication("tenant", "signature", "message");
+ }
+ });
+ }
+
+ @Test
+ void userSessionTokenValidationFailsFastWithFailFastPolicy() {
+ assertFailsFast(() -> AviatorUserSessionHelper.instance()
+ .validateToken(closedPortUrl, syntheticToken(), AviatorRequestPolicy.FAIL_FAST));
+ }
+
+ private static String syntheticToken() {
+ var encoder = Base64.getUrlEncoder().withoutPadding();
+ return encoder.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8))
+ + "." + encoder.encodeToString("{\"tenantName\":\"tenant\"}".getBytes(StandardCharsets.UTF_8))
+ + ".signature";
+ }
+
+ private static void assertFailsFast(Executable executable) {
+ long start = System.nanoTime();
+ var exception = assertThrows(AviatorConnectivityException.class, executable);
+ var elapsed = Duration.ofNanos(System.nanoTime() - start);
+ assertTrue(elapsed.compareTo(FAIL_FAST_LIMIT) < 0, "Call took " + elapsed);
+ assertTrue(exception.getMessage().contains("refused the connection"), exception.getMessage());
+ }
+
+ private static final class TestAdminCommand extends AbstractAviatorAdminSessionOutputCommand {
+ @Override
+ protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) {
+ return null;
+ }
+
+ @Override
+ public boolean isSingular() {
+ return true;
+ }
+
+ @Override
+ public IOutputHelper getOutputHelper() {
+ return null;
+ }
+ }
+}
diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java
index ce93114f719..cc7a6ba0d0f 100644
--- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java
+++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java
@@ -28,7 +28,9 @@ public JsonNode getJsonNode() {
var sessionNameSupplier = getSessionNameSupplier();
String sessionName = sessionNameSupplier==null?"default":sessionNameSupplier.getSessionName();
var sessionHelper = getSessionHelper();
- logoutIfSessionExists(sessionName);
+ if ( shouldReplaceExistingSessionBeforeLogin() ) {
+ logoutIfSessionExists(sessionName);
+ }
D sessionDescriptor = login(sessionName);
sessionHelper.save(sessionName, sessionDescriptor);
testAuthenticatedConnection(sessionName);
@@ -63,4 +65,13 @@ private void logoutIfSessionExists(String sessionName) {
protected abstract void logoutBeforeNewLogin(String sessionName, D sessionDescriptor);
protected abstract D login(String sessionName);
protected void testAuthenticatedConnection(String sessionName) {}
+
+ /**
+ * Return false only when no remote logout is needed and {@link #login(String)} preserves the existing session until success.
+ * The session helper saves the new session after {@code login} returns.
+ * {@link #testAuthenticatedConnection(String)} runs after saving; failures there do not restore the previous session.
+ */
+ protected boolean shouldReplaceExistingSessionBeforeLogin() {
+ return true;
+ }
}
diff --git a/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommandTest.java b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommandTest.java
new file mode 100644
index 00000000000..a274fd50f19
--- /dev/null
+++ b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommandTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.session.cli.cmd;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.nio.file.Path;
+import java.util.Date;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fortify.cli.common.cli.util.FcliExecutionContextHolder;
+import com.fortify.cli.common.exception.FcliSimpleException;
+import com.fortify.cli.common.output.cli.mixin.IOutputHelper;
+import com.fortify.cli.common.session.cli.mixin.ISessionNameSupplier;
+import com.fortify.cli.common.session.helper.AbstractSessionHelper;
+import com.fortify.cli.common.session.helper.ISessionDescriptor;
+
+class AbstractSessionLoginCommandTest {
+ private static final String FCLI_DATA_DIR_PROPERTY = "fcli.env.FCLI_DATA_DIR";
+ private static final String SESSION_NAME = "test-session";
+
+ @TempDir Path fcliDataDir;
+ private String previousFcliDataDir;
+ private final TestSessionHelper sessionHelper = new TestSessionHelper();
+
+ @BeforeEach
+ void setUp() {
+ previousFcliDataDir = System.getProperty(FCLI_DATA_DIR_PROPERTY);
+ System.setProperty(FCLI_DATA_DIR_PROPERTY, fcliDataDir.toString());
+ FcliExecutionContextHolder.pushNew();
+ }
+
+ @AfterEach
+ void tearDown() {
+ FcliExecutionContextHolder.pop();
+ if ( previousFcliDataDir==null ) {
+ System.clearProperty(FCLI_DATA_DIR_PROPERTY);
+ } else {
+ System.setProperty(FCLI_DATA_DIR_PROPERTY, previousFcliDataDir);
+ }
+ }
+
+ @Test
+ void defaultLifecycleDestroysExistingSessionBeforeLogin() {
+ sessionHelper.save(SESSION_NAME, new TestSessionDescriptor("old"));
+ assertTrue(sessionHelper.exists(SESSION_NAME));
+ var command = new TestLoginCommand(sessionHelper, null);
+
+ assertThrows(FcliSimpleException.class, command::getJsonNode);
+
+ assertFalse(sessionHelper.exists(SESSION_NAME));
+ assertEquals(1, command.logoutCount);
+ }
+
+ @Test
+ void optOutKeepsExistingSessionWhenLoginFails() {
+ sessionHelper.save(SESSION_NAME, new TestSessionDescriptor("old"));
+ TestLoginCommand command = new OptOutTestLoginCommand(sessionHelper, null);
+
+ assertThrows(FcliSimpleException.class, command::getJsonNode);
+
+ assertTrue(sessionHelper.exists(SESSION_NAME));
+ assertEquals("old", sessionHelper.get(SESSION_NAME, true).getValue());
+ assertEquals(0, command.logoutCount);
+ }
+
+ @Test
+ void optOutCreatesNoSessionWhenLoginFailsForNewName() {
+ TestLoginCommand command = new OptOutTestLoginCommand(sessionHelper, null);
+
+ assertThrows(FcliSimpleException.class, command::getJsonNode);
+
+ assertFalse(sessionHelper.exists(SESSION_NAME));
+ }
+
+ @Test
+ void optOutReplacesExistingSessionWhenLoginSucceeds() {
+ sessionHelper.save(SESSION_NAME, new TestSessionDescriptor("old"));
+ TestLoginCommand command = new OptOutTestLoginCommand(sessionHelper, new TestSessionDescriptor("new"));
+
+ command.getJsonNode();
+
+ assertEquals("new", sessionHelper.get(SESSION_NAME, true).getValue());
+ assertEquals(0, command.logoutCount);
+ }
+
+ private static class TestLoginCommand extends AbstractSessionLoginCommand {
+ private final TestSessionHelper sessionHelper;
+ private final TestSessionDescriptor loginResult;
+ private int logoutCount;
+
+ private TestLoginCommand(TestSessionHelper sessionHelper, TestSessionDescriptor loginResult) {
+ this.sessionHelper = sessionHelper;
+ this.loginResult = loginResult;
+ }
+
+ @Override
+ public ISessionNameSupplier getSessionNameSupplier() {
+ return () -> SESSION_NAME;
+ }
+
+ @Override
+ protected void logoutBeforeNewLogin(String sessionName, TestSessionDescriptor sessionDescriptor) {
+ logoutCount++;
+ }
+
+ @Override
+ protected TestSessionDescriptor login(String sessionName) {
+ if ( loginResult==null ) {
+ throw new FcliSimpleException("Validation failed");
+ }
+ return loginResult;
+ }
+
+ @Override
+ protected AbstractSessionHelper getSessionHelper() {
+ return sessionHelper;
+ }
+
+ @Override
+ public IOutputHelper getOutputHelper() {
+ return null;
+ }
+ }
+
+ private static final class OptOutTestLoginCommand extends TestLoginCommand {
+ private OptOutTestLoginCommand(TestSessionHelper sessionHelper, TestSessionDescriptor loginResult) {
+ super(sessionHelper, loginResult);
+ }
+
+ @Override
+ protected boolean shouldReplaceExistingSessionBeforeLogin() {
+ return false;
+ }
+ }
+
+ private static final class TestSessionHelper extends AbstractSessionHelper {
+ @Override
+ public String getType() {
+ return "test-login";
+ }
+
+ @Override
+ protected String getLoginCmd() {
+ return "fcli test session login";
+ }
+
+ @Override
+ protected Class getSessionDescriptorType() {
+ return TestSessionDescriptor.class;
+ }
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static final class TestSessionDescriptor implements ISessionDescriptor {
+ private String value;
+
+ public TestSessionDescriptor() {}
+
+ TestSessionDescriptor(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String getUrlDescriptor() {
+ return "test";
+ }
+
+ @Override
+ public Date getCreatedDate() {
+ return new Date();
+ }
+
+ @Override
+ public Date getExpiryDate() {
+ return null;
+ }
+
+ @Override
+ public String getType() {
+ return "test-login";
+ }
+ }
+}