| 1 | // SPDX-License-Identifier: Apache-2.0 | |
| 2 | // Copyright 2026 Egothor | |
| 3 | // Copyright 2026 Accenture | |
| 4 | package org.egothor.methodatlas.evidence; | |
| 5 | ||
| 6 | import java.io.BufferedWriter; | |
| 7 | import java.io.IOException; | |
| 8 | import java.io.InputStream; | |
| 9 | import java.nio.charset.StandardCharsets; | |
| 10 | import java.nio.file.Files; | |
| 11 | import java.nio.file.Path; | |
| 12 | import java.security.MessageDigest; | |
| 13 | import java.security.NoSuchAlgorithmException; | |
| 14 | import java.util.ArrayList; | |
| 15 | import java.util.Collections; | |
| 16 | import java.util.HexFormat; | |
| 17 | import java.util.List; | |
| 18 | import java.util.stream.Stream; | |
| 19 | ||
| 20 | /** | |
| 21 | * Builds the {@code manifest.sha256} file that anchors an evidence pack. | |
| 22 | * | |
| 23 | * <p> | |
| 24 | * The manifest enumerates every file in the pack directory other than | |
| 25 | * itself and any {@code .signed} sibling, listing one entry per line in the | |
| 26 | * format {@code <hex-digest> <filename>}. Files are sorted lexicographically | |
| 27 | * by filename so the manifest is byte-stable between runs that produce the | |
| 28 | * same artefacts. | |
| 29 | * </p> | |
| 30 | * | |
| 31 | * <p> | |
| 32 | * Package-private because nothing outside the evidence module needs to call | |
| 33 | * this writer. | |
| 34 | * </p> | |
| 35 | */ | |
| 36 | final class ManifestWriter { | |
| 37 | ||
| 38 | /** Java standard digest algorithm name for SHA-256. */ | |
| 39 | private static final String DIGEST_ALGORITHM = "SHA-256"; | |
| 40 | ||
| 41 | /** Buffer size used when streaming files through the digest. */ | |
| 42 | private static final int BUFFER_SIZE = 8192; | |
| 43 | ||
| 44 | /** Filename suffix marking a signed-envelope sibling that must be excluded. */ | |
| 45 | private static final String SIGNED_SUFFIX = ".signed"; | |
| 46 | ||
| 47 | /** Two-space separator required by the shasum/coreutils manifest format. */ | |
| 48 | private static final String SEPARATOR = " "; | |
| 49 | ||
| 50 | private ManifestWriter() { | |
| 51 | // Utility class — instantiation makes no sense. | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Writes a SHA-256 manifest of every regular file in {@code dir}, except | |
| 56 | * {@code manifestFile} itself and any file whose name ends with | |
| 57 | * {@code .signed}. | |
| 58 | * | |
| 59 | * @param dir directory to enumerate; must exist | |
| 60 | * @param manifestFile output path; overwritten if it already exists | |
| 61 | * @throws IOException if directory listing, digest computation, or | |
| 62 | * writing fails | |
| 63 | */ | |
| 64 | /* default */ static void write(Path dir, Path manifestFile) throws IOException { | |
| 65 | List<Path> files = collectFiles(dir, manifestFile); | |
| 66 | try (BufferedWriter writer = Files.newBufferedWriter(manifestFile, StandardCharsets.UTF_8)) { | |
| 67 | for (Path file : files) { | |
| 68 | String digest = sha256Hex(file); | |
| 69 | writer.write(digest); | |
| 70 | writer.write(SEPARATOR); | |
| 71 | writer.write(filenameOf(file)); | |
| 72 | writer.write('\n'); | |
| 73 | } | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Returns the filename portion of {@code p} as a non-null string. Used | |
| 79 | * defensively because {@link Path#getFileName()} can theoretically | |
| 80 | * return {@code null} (root paths); inside {@link Files#list(Path)} | |
| 81 | * results that case is unreachable, but SpotBugs flags the chained | |
| 82 | * {@code toString()} on a possibly-null return. | |
| 83 | * | |
| 84 | * @param p path to inspect | |
| 85 | * @return filename as string; never {@code null} | |
| 86 | */ | |
| 87 | private static String filenameOf(Path p) { | |
| 88 | Path name = p.getFileName(); | |
| 89 |
3
1. filenameOf : removed conditional - replaced equality check with false → SURVIVED 2. filenameOf : replaced return value with "" for org/egothor/methodatlas/evidence/ManifestWriter::filenameOf → KILLED 3. filenameOf : removed conditional - replaced equality check with true → KILLED |
return name == null ? "" : name.toString(); |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Lists candidate files in lexicographic order. | |
| 94 | * | |
| 95 | * @param dir directory to enumerate | |
| 96 | * @param manifestFile manifest path to exclude | |
| 97 | * @return sorted list of files eligible for inclusion | |
| 98 | * @throws IOException if the directory cannot be listed | |
| 99 | */ | |
| 100 | private static List<Path> collectFiles(Path dir, Path manifestFile) throws IOException { | |
| 101 | List<Path> files = new ArrayList<>(); | |
| 102 | try (Stream<Path> stream = Files.list(dir)) { | |
| 103 |
2
1. lambda$collectFiles$0 : replaced boolean return with true for org/egothor/methodatlas/evidence/ManifestWriter::lambda$collectFiles$0 → SURVIVED 2. lambda$collectFiles$0 : replaced boolean return with false for org/egothor/methodatlas/evidence/ManifestWriter::lambda$collectFiles$0 → KILLED |
stream.filter(Files::isRegularFile) |
| 104 |
3
1. lambda$collectFiles$1 : removed conditional - replaced equality check with false → KILLED 2. lambda$collectFiles$1 : replaced boolean return with true for org/egothor/methodatlas/evidence/ManifestWriter::lambda$collectFiles$1 → KILLED 3. lambda$collectFiles$1 : removed conditional - replaced equality check with true → KILLED |
.filter(p -> !p.equals(manifestFile)) |
| 105 |
3
1. lambda$collectFiles$2 : removed conditional - replaced equality check with true → KILLED 2. lambda$collectFiles$2 : replaced boolean return with true for org/egothor/methodatlas/evidence/ManifestWriter::lambda$collectFiles$2 → KILLED 3. lambda$collectFiles$2 : removed conditional - replaced equality check with false → KILLED |
.filter(p -> !filenameOf(p).endsWith(SIGNED_SUFFIX)) |
| 106 | .forEach(files::add); | |
| 107 | } | |
| 108 |
1
1. lambda$collectFiles$3 : replaced int return with 0 for org/egothor/methodatlas/evidence/ManifestWriter::lambda$collectFiles$3 → KILLED |
Collections.sort(files, (a, b) -> filenameOf(a).compareTo(filenameOf(b))); |
| 109 |
1
1. collectFiles : replaced return value with Collections.emptyList for org/egothor/methodatlas/evidence/ManifestWriter::collectFiles → KILLED |
return files; |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * Computes a lowercase-hex SHA-256 digest of the supplied file. | |
| 114 | * | |
| 115 | * @param file path to digest | |
| 116 | * @return 64-character lowercase hex string | |
| 117 | * @throws IOException if reading fails or SHA-256 is unavailable | |
| 118 | */ | |
| 119 | private static String sha256Hex(Path file) throws IOException { | |
| 120 | MessageDigest digest = newDigest(); | |
| 121 | byte[] buffer = new byte[BUFFER_SIZE]; | |
| 122 | try (InputStream in = Files.newInputStream(file)) { | |
| 123 | int read; | |
| 124 |
3
1. sha256Hex : changed conditional boundary → SURVIVED 2. sha256Hex : removed conditional - replaced comparison check with false → KILLED 3. sha256Hex : removed conditional - replaced comparison check with true → KILLED |
while ((read = in.read(buffer)) >= 0) { |
| 125 | digest.update(buffer, 0, read); | |
| 126 | } | |
| 127 | } | |
| 128 |
1
1. sha256Hex : replaced return value with "" for org/egothor/methodatlas/evidence/ManifestWriter::sha256Hex → KILLED |
return HexFormat.of().formatHex(digest.digest()); |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * Creates a fresh SHA-256 digest, wrapping the checked exception into an | |
| 133 | * {@link IOException} so callers do not need a separate catch clause. | |
| 134 | * | |
| 135 | * @return new {@link MessageDigest} instance | |
| 136 | * @throws IOException when SHA-256 is unexpectedly unavailable (never on a | |
| 137 | * standard JDK) | |
| 138 | */ | |
| 139 | private static MessageDigest newDigest() throws IOException { | |
| 140 | try { | |
| 141 |
1
1. newDigest : replaced return value with null for org/egothor/methodatlas/evidence/ManifestWriter::newDigest → KILLED |
return MessageDigest.getInstance(DIGEST_ALGORITHM); |
| 142 | } catch (NoSuchAlgorithmException e) { | |
| 143 | throw new IOException("SHA-256 not available", e); | |
| 144 | } | |
| 145 | } | |
| 146 | } | |
Mutations | ||
| 89 |
1.1 2.2 3.3 |
|
| 103 |
1.1 2.2 |
|
| 104 |
1.1 2.2 3.3 |
|
| 105 |
1.1 2.2 3.3 |
|
| 108 |
1.1 |
|
| 109 |
1.1 |
|
| 124 |
1.1 2.2 3.3 |
|
| 128 |
1.1 |
|
| 141 |
1.1 |