ContentHasher.java

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
Location : hashClass
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:hashClass_returnsLowercaseHex64Characters()]
replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::hashClass → KILLED

128

1.1
Location : hashFile
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:hashFile_emptyFileMatchesPublishedShaOfEmptyInput(java.nio.file.Path)]
replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::hashFile → KILLED

167

1.1
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_emptyList_returnsEmptyString()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_relativeRoot_endsWithSlash(java.nio.file.Path)]
removed conditional - replaced equality check with true → KILLED

179

1.1
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_relativeRoot_endsWithSlash(java.nio.file.Path)]
removed conditional - replaced equality check with false → KILLED

2.2
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_relativeRoot_endsWithSlash(java.nio.file.Path)]
removed conditional - replaced equality check with false → KILLED

3.3
Location : filePrefix
Killed by : none
removed conditional - replaced equality check with true → SURVIVED
Covering tests

4.4
Location : filePrefix
Killed by : none
removed conditional - replaced equality check with true → SURVIVED Covering tests

182

1.1
Location : filePrefix
Killed by : org.egothor.methodatlas.command.ContentHasherTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.ContentHasherTest]/[method:filePrefix_relativeRoot_endsWithSlash(java.nio.file.Path)]
replaced return value with "" for org/egothor/methodatlas/command/ContentHasher::filePrefix → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.1 support