diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/AbstractApplyRemediationsOptionsMixin.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/AbstractApplyRemediationsOptionsMixin.java new file mode 100644 index 0000000000..9eeb0c71b1 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/AbstractApplyRemediationsOptionsMixin.java @@ -0,0 +1,78 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.cli.mixin; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.aviator._common.remediations_cache.IApplyRemediationsOptions; +import com.fortify.cli.common.exception.FcliSimpleException; + +import lombok.Getter; +import picocli.CommandLine.Option; + +/** + * Abstract base for apply-remediations options. Provides shared CLI options and validation template method. + * Product-specific subclasses declare source-selection mixins and implement validation hooks. + */ +@Getter +public abstract class AbstractApplyRemediationsOptionsMixin implements IApplyRemediationsOptions { + @Option(names = {"--source-dir"}) + private String sourceCodeDirectory = System.getProperty("user.dir"); + + @Option(names = {"--issue-ids"}, split = ",") + private List issueIds; + + @Option(names = {"--preview"}) + private boolean previewMode = false; + + /** + * Validates all options by calling validation hooks in order. + * Template method: ensures consistent validation sequence across SSC and FoD. + */ + @Override + public final void validate() { + validateSourceSelection(); + validateSourceDir(); + validateIssueIdsConstraints(); + } + + /** Hook for product-specific source selection validation (--from-cache vs online selection). */ + protected abstract void validateSourceSelection(); + + /** Hook to determine if --from-cache is selected (needed for --issue-ids constraint validation). */ + protected abstract boolean isCacheMode(); + + private void validateSourceDir() { + FcliSimpleException.throwIf( + StringUtils.isBlank(sourceCodeDirectory), + "--source-dir must specify a valid directory path"); + Path path = Path.of(sourceCodeDirectory); + FcliSimpleException.throwIf( + !Files.exists(path) || !Files.isDirectory(path), + "--source-dir path does not exist or is not a directory: %s", sourceCodeDirectory); + FcliSimpleException.throwIf( + !Files.isReadable(path), + "--source-dir path is not accessible: %s", sourceCodeDirectory); + } + + private void validateIssueIdsConstraints() { + FcliSimpleException.throwIf( + issueIds != null && !issueIds.isEmpty() && !isCacheMode(), + "--issue-ids can only be used with --from-cache; " + + "create a cache with download-remediations-cache and rerun with --from-cache"); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorApplyRemediationsCommand.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorApplyRemediationsCommand.java new file mode 100644 index 0000000000..d866daf1f4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorApplyRemediationsCommand.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.output.cli.cmd; + +import java.util.Set; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.cli.mixin.AbstractApplyRemediationsOptionsMixin; +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.progress.helper.IProgressWriter; + +import lombok.Getter; +import picocli.CommandLine.Mixin; + +/** + * Abstract base command for applying remediations. Orchestrates validation, FPR source acquisition, + * and remediation application. Product-specific subclasses provide options mixin and implement hooks. + */ +public abstract class AbstractAviatorApplyRemediationsCommand extends AbstractOutputCommand + implements IJsonNodeSupplier { + + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + + /** Subclasses declare their product-specific options mixin (SSC or FoD). */ + protected abstract AbstractApplyRemediationsOptionsMixin getApplyOptions(); + + @Override + public final JsonNode getJsonNode() { + AbstractApplyRemediationsOptionsMixin applyOptions = getApplyOptions(); + applyOptions.validate(); + Set issueIdFilter = AviatorIssueIdFilterUtils.normalizeIssueIds(applyOptions.getIssueIds()); + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + try (IRemediationsFprSource fprSource = openFprSource(logger, progressWriter)) { + ApplyResult result = RemediationsApplyHelper.apply(fprSource, applyOptions, issueIdFilter, logger); + return buildResultNode(fprSource, result, issueIdFilter); + } + } + } + + protected abstract IRemediationsFprSource openFprSource(AviatorLoggerImpl logger, IProgressWriter progressWriter); + + protected abstract JsonNode buildResultNode(IRemediationsFprSource fprSource, ApplyResult result, Set issueIdFilter); + + @Override + public final boolean isSingular() { return true; } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IApplyRemediationsOptions.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IApplyRemediationsOptions.java new file mode 100644 index 0000000000..488987fcbe --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IApplyRemediationsOptions.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.util.List; + +/** Abstraction over the shared apply-remediations CLI options, allowing RemediationsApplyHelper + * to remain independent of concrete Picocli types. */ +public interface IApplyRemediationsOptions { + String getSourceCodeDirectory(); + List getIssueIds(); + boolean isPreviewMode(); + void validate(); +} 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..72658335d6 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 @@ -19,8 +19,6 @@ import java.util.List; import java.util.Set; -import org.slf4j.Logger; - import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; @@ -29,10 +27,13 @@ import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.common.exception.FcliTechnicalException; +import lombok.extern.slf4j.Slf4j; + /** * Single apply-remediations loop for any {@link IRemediationsFprSource} * (cache zip entries or online downloads). Soft-skips on {@link AviatorSimpleException}. */ +@Slf4j public final class RemediationsApplyHelper { private RemediationsApplyHelper() {} @@ -43,22 +44,23 @@ public record ApplyResult( List metrics) {} /** - * Applies remediations for each source entry until done or the issue-id filter is exhausted. - * Caller owns {@code source} lifecycle (try-with-resources). + * Applies or previews remediations for each FPR source entry until done or the issue-id filter is exhausted. + * Caller owns {@code fprSource} lifecycle (try-with-resources). */ public static ApplyResult apply( - IRemediationsFprSource source, - String sourceCodeDirectory, - IAviatorLogger logger, + IRemediationsFprSource fprSource, + IApplyRemediationsOptions options, Set issueIdFilter, - Logger skipLog) { + IAviatorLogger logger) { Accumulator acc = new Accumulator(issueIdFilter); - source.forEachEntry((fprPath, label, id, index, total) -> { + fprSource.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, + options.getSourceCodeDirectory(), logger, acc.remaining, + options.isPreviewMode()); if (metric == null) { acc.skipped++; } else { @@ -85,13 +87,13 @@ private static RemediationMetric applyOne( String sourceCodeDirectory, IAviatorLogger logger, Set issueFilter, - 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()); + log.warn("Skipping entry {} as {}", entryLabel, e.getMessage()); return null; } catch (IOException e) { throw new FcliTechnicalException("Failed to close FPR handle for entry " + entryLabel, e); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorApplyRemediationsCliSupport.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorApplyRemediationsCliSupport.java deleted file mode 100644 index cc066a3b33..0000000000 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorApplyRemediationsCliSupport.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.aviator._common.util; - -import java.util.List; -import java.util.Set; - -import com.fortify.cli.common.exception.FcliSimpleException; - -/** - * Shared option validation for SSC/FoD apply-remediations commands (CLI surface only). - */ -public final class AviatorApplyRemediationsCliSupport { - private AviatorApplyRemediationsCliSupport() {} - - public static void requireSourceDir(String sourceCodeDirectory) { - FcliSimpleException.throwIf(sourceCodeDirectory == null || sourceCodeDirectory.isBlank(), - "--source-dir must specify a valid directory path"); - } - - /** - * Normalizes {@code --issue-ids} and enforces cache-only mode. - * - * @return normalized filter, or {@code null} when the option is omitted - */ - public static Set normalizeIssueIdsForCacheOnly(List issueIds, boolean fromCacheSelected) { - FcliSimpleException.throwIf( - issueIds != null && !issueIds.isEmpty() && !fromCacheSelected, - "--issue-ids can only be used with --from-cache; " - + "create a cache with download-remediations-cache and rerun with --from-cache"); - return AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); - } -} 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..e59326bd2e 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 @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; +import com.fortify.cli.aviator.fpr.processor.preview.PreviewDetail; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -38,25 +39,49 @@ private AviatorRemediationMetricsHelper() {} * aggregation (XML totals); non-null selects filtered aggregation (requested IDs). */ public static RemediationMetric aggregateMetrics(Set requestedIssueIds, Collection metrics) { + Collection safeMetrics = metrics == null ? List.of() : metrics; + return requestedIssueIds == null + ? aggregateUnfiltered(safeMetrics) + : aggregateFiltered(requestedIssueIds, safeMetrics); + } + + private static RemediationMetric aggregateUnfiltered(Collection metrics) { + int totalRemediations = 0, appliedRemediations = 0; Set modifiedFiles = new LinkedHashSet<>(); Map skippedByReason = new LinkedHashMap<>(); - Collection safeMetrics = metrics == null ? List.of() : metrics; - if (requestedIssueIds == null) { - int totalRemediations = 0; - int appliedRemediations = 0; - for (RemediationMetric metric : safeMetrics) { - totalRemediations += metric.totalRemediations(); - appliedRemediations += metric.appliedRemediations(); - accumulateFilesAndSkips(metric, modifiedFiles, skippedByReason); + List previewDetails = new ArrayList<>(); + boolean previewMode = false; + for (RemediationMetric metric : metrics) { + totalRemediations += metric.totalRemediations(); + appliedRemediations += metric.appliedRemediations(); + accumulateFilesAndSkips(metric, modifiedFiles, skippedByReason); + if (metric instanceof RemediationMetric.Preview preview) { + previewMode = true; + previewDetails.addAll(preview.previewDetails()); } - return RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason); } + return previewMode + ? RemediationMetric.previewUnfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason, previewDetails) + : RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles, skippedByReason); + } + + private static RemediationMetric aggregateFiltered(Set requestedIssueIds, Collection metrics) { Set appliedIssueIds = new LinkedHashSet<>(); - for (RemediationMetric metric : safeMetrics) { + Set modifiedFiles = new LinkedHashSet<>(); + Map skippedByReason = new LinkedHashMap<>(); + List previewDetails = new ArrayList<>(); + boolean previewMode = false; + for (RemediationMetric metric : metrics) { appliedIssueIds.addAll(metric.appliedIssueIds()); accumulateFilesAndSkips(metric, modifiedFiles, skippedByReason); + if (metric instanceof RemediationMetric.Preview preview) { + previewMode = true; + previewDetails.addAll(preview.previewDetails()); + } } - return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason); + return previewMode + ? RemediationMetric.previewFiltered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason, previewDetails) + : RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles, skippedByReason); } private static void accumulateFilesAndSkips( @@ -92,7 +117,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 +148,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..79f54eae7e 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 @@ -34,6 +34,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -45,10 +46,15 @@ import org.w3c.dom.NodeList; import org.xml.sax.SAXException; +import com.fortify.cli.aviator._common.exception.AviatorBugException; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; 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 +65,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 AviatorBugException("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 +286,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 +305,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 +381,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 +419,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(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 +503,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 +520,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 +597,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 +630,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 +661,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 +671,56 @@ 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 = ChangeDetail.builder() + .changeIndex(changeIndex) + .lineFrom(lineFrom) + .lineTo(lineTo) + .originalCode(originalCodeText) + .newCode(newCodeText) + .contextLinesBefore(contextBefore) + .contextLinesAfter(contextAfter) + .contextContent(contextText) + .fuzzyMatched(fuzzyMatched) + .build(); + + 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..e68a665a7f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ChangeDetail.java @@ -0,0 +1,53 @@ +/* + * 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; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Internal change detail captured during preview processing. + * This is an internal representation that gets converted to FileChange for output. + */ +@Reflectable +@Builder +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class ChangeDetail { + private final int changeIndex; + private final int lineFrom; + private final int lineTo; + private final String originalCode; + private final String newCode; + private final int contextLinesBefore; + private final int contextLinesAfter; + private final String contextContent; + private final boolean fuzzyMatched; + + public FileChange toFileChange() { + ContextMetadata context = new ContextMetadata(contextLinesBefore, contextLinesAfter, contextContent); + return FileChange.builder() + .changeIndex(changeIndex) + .lineFrom(lineFrom) + .lineTo(lineTo) + .originalCode(originalCode) + .newCode(newCode) + .context(context) + .fuzzyMatched(fuzzyMatched) + .build(); + } +} 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..24a5f6cd01 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadata.java @@ -0,0 +1,43 @@ +/* + * 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; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +/** + * 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 AviatorBugException("ContextMetadata linesBefore must be non-negative"); + } + if (linesAfter < 0) { + throw new AviatorBugException("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..c41e553f43 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FileChange.java @@ -0,0 +1,56 @@ +/* + * 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; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +import lombok.Builder; + +/** + * 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 +@Builder +@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 AviatorBugException("FileChange changeIndex must be positive"); + } + if (lineFrom < 1 || lineTo < lineFrom) { + throw new AviatorBugException("FileChange invalid line range: " + lineFrom + "-" + lineTo); + } + if (context == null) { + throw new AviatorBugException("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..e28d2d4952 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreview.java @@ -0,0 +1,47 @@ +/* + * 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; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +/** + * 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 AviatorBugException("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..889f791753 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetail.java @@ -0,0 +1,65 @@ +/* + * 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; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +/** + * 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 AviatorBugException("PreviewDetail issueId is required"); + } + if (status == null || status.isBlank()) { + throw new AviatorBugException("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..3e1c3f6185 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/ContextMetadataTest.java @@ -0,0 +1,74 @@ +/* + * 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; + +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +/** + * 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(AviatorBugException.class, + () -> new ContextMetadata(-1, 2, "content")); + } + + @Test + void negativeLinesAfterThrowsException() { + assertThrows(AviatorBugException.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..9fe95370f2 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FileChangeTest.java @@ -0,0 +1,101 @@ +/* + * 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; + +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +/** + * Tests for FileChange record validation. + */ +class FileChangeTest { + + @Test + void validFileChangeCreatedCorrectly() { + ContextMetadata context = new ContextMetadata(2, 2, "context line"); + FileChange change = FileChange.builder() + .changeIndex(1) + .lineFrom(10) + .lineTo(12) + .originalCode("old code") + .newCode("new code") + .context(context) + .build(); + + 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(AviatorBugException.class, + () -> new FileChange(0, 10, 12, "old", "new", context, false)); + } + + @Test + void changeIndexNegativeThrowsException() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + assertThrows(AviatorBugException.class, + () -> new FileChange(-1, 10, 12, "old", "new", context, false)); + } + + @Test + void lineFromZeroThrowsException() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + assertThrows(AviatorBugException.class, + () -> new FileChange(1, 0, 12, "old", "new", context, false)); + } + + @Test + void lineFromNegativeThrowsException() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + assertThrows(AviatorBugException.class, + () -> new FileChange(1, -1, 12, "old", "new", context, false)); + } + + @Test + void lineToLessThanLineFromThrowsException() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + assertThrows(AviatorBugException.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(AviatorBugException.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..6d80e29bf3 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/FilePreviewTest.java @@ -0,0 +1,85 @@ +/* + * 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; + +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +/** + * 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(AviatorBugException.class, + () -> new FilePreview(null, "UTF-8", List.of())); + } + + @Test + void blankPathThrowsException() { + assertThrows(AviatorBugException.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 = FileChange.builder() + .changeIndex(1).lineFrom(10).lineTo(12) + .originalCode("old").newCode("new").context(context).build(); + FilePreview preview = new FilePreview("/path", "UTF-8", List.of(change)); + + assertThrows(UnsupportedOperationException.class, + () -> preview.changes().add(FileChange.builder() + .changeIndex(2).lineFrom(20).lineTo(22) + .originalCode("old2").newCode("new2").context(context).build())); + } + + @Test + void totalChangesReturnsCorrectCount() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + List changes = List.of( + FileChange.builder().changeIndex(1).lineFrom(10).lineTo(12).originalCode("old1").newCode("new1").context(context).build(), + FileChange.builder().changeIndex(2).lineFrom(20).lineTo(22).originalCode("old2").newCode("new2").context(context).build(), + FileChange.builder().changeIndex(3).lineFrom(30).lineTo(32).originalCode("old3").newCode("new3").context(context).build() + ); + 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..b40841a8e8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/preview/PreviewDetailTest.java @@ -0,0 +1,97 @@ +/* + * 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; + +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +/** + * 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(AviatorBugException.class, + () -> new PreviewDetail(null, "available", Map.of(), null)); + } + + @Test + void blankIssueIdThrowsException() { + assertThrows(AviatorBugException.class, + () -> new PreviewDetail("", "available", Map.of(), null)); + } + + @Test + void nullStatusThrowsException() { + assertThrows(AviatorBugException.class, + () -> new PreviewDetail("ISSUE-1", null, Map.of(), null)); + } + + @Test + void blankStatusThrowsException() { + assertThrows(AviatorBugException.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..4e273ce17f 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 @@ -13,112 +13,60 @@ package com.fortify.cli.aviator.ssc.cli.cmd; import java.time.OffsetDateTime; -import java.util.List; import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.cli.mixin.AbstractApplyRemediationsOptionsMixin; +import com.fortify.cli.aviator._common.output.cli.cmd.AbstractAviatorApplyRemediationsCommand; import com.fortify.cli.aviator._common.remediations_cache.CacheRemediationsFprSource; -import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; -import com.fortify.cli.aviator._common.util.AviatorApplyRemediationsCliSupport; import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsSourceMixin; import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup.ResolvedOnlineArtifacts; +import com.fortify.cli.aviator.ssc.cli.mixin.SscApplyRemediationsOptionsMixin; import com.fortify.cli.aviator.ssc.helper.AviatorSSCApplyRemediationsHelper; import com.fortify.cli.aviator.ssc.helper.SSCOnlineRemediationsFprSource; import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; -import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; -import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; -import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; -import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; -import com.fortify.cli.common.output.transform.IRecordTransformer; -import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.ssc._common.rest.ssc.cli.mixin.SSCUnirestInstanceSupplierMixin; import kong.unirest.UnirestInstance; -import lombok.Getter; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -import picocli.CommandLine.Option; @Command(name = "apply-remediations") -public class AviatorSSCApplyRemediationsCommand extends AbstractOutputCommand - implements IJsonNodeSupplier, IRecordTransformer, IActionCommandResultSupplier { - private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCApplyRemediationsCommand.class); - - @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; - @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - @Mixin private AviatorSSCApplyRemediationsSourceMixin sourceSelector; +public class AviatorSSCApplyRemediationsCommand extends AbstractAviatorApplyRemediationsCommand { + @Mixin private SscApplyRemediationsOptionsMixin applyOptions; @Mixin private SSCUnirestInstanceSupplierMixin unirestInstanceSupplier; - @Option(names = {"--source-dir"}) - private String sourceCodeDirectory = System.getProperty("user.dir"); - @Option(names = {"--issue-ids"}, split = ",") - private List issueIds; - @Override - public JsonNode getJsonNode() { - sourceSelector.validate(); - AviatorApplyRemediationsCliSupport.requireSourceDir(sourceCodeDirectory); - Set issueIdFilter = AviatorApplyRemediationsCliSupport.normalizeIssueIdsForCacheOnly( - issueIds, sourceSelector.isFromCacheSelected()); - - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { - AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - if (sourceSelector.isFromCacheSelected()) { - return processFromCache(logger, issueIdFilter); - } - return processOnline(logger, progressWriter, issueIdFilter); - } + protected AbstractApplyRemediationsOptionsMixin getApplyOptions() { + return applyOptions; } - private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { - try (CacheRemediationsFprSource source = CacheRemediationsFprSource.open( - sourceSelector.getFromCache(), - RemediationsCacheConstants.PRODUCT_SSC)) { - ApplyResult applyResult = RemediationsApplyHelper.apply( - source, sourceCodeDirectory, logger, issueIdFilter, LOG); - return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( - sourceSelector.getFromCache(), - applyResult, - issueIdFilter, - source.reader().getManifest().getSelection()); + @Override + protected IRemediationsFprSource openFprSource(AviatorLoggerImpl logger, IProgressWriter progressWriter) { + if (applyOptions.getSourceSelector().isFromCacheSelected()) { + return CacheRemediationsFprSource.open( + applyOptions.getSourceSelector().getFromCache(), + RemediationsCacheConstants.PRODUCT_SSC); } - } - - private JsonNode processOnline( - AviatorLoggerImpl logger, IProgressWriter progressWriter, Set issueIdFilter) { UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); - OffsetDateTime sinceDate = SinceOptionHelper.parse(sourceSelector.getOnline().getSince()); - // One resolve: artifacts + appVersionId (no second getAppVersionId REST call). - ResolvedOnlineArtifacts resolved = sourceSelector.getOnline().resolveArtifacts(unirest, sinceDate); - try (SSCOnlineRemediationsFprSource source = new SSCOnlineRemediationsFprSource( - unirest, logger, progressWriter, resolved.artifacts())) { - ApplyResult applyResult = RemediationsApplyHelper.apply( - source, sourceCodeDirectory, logger, issueIdFilter, LOG); - return AviatorSSCApplyRemediationsHelper.buildOnlineResultNode( - resolved.artifacts(), resolved.appVersionId(), applyResult, issueIdFilter); - } + OffsetDateTime sinceDate = SinceOptionHelper.parse(applyOptions.getSourceSelector().getOnline().getSince()); + ResolvedOnlineArtifacts resolvedOnline = applyOptions.getSourceSelector().getOnline().resolveArtifacts(unirest, sinceDate); + return new SSCOnlineRemediationsFprSource(unirest, logger, progressWriter, resolvedOnline); } @Override - public boolean isSingular() { - return true; - } - - @Override - public String getActionCommandResult() { - // Fallback only if result JSON has no __action__; helpers set Remediation-Applied / No-Remediation-Applied. - return "Remediation-Applied"; - } - - @Override - public JsonNode transformRecord(JsonNode record) { - return record; + protected JsonNode buildResultNode(IRemediationsFprSource fprSource, ApplyResult result, Set issueIdFilter) { + if (applyOptions.getSourceSelector().isFromCacheSelected()) { + return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( + applyOptions.getSourceSelector().getFromCache(), result, issueIdFilter, + ((CacheRemediationsFprSource) fprSource).reader().getManifest().getSelection()); + } + ResolvedOnlineArtifacts resolvedOnline = ((SSCOnlineRemediationsFprSource) fprSource).getResolvedOnline(); + return AviatorSSCApplyRemediationsHelper.buildOnlineResultNode( + resolvedOnline.artifacts(), resolvedOnline.appVersionId(), result, issueIdFilter); } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SscApplyRemediationsOptionsMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SscApplyRemediationsOptionsMixin.java new file mode 100644 index 0000000000..0215ecf7c4 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SscApplyRemediationsOptionsMixin.java @@ -0,0 +1,38 @@ +/* + * 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.ssc.cli.mixin; + +import com.fortify.cli.aviator._common.cli.mixin.AbstractApplyRemediationsOptionsMixin; + +import lombok.Getter; +import picocli.CommandLine.Mixin; + +/** + * SSC-specific apply-remediations options mixin. Composes SSC source selection with shared options + * and provides SSC-specific validation logic. + */ +@Getter +public final class SscApplyRemediationsOptionsMixin extends AbstractApplyRemediationsOptionsMixin { + @Mixin + private AviatorSSCApplyRemediationsSourceMixin sourceSelector; + + @Override + protected void validateSourceSelection() { + sourceSelector.validate(); + } + + @Override + protected boolean isCacheMode() { + return sourceSelector.isFromCacheSelected(); + } +} 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/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java index 470b94ae37..26fd116807 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java @@ -12,11 +12,11 @@ */ package com.fortify.cli.aviator.ssc.helper; -import java.util.List; import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; import com.fortify.cli.aviator._common.util.AviatorTempFprFile; import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup.ResolvedOnlineArtifacts; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; @@ -24,6 +24,7 @@ import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; import kong.unirest.UnirestInstance; +import lombok.Getter; /** * Online SSC remediations source: downloads each artifact FPR to a managed temp path @@ -33,30 +34,31 @@ * The type implements {@link AutoCloseable} so callers can use one try-with-resources * pattern for all {@link IRemediationsFprSource} implementations. */ +@Getter public final class SSCOnlineRemediationsFprSource implements IRemediationsFprSource { private final UnirestInstance unirest; private final IAviatorLogger logger; private final IProgressWriter progressWriter; - private final List artifacts; + private final ResolvedOnlineArtifacts resolvedOnline; public SSCOnlineRemediationsFprSource( UnirestInstance unirest, IAviatorLogger logger, IProgressWriter progressWriter, - List artifacts) { - FcliSimpleException.throwIf(artifacts == null || artifacts.isEmpty(), + ResolvedOnlineArtifacts resolvedOnline) { + FcliSimpleException.throwIf(resolvedOnline == null || resolvedOnline.artifacts() == null || resolvedOnline.artifacts().isEmpty(), "No SSC artifacts to apply remediations from"); this.unirest = unirest; this.logger = logger; this.progressWriter = progressWriter; - this.artifacts = List.copyOf(artifacts); + this.resolvedOnline = resolvedOnline; } @Override public void forEachEntry(EntryAction action) { - int total = artifacts.size(); + int total = resolvedOnline.artifacts().size(); for (int i = 0; i < total; i++) { - SSCArtifactDescriptor artifact = artifacts.get(i); + SSCArtifactDescriptor artifact = resolvedOnline.artifacts().get(i); String id = artifact.getId(); String label = "artifact id=" + id; try (AviatorTempFprFile tempFpr = AviatorTempFprFile.create(id)) { 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..8b86bb590f 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 @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsSourceMixin; +import com.fortify.cli.aviator.ssc.cli.mixin.SscApplyRemediationsOptionsMixin; import com.fortify.cli.common.exception.FcliSimpleException; import picocli.CommandLine; @@ -50,6 +51,25 @@ void issueIdsRequireFromCache() { assertThrows(FcliSimpleException.class, command::getJsonNode); } + @Test + void previewFlagParsedCorrectly() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "remediations.zip", "--preview"); + assertTrue(getApplyOptions(command).isPreviewMode()); + } + + @Test + void previewWorksWithIssueIds() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--preview", "--issue-ids", "ISSUE-1,ISSUE-2"); + assertTrue(getApplyOptions(command).isPreviewMode()); + assertEquals(2, getApplyOptions(command).getIssueIds().size()); + } + + @Test + void previewWorksWithOnlineSelection() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--artifact-id", "123", "--preview"); + assertTrue(getApplyOptions(command).isPreviewMode()); + } + private static AviatorSSCApplyRemediationsCommand parse(String... args) { AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); new CommandLine(command).parseArgs(args); @@ -58,8 +78,16 @@ private static AviatorSSCApplyRemediationsCommand parse(String... args) { private static AviatorSSCApplyRemediationsSourceMixin getSourceMixin(AviatorSSCApplyRemediationsCommand command) throws Exception { - Field field = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("sourceSelector"); + Field applyOptionsField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("applyOptions"); + applyOptionsField.setAccessible(true); + SscApplyRemediationsOptionsMixin applyOptions = (SscApplyRemediationsOptionsMixin) applyOptionsField.get(command); + return applyOptions.getSourceSelector(); + } + + private static SscApplyRemediationsOptionsMixin getApplyOptions(AviatorSSCApplyRemediationsCommand command) + throws Exception { + Field field = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("applyOptions"); field.setAccessible(true); - return (AviatorSSCApplyRemediationsSourceMixin) field.get(command); + return (SscApplyRemediationsOptionsMixin) field.get(command); } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDApplyRemediationsOptionsMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDApplyRemediationsOptionsMixin.java new file mode 100644 index 0000000000..9b383780dd --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDApplyRemediationsOptionsMixin.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.fod.aviator.cli.mixin; + +import com.fortify.cli.aviator._common.cli.mixin.AbstractApplyRemediationsOptionsMixin; +import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; +import com.fortify.cli.fod._common.cli.mixin.IFoDDelimiterMixinAware; + +import lombok.Getter; +import picocli.CommandLine.Mixin; + +/** + * FoD-specific apply-remediations options mixin. Composes FoD source selection with shared options + * and provides FoD-specific validation logic. Implements delimiter injection for source selector. + */ +@Getter +public final class FoDApplyRemediationsOptionsMixin extends AbstractApplyRemediationsOptionsMixin + implements IFoDDelimiterMixinAware { + @Mixin + private FoDAviatorApplyRemediationsSourceMixin sourceSelector; + + @Override + public void setDelimiterMixin(FoDDelimiterMixin delimiterMixin) { + sourceSelector.setDelimiterMixin(delimiterMixin); + } + + @Override + protected void validateSourceSelection() { + // FoD ArgGroup has multiplicity="1" validation; no additional checks needed + } + + @Override + protected boolean isCacheMode() { + return sourceSelector.isFromCacheSelected(); + } +} 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..9c026ae926 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 @@ -12,104 +12,58 @@ */ package com.fortify.cli.fod.aviator.cmd; -import java.util.List; import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.cli.mixin.AbstractApplyRemediationsOptionsMixin; +import com.fortify.cli.aviator._common.output.cli.cmd.AbstractAviatorApplyRemediationsCommand; import com.fortify.cli.aviator._common.remediations_cache.CacheRemediationsFprSource; -import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; -import com.fortify.cli.aviator._common.util.AviatorApplyRemediationsCliSupport; import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; -import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; -import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; -import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; -import com.fortify.cli.common.output.transform.IRecordTransformer; -import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; import com.fortify.cli.fod._common.session.cli.mixin.FoDUnirestInstanceSupplierMixin; -import com.fortify.cli.fod.aviator.cli.mixin.FoDAviatorApplyRemediationsSourceMixin; +import com.fortify.cli.fod.aviator.cli.mixin.FoDApplyRemediationsOptionsMixin; import com.fortify.cli.fod.aviator.helper.AviatorFoDApplyRemediationsHelper; import com.fortify.cli.fod.aviator.helper.FoDOnlineRemediationsFprSource; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; import kong.unirest.UnirestInstance; -import lombok.Getter; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -import picocli.CommandLine.Option; @Command(name = "apply-remediations") -public class FoDAviatorApplyRemediationsCommand extends AbstractOutputCommand - implements IJsonNodeSupplier, IRecordTransformer, IActionCommandResultSupplier { - private static final Logger LOG = LoggerFactory.getLogger(FoDAviatorApplyRemediationsCommand.class); - - @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; - @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into sourceSelector +public class FoDAviatorApplyRemediationsCommand extends AbstractAviatorApplyRemediationsCommand { + @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into applyOptions + @Mixin private FoDApplyRemediationsOptionsMixin applyOptions; @Mixin private FoDUnirestInstanceSupplierMixin unirestInstanceSupplier; - @Mixin private FoDAviatorApplyRemediationsSourceMixin sourceSelector; - - @Option(names = {"--source-dir"}) - private String sourceCodeDirectory = System.getProperty("user.dir"); - @Option(names = {"--issue-ids"}, split = ",") - private List issueIds; @Override - public JsonNode getJsonNode() { - AviatorApplyRemediationsCliSupport.requireSourceDir(sourceCodeDirectory); - Set issueIdFilter = AviatorApplyRemediationsCliSupport.normalizeIssueIdsForCacheOnly( - issueIds, sourceSelector.isFromCacheSelected()); - - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { - AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - if (sourceSelector.isFromCacheSelected()) { - return processFromCache(logger, issueIdFilter); - } - return processOnline(logger, issueIdFilter); - } + protected AbstractApplyRemediationsOptionsMixin getApplyOptions() { + return applyOptions; } - private JsonNode processOnline(AviatorLoggerImpl logger, Set issueIdFilter) { - UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); - FoDReleaseDescriptor release = sourceSelector.getReleaseDescriptor(unirest); - try (FoDOnlineRemediationsFprSource source = - new FoDOnlineRemediationsFprSource(unirest, logger, release)) { - ApplyResult applyResult = RemediationsApplyHelper.apply( - source, sourceCodeDirectory, logger, issueIdFilter, LOG); - return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(release, applyResult); + @Override + protected IRemediationsFprSource openFprSource(AviatorLoggerImpl logger, IProgressWriter progressWriter) { + if (applyOptions.getSourceSelector().isFromCacheSelected()) { + return CacheRemediationsFprSource.open( + applyOptions.getSourceSelector().getFromCache(), + RemediationsCacheConstants.PRODUCT_FOD); } + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + FoDReleaseDescriptor releaseDescriptor = applyOptions.getSourceSelector().getReleaseDescriptor(unirest); + return new FoDOnlineRemediationsFprSource(unirest, logger, releaseDescriptor); } - private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { - try (CacheRemediationsFprSource source = CacheRemediationsFprSource.open( - sourceSelector.getFromCache(), - RemediationsCacheConstants.PRODUCT_FOD)) { - ApplyResult applyResult = RemediationsApplyHelper.apply( - source, sourceCodeDirectory, logger, issueIdFilter, LOG); + @Override + protected JsonNode buildResultNode(IRemediationsFprSource fprSource, ApplyResult result, Set issueIdFilter) { + if (applyOptions.getSourceSelector().isFromCacheSelected()) { return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( - sourceSelector.getFromCache(), applyResult, issueIdFilter); + applyOptions.getSourceSelector().getFromCache(), result, issueIdFilter); } - } - - @Override - public boolean isSingular() { - return true; - } - - @Override - public String getActionCommandResult() { - return "Remediation-Applied"; - } - - @Override - public JsonNode transformRecord(JsonNode record) { - return record; + FoDReleaseDescriptor releaseDescriptor = ((FoDOnlineRemediationsFprSource) fprSource).getReleaseDescriptor(); + return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(releaseDescriptor, result); } } 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/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java index de18eadae3..db39cbfa0b 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java @@ -18,6 +18,7 @@ import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; import kong.unirest.UnirestInstance; +import lombok.Getter; /** * Online FoD remediations source: downloads the release FPR to a managed temp path @@ -27,6 +28,7 @@ * Implements {@link AutoCloseable} so callers share one try-with-resources pattern with * cache sources. */ +@Getter public final class FoDOnlineRemediationsFprSource implements IRemediationsFprSource { private final UnirestInstance unirest; private final IAviatorLogger logger; 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..abe56f1ca3 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 @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.fod.aviator.cli.mixin.FoDApplyRemediationsOptionsMixin; import com.fortify.cli.fod.aviator.cli.mixin.FoDAviatorApplyRemediationsSourceMixin; import com.fortify.cli.fod.aviator.cmd.FoDAviatorApplyRemediationsCommand; @@ -35,10 +36,10 @@ class FoDAviatorApplyRemediationsCommandTest { @Test void fromCacheParsesPath() throws Exception { FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "remediations.zip"); - Field sourceSelectorField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceSelector"); - sourceSelectorField.setAccessible(true); - FoDAviatorApplyRemediationsSourceMixin sourceSelector = - (FoDAviatorApplyRemediationsSourceMixin) sourceSelectorField.get(command); + Field applyOptionsField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("applyOptions"); + applyOptionsField.setAccessible(true); + FoDApplyRemediationsOptionsMixin applyOptions = (FoDApplyRemediationsOptionsMixin) applyOptionsField.get(command); + FoDAviatorApplyRemediationsSourceMixin sourceSelector = applyOptions.getSourceSelector(); assertEquals(Path.of("remediations.zip"), sourceSelector.getFromCache()); assertTrue(sourceSelector.isFromCacheSelected()); } @@ -58,15 +59,43 @@ void issueIdsRequireFromCache() { @Test void blankSourceDirIsRejected() throws Exception { FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip"); - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); - field.setAccessible(true); - field.set(command, ""); + FoDApplyRemediationsOptionsMixin applyOptions = getApplyOptions(command); + Field sourceDirField = FoDApplyRemediationsOptionsMixin.class + .getSuperclass().getDeclaredField("sourceCodeDirectory"); + sourceDirField.setAccessible(true); + sourceDirField.set(applyOptions, ""); assertThrows(FcliSimpleException.class, command::getJsonNode); } + @Test + void previewFlagParsedCorrectly() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "remediations.zip", "--preview"); + assertTrue(getApplyOptions(command).isPreviewMode()); + } + + @Test + void previewWorksWithIssueIds() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--preview", "--issue-ids", "ISSUE-1,ISSUE-2"); + assertTrue(getApplyOptions(command).isPreviewMode()); + assertEquals(2, getApplyOptions(command).getIssueIds().size()); + } + + @Test + void previewWorksWithOnlineSelection() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--release", "123", "--preview"); + assertTrue(getApplyOptions(command).isPreviewMode()); + } + private static FoDAviatorApplyRemediationsCommand parse(String... args) { FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); new CommandLine(command).parseArgs(args); return command; } + + private static FoDApplyRemediationsOptionsMixin getApplyOptions(FoDAviatorApplyRemediationsCommand command) + throws Exception { + Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("applyOptions"); + field.setAccessible(true); + return (FoDApplyRemediationsOptionsMixin) field.get(command); + } }