From 3864b096679ef3c3067ddd6f7f0e78313b4e97c9 Mon Sep 17 00:00:00 2001 From: Dhanwanth Pratheep Date: Thu, 20 Aug 2026 15:52:07 +0530 Subject: [PATCH 1/3] feat: add --preview flag to preview aviator remediations before applying --- .../RemediationsApplyHelper.java | 23 +- .../util/AviatorRemediationMetricsHelper.java | 32 ++- .../ApplyAutoRemediationOnSource.java | 12 +- .../fpr/processor/RemediationProcessor.java | 240 ++++++++++++++++-- .../fpr/processor/preview/ChangeDetail.java | 48 ++++ .../processor/preview/ContextMetadata.java | 42 +++ .../fpr/processor/preview/FileChange.java | 52 ++++ .../fpr/processor/preview/FilePreview.java | 48 ++++ .../fpr/processor/preview/PreviewDetail.java | 64 +++++ .../RemediationProcessorEdgeCasesTest.java | 105 ++++++++ .../preview/ContextMetadataTest.java | 72 ++++++ .../fpr/processor/preview/FileChangeTest.java | 92 +++++++ .../processor/preview/FilePreviewTest.java | 80 ++++++ .../processor/preview/PreviewDetailTest.java | 95 +++++++ .../AviatorSSCApplyRemediationsCommand.java | 11 +- .../AviatorSSCApplyRemediationsHelper.java | 29 ++- .../aviator/i18n/AviatorMessages.properties | 3 + ...viatorSSCApplyRemediationsCommandTest.java | 31 +++ .../FoDAviatorApplyRemediationsCommand.java | 10 +- .../AviatorFoDApplyRemediationsHelper.java | 20 +- .../cli/fod/i18n/FoDMessages.properties | 3 + ...oDAviatorApplyRemediationsCommandTest.java | 31 +++ 22 files changed, 1090 insertions(+), 53 deletions(-) create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ChangeDetail.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadata.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FileChange.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetail.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadataTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FileChangeTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetailTest.java diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java index aae450c8de..ff7c0dcd58 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java @@ -52,13 +52,27 @@ public static ApplyResult apply( IAviatorLogger logger, Set issueIdFilter, Logger skipLog) { + return apply(source, sourceCodeDirectory, logger, issueIdFilter, skipLog, false); + } + + /** + * Applies or previews remediations for each source entry until done or the issue-id filter is exhausted. + * Caller owns {@code source} lifecycle (try-with-resources). + */ + public static ApplyResult apply( + IRemediationsFprSource source, + String sourceCodeDirectory, + IAviatorLogger logger, + Set issueIdFilter, + Logger skipLog, + boolean previewMode) { Accumulator acc = new Accumulator(issueIdFilter); source.forEachEntry((fprPath, label, id, index, total) -> { if (acc.remaining != null && acc.remaining.isEmpty()) { return false; } RemediationMetric metric = applyOne( - fprPath, label, index, total, sourceCodeDirectory, logger, acc.remaining, skipLog); + fprPath, label, index, total, sourceCodeDirectory, logger, acc.remaining, skipLog, previewMode); if (metric == null) { acc.skipped++; } else { @@ -85,11 +99,12 @@ private static RemediationMetric applyOne( String sourceCodeDirectory, IAviatorLogger logger, Set issueFilter, - Logger skipLog) { + Logger skipLog, + boolean previewMode) { logger.progress("Processing FPR " + index + "/" + total + " (" + entryLabel + ")"); - logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); + logger.progress("Status: Processing FPR with Aviator for " + (previewMode ? "Previewing" : "Applying") + " Auto Remediations"); try (FprHandle fprHandle = new FprHandle(fprPath)) { - return ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter); + return ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter, previewMode); } catch (AviatorSimpleException e) { skipLog.warn("Skipping entry {} as {}", entryLabel, e.getMessage()); return null; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java index f45b19a33f..10a2d82db3 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java @@ -40,7 +40,16 @@ private AviatorRemediationMetricsHelper() {} public static RemediationMetric aggregateMetrics(Set requestedIssueIds, Collection metrics) { Set modifiedFiles = new LinkedHashSet<>(); Map skippedByReason = new LinkedHashMap<>(); + List allPreviewDetails = new ArrayList<>(); Collection safeMetrics = metrics == null ? List.of() : metrics; + + // Aggregate preview details from all metrics + for (RemediationMetric metric : safeMetrics) { + if (metric.previewDetails() != null) { + allPreviewDetails.addAll(metric.previewDetails()); + } + } + if (requestedIssueIds == null) { int totalRemediations = 0; int appliedRemediations = 0; @@ -49,14 +58,16 @@ public static RemediationMetric aggregateMetrics(Set requestedIssueIds, appliedRemediations += metric.appliedRemediations(); accumulateFilesAndSkips(metric, modifiedFiles, skippedByReason); } - return RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason); + return RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason, + allPreviewDetails.isEmpty() ? null : allPreviewDetails); } Set appliedIssueIds = new LinkedHashSet<>(); for (RemediationMetric metric : safeMetrics) { appliedIssueIds.addAll(metric.appliedIssueIds()); accumulateFilesAndSkips(metric, modifiedFiles, skippedByReason); } - return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason); + return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, + allPreviewDetails.isEmpty() ? null : allPreviewDetails); } private static void accumulateFilesAndSkips( @@ -120,8 +131,25 @@ public static void putRemediationMetricFields(ObjectNode result, RemediationMetr /** Metric fields plus {@code __action__} (shared by SSC/FoD result builders). */ public static void putMetricAndAction(ObjectNode result, RemediationMetric metric) { + putMetricAndAction(result, metric, false); + } + + /** Metric fields plus {@code __action__} and optional preview details (shared by SSC/FoD result builders). */ + public static void putMetricAndAction(ObjectNode result, RemediationMetric metric, boolean previewMode) { putRemediationMetricFields(result, metric); result.put(IActionCommandResultSupplier.actionFieldName, actionLabel(metric)); + + if (previewMode && metric != null && metric.previewDetails() != null) { + result.set("previewDetails", toPreviewDetailsArray(metric.previewDetails())); + } + } + + private static ArrayNode toPreviewDetailsArray(List previewDetails) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (previewDetails != null) { + previewDetails.forEach(detail -> array.add(JsonHelper.getObjectMapper().valueToTree(detail))); + } + return array; } /** diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java index 561c3cb2d3..52a126fc83 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java @@ -30,21 +30,27 @@ public class ApplyAutoRemediationOnSource { public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger) throws AviatorSimpleException, AviatorTechnicalException { - return applyRemediations(fprHandle, sourceCodeDirectory, logger, null); + return applyRemediations(fprHandle, sourceCodeDirectory, logger, null, false); } public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, Set issueIdFilter) throws AviatorSimpleException, AviatorTechnicalException { + return applyRemediations(fprHandle, sourceCodeDirectory, logger, issueIdFilter, false); + } + + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + Set issueIdFilter, boolean previewMode) + throws AviatorSimpleException, AviatorTechnicalException { - LOG.info("Starting apply auto-remediation process for file: {}", fprHandle.getFprPath()); + LOG.info("Starting {} process for file: {}", previewMode ? "preview" : "apply auto-remediation", fprHandle.getFprPath()); if (!fprHandle.hasRemediations()) { throw new AviatorSimpleException("FPR file does not contain remediations.xml file."); } LOG.info("FPR validation successful"); - RemediationProcessor remediationProcessor = new RemediationProcessor(fprHandle, sourceCodeDirectory, issueIdFilter); + RemediationProcessor remediationProcessor = new RemediationProcessor(fprHandle, sourceCodeDirectory, issueIdFilter, previewMode); return remediationProcessor.processRemediationXML(); } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java index 745da2520f..bba3056af1 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java @@ -49,6 +49,10 @@ import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.fpr.processor.preview.ChangeDetail; +import com.fortify.cli.aviator.fpr.processor.preview.FileChange; +import com.fortify.cli.aviator.fpr.processor.preview.FilePreview; +import com.fortify.cli.aviator.fpr.processor.preview.PreviewDetail; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.FuzzyContextSearcher; @@ -59,10 +63,25 @@ public class RemediationProcessor { private final FprHandle fprHandle; private final String sourceCodeDirectory; private final Set issueIdFilter; + /** + * Preview mode performs full validation and processing without modifying files. + * This is idempotent and side-effect-free: running preview multiple times produces + * identical results and does not affect the file system. The only state accumulation + * is in-memory for metrics and preview details, which is thread-local to each invocation. + */ + private final boolean previewMode; /** * Apply-remediations summary. Mode is explicit: unfiltered counts XML remediations; * filtered counts requested issue IDs. Factories are the only public construction path. + * + *

Preview Details: The {@code previewDetails} field is null when not in preview mode, + * and non-null (possibly empty list) when in preview mode. Callers should check both + * {@code previewMode} flag (from context) and null status before accessing preview data. + * This design avoids Optional in record fields (per style guidelines) while maintaining + * clear semantics: null = not applicable, empty list = no preview data available.

+ * + * @param previewDetails Detailed change information per issue (null if not in preview mode) */ public record RemediationMetric( Mode mode, @@ -72,7 +91,8 @@ public record RemediationMetric( Set modifiedFiles, Map skippedByReason, Set requestedIssueIds, - Set appliedIssueIds) { + Set appliedIssueIds, + List previewDetails) { public enum Mode { UNFILTERED, @@ -95,30 +115,41 @@ public enum Mode { requestedIssueIds = immutableCopy(requestedIssueIds); appliedIssueIds = immutableCopy(appliedIssueIds); } + previewDetails = previewDetails == null ? null : Collections.unmodifiableList(List.copyOf(previewDetails)); } public static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles) { - return unfiltered(totalRemediations, appliedRemediations, modifiedFiles, Map.of()); + return unfiltered(totalRemediations, appliedRemediations, modifiedFiles, Map.of(), null); } public static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles, Map skippedByReason) { + return unfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason, null); + } + + public static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles, + Map skippedByReason, List previewDetails) { return new RemediationMetric(Mode.UNFILTERED, totalRemediations, appliedRemediations, - totalRemediations - appliedRemediations, modifiedFiles, skippedByReason, Set.of(), Set.of()); + totalRemediations - appliedRemediations, modifiedFiles, skippedByReason, Set.of(), Set.of(), previewDetails); } public static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles) { - return filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, Map.of()); + return filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, Map.of(), null); } public static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles, Map skippedByReason) { + return filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, null); + } + + public static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles, + Map skippedByReason, List previewDetails) { Set requested = requestedIssueIds == null ? Set.of() : requestedIssueIds; Set applied = appliedIssueIds == null ? Set.of() : appliedIssueIds; int totalRemediations = requested.size(); int appliedRemediations = applied.size(); return new RemediationMetric(Mode.FILTERED, totalRemediations, appliedRemediations, - totalRemediations - appliedRemediations, modifiedFiles, skippedByReason, requested, applied); + totalRemediations - appliedRemediations, modifiedFiles, skippedByReason, requested, applied, previewDetails); } public boolean isFiltered() { @@ -132,7 +163,14 @@ private static Set immutableCopy(Set values) { private record FvdlMetadataResult(FVDLMetadata metadata, SkipReason skipReason) {} - private record PendingFileWrite(String filename, Path filePath, String content, byte[] updatedBytes) {} + private record PendingFileWrite( + String filename, + Path filePath, + String originalContent, + String content, + byte[] updatedBytes, + String encoding, + List changeDetails) {} private record RollbackFileWrite(String filename, Path filePath, byte[] originalBytes) {} @@ -201,13 +239,18 @@ private static class RollbackRemediationException extends AviatorTechnicalExcept } public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory) { - this(fprHandle, sourceCodeDirectory, null); + this(fprHandle, sourceCodeDirectory, null, false); } public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory, Set issueIdFilter) { + this(fprHandle, sourceCodeDirectory, issueIdFilter, false); + } + + public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory, Set issueIdFilter, boolean previewMode) { this.fprHandle = fprHandle; this.sourceCodeDirectory = sourceCodeDirectory; this.issueIdFilter = issueIdFilter == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(issueIdFilter)); + this.previewMode = previewMode; } public RemediationMetric processRemediationXML() { @@ -215,7 +258,7 @@ public RemediationMetric processRemediationXML() { Path sourceBasePath = getSourceBasePath(); LOG.debug("Applying remediations from {} to source directory {}", remediationPath, sourceBasePath); FvdlMetadataResult fvdlMetadataResult = loadFvdlMetadata(); - ProcessingState state = new ProcessingState(issueIdFilter); + ProcessingState state = new ProcessingState(issueIdFilter, previewMode); try (InputStream remediationStream = Files.newInputStream(remediationPath)) { Document remediationDoc = parseRemediationDocument(remediationStream); @@ -291,11 +334,17 @@ private static final class ProcessingState { private final Set seenRequestedIssueIds = new LinkedHashSet<>(); private final Set modifiedFiles = new LinkedHashSet<>(); private final Map skippedByReason = new LinkedHashMap<>(); + private final boolean previewMode; + private final Map> changesByIssue = new LinkedHashMap<>(); + private final Map skipReasonsByIssue = new LinkedHashMap<>(); private int xmlEntryCount; private int appliedRemediations; - private ProcessingState(Set issueIdFilter) { + private record FileMetadata(String path, String encoding, List changes) {} + + private ProcessingState(Set issueIdFilter, boolean previewMode) { this.requestedIssueIds = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); + this.previewMode = previewMode; } private void setXmlEntryCount(int xmlEntryCount) { @@ -323,9 +372,82 @@ private void recordSkip(SkipReason reason) { skippedByReason.merge(reason.displayName, 1, Integer::sum); } + private void recordSkipForIssue(String instanceId, SkipReason reason) { + recordSkip(reason); + if (previewMode) { + skipReasonsByIssue.put(instanceId, reason.displayName); + } + } + + private void addChangeDetails(String instanceId, String filename, String encoding, List changes) { + if (previewMode) { + changesByIssue + .computeIfAbsent(instanceId, k -> new LinkedHashMap<>()) + .compute(filename, (k, existing) -> { + if (existing == null) { + return new FileMetadata(filename, encoding, new ArrayList<>(changes)); + } else { + existing.changes.addAll(changes); + return existing; + } + }); + } + } + + private List buildPreviewDetails() { + if (!previewMode) { + return null; + } + List details = new ArrayList<>(); + + // Add successfully processed remediations + for (var issueEntry : changesByIssue.entrySet()) { + String issueId = issueEntry.getKey(); + Map fileMap = new LinkedHashMap<>(); + + for (var fileEntry : issueEntry.getValue().entrySet()) { + String filename = fileEntry.getKey(); + FileMetadata metadata = fileEntry.getValue(); + + // Direct collection without intermediate list allocation + List fileChanges = metadata.changes.stream() + .map(ChangeDetail::toFileChange) + .collect(java.util.stream.Collectors.toUnmodifiableList()); + + FilePreview filePreview = new FilePreview(filename, metadata.path, metadata.encoding, fileChanges); + fileMap.put(filename, filePreview); + } + details.add(PreviewDetail.available(issueId, fileMap)); + } + + // Add skipped remediations + for (var entry : skipReasonsByIssue.entrySet()) { + String issueId = entry.getKey(); + String skipReason = entry.getValue(); + // Only add if not already in successful list + if (!changesByIssue.containsKey(issueId)) { + details.add(PreviewDetail.skipped(issueId, skipReason)); + } + } + + return details; + } + private RemediationMetric toMetric() { + // Validate requested issue IDs before building metric + validateRequestedIssueIds(); + + List previewDetails = buildPreviewDetails(); + + if (requestedIssueIds == null) { + return RemediationMetric.unfiltered(xmlEntryCount, appliedRemediations, modifiedFiles, skippedByReason, previewDetails); + } + return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, previewDetails); + } + + private void validateRequestedIssueIds() { if (requestedIssueIds == null) { - return RemediationMetric.unfiltered(xmlEntryCount, appliedRemediations, modifiedFiles, skippedByReason); + return; } for (String requestedId : requestedIssueIds) { if (appliedIssueIds.contains(requestedId)) { @@ -334,12 +456,14 @@ private RemediationMetric toMetric() { if (!seenRequestedIssueIds.contains(requestedId)) { recordSkip(SkipReason.REQUESTED_ISSUE_NOT_FOUND); LOG.debug("Requested issue ID '{}' was not found in remediations.xml", requestedId); + if (previewMode) { + skipReasonsByIssue.put(requestedId, SkipReason.REQUESTED_ISSUE_NOT_FOUND.displayName); + } } else { LOG.debug("Requested issue ID '{}' was present in remediations.xml but could not be applied", requestedId); } } - return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason); } } @@ -349,25 +473,38 @@ private boolean processRemediation( try { Map pendingWrites = prepareFileChanges(remediation, sourceBasePath, fvdlMetadataResult); if (pendingWrites.isEmpty()) { - state.recordSkip(SkipReason.NO_CHANGES); + state.recordSkipForIssue(instanceId, SkipReason.NO_CHANGES); return false; } - try { - commitRemediationWrites(instanceId, pendingWrites, state.modifiedFiles); + + if (previewMode) { + // Preview mode: collect change details without writing files + for (PendingFileWrite pendingWrite : pendingWrites.values()) { + // Use filename (relative path from FVDL) instead of absolute filePath for security + state.addChangeDetails(instanceId, pendingWrite.filename(), + pendingWrite.encoding(), pendingWrite.changeDetails()); + state.modifiedFiles.add(pendingWrite.filename()); + } return true; - } catch (RemediationCommitException e) { - rollbackRemediationWrites(instanceId, e.getRollbacks()); - throw new SkipRemediationException(SkipReason.SOURCE_WRITE_FAILED, e.getMessage(), e); + } else { + // Apply mode: write files + try { + commitRemediationWrites(instanceId, pendingWrites, state.modifiedFiles); + return true; + } catch (RemediationCommitException e) { + rollbackRemediationWrites(instanceId, e.getRollbacks()); + throw new SkipRemediationException(SkipReason.SOURCE_WRITE_FAILED, e.getMessage(), e); + } } } catch (SkipRemediationException e) { - state.recordSkip(e.reason); + state.recordSkipForIssue(instanceId, e.reason); LOG.info("Skipping remediation {}: {}", instanceId, e.getMessage()); LOG.debug("Skip reason for remediation {}: {}", instanceId, e.reason.displayName, e); return false; } catch (RollbackRemediationException e) { throw e; } catch (Exception e) { - state.recordSkip(SkipReason.UNEXPECTED_ERROR); + state.recordSkipForIssue(instanceId, SkipReason.UNEXPECTED_ERROR); LOG.info("Skipping remediation {} due to an unexpected processing error", instanceId); LOG.debug("Unexpected error while processing remediation {}", instanceId, e); return false; @@ -413,18 +550,27 @@ private void processFileChanges(Element remediation, Element fileChanges, Path s LOG.debug("Remediation {} has {} change(s) for '{}' using FVDL encoding {}", instanceId, changesNodes.getLength(), filename, sourceEncoding.name()); - String updatedContent = getPendingOrSourceContent(filePath, filename, sourceEncoding, pendingWrites); + String originalContent = getPendingOrSourceContent(filePath, filename, sourceEncoding, pendingWrites); + String updatedContent = originalContent; + List changeDetails = new ArrayList<>(); + for (int k = 0; k < changesNodes.getLength(); k++) { - updatedContent = applyChange(instanceId, filename, fileHash, sourceEncoding, updatedContent, + ChangeResult changeResult = applyChange(instanceId, filename, fileHash, sourceEncoding, updatedContent, (Element) changesNodes.item(k), k + 1); + updatedContent = changeResult.updatedContent(); + changeDetails.add(changeResult.changeDetail()); } + byte[] updatedBytes = encodeStrict(updatedContent, sourceEncoding, filename); - pendingWrites.put(filePath, new PendingFileWrite(filename, filePath, updatedContent, updatedBytes)); + pendingWrites.put(filePath, new PendingFileWrite(filename, filePath, originalContent, updatedContent, updatedBytes, + sourceEncoding.name(), changeDetails)); LOG.debug("Staged remediation {} for '{}' using FVDL encoding {}; changes={}, encodedBytes={}", instanceId, filename, sourceEncoding.name(), changesNodes.getLength(), updatedBytes.length); } + + private record ChangeResult(String updatedContent, ChangeDetail changeDetail) {} - private String applyChange(String instanceId, String filename, String fileHash, Charset sourceEncoding, String originalContent, + private ChangeResult applyChange(String instanceId, String filename, String fileHash, Charset sourceEncoding, String originalContent, Element change, int changeIndex) { String lineSeparator = detectLineSeparator(originalContent); String content = normalizeLineEndings(originalContent); @@ -437,12 +583,27 @@ private String applyChange(String instanceId, String filename, String fileHash, int lineTo = parseRequiredInt(change, "LineTo"); LOG.debug("Remediation {} change {} for '{}' targets lines {}-{}", instanceId, changeIndex, filename, lineFrom, lineTo); + // Extract context metadata + Element contextElement = (Element) change.getElementsByTagNameNS(NAMESPACE_URI, "Context").item(0); + int contextBefore = 0; + int contextAfter = 0; + String contextText = ""; + + if (contextElement != null) { + contextBefore = parseIntAttribute(contextElement, "before", 0); + contextAfter = parseIntAttribute(contextElement, "after", 0); + contextText = contextElement.getTextContent(); + } + + String originalCodeText = getRequiredElementText(change, "OriginalCode"); + String newCodeText = getRequiredElementText(change, "NewCode"); + boolean fuzzyMatched = false; + String calculatedHash = calculateHashBase64(content, "SHA-256"); boolean fileHashMatches = calculatedHash.equals(fileHash); LOG.debug("Remediation {} hash check for '{}': {}", instanceId, filename, fileHashMatches ? "matched" : "mismatched"); if (!fileHashMatches) { LOG.debug("File hash mismatch for remediation {} in {}; searching changed source content", instanceId, filename); - String contextText = getRequiredElementText(change, "Context"); List contextLine = Arrays.asList(contextText.split("\\r?\\n")); int contextLineFrom = fuzzySearchContext(instanceId, filename, originalLines, contextLine); if (contextLineFrom == -1) { @@ -453,7 +614,6 @@ private String applyChange(String instanceId, String filename, String fileHash, } LOG.debug("Context for remediation {} in {} matched at line {}", instanceId, filename, contextLineFrom + 1); - String originalCodeText = getRequiredElementText(change, "OriginalCode"); List originalCodeLine = Arrays.asList(originalCodeText.split("\\r?\\n")); int[] lineFromTo = fuzzySearchOriginalCode(instanceId, filename, originalLines, originalCodeLine, contextLineFrom); if (lineFromTo[0] == -1 || lineFromTo[1] == -1) { @@ -464,24 +624,48 @@ private String applyChange(String instanceId, String filename, String fileHash, } lineFrom = lineFromTo[0] + 1; lineTo = lineFromTo[1] + 1; + fuzzyMatched = true; LOG.debug("Original code for remediation {} in {} matched at lines {}-{}", instanceId, filename, lineFrom, lineTo); } validateLineRange(lineFrom, lineTo, originalLines.size(), filename); - List newCodeLines = Arrays.asList(getRequiredElementText(change, "NewCode").split("\\r?\\n")); + List newCodeLines = Arrays.asList(newCodeText.split("\\r?\\n")); List updatedLines = new ArrayList<>(); updatedLines.addAll(originalLines.subList(0, lineFrom - 1)); updatedLines.addAll(newCodeLines); updatedLines.addAll(originalLines.subList(lineTo, originalLines.size())); LOG.debug("Staged remediation {} change {} for '{}' using FVDL encoding {}; updatedLines={}", instanceId, changeIndex, filename, sourceEncoding.name(), updatedLines.size()); - return String.join(lineSeparator, updatedLines); + + String updatedContent = String.join(lineSeparator, updatedLines); + ChangeDetail changeDetail = new ChangeDetail( + changeIndex, lineFrom, lineTo, originalCodeText, newCodeText, + contextBefore, contextAfter, contextText, fuzzyMatched); + + return new ChangeResult(updatedContent, changeDetail); + } + + private int parseIntAttribute(Element element, String attrName, int defaultValue) { + String value = element.getAttribute(attrName); + if (value == null || value.isEmpty()) { + return defaultValue; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + LOG.warn("Invalid {} attribute value '{}', using default {}", attrName, value, defaultValue); + return defaultValue; + } } private String getPendingOrSourceContent(Path filePath, String filename, Charset sourceEncoding, Map pendingWrites) { PendingFileWrite pendingWrite = pendingWrites.get(filePath); - return pendingWrite == null ? readSourceFile(filePath, filename, sourceEncoding) : pendingWrite.content(); + if (pendingWrite == null) { + return readSourceFile(filePath, filename, sourceEncoding); + } + // If there's a pending write, use its updated content (not original) for next change + return pendingWrite.content(); } private void commitRemediationWrites(String instanceId, Map pendingWrites, Set modifiedFiles) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ChangeDetail.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ChangeDetail.java new file mode 100644 index 0000000000..ea8f0e721b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ChangeDetail.java @@ -0,0 +1,48 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.processor.preview; + +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * Internal change detail captured during preview processing. + * Includes line numbers, code snippets, and context metadata. + * This is an internal representation that gets converted to FileChange for output. + * + * @param changeIndex 1-based index of this change + * @param lineFrom Starting line number (1-based) + * @param lineTo Ending line number (1-based) + * @param originalCode Code being replaced + * @param newCode Replacement code + * @param contextLinesBefore Number of context lines before the change + * @param contextLinesAfter Number of context lines after the change + * @param contextContent Full context text from remediations.xml + * @param fuzzyMatched Whether fuzzy matching was used + */ +@Reflectable +public record ChangeDetail( + int changeIndex, + int lineFrom, + int lineTo, + String originalCode, + String newCode, + int contextLinesBefore, + int contextLinesAfter, + String contextContent, + boolean fuzzyMatched) { + + public FileChange toFileChange() { + ContextMetadata context = new ContextMetadata(contextLinesBefore, contextLinesAfter, contextContent); + return new FileChange(changeIndex, lineFrom, lineTo, originalCode, newCode, context, fuzzyMatched); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadata.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadata.java new file mode 100644 index 0000000000..930075605a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadata.java @@ -0,0 +1,42 @@ +/* + * 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.fpr.processor.preview; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * Context metadata from the remediations XML, including before/after line counts + * and the full context text. Used for fuzzy matching when file hashes don't match. + * + * @param linesBefore Number of context lines before the changed code + * @param linesAfter Number of context lines after the changed code + * @param content Full context text as a single string (may contain newlines) + */ +@Reflectable +@JsonPropertyOrder({"linesBefore", "linesAfter", "content"}) +public record ContextMetadata( + int linesBefore, + int linesAfter, + String content) { + + public ContextMetadata { + if (linesBefore < 0) { + throw new IllegalArgumentException("ContextMetadata linesBefore must be non-negative"); + } + if (linesAfter < 0) { + throw new IllegalArgumentException("ContextMetadata linesAfter must be non-negative"); + } + content = content == null ? "" : content; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FileChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FileChange.java new file mode 100644 index 0000000000..b16a92ce10 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FileChange.java @@ -0,0 +1,52 @@ +/* + * 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.fpr.processor.preview; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * A single code change within a file remediation, with context metadata. + * Represents one transformation: replacing lines lineFrom-lineTo with newCode. + * + * @param changeIndex 1-based index of this change within the file (for ordering) + * @param lineFrom Starting line number (1-based, inclusive) + * @param lineTo Ending line number (1-based, inclusive) + * @param originalCode The code being replaced + * @param newCode The replacement code + * @param context Context lines surrounding the change (for validation) + * @param fuzzyMatched True if file hash didn't match and fuzzy context search was used + */ +@Reflectable +@JsonPropertyOrder({"changeIndex", "lineFrom", "lineTo", "originalCode", "newCode", "context", "fuzzyMatched"}) +public record FileChange( + int changeIndex, + int lineFrom, + int lineTo, + String originalCode, + String newCode, + ContextMetadata context, + boolean fuzzyMatched) { + + public FileChange { + if (changeIndex < 1) { + throw new IllegalArgumentException("FileChange changeIndex must be positive"); + } + if (lineFrom < 1 || lineTo < lineFrom) { + throw new IllegalArgumentException("FileChange invalid line range: " + lineFrom + "-" + lineTo); + } + if (context == null) { + throw new IllegalArgumentException("FileChange context is required"); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java new file mode 100644 index 0000000000..8bd7d5fab8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java @@ -0,0 +1,48 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.processor.preview; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * Preview information for a single file in a remediation. + * Contains metadata about the file and all code changes that would be applied. + * + * @param filename The relative filename as stored in FVDL (e.g., "src/Example.java") + * @param path The file path - kept as relative path for security (does not expose absolute filesystem paths) + * @param encoding The character encoding used to read/write the file (from FVDL metadata) + * @param changes List of individual code changes within this file + */ +@Reflectable +@JsonPropertyOrder({"filename", "path", "encoding", "changes"}) +public record FilePreview( + String filename, + String path, + String encoding, + List changes) { + + public FilePreview { + if (filename == null || filename.isBlank()) { + throw new IllegalArgumentException("FilePreview filename is required"); + } + changes = changes == null ? List.of() : Collections.unmodifiableList(List.copyOf(changes)); + } + + public int totalChanges() { + return changes.size(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetail.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetail.java new file mode 100644 index 0000000000..25fcaee293 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetail.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.processor.preview; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * Preview details for a single remediation (issue ID), containing all file changes. + * This record is serialized to JSON for IDE plugin consumption. + * + * @param issueId The issue/remediation ID from the remediations.xml file + * @param status Either "available" (successfully processed) or "skipped" (processing failed) + * @param files Map of filename to FilePreview objects containing change details + * @param skipReason Human-readable reason why remediation was skipped (null if status is "available") + */ +@Reflectable +@JsonPropertyOrder({"issueId", "status", "files", "skipReason"}) +public record PreviewDetail( + String issueId, + String status, + Map files, + String skipReason) { + + public PreviewDetail { + if (issueId == null || issueId.isBlank()) { + throw new IllegalArgumentException("PreviewDetail issueId is required"); + } + if (status == null || status.isBlank()) { + throw new IllegalArgumentException("PreviewDetail status is required"); + } + files = files == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(files)); + } + + public static PreviewDetail available(String issueId, Map files) { + return new PreviewDetail(issueId, "available", files, null); + } + + public static PreviewDetail skipped(String issueId, String skipReason) { + return new PreviewDetail(issueId, "skipped", Map.of(), skipReason); + } + + public boolean isAvailable() { + return "available".equals(status); + } + + public boolean isSkipped() { + return "skipped".equals(status); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java new file mode 100644 index 0000000000..bdf6a2d4ac --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java @@ -0,0 +1,105 @@ +/* + * 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.fpr.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; + +/** + * Tests for edge cases in RemediationProcessor and RemediationMetric. + */ +class RemediationProcessorEdgeCasesTest { + + @Test + void emptyRemediationsReturnsEmptyPreviewDetails() { + RemediationMetric metric = RemediationMetric.unfiltered(0, 0, Set.of(), java.util.Map.of(), java.util.List.of()); + + assertNotNull(metric); + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertNotNull(metric.previewDetails()); + assertEquals(0, metric.previewDetails().size()); + } + + @Test + void nonExistentIssueIdIsTrackedAsRequested() { + Set requestedIds = Set.of("ISSUE-1", "NONEXISTENT-123"); + Set appliedIds = Set.of("ISSUE-1"); + + RemediationMetric metric = RemediationMetric.filtered(requestedIds, appliedIds, Set.of("file.java")); + + assertNotNull(metric); + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertTrue(metric.requestedIssueIds().contains("NONEXISTENT-123")); + } + + @Test + void filteredMetricWithAllIdsAppliedHasNoSkips() { + Set requestedIds = Set.of("ISSUE-1", "ISSUE-2"); + Set appliedIds = Set.of("ISSUE-1", "ISSUE-2"); + + RemediationMetric metric = RemediationMetric.filtered(requestedIds, appliedIds, Set.of("file.java")); + + assertEquals(2, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + } + + @Test + void unfilteredMetricWithNoRemediationsHasZeroTotals() { + RemediationMetric metric = RemediationMetric.unfiltered(0, 0, Set.of()); + + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(0, metric.modifiedFiles().size()); + } + + @Test + void filteredModeDoesNotIncludeUnfilteredFields() { + Set requestedIds = Set.of("ISSUE-1"); + Set appliedIds = Set.of("ISSUE-1"); + + RemediationMetric metric = RemediationMetric.filtered(requestedIds, appliedIds, Set.of()); + + assertTrue(metric.isFiltered()); + assertNotNull(metric.requestedIssueIds()); + assertNotNull(metric.appliedIssueIds()); + } + + @Test + void unfilteredModeHasEmptyIssueIdSets() { + RemediationMetric metric = RemediationMetric.unfiltered(5, 3, Set.of("file.java")); + + assertEquals(false, metric.isFiltered()); + assertEquals(0, metric.requestedIssueIds().size()); + assertEquals(0, metric.appliedIssueIds().size()); + } + + @Test + void previewDetailsCanBeNull() { + RemediationMetric metric = RemediationMetric.unfiltered(5, 3, Set.of(), java.util.Map.of(), null); + + // Null preview details is acceptable (non-preview mode) + assertEquals(null, metric.previewDetails()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadataTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadataTest.java new file mode 100644 index 0000000000..10ad05064d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadataTest.java @@ -0,0 +1,72 @@ +/* + * 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.fpr.processor.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Tests for ContextMetadata record validation. + */ +class ContextMetadataTest { + + @Test + void validContextMetadataCreatedCorrectly() { + ContextMetadata metadata = new ContextMetadata(2, 3, "context content"); + + assertNotNull(metadata); + assertEquals(2, metadata.linesBefore()); + assertEquals(3, metadata.linesAfter()); + assertEquals("context content", metadata.content()); + } + + @Test + void zeroLinesBeforeAndAfterIsValid() { + ContextMetadata metadata = new ContextMetadata(0, 0, "content"); + + assertNotNull(metadata); + assertEquals(0, metadata.linesBefore()); + assertEquals(0, metadata.linesAfter()); + } + + @Test + void negativeLineBeforeThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new ContextMetadata(-1, 2, "content")); + } + + @Test + void negativeLinesAfterThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new ContextMetadata(2, -1, "content")); + } + + @Test + void nullContentConvertedToEmptyString() { + ContextMetadata metadata = new ContextMetadata(1, 1, null); + + assertNotNull(metadata.content()); + assertEquals("", metadata.content()); + } + + @Test + void emptyContentIsValid() { + ContextMetadata metadata = new ContextMetadata(0, 0, ""); + + assertNotNull(metadata); + assertEquals("", metadata.content()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FileChangeTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FileChangeTest.java new file mode 100644 index 0000000000..a3f40385de --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FileChangeTest.java @@ -0,0 +1,92 @@ +/* + * 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.fpr.processor.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Tests for FileChange record validation. + */ +class FileChangeTest { + + @Test + void validFileChangeCreatedCorrectly() { + ContextMetadata context = new ContextMetadata(2, 2, "context line"); + FileChange change = new FileChange(1, 10, 12, "old code", "new code", context, false); + + assertNotNull(change); + assertEquals(1, change.changeIndex()); + assertEquals(10, change.lineFrom()); + assertEquals(12, change.lineTo()); + assertEquals("old code", change.originalCode()); + assertEquals("new code", change.newCode()); + assertEquals(context, change.context()); + assertFalse(change.fuzzyMatched()); + } + + @Test + void changeIndexZeroThrowsException() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + assertThrows(IllegalArgumentException.class, + () -> new FileChange(0, 10, 12, "old", "new", context, false)); + } + + @Test + void changeIndexNegativeThrowsException() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + assertThrows(IllegalArgumentException.class, + () -> new FileChange(-1, 10, 12, "old", "new", context, false)); + } + + @Test + void lineFromZeroThrowsException() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + assertThrows(IllegalArgumentException.class, + () -> new FileChange(1, 0, 12, "old", "new", context, false)); + } + + @Test + void lineFromNegativeThrowsException() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + assertThrows(IllegalArgumentException.class, + () -> new FileChange(1, -1, 12, "old", "new", context, false)); + } + + @Test + void lineToLessThanLineFromThrowsException() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + assertThrows(IllegalArgumentException.class, + () -> new FileChange(1, 12, 10, "old", "new", context, false)); + } + + @Test + void lineToEqualToLineFromIsValid() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + FileChange change = new FileChange(1, 10, 10, "old", "new", context, false); + + assertNotNull(change); + assertEquals(10, change.lineFrom()); + assertEquals(10, change.lineTo()); + } + + @Test + void nullContextThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new FileChange(1, 10, 12, "old", "new", null, false)); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java new file mode 100644 index 0000000000..323a3432d9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java @@ -0,0 +1,80 @@ +/* + * 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.fpr.processor.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Tests for FilePreview record validation. + */ +class FilePreviewTest { + + @Test + void validFilePreviewCreatedCorrectly() { + FilePreview preview = new FilePreview("Example.java", "/path/to/Example.java", "UTF-8", List.of()); + + assertNotNull(preview); + assertEquals("Example.java", preview.filename()); + assertEquals("/path/to/Example.java", preview.path()); + assertEquals("UTF-8", preview.encoding()); + assertEquals(0, preview.totalChanges()); + } + + @Test + void nullFilenameThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new FilePreview(null, "/path", "UTF-8", List.of())); + } + + @Test + void blankFilenameThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new FilePreview(" ", "/path", "UTF-8", List.of())); + } + + @Test + void nullChangesListIsConvertedToEmptyList() { + FilePreview preview = new FilePreview("Test.java", "/path", "UTF-8", null); + assertNotNull(preview.changes()); + assertEquals(0, preview.changes().size()); + } + + @Test + void changesListIsUnmodifiable() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + FileChange change = new FileChange(1, 10, 12, "old", "new", context, false); + FilePreview preview = new FilePreview("Test.java", "/path", "UTF-8", List.of(change)); + + assertThrows(UnsupportedOperationException.class, + () -> preview.changes().add(new FileChange(2, 20, 22, "old2", "new2", context, false))); + } + + @Test + void totalChangesReturnsCorrectCount() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + List changes = List.of( + new FileChange(1, 10, 12, "old1", "new1", context, false), + new FileChange(2, 20, 22, "old2", "new2", context, false), + new FileChange(3, 30, 32, "old3", "new3", context, false) + ); + FilePreview preview = new FilePreview("Test.java", "/path", "UTF-8", changes); + + assertEquals(3, preview.totalChanges()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetailTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetailTest.java new file mode 100644 index 0000000000..d2bd5420a6 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetailTest.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.fpr.processor.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +/** + * Tests for PreviewDetail record validation and factory methods. + */ +class PreviewDetailTest { + + @Test + void availablePreviewDetailCreatedCorrectly() { + Map files = Map.of("Example.java", + new FilePreview("Example.java", "/path/to/Example.java", "UTF-8", java.util.List.of())); + PreviewDetail detail = PreviewDetail.available("ISSUE-123", files); + + assertNotNull(detail); + assertEquals("ISSUE-123", detail.issueId()); + assertEquals("available", detail.status()); + assertEquals(1, detail.files().size()); + assertEquals(null, detail.skipReason()); + assertTrue(detail.isAvailable()); + } + + @Test + void skippedPreviewDetailCreatedCorrectly() { + PreviewDetail detail = PreviewDetail.skipped("ISSUE-456", "Source file missing"); + + assertNotNull(detail); + assertEquals("ISSUE-456", detail.issueId()); + assertEquals("skipped", detail.status()); + assertEquals(0, detail.files().size()); + assertEquals("Source file missing", detail.skipReason()); + assertTrue(detail.isSkipped()); + } + + @Test + void nullIssueIdThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new PreviewDetail(null, "available", Map.of(), null)); + } + + @Test + void blankIssueIdThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new PreviewDetail("", "available", Map.of(), null)); + } + + @Test + void nullStatusThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new PreviewDetail("ISSUE-1", null, Map.of(), null)); + } + + @Test + void blankStatusThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new PreviewDetail("ISSUE-1", " ", Map.of(), null)); + } + + @Test + void nullFilesMapIsConvertedToEmptyMap() { + PreviewDetail detail = new PreviewDetail("ISSUE-1", "available", null, null); + assertNotNull(detail.files()); + assertEquals(0, detail.files().size()); + } + + @Test + void filesMapIsUnmodifiable() { + Map files = new java.util.LinkedHashMap<>(); + files.put("Test.java", new FilePreview("Test.java", "/path", "UTF-8", java.util.List.of())); + PreviewDetail detail = new PreviewDetail("ISSUE-1", "available", files, null); + + assertThrows(UnsupportedOperationException.class, + () -> detail.files().put("Another.java", new FilePreview("Another.java", "/path2", "UTF-8", java.util.List.of()))); + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java index bfcb110062..488201ddbe 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java @@ -60,6 +60,8 @@ public class AviatorSSCApplyRemediationsCommand extends AbstractOutputCommand private String sourceCodeDirectory = System.getProperty("user.dir"); @Option(names = {"--issue-ids"}, split = ",") private List issueIds; + @Option(names = {"--preview"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.preview") + private boolean previewMode = false; @Override public JsonNode getJsonNode() { @@ -82,12 +84,13 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF sourceSelector.getFromCache(), RemediationsCacheConstants.PRODUCT_SSC)) { ApplyResult applyResult = RemediationsApplyHelper.apply( - source, sourceCodeDirectory, logger, issueIdFilter, LOG); + source, sourceCodeDirectory, logger, issueIdFilter, LOG, previewMode); return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( sourceSelector.getFromCache(), applyResult, issueIdFilter, - source.reader().getManifest().getSelection()); + source.reader().getManifest().getSelection(), + previewMode); } } @@ -100,9 +103,9 @@ private JsonNode processOnline( try (SSCOnlineRemediationsFprSource source = new SSCOnlineRemediationsFprSource( unirest, logger, progressWriter, resolved.artifacts())) { ApplyResult applyResult = RemediationsApplyHelper.apply( - source, sourceCodeDirectory, logger, issueIdFilter, LOG); + source, sourceCodeDirectory, logger, issueIdFilter, LOG, previewMode); return AviatorSSCApplyRemediationsHelper.buildOnlineResultNode( - resolved.artifacts(), resolved.appVersionId(), applyResult, issueIdFilter); + resolved.artifacts(), resolved.appVersionId(), applyResult, issueIdFilter, previewMode); } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java index ce0be7483f..f400f051d9 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java @@ -36,13 +36,23 @@ public static ObjectNode buildOnlineResultNode( String appVersionId, ApplyResult applyResult, Set issueIdFilter) { + return buildOnlineResultNode(artifacts, appVersionId, applyResult, issueIdFilter, false); + } + + public static ObjectNode buildOnlineResultNode( + List artifacts, + String appVersionId, + ApplyResult applyResult, + Set issueIdFilter, + boolean previewMode) { RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( issueIdFilter, applyResult.metrics()); return buildCommonNode( resolveAppVersionId(artifacts, appVersionId), resolveSingleArtifactId(artifacts, applyResult), applyResult, - aggregated); + aggregated, + previewMode); } public static ObjectNode buildCacheResultNode( @@ -50,10 +60,19 @@ public static ObjectNode buildCacheResultNode( ApplyResult applyResult, Set issueIdFilter, Map selection) { + return buildCacheResultNode(cacheZip, applyResult, issueIdFilter, selection, false); + } + + public static ObjectNode buildCacheResultNode( + Path cacheZip, + ApplyResult applyResult, + Set issueIdFilter, + Map selection, + boolean previewMode) { RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( issueIdFilter, applyResult.metrics()); String appVersionId = selection != null ? selection.get("appVersionId") : null; - ObjectNode result = buildCommonNode(appVersionId, null, applyResult, aggregated); + ObjectNode result = buildCommonNode(appVersionId, null, applyResult, aggregated, previewMode); AviatorRemediationMetricsHelper.putCacheExtras( result, cacheZip, applyResult.processedEntries(), "artifactIds", applyResult.processedIds()); return result; @@ -63,13 +82,15 @@ private static ObjectNode buildCommonNode( String appVersionId, String artifactId, ApplyResult applyResult, - RemediationMetric aggregated) { + RemediationMetric aggregated, + boolean previewMode) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("appVersionId", AviatorRemediationMetricsHelper.na(appVersionId)); result.put("artifactId", AviatorRemediationMetricsHelper.na(artifactId)); result.put("artifactsProcessed", applyResult.metrics().size()); result.put("artifactsSkipped", applyResult.skipped()); - AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated); + result.put("previewMode", previewMode); + AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated, previewMode); return result; } diff --git a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties index 577c89836a..a19d2d36d5 100644 --- a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties +++ b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties @@ -175,6 +175,9 @@ fcli.aviator.ssc.apply-remediations.usage.description = Downloads FPR artifact(s %n fcli aviator ssc apply-remediations --av "MyApp:1.0" --latest --source-dir /path/to/src fcli.aviator.ssc.apply-remediations.from-cache = Local remediations cache zip produced by download-remediations-cache. Mutually exclusive with online selection options. Does not require an SSC session. fcli.aviator.ssc.apply-remediations.source-dir = Source code directory where remediations will be applied. Defaults to current directory. +fcli.aviator.ssc.apply-remediations.preview = Dry-run mode: validates all remediations, reads source files for verification, and outputs detailed change information without modifying any files. \ + Performs full processing including file reading, encoding validation, and change preparation. Output includes a previewDetails array with per-issue change information. \ + Compatible with both --from-cache (offline) and online artifact selection. Works with --issue-ids to preview specific remediations. fcli.aviator.ssc.apply-remediations.issue-ids = Comma-separated list of issue IDs to apply. Matches requested values against remediations.xml \ instanceId values. Requires --from-cache so integrations can download once via download-remediations-cache and apply selected remediations without repeated SSC downloads. # Shared by download-remediations-cache and apply-remediations online selection (AviatorSSCRemediationsSelectorArgGroups). diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java index 4e2b9608c0..26f13bc630 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -18,6 +18,7 @@ import java.lang.reflect.Field; import java.nio.file.Path; +import java.util.List; import org.junit.jupiter.api.Test; @@ -50,6 +51,36 @@ void issueIdsRequireFromCache() { assertThrows(FcliSimpleException.class, command::getJsonNode); } + @Test + void previewFlagParsedCorrectly() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "remediations.zip", "--preview"); + Field previewField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("previewMode"); + previewField.setAccessible(true); + assertTrue((Boolean) previewField.get(command)); + } + + @Test + void previewWorksWithIssueIds() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--preview", "--issue-ids", "ISSUE-1,ISSUE-2"); + Field previewField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("previewMode"); + previewField.setAccessible(true); + assertTrue((Boolean) previewField.get(command)); + + Field issueIdsField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("issueIds"); + issueIdsField.setAccessible(true); + @SuppressWarnings("unchecked") + List issueIds = (List) issueIdsField.get(command); + assertEquals(2, issueIds.size()); + } + + @Test + void previewWorksWithOnlineSelection() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--artifact-id", "123", "--preview"); + Field previewField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("previewMode"); + previewField.setAccessible(true); + assertTrue((Boolean) previewField.get(command)); + } + private static AviatorSSCApplyRemediationsCommand parse(String... args) { AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); new CommandLine(command).parseArgs(args); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java index 3cd48ea1b9..7d1f3c51a8 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java @@ -60,6 +60,8 @@ public class FoDAviatorApplyRemediationsCommand extends AbstractOutputCommand private String sourceCodeDirectory = System.getProperty("user.dir"); @Option(names = {"--issue-ids"}, split = ",") private List issueIds; + @Option(names = {"--preview"}, descriptionKey = "fcli.fod.aviator.apply-remediations.preview") + private boolean previewMode = false; @Override public JsonNode getJsonNode() { @@ -82,8 +84,8 @@ private JsonNode processOnline(AviatorLoggerImpl logger, Set issueIdFilt try (FoDOnlineRemediationsFprSource source = new FoDOnlineRemediationsFprSource(unirest, logger, release)) { ApplyResult applyResult = RemediationsApplyHelper.apply( - source, sourceCodeDirectory, logger, issueIdFilter, LOG); - return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(release, applyResult); + source, sourceCodeDirectory, logger, issueIdFilter, LOG, previewMode); + return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(release, applyResult, previewMode); } } @@ -92,9 +94,9 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF sourceSelector.getFromCache(), RemediationsCacheConstants.PRODUCT_FOD)) { ApplyResult applyResult = RemediationsApplyHelper.apply( - source, sourceCodeDirectory, logger, issueIdFilter, LOG); + source, sourceCodeDirectory, logger, issueIdFilter, LOG, previewMode); return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( - sourceSelector.getFromCache(), applyResult, issueIdFilter); + sourceSelector.getFromCache(), applyResult, issueIdFilter, previewMode); } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java index 86c000873c..77999610b0 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java @@ -31,22 +31,32 @@ public final class AviatorFoDApplyRemediationsHelper { private AviatorFoDApplyRemediationsHelper() {} public static ObjectNode buildOnlineResultNode(FoDReleaseDescriptor releaseDescriptor, ApplyResult applyResult) { + return buildOnlineResultNode(releaseDescriptor, applyResult, false); + } + + public static ObjectNode buildOnlineResultNode(FoDReleaseDescriptor releaseDescriptor, ApplyResult applyResult, boolean previewMode) { RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( null, applyResult.metrics()); return buildCommonNode( releaseDescriptor.getReleaseId(), releaseDescriptor.getApplicationName(), releaseDescriptor.getReleaseName(), - aggregated); + aggregated, + previewMode); } public static ObjectNode buildCacheResultNode( Path cacheZip, ApplyResult applyResult, Set issueIdFilter) { + return buildCacheResultNode(cacheZip, applyResult, issueIdFilter, false); + } + + public static ObjectNode buildCacheResultNode( + Path cacheZip, ApplyResult applyResult, Set issueIdFilter, boolean previewMode) { RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( issueIdFilter, applyResult.metrics()); List releaseIds = applyResult.processedIds(); String releaseId = releaseIds != null && !releaseIds.isEmpty() ? releaseIds.get(0) : null; - ObjectNode result = buildCommonNode(releaseId, null, null, aggregated); + ObjectNode result = buildCommonNode(releaseId, null, null, aggregated, previewMode); AviatorRemediationMetricsHelper.putCacheExtras( result, cacheZip, applyResult.processedEntries(), "releaseIds", releaseIds); return result; @@ -56,12 +66,14 @@ private static ObjectNode buildCommonNode( String releaseId, String applicationName, String releaseName, - RemediationMetric aggregated) { + RemediationMetric aggregated, + boolean previewMode) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("releaseId", AviatorRemediationMetricsHelper.na(releaseId)); result.put("applicationName", AviatorRemediationMetricsHelper.na(applicationName)); result.put("releaseName", AviatorRemediationMetricsHelper.na(releaseName)); - AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated); + result.put("previewMode", previewMode); + AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated, previewMode); return result; } } diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index 282a14648b..d1dae5197a 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -1041,6 +1041,9 @@ fcli.fod.aviator.apply-remediations.usage.description = Downloads the FPR from a Exactly one of --release/--rel or --from-cache must be specified. Online selection requires an FoD session; --from-cache does not. fcli.fod.aviator.apply-remediations.source-dir = Directory containing source code to apply remediations to. Default value: ${DEFAULT-VALUE}. fcli.fod.aviator.apply-remediations.from-cache = Local remediations cache zip produced by download-remediations-cache. Mutually exclusive with --release/--rel. Does not require an FoD session. +fcli.fod.aviator.apply-remediations.preview = Dry-run mode: validates all remediations, reads source files for verification, and outputs detailed change information without modifying any files. \ + Performs full processing including file reading, encoding validation, and change preparation. Output includes a previewDetails array with per-issue change information. \ + Compatible with both --from-cache (offline) and online release selection. Works with --issue-ids to preview specific remediations. fcli.fod.aviator.apply-remediations.issue-ids = Comma-separated list of issue IDs to apply. Matches requested values against remediations.xml \ instanceId entries. Requires --from-cache so integrations can download once via download-remediations-cache and apply selected remediations without repeated FoD downloads. fcli.fod.aviator.download-remediations-cache.usage.header = Download a remediations cache zip containing Fortify Remediation Aviator remediations from FoD. diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java index f1dedd1956..04df0ba283 100644 --- a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java @@ -18,6 +18,7 @@ import java.lang.reflect.Field; import java.nio.file.Path; +import java.util.List; import org.junit.jupiter.api.Test; @@ -64,6 +65,36 @@ void blankSourceDirIsRejected() throws Exception { assertThrows(FcliSimpleException.class, command::getJsonNode); } + @Test + void previewFlagParsedCorrectly() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "remediations.zip", "--preview"); + Field previewField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("previewMode"); + previewField.setAccessible(true); + assertTrue((Boolean) previewField.get(command)); + } + + @Test + void previewWorksWithIssueIds() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--preview", "--issue-ids", "ISSUE-1,ISSUE-2"); + Field previewField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("previewMode"); + previewField.setAccessible(true); + assertTrue((Boolean) previewField.get(command)); + + Field issueIdsField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("issueIds"); + issueIdsField.setAccessible(true); + @SuppressWarnings("unchecked") + List issueIds = (List) issueIdsField.get(command); + assertEquals(2, issueIds.size()); + } + + @Test + void previewWorksWithOnlineSelection() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--release", "123", "--preview"); + Field previewField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("previewMode"); + previewField.setAccessible(true); + assertTrue((Boolean) previewField.get(command)); + } + private static FoDAviatorApplyRemediationsCommand parse(String... args) { FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); new CommandLine(command).parseArgs(args); From 205ecde3105f323966e3c5bd97d2b50ca1d4bd26 Mon Sep 17 00:00:00 2001 From: Dhanwanth Pratheep Date: Fri, 21 Aug 2026 13:09:21 +0530 Subject: [PATCH 2/3] fix: updated the output of --preview flag for apply-remediations command to display the correct value for __action__ --- .../remediations_cache/RemediationsApplyHelper.java | 6 +++--- .../_common/util/AviatorRemediationMetricsHelper.java | 10 +++++++--- .../cli/cmd/AviatorSSCApplyRemediationsCommand.java | 4 ++-- .../cmd/FoDAviatorApplyRemediationsCommand.java | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java index ff7c0dcd58..bb874b520f 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java @@ -86,9 +86,9 @@ public static ApplyResult apply( return acc.toResult(); } - /** @see AviatorRemediationMetricsHelper#actionLabel(RemediationMetric) */ - public static String actionLabel(RemediationMetric metric) { - return AviatorRemediationMetricsHelper.actionLabel(metric); + /** @see AviatorRemediationMetricsHelper#actionLabel(RemediationMetric, boolean) */ + public static String actionLabel(RemediationMetric metric, boolean previewMode) { + return AviatorRemediationMetricsHelper.actionLabel(metric, previewMode); } private static RemediationMetric applyOne( diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java index 10a2d82db3..7fae2053d7 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java @@ -102,8 +102,12 @@ public static String formatSkippedReasons(Map skippedByReason) return String.join(", ", parts); } - public static String actionLabel(RemediationMetric metric) { - return metric != null && metric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + public static String actionLabel(RemediationMetric metric, boolean previewMode) { + if (metric != null && metric.appliedRemediations() > 0) { + return previewMode ? "Remediation-Previewed" : "Remediation-Applied"; + } else { + return previewMode ? "No-Remediation-Previewed" : "No-Remediation-Applied"; + } } public static String na(String value) { @@ -137,7 +141,7 @@ public static void putMetricAndAction(ObjectNode result, RemediationMetric metri /** Metric fields plus {@code __action__} and optional preview details (shared by SSC/FoD result builders). */ public static void putMetricAndAction(ObjectNode result, RemediationMetric metric, boolean previewMode) { putRemediationMetricFields(result, metric); - result.put(IActionCommandResultSupplier.actionFieldName, actionLabel(metric)); + result.put(IActionCommandResultSupplier.actionFieldName, actionLabel(metric, previewMode)); if (previewMode && metric != null && metric.previewDetails() != null) { result.set("previewDetails", toPreviewDetailsArray(metric.previewDetails())); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java index 488201ddbe..9af1c55b52 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java @@ -116,8 +116,8 @@ public boolean isSingular() { @Override public String getActionCommandResult() { - // Fallback only if result JSON has no __action__; helpers set Remediation-Applied / No-Remediation-Applied. - return "Remediation-Applied"; + // Fallback only if result JSON has no __action__; helpers set the appropriate action based on previewMode. + return previewMode ? "Remediation-Previewed" : "Remediation-Applied"; } @Override diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java index 7d1f3c51a8..2d29195d84 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java @@ -107,7 +107,7 @@ public boolean isSingular() { @Override public String getActionCommandResult() { - return "Remediation-Applied"; + return previewMode ? "Remediation-Previewed" : "Remediation-Applied"; } @Override From 7bcba4a602d79abff1fac37511ec922c9db72f5e Mon Sep 17 00:00:00 2001 From: Dhanwanth Pratheep Date: Mon, 24 Aug 2026 01:48:36 +0530 Subject: [PATCH 3/3] fix: Changed preview to a real type, reordered some of the output metrics for better readability, removed a redundant field and added tests for code that was not covered before. --- .../RemediationsApplyHelper.java | 6 +- .../util/AviatorRemediationMetricsHelper.java | 38 ++-- .../fpr/processor/RemediationProcessor.java | 169 +++++++++----- .../fpr/processor/preview/FilePreview.java | 10 +- .../fpr/processor/preview/PreviewDetail.java | 2 +- .../AviatorRemediationMetricsHelperTest.java | 24 ++ .../RemediationProcessorEdgeCasesTest.java | 14 +- .../processor/RemediationProcessorTest.java | 215 ++++++++++++++++++ .../processor/preview/FilePreviewTest.java | 17 +- .../processor/preview/PreviewDetailTest.java | 6 +- .../AviatorSSCApplyRemediationsCommand.java | 7 +- .../AviatorSSCApplyRemediationsHelper.java | 30 +-- .../FoDAviatorApplyRemediationsCommand.java | 4 +- .../AviatorFoDApplyRemediationsHelper.java | 21 +- 14 files changed, 406 insertions(+), 157 deletions(-) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java index bb874b520f..ff7c0dcd58 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java @@ -86,9 +86,9 @@ public static ApplyResult apply( return acc.toResult(); } - /** @see AviatorRemediationMetricsHelper#actionLabel(RemediationMetric, boolean) */ - public static String actionLabel(RemediationMetric metric, boolean previewMode) { - return AviatorRemediationMetricsHelper.actionLabel(metric, previewMode); + /** @see AviatorRemediationMetricsHelper#actionLabel(RemediationMetric) */ + public static String actionLabel(RemediationMetric metric) { + return AviatorRemediationMetricsHelper.actionLabel(metric); } private static RemediationMetric applyOne( diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java index 7fae2053d7..b5501c0d2b 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java @@ -42,14 +42,16 @@ public static RemediationMetric aggregateMetrics(Set requestedIssueIds, Map skippedByReason = new LinkedHashMap<>(); List allPreviewDetails = new ArrayList<>(); Collection safeMetrics = metrics == null ? List.of() : metrics; - + boolean previewMode = false; + // Aggregate preview details from all metrics for (RemediationMetric metric : safeMetrics) { - if (metric.previewDetails() != null) { - allPreviewDetails.addAll(metric.previewDetails()); + if (metric instanceof RemediationMetric.Preview preview) { + previewMode = true; + allPreviewDetails.addAll(preview.previewDetails()); } } - + if (requestedIssueIds == null) { int totalRemediations = 0; int appliedRemediations = 0; @@ -58,16 +60,18 @@ public static RemediationMetric aggregateMetrics(Set requestedIssueIds, appliedRemediations += metric.appliedRemediations(); accumulateFilesAndSkips(metric, modifiedFiles, skippedByReason); } - return RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason, - allPreviewDetails.isEmpty() ? null : allPreviewDetails); + return previewMode + ? RemediationMetric.previewUnfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason, allPreviewDetails) + : RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason); } Set appliedIssueIds = new LinkedHashSet<>(); for (RemediationMetric metric : safeMetrics) { appliedIssueIds.addAll(metric.appliedIssueIds()); accumulateFilesAndSkips(metric, modifiedFiles, skippedByReason); } - return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, - allPreviewDetails.isEmpty() ? null : allPreviewDetails); + return previewMode + ? RemediationMetric.previewFiltered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, allPreviewDetails) + : RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason); } private static void accumulateFilesAndSkips( @@ -102,7 +106,8 @@ public static String formatSkippedReasons(Map skippedByReason) return String.join(", ", parts); } - public static String actionLabel(RemediationMetric metric, boolean previewMode) { + public static String actionLabel(RemediationMetric metric) { + boolean previewMode = metric instanceof RemediationMetric.Preview; if (metric != null && metric.appliedRemediations() > 0) { return previewMode ? "Remediation-Previewed" : "Remediation-Applied"; } else { @@ -133,18 +138,13 @@ public static void putRemediationMetricFields(ObjectNode result, RemediationMetr result.set("modifiedFiles", toArrayNode(modifiedFiles)); } - /** Metric fields plus {@code __action__} (shared by SSC/FoD result builders). */ + /** Metric fields plus {@code __action__} and, for preview results, preview details (shared by SSC/FoD result builders). */ public static void putMetricAndAction(ObjectNode result, RemediationMetric metric) { - putMetricAndAction(result, metric, false); - } - - /** Metric fields plus {@code __action__} and optional preview details (shared by SSC/FoD result builders). */ - public static void putMetricAndAction(ObjectNode result, RemediationMetric metric, boolean previewMode) { putRemediationMetricFields(result, metric); - result.put(IActionCommandResultSupplier.actionFieldName, actionLabel(metric, previewMode)); - - if (previewMode && metric != null && metric.previewDetails() != null) { - result.set("previewDetails", toPreviewDetailsArray(metric.previewDetails())); + result.put(IActionCommandResultSupplier.actionFieldName, actionLabel(metric)); + + if (metric instanceof RemediationMetric.Preview preview) { + result.set("previewDetails", toPreviewDetailsArray(preview.previewDetails())); } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java index bba3056af1..2353a46224 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java @@ -74,91 +74,136 @@ public class RemediationProcessor { /** * Apply-remediations summary. Mode is explicit: unfiltered counts XML remediations; * filtered counts requested issue IDs. Factories are the only public construction path. - * - *

Preview Details: The {@code previewDetails} field is null when not in preview mode, - * and non-null (possibly empty list) when in preview mode. Callers should check both - * {@code previewMode} flag (from context) and null status before accessing preview data. - * This design avoids Optional in record fields (per style guidelines) while maintaining - * clear semantics: null = not applicable, empty list = no preview data available.

- * - * @param previewDetails Detailed change information per issue (null if not in preview mode) + * + *

Sealed so preview vs. apply is a compile-time-checked type distinction rather than + * a nullable {@code previewDetails} field paired with an out-of-band {@code previewMode} + * flag: {@link Preview#previewDetails()} is always non-null (possibly empty); {@link Applied} + * carries no preview data at all.

*/ - public record RemediationMetric( - Mode mode, - int totalRemediations, - int appliedRemediations, - int skippedRemediations, - Set modifiedFiles, - Map skippedByReason, - Set requestedIssueIds, - Set appliedIssueIds, - List previewDetails) { - - public enum Mode { + public sealed interface RemediationMetric { + Mode mode(); + int totalRemediations(); + int appliedRemediations(); + int skippedRemediations(); + Set modifiedFiles(); + Map skippedByReason(); + Set requestedIssueIds(); + Set appliedIssueIds(); + + enum Mode { UNFILTERED, FILTERED } - public RemediationMetric { - if (mode == null) { - throw new IllegalArgumentException("RemediationMetric mode is required"); + default boolean isFiltered() { + return mode() == Mode.FILTERED; + } + + record Applied( + Mode mode, + int totalRemediations, + int appliedRemediations, + int skippedRemediations, + Set modifiedFiles, + Map skippedByReason, + Set requestedIssueIds, + Set appliedIssueIds) implements RemediationMetric { + + public Applied { + mode = requireMode(mode); + modifiedFiles = immutableCopy(modifiedFiles); + skippedByReason = immutableSkippedByReason(skippedByReason); + Set[] issueIds = immutableIssueIds(mode, requestedIssueIds, appliedIssueIds); + requestedIssueIds = issueIds[0]; + appliedIssueIds = issueIds[1]; } - modifiedFiles = immutableCopy(modifiedFiles); - // Preserve insertion order (LinkedHashMap) for stable skippedReasons table text. - skippedByReason = skippedByReason == null || skippedByReason.isEmpty() - ? Map.of() - : Collections.unmodifiableMap(new LinkedHashMap<>(skippedByReason)); - if (mode == Mode.UNFILTERED) { - requestedIssueIds = Set.of(); - appliedIssueIds = Set.of(); - } else { - requestedIssueIds = immutableCopy(requestedIssueIds); - appliedIssueIds = immutableCopy(appliedIssueIds); + } + + /** @param previewDetails Detailed change information per issue; always non-null (empty if no data). */ + record Preview( + Mode mode, + int totalRemediations, + int appliedRemediations, + int skippedRemediations, + Set modifiedFiles, + Map skippedByReason, + Set requestedIssueIds, + Set appliedIssueIds, + List previewDetails) implements RemediationMetric { + + public Preview { + mode = requireMode(mode); + modifiedFiles = immutableCopy(modifiedFiles); + skippedByReason = immutableSkippedByReason(skippedByReason); + Set[] issueIds = immutableIssueIds(mode, requestedIssueIds, appliedIssueIds); + requestedIssueIds = issueIds[0]; + appliedIssueIds = issueIds[1]; + previewDetails = previewDetails == null + ? List.of() + : Collections.unmodifiableList(List.copyOf(previewDetails)); } - previewDetails = previewDetails == null ? null : Collections.unmodifiableList(List.copyOf(previewDetails)); } - public static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles) { - return unfiltered(totalRemediations, appliedRemediations, modifiedFiles, Map.of(), null); + static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles) { + return unfiltered(totalRemediations, appliedRemediations, modifiedFiles, Map.of()); } - public static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles, + static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles, Map skippedByReason) { - return unfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason, null); + return new Applied(Mode.UNFILTERED, totalRemediations, appliedRemediations, + totalRemediations - appliedRemediations, modifiedFiles, skippedByReason, Set.of(), Set.of()); } - public static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles, + static RemediationMetric previewUnfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles, Map skippedByReason, List previewDetails) { - return new RemediationMetric(Mode.UNFILTERED, totalRemediations, appliedRemediations, + return new Preview(Mode.UNFILTERED, totalRemediations, appliedRemediations, totalRemediations - appliedRemediations, modifiedFiles, skippedByReason, Set.of(), Set.of(), previewDetails); } - public static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles) { - return filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, Map.of(), null); + static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles) { + return filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, Map.of()); } - public static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles, + static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles, Map skippedByReason) { - return filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, null); + Set requested = requestedIssueIds == null ? Set.of() : requestedIssueIds; + Set applied = appliedIssueIds == null ? Set.of() : appliedIssueIds; + return new Applied(Mode.FILTERED, requested.size(), applied.size(), + requested.size() - applied.size(), modifiedFiles, skippedByReason, requested, applied); } - public static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles, + static RemediationMetric previewFiltered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles, Map skippedByReason, List previewDetails) { Set requested = requestedIssueIds == null ? Set.of() : requestedIssueIds; Set applied = appliedIssueIds == null ? Set.of() : appliedIssueIds; - int totalRemediations = requested.size(); - int appliedRemediations = applied.size(); - return new RemediationMetric(Mode.FILTERED, totalRemediations, appliedRemediations, - totalRemediations - appliedRemediations, modifiedFiles, skippedByReason, requested, applied, previewDetails); + return new Preview(Mode.FILTERED, requested.size(), applied.size(), + requested.size() - applied.size(), modifiedFiles, skippedByReason, requested, applied, previewDetails); } - public boolean isFiltered() { - return mode == Mode.FILTERED; + private static Mode requireMode(Mode mode) { + if (mode == null) { + throw new IllegalArgumentException("RemediationMetric mode is required"); + } + return mode; } private static Set immutableCopy(Set values) { return values == null ? Set.of() : Collections.unmodifiableSet(new LinkedHashSet<>(values)); } + + private static Map immutableSkippedByReason(Map skippedByReason) { + // Preserve insertion order (LinkedHashMap) for stable skippedReasons table text. + return skippedByReason == null || skippedByReason.isEmpty() + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(skippedByReason)); + } + + @SuppressWarnings("unchecked") + private static Set[] immutableIssueIds(Mode mode, Set requestedIssueIds, Set appliedIssueIds) { + return mode == Mode.UNFILTERED + ? new Set[] {Set.of(), Set.of()} + : new Set[] {immutableCopy(requestedIssueIds), immutableCopy(appliedIssueIds)}; + } } private record FvdlMetadataResult(FVDLMetadata metadata, SkipReason skipReason) {} @@ -395,9 +440,6 @@ private void addChangeDetails(String instanceId, String filename, String encodin } private List buildPreviewDetails() { - if (!previewMode) { - return null; - } List details = new ArrayList<>(); // Add successfully processed remediations @@ -414,7 +456,7 @@ private List buildPreviewDetails() { .map(ChangeDetail::toFileChange) .collect(java.util.stream.Collectors.toUnmodifiableList()); - FilePreview filePreview = new FilePreview(filename, metadata.path, metadata.encoding, fileChanges); + FilePreview filePreview = new FilePreview(metadata.path, metadata.encoding, fileChanges); fileMap.put(filename, filePreview); } details.add(PreviewDetail.available(issueId, fileMap)); @@ -436,13 +478,16 @@ private List buildPreviewDetails() { private RemediationMetric toMetric() { // Validate requested issue IDs before building metric validateRequestedIssueIds(); - - List previewDetails = buildPreviewDetails(); - - if (requestedIssueIds == null) { - return RemediationMetric.unfiltered(xmlEntryCount, appliedRemediations, modifiedFiles, skippedByReason, previewDetails); + + if (!previewMode) { + return requestedIssueIds == null + ? RemediationMetric.unfiltered(xmlEntryCount, appliedRemediations, modifiedFiles, skippedByReason) + : RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason); } - return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, previewDetails); + List previewDetails = buildPreviewDetails(); + return requestedIssueIds == null + ? RemediationMetric.previewUnfiltered(xmlEntryCount, appliedRemediations, modifiedFiles, skippedByReason, previewDetails) + : RemediationMetric.previewFiltered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, previewDetails); } private void validateRequestedIssueIds() { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java index 8bd7d5fab8..7c144ed0eb 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java @@ -22,22 +22,20 @@ * Preview information for a single file in a remediation. * Contains metadata about the file and all code changes that would be applied. * - * @param filename The relative filename as stored in FVDL (e.g., "src/Example.java") - * @param path The file path - kept as relative path for security (does not expose absolute filesystem paths) + * @param path The relative file path as stored in FVDL (e.g., "src/Example.java") - kept relative for security (does not expose absolute filesystem paths) * @param encoding The character encoding used to read/write the file (from FVDL metadata) * @param changes List of individual code changes within this file */ @Reflectable -@JsonPropertyOrder({"filename", "path", "encoding", "changes"}) +@JsonPropertyOrder({"path", "encoding", "changes"}) public record FilePreview( - String filename, String path, String encoding, List changes) { public FilePreview { - if (filename == null || filename.isBlank()) { - throw new IllegalArgumentException("FilePreview filename is required"); + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("FilePreview path is required"); } changes = changes == null ? List.of() : Collections.unmodifiableList(List.copyOf(changes)); } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetail.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetail.java index 25fcaee293..cf045e4388 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetail.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetail.java @@ -29,7 +29,7 @@ * @param skipReason Human-readable reason why remediation was skipped (null if status is "available") */ @Reflectable -@JsonPropertyOrder({"issueId", "status", "files", "skipReason"}) +@JsonPropertyOrder({"issueId", "status", "files", "available", "skipped", "skipReason"}) public record PreviewDetail( String issueId, String status, diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java index 2ae98b384f..2bc938f58d 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java @@ -80,4 +80,28 @@ void remainingIssueIdsDropsAlreadyApplied() { Set.of("ISSUE-2"), AviatorRemediationMetricsHelper.getRemainingIssueIds(Set.of("ISSUE-1", "ISSUE-2"), metric)); } + + @Test + void aggregatingAnyPreviewMetricYieldsPreviewResultWithMergedDetails() { + RemediationMetric applied = RemediationMetric.unfiltered(1, 1, Set.of("A.java")); + RemediationMetric preview = RemediationMetric.previewUnfiltered(1, 0, Set.of(), Map.of(), + List.of(com.fortify.cli.aviator.fpr.processor.preview.PreviewDetail.skipped("ISSUE-2", "Source file missing"))); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(applied, preview)); + + assertTrue(aggregated instanceof RemediationMetric.Preview); + assertEquals(1, ((RemediationMetric.Preview) aggregated).previewDetails().size()); + } + + @Test + void aggregatingOnlyAppliedMetricsYieldsAppliedResult() { + RemediationMetric metricOne = RemediationMetric.unfiltered(1, 1, Set.of("A.java")); + RemediationMetric metricTwo = RemediationMetric.unfiltered(1, 0, Set.of()); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(metricOne, metricTwo)); + + assertTrue(aggregated instanceof RemediationMetric.Applied); + } } diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java index bdf6a2d4ac..a214248412 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java @@ -29,13 +29,13 @@ class RemediationProcessorEdgeCasesTest { @Test void emptyRemediationsReturnsEmptyPreviewDetails() { - RemediationMetric metric = RemediationMetric.unfiltered(0, 0, Set.of(), java.util.Map.of(), java.util.List.of()); + RemediationMetric metric = RemediationMetric.previewUnfiltered(0, 0, Set.of(), java.util.Map.of(), java.util.List.of()); assertNotNull(metric); assertEquals(0, metric.totalRemediations()); assertEquals(0, metric.appliedRemediations()); - assertNotNull(metric.previewDetails()); - assertEquals(0, metric.previewDetails().size()); + assertTrue(metric instanceof RemediationMetric.Preview); + assertEquals(0, ((RemediationMetric.Preview) metric).previewDetails().size()); } @Test @@ -96,10 +96,10 @@ void unfilteredModeHasEmptyIssueIdSets() { } @Test - void previewDetailsCanBeNull() { - RemediationMetric metric = RemediationMetric.unfiltered(5, 3, Set.of(), java.util.Map.of(), null); + void unfilteredMetricIsAppliedVariantWithNoPreviewData() { + RemediationMetric metric = RemediationMetric.unfiltered(5, 3, Set.of(), java.util.Map.of()); - // Null preview details is acceptable (non-preview mode) - assertEquals(null, metric.previewDetails()); + // Apply-mode metrics carry no preview data at all - not merely a null field + assertTrue(metric instanceof RemediationMetric.Applied); } } diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java index 33ae3d70c2..69346cca1a 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java @@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -30,6 +31,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric.Mode; import com.fortify.cli.aviator.util.FprHandle; @@ -282,6 +284,219 @@ private String pathTraversalAndValidRemediationsXml(String hash) { """.formatted(hash, hash); } + @Test + void previewModeDoesNotModifySourceFiles() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-unchanged")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " oldTwo();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(remediationsXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), null, true); + var metric = processor.processRemediationXML(); + + assertEquals(2, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + + // CRITICAL: Source file must be unchanged in preview mode + String actualContent = Files.readString(sourceFile, StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertEquals(originalContent, actualContent); + assertTrue(actualContent.contains(" oldOne();")); + assertTrue(actualContent.contains(" oldTwo();")); + assertFalse(actualContent.contains("newOne")); + assertFalse(actualContent.contains("newTwo")); + } + } + + @Test + void previewModePopulatesPreviewDetailsWithChanges() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-details")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(singleRemediationXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), null, true); + var metric = processor.processRemediationXML(); + + // Verify preview details are populated + assertTrue(metric instanceof RemediationMetric.Preview); + var preview = (RemediationMetric.Preview) metric; + assertEquals(1, preview.previewDetails().size()); + + var detail = preview.previewDetails().get(0); + assertEquals("ISSUE-1", detail.issueId()); + assertEquals("available", detail.status()); + assertNotNull(detail.files()); + assertEquals(1, detail.files().size()); + + var filePreview = detail.files().get("Example.java"); + assertNotNull(filePreview); + assertEquals("UTF-8", filePreview.encoding()); + assertEquals(1, filePreview.changes().size()); + + var change = filePreview.changes().get(0); + assertEquals(1, change.changeIndex()); + assertEquals(3, change.lineFrom()); + assertEquals(3, change.lineTo()); + assertTrue(change.originalCode().contains("oldOne")); + assertTrue(change.newCode().contains("newOne")); + } + } + + @Test + void previewModeCapturesSkipReasonsInPreviewDetails() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-skip")); + // Create file for one remediation, but not the other + Path validFile = sourceDir.resolve("Valid.java"); + Files.writeString(validFile, "class Valid { void run() { old(); } }", StandardCharsets.UTF_8); + + String validHash = TestHashUtil.sha256Base64Unix("class Valid { void run() { old(); } }"); + String missingHash = "dGVzdGhhc2g="; // arbitrary hash for missing file + + String xml = """ + + + + + Valid.java + %s + + 1 + 1 + class Valid { void run() { old(); } } + old(); + new(); + + + + + + Missing.java + %s + + 1 + 1 + ignored + ignored + ignored + + + + + """.formatted(validHash, missingHash); + + Path fprPath = tempDir.resolve("test-skip.fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + writeEntry(zipOutputStream, "audit.fvdl", """ + + + + + + Valid.java + + + Missing.java + + + + + """); + writeEntry(zipOutputStream, "remediations.xml", xml); + } + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), null, true); + var metric = processor.processRemediationXML(); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + + // Verify preview details capture both available and skipped + assertTrue(metric instanceof RemediationMetric.Preview); + var preview = (RemediationMetric.Preview) metric; + assertEquals(2, preview.previewDetails().size()); + + var available = preview.previewDetails().stream() + .filter(d -> "available".equals(d.status())) + .findFirst().orElseThrow(); + assertEquals("ISSUE-VALID", available.issueId()); + assertNotNull(available.files().get("Valid.java")); + + var skipped = preview.previewDetails().stream() + .filter(d -> "skipped".equals(d.status())) + .findFirst().orElseThrow(); + assertEquals("ISSUE-MISSING", skipped.issueId()); + assertEquals("Source file missing", skipped.skipReason()); + assertTrue(skipped.files().isEmpty()); + } + } + + @Test + void previewModeWithEmptyRemediationsXmlReturnsEmptyList() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-empty")); + Path sourceFile = sourceDir.resolve("Example.java"); + Files.writeString(sourceFile, "class Example { }", StandardCharsets.UTF_8); + + String emptyXml = """ + + + + """; + + Path fprPath = tempDir.resolve("test-empty.fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + writeEntry(zipOutputStream, "audit.fvdl", """ + + + + + + Example.java + + + + + """); + writeEntry(zipOutputStream, "remediations.xml", emptyXml); + } + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), null, true); + var metric = processor.processRemediationXML(); + + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + + // CRITICAL: Must return empty list [], not null + assertTrue(metric instanceof RemediationMetric.Preview); + var preview = (RemediationMetric.Preview) metric; + assertEquals(0, preview.previewDetails().size()); + assertEquals(java.util.List.of(), preview.previewDetails()); + } + } + private static final class TestHashUtil { private static String sha256Base64Unix(String content) { try { diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java index 323a3432d9..1b29201304 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java @@ -27,30 +27,29 @@ class FilePreviewTest { @Test void validFilePreviewCreatedCorrectly() { - FilePreview preview = new FilePreview("Example.java", "/path/to/Example.java", "UTF-8", List.of()); + FilePreview preview = new FilePreview("/path/to/Example.java", "UTF-8", List.of()); assertNotNull(preview); - assertEquals("Example.java", preview.filename()); assertEquals("/path/to/Example.java", preview.path()); assertEquals("UTF-8", preview.encoding()); assertEquals(0, preview.totalChanges()); } @Test - void nullFilenameThrowsException() { + void nullPathThrowsException() { assertThrows(IllegalArgumentException.class, - () -> new FilePreview(null, "/path", "UTF-8", List.of())); + () -> new FilePreview(null, "UTF-8", List.of())); } @Test - void blankFilenameThrowsException() { + void blankPathThrowsException() { assertThrows(IllegalArgumentException.class, - () -> new FilePreview(" ", "/path", "UTF-8", List.of())); + () -> new FilePreview(" ", "UTF-8", List.of())); } @Test void nullChangesListIsConvertedToEmptyList() { - FilePreview preview = new FilePreview("Test.java", "/path", "UTF-8", null); + FilePreview preview = new FilePreview("/path", "UTF-8", null); assertNotNull(preview.changes()); assertEquals(0, preview.changes().size()); } @@ -59,7 +58,7 @@ void nullChangesListIsConvertedToEmptyList() { void changesListIsUnmodifiable() { ContextMetadata context = new ContextMetadata(1, 1, "context"); FileChange change = new FileChange(1, 10, 12, "old", "new", context, false); - FilePreview preview = new FilePreview("Test.java", "/path", "UTF-8", List.of(change)); + FilePreview preview = new FilePreview("/path", "UTF-8", List.of(change)); assertThrows(UnsupportedOperationException.class, () -> preview.changes().add(new FileChange(2, 20, 22, "old2", "new2", context, false))); @@ -73,7 +72,7 @@ void totalChangesReturnsCorrectCount() { new FileChange(2, 20, 22, "old2", "new2", context, false), new FileChange(3, 30, 32, "old3", "new3", context, false) ); - FilePreview preview = new FilePreview("Test.java", "/path", "UTF-8", changes); + FilePreview preview = new FilePreview("/path", "UTF-8", changes); assertEquals(3, preview.totalChanges()); } diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetailTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetailTest.java index d2bd5420a6..a1b0264d64 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetailTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetailTest.java @@ -29,7 +29,7 @@ class PreviewDetailTest { @Test void availablePreviewDetailCreatedCorrectly() { Map files = Map.of("Example.java", - new FilePreview("Example.java", "/path/to/Example.java", "UTF-8", java.util.List.of())); + new FilePreview("/path/to/Example.java", "UTF-8", java.util.List.of())); PreviewDetail detail = PreviewDetail.available("ISSUE-123", files); assertNotNull(detail); @@ -86,10 +86,10 @@ void nullFilesMapIsConvertedToEmptyMap() { @Test void filesMapIsUnmodifiable() { Map files = new java.util.LinkedHashMap<>(); - files.put("Test.java", new FilePreview("Test.java", "/path", "UTF-8", java.util.List.of())); + files.put("Test.java", new FilePreview("/path", "UTF-8", java.util.List.of())); PreviewDetail detail = new PreviewDetail("ISSUE-1", "available", files, null); assertThrows(UnsupportedOperationException.class, - () -> detail.files().put("Another.java", new FilePreview("Another.java", "/path2", "UTF-8", java.util.List.of()))); + () -> detail.files().put("Another.java", new FilePreview("/path2", "UTF-8", java.util.List.of()))); } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java index 9af1c55b52..9239c1639f 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java @@ -60,7 +60,7 @@ public class AviatorSSCApplyRemediationsCommand extends AbstractOutputCommand private String sourceCodeDirectory = System.getProperty("user.dir"); @Option(names = {"--issue-ids"}, split = ",") private List issueIds; - @Option(names = {"--preview"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.preview") + @Option(names = {"--preview"}) private boolean previewMode = false; @Override @@ -89,8 +89,7 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF sourceSelector.getFromCache(), applyResult, issueIdFilter, - source.reader().getManifest().getSelection(), - previewMode); + source.reader().getManifest().getSelection()); } } @@ -105,7 +104,7 @@ private JsonNode processOnline( ApplyResult applyResult = RemediationsApplyHelper.apply( source, sourceCodeDirectory, logger, issueIdFilter, LOG, previewMode); return AviatorSSCApplyRemediationsHelper.buildOnlineResultNode( - resolved.artifacts(), resolved.appVersionId(), applyResult, issueIdFilter, previewMode); + resolved.artifacts(), resolved.appVersionId(), applyResult, issueIdFilter); } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java index f400f051d9..38c1f49828 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java @@ -36,23 +36,13 @@ public static ObjectNode buildOnlineResultNode( String appVersionId, ApplyResult applyResult, Set issueIdFilter) { - return buildOnlineResultNode(artifacts, appVersionId, applyResult, issueIdFilter, false); - } - - public static ObjectNode buildOnlineResultNode( - List artifacts, - String appVersionId, - ApplyResult applyResult, - Set issueIdFilter, - boolean previewMode) { RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( issueIdFilter, applyResult.metrics()); return buildCommonNode( resolveAppVersionId(artifacts, appVersionId), resolveSingleArtifactId(artifacts, applyResult), applyResult, - aggregated, - previewMode); + aggregated); } public static ObjectNode buildCacheResultNode( @@ -60,19 +50,10 @@ public static ObjectNode buildCacheResultNode( ApplyResult applyResult, Set issueIdFilter, Map selection) { - return buildCacheResultNode(cacheZip, applyResult, issueIdFilter, selection, false); - } - - public static ObjectNode buildCacheResultNode( - Path cacheZip, - ApplyResult applyResult, - Set issueIdFilter, - Map selection, - boolean previewMode) { RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( issueIdFilter, applyResult.metrics()); String appVersionId = selection != null ? selection.get("appVersionId") : null; - ObjectNode result = buildCommonNode(appVersionId, null, applyResult, aggregated, previewMode); + ObjectNode result = buildCommonNode(appVersionId, null, applyResult, aggregated); AviatorRemediationMetricsHelper.putCacheExtras( result, cacheZip, applyResult.processedEntries(), "artifactIds", applyResult.processedIds()); return result; @@ -82,15 +63,14 @@ private static ObjectNode buildCommonNode( String appVersionId, String artifactId, ApplyResult applyResult, - RemediationMetric aggregated, - boolean previewMode) { + RemediationMetric aggregated) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("appVersionId", AviatorRemediationMetricsHelper.na(appVersionId)); result.put("artifactId", AviatorRemediationMetricsHelper.na(artifactId)); result.put("artifactsProcessed", applyResult.metrics().size()); result.put("artifactsSkipped", applyResult.skipped()); - result.put("previewMode", previewMode); - AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated, previewMode); + result.put("previewMode", aggregated instanceof RemediationMetric.Preview); + AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated); return result; } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java index 2d29195d84..0786ed843e 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java @@ -85,7 +85,7 @@ private JsonNode processOnline(AviatorLoggerImpl logger, Set issueIdFilt new FoDOnlineRemediationsFprSource(unirest, logger, release)) { ApplyResult applyResult = RemediationsApplyHelper.apply( source, sourceCodeDirectory, logger, issueIdFilter, LOG, previewMode); - return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(release, applyResult, previewMode); + return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(release, applyResult); } } @@ -96,7 +96,7 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF ApplyResult applyResult = RemediationsApplyHelper.apply( source, sourceCodeDirectory, logger, issueIdFilter, LOG, previewMode); return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( - sourceSelector.getFromCache(), applyResult, issueIdFilter, previewMode); + sourceSelector.getFromCache(), applyResult, issueIdFilter); } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java index 77999610b0..dcb8b34fd2 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java @@ -31,32 +31,22 @@ public final class AviatorFoDApplyRemediationsHelper { private AviatorFoDApplyRemediationsHelper() {} public static ObjectNode buildOnlineResultNode(FoDReleaseDescriptor releaseDescriptor, ApplyResult applyResult) { - return buildOnlineResultNode(releaseDescriptor, applyResult, false); - } - - public static ObjectNode buildOnlineResultNode(FoDReleaseDescriptor releaseDescriptor, ApplyResult applyResult, boolean previewMode) { RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( null, applyResult.metrics()); return buildCommonNode( releaseDescriptor.getReleaseId(), releaseDescriptor.getApplicationName(), releaseDescriptor.getReleaseName(), - aggregated, - previewMode); + aggregated); } public static ObjectNode buildCacheResultNode( Path cacheZip, ApplyResult applyResult, Set issueIdFilter) { - return buildCacheResultNode(cacheZip, applyResult, issueIdFilter, false); - } - - public static ObjectNode buildCacheResultNode( - Path cacheZip, ApplyResult applyResult, Set issueIdFilter, boolean previewMode) { RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( issueIdFilter, applyResult.metrics()); List releaseIds = applyResult.processedIds(); String releaseId = releaseIds != null && !releaseIds.isEmpty() ? releaseIds.get(0) : null; - ObjectNode result = buildCommonNode(releaseId, null, null, aggregated, previewMode); + ObjectNode result = buildCommonNode(releaseId, null, null, aggregated); AviatorRemediationMetricsHelper.putCacheExtras( result, cacheZip, applyResult.processedEntries(), "releaseIds", releaseIds); return result; @@ -66,14 +56,13 @@ private static ObjectNode buildCommonNode( String releaseId, String applicationName, String releaseName, - RemediationMetric aggregated, - boolean previewMode) { + RemediationMetric aggregated) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("releaseId", AviatorRemediationMetricsHelper.na(releaseId)); result.put("applicationName", AviatorRemediationMetricsHelper.na(applicationName)); result.put("releaseName", AviatorRemediationMetricsHelper.na(releaseName)); - result.put("previewMode", previewMode); - AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated, previewMode); + result.put("previewMode", aggregated instanceof RemediationMetric.Preview); + AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated); return result; } }