| 1 | // SPDX-License-Identifier: Apache-2.0 | |
| 2 | // Copyright 2026 Egothor | |
| 3 | // Copyright 2026 Accenture | |
| 4 | package org.egothor.methodatlas.command; | |
| 5 | ||
| 6 | import java.io.IOException; | |
| 7 | import java.nio.charset.StandardCharsets; | |
| 8 | import java.nio.file.Files; | |
| 9 | import java.nio.file.Path; | |
| 10 | import java.nio.file.Paths; | |
| 11 | import java.security.MessageDigest; | |
| 12 | import java.security.NoSuchAlgorithmException; | |
| 13 | import java.util.HexFormat; | |
| 14 | import java.util.List; | |
| 15 | ||
| 16 | /** | |
| 17 | * Pure utility helpers for content fingerprints and scan-root path prefixes. | |
| 18 | * | |
| 19 | * <p> | |
| 20 | * Two unrelated-but-small concerns live here because both are stateless, | |
| 21 | * dependency-free, and named-focused. Pulling them into one | |
| 22 | * single-responsibility utility class keeps each concern visible without | |
| 23 | * the cost of constructor injection — both methods are pure functions | |
| 24 | * mandated by their specifications (SHA-256 for hashing, OS-native | |
| 25 | * relativisation for the prefix), so there is nothing to substitute: | |
| 26 | * </p> | |
| 27 | * <ul> | |
| 28 | * <li>{@link #hashClass(String)} — computes a SHA-256 fingerprint of the | |
| 29 | * canonical pretty-printed AST text of a class. This is the value | |
| 30 | * exposed as {@code content_hash} in CSV / SARIF output and used as the | |
| 31 | * cache key by {@link org.egothor.methodatlas.AiResultCache}.</li> | |
| 32 | * <li>{@link #filePrefix(List)} — derives the forward-slashed path prefix | |
| 33 | * used in GitHub Actions workflow annotations and SARIF location URIs, | |
| 34 | * relativised to the current working directory so that paths resolve | |
| 35 | * to inline positions in PR diffs.</li> | |
| 36 | * </ul> | |
| 37 | * | |
| 38 | * <p> | |
| 39 | * Both methods are pure functions and therefore exposed as {@code static}. | |
| 40 | * Test code calls them directly with handcrafted inputs; no dependency | |
| 41 | * injection is needed because there is nothing to substitute — the SHA-256 | |
| 42 | * implementation is mandated by the Java SE specification and the path | |
| 43 | * relativisation has only one correct answer. | |
| 44 | * </p> | |
| 45 | * | |
| 46 | * @see ScanOrchestrator | |
| 47 | * @since 1.0.0 | |
| 48 | */ | |
| 49 | public final class ContentHasher { | |
| 50 | ||
| 51 | private ContentHasher() { | |
| 52 | // Utility class; instantiation is prevented to make the static | |
| 53 | // intent obvious to callers and to satisfy PMD. | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Computes a SHA-256 content fingerprint of a class source string. | |
| 58 | * | |
| 59 | * <p> | |
| 60 | * The input is expected to be the canonical AST text of the class — for | |
| 61 | * Java this is the JavaParser pretty-printed form, which normalises | |
| 62 | * whitespace and comments so that semantically equivalent classes that | |
| 63 | * differ only in formatting produce identical hashes. The output is | |
| 64 | * suitable for incremental scanning, AI-cache lookups, and audit | |
| 65 | * traceability across two pipeline stages. | |
| 66 | * </p> | |
| 67 | * | |
| 68 | * <p> | |
| 69 | * Algorithm: SHA-256 (FIPS 180-4) applied to the UTF-8 bytes of | |
| 70 | * {@code classSource}. Time complexity is {@code O(n)} in the source | |
| 71 | * size. The result is a 64-character lowercase hexadecimal string. | |
| 72 | * </p> | |
| 73 | * | |
| 74 | * <p> | |
| 75 | * Line endings are normalised to {@code \n} (CRLF and lone CR both become | |
| 76 | * LF) before hashing, so the digest is independent of the operating system | |
| 77 | * that produced the text (JavaParser pretty-printing uses the platform line | |
| 78 | * separator) and of the checkout's end-of-line conversion for file-based | |
| 79 | * language plugins. | |
| 80 | * </p> | |
| 81 | * | |
| 82 | * @param classSource canonical pretty-printed form of the class | |
| 83 | * declaration; must not be {@code null} | |
| 84 | * @return 64-character lowercase hexadecimal SHA-256 digest; never | |
| 85 | * {@code null}, never empty | |
| 86 | * @throws IllegalStateException if SHA-256 is unavailable — never in | |
| 87 | * practice, because SHA-256 is mandated by | |
| 88 | * the Java SE specification | |
| 89 | */ | |
| 90 | public static String hashClass(String classSource) { | |
| 91 | String canonical = classSource.replace("\r\n", "\n").replace('\r', '\n'); | |
| 92 | try { | |
| 93 | MessageDigest digest = MessageDigest.getInstance("SHA-256"); | |
| 94 | byte[] bytes = digest.digest(canonical.getBytes(StandardCharsets.UTF_8)); | |
| 95 |
1
1. hashClass : replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::hashClass → KILLED |
return HexFormat.of().formatHex(bytes); |
| 96 | } catch (NoSuchAlgorithmException e) { | |
| 97 | throw new IllegalStateException("SHA-256 not available", e); | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Computes a SHA-256 content fingerprint of the raw bytes of a file. | |
| 103 | * | |
| 104 | * <p> | |
| 105 | * The algorithm and output format are identical to | |
| 106 | * {@link #hashClass(String)} — SHA-256 (FIPS 180-4) followed by lowercase | |
| 107 | * hexadecimal encoding via {@link HexFormat#of()}. The only difference is | |
| 108 | * the input source: this overload reads bytes from disk verbatim rather | |
| 109 | * than taking an in-memory canonical class source string. Use it to | |
| 110 | * fingerprint configuration artefacts such as override YAML files, | |
| 111 | * taxonomy files, and AI cache CSVs whose semantic identity is the | |
| 112 | * entire file contents, not a parsed/normalised view. | |
| 113 | * </p> | |
| 114 | * | |
| 115 | * @param file path to the file to fingerprint; must not be {@code null} | |
| 116 | * and must point to a readable regular file | |
| 117 | * @return 64-character lowercase hexadecimal SHA-256 digest; never | |
| 118 | * {@code null}, never empty | |
| 119 | * @throws IOException if {@code file} cannot be read | |
| 120 | * @throws IllegalStateException if SHA-256 is unavailable — never in | |
| 121 | * practice, because SHA-256 is mandated by | |
| 122 | * the Java SE specification | |
| 123 | */ | |
| 124 | public static String hashFile(Path file) throws IOException { | |
| 125 | byte[] bytes = Files.readAllBytes(file); | |
| 126 | try { | |
| 127 | MessageDigest digest = MessageDigest.getInstance("SHA-256"); | |
| 128 |
1
1. hashFile : replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::hashFile → KILLED |
return HexFormat.of().formatHex(digest.digest(bytes)); |
| 129 | } catch (NoSuchAlgorithmException e) { | |
| 130 | throw new IllegalStateException("SHA-256 not available", e); | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Derives the forward-slashed path prefix used in GitHub Actions | |
| 136 | * workflow annotations and SARIF location URIs. | |
| 137 | * | |
| 138 | * <p> | |
| 139 | * The first configured scan root is relativised against the current | |
| 140 | * working directory and converted to forward slashes. A trailing slash | |
| 141 | * is appended unless the prefix is empty. The resulting string is | |
| 142 | * concatenated with the per-method relative path to produce annotation | |
| 143 | * paths that GitHub resolves to inline positions in the PR diff | |
| 144 | * (for example {@code src/test/java/com/acme/AuthTest.java}). | |
| 145 | * </p> | |
| 146 | * | |
| 147 | * <p> | |
| 148 | * When {@code roots} is empty the returned prefix is the empty string, | |
| 149 | * which produces unprefixed annotation paths — appropriate when no scan | |
| 150 | * root was configured because the caller is operating on the current | |
| 151 | * directory directly. | |
| 152 | * </p> | |
| 153 | * | |
| 154 | * <p> | |
| 155 | * On Windows, scan roots that resolve to a different drive than the | |
| 156 | * current working directory cannot be relativised. The method falls | |
| 157 | * back to the absolute path of the root in that case rather than | |
| 158 | * throwing. | |
| 159 | * </p> | |
| 160 | * | |
| 161 | * @param roots configured scan roots; must not be {@code null}; may be | |
| 162 | * empty | |
| 163 | * @return forward-slash path ending with {@code /}, or the empty string | |
| 164 | * when {@code roots} is empty | |
| 165 | */ | |
| 166 | public static String filePrefix(List<Path> roots) { | |
| 167 |
2
1. filePrefix : removed conditional - replaced equality check with false → KILLED 2. filePrefix : removed conditional - replaced equality check with true → KILLED |
if (roots.isEmpty()) { |
| 168 | return ""; | |
| 169 | } | |
| 170 | Path root = roots.get(0).toAbsolutePath().normalize(); | |
| 171 | String prefix; | |
| 172 | try { | |
| 173 | Path cwd = Paths.get("").toAbsolutePath(); | |
| 174 | prefix = cwd.relativize(root).toString().replace('\\', '/'); | |
| 175 | } catch (IllegalArgumentException e) { | |
| 176 | // Different drive on Windows — fall back to the absolute path. | |
| 177 | prefix = root.toString().replace('\\', '/'); | |
| 178 | } | |
| 179 |
4
1. filePrefix : removed conditional - replaced equality check with true → SURVIVED 2. filePrefix : removed conditional - replaced equality check with true → SURVIVED 3. filePrefix : removed conditional - replaced equality check with false → KILLED 4. filePrefix : removed conditional - replaced equality check with false → KILLED |
if (!prefix.isEmpty() && !prefix.endsWith("/")) { |
| 180 | prefix += "/"; | |
| 181 | } | |
| 182 |
1
1. filePrefix : replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::filePrefix → KILLED |
return prefix; |
| 183 | } | |
| 184 | } | |
Mutations | ||
| 95 |
1.1 |
|
| 128 |
1.1 |
|
| 167 |
1.1 2.2 |
|
| 179 |
1.1 2.2 3.3 4.4 |
|
| 182 |
1.1 |