From 3864b096679ef3c3067ddd6f7f0e78313b4e97c9 Mon Sep 17 00:00:00 2001 From: Dhanwanth Pratheep Date: Thu, 20 Aug 2026 15:52:07 +0530 Subject: [PATCH] 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);