ManifestWriter.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.evidence;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;
import java.util.stream.Stream;
/**
* Builds the {@code manifest.sha256} file that anchors an evidence pack.
*
* <p>
* The manifest enumerates every file in the pack directory other than
* itself and any {@code .signed} sibling, listing one entry per line in the
* format {@code <hex-digest> <filename>}. Files are sorted lexicographically
* by filename so the manifest is byte-stable between runs that produce the
* same artefacts.
* </p>
*
* <p>
* Package-private because nothing outside the evidence module needs to call
* this writer.
* </p>
*/
final class ManifestWriter {
/** Java standard digest algorithm name for SHA-256. */
private static final String DIGEST_ALGORITHM = "SHA-256";
/** Buffer size used when streaming files through the digest. */
private static final int BUFFER_SIZE = 8192;
/** Filename suffix marking a signed-envelope sibling that must be excluded. */
private static final String SIGNED_SUFFIX = ".signed";
/** Two-space separator required by the shasum/coreutils manifest format. */
private static final String SEPARATOR = " ";
private ManifestWriter() {
// Utility class — instantiation makes no sense.
}
/**
* Writes a SHA-256 manifest of every regular file in {@code dir}, except
* {@code manifestFile} itself and any file whose name ends with
* {@code .signed}.
*
* @param dir directory to enumerate; must exist
* @param manifestFile output path; overwritten if it already exists
* @throws IOException if directory listing, digest computation, or
* writing fails
*/
/* default */ static void write(Path dir, Path manifestFile) throws IOException {
List<Path> files = collectFiles(dir, manifestFile);
try (BufferedWriter writer = Files.newBufferedWriter(manifestFile, StandardCharsets.UTF_8)) {
for (Path file : files) {
String digest = sha256Hex(file);
writer.write(digest);
writer.write(SEPARATOR);
writer.write(filenameOf(file));
writer.write('\n');
}
}
}
/**
* Returns the filename portion of {@code p} as a non-null string. Used
* defensively because {@link Path#getFileName()} can theoretically
* return {@code null} (root paths); inside {@link Files#list(Path)}
* results that case is unreachable, but SpotBugs flags the chained
* {@code toString()} on a possibly-null return.
*
* @param p path to inspect
* @return filename as string; never {@code null}
*/
private static String filenameOf(Path p) {
Path name = p.getFileName();
return name == null ? "" : name.toString();
}
/**
* Lists candidate files in lexicographic order.
*
* @param dir directory to enumerate
* @param manifestFile manifest path to exclude
* @return sorted list of files eligible for inclusion
* @throws IOException if the directory cannot be listed
*/
private static List<Path> collectFiles(Path dir, Path manifestFile) throws IOException {
List<Path> files = new ArrayList<>();
try (Stream<Path> stream = Files.list(dir)) {
stream.filter(Files::isRegularFile)
.filter(p -> !p.equals(manifestFile))
.filter(p -> !filenameOf(p).endsWith(SIGNED_SUFFIX))
.forEach(files::add);
}
Collections.sort(files, (a, b) -> filenameOf(a).compareTo(filenameOf(b)));
return files;
}
/**
* Computes a lowercase-hex SHA-256 digest of the supplied file.
*
* @param file path to digest
* @return 64-character lowercase hex string
* @throws IOException if reading fails or SHA-256 is unavailable
*/
private static String sha256Hex(Path file) throws IOException {
MessageDigest digest = newDigest();
byte[] buffer = new byte[BUFFER_SIZE];
try (InputStream in = Files.newInputStream(file)) {
int read;
while ((read = in.read(buffer)) >= 0) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
/**
* Creates a fresh SHA-256 digest, wrapping the checked exception into an
* {@link IOException} so callers do not need a separate catch clause.
*
* @return new {@link MessageDigest} instance
* @throws IOException when SHA-256 is unexpectedly unavailable (never on a
* standard JDK)
*/
private static MessageDigest newDigest() throws IOException {
try {
return MessageDigest.getInstance(DIGEST_ALGORITHM);
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 not available", e);
}
}
}