EvidencePackCommand.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.evidence;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
import org.egothor.methodatlas.AiResultCache;
import org.egothor.methodatlas.CliConfig;
import org.egothor.methodatlas.ai.AiSuggestionEngine;
import org.egothor.methodatlas.api.TestDiscoveryConfig;
import org.egothor.methodatlas.command.ContentHasher;
import org.egothor.methodatlas.command.ScanOrchestrator;
import org.egothor.methodatlas.emit.ClassificationOverride;
import org.egothor.methodatlas.emit.CompositeTestMethodSink;
import org.egothor.methodatlas.emit.OutputEmitter;
import org.egothor.methodatlas.emit.OutputMode;
import org.egothor.methodatlas.emit.SarifEmitter;
import org.egothor.methodatlas.emit.TestMethodSink;
/**
* Materialises a tamper-evident evidence pack on disk by running one scan and
* bundling every artefact an auditor needs to verify it later.
*
* <p>
* The command is selected from {@code MethodAtlasApp} when the user passes
* {@code -evidence-pack <framework>}. It owns its output directory: it
* creates the directory if absent, refuses to overwrite an existing one
* unless {@code -evidence-pack-overwrite} was supplied, writes all
* artefacts, computes a SHA-256 manifest, and optionally signs that manifest
* via ZeroEcho.
* </p>
*
* <p>
* {@code MethodAtlasApp} is the only caller; the type is {@code public} so the
* root package can construct it and read its {@link #outputDir()} and
* {@link #framework()} for the post-run summary.
* </p>
*
* @since 4.0.0
*/
public final class EvidencePackCommand {
private static final Logger LOG = Logger.getLogger(EvidencePackCommand.class.getName());
/** Exit code returned for success. */
private static final int EXIT_OK = 0;
/** Exit code returned when one or more files produced a scan error. */
private static final int EXIT_SCAN_ERROR = 1;
/** Subdirectory used when no explicit -evidence-pack-dir is supplied. */
private static final String DEFAULT_PARENT = "evidence-packs";
/** Manifest filename inside the pack. */
private static final String MANIFEST_FILE = "manifest.sha256";
/** Signed-envelope filename inside the pack. */
private static final String MANIFEST_SIGNED_FILE = "manifest.sha256.signed";
/** Filename of the SARIF artefact. */
private static final String SARIF_FILE = "findings.sarif";
/** Filename of the CSV artefact. */
private static final String CSV_FILE = "findings.csv";
/** Filename of the copied override file. */
private static final String OVERRIDES_FILE = "overrides.yaml";
/** Filename of the AI provenance file. */
private static final String AI_RESPONSES_FILE = "ai-responses.jsonl";
/** Filename of the pack metadata file. */
private static final String META_FILE = "pack-meta.json";
private final CliConfig cliConfig;
private final EvidencePackOptions packOptions;
private final TestDiscoveryConfig discoveryConfig;
private final AiSuggestionEngine aiEngine;
private final ClassificationOverride override;
private final AiResultCache aiCache;
private final ScanOrchestrator orchestrator;
/**
* Creates a new evidence-pack command.
*
* @param cliConfig parsed CLI configuration
* @param packOptions evidence-pack–specific options
* @param discoveryConfig discovery configuration forwarded to providers
* @param aiEngine AI engine, or {@code null} when AI is disabled
* @param override classification override
* @param aiCache AI result cache
* @param orchestrator pre-built scan orchestrator
*/
public EvidencePackCommand(CliConfig cliConfig, EvidencePackOptions packOptions,
TestDiscoveryConfig discoveryConfig, AiSuggestionEngine aiEngine,
ClassificationOverride override, AiResultCache aiCache,
ScanOrchestrator orchestrator) {
this.cliConfig = cliConfig;
this.packOptions = packOptions;
this.discoveryConfig = discoveryConfig;
this.aiEngine = aiEngine;
this.override = override;
this.aiCache = aiCache;
this.orchestrator = orchestrator;
}
/**
* Executes the command: runs the scan, writes every pack artefact, and —
* when a keyring is configured — signs the manifest.
*
* <p>
* Ordering matters for the integrity chain. Artefacts are written first,
* then {@code pack-meta.json} (optimistically recording whether signing was
* requested), then {@code manifest.sha256} (the SHA-256 of every artefact,
* including {@code pack-meta.json}), and finally the manifest is signed. If
* signing fails, {@code pack-meta.json} is rewritten as unsigned, any partial
* {@code manifest.sha256.signed} is deleted, and {@code manifest.sha256} is
* re-hashed so its {@code pack-meta.json} digest still matches the file on
* disk. A signing failure is non-fatal — the pack is produced unsigned.
* </p>
*
* @return {@code 0} on success, {@code 1} when one or more source files
* produced a parse or processing error
* @throws IOException if any pack artefact cannot be written
*/
public int execute() throws IOException {
Path outputDir = resolveOutputDir();
prepareOutputDir(outputDir);
List<Path> roots = cliConfig.paths().isEmpty() ? List.of(Paths.get(".")) : cliConfig.paths();
AiResponseArchive aiArchive = wireAiArchive();
ScanResult scanResult = runScan(outputDir, roots);
copyOverridesIfPresent(outputDir);
aiArchive.flush(outputDir.resolve(AI_RESPONSES_FILE));
SignResult signResult = signIfRequested(outputDir);
writePackMeta(outputDir, roots, signResult);
ManifestWriter.write(outputDir, outputDir.resolve(MANIFEST_FILE));
if (signResult != null && signResult.signer != null) {
// try-with-resources releases the signer's owned context (the hybrid
// SignatureContext) once signing completes or fails.
try (ZeroEchoSigner signer = signResult.signer) {
signer.sign(
outputDir.resolve(MANIFEST_FILE),
outputDir.resolve(MANIFEST_SIGNED_FILE));
} catch (IOException | GeneralSecurityException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Manifest signing failed", e);
}
signResult.signFailed = true;
// pack-meta.json was written with signed=true before the manifest
// was hashed; now that signing failed it is rewritten as
// signed=false. Discard any partial signature envelope and
// re-hash the manifest so its pack-meta.json digest matches the
// corrected file — otherwise an auditor would see a false tamper
// mismatch on an (intentionally) unsigned pack.
writePackMeta(outputDir, roots, signResult);
Files.deleteIfExists(outputDir.resolve(MANIFEST_SIGNED_FILE));
ManifestWriter.write(outputDir, outputDir.resolve(MANIFEST_FILE));
}
}
return scanResult.hadErrors ? EXIT_SCAN_ERROR : EXIT_OK;
}
/**
* Returns the absolute path of the produced pack directory. Useful for
* the caller's success message.
*
* @return resolved absolute output directory
*/
public Path outputDir() {
return resolveOutputDir().toAbsolutePath();
}
/**
* Returns the resolved framework name (canonical token).
*
* @return canonical framework token
*/
public String framework() {
return packOptions.framework().canonicalToken();
}
// -------------------------------------------------------------------------
// Internal steps
// -------------------------------------------------------------------------
private Path resolveOutputDir() {
if (packOptions.outputDir() != null) {
return packOptions.outputDir();
}
Path base = cliConfig.paths().isEmpty() ? Paths.get(".") : cliConfig.paths().get(0);
return base.resolve(DEFAULT_PARENT).resolve(packOptions.framework().canonicalToken());
}
private void prepareOutputDir(Path dir) throws IOException {
if (Files.exists(dir)) {
if (!packOptions.overwrite()) {
throw new IOException("Evidence pack directory already exists (use "
+ "-evidence-pack-overwrite to allow reuse): " + dir);
}
if (!Files.isDirectory(dir)) {
throw new IOException("Evidence pack path is not a directory: " + dir);
}
} else {
Files.createDirectories(dir);
}
}
private AiResponseArchive wireAiArchive() {
AiResponseArchive archive = new AiResponseArchive();
if (aiEngine != null) {
aiEngine.setResponseListener(archive);
}
return archive;
}
private ScanResult runScan(Path outputDir, List<Path> roots) throws IOException {
boolean aiEnabled = aiEngine != null;
boolean confidenceEnabled = aiEnabled && cliConfig.aiOptions().confidence();
String filePrefix = ContentHasher.filePrefix(roots);
SarifEmitter sarifEmitter = new SarifEmitter(aiEnabled, confidenceEnabled, filePrefix,
!cliConfig.sarifOmitScores());
try (PrintWriter csvWriter = new PrintWriter(
new OutputStreamWriter(
Files.newOutputStream(outputDir.resolve(CSV_FILE)),
StandardCharsets.UTF_8), true)) {
OutputEmitter csvEmitter = new OutputEmitter(csvWriter, aiEnabled, confidenceEnabled,
cliConfig.contentHash(), cliConfig.driftDetect(), false);
csvEmitter.emitCsvHeader(OutputMode.CSV);
TestMethodSink csvSink = (fqcn, method, beginLine, loc, contentHash, tags,
displayName, suggestion) ->
csvEmitter.emit(OutputMode.CSV, fqcn, method, loc, contentHash, tags,
displayName, suggestion, null);
TestMethodSink composite = new CompositeTestMethodSink(sarifEmitter, csvSink);
TestMethodSink filtered = orchestrator.filterSink(composite, cliConfig.securityOnly(),
cliConfig.minConfidence(), confidenceEnabled);
int result = orchestrator.scan(roots, cliConfig, discoveryConfig, aiEngine,
filtered, override, aiCache);
// Surface any write error swallowed by the streaming CSV writer before
// the manifest is hashed, so a truncated manifest.csv cannot be signed.
csvEmitter.finish();
writeSarif(outputDir, sarifEmitter);
return new ScanResult(result != 0);
}
}
private static void writeSarif(Path outputDir, SarifEmitter sarifEmitter) throws IOException {
try (PrintWriter sarifWriter = new PrintWriter(
new OutputStreamWriter(
Files.newOutputStream(outputDir.resolve(SARIF_FILE)),
StandardCharsets.UTF_8), true)) {
sarifEmitter.flush(sarifWriter);
}
}
private void copyOverridesIfPresent(Path outputDir) throws IOException {
Path overrideFile = cliConfig.overrideFile();
if (overrideFile != null && Files.exists(overrideFile)) {
Files.copy(overrideFile, outputDir.resolve(OVERRIDES_FILE),
StandardCopyOption.REPLACE_EXISTING);
}
}
private SignResult signIfRequested(Path outputDir) {
// A secret environment variable (CI/CD) takes precedence over a keyring
// file (interactive CLI); when neither is configured the pack is unsigned.
if (packOptions.keyringEnv() != null) {
return signFromEnv(outputDir, packOptions.keyringEnv());
}
if (packOptions.keyringFile() != null) {
return signFromFile(outputDir, packOptions.keyringFile());
}
return new SignResult(null, false);
}
private SignResult signFromEnv(Path outputDir, String envVar) {
String keyringText = System.getenv(envVar);
if (keyringText == null || keyringText.isBlank()) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Keyring environment variable {0} is unset or empty; pack will be unsigned",
envVar);
}
return new SignResult(null, true);
}
try {
ZeroEchoSigner signer = ZeroEchoSigner.fromKeyringText(keyringText,
packOptions.keyAlias(), packOptions.signatureAlgorithm());
return new SignResult(signer, false);
} catch (IOException | GeneralSecurityException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Failed to initialise ZeroEcho signer from " + envVar + " for " + outputDir, e);
}
return new SignResult(null, true);
}
}
private SignResult signFromFile(Path outputDir, Path keyringFile) {
try {
ZeroEchoSigner signer = ZeroEchoSigner.fromKeyringFile(keyringFile,
packOptions.keyAlias(), packOptions.signatureAlgorithm());
return new SignResult(signer, false);
} catch (IOException | GeneralSecurityException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Failed to initialise ZeroEcho signer for " + outputDir, e);
}
return new SignResult(null, true);
}
}
private void writePackMeta(Path outputDir, List<Path> roots, SignResult signResult)
throws IOException {
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("framework", packOptions.framework().canonicalToken());
meta.put("methodAtlasVersion", versionString());
meta.put("javaVersion", System.getProperty("java.version"));
meta.put("os", System.getProperty("os.name") + " " + System.getProperty("os.version"));
meta.put("scanRoots", roots.stream().map(p -> p.toAbsolutePath().toString()).toList());
meta.put("generatedUtc", Instant.now().toString());
boolean signed = signResult != null && signResult.signer != null && !signResult.signFailed;
meta.put("signed", signed);
meta.put("signatureAlgorithm", signed ? signResult.signer.algorithm() : null);
meta.put("zeroEchoLibVersion", signed ? ZeroEchoSigner.ZEROECHO_LIB_VERSION : null);
meta.put("keyAlias", signed ? signResult.signer.resolvedAlias() : null);
JsonMapper mapper = JsonMapper.builder()
.enable(SerializationFeature.INDENT_OUTPUT)
.build();
Files.writeString(outputDir.resolve(META_FILE),
mapper.writeValueAsString(meta), StandardCharsets.UTF_8);
}
private static String versionString() {
String impl = EvidencePackCommand.class.getPackage().getImplementationVersion();
return impl != null ? impl : "dev";
}
// -------------------------------------------------------------------------
// Internal types
// -------------------------------------------------------------------------
/** Outcome of the scan step. */
private static final class ScanResult {
/** {@code true} when at least one provider reported errors. */
private final boolean hadErrors;
/* default */ ScanResult(boolean hadErrors) {
this.hadErrors = hadErrors;
}
}
/** Outcome of the optional signing step. */
private static final class SignResult {
/** Configured signer, or {@code null} when signing was skipped. */
private final ZeroEchoSigner signer;
/** Set to {@code true} when signing was attempted but failed. */
private boolean signFailed;
/* default */ SignResult(ZeroEchoSigner signer, boolean signFailed) {
this.signer = signer;
this.signFailed = signFailed;
}
}
}