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 23b77f0119..26feada445 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 @@ -20,8 +20,8 @@ import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.config.IAviatorLogger; -import com.fortify.cli.aviator.fpr.processor.RemediationProcessor; -import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessor; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; import com.fortify.cli.aviator.fpr.utils.SourceDecoders; import com.fortify.cli.aviator.util.FprHandle; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java index 11a79e38ab..5707630dfc 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java @@ -66,11 +66,13 @@ import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; import com.fortify.cli.aviator.fpr.utils.SourceDecoders; import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.aviator.util.FileUtil; import com.fortify.cli.aviator.util.FprHandle; import lombok.Setter; + public class AuditProcessor { Logger logger = LoggerFactory.getLogger(AuditProcessor.class); @@ -938,7 +940,9 @@ private Document generateRemediationsXml(Map auditRespons changeElement.appendChild(originalCodeElement); Element newCodeElement = finalDoc.createElementNS(REMEDIATIONS_NAMESPACE_URI, "NewCode"); - newCodeElement.appendChild(finalDoc.createCDATASection(change.getReplaceWith() != null ? change.getReplaceWith() : "")); + String sanitizedNewCode = FileUtil.stripSyntheticLineMarkers( + change.getReplaceWith() != null ? change.getReplaceWith() : "", filename); + newCodeElement.appendChild(finalDoc.createCDATASection(sanitizedNewCode)); changeElement.appendChild(newCodeElement); final int CONTEXT_LINES = 3; @@ -1007,10 +1011,13 @@ private void recordSkipped(Map skippedByReason, RemediationSkip } private String calculateHashBase64(String content, String algorithm) { - if (content == null) return ""; try { MessageDigest md = MessageDigest.getInstance(algorithm); - byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); + // hash the canonical form (LF-normalised, no trailing newline) so the apply + // side can reproduce the digest regardless of the OS that ran the audit or the + // file's trailing-newline state. + String canonical = FileUtil.canonicalizeForHash(content); + byte[] digest = md.digest(canonical.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(digest); } catch (NoSuchAlgorithmException e) { throw new AviatorTechnicalException("Hashing algorithm not available: " + algorithm, e); 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 deleted file mode 100644 index 2f717dd66b..0000000000 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java +++ /dev/null @@ -1,580 +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.fpr.processor; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.zip.ZipFile; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; - -import com.fortify.cli.aviator._common.exception.AviatorSimpleException; -import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; -import com.fortify.cli.aviator.fpr.model.FVDLMetadata; -import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; -import com.fortify.cli.aviator.fpr.utils.ISourceDecoder.DecodeResult; -import com.fortify.cli.aviator.fpr.utils.ISourceDecoder.SourceDecodeException; -import com.fortify.cli.aviator.fpr.utils.SourceDecoders; -import com.fortify.cli.aviator.fpr.utils.SourceEncoder; -import com.fortify.cli.aviator.fpr.utils.SourceEncoder.SourceEncodeException; -import com.fortify.cli.aviator.util.FprHandle; -import com.fortify.cli.aviator.util.FuzzyContextSearcher; - -public class RemediationProcessor { - private static final Logger LOG = LoggerFactory.getLogger(RemediationProcessor.class); - private static final String NAMESPACE_URI = "xmlns://www.fortify.com/schema/remediations"; - - private final FprHandle fprHandle; - private final String sourceCodeDirectory; - private final ISourceDecoder sourceDecoder; - - public record RemediationMetric(int totalRemediations, int appliedRemediations, int skippedRemediations, Set modifiedFiles, - Map skippedByReason) { - public RemediationMetric(int totalRemediations, int appliedRemediations, int skippedRemediations, Set modifiedFiles) { - this(totalRemediations, appliedRemediations, skippedRemediations, modifiedFiles, Map.of()); - } - } - - private record SourceFileContent(String content, Charset charset, String encodingSource) {} - - private record PendingFileWrite(String filename, Path filePath, String content, Charset charset, String encodingSource, - byte[] updatedBytes) {} - - private record RollbackFileWrite(String filename, Path filePath, byte[] originalBytes) {} - - private enum SkipReason { - SOURCE_FILE_MISSING("Source file missing"), - SOURCE_FILE_OUTSIDE_SOURCE_DIR("Source file outside source directory"), - SOURCE_READ_FAILED("Source file read failed"), - SOURCE_DECODE_FAILED("Source file decode failed"), - REMEDIATION_DATA_INVALID("Remediation data invalid"), - REMEDIATION_LINE_RANGE_INVALID("Remediation line range invalid"), - SOURCE_CONTEXT_NOT_FOUND("Source context not found"), - SOURCE_CONTEXT_AMBIGUOUS("Source context matched multiple locations"), - ORIGINAL_CODE_NOT_FOUND("Original code not found"), - REMEDIATION_ENCODE_FAILED("Remediation encode failed"), - SOURCE_WRITE_FAILED("Source file write failed"), - NO_CHANGES("No file changes found"), - UNEXPECTED_ERROR("Unexpected remediation processing error"); - - private final String displayName; - - SkipReason(String displayName) { - this.displayName = displayName; - } - } - - private static class SkipRemediationException extends AviatorSimpleException { - private static final long serialVersionUID = 1L; - - private final SkipReason reason; - - SkipRemediationException(SkipReason reason, String message) { - super(message); - this.reason = reason; - } - - SkipRemediationException(SkipReason reason, String message, Throwable cause) { - super(message, cause); - this.reason = reason; - } - } - - private static class RemediationCommitException extends AviatorTechnicalException { - private static final long serialVersionUID = 1L; - - private final List rollbacks; - - RemediationCommitException(String message, Throwable cause, List rollbacks) { - super(message, cause); - this.rollbacks = rollbacks; - } - - List getRollbacks() { - return rollbacks; - } - } - - private static class RollbackRemediationException extends AviatorTechnicalException { - private static final long serialVersionUID = 1L; - - RollbackRemediationException(String message, Throwable cause) { - super(message, cause); - } - } - - public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory) { - this(fprHandle, sourceCodeDirectory, SourceDecoders.defaults()); - } - - public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory, ISourceDecoder sourceDecoder) { - this.fprHandle = fprHandle; - this.sourceCodeDirectory = sourceCodeDirectory; - this.sourceDecoder = Objects.requireNonNull(sourceDecoder, "sourceDecoder"); - } - - public RemediationMetric processRemediationXML() { - Path remediationPath = fprHandle.getPath("/remediations.xml"); - Document remediationDoc; - int totalRemediations; - int appliedRemediations; - Set modifiedFiles = new LinkedHashSet<>(); - Map skippedByReason = new LinkedHashMap<>(); - - // Sanitize and normalize the base source directory path once. - String trimmedSourceDir = sourceCodeDirectory.trim(); - if (trimmedSourceDir.length() > 1 && - ((trimmedSourceDir.startsWith("\"") && trimmedSourceDir.endsWith("\"")) || - (trimmedSourceDir.startsWith("'") && trimmedSourceDir.endsWith("'")))) { - trimmedSourceDir = trimmedSourceDir.substring(1, trimmedSourceDir.length() - 1); - } - final Path sourceBasePath = Paths.get(trimmedSourceDir).toAbsolutePath().normalize(); - LOG.debug("Applying remediations from {} to source directory {}", remediationPath, sourceBasePath); - final FVDLMetadata fvdlMetadata = loadFvdlMetadata(); - - try (InputStream remediationStream = Files.newInputStream(remediationPath)) { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - DocumentBuilder builder = factory.newDocumentBuilder(); - remediationDoc = builder.parse(remediationStream); - - NodeList remediationNodes = remediationDoc.getElementsByTagNameNS(NAMESPACE_URI, "Remediation"); - totalRemediations = remediationNodes.getLength(); - LOG.debug("Loaded {} remediation entries from {}", totalRemediations, remediationPath); - appliedRemediations = 0; - for (int i = 0; i < remediationNodes.getLength(); i++) { - Element remediation = (Element) remediationNodes.item(i); - if (processRemediation(remediation, sourceBasePath, fvdlMetadata, modifiedFiles, skippedByReason)) { - appliedRemediations++; - } - } - - } catch (ParserConfigurationException | SAXException | IOException e) { - LOG.error("Error parsing remediations.xml file: {}", remediationPath, e); - throw new AviatorTechnicalException("Error processing remediation.xml file.", e); - } catch (AviatorTechnicalException e) { - throw e; - } catch (Exception e) { - LOG.error("Unexpected error processing remediation.xml: {}", remediationPath, e); - throw new AviatorTechnicalException("Unexpected error processing remediations.xml.", e); - } - int skippedRemediations = totalRemediations - appliedRemediations; - LOG.info("Auto-remediation summary: total={}, applied={}, skipped={}", totalRemediations, appliedRemediations, skippedRemediations); - if (!skippedByReason.isEmpty()) { - LOG.info("Skipped remediations by reason: {}", formatSkippedReasons(skippedByReason)); - } - return new RemediationMetric(totalRemediations, appliedRemediations, skippedRemediations, modifiedFiles, skippedByReason); - } - - private boolean processRemediation(Element remediation, Path sourceBasePath, FVDLMetadata fvdlMetadata, - Set modifiedFiles, Map skippedByReason) { - String instanceId = remediation.getAttribute("instanceId"); - try { - Map pendingWrites = prepareFileChanges(remediation, sourceBasePath, fvdlMetadata); - if (pendingWrites.isEmpty()) { - recordSkipped(skippedByReason, SkipReason.NO_CHANGES.displayName); - return false; - } - try { - commitRemediationWrites(instanceId, pendingWrites, modifiedFiles); - return true; - } catch (RemediationCommitException e) { - rollbackRemediationWrites(instanceId, e.getRollbacks()); - throw new SkipRemediationException(SkipReason.SOURCE_WRITE_FAILED, e.getMessage(), e); - } - } catch (SkipRemediationException e) { - recordSkipped(skippedByReason, skipReasonLabel(e)); - LOG.warn("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) { - recordSkipped(skippedByReason, SkipReason.UNEXPECTED_ERROR.displayName); - LOG.warn("Skipping remediation {} due to an unexpected processing error", instanceId); - LOG.debug("Unexpected error while processing remediation {}", instanceId, e); - return false; - } - } - - private Map prepareFileChanges(Element remediation, Path sourceBasePath, - FVDLMetadata fvdlMetadata) { - NodeList fileChangesNodes = remediation.getElementsByTagNameNS(NAMESPACE_URI, "FileChanges"); - if (fileChangesNodes.getLength() == 0) { - throw new SkipRemediationException(SkipReason.NO_CHANGES, "No file changes found"); - } - - Map pendingWrites = new LinkedHashMap<>(); - for (int j = 0; j < fileChangesNodes.getLength(); j++) { - processFileChanges(remediation, (Element) fileChangesNodes.item(j), sourceBasePath, fvdlMetadata, pendingWrites); - } - return pendingWrites; - } - - private boolean processFileChanges(Element remediation, Element fileChanges, Path sourceBasePath, FVDLMetadata fvdlMetadata, - Map pendingWrites) { - String instanceId = remediation.getAttribute("instanceId"); - String filename = getRequiredElementText(fileChanges, "Filename"); - Path filePath = sourceBasePath.resolve(filename).normalize(); - LOG.debug("Processing remediation {} file change for '{}' resolved to '{}'", instanceId, filename, filePath); - - if (!filePath.startsWith(sourceBasePath)) { - throw new SkipRemediationException(SkipReason.SOURCE_FILE_OUTSIDE_SOURCE_DIR, - "Source file resolves outside source directory: " + filename); - } - - if (!isFilePresent(filePath)) { - throw new SkipRemediationException(SkipReason.SOURCE_FILE_MISSING, "Source code file not present at: " + filePath); - } - - String fileHash = getRequiredElementText(fileChanges, "Hash"); - NodeList changesNodes = fileChanges.getElementsByTagNameNS(NAMESPACE_URI, "Change"); - if (changesNodes.getLength() == 0) { - throw new SkipRemediationException(SkipReason.NO_CHANGES, "No changes found for file: " + filename); - } - SourceFileContent sourceFileContent = getPendingOrSourceContent(filePath, filename, fvdlMetadata, pendingWrites); - Charset sourceEncoding = sourceFileContent.charset(); - LOG.debug("Remediation {} has {} change(s) for '{}' using source encoding {}", instanceId, changesNodes.getLength(), filename, - sourceFileContent.encodingSource()); - - String updatedContent = sourceFileContent.content(); - for (int k = 0; k < changesNodes.getLength(); k++) { - updatedContent = applyChange(instanceId, filename, fileHash, sourceEncoding, updatedContent, - (Element) changesNodes.item(k), k + 1); - } - byte[] updatedBytes = encodeSourceFile(updatedContent, sourceEncoding, filename); - pendingWrites.put(filePath, new PendingFileWrite(filename, filePath, updatedContent, sourceEncoding, - sourceFileContent.encodingSource(), updatedBytes)); - LOG.debug("Staged remediation {} for '{}' using source encoding {}; changes={}, encodedBytes={}", instanceId, filename, - sourceFileContent.encodingSource(), changesNodes.getLength(), updatedBytes.length); - return true; - } - - private String applyChange(String instanceId, String filename, String fileHash, Charset sourceEncoding, String originalContent, - Element change, int changeIndex) { - String lineSeparator = detectLineSeparator(originalContent); - String content = normalizeLineEndings(originalContent); - - List originalLines = Arrays.asList(content.split("\n", -1)); - LOG.debug("Decoded '{}' using {}; lineSeparator={}, normalizedLines={}", filename, sourceEncoding.name(), - describeLineSeparator(lineSeparator), originalLines.size()); - - int lineFrom = parseRequiredInt(change, "LineFrom"); - int lineTo = parseRequiredInt(change, "LineTo"); - LOG.debug("Remediation {} change {} for '{}' targets lines {}-{}", instanceId, changeIndex, filename, lineFrom, lineTo); - - 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); - Element contextElement = getRequiredElement(change, "Context"); - String contextText = contextElement.getTextContent(); - List contextLine = Arrays.asList(contextText.split("\\r?\\n")); - int contextLineFrom = fuzzySearchContext(instanceId, filename, originalLines, contextLine); - if (contextLineFrom == -1) { - LOG.debug("Context search failed for remediation {} in {}; context lines={}, source lines={}", instanceId, filename, - contextLine.size(), originalLines.size()); - throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_NOT_FOUND, "Source context not found for file '" + filename + - "'; file may have changed or remediation may overlap a previous change"); - } - 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 contextBefore = parseRequiredContextAttribute(contextElement, "before"); - int contextAfter = parseRequiredContextAttribute(contextElement, "after"); - int[] lineFromTo = fuzzySearchOriginalCode(instanceId, filename, originalLines, originalCodeLine, - contextLineFrom, contextLine.size(), contextBefore, contextAfter); - if (lineFromTo[0] == -1 || lineFromTo[1] == -1) { - LOG.debug("Original code search failed for remediation {} in {}; context line={}, original code lines={}, source lines={}", - instanceId, filename, contextLineFrom + 1, originalCodeLine.size(), originalLines.size()); - throw new SkipRemediationException(SkipReason.ORIGINAL_CODE_NOT_FOUND, "Original code not found for file '" + filename + - "'; file may have changed or remediation may overlap a previous change"); - } - lineFrom = lineFromTo[0] + 1; - lineTo = lineFromTo[1] + 1; - 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("\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); - } - - private SourceFileContent getPendingOrSourceContent(Path filePath, String filename, FVDLMetadata fvdlMetadata, - Map pendingWrites) { - PendingFileWrite pendingWrite = pendingWrites.get(filePath); - return pendingWrite == null - ? readSourceFile(filePath, filename, fvdlMetadata) - : new SourceFileContent(pendingWrite.content(), pendingWrite.charset(), pendingWrite.encodingSource()); - } - - private void commitRemediationWrites(String instanceId, Map pendingWrites, Set modifiedFiles) - throws RemediationCommitException { - List rollbacks = new ArrayList<>(); - for (PendingFileWrite pendingWrite : pendingWrites.values()) { - try { - byte[] originalBytes = Files.readAllBytes(pendingWrite.filePath()); - rollbacks.add(new RollbackFileWrite(pendingWrite.filename(), pendingWrite.filePath(), originalBytes)); - LOG.debug("Writing remediation {} to '{}' using staged bytes; encodedBytes={}", instanceId, pendingWrite.filename(), - pendingWrite.updatedBytes().length); - Files.write(pendingWrite.filePath(), pendingWrite.updatedBytes()); - } catch (Exception e) { - throw new RemediationCommitException("Error writing source code file '" + pendingWrite.filename() + "'", e, rollbacks); - } - } - - for (PendingFileWrite pendingWrite : pendingWrites.values()) { - modifiedFiles.add(pendingWrite.filename()); - LOG.info("Remediation applied for {} in file {}", instanceId, pendingWrite.filename()); - } - } - - private void rollbackRemediationWrites(String instanceId, List rollbacks) { - for (RollbackFileWrite rollback : rollbacks) { - try { - Files.write(rollback.filePath(), rollback.originalBytes()); - LOG.warn("Rolled back remediation {} changes for '{}' after write failure", instanceId, rollback.filename()); - } catch (IOException rollbackException) { - LOG.error("Failed to roll back remediation {} changes for '{}'", instanceId, rollback.filename(), rollbackException); - throw new RollbackRemediationException("Failed to roll back remediation changes for '" + rollback.filename() + - "'. Source files may be partially modified; inspect the source tree before retrying", rollbackException); - } - } - } - - private int fuzzySearchContext(String instanceId, String filename, List originalLines, List contextLine) { - try { - List matches = FuzzyContextSearcher.fuzzySearchContextMatches(originalLines, contextLine, 0); - if (matches.size() > 1) { - String candidateLines = matches.stream() - .map(line -> String.valueOf(line + 1)) - .collect(Collectors.joining(", ")); - throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_AMBIGUOUS, - "Source context matched multiple locations in file '" + filename + "'; candidate lines: " + candidateLines); - } - return matches.isEmpty() ? -1 : matches.get(0); - } catch (IOException e) { - throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_NOT_FOUND, - "Error searching source context for remediation '" + instanceId + "' in file '" + filename + "'", e); - } - } - - private int[] fuzzySearchOriginalCode(String instanceId, String filename, List originalLines, List originalCodeLine, - int contextLineFrom, int contextLineCount, int contextBefore, int contextAfter) { - int contextStart = contextLineFrom + contextBefore; - int contextEnd = contextLineFrom + contextLineCount - contextAfter; - if (contextStart < 0 || contextStart >= contextEnd || contextEnd > originalLines.size()) { - return new int[] {-1, -1}; - } - - int[] lineFromTo = FuzzyContextSearcher.fuzzySearchOriginalCode( - originalLines.subList(contextStart, contextEnd), originalCodeLine, 0, 0); - if (lineFromTo[0] == -1 || lineFromTo[1] == -1) { - return lineFromTo; - } - return new int[] {lineFromTo[0] + contextStart, lineFromTo[1] + contextStart}; - } - - private boolean isFilePresent(Path path) { - return Files.exists(path) && Files.isRegularFile(path); - } - - /** Nullable: missing/unreadable FVDL means FPR encoding candidate is skipped. */ - private FVDLMetadata loadFvdlMetadata() { - if (!Files.exists(fprHandle.getPath("/audit.fvdl"))) { - LOG.warn("FVDL file '/audit.fvdl' is missing; FPR encoding candidate will be skipped"); - return null; - } - - try (ZipFile zipFile = new ZipFile(fprHandle.getFprPath().toFile())) { - LOG.debug("Loading FVDL build metadata from '{}' to resolve source encodings", fprHandle.getFprPath()); - // Decoder unused for metadata-only parse; ctor requires one for FileUtils wiring. - StreamingFVDLProcessor processor = new StreamingFVDLProcessor(fprHandle, sourceDecoder); - processor.parseBuildMetadata(zipFile, "audit.fvdl"); - LOG.debug("Loaded FVDL build metadata from '{}'", fprHandle.getFprPath()); - return processor.getFvdlMetadata(); - } catch (Exception e) { - LOG.warn("Error reading source file encodings from audit.fvdl; FPR encoding candidate will be skipped", e); - return null; - } - } - - private SourceFileContent readSourceFile(Path filePath, String filename, FVDLMetadata fvdlMetadata) { - try { - byte[] sourceBytes = Files.readAllBytes(filePath); - // Metadata may be null (FVDL missing); FPR candidate fails and other encodings are tried. - DecodeResult decodeResult = sourceDecoder.decode(sourceBytes, filename, fvdlMetadata); - LOG.debug("Strict decoded '{}' using {}; sourceBytes={}, decodedChars={}", filename, decodeResult.source(), sourceBytes.length, - decodeResult.content().length()); - return new SourceFileContent(decodeResult.content(), decodeResult.charset(), decodeResult.source()); - } catch (SourceDecodeException e) { - throw new SkipRemediationException(SkipReason.SOURCE_DECODE_FAILED, e.getMessage(), e); - } catch (IOException e) { - throw new SkipRemediationException(SkipReason.SOURCE_READ_FAILED, "Error reading source code file '" + filePath + "'", e); - } - } - - private byte[] encodeSourceFile(String content, Charset charset, String filename) { - try { - return SourceEncoder.encode(content, charset, filename); - } catch (SourceEncodeException e) { - throw new SkipRemediationException(SkipReason.REMEDIATION_ENCODE_FAILED, e.getMessage(), e); - } - } - - private String getRequiredElementText(Element parent, String elementName) { - return getRequiredElement(parent, elementName).getTextContent(); - } - - private Element getRequiredElement(Element parent, String elementName) { - NodeList nodes = parent.getElementsByTagNameNS(NAMESPACE_URI, elementName); - if (nodes.getLength() == 0 || nodes.item(0) == null) { - throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, - "Missing required remediation element '" + elementName + "'"); - } - return (Element) nodes.item(0); - } - - private int parseRequiredContextAttribute(Element context, String attributeName) { - String value = context.getAttribute(attributeName); - if (value == null || value.isBlank()) { - throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, - "Missing required remediation context attribute '" + attributeName + "'"); - } - try { - int parsedValue = Integer.parseInt(value); - if (parsedValue < 0) { - throw new NumberFormatException("negative value"); - } - return parsedValue; - } catch (NumberFormatException e) { - throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, - "Invalid remediation context attribute '" + attributeName + "': " + value, e); - } - } - - private int parseRequiredInt(Element parent, String elementName) { - String value = getRequiredElementText(parent, elementName); - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, - "Invalid integer value for remediation element '" + elementName + "': " + value, e); - } - } - - private void validateLineRange(int lineFrom, int lineTo, int sourceLineCount, String filename) { - if (lineFrom < 1 || lineTo < lineFrom || lineTo > sourceLineCount) { - throw new SkipRemediationException(SkipReason.REMEDIATION_LINE_RANGE_INVALID, - "Invalid remediation line range " + lineFrom + "-" + lineTo + " for file '" + filename + "'"); - } - } - - private String detectLineSeparator(String content) { - int crlfIndex = content.indexOf("\r\n"); - int lfIndex = content.indexOf('\n'); - int crIndex = content.indexOf('\r'); - - if (crlfIndex >= 0 && (lfIndex == crlfIndex + 1 || lfIndex < 0) && (crIndex == crlfIndex || crIndex < 0)) { - return "\r\n"; - } - if (lfIndex >= 0 && (crIndex < 0 || lfIndex < crIndex)) { - return "\n"; - } - if (crIndex >= 0) { - return "\r"; - } - return System.lineSeparator(); - } - - private String normalizeLineEndings(String content) { - return content.replace("\r\n", "\n").replace('\r', '\n'); - } - - private String describeLineSeparator(String lineSeparator) { - return switch (lineSeparator) { - case "\r\n" -> "CRLF"; - case "\n" -> "LF"; - case "\r" -> "CR"; - default -> "system"; - }; - } - - private String calculateHashBase64(String content, String algorithm) { - String hash; - if (content == null) { - return ""; - } - try { - MessageDigest md = MessageDigest.getInstance(algorithm); - byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); - hash = Base64.getEncoder().encodeToString(digest); - return hash; - } catch (NoSuchAlgorithmException e) { - throw new AviatorTechnicalException("Hashing algorithm not available: " + algorithm, e); - } - } - - private void recordSkipped(Map skippedByReason, String reason) { - skippedByReason.merge(reason, 1, Integer::sum); - } - - private String skipReasonLabel(SkipRemediationException exception) { - return exception.reason.displayName; - } - - private String formatSkippedReasons(Map skippedByReason) { - List parts = new ArrayList<>(); - skippedByReason.forEach((reason, count) -> parts.add(reason + "=" + count)); - return String.join(", ", parts); - } -} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java new file mode 100644 index 0000000000..c528ea4d10 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java @@ -0,0 +1,318 @@ +/* + * 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.remediation; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.zip.ZipFile; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.fpr.processor.StreamingFVDLProcessor; +import com.fortify.cli.aviator.fpr.remediation.applier.RemediationApplier; +import com.fortify.cli.aviator.fpr.remediation.classifier.AppliedChangeLedger; +import com.fortify.cli.aviator.fpr.remediation.classifier.HunkClassifier; +import com.fortify.cli.aviator.fpr.remediation.exception.RemediationCommitException; +import com.fortify.cli.aviator.fpr.remediation.exception.RollbackRemediationException; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.fpr.remediation.model.FileChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.fpr.remediation.model.HunkOutcome; +import com.fortify.cli.aviator.fpr.remediation.model.Remediation; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationDocument; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationKey; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.writer.FileWriteCoordinator; +import com.fortify.cli.aviator.fpr.remediation.writer.PendingFileWrite; +import com.fortify.cli.aviator.fpr.remediation.writer.PreparedFileChanges; +import com.fortify.cli.aviator.fpr.remediation.xmlprocessor.RemediationDocumentMapper; +import com.fortify.cli.aviator.fpr.remediation.xmlprocessor.RemediationXmlReader; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; +import com.fortify.cli.aviator.util.FprHandle; + +/** + * Orchestrator. {@link #processRemediationXML()} runs the three phases in sequence: parse + * XML into a DOM {@link Document} ({@link RemediationXmlReader}), map the document into the + * domain model with zero business logic ({@link RemediationDocumentMapper}), then classify + * and apply each remediation ({@link #classifyAndApply}). Public API (constructors, method + * signature) is unchanged from the original single-class implementation. + */ +public class RemediationProcessor { + private static final Logger LOG = LoggerFactory.getLogger(RemediationProcessor.class); + + private final FprHandle fprHandle; + private final String sourceCodeDirectory; + private final ISourceDecoder sourceDecoder; + + private final RemediationXmlReader xmlReader = new RemediationXmlReader(); + private final RemediationDocumentMapper documentMapper = new RemediationDocumentMapper(); + private final HunkClassifier hunkClassifier = new HunkClassifier(); + private final AppliedChangeLedger ledger = new AppliedChangeLedger(); + private final RemediationApplier remediationApplier = new RemediationApplier(); + private final FileWriteCoordinator fileWriteCoordinator; + + public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory) { + this(fprHandle, sourceCodeDirectory, SourceDecoders.defaults()); + } + + public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory, ISourceDecoder sourceDecoder) { + this.fprHandle = fprHandle; + this.sourceCodeDirectory = sourceCodeDirectory; + this.sourceDecoder = Objects.requireNonNull(sourceDecoder, "sourceDecoder"); + this.fileWriteCoordinator = new FileWriteCoordinator(sourceDecoder, remediationApplier); + } + + public RemediationMetric processRemediationXML() { + Path remediationPath = fprHandle.getPath("/remediations.xml"); + Path sourceBasePath = resolveSourceBasePath(); + LOG.debug("Applying remediations from {} to source directory {}", remediationPath, sourceBasePath); + FVDLMetadata fvdlMetadata = loadFvdlMetadata(); + + try { + Document remediationDoc = xmlReader.read(remediationPath); + RemediationDocument remediations = documentMapper.map(remediationDoc); + return classifyAndApply(remediations, sourceBasePath, fvdlMetadata); + } catch (AviatorTechnicalException e) { + throw e; + } catch (Exception e) { + LOG.error("Unexpected error processing remediation.xml: {}", remediationPath, e); + throw new AviatorTechnicalException("Unexpected error processing remediations.xml.", e); + } + } + + private Path resolveSourceBasePath() { + String trimmedSourceDir = sourceCodeDirectory.trim(); + if (trimmedSourceDir.length() > 1 && + ((trimmedSourceDir.startsWith("\"") && trimmedSourceDir.endsWith("\"")) || + (trimmedSourceDir.startsWith("'") && trimmedSourceDir.endsWith("'")))) { + trimmedSourceDir = trimmedSourceDir.substring(1, trimmedSourceDir.length() - 1); + } + return Paths.get(trimmedSourceDir).toAbsolutePath().normalize(); + } + + private RemediationMetric classifyAndApply(RemediationDocument remediationDocument, Path sourceBasePath, FVDLMetadata fvdlMetadata) { + List orderedRemediations = new ArrayList<>(remediationDocument.remediations()); + int totalRemediations = orderedRemediations.size(); + LOG.debug("Loaded {} remediation entries", totalRemediations); + int appliedRemediations = 0; + int identicalRemediations = 0; + int supersededRemediations = 0; + int possiblyRemediatedRemediations = 0; + Set modifiedFiles = new LinkedHashSet<>(); + Map skippedByReason = new LinkedHashMap<>(); + Map remediationLookup = new LinkedHashMap<>(); + + // Widest-first ordering: broader fixes land first so narrower nested ones classify as SUPERSEDED. + orderedRemediations.sort((a, b) -> Integer.compare(maxHunkWidth(b), maxHunkWidth(a))); + + for (Remediation remediation : orderedRemediations) { + String instanceId = remediation.instanceId(); + List remediationKeys = createRemediationKeys(remediation, sourceBasePath); + + // Hunk-level identity: partition keys into already-satisfied vs to-apply. + Set satisfiedKeys = new LinkedHashSet<>(); + Set toApplyKeys = new LinkedHashSet<>(); + Set satisfiedByInstances = new LinkedHashSet<>(); + for (RemediationKey key : remediationKeys) { + String owner = remediationLookup.get(key); + if (owner != null) { + satisfiedKeys.add(key); + satisfiedByInstances.add(owner); + } else { + toApplyKeys.add(key); + } + } + + // Fully identical: every hunk was already applied by an earlier remediation with same content. + if (!remediationKeys.isEmpty() && toApplyKeys.isEmpty()) { + identicalRemediations++; + LOG.info("Remediation {} is fully identical to prior remediation(s) {}; {} hunk(s) already applied", + instanceId, satisfiedByInstances, satisfiedKeys.size()); + continue; + } + + // SUPERSEDED / CONFLICTS pre-check: classify each unsatisfied hunk against the ledger. + List preClass = hunkClassifier.classifyRemediationHunks(remediation, sourceBasePath, ledger); + boolean anyApplyCandidate = preClass.stream().anyMatch(o -> o == HunkOutcome.APPLIED); + boolean allSuperseded = !preClass.isEmpty() && preClass.stream().allMatch(o -> o == HunkOutcome.SUPERSEDED); + boolean allConflicts = !preClass.isEmpty() && preClass.stream().allMatch(o -> o == HunkOutcome.CONFLICTS); + boolean allPossiblyRemediated = !preClass.isEmpty() + && preClass.stream().noneMatch(o -> o == HunkOutcome.APPLIED || o == HunkOutcome.CONFLICTS) + && preClass.stream().anyMatch(o -> o == HunkOutcome.POSSIBLY_REMEDIATED); + + if (!anyApplyCandidate && allSuperseded) { + supersededRemediations++; + LOG.info("Remediation {} is superseded by a broader prior fix for all {} hunk(s); no write needed", + instanceId, preClass.size()); + continue; + } + if (!anyApplyCandidate && allConflicts) { + recordSkipped(skippedByReason, SkipReason.CONFLICTS_WITH_ANOTHER_FIX.displayName()); + LOG.info("Remediation {} conflicts with prior fix(es) on all {} hunk(s); skipping", + instanceId, preClass.size()); + continue; + } + if (!anyApplyCandidate && allPossiblyRemediated) { + possiblyRemediatedRemediations++; + LOG.info("Remediation {} possibly remediated by a sibling fix with different content for all {} hunk(s)", + instanceId, preClass.size()); + continue; + } + + // Partial identity: some hunks already applied; apply only the rest. + if (!satisfiedKeys.isEmpty()) { + LOG.info("Remediation {} is partially identical to prior remediation(s) {}; {} of {} hunk(s) already applied, {} still to apply", + instanceId, satisfiedByInstances, satisfiedKeys.size(), remediationKeys.size(), toApplyKeys.size()); + } + + Set filter = satisfiedKeys.isEmpty() ? null : toApplyKeys; + Set applied = processRemediation(remediation, sourceBasePath, fvdlMetadata, modifiedFiles, skippedByReason, filter); + if (!applied.isEmpty()) { + appliedRemediations++; + for (RemediationKey key : applied) { + LOG.debug("putting {}", instanceId); + remediationLookup.put(key, instanceId); + } + } + } + + int skippedRemediations = totalRemediations - appliedRemediations - identicalRemediations - supersededRemediations + - possiblyRemediatedRemediations; + LOG.info("Auto-remediation summary: total={}, applied={}, identical={}, superseded={}, possiblyRemediated={}, skipped={}", + totalRemediations, appliedRemediations, identicalRemediations, supersededRemediations, possiblyRemediatedRemediations, + skippedRemediations); + if (!skippedByReason.isEmpty()) { + LOG.info("Skipped remediations by reason: {}", formatSkippedReasons(skippedByReason)); + } + return new RemediationMetric(totalRemediations, appliedRemediations, identicalRemediations, + supersededRemediations, possiblyRemediatedRemediations, skippedRemediations, modifiedFiles, skippedByReason); + } + + private Set processRemediation(Remediation remediation, Path sourceBasePath, FVDLMetadata fvdlMetadata, + Set modifiedFiles, Map skippedByReason, Set keysToApply) { + String instanceId = remediation.instanceId(); + ledger.discardStaged(); + try { + PreparedFileChanges prepared = fileWriteCoordinator.prepareFileChanges(remediation, sourceBasePath, fvdlMetadata, keysToApply, ledger); + Map pendingWrites = prepared.pendingWrites(); + + if (pendingWrites.isEmpty()) { + recordSkipped(skippedByReason, SkipReason.NO_CHANGES.displayName()); + return Set.of(); + } + try { + fileWriteCoordinator.commitRemediationWrites(instanceId, pendingWrites, modifiedFiles); + // Only on successful commit do the staged hunks enter the per-run offset map. + ledger.commitStaged(); + return prepared.appliedKeys(); + } catch (RemediationCommitException e) { + ledger.discardStaged(); + fileWriteCoordinator.rollbackRemediationWrites(instanceId, e.getRollbacks()); + throw new SkipRemediationException(SkipReason.SOURCE_WRITE_FAILED, e.getMessage(), e); + } + } catch (SkipRemediationException e) { + ledger.discardStaged(); + recordSkipped(skippedByReason, skipReasonLabel(e)); + LOG.warn("Skipping remediation {}: {}", instanceId, e.getMessage()); + LOG.debug("Skip reason for remediation {}: {}", instanceId, e.getReason().displayName(), e); + return Set.of(); + } catch (RollbackRemediationException e) { + throw e; + } catch (Exception e) { + ledger.discardStaged(); + recordSkipped(skippedByReason, SkipReason.UNEXPECTED_ERROR.displayName()); + LOG.warn("Skipping remediation {} due to an unexpected processing error", instanceId); + LOG.debug("Unexpected error while processing remediation {}", instanceId, e); + return Set.of(); + } + } + + private void recordSkipped(Map skippedByReason, String reason) { + skippedByReason.merge(reason, 1, Integer::sum); + } + + private String skipReasonLabel(SkipRemediationException exception) { + return exception.getReason().displayName(); + } + + private String formatSkippedReasons(Map skippedByReason) { + List parts = new ArrayList<>(); + skippedByReason.forEach((reason, count) -> parts.add(reason + "=" + count)); + return String.join(", ", parts); + } + + /** + * Widest hunk (lineTo - lineFrom) across all FileChanges/Hunks in a Remediation. Used to + * order remediations broader-first so nested narrower fixes classify as SUPERSEDED. + */ + private int maxHunkWidth(Remediation remediation) { + int max = 0; + for (FileChange fileChange : remediation.fileChanges()) { + for (Hunk hunk : fileChange.hunks()) { + try { + int from = hunk.lineFrom(); + int to = hunk.lineTo(); + max = Math.max(max, to - from); + } catch (Exception ignore) { + // best-effort ordering; malformed hunks fall to the back + } + } + } + return max; + } + + private List createRemediationKeys(Remediation remediation, Path sourceBasePath) { + List keys = new ArrayList<>(); + for (FileChange fileChange : remediation.fileChanges()) { + for (Hunk hunk : fileChange.hunks()) { + String filename = fileChange.requiredFilename(); + String comparisonCode = hunk.comparisonCode(filename); + keys.add(RemediationKey.of(fileChange, hunk, sourceBasePath, comparisonCode)); + } + } + return keys; + } + + /** Nullable: missing/unreadable FVDL means FPR encoding candidate is skipped. */ + private FVDLMetadata loadFvdlMetadata() { + if (!Files.exists(fprHandle.getPath("/audit.fvdl"))) { + LOG.warn("FVDL file '/audit.fvdl' is missing; FPR encoding candidate will be skipped"); + return null; + } + + try (ZipFile zipFile = new ZipFile(fprHandle.getFprPath().toFile())) { + LOG.debug("Loading FVDL build metadata from '{}' to resolve source encodings", fprHandle.getFprPath()); + // Decoder unused for metadata-only parse; ctor requires one for FileUtils wiring. + StreamingFVDLProcessor processor = new StreamingFVDLProcessor(fprHandle, sourceDecoder); + processor.parseBuildMetadata(zipFile, "audit.fvdl"); + LOG.debug("Loaded FVDL build metadata from '{}'", fprHandle.getFprPath()); + return processor.getFvdlMetadata(); + } catch (Exception e) { + LOG.warn("Error reading source file encodings from audit.fvdl; FPR encoding candidate will be skipped", e); + return null; + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/SkipReason.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/SkipReason.java new file mode 100644 index 0000000000..a444c70f68 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/SkipReason.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.remediation; + + public enum SkipReason { + SOURCE_FILE_MISSING("Source file missing"), + SOURCE_FILE_OUTSIDE_SOURCE_DIR("Source file outside source directory"), + SOURCE_READ_FAILED("Source file read failed"), + SOURCE_DECODE_FAILED("Source file decode failed"), + REMEDIATION_DATA_INVALID("Remediation data invalid"), + REMEDIATION_LINE_RANGE_INVALID("Remediation line range invalid"), + SOURCE_CONTEXT_NOT_FOUND("Source context not found"), + SOURCE_CONTEXT_AMBIGUOUS("Source context matched multiple locations"), + ORIGINAL_CODE_NOT_FOUND("Original code not found"), + ORIGINAL_CODE_AMBIGUOUS("Original code matched multiple locations"), + SUPERSEDED_BY_BROADER_FIX("Superseded by broader fix"), + CONFLICTS_WITH_ANOTHER_FIX("Conflicts with another fix"), + ANCHOR_DOES_NOT_MATCH("Anchor does not match"), + REMEDIATION_ENCODE_FAILED("Remediation encode failed"), + SOURCE_WRITE_FAILED("Source file write failed"), + NO_CHANGES("No file changes found"), + UNEXPECTED_ERROR("Unexpected remediation processing error"); + + final String displayName; + + SkipReason(String displayName) { + this.displayName = displayName; + } + + final String displayName() { + return displayName; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/FuzzyAnchorLocator.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/FuzzyAnchorLocator.java new file mode 100644 index 0000000000..d7a2714ff5 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/FuzzyAnchorLocator.java @@ -0,0 +1,80 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.applier; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import com.fortify.cli.aviator.fpr.remediation.SkipReason; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.util.FuzzyContextSearcher; + +/** Wraps the {@link FuzzyContextSearcher} utility's context/original-code matching, unmodified from the original. */ +public final class FuzzyAnchorLocator { + + public int searchContext(String instanceId, String filename, List originalLines, List contextLine, + int projectedDeclaredFrom, int contextBefore) { + try { + List matches = FuzzyContextSearcher.fuzzySearchContextMatches(originalLines, contextLine, 0); + if (matches.size() > 1) { + int expectedContextLineFrom = projectedDeclaredFrom - 1 - contextBefore; + List exact = matches.stream().filter(m -> m == expectedContextLineFrom).toList(); + if (exact.size() == 1) { + return exact.get(0); + } + String candidateLines = matches.stream() + .map(line -> String.valueOf(line + 1)) + .collect(Collectors.joining(", ")); + throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_AMBIGUOUS, + "Source context matched multiple locations in file '" + filename + "'; candidate lines: " + candidateLines); + } + return matches.isEmpty() ? -1 : matches.get(0); + } catch (IOException e) { + throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_NOT_FOUND, + "Error searching source context for remediation '" + instanceId + "' in file '" + filename + "'", e); + } + } + + public int[] searchOriginalCode(String instanceId, String filename, List originalLines, List originalCodeLine, + int contextLineFrom, int contextLineCount, int contextBefore, int contextAfter, + int projectedDeclaredFrom, int projectedDeclaredTo) { + int contextStart = contextLineFrom + contextBefore; + int contextEnd = contextLineFrom + contextLineCount - contextAfter; + if (contextStart < 0 || contextStart >= contextEnd || contextEnd > originalLines.size()) { + return new int[] {-1, -1}; + } + + List matches = FuzzyContextSearcher.fuzzySearchOriginalCodeMatches( + originalLines.subList(contextStart, contextEnd), originalCodeLine, 0, 0); + if (matches.size() > 1) { + int expectedFrom = projectedDeclaredFrom - 1 - contextStart; + int expectedTo = projectedDeclaredTo - 1 - contextStart; + List exact = matches.stream().filter(m -> m[0] == expectedFrom && m[1] == expectedTo).toList(); + if (exact.size() == 1) { + int[] m = exact.get(0); + return new int[] {m[0] + contextStart, m[1] + contextStart}; + } + String candidateLines = matches.stream() + .map(m -> String.valueOf(m[0] + contextStart + 1)) + .collect(Collectors.joining(", ")); + throw new SkipRemediationException(SkipReason.ORIGINAL_CODE_AMBIGUOUS, + "Original code matched multiple locations in file '" + filename + "'; candidate lines: " + candidateLines); + } + if (matches.isEmpty()) { + return new int[] {-1, -1}; + } + int[] lineFromTo = matches.get(0); + return new int[] {lineFromTo[0] + contextStart, lineFromTo[1] + contextStart}; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/RemediationApplier.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/RemediationApplier.java new file mode 100644 index 0000000000..cb8242cfa9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/RemediationApplier.java @@ -0,0 +1,318 @@ +/* + * 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.remediation.applier; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.fpr.remediation.SkipReason; +import com.fortify.cli.aviator.fpr.remediation.classifier.AppliedChangeLedger; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.fpr.remediation.model.AppliedChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.util.FileUtil; + +/** Houses the original {@code applyChange}, split into the same steps it always tried, just named. */ +public final class RemediationApplier { + private static final Logger LOG = LoggerFactory.getLogger(RemediationApplier.class); + + private final FuzzyAnchorLocator fuzzyAnchorLocator = new FuzzyAnchorLocator(); + + public String applyChange(String instanceId, String filename, Path filePath, String fileHash, Charset sourceEncoding, + String originalContent, Hunk hunk, int changeIndex, AppliedChangeLedger ledger) { + String lineSeparator = detectLineSeparator(originalContent); + String content = normalizeLineEndings(originalContent); + + List originalLines = Arrays.asList(content.split("\n", -1)); + LOG.debug("Decoded '{}' using {}; lineSeparator={}, normalizedLines={}", filename, sourceEncoding.name(), + describeLineSeparator(lineSeparator), originalLines.size()); + + int lineFrom = hunk.lineFrom(); + int lineTo = hunk.lineTo(); + LOG.debug("Remediation {} change {} for '{}' targets lines {}-{}", instanceId, changeIndex, filename, lineFrom, lineTo); + + boolean fileHashMatches = tryHashMatch(instanceId, filename, fileHash, sourceEncoding, content, originalContent); + if (!fileHashMatches) { + LOG.debug("File hash mismatch for remediation {} in {}; searching changed source content", instanceId, filename); + List priorApplied = ledger.changesFor(filePath); + + int[] projected = tryOffsetProjection(instanceId, filename, hunk, originalLines, lineFrom, lineTo, priorApplied, ledger, filePath); + if (projected != null) { + lineFrom = projected[0]; + lineTo = projected[1]; + } else { + int[] anchored = tryFuzzyAnchor(instanceId, filename, hunk, originalLines, priorApplied, + lineFrom, lineTo, filePath, ledger); + lineFrom = anchored[0]; + lineTo = anchored[1]; + } + } + + validateLineRange(lineFrom, lineTo, originalLines.size(), filename); + List newCodeLines = new ArrayList<>(Arrays.asList(FileUtil.stripSyntheticLineMarkers( + hunk.requiredNewCode(), filename).split("\n"))); + dropDuplicatedBoundaryTokens(newCodeLines, originalLines, lineFrom, lineTo, instanceId, filename); + 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); + } + + /** + * Try canonical hash first (matches the new AuditProcessor form), then legacy raw-content + * hash so pre-fix FPRs still match. Try each with BOTH UTF-8 and the file's declared source + * encoding — the doc's "5 of 53 files not valid UTF-8" case fails when audit and apply disagree + * on the encoding used for the getBytes step; accepting the source-encoding form covers it. + */ + private boolean tryHashMatch(String instanceId, String filename, String fileHash, Charset sourceEncoding, String content, + String originalContent) { + String canonicalStr = FileUtil.canonicalizeForHash(content); + String legacyStr = originalContent; + String canonicalHashUtf8 = calculateHashBase64Bytes(canonicalStr.getBytes(StandardCharsets.UTF_8), "SHA-256"); + String legacyHashUtf8 = calculateHashBase64Bytes(legacyStr.getBytes(StandardCharsets.UTF_8), "SHA-256"); + String canonicalHashSrc = calculateHashBase64Bytes(canonicalStr.getBytes(sourceEncoding), "SHA-256"); + String legacyHashSrc = calculateHashBase64Bytes(legacyStr.getBytes(sourceEncoding), "SHA-256"); + boolean fileHashMatches; + String matchedForm; + if (canonicalHashUtf8.equals(fileHash)) { + fileHashMatches = true; + matchedForm = "canonical"; + } else if (legacyHashUtf8.equals(fileHash)) { + fileHashMatches = true; + matchedForm = "legacy"; + } else if (canonicalHashSrc.equals(fileHash)) { + fileHashMatches = true; + matchedForm = "canonical/" + sourceEncoding.name(); + } else if (legacyHashSrc.equals(fileHash)) { + fileHashMatches = true; + matchedForm = "legacy/" + sourceEncoding.name(); + } else { + fileHashMatches = false; + matchedForm = "none"; + } + LOG.debug("Remediation {} hash check for '{}': {}", + instanceId, filename, fileHashMatches ? ("matched (" + matchedForm + ")") : "mismatched"); + return fileHashMatches; + } + + /** + * If a prior remediation this run modified this file, project the declared range through + * the accumulated line-delta of every AppliedChange whose original range sits strictly + * before this hunk's declared start. Verify the projected position holds the expected + * OriginalCode (whitespace-insensitive). Returns {@code null} (try the fuzzy fallback + * instead) if there is no prior history, the projected range is out of bounds, or the + * anchor at the projected position doesn't match. + */ + private int[] tryOffsetProjection(String instanceId, String filename, Hunk hunk, List originalLines, + int lineFrom, int lineTo, List priorApplied, AppliedChangeLedger ledger, Path filePath) { + if (priorApplied.isEmpty()) { + return null; + } + int shift = ledger.projectOffset(filePath, lineFrom); + int projectedFrom = lineFrom + shift; + int projectedTo = lineTo + shift; + if (projectedFrom >= 1 && projectedTo >= projectedFrom && projectedTo <= originalLines.size()) { + String originalCodeText = hunk.requiredOriginalCode(); + List originalCodeLines = Arrays.asList(originalCodeText.split("\\r?\\n")); + if (linesEqualNormalized(originalLines, projectedFrom - 1, projectedTo - 1, originalCodeLines)) { + LOG.debug("Remediation {} projected via offset map for '{}': declared {}-{} shifted by {} to {}-{}", + instanceId, filename, lineFrom, lineTo, shift, projectedFrom, projectedTo); + return new int[] {projectedFrom, projectedTo}; + } else { + LOG.debug("Remediation {} projection anchor mismatch for '{}' at projected {}-{}; falling back", + instanceId, filename, projectedFrom, projectedTo); + } + } + return null; + } + + /** Context search first, then a whole-file OriginalCode fallback if no context match was found. */ + private int[] tryFuzzyAnchor(String instanceId, String filename, Hunk hunk, List originalLines, + List priorApplied, int lineFrom, int lineTo, Path filePath, AppliedChangeLedger ledger) { + int shift = ledger.projectOffset(filePath, lineFrom); + int projectedFrom = lineFrom + shift; + int projectedTo = lineTo + shift; + String contextText = hunk.requiredContextText(); + List contextLine = Arrays.asList(contextText.split("\\r?\\n")); + int contextBefore = hunk.contextBefore(); + int contextAfter = hunk.contextAfter(); + int contextLineFrom = fuzzyAnchorLocator.searchContext(instanceId, filename, originalLines, contextLine, + projectedFrom, contextBefore); + if (contextLineFrom == -1) { + LOG.debug("Context search failed for remediation {} in {}; trying whole-file OriginalCode fallback", + instanceId, filename); + String fallbackOriginalCodeText = hunk.requiredOriginalCode(); + List fallbackOriginalCodeLine = Arrays.asList(fallbackOriginalCodeText.split("\\r?\\n")); + int[] wholeFile = fuzzyAnchorLocator.searchOriginalCode(instanceId, filename, originalLines, fallbackOriginalCodeLine, + 0, originalLines.size(), 0, 0, projectedFrom, projectedTo); + if (wholeFile[0] != -1 && wholeFile[1] != -1) { + LOG.debug("Whole-file OriginalCode fallback matched remediation {} in {} at lines {}-{}", + instanceId, filename, wholeFile[0] + 1, wholeFile[1] + 1); + return new int[] {wholeFile[0] + 1, wholeFile[1] + 1}; + } else { + LOG.debug("Whole-file OriginalCode fallback failed for remediation {} in {}", instanceId, filename); + SkipReason failureReason = priorApplied.isEmpty() + ? SkipReason.SOURCE_CONTEXT_NOT_FOUND + : SkipReason.ANCHOR_DOES_NOT_MATCH; + throw new SkipRemediationException(failureReason, "Anchor not found for file '" + filename + + "'; " + (priorApplied.isEmpty() + ? "file may have changed on disk or context is missing" + : "prior remediation shifted or rewrote the anchor lines this run")); + } + } else { + LOG.debug("Context for remediation {} in {} matched at line {}", instanceId, filename, contextLineFrom + 1); + String originalCodeText = hunk.requiredOriginalCode(); + List originalCodeLine = Arrays.asList(originalCodeText.split("\\r?\\n")); + int[] lineFromTo = fuzzyAnchorLocator.searchOriginalCode(instanceId, filename, originalLines, originalCodeLine, + contextLineFrom, contextLine.size(), contextBefore, contextAfter, projectedFrom, projectedTo); + if (lineFromTo[0] == -1 || lineFromTo[1] == -1) { + LOG.debug("Original code search failed for remediation {} in {}; context line={}, original code lines={}, source lines={}", + instanceId, filename, contextLineFrom + 1, originalCodeLine.size(), originalLines.size()); + SkipReason failureReason = priorApplied.isEmpty() + ? SkipReason.ORIGINAL_CODE_NOT_FOUND + : SkipReason.ANCHOR_DOES_NOT_MATCH; + throw new SkipRemediationException(failureReason, "Original code not found for file '" + filename + + "'; " + (priorApplied.isEmpty() + ? "file may have changed on disk" + : "prior remediation altered lines inside this hunk's context window")); + } + int resultLineFrom = lineFromTo[0] + 1; + int resultLineTo = lineFromTo[1] + 1; + LOG.debug("Original code for remediation {} in {} matched at lines {}-{}", instanceId, filename, resultLineFrom, resultLineTo); + return new int[] {resultLineFrom, resultLineTo}; + } + } + + private void dropDuplicatedBoundaryTokens(List newCodeLines, List originalLines, + int lineFrom, int lineTo, String instanceId, String filename) { + if (newCodeLines.isEmpty()) return; + if (lineFrom > 1 && newCodeLines.size() > 1) { + String lineBefore = originalLines.get(lineFrom - 2); + if (boundaryLinesMatch(lineBefore, newCodeLines.get(0))) { + LOG.debug("Remediation {} for '{}': dropping duplicated leading boundary token in NewCode (matches line {})", + instanceId, filename, lineFrom - 1); + newCodeLines.remove(0); + } + } + if (lineTo < originalLines.size() && newCodeLines.size() > 1) { + String lineAfter = originalLines.get(lineTo); + if (boundaryLinesMatch(lineAfter, newCodeLines.get(newCodeLines.size() - 1))) { + LOG.debug("Remediation {} for '{}': dropping duplicated trailing boundary token in NewCode (matches line {})", + instanceId, filename, lineTo + 1); + newCodeLines.remove(newCodeLines.size() - 1); + } + } + } + + private boolean boundaryLinesMatch(String a, String b) { + if (a == null || b == null) return false; + String normA = a.trim().replaceAll("\\s+", " "); + String normB = b.trim().replaceAll("\\s+", " "); + return !normA.isEmpty() && normA.equals(normB); + } + + private void validateLineRange(int lineFrom, int lineTo, int sourceLineCount, String filename) { + if (lineFrom < 1 || lineTo < lineFrom || lineTo > sourceLineCount) { + throw new SkipRemediationException(SkipReason.REMEDIATION_LINE_RANGE_INVALID, + "Invalid remediation line range " + lineFrom + "-" + lineTo + " for file '" + filename + "'"); + } + } + + /** + * Anchor verification: line-by-line whitespace-insensitive, case-insensitive comparison + * between a slice of the current file and the expected OriginalCode. Matches the same + * normalization ({@code trim().replaceAll("\\s+", " ")}, {@code equalsIgnoreCase}) that + * {@link com.fortify.cli.aviator.util.FuzzyContextSearcher} uses so behaviour is consistent + * between the fast projection path and the fuzzy fallback. + */ + private boolean linesEqualNormalized(List source, int startInclusive, int endInclusive, List expected) { + int len = endInclusive - startInclusive + 1; + if (len != expected.size()) return false; + for (int i = 0; i < len; i++) { + String a = source.get(startInclusive + i).trim().replaceAll("\\s+", " "); + String b = expected.get(i).trim().replaceAll("\\s+", " "); + if (!a.equalsIgnoreCase(b)) return false; + } + return true; + } + + private String detectLineSeparator(String content) { + int crlfIndex = content.indexOf("\r\n"); + int lfIndex = content.indexOf('\n'); + int crIndex = content.indexOf('\r'); + + if (crlfIndex >= 0 && (lfIndex == crlfIndex + 1 || lfIndex < 0) && (crIndex == crlfIndex || crIndex < 0)) { + return "\r\n"; + } + if (lfIndex >= 0 && (crIndex < 0 || lfIndex < crIndex)) { + return "\n"; + } + if (crIndex >= 0) { + return "\r"; + } + return System.lineSeparator(); + } + + private String normalizeLineEndings(String content) { + return content.replace("\r\n", "\n").replace('\r', '\n'); + } + + private String describeLineSeparator(String lineSeparator) { + return switch (lineSeparator) { + case "\r\n" -> "CRLF"; + case "\n" -> "LF"; + case "\r" -> "CR"; + default -> "system"; + }; + } + + private String calculateHashBase64(String content, String algorithm) { + String hash; + if (content == null) { + return ""; + } + try { + MessageDigest md = MessageDigest.getInstance(algorithm); + byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); + hash = Base64.getEncoder().encodeToString(digest); + return hash; + } catch (NoSuchAlgorithmException e) { + throw new AviatorTechnicalException("Hashing algorithm not available: " + algorithm, e); + } + } + + /** Encoding-agnostic hash — caller supplies the already-encoded bytes. */ + private String calculateHashBase64Bytes(byte[] bytes, String algorithm) { + if (bytes == null) return ""; + try { + MessageDigest md = MessageDigest.getInstance(algorithm); + return Base64.getEncoder().encodeToString(md.digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new AviatorTechnicalException("Hashing algorithm not available: " + algorithm, e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java new file mode 100644 index 0000000000..143a1752ca --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java @@ -0,0 +1,86 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.classifier; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fortify.cli.aviator.fpr.remediation.model.AppliedChange; + +/** + * Per-run offset ledger. Replaces the two raw fields ({@code appliedByFile}, + * {@code pendingAppliedChanges}) that used to live directly on the orchestrator, giving the + * stage -> commit-or-discard lifecycle one explicit, independently-testable home. + * + *

Populated when a hunk is written to disk; consulted before applying subsequent hunks to + * detect SUPERSEDED/CONFLICTS and to project declared line ranges through prior edits. A fresh + * instance is used per apply operation (there is no reset method), matching the original's + * per-{@code RemediationProcessor}-instance lifetime. + */ +public final class AppliedChangeLedger { + private final Map> appliedByFile = new LinkedHashMap<>(); + private final List pendingAppliedChanges = new ArrayList<>(); + + /** Committed changes for a file, or an empty list if none have been applied yet this run. */ + public List changesFor(Path filePath) { + return appliedByFile.getOrDefault(filePath, List.of()); + } + + /** Stage a hunk this remediation intends to apply. Merged into the ledger on {@link #commitStaged()}. */ + public void stage(PendingAppliedChange pendingAppliedChange) { + pendingAppliedChanges.add(pendingAppliedChange); + } + + /** Current staging-list size, to be passed back to {@link #discardStagedSince(int)} for a partial rollback. */ + public int stagedMark() { + return pendingAppliedChanges.size(); + } + + /** Discards only the staged entries added since {@code mark} (a single file's failed staging), keeping earlier ones. */ + public void discardStagedSince(int mark) { + while (pendingAppliedChanges.size() > mark) { + pendingAppliedChanges.remove(pendingAppliedChanges.size() - 1); + } + } + + /** Discards all currently staged entries (skip/rollback of the whole remediation). */ + public void discardStaged() { + pendingAppliedChanges.clear(); + } + + /** Moves all staged entries into the committed ledger (called only after a successful write) and clears staging. */ + public void commitStaged() { + for (PendingAppliedChange pac : pendingAppliedChanges) { + appliedByFile.computeIfAbsent(pac.filePath(), k -> new ArrayList<>()) + .add(new AppliedChange(pac.instanceId(), pac.lineFrom(), pac.lineTo(), pac.deltaLines(), pac.comparisonCode())); + } + pendingAppliedChanges.clear(); + } + + /** + * Accumulated line-delta shift for {@code filePath} from every committed change whose + * original range sits strictly before {@code lineFrom}. + */ + public int projectOffset(Path filePath, int lineFrom) { + int shift = 0; + for (AppliedChange ac : changesFor(filePath)) { + if (ac.originalLineTo() < lineFrom) { + shift += ac.deltaLines(); + } + } + return shift; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java new file mode 100644 index 0000000000..2482d0683a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java @@ -0,0 +1,95 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.classifier; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.fpr.remediation.model.AppliedChange; +import com.fortify.cli.aviator.fpr.remediation.model.FileChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.fpr.remediation.model.HunkOutcome; +import com.fortify.cli.aviator.fpr.remediation.model.Remediation; + +/** + * Pre-classifies each hunk of a {@link Remediation} against the per-run {@link AppliedChangeLedger}. + * Returns {@link HunkOutcome#SUPERSEDED} if a prior applied hunk fully contains the range AND + * its fix content actually covers this hunk's proposed change (normalized, comment/whitespace- + * insensitive substring match); {@link HunkOutcome#POSSIBLY_REMEDIATED} for a fully-nested + * range whose content does NOT match what was actually written (a different fix hidden behind + * a broader one, but the location is still covered); {@link HunkOutcome#CONFLICTS} for a + * partial, non-nested overlap (coverage is genuinely ambiguous); {@link HunkOutcome#APPLIED} + * for no overlap (candidate to attempt). Identity-satisfied hunks whose exact range was written + * by a prior remediation naturally classify as SUPERSEDED, which is semantically correct. + */ +public final class HunkClassifier { + + public List classifyRemediationHunks(Remediation remediation, Path sourceBasePath, AppliedChangeLedger ledger) { + List outcomes = new ArrayList<>(); + for (FileChange fileChange : remediation.fileChanges()) { + Path filePath; + try { + filePath = fileChange.resolve(sourceBasePath); + } catch (SkipRemediationException e) { + outcomes.add(HunkOutcome.APPLIED); + continue; + } + List applied = ledger.changesFor(filePath); + for (Hunk hunk : fileChange.hunks()) { + int from; + int to; + try { + from = hunk.lineFrom(); + to = hunk.lineTo(); + } catch (Exception e) { + outcomes.add(HunkOutcome.APPLIED); + continue; + } + String candidateComparisonCode = null; + try { + candidateComparisonCode = hunk.comparisonCode(fileChange.requiredFilename()); + } catch (SkipRemediationException e) { + // Content unavailable for comparison; classifyRange falls back to range-only classification. + } + outcomes.add(classifyRange(from, to, applied, candidateComparisonCode)); + } + } + return outcomes; + } + + /** + * SUPERSEDED if nested in an AppliedChange whose written content actually covers this + * hunk's proposed fix (normalized substring match); POSSIBLY_REMEDIATED if nested but the + * content differs (the sibling fully covers this location, just not proven identical); + * CONFLICTS if there is only a partial, non-nested line overlap (neither range contains + * the other, so coverage is genuinely ambiguous); APPLIED otherwise. When either side's + * content is unavailable ({@code null}), falls back to the conservative range-only default + * of SUPERSEDED for a fully-nested range. + */ + private HunkOutcome classifyRange(int lineFrom, int lineTo, List applied, String candidateComparisonCode) { + for (AppliedChange ac : applied) { + if (ac.coversFully(lineFrom, lineTo)) { + if (ac.contentCovers(candidateComparisonCode)) { + return HunkOutcome.SUPERSEDED; + } + return HunkOutcome.POSSIBLY_REMEDIATED; + } + if (ac.overlapsPartially(lineFrom, lineTo)) { + return HunkOutcome.CONFLICTS; + } + } + return HunkOutcome.APPLIED; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java new file mode 100644 index 0000000000..dd8ce9c8ae --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java @@ -0,0 +1,19 @@ +/* + * 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.remediation.classifier; + +import java.nio.file.Path; + +public record PendingAppliedChange(Path filePath, String instanceId, int lineFrom, int lineTo, int deltaLines, + String comparisonCode) { +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RemediationCommitException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RemediationCommitException.java new file mode 100644 index 0000000000..5d1232c61c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RemediationCommitException.java @@ -0,0 +1,33 @@ +/* + * 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.remediation.exception; + +import java.util.List; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.fpr.remediation.writer.RollbackFileWrite; + +public class RemediationCommitException extends AviatorTechnicalException { + private static final long serialVersionUID = 1L; + + private final List rollbacks; + + public RemediationCommitException(String message, Throwable cause, List rollbacks) { + super(message, cause); + this.rollbacks = rollbacks; + } + + public List getRollbacks() { + return rollbacks; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RollbackRemediationException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RollbackRemediationException.java new file mode 100644 index 0000000000..1dc98c2dc7 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RollbackRemediationException.java @@ -0,0 +1,23 @@ +/* + * 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.remediation.exception; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; + +public class RollbackRemediationException extends AviatorTechnicalException { + private static final long serialVersionUID = 1L; + + public RollbackRemediationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/SkipRemediationException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/SkipRemediationException.java new file mode 100644 index 0000000000..ab379b6516 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/SkipRemediationException.java @@ -0,0 +1,37 @@ +/* + * 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.remediation.exception; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator.fpr.remediation.SkipReason; + +public class SkipRemediationException extends AviatorSimpleException { + private static final long serialVersionUID = 1L; + + private final SkipReason reason; + + public SkipRemediationException(SkipReason reason, String message) { + super(message); + this.reason = reason; + } + + public SkipRemediationException(SkipReason reason, String message, Throwable cause) { + super(message, cause); + this.reason = reason; + } + + public SkipReason getReason() { + + return reason; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.java new file mode 100644 index 0000000000..046b8415d1 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.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.fpr.remediation.model; + +/** + * Offset-map entry: records a hunk that was actually written to a file this run, + * in terms of the PRISTINE file's line numbers. {@code deltaLines} is + * (newLineCount - originalLineCount); positive means the file grew, negative means it shrunk. + * + *

Was a record; promoted to a class because the line-range/content comparisons in + * {@code classifyRange} are logic that reads these fields and belongs on the object that owns + * them, not as free functions in the caller. + */ +public final class AppliedChange { + private final String instanceId; + private final int originalLineFrom; + private final int originalLineTo; + private final int deltaLines; + private final String comparisonCode; + + public AppliedChange(String instanceId, int originalLineFrom, int originalLineTo, int deltaLines, String comparisonCode) { + this.instanceId = instanceId; + this.originalLineFrom = originalLineFrom; + this.originalLineTo = originalLineTo; + this.deltaLines = deltaLines; + this.comparisonCode = comparisonCode; + } + + public String instanceId() { + return instanceId; + } + + public int originalLineFrom() { + return originalLineFrom; + } + + public int originalLineTo() { + return originalLineTo; + } + + public int deltaLines() { + return deltaLines; + } + + public String comparisonCode() { + return comparisonCode; + } + + /** True if this change's original range fully contains [lineFrom, lineTo]. */ + public boolean coversFully(int lineFrom, int lineTo) { + return originalLineFrom <= lineFrom && lineTo <= originalLineTo; + } + + /** True if [lineFrom, lineTo] overlaps this change's original range without either side fully containing the other. */ + public boolean overlapsPartially(int lineFrom, int lineTo) { + boolean disjoint = lineTo < originalLineFrom || lineFrom > originalLineTo; + return !disjoint; + } + + /** + * True if this change's own comparison code contains the candidate's comparison code + * (normalized substring match), or if either side is unavailable for comparison — in which + * case the caller conservatively treats coverage as proven. + */ + public boolean contentCovers(String candidateComparisonCode) { + return candidateComparisonCode == null || comparisonCode == null || comparisonCode.contains(candidateComparisonCode); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/FileChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/FileChange.java new file mode 100644 index 0000000000..e8d1723ac9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/FileChange.java @@ -0,0 +1,45 @@ +/* + * 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.remediation.model; + +import java.nio.file.Path; +import java.util.List; + + +public final class FileChange { + private final String filenameRaw; + private final String hashRaw; + private final List hunks; + + public FileChange(String filenameRaw, String hashRaw, List hunks) { + this.filenameRaw = filenameRaw; + this.hashRaw = hashRaw; + this.hunks = hunks; + } + + public String requiredFilename() { + return RequiredFields.requireText(filenameRaw, "Filename"); + } + + public String requiredHash() { + return RequiredFields.requireText(hashRaw, "Hash"); + } + + public List hunks() { + return hunks; + } + + public Path resolve(Path sourceBasePath) { + return sourceBasePath.resolve(requiredFilename()).normalize(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java new file mode 100644 index 0000000000..82f59e769a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java @@ -0,0 +1,156 @@ +/* + * 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.remediation.model; + +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.fortify.cli.aviator.util.FileTypeLanguageMapperUtil; +import com.fortify.cli.aviator.util.FileUtil; +import com.fortify.cli.aviator.util.LanguageCommentMapperUtil; + + +public final class Hunk { + private final String lineFromRaw; + private final String lineToRaw; + private final String contextTextRaw; + private final String contextBeforeRaw; + private final String contextAfterRaw; + private final String originalCodeRaw; + private final String newCodeRaw; + + public Hunk(String lineFromRaw, String lineToRaw, String contextTextRaw, String contextBeforeRaw, + String contextAfterRaw, String originalCodeRaw, String newCodeRaw) { + this.lineFromRaw = lineFromRaw; + this.lineToRaw = lineToRaw; + this.contextTextRaw = contextTextRaw; + this.contextBeforeRaw = contextBeforeRaw; + this.contextAfterRaw = contextAfterRaw; + this.originalCodeRaw = originalCodeRaw; + this.newCodeRaw = newCodeRaw; + } + + public int lineFrom() { + return RequiredFields.requireInt(lineFromRaw, "LineFrom"); + } + + public int lineTo() { + return RequiredFields.requireInt(lineToRaw, "LineTo"); + } + + public String requiredContextText() { + return RequiredFields.requireText(contextTextRaw, "Context"); + } + + public int contextBefore() { + return RequiredFields.requireContextAttribute(contextBeforeRaw, "before"); + } + + public int contextAfter() { + return RequiredFields.requireContextAttribute(contextAfterRaw, "after"); + } + + public String requiredOriginalCode() { + return RequiredFields.requireText(originalCodeRaw, "OriginalCode"); + } + + public String requiredNewCode() { + return RequiredFields.requireText(newCodeRaw, "NewCode"); + } + + + public String comparisonCode(String filename) { + String normalizedCode = normalizeProposedCode(requiredNewCode(), filename); + return createComparisonCode(normalizedCode, filename); + } + + private static String normalizeProposedCode(String content, String fileName) { + if (content == null) return null; + + String language = FileTypeLanguageMapperUtil.getProgrammingLanguage( + FileUtil.getFileExtension(fileName)); + String commentSymbol = LanguageCommentMapperUtil.getProgrammingLanguageComment(language); + + if ("Unknown".equals(commentSymbol)) return trimBlankLines(content); + + String closingToken = commentSymbol.equals("" + : commentSymbol.equals("<%--") ? "--%>" : null; + + Pattern markerPattern = Pattern.compile( + "[ \\t]*" + Pattern.quote(commentSymbol) + " L\\d+" + + (closingToken != null ? "[ \\t]*" + Pattern.quote(closingToken) : "") + + "[ \\t]*$"); + + String[] lines = content.split("\\R", -1); + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < lines.length; i++) { + Matcher matcher = markerPattern.matcher(lines[i]); + result.append(matcher.find() ? lines[i].substring(0, matcher.start()) : lines[i]); + if (i < lines.length - 1) result.append(System.lineSeparator()); + } + + return trimBlankLines(result.toString()); + } + + private static String createComparisonCode(String normalizedCode, String fileName) { + if (normalizedCode == null) return null; + + String language = FileTypeLanguageMapperUtil.getProgrammingLanguage( + FileUtil.getFileExtension(fileName)); + String commentSymbol = LanguageCommentMapperUtil.getProgrammingLanguageComment(language); + + if ("Unknown".equals(commentSymbol)) { + return normalizeLiteralAliases(normalizedCode).replaceAll("\\s+", ""); + } + + String comparisonCode = normalizedCode; + String closingToken = commentSymbol.equals("" + : commentSymbol.equals("<%--") ? "--%>" : null; + + if (closingToken != null) { + comparisonCode = comparisonCode.replaceAll( + "(?s)" + Pattern.quote(commentSymbol) + ".*?" + Pattern.quote(closingToken), ""); + } else if ("//".equals(commentSymbol)) { + comparisonCode = comparisonCode.replaceAll("(?m)" + Pattern.quote(commentSymbol) + ".*$", "") + .replaceAll("(?s)/\\*.*?\\*/", ""); + } else if ("#".equals(commentSymbol)) { + comparisonCode = comparisonCode.replaceAll("(?m)" + Pattern.quote(commentSymbol) + ".*$", ""); + } + + return normalizeLiteralAliases(comparisonCode).replaceAll("\\s+", ""); + } + + /** + * Treats semantically-equivalent literal forms as identical for near-identical-fix + * comparison only; never applied to code actually written to source files. + */ + private static String normalizeLiteralAliases(String code) { + if (code == null) return null; + String normalized = code.replaceAll("'\\\\0'", "0"); + normalized = normalized.replaceAll("\\bnullptr\\b", "NULL"); + return normalized; + } + + private static String trimBlankLines(String content) { + String[] lines = content.split("\\R", -1); + int start = 0, end = lines.length - 1; + + while (start <= end && lines[start].isBlank()) start++; + while (end >= start && lines[end].isBlank()) end--; + + return start > end ? "" : + String.join(System.lineSeparator(), Arrays.copyOfRange(lines, start, end + 1)); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/HunkOutcome.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/HunkOutcome.java new file mode 100644 index 0000000000..bf14e48c5c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/HunkOutcome.java @@ -0,0 +1,18 @@ +/* + * 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.remediation.model; + +/** Per-hunk classification for the state machine. */ +public enum HunkOutcome { + APPLIED, IDENTICAL, SUPERSEDED, CONFLICTS, POSSIBLY_REMEDIATED, ANCHOR_MISMATCH +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Remediation.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Remediation.java new file mode 100644 index 0000000000..14afe3abb5 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Remediation.java @@ -0,0 +1,33 @@ +/* + * 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.remediation.model; + +import java.util.List; + +public final class Remediation { + private final String instanceId; + private final List fileChanges; + + public Remediation(String instanceId, List fileChanges) { + this.instanceId = instanceId; + this.fileChanges = fileChanges; + } + + public String instanceId() { + return instanceId; + } + + public List fileChanges() { + return fileChanges; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationDocument.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationDocument.java new file mode 100644 index 0000000000..b3b044282c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationDocument.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import java.util.List; + +public final class RemediationDocument { + private final List remediations; + + public RemediationDocument(List remediations) { + this.remediations = remediations; + } + + public List remediations() { + return remediations; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationKey.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationKey.java new file mode 100644 index 0000000000..02a7afa1a9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationKey.java @@ -0,0 +1,26 @@ +/* + * 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.remediation.model; + +import java.nio.file.Path; + +public record RemediationKey(String fileName, Path filePath, int lineFrom, int lineTo, String comparisonCode) { + + public static RemediationKey of(FileChange fileChange, Hunk hunk, Path sourceBasePath, String comparisonCode) { + String fileName = fileChange.requiredFilename(); + Path filePath = sourceBasePath.resolve(fileName).normalize(); + int lineFrom = hunk.lineFrom(); + int lineTo = hunk.lineTo(); + return new RemediationKey(fileName, filePath, lineFrom, lineTo, comparisonCode); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java new file mode 100644 index 0000000000..2cf6cd94d7 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java @@ -0,0 +1,26 @@ +/* + * 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.remediation.model; + +import java.util.Map; +import java.util.Set; + +public record RemediationMetric(int totalRemediations, int appliedRemediations, int identicalRemediations, + int supersededRemediations, int possiblyRemediatedRemediations, int skippedRemediations, + Set modifiedFiles, Map skippedByReason) { + public RemediationMetric(int totalRemediations, int appliedRemediations, int identicalRemediations, + int supersededRemediations, int skippedRemediations, Set modifiedFiles) { + this(totalRemediations, appliedRemediations, identicalRemediations, supersededRemediations, + 0, skippedRemediations, modifiedFiles, Map.of()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RequiredFields.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RequiredFields.java new file mode 100644 index 0000000000..97c8c3fa2d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RequiredFields.java @@ -0,0 +1,58 @@ +/* + * 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.remediation.model; + +import com.fortify.cli.aviator.fpr.remediation.*; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; + + +public final class RequiredFields { + + private RequiredFields() { + } + + public static String requireText(String value, String elementName) { + if (value == null) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Missing required remediation element '" + elementName + "'"); + } + return value; + } + + public static int requireInt(String value, String elementName) { + String text = requireText(value, elementName); + try { + return Integer.parseInt(text); + } catch (NumberFormatException e) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Invalid integer value for remediation element '" + elementName + "': " + text, e); + } + } + + public static int requireContextAttribute(String value, String attributeName) { + if (value == null || value.isBlank()) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Missing required remediation context attribute '" + attributeName + "'"); + } + try { + int parsedValue = Integer.parseInt(value); + if (parsedValue < 0) { + throw new NumberFormatException("negative value"); + } + return parsedValue; + } catch (NumberFormatException e) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Invalid remediation context attribute '" + attributeName + "': " + value, e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java new file mode 100644 index 0000000000..68c9361d11 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java @@ -0,0 +1,232 @@ +/* + * 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.remediation.writer; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.fpr.remediation.SkipReason; +import com.fortify.cli.aviator.fpr.remediation.applier.RemediationApplier; +import com.fortify.cli.aviator.fpr.remediation.classifier.AppliedChangeLedger; +import com.fortify.cli.aviator.fpr.remediation.classifier.PendingAppliedChange; +import com.fortify.cli.aviator.fpr.remediation.exception.RemediationCommitException; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.fpr.remediation.model.FileChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.fpr.remediation.model.Remediation; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationKey; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder.DecodeResult; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder.SourceDecodeException; +import com.fortify.cli.aviator.fpr.utils.SourceEncoder; +import com.fortify.cli.aviator.fpr.utils.SourceEncoder.SourceEncodeException; + +/** Houses the original prepareFileChanges/processFileChanges/commit/rollback/read/encode logic, unmodified. */ +public final class FileWriteCoordinator { + private static final Logger LOG = LoggerFactory.getLogger(FileWriteCoordinator.class); + + private final ISourceDecoder sourceDecoder; + private final RemediationApplier remediationApplier; + + public FileWriteCoordinator(ISourceDecoder sourceDecoder, RemediationApplier remediationApplier) { + this.sourceDecoder = sourceDecoder; + this.remediationApplier = remediationApplier; + } + + public PreparedFileChanges prepareFileChanges(Remediation remediation, Path sourceBasePath, FVDLMetadata fvdlMetadata, + Set keysToApply, AppliedChangeLedger ledger) { + String instanceId = remediation.instanceId(); + List fileChanges = remediation.fileChanges(); + if (fileChanges.isEmpty()) { + throw new SkipRemediationException(SkipReason.NO_CHANGES, "No file changes found"); + } + + Map pendingWrites = new LinkedHashMap<>(); + Set appliedKeys = new LinkedHashSet<>(); + SkipRemediationException firstFailure = null; + for (int j = 0; j < fileChanges.size(); j++) { + int appliedChangesMark = ledger.stagedMark(); + Set fileAppliedKeys = new LinkedHashSet<>(); + try { + processFileChanges(remediation, fileChanges.get(j), sourceBasePath, fvdlMetadata, pendingWrites, keysToApply, + fileAppliedKeys, ledger); + appliedKeys.addAll(fileAppliedKeys); + } catch (SkipRemediationException e) { + // Fix: a failure applying ONE file's hunk(s) in a multi-file remediation must not + // discard otherwise-valid fixes already staged for OTHER files in the same remediation. + // Roll back only this file's partial staging (it never reached pendingWrites) and continue. + ledger.discardStagedSince(appliedChangesMark); + if (firstFailure == null) { + firstFailure = e; + } + LOG.warn("Remediation {}: file change {}/{} could not be applied ({}); other file(s) in this remediation, if any, are still attempted", + instanceId, j + 1, fileChanges.size(), e.getMessage()); + } + } + if (pendingWrites.isEmpty() && firstFailure != null) { + throw firstFailure; + } + return new PreparedFileChanges(pendingWrites, appliedKeys); + } + + private boolean processFileChanges(Remediation remediation, FileChange fileChange, Path sourceBasePath, FVDLMetadata fvdlMetadata, + Map pendingWrites, Set keysToApply, Set appliedKeysOut, + AppliedChangeLedger ledger) { + + String instanceId = remediation.instanceId(); + String filename = fileChange.requiredFilename(); + Path filePath = fileChange.resolve(sourceBasePath); + LOG.debug("Processing remediation {} file change for '{}' resolved to '{}'", instanceId, filename, filePath); + + if (!filePath.startsWith(sourceBasePath)) { + throw new SkipRemediationException(SkipReason.SOURCE_FILE_OUTSIDE_SOURCE_DIR, + "Source file resolves outside source directory: " + filename); + } + + if (!isFilePresent(filePath)) { + throw new SkipRemediationException(SkipReason.SOURCE_FILE_MISSING, "Source code file not present at: " + filePath); + } + + String fileHash = fileChange.requiredHash(); + List hunks = fileChange.hunks(); + if (hunks.isEmpty()) { + throw new SkipRemediationException(SkipReason.NO_CHANGES, "No changes found for file: " + filename); + } + SourceFileContent sourceFileContent = getPendingOrSourceContent(filePath, filename, fvdlMetadata, pendingWrites); + Charset sourceEncoding = sourceFileContent.charset(); + LOG.debug("Remediation {} has {} change(s) for '{}' using source encoding {}", instanceId, hunks.size(), filename, + sourceFileContent.encodingSource()); + + String updatedContent = sourceFileContent.content(); + int appliedInThisFile = 0; + int skippedAlreadySatisfied = 0; + for (int k = 0; k < hunks.size(); k++) { + Hunk hunk = hunks.get(k); + String comparisonCode = hunk.comparisonCode(filename); + RemediationKey key = RemediationKey.of(fileChange, hunk, sourceBasePath, comparisonCode); + if (keysToApply != null && !keysToApply.contains(key)) { + LOG.info("Skipping hunk {} of remediation {} in '{}': already applied by prior identical hunk", + k + 1, instanceId, filename); + skippedAlreadySatisfied++; + continue; + } + int declaredLineFrom = hunk.lineFrom(); + int declaredLineTo = hunk.lineTo(); + int linesBeforeChange = updatedContent.split("\n", -1).length; + updatedContent = remediationApplier.applyChange(instanceId, filename, filePath, fileHash, sourceEncoding, updatedContent, + hunk, k + 1, ledger); + // Stage this hunk into the per-run offset map (merged on commit success). Delta is measured + // from the actual before/after line count of the document, not the raw NewCode line count, + // since RemediationApplier may drop duplicated boundary lines from NewCode before splicing it in. + int linesAfterChange = updatedContent.split("\n", -1).length; + int delta = linesAfterChange - linesBeforeChange; + ledger.stage(new PendingAppliedChange(filePath, instanceId, declaredLineFrom, declaredLineTo, delta, comparisonCode)); + appliedKeysOut.add(key); + appliedInThisFile++; + } + if (appliedInThisFile == 0) { + LOG.debug("Remediation {} produced no new hunks for '{}' ({} already satisfied); no write staged", + instanceId, filename, skippedAlreadySatisfied); + return true; + } + byte[] updatedBytes = encodeSourceFile(updatedContent, sourceEncoding, filename); + + pendingWrites.put(filePath, new PendingFileWrite(filename, filePath, updatedContent, sourceEncoding, + sourceFileContent.encodingSource(), updatedBytes)); + LOG.debug("Staged remediation {} for '{}' using source encoding {}; changes={}, encodedBytes={}", instanceId, filename, + sourceFileContent.encodingSource(), hunks.size(), updatedBytes.length); + return true; + } + + public void commitRemediationWrites(String instanceId, Map pendingWrites, Set modifiedFiles) + throws RemediationCommitException { + List rollbacks = new ArrayList<>(); + for (PendingFileWrite pendingWrite : pendingWrites.values()) { + try { + byte[] originalBytes = Files.readAllBytes(pendingWrite.filePath()); + rollbacks.add(new RollbackFileWrite(pendingWrite.filename(), pendingWrite.filePath(), originalBytes)); + LOG.debug("Writing remediation {} to '{}' using staged bytes; encodedBytes={}", instanceId, pendingWrite.filename(), + pendingWrite.updatedBytes().length); + Files.write(pendingWrite.filePath(), pendingWrite.updatedBytes()); + } catch (Exception e) { + throw new RemediationCommitException("Error writing source code file '" + pendingWrite.filename() + "'", e, rollbacks); + } + } + + for (PendingFileWrite pendingWrite : pendingWrites.values()) { + modifiedFiles.add(pendingWrite.filename()); + LOG.info("Remediation applied for {} in file {}", instanceId, pendingWrite.filename()); + } + } + + public void rollbackRemediationWrites(String instanceId, List rollbacks) { + for (RollbackFileWrite rollback : rollbacks) { + try { + Files.write(rollback.filePath(), rollback.originalBytes()); + LOG.warn("Rolled back remediation {} changes for '{}' after write failure", instanceId, rollback.filename()); + } catch (IOException rollbackException) { + LOG.error("Failed to roll back remediation {} changes for '{}'", instanceId, rollback.filename(), rollbackException); + throw new com.fortify.cli.aviator.fpr.remediation.exception.RollbackRemediationException( + "Failed to roll back remediation changes for '" + rollback.filename() + + "'. Source files may be partially modified; inspect the source tree before retrying", rollbackException); + } + } + } + + private SourceFileContent getPendingOrSourceContent(Path filePath, String filename, FVDLMetadata fvdlMetadata, + Map pendingWrites) { + PendingFileWrite pendingWrite = pendingWrites.get(filePath); + return pendingWrite == null + ? readSourceFile(filePath, filename, fvdlMetadata) + : new SourceFileContent(pendingWrite.content(), pendingWrite.charset(), pendingWrite.encodingSource()); + } + + private boolean isFilePresent(Path path) { + return Files.exists(path) && Files.isRegularFile(path); + } + + private SourceFileContent readSourceFile(Path filePath, String filename, FVDLMetadata fvdlMetadata) { + try { + byte[] sourceBytes = Files.readAllBytes(filePath); + // Metadata may be null (FVDL missing); FPR candidate fails and other encodings are tried. + DecodeResult decodeResult = sourceDecoder.decode(sourceBytes, filename, fvdlMetadata); + LOG.debug("Strict decoded '{}' using {}; sourceBytes={}, decodedChars={}", filename, decodeResult.source(), sourceBytes.length, + decodeResult.content().length()); + return new SourceFileContent(decodeResult.content(), decodeResult.charset(), decodeResult.source()); + } catch (SourceDecodeException e) { + throw new SkipRemediationException(SkipReason.SOURCE_DECODE_FAILED, e.getMessage(), e); + } catch (IOException e) { + throw new SkipRemediationException(SkipReason.SOURCE_READ_FAILED, "Error reading source code file '" + filePath + "'", e); + } + } + + private byte[] encodeSourceFile(String content, Charset charset, String filename) { + try { + return SourceEncoder.encode(content, charset, filename); + } catch (SourceEncodeException e) { + throw new SkipRemediationException(SkipReason.REMEDIATION_ENCODE_FAILED, e.getMessage(), e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PendingFileWrite.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PendingFileWrite.java new file mode 100644 index 0000000000..de21b33729 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PendingFileWrite.java @@ -0,0 +1,20 @@ +/* + * 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.remediation.writer; + +import java.nio.charset.Charset; +import java.nio.file.Path; + +public record PendingFileWrite(String filename, Path filePath, String content, Charset charset, String encodingSource, + byte[] updatedBytes) { +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PreparedFileChanges.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PreparedFileChanges.java new file mode 100644 index 0000000000..da58b64951 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PreparedFileChanges.java @@ -0,0 +1,23 @@ +/* + * 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.remediation.writer; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; + +import com.fortify.cli.aviator.fpr.remediation.model.RemediationKey; + + +public record PreparedFileChanges(Map pendingWrites, Set appliedKeys) { +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/RollbackFileWrite.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/RollbackFileWrite.java new file mode 100644 index 0000000000..c304ee8aa6 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/RollbackFileWrite.java @@ -0,0 +1,18 @@ +/* + * 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.remediation.writer; + +import java.nio.file.Path; + +public record RollbackFileWrite(String filename, Path filePath, byte[] originalBytes) { +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/SourceFileContent.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/SourceFileContent.java new file mode 100644 index 0000000000..abc50e7d60 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/SourceFileContent.java @@ -0,0 +1,40 @@ +/* + * 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.remediation.writer; + +import java.nio.charset.Charset; + + +public final class SourceFileContent { + private final String content; + private final Charset charset; + private final String encodingSource; + + public SourceFileContent(String content, Charset charset, String encodingSource) { + this.content = content; + this.charset = charset; + this.encodingSource = encodingSource; + } + + public String content() { + return content; + } + + public Charset charset() { + return charset; + } + + public String encodingSource() { + return encodingSource; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationDocumentMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationDocumentMapper.java new file mode 100644 index 0000000000..993be55062 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationDocumentMapper.java @@ -0,0 +1,88 @@ +/* + * 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.remediation.xmlprocessor; + +import java.util.ArrayList; +import java.util.List; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import com.fortify.cli.aviator.fpr.remediation.model.FileChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.fpr.remediation.model.Remediation; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationDocument; + +/** + * Phase 2: maps a DOM {@link Document} into the domain model, without any additional logic — + * no normalization, no comparison codes, no classification, and (deliberately) no validation: + * fields whose element/attribute is absent are stored as {@code null}/blank rather than + * throwing, so that phase 3 code can validate lazily at the exact point of use, exactly as the + * original single-class implementation did. + */ +public final class RemediationDocumentMapper { + private static final String NAMESPACE_URI = "xmlns://www.fortify.com/schema/remediations"; + + public RemediationDocument map(Document remediationDoc) { + NodeList remediationNodes = remediationDoc.getElementsByTagNameNS(NAMESPACE_URI, "Remediation"); + List remediations = new ArrayList<>(); + for (int i = 0; i < remediationNodes.getLength(); i++) { + remediations.add(mapRemediation((Element) remediationNodes.item(i))); + } + return new RemediationDocument(remediations); + } + + private Remediation mapRemediation(Element remediationElement) { + String instanceId = remediationElement.getAttribute("instanceId"); + NodeList fileChangesNodes = remediationElement.getElementsByTagNameNS(NAMESPACE_URI, "FileChanges"); + List fileChanges = new ArrayList<>(); + for (int i = 0; i < fileChangesNodes.getLength(); i++) { + fileChanges.add(mapFileChange((Element) fileChangesNodes.item(i))); + } + return new Remediation(instanceId, fileChanges); + } + + private FileChange mapFileChange(Element fileChangesElement) { + String filename = optionalElementText(fileChangesElement, "Filename"); + String hash = optionalElementText(fileChangesElement, "Hash"); + NodeList changeNodes = fileChangesElement.getElementsByTagNameNS(NAMESPACE_URI, "Change"); + List hunks = new ArrayList<>(); + for (int i = 0; i < changeNodes.getLength(); i++) { + hunks.add(mapHunk((Element) changeNodes.item(i))); + } + return new FileChange(filename, hash, hunks); + } + + private Hunk mapHunk(Element changeElement) { + String lineFrom = optionalElementText(changeElement, "LineFrom"); + String lineTo = optionalElementText(changeElement, "LineTo"); + Element contextElement = optionalElement(changeElement, "Context"); + String contextText = contextElement == null ? null : contextElement.getTextContent(); + String contextBefore = contextElement == null ? null : contextElement.getAttribute("before"); + String contextAfter = contextElement == null ? null : contextElement.getAttribute("after"); + String originalCode = optionalElementText(changeElement, "OriginalCode"); + String newCode = optionalElementText(changeElement, "NewCode"); + return new Hunk(lineFrom, lineTo, contextText, contextBefore, contextAfter, originalCode, newCode); + } + + private String optionalElementText(Element parent, String elementName) { + Element element = optionalElement(parent, elementName); + return element == null ? null : element.getTextContent(); + } + + private Element optionalElement(Element parent, String elementName) { + NodeList nodes = parent.getElementsByTagNameNS(NAMESPACE_URI, elementName); + return nodes.getLength() == 0 ? null : (Element) nodes.item(0); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java new file mode 100644 index 0000000000..b81cf5ba87 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java @@ -0,0 +1,51 @@ +/* + * 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.remediation.xmlprocessor; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.xml.sax.SAXException; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; + +/** Phase 1: parses {@code remediations.xml} into a DOM {@link Document}. No mapping, no business logic. */ +public final class RemediationXmlReader { + private static final Logger LOG = LoggerFactory.getLogger(RemediationXmlReader.class); + + public Document read(Path remediationPath) { + try (InputStream remediationStream = Files.newInputStream(remediationPath)) { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(remediationStream); + } catch (ParserConfigurationException | SAXException | IOException e) { + LOG.error("Error parsing remediations.xml file: {}", remediationPath, e); + throw new AviatorTechnicalException("Error processing remediation.xml file.", e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FileUtil.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FileUtil.java index 5a50574d37..e78b6c8873 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FileUtil.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FileUtil.java @@ -21,6 +21,7 @@ import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.Comparator; +import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.zip.ZipInputStream; @@ -30,6 +31,7 @@ import com.fortify.cli.common.exception.FcliTechnicalException; + public final class FileUtil { private static final Logger LOG = LoggerFactory.getLogger(FileTypeLanguageMapperUtil.class); @@ -130,4 +132,65 @@ public static void writeStringToFile(Path filePath, String content, boolean over throw new FcliTechnicalException("Error writing to file " + absolutePath, e); } } -} \ No newline at end of file + + /** + * Canonical form for file hashing. Normalises line endings to LF and strips a single + * trailing newline. Both the audit side (writing the hash into remediations.xml) and the + * apply side (verifying it) must call this before hashing so the two sides agree + * byte-for-byte regardless of the OS that ran the audit or whether the file had a + * trailing newline on disk. Callers hash the UTF-8 bytes of the returned string. + */ + public static String canonicalizeForHash(String content) { + if (content == null) return ""; + String normalized = content.replace("\r\n", "\n").replace('\r', '\n'); + if (normalized.endsWith("\n")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + public static String stripSyntheticLineMarkers(String content, String fileName) { + if (content == null || content.isEmpty()) { + return content; + } + String language = FileTypeLanguageMapperUtil.getProgrammingLanguage(getFileExtension(fileName)); + String commentSymbol = LanguageCommentMapperUtil.getProgrammingLanguageComment(language); + String stripped = content; + if (!"Unknown".equals(commentSymbol)) { + String closingToken = commentSymbol.equals("" + : commentSymbol.equals("<%--") ? "--%>" + : null; + Pattern markerPattern = Pattern.compile( + "[ \\t]*" + Pattern.quote(commentSymbol) + " L\\d+" + + (closingToken != null ? "[ \\t]*" + Pattern.quote(closingToken) : "") + + "[ \\t]*$"); + String[] lines = content.split("\\R", -1); + StringBuilder result = new StringBuilder(); + for (int i = 0; i < lines.length; i++) { + Matcher matcher = markerPattern.matcher(lines[i]); + result.append(matcher.find() ? lines[i].substring(0, matcher.start()) : lines[i]); + if (i < lines.length - 1) { + result.append('\n'); + } + } + stripped = result.toString(); + } + return stripped; + } + + private static String trimBlankLines(String content) { + String[] lines = content.split("\\R", -1); + int start = 0; + int end = lines.length - 1; + while (start <= end && lines[start].isBlank()) start++; + while (end >= start && lines[end].isBlank()) end--; + if (start > end) return ""; + StringBuilder sb = new StringBuilder(); + for (int i = start; i <= end; i++) { + sb.append(lines[i]); + if (i < end) sb.append('\n'); + } + return sb.toString(); + } + +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java index 4a4b8e6dd5..db4f901e60 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java @@ -93,8 +93,20 @@ private static Integer findContextMatchStart(List normalizedSource, List } public static int[] fuzzySearchOriginalCode(List sourceLines, List originalCodeLine, int maxMismatches, int startIndex) { + List matches = fuzzySearchOriginalCodeMatches(sourceLines, originalCodeLine, maxMismatches, startIndex); + return matches.isEmpty() ? new int[] {-1, -1} : matches.get(0); + } + + /** + * Same search as {@link #fuzzySearchOriginalCode}, but returns every {@code {lineFrom, lineTo}} + * match found in the searched range instead of only the first, so callers can detect an + * ambiguous (multiple-candidate) match rather than silently acting on whichever occurrence + * comes first in scan order. + */ + public static List fuzzySearchOriginalCodeMatches(List sourceLines, List originalCodeLine, int maxMismatches, int startIndex) { List normalizedSource = normalizeLines(sourceLines); List normalizedOriginalCode = normalizeLines(originalCodeLine); + List matches = new ArrayList<>(); for (int i = Math.max(0, startIndex); i < normalizedSource.size(); i++) { if (normalizedSource.get(i).isEmpty()) { @@ -103,11 +115,11 @@ public static int[] fuzzySearchOriginalCode(List sourceLines, List normalizedSource, List normalizedOriginalCode, int maxMismatches, 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 fe171bb4b7..602e7e2adb 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 @@ -17,13 +17,20 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessor; +import com.fortify.cli.aviator.fpr.remediation.model.*; +import com.fortify.cli.aviator.util.FileUtil; import com.fortify.cli.aviator.util.FprHandle; class RemediationProcessorTest { @@ -32,13 +39,40 @@ class RemediationProcessorTest { @TempDir Path tempDir; + /** + * Fix #2 (exact-line disambiguation): two identical context blocks are ambiguous on their + * own, but the declared/projected LineFrom lands exactly on the first occurrence, so it + * resolves deterministically instead of being skipped as ambiguous. + */ @Test - void skipsAmbiguousContextWithoutChangingSource() throws Exception { + void resolvesAmbiguousContextByExactDeclaredPosition() throws Exception { String originalSource = "before\nTARGET\nafter\nbefore\nTARGET\nafter\n"; Path sourceFile = writeSourceFile(originalSource); Path fprPath = createRemediationFpr(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"); - RemediationProcessor.RemediationMetric metric; + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("before\nREPLACED\nafter\nbefore\nTARGET\nafter\n", Files.readString(sourceFile)); + } + + /** + * Fix #2 must not guess: when the declared/projected position doesn't exactly match any of + * the ambiguous candidates, it still throws SOURCE_CONTEXT_AMBIGUOUS rather than picking one. + */ + @Test + void skipsAmbiguousContextWhenDeclaredPositionMatchesNoCandidate() throws Exception { + String originalSource = "before\nTARGET\nafter\nbefore\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(99, 99, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; try (FprHandle fprHandle = new FprHandle(fprPath)) { metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); } @@ -51,13 +85,12 @@ void skipsAmbiguousContextWithoutChangingSource() throws Exception { metric.skippedByReason()); assertEquals(originalSource, Files.readString(sourceFile)); } - @Test void appliesRemediationWhenContextMatchesOnce() throws Exception { Path sourceFile = writeSourceFile("before\nTARGET\nafter\n"); Path fprPath = createRemediationFpr(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"); - RemediationProcessor.RemediationMetric metric; + RemediationMetric metric; try (FprHandle fprHandle = new FprHandle(fprPath)) { metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); } @@ -72,7 +105,7 @@ void appliesOriginalCodeAfterLeadingContextLines() throws Exception { Path sourceFile = writeSourceFile("TARGET\nkeep\nTARGET\nafter\n"); Path fprPath = createRemediationFpr(3, 3, 2, 1, "TARGET\nkeep\nTARGET\nafter", "TARGET", "REPLACED"); - RemediationProcessor.RemediationMetric metric; + RemediationMetric metric; try (FprHandle fprHandle = new FprHandle(fprPath)) { metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); } @@ -86,7 +119,7 @@ void appliesRemediationWhenContextStartsWithBlankLine() throws Exception { Path sourceFile = writeSourceFile("header\n\nTARGET\nafter\n"); Path fprPath = createRemediationFpr(3, 3, 1, 1, "\nTARGET\nafter", "TARGET", "REPLACED"); - RemediationProcessor.RemediationMetric metric; + RemediationMetric metric; try (FprHandle fprHandle = new FprHandle(fprPath)) { metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); } @@ -95,8 +128,306 @@ void appliesRemediationWhenContextStartsWithBlankLine() throws Exception { assertEquals("header\n\nREPLACED\nafter\n", Files.readString(sourceFile)); } + @Test + void nestedRemediationWithDifferentContentIsPossiblyRemediated() throws Exception { + Path sourceFile = writeSourceFile("before\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("wide-fix", 1, 3, 0, 0, "before\nTARGET\nafter", + "before\nTARGET\nafter", "wideline1\nwideline2\nwideline3"), + new RemediationSpec("narrow-fix", 2, 2, 1, 1, "before\ntarget\nafter", + "TARGET", "NARROW_DIFFERENT"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.possiblyRemediatedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("wideline1\nwideline2\nwideline3\n", Files.readString(sourceFile)); + } + + /** + * Fix #1 (boundary-token duplication): NewCode repeats the line immediately before LineFrom + * verbatim as its first line; that duplicate must be dropped rather than doubling the line. + */ + @Test + void dropsDuplicatedLeadingBoundaryLineInNewCode() throws Exception { + Path sourceFile = writeSourceFile("line1\nline2\nline3\n"); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "line1\nline2\nline3", "line2", "line1\nreplaced2"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("line1\nreplaced2\nline3\n", Files.readString(sourceFile)); + } + + /** + * Fix #1 (boundary-token duplication): NewCode repeats the line immediately after LineTo + * verbatim as its last line; that duplicate must be dropped rather than doubling the line. + */ + @Test + void dropsDuplicatedTrailingBoundaryLineInNewCode() throws Exception { + Path sourceFile = writeSourceFile("line1\nline2\nline3\n"); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "line1\nline2\nline3", "line2", "replaced2\nline3"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("line1\nreplaced2\nline3\n", Files.readString(sourceFile)); + } + + /** + * Regression test: the offset ledger must record the delta actually spliced into the file + * (post boundary-dedup), not the raw NewCode line count. A prior hunk in this file drops a + * duplicated boundary line (fix #1), shrinking the file by one more line than NewCode's raw + * length implies. A later hunk in the same file targets the second of two identical "TARGET" + * blocks; only a correctly-shifted projection lands exactly on it. With the pre-fix (buggy) + * delta, the projected position misses, the fuzzy fallback's context match is ambiguous + * between the two blocks, and neither lands on the declared/projected position either - + * causing an incorrect skip instead of resolving to the second occurrence. + */ + @Test + void offsetLedgerAccountsForDedupWhenProjectingLaterHunkInSameFile() throws Exception { + String originalSource = "line0\nhead1\nhead2\nhead3\nbefore\nTARGET\nafter\nbefore\nTARGET\nafter\ntail\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("hunkA", 2, 4, 1, 1, "line0\nhead1\nhead2\nhead3\nbefore", + "head1\nhead2\nhead3", "line0\nreplacedHead"), + new RemediationSpec("hunkB", 9, 9, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("line0\nreplacedHead\nbefore\nTARGET\nafter\nbefore\nREPLACED\nafter\ntail\n", + Files.readString(sourceFile)); + } + + /** + * Fix #1 (boundary-token duplication): NewCode repeats BOTH the line before LineFrom and the + * line after LineTo verbatim in the same hunk. Both duplicates must be dropped, not just one - + * dropDuplicatedBoundaryTokens checks leading and trailing independently, so this proves + * neither check clobbers or is skipped because of the other having already mutated the list. + */ + @Test + void dropsBothDuplicatedBoundaryLinesWhenNewCodeRepeatsBothNeighbors() throws Exception { + Path sourceFile = writeSourceFile("line1\nline2\nline3\n"); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "line1\nline2\nline3", "line2", + "line1\nreplaced2\nline3"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("line1\nreplaced2\nline3\n", Files.readString(sourceFile)); + } + + /** + * Fix #2 (exact-line disambiguation) must also cover OriginalCode search, not just Context + * search: two identical single-line OriginalCode matches inside the context window are + * ambiguous on their own, but the declared/projected LineFrom lands exactly on the second + * occurrence, so it resolves deterministically instead of being skipped as ambiguous. + */ + @Test + void resolvesAmbiguousOriginalCodeByExactDeclaredPosition() throws Exception { + String originalSource = "before\nTARGET\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(3, 3, 1, 1, "before\nTARGET\nTARGET\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("before\nTARGET\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * Companion to the above: when the declared/projected position matches none of the ambiguous + * OriginalCode candidates, it must throw ORIGINAL_CODE_AMBIGUOUS rather than guessing one. + */ + @Test + void skipsAmbiguousOriginalCodeWhenDeclaredPositionMatchesNoCandidate() throws Exception { + String originalSource = "before\nTARGET\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(99, 99, 1, 1, "before\nTARGET\nTARGET\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(0, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of("Original code matched multiple locations", 1), metric.skippedByReason()); + assertEquals(originalSource, Files.readString(sourceFile)); + } + + /** + * Phase 2 "make the hash check work": when the declared Hash matches the canonical form of + * the file content, the change is applied directly at the declared line range with NO context + * or OriginalCode search at all. Context and OriginalCode here are deliberately garbage text + * that appears nowhere in the source - if the hash-match fast path were not actually short- + * circuiting the fuzzy search, this remediation would be skipped as not-found. + */ + @Test + void appliesRemediationViaCanonicalHashMatchWithoutContextOrOriginalCodeSearch() throws Exception { + String originalSource = "before\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + String canonicalHash = sha256Base64(FileUtil.canonicalizeForHash(originalSource) + .getBytes(StandardCharsets.UTF_8)); + Path fprPath = createRemediationFprWithHash(2, 2, 1, 1, + "garbage-context-not-in-file\nmore-garbage\nstill-garbage", "GARBAGE-CODE-NOT-IN-FILE", + "REPLACED", canonicalHash); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * SUPERSEDED: a broader prior fix's actually-written content already contains the narrower + * candidate's proposed replacement (normalized). The narrower remediation must be recognized + * as superseded and not re-applied/re-searched at all. + */ + @Test + void supersededRemediationWithMatchingContentIsNotReapplied() throws Exception { + Path sourceFile = writeSourceFile("before\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("wide-fix", 1, 3, 0, 0, "before\nTARGET\nafter", + "before\nTARGET\nafter", "before\nREPLACED\nafter"), + new RemediationSpec("narrow-fix", 2, 2, 1, 1, "before\ntarget\nafter", + "TARGET", "REPLACED"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.supersededRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * CONFLICTS: two remediations with a partial, non-nested line overlap (neither range contains + * the other) is genuinely ambiguous coverage. Fix #2's "no heuristic guessing" philosophy + * extends here too: the later, overlapping remediation must be skipped with + * CONFLICTS_WITH_ANOTHER_FIX rather than guessed at. + */ + @Test + void conflictingOverlappingRemediationIsSkippedNotGuessed() throws Exception { + Path sourceFile = writeSourceFile("line1\nline2\nline3\nline4\nline5\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("fix-A", 2, 3, 1, 1, "line1\nline2\nline3\nline4", + "line2\nline3", "A2\nA3"), + new RemediationSpec("fix-B", 3, 4, 1, 1, "line2\nline3\nline4\nline5", + "line3\nline4", "B3\nB4"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of("Conflicts with another fix", 1), metric.skippedByReason()); + assertEquals("line1\nA2\nA3\nline4\nline5\n", Files.readString(sourceFile)); + } + + /** + * Near-identical recognition (Phase 2): two remediations at the same location whose NewCode + * differs only by a trailing Java comment normalize to the same comparisonCode. The second + * must be recognized as fully identical to the first and counted once, not re-applied and not + * leaving any trace of its own NewCode text in the file. + */ + @Test + void fullyIdenticalNearIdenticalRemediationIsCountedOnceNotReapplied() throws Exception { + Path sourceFile = writeSourceFile("before\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("fix-1", 2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"), + new RemediationSpec("fix-2", 2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED // note"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.identicalRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * Fix #4 (multi-file partial-failure isolation): one remediation touching two files where one + * file's hunk cannot be located must not discard the other file's otherwise-valid fix. The + * good file's change must still be written and counted as applied. + */ + @Test + void multiFileRemediationAppliesSucceedingFileWhenAnotherFileFails() throws Exception { + writeSourceFile("Bad.java", "one\ntwo\nthree\n"); + Path goodFile = writeSourceFile("Good.java", "before\nTARGET\nafter\n"); + Path fprPath = createMultiFileRemediationFpr("multi-file-fix", List.of( + new FileChangeSpec("Bad.java", 2, 2, 1, 1, "nomatch-a\nnomatch-b\nnomatch-c", "NOMATCH", "X"), + new FileChangeSpec("Good.java", 2, 2, 1, 1, "before\nTARGET\nafter", "TARGET", "REPLACED"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Set.of("Good.java"), metric.modifiedFiles()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(goodFile)); + assertEquals("one\ntwo\nthree\n", Files.readString(tempDir.resolve("Bad.java"))); + } + + private static String sha256Base64(byte[] bytes) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return Base64.getEncoder().encodeToString(digest.digest(bytes)); + } + private Path writeSourceFile(String content) throws Exception { - Path sourceFile = tempDir.resolve("Example.java"); + return writeSourceFile("Example.java", content); + } + + private Path writeSourceFile(String filename, String content) throws Exception { + Path sourceFile = tempDir.resolve(filename); Files.writeString(sourceFile, content, StandardCharsets.UTF_8); return sourceFile; } @@ -105,6 +436,52 @@ private Path createRemediationFpr(String context, String originalCode, String ne return createRemediationFpr(2, 2, 1, 1, context, originalCode, newCode); } + private record RemediationSpec(String instanceId, int lineFrom, int lineTo, int contextBefore, int contextAfter, + String context, String originalCode, String newCode) {} + + private Path createRemediationFpr(List specs) throws Exception { + Path fprPath = tempDir.resolve("remediation.fpr"); + StringBuilder remediations = new StringBuilder(); + for (RemediationSpec spec : specs) { + remediations.append(""" + + test + + Example.java + not-the-source-hash + + %d + %d + %s + %s + %s + + + + """.formatted(spec.instanceId(), spec.lineFrom(), spec.lineTo(), spec.contextBefore(), + spec.contextAfter(), spec.context(), spec.originalCode(), spec.newCode())); + } + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + %s + + + """.formatted(REMEDIATIONS_NAMESPACE, remediations); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } + private Path createRemediationFpr(int lineFrom, int lineTo, int contextBefore, int contextAfter, String context, String originalCode, String newCode) throws Exception { Path fprPath = tempDir.resolve("remediation.fpr"); @@ -142,4 +519,88 @@ private Path createRemediationFpr(int lineFrom, int lineTo, int contextBefore, i } return fprPath; } + + private Path createRemediationFprWithHash(int lineFrom, int lineTo, int contextBefore, int contextAfter, + String context, String originalCode, String newCode, String hash) throws Exception { + Path fprPath = tempDir.resolve("remediation.fpr"); + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + + test + + Example.java + %s + + %d + %d + %s + %s + %s + + + + + + """.formatted(REMEDIATIONS_NAMESPACE, hash, lineFrom, lineTo, contextBefore, contextAfter, + context, originalCode, newCode); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } + + private record FileChangeSpec(String filename, int lineFrom, int lineTo, int contextBefore, int contextAfter, + String context, String originalCode, String newCode) {} + + private Path createMultiFileRemediationFpr(String instanceId, List fileChangeSpecs) throws Exception { + Path fprPath = tempDir.resolve("remediation.fpr"); + StringBuilder fileChanges = new StringBuilder(); + for (FileChangeSpec spec : fileChangeSpecs) { + fileChanges.append(""" + + %s + not-the-source-hash + + %d + %d + %s + %s + %s + + + """.formatted(spec.filename(), spec.lineFrom(), spec.lineTo(), spec.contextBefore(), + spec.contextAfter(), spec.context(), spec.originalCode(), spec.newCode())); + } + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + + test + %s + + + + """.formatted(REMEDIATIONS_NAMESPACE, instanceId, fileChanges); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } } diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FileUtilTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FileUtilTest.java new file mode 100644 index 0000000000..ef6f959fe7 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FileUtilTest.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.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.config.LanguagesCommentConfig; + +class FileUtilTest { + + @BeforeAll + static void initializeCommentConfig() { + LanguagesCommentConfig commentConfig = new LanguagesCommentConfig(); + commentConfig.setLineCommentSymbols(Map.of( + "HTML", "