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..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 @@ -40,7 +40,18 @@ 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; + boolean previewMode = false; + + // Aggregate preview details from all metrics + for (RemediationMetric metric : safeMetrics) { + if (metric instanceof RemediationMetric.Preview preview) { + previewMode = true; + allPreviewDetails.addAll(preview.previewDetails()); + } + } + if (requestedIssueIds == null) { int totalRemediations = 0; int appliedRemediations = 0; @@ -49,14 +60,18 @@ public static RemediationMetric aggregateMetrics(Set requestedIssueIds, appliedRemediations += metric.appliedRemediations(); accumulateFilesAndSkips(metric, modifiedFiles, skippedByReason); } - return RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason); + 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); + return previewMode + ? RemediationMetric.previewFiltered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, allPreviewDetails) + : RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason); } private static void accumulateFilesAndSkips( @@ -92,7 +107,12 @@ public static String formatSkippedReasons(Map skippedByReason) } public static String actionLabel(RemediationMetric metric) { - return metric != null && metric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + boolean previewMode = metric instanceof RemediationMetric.Preview; + 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) { @@ -118,10 +138,22 @@ 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) { putRemediationMetricFields(result, metric); result.put(IActionCommandResultSupplier.actionFieldName, actionLabel(metric)); + + if (metric instanceof RemediationMetric.Preview preview) { + result.set("previewDetails", toPreviewDetailsArray(preview.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..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 @@ -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,80 +63,159 @@ 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. + * + *

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) { - - 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)); } } - public static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles) { + 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 new RemediationMetric(Mode.UNFILTERED, totalRemediations, appliedRemediations, + return new Applied(Mode.UNFILTERED, totalRemediations, appliedRemediations, totalRemediations - appliedRemediations, modifiedFiles, skippedByReason, Set.of(), Set.of()); } - public static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles) { + static RemediationMetric previewUnfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles, + Map skippedByReason, List previewDetails) { + return new Preview(Mode.UNFILTERED, totalRemediations, appliedRemediations, + totalRemediations - appliedRemediations, modifiedFiles, skippedByReason, Set.of(), Set.of(), previewDetails); + } + + 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) { 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); + return new Applied(Mode.FILTERED, requested.size(), applied.size(), + requested.size() - applied.size(), modifiedFiles, skippedByReason, requested, applied); } - public boolean isFiltered() { - return mode == Mode.FILTERED; + 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; + return new Preview(Mode.FILTERED, requested.size(), applied.size(), + requested.size() - applied.size(), modifiedFiles, skippedByReason, requested, applied, previewDetails); + } + + 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) {} - 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 +284,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 +303,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 +379,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 +417,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() { + 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(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(); + + if (!previewMode) { + return requestedIssueIds == null + ? RemediationMetric.unfiltered(xmlEntryCount, appliedRemediations, modifiedFiles, skippedByReason) + : RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason); + } + List previewDetails = buildPreviewDetails(); + return requestedIssueIds == null + ? RemediationMetric.previewUnfiltered(xmlEntryCount, appliedRemediations, modifiedFiles, skippedByReason, previewDetails) + : RemediationMetric.previewFiltered(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 +501,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 +518,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 +595,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 +628,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 +659,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 +669,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..7c144ed0eb --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.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 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({"path", "encoding", "changes"}) +public record FilePreview( + String path, + String encoding, + List changes) { + + public FilePreview { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("FilePreview path 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..cf045e4388 --- /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", "available", "skipped", "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/_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 new file mode 100644 index 0000000000..a214248412 --- /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.previewUnfiltered(0, 0, Set.of(), java.util.Map.of(), java.util.List.of()); + + assertNotNull(metric); + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertTrue(metric instanceof RemediationMetric.Preview); + assertEquals(0, ((RemediationMetric.Preview) 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 unfilteredMetricIsAppliedVariantWithNoPreviewData() { + RemediationMetric metric = RemediationMetric.unfiltered(5, 3, Set.of(), java.util.Map.of()); + + // 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/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..1b29201304 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.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("/path/to/Example.java", "UTF-8", List.of()); + + assertNotNull(preview); + assertEquals("/path/to/Example.java", preview.path()); + assertEquals("UTF-8", preview.encoding()); + assertEquals(0, preview.totalChanges()); + } + + @Test + void nullPathThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new FilePreview(null, "UTF-8", List.of())); + } + + @Test + void blankPathThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> new FilePreview(" ", "UTF-8", List.of())); + } + + @Test + void nullChangesListIsConvertedToEmptyList() { + FilePreview preview = new FilePreview("/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("/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("/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..a1b0264d64 --- /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("/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("/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("/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..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,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"}) + private boolean previewMode = false; @Override public JsonNode getJsonNode() { @@ -82,7 +84,7 @@ 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, @@ -100,7 +102,7 @@ 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); } @@ -113,8 +115,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-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..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 @@ -69,6 +69,7 @@ private static ObjectNode buildCommonNode( result.put("artifactId", AviatorRemediationMetricsHelper.na(artifactId)); result.put("artifactsProcessed", applyResult.metrics().size()); result.put("artifactsSkipped", applyResult.skipped()); + result.put("previewMode", aggregated instanceof RemediationMetric.Preview); AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated); 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..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 @@ -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,7 +84,7 @@ private JsonNode processOnline(AviatorLoggerImpl logger, Set issueIdFilt try (FoDOnlineRemediationsFprSource source = new FoDOnlineRemediationsFprSource(unirest, logger, release)) { ApplyResult applyResult = RemediationsApplyHelper.apply( - source, sourceCodeDirectory, logger, issueIdFilter, LOG); + source, sourceCodeDirectory, logger, issueIdFilter, LOG, previewMode); return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(release, applyResult); } } @@ -92,7 +94,7 @@ 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); } @@ -105,7 +107,7 @@ public boolean isSingular() { @Override public String getActionCommandResult() { - return "Remediation-Applied"; + return previewMode ? "Remediation-Previewed" : "Remediation-Applied"; } @Override 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..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 @@ -61,6 +61,7 @@ private static ObjectNode buildCommonNode( result.put("releaseId", AviatorRemediationMetricsHelper.na(releaseId)); result.put("applicationName", AviatorRemediationMetricsHelper.na(applicationName)); result.put("releaseName", AviatorRemediationMetricsHelper.na(releaseName)); + result.put("previewMode", aggregated instanceof RemediationMetric.Preview); AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated); 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);