diff --git a/fb-excludes.xml b/fb-excludes.xml
index 9681064f0..7dd3dae5d 100644
--- a/fb-excludes.xml
+++ b/fb-excludes.xml
@@ -18,9 +18,9 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubusercontent.com/spotbugs/spotbugs/3.1.0/spotbugs/etc/findbugsfilter.xsd">
-
+
-
+
diff --git a/pom.xml b/pom.xml
index 952faf3a0..abc154455 100644
--- a/pom.xml
+++ b/pom.xml
@@ -104,9 +104,18 @@
true
2.21.3
+ 2.0.17
+
+ org.slf4j
+ slf4j-bom
+ ${commons.slf4j.version}
+ pom
+ import
+
+
com.fasterxml.jackson
jackson-bom
@@ -173,10 +182,29 @@
commons-compress
1.28.0
+
+ org.apache.commons
+ commons-lang3
+ 3.20.0
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 3.2.8
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
com.fasterxml.jackson.core
jackson-annotations
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+ runtime
+
org.apache.maven.plugin-testing
maven-plugin-testing-harness
@@ -194,11 +222,34 @@
junit-jupiter
test
+
+ net.javacrumbs.json-unit
+ json-unit
+ 2.40.1
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
org.junit.vintage
junit-vintage-engine
test
+
+ org.mockito
+ mockito-core
+ 4.11.0
+ test
+
+
+ org.apache.commons
+ commons-exec
+ 1.6.0
+ test
+
org.apache.maven
@@ -229,6 +280,11 @@
+
+ org.slf4j
+ slf4j-simple
+ test
+
clean verify apache-rat:check checkstyle:check spotbugs:check javadoc:javadoc site
@@ -246,6 +302,25 @@
+
+
+
+ src/test/resources
+ true
+
+ attestations/**
+ plugin.properties
+
+
+
+ src/test/resources
+ false
+
+ attestations/**
+ plugin.properties
+
+
+
@@ -538,7 +613,7 @@
com.github.spotbugs
spotbugs-maven-plugin
-
+
diff --git a/src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java
new file mode 100644
index 000000000..20cee902d
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.internal;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.release.plugin.slsa.v1_2.ResourceDescriptor;
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.plugin.MojoExecutionException;
+
+/**
+ * Utilities to convert {@link Artifact} from and to other types.
+ */
+public final class ArtifactUtils {
+
+ /**
+ * Maps standard JDK {@link java.security.MessageDigest} algorithm names to the in-toto digest names used in SLSA {@link ResourceDescriptor} digest sets.
+ *
+ * JDK algorithms that have no in-toto equivalent (such as {@code MD2}) are omitted.
+ *
+ * @see
+ * JDK standard {@code MessageDigest} algorithm names
+ * @see
+ * in-toto digest set specification
+ */
+ private static final Map IN_TOTO_DIGEST_NAMES;
+
+ static {
+ final Map m = new HashMap<>();
+ m.put("MD5", "md5");
+ m.put("SHA-1", "sha1");
+ m.put("SHA-224", "sha224");
+ m.put("SHA-256", "sha256");
+ m.put("SHA-384", "sha384");
+ m.put("SHA-512", "sha512");
+ m.put("SHA-512/224", "sha512_224");
+ m.put("SHA-512/256", "sha512_256");
+ m.put("SHA3-224", "sha3_224");
+ m.put("SHA3-256", "sha3_256");
+ m.put("SHA3-384", "sha3_384");
+ m.put("SHA3-512", "sha3_512");
+ IN_TOTO_DIGEST_NAMES = Collections.unmodifiableMap(m);
+ }
+
+ /**
+ * Gets a map of checksum algorithm names to hex-encoded digest values for the given artifact file.
+ *
+ * @param artifact A Maven artifact.
+ * @param algorithms JSSE names of algorithms to use
+ * @return A map of checksum algorithm names to hex-encoded digest values.
+ * @throws IOException If an I/O error occurs reading the artifact file.
+ * @throws IllegalArgumentException If any of the algorithms is not supported.
+ */
+ private static Map getChecksums(final Artifact artifact, final String... algorithms) throws IOException {
+ final Map checksums = new HashMap<>();
+ for (final String algorithm : algorithms) {
+ final String key = IN_TOTO_DIGEST_NAMES.get(algorithm);
+ if (key == null) {
+ throw new IllegalArgumentException("Invalid algorithm name for in-toto attestation: " + algorithm);
+ }
+ final DigestUtils digest = new DigestUtils(DigestUtils.getDigest(algorithm));
+ final String checksum = digest.digestAsHex(artifact.getFile());
+ checksums.put(key, checksum);
+ }
+ return checksums;
+ }
+
+ /**
+ * Gets the filename of an artifact in the default Maven repository layout, using the specified extension.
+ *
+ * @param artifact A Maven artifact.
+ * @param extension The file name extension.
+ * @return A filename.
+ */
+ public static String getFileName(final Artifact artifact, final String extension) {
+ final StringBuilder fileName = new StringBuilder();
+ fileName.append(artifact.getArtifactId()).append("-").append(artifact.getVersion());
+ if (artifact.getClassifier() != null) {
+ fileName.append("-").append(artifact.getClassifier());
+ }
+ fileName.append(".").append(extension);
+ return fileName.toString();
+ }
+
+ /**
+ * Gets the filename of an artifact in the default Maven repository layout.
+ *
+ * @param artifact A Maven artifact.
+ * @return A filename.
+ */
+ public static String getFileName(final Artifact artifact) {
+ return getFileName(artifact, artifact.getArtifactHandler().getExtension());
+ }
+
+ /**
+ * Gets the Package URL corresponding to this artifact.
+ *
+ * @param artifact A maven artifact.
+ * @return A PURL for the given artifact.
+ */
+ public static String getPackageUrl(final Artifact artifact) {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("pkg:maven/").append(artifact.getGroupId()).append("/").append(artifact.getArtifactId()).append("@").append(artifact.getVersion())
+ .append("?");
+ final String classifier = artifact.getClassifier();
+ if (classifier != null) {
+ sb.append("classifier=").append(classifier).append("&");
+ }
+ sb.append("type=").append(artifact.getType());
+ return sb.toString();
+ }
+
+ /**
+ * Converts a Maven artifact to a SLSA {@link ResourceDescriptor}.
+ *
+ * @param artifact A Maven artifact.
+ * @param algorithms A comma-separated list of checksum algorithms to use.
+ * @return A SLSA resource descriptor.
+ * @throws MojoExecutionException If an I/O error occurs retrieving the artifact.
+ */
+ public static ResourceDescriptor toResourceDescriptor(final Artifact artifact, final String algorithms) throws MojoExecutionException {
+ final ResourceDescriptor descriptor = new ResourceDescriptor()
+ .setName(getFileName(artifact))
+ .setUri(getPackageUrl(artifact));
+ if (artifact.getFile() != null) {
+ try {
+ descriptor.setDigest(getChecksums(artifact, StringUtils.split(algorithms, ",")));
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Unable to compute hash for artifact file: " + artifact.getFile(), e);
+ }
+ }
+ return descriptor;
+ }
+
+ /** No instances. */
+ private ArtifactUtils() {
+ // prevent instantiation
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java b/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java
new file mode 100644
index 000000000..5935492bb
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.internal;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.management.ManagementFactory;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.TreeMap;
+
+import org.apache.commons.release.plugin.slsa.v1_2.ResourceDescriptor;
+import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.execution.MavenSession;
+
+/**
+ * Factory methods for the SLSA {@code BuildDefinition} fields: JVM, Maven descriptors and external build parameters.
+ */
+public final class BuildDefinitions {
+
+ /**
+ * User-property names containing any of these substrings (case-insensitive) are omitted from attestations.
+ *
+ * The Maven GPG plugin discourages passing credentials on the command line, but a stray {@code -Dgpg.passphrase=...} must not be captured in the
+ * attestation if someone does it anyway.
+ */
+ private static final List SENSITIVE_KEYWORDS =
+ Arrays.asList("secret", "password", "passphrase", "token", "credential");
+
+ /**
+ * Reconstructs the Maven command line string from the given execution request.
+ * User properties whose name matches {@link #SENSITIVE_KEYWORDS} are omitted.
+ *
+ * @param request the Maven execution request
+ * @return a string representation of the Maven command line
+ */
+ static String commandLine(final MavenExecutionRequest request) {
+ final List args = new ArrayList<>(request.getGoals());
+ final String profiles = String.join(",", request.getActiveProfiles());
+ if (!profiles.isEmpty()) {
+ args.add("-P" + profiles);
+ }
+ request.getUserProperties().forEach((key, value) -> {
+ final String k = key.toString();
+ if (isNotSensitive(k)) {
+ args.add("-D" + k + "=" + value);
+ }
+ });
+ return String.join(" ", args);
+ }
+
+ /**
+ * Checks if a property key is not sensitive.
+ *
+ * @param property A property key
+ * @return {@code true} if the property is not considered sensitive
+ */
+ private static boolean isNotSensitive(final String property) {
+ final String lower = property.toLowerCase(Locale.ROOT);
+ return SENSITIVE_KEYWORDS.stream().noneMatch(lower::contains);
+ }
+
+ /**
+ * Returns a map of external build parameters captured from the current JVM and Maven session.
+ *
+ * @param session the current Maven session
+ * @return a map of parameter names to values
+ */
+ public static Map externalParameters(final MavenSession session) {
+ final Map params = new HashMap<>();
+ params.put("jvm.args", ManagementFactory.getRuntimeMXBean().getInputArguments());
+ final MavenExecutionRequest request = session.getRequest();
+ params.put("maven.goals", request.getGoals());
+ params.put("maven.profiles", request.getActiveProfiles());
+ params.put("maven.user.properties", getUserProperties(request));
+ params.put("maven.cmdline", commandLine(request));
+ final Map env = new HashMap<>();
+ params.put("env", env);
+ for (final Map.Entry entry : System.getenv().entrySet()) {
+ final String key = entry.getKey();
+ if ("TZ".equals(key) || "LANG".equals(key) || key.startsWith("LC_")) {
+ env.put(key, entry.getValue());
+ }
+ }
+ return params;
+ }
+
+ /**
+ * Returns a filtered map of user properties.
+ *
+ * @param request A Maven request
+ * @return A map of user properties.
+ */
+ private static TreeMap getUserProperties(final MavenExecutionRequest request) {
+ final TreeMap properties = new TreeMap<>();
+ request.getUserProperties().forEach((k, value) -> {
+ final String key = k.toString();
+ if (isNotSensitive(key)) {
+ properties.put(key, value.toString());
+ }
+ });
+ return properties;
+ }
+
+ /**
+ * Creates a {@link ResourceDescriptor} for the JDK used during the build.
+ *
+ * @param javaHome path to the JDK home directory (value of the {@code java.home} system property)
+ * @return a descriptor with digest and annotations populated from system properties
+ * @throws IOException if hashing the JDK directory fails
+ */
+ public static ResourceDescriptor jvm(final Path javaHome) throws IOException {
+ final String[] propertyNames = {
+ "java.home", "java.specification.maintenance.version", "java.specification.name", "java.specification.vendor", "java.specification.version",
+ "java.vendor", "java.vendor.url", "java.vendor.version", "java.version", "java.version.date", "java.vm.name", "java.vm.specification.name",
+ "java.vm.specification.vendor", "java.vm.specification.version", "java.vm.vendor", "java.vm.version"
+ };
+ final Map annotations = new TreeMap<>();
+ for (final String prop : propertyNames) {
+ annotations.put(prop.substring("java.".length()), System.getProperty(prop));
+ }
+ return new ResourceDescriptor()
+ .setName("JDK")
+ .setDigest(Collections.singletonMap("gitTree", GitUtils.gitTree(javaHome)))
+ .setAnnotations(annotations);
+ }
+
+ /**
+ * Creates a {@link ResourceDescriptor} for the Maven installation used during the build.
+ *
+ * {@code build.properties} resides in a JAR inside {@code ${maven.home}/lib/}, which is loaded by Maven's Core Classloader.
+ * Plugin code runs in an isolated Plugin Classloader, which does not see those resources. Therefore, we need to pass the classloader from a class from
+ * Maven Core, such as {@link org.apache.maven.rtinfo.RuntimeInformation}.
+ *
+ * @param version Maven version string
+ * @param mavenHome path to the Maven home directory
+ * @param coreClassLoader a classloader from Maven's Core Classloader realm, used to load core resources
+ * @return a descriptor for the Maven installation
+ * @throws IOException if hashing the Maven home directory fails
+ */
+ public static ResourceDescriptor maven(final String version, final Path mavenHome, final ClassLoader coreClassLoader) throws IOException {
+ final ResourceDescriptor descriptor = new ResourceDescriptor()
+ .setName("Maven")
+ .setUri("pkg:maven/org.apache.maven/apache-maven@" + version)
+ .setDigest(Collections.singletonMap("gitTree", GitUtils.gitTree(mavenHome)));
+ final Properties buildProps = new Properties();
+ try (InputStream in = coreClassLoader.getResourceAsStream("org/apache/maven/messages/build.properties")) {
+ if (in != null) {
+ buildProps.load(in);
+ }
+ }
+ if (!buildProps.isEmpty()) {
+ final Map annotations = new HashMap<>();
+ buildProps.forEach((key, value) -> annotations.put((String) key, value));
+ descriptor.setAnnotations(annotations);
+ }
+ return descriptor;
+ }
+
+ /**
+ * No instances.
+ */
+ private BuildDefinitions() {
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
new file mode 100644
index 000000000..22e49249d
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.internal;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+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.util.Locale;
+
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.release.plugin.slsa.v1_2.DsseEnvelope;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugin.logging.Log;
+import org.apache.maven.plugins.gpg.AbstractGpgSigner;
+import org.apache.maven.plugins.gpg.GpgSigner;
+import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.bcpg.sig.IssuerFingerprint;
+import org.bouncycastle.openpgp.PGPSignature;
+import org.bouncycastle.openpgp.PGPSignatureList;
+import org.bouncycastle.openpgp.PGPSignatureSubpacketVector;
+import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
+
+/**
+ * Utility methods for creating DSSE (Dead Simple Signing Envelope) envelopes signed with a PGP key.
+ */
+public final class DsseUtils {
+
+ /**
+ * Creates and prepares a {@link GpgSigner} from the given configuration.
+ *
+ * The returned signer has {@link AbstractGpgSigner#prepare()} already called and is ready for use with {@link #signStatement}.
+ *
+ * @param executable path to the GPG executable, or {@code null} to use {@code gpg} from {@code PATH}
+ * @param defaultKeyring whether to include the default GPG keyring
+ * @param lockMode GPG lock mode ({@code "once"}, {@code "multiple"}, or {@code "never"}), or {@code null} for no explicit lock flag
+ * @param keyname name or fingerprint of the signing key, or {@code null} for the default key
+ * @param useAgent whether to use gpg-agent for passphrase management
+ * @param log Maven logger to attach to the signer
+ * @return a prepared {@link AbstractGpgSigner}
+ * @throws MojoFailureException if {@link AbstractGpgSigner#prepare()} fails
+ */
+ public static AbstractGpgSigner createGpgSigner(final String executable, final boolean defaultKeyring, final String lockMode, final String keyname,
+ final boolean useAgent, final Log log) throws MojoFailureException {
+ final GpgSigner signer = new GpgSigner(executable);
+ signer.setDefaultKeyring(defaultKeyring);
+ signer.setLockMode(lockMode);
+ signer.setKeyName(keyname);
+ signer.setUseAgent(useAgent);
+ signer.setLog(log);
+ signer.prepare();
+ return signer;
+ }
+
+ /**
+ * Extracts the key identifier from a binary OpenPGP Signature Packet.
+ *
+ * @param sigBytes raw binary OpenPGP Signature Packet bytes
+ * @return uppercase hex-encoded fingerprint or key ID string
+ * @throws MojoExecutionException if {@code sigBytes} cannot be parsed as an OpenPGP signature
+ */
+ public static String getKeyId(final byte[] sigBytes) throws MojoExecutionException {
+ try {
+ final PGPSignatureList sigList = (PGPSignatureList) new BcPGPObjectFactory(sigBytes).nextObject();
+ final PGPSignature sig = sigList.get(0);
+ final PGPSignatureSubpacketVector hashed = sig.getHashedSubPackets();
+ if (hashed != null) {
+ final IssuerFingerprint fp = hashed.getIssuerFingerprint();
+ if (fp != null) {
+ return Hex.encodeHexString(fp.getFingerprint());
+ }
+ }
+ return Long.toHexString(sig.getKeyID()).toLowerCase(Locale.ROOT);
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Failed to extract key ID from signature", e);
+ }
+ }
+
+ /**
+ * Signs a serialized DSSE payload and returns the raw OpenPGP signature bytes.
+ *
+ * Creates a unique temporary {@code .pae} file inside {@code workDir}, containing the payload
+ * wrapped in the DSSE Pre-Authentication Encoding:
+ *
+ * PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
+ *
+ * then invokes {@code signer} on that file, reads back the un-armored PGP signature, and
+ * deletes both temporary files in a {@code finally} block. Cleanup is best-effort; a delete
+ * failure at the end of a successful sign only leaves a stray temporary file in {@code workDir}
+ * and does not fail the build.
+ *
+ * The signer must already have {@link AbstractGpgSigner#prepare()} called before this method
+ * is invoked.
+ *
+ * @param signer the configured, prepared signer
+ * @param statementBytes the already-serialized JSON statement bytes to sign
+ * @param workDir directory in which to create the intermediate PAE and signature files
+ * @return raw binary PGP signature bytes
+ * @throws MojoExecutionException if encoding, signing, or signature decoding fails
+ */
+ public static byte[] signStatement(final AbstractGpgSigner signer, final byte[] statementBytes, final Path workDir)
+ throws MojoExecutionException {
+ File paeFile = null;
+ File ascFile = null;
+ try {
+ paeFile = File.createTempFile("statement-", ".pae", workDir.toFile());
+ FileUtils.writeByteArrayToFile(paeFile, paeEncode(statementBytes));
+ ascFile = signer.generateSignatureForArtifact(paeFile);
+ try (InputStream in = Files.newInputStream(ascFile.toPath());
+ ArmoredInputStream armoredIn = new ArmoredInputStream(in)) {
+ return IOUtils.toByteArray(armoredIn);
+ }
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Failed to sign attestation statement", e);
+ } finally {
+ FileUtils.deleteQuietly(paeFile);
+ FileUtils.deleteQuietly(ascFile);
+ }
+ }
+
+ /**
+ * Encodes {@code statementBytes} using the DSSEv1 Pre-Authentication Encoding.
+ *
+ * @param statementBytes the already-serialized JSON statement bytes to encode
+ * @return the PAE-encoded bytes
+ */
+ private static byte[] paeEncode(final byte[] statementBytes) {
+ final byte[] payloadTypeBytes = DsseEnvelope.PAYLOAD_TYPE.getBytes(StandardCharsets.UTF_8);
+ final ByteArrayOutputStream pae = new ByteArrayOutputStream();
+ try {
+ pae.write(("DSSEv1 " + payloadTypeBytes.length + " ").getBytes(StandardCharsets.UTF_8));
+ pae.write(payloadTypeBytes);
+ pae.write((" " + statementBytes.length + " ").getBytes(StandardCharsets.UTF_8));
+ pae.write(statementBytes);
+ } catch (final IOException e) {
+ // ByteArrayOutputStream#write(byte[]) never throws; this branch is unreachable.
+ throw new IllegalStateException(e);
+ }
+ return pae.toByteArray();
+ }
+
+ /**
+ * Not instantiable.
+ */
+ private DsseUtils() {
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java
new file mode 100644
index 000000000..3b7c7be91
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java
@@ -0,0 +1,228 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.internal;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.codec.digest.GitIdentifiers;
+
+/**
+ * Utilities for Git operations.
+ */
+public final class GitUtils {
+
+ /**
+ * Prefix used in a {@code gitfile} to point to the Git directory.
+ *
+ * See gitrepository-layout.
+ */
+ private static final String GITDIR_PREFIX = "gitdir: ";
+ /**
+ * Maximum number of symbolic-ref hops before we give up (to avoid cycles).
+ */
+ private static final int MAX_REF_DEPTH = 5;
+ /**
+ * Prefix used in {@code HEAD} and ref files to indicate a symbolic reference.
+ */
+ private static final String REF_PREFIX = "ref: ";
+ /**
+ * The SCM URI prefix for Git repositories.
+ */
+ private static final String SCM_GIT_PREFIX = "scm:git:";
+
+ /**
+ * Walks up the directory tree from {@code path} to find the {@code .git} directory.
+ *
+ * @param path A path inside the Git repository.
+ * @return The path to the {@code .git} directory (or file for worktrees).
+ * @throws IOException If no {@code .git} directory is found.
+ */
+ private static Path findGitDir(final Path path) throws IOException {
+ Path current = path.toAbsolutePath();
+ while (current != null) {
+ final Path candidate = current.resolve(".git");
+ if (Files.isDirectory(candidate)) {
+ return candidate;
+ }
+ if (Files.isRegularFile(candidate)) {
+ // git worktree: .git is a file containing "gitdir: /path/to/real/.git"
+ final String content = new String(Files.readAllBytes(candidate), StandardCharsets.UTF_8).trim();
+ if (content.startsWith(GITDIR_PREFIX)) {
+ return current.resolve(content.substring(GITDIR_PREFIX.length()));
+ }
+ }
+ current = current.getParent();
+ }
+ throw new IOException("No .git directory found above: " + path);
+ }
+
+ /**
+ * Gets the current branch name for the given repository path.
+ *
+ * Returns the commit SHA if the repository is in a detached HEAD state.
+ *
+ * @param repositoryPath A path inside the Git repository.
+ * @return The current branch name, or the commit SHA for a detached HEAD.
+ * @throws IOException If the {@code .git} directory cannot be found or read.
+ */
+ public static String getCurrentBranch(final Path repositoryPath) throws IOException {
+ final Path gitDir = findGitDir(repositoryPath);
+ final String head = readHead(gitDir);
+ if (head.startsWith("ref: refs/heads/")) {
+ return head.substring("ref: refs/heads/".length());
+ }
+ // Detached HEAD: the file contains the commit SHA.
+ return head;
+ }
+
+ /**
+ * Gets the commit SHA pointed to by {@code HEAD}.
+ *
+ *
Handles loose refs under {@code /refs/...}, packed refs in {@code /packed-refs},
+ * symbolic indirection (a ref file that itself contains {@code ref: ...}), and detached HEAD.
+ *
+ * @param repositoryPath A path inside the Git repository.
+ * @return The hex-encoded commit SHA.
+ * @throws IOException If the {@code .git} directory cannot be found, the ref cannot be resolved,
+ * or the symbolic chain is deeper than {@value #MAX_REF_DEPTH}.
+ */
+ public static String getHeadCommit(final Path repositoryPath) throws IOException {
+ final Path gitDir = findGitDir(repositoryPath);
+ String value = readHead(gitDir);
+ for (int i = 0; i < MAX_REF_DEPTH; i++) {
+ if (!value.startsWith(REF_PREFIX)) {
+ return value;
+ }
+ value = resolveRef(gitDir, value.substring(REF_PREFIX.length()));
+ }
+ throw new IOException("Symbolic ref chain exceeds " + MAX_REF_DEPTH + " hops in: " + gitDir);
+ }
+
+ /**
+ * Returns the Git tree hash for the given directory.
+ *
+ * @param path A directory path.
+ * @return A hex-encoded SHA-1 tree hash.
+ * @throws IOException If the path is not a directory or an I/O error occurs.
+ */
+ public static String gitTree(final Path path) throws IOException {
+ if (!Files.isDirectory(path)) {
+ throw new IOException("Path is not a directory: " + path);
+ }
+ final MessageDigest digest = DigestUtils.getSha1Digest();
+ return Hex.encodeHexString(GitIdentifiers.treeId(digest, path));
+ }
+
+ /**
+ * Reads and trims the {@code HEAD} file of the given Git directory.
+ *
+ * @param gitDir The {@code .git} directory.
+ * @return The trimmed contents of {@code /HEAD}.
+ * @throws IOException If the file cannot be read.
+ */
+ private static String readHead(final Path gitDir) throws IOException {
+ return new String(Files.readAllBytes(gitDir.resolve("HEAD")), StandardCharsets.UTF_8).trim();
+ }
+
+ /**
+ * Returns the directory that holds shared repository state (loose refs, {@code packed-refs}).
+ * In a linked worktree this is read from {@code /commondir}; otherwise it is
+ * {@code gitDir} itself.
+ *
+ * @param gitDir The {@code .git} directory.
+ * @return The shared-state directory.
+ * @throws IOException If {@code commondir} exists but cannot be read.
+ */
+ private static Path resolveCommonDir(final Path gitDir) throws IOException {
+ final Path commonDir = gitDir.resolve("commondir");
+ if (Files.isRegularFile(commonDir)) {
+ final String value = new String(Files.readAllBytes(commonDir), StandardCharsets.UTF_8).trim();
+ return gitDir.resolve(value).normalize();
+ }
+ return gitDir;
+ }
+
+ /**
+ * Resolves a single ref (e.g. {@code refs/heads/foo}) to its stored value.
+ *
+ * The return value is either a commit SHA or another {@code ref: ...} line, which the caller continues to resolve.
+ *
+ * In a linked worktree, loose and packed refs are stored in the "common dir" (usually the
+ * main repository's {@code .git}), which is pointed to by {@code /commondir}.
+ *
+ * @param gitDir The {@code .git} directory.
+ * @param refPath The ref path relative to the common dir (e.g. {@code refs/heads/main}).
+ * @return Either a commit SHA or another {@code ref: ...} line to be resolved by the caller.
+ * @throws IOException If the ref is not found as a loose file or in {@code packed-refs}.
+ */
+ private static String resolveRef(final Path gitDir, final String refPath) throws IOException {
+ final Path refsDir = resolveCommonDir(gitDir);
+ final Path refFile = refsDir.resolve(refPath);
+ if (Files.isRegularFile(refFile)) {
+ return new String(Files.readAllBytes(refFile), StandardCharsets.UTF_8).trim();
+ }
+ final Path packed = refsDir.resolve("packed-refs");
+ if (Files.isRegularFile(packed)) {
+ try (BufferedReader reader = Files.newBufferedReader(packed, StandardCharsets.UTF_8)) {
+ // packed-refs format: one ref per line as " ", with '#' header lines,
+ // blank lines, and "^" peeled-tag continuation lines that we skip.
+ // See https://git-scm.com/docs/gitrepository-layout
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.isEmpty() || line.charAt(0) == '#' || line.charAt(0) == '^') {
+ continue;
+ }
+ final int space = line.indexOf(' ');
+ if (space > 0 && refPath.equals(line.substring(space + 1))) {
+ return line.substring(0, space);
+ }
+ }
+ }
+ }
+ throw new IOException("Cannot resolve ref: " + refPath);
+ }
+
+ /**
+ * Converts an SCM URI to a download URI suffixed with the current branch name.
+ *
+ * @param scmUri A Maven SCM URI starting with {@code scm:git}.
+ * @param repositoryPath A path inside the Git repository.
+ * @return A download URI of the form {@code git+@}.
+ * @throws IOException If the current branch cannot be determined.
+ */
+ public static String scmToDownloadUri(final String scmUri, final Path repositoryPath) throws IOException {
+ if (!scmUri.startsWith(SCM_GIT_PREFIX)) {
+ throw new IllegalArgumentException("Invalid scmUri: " + scmUri);
+ }
+ final String currentBranch = getCurrentBranch(repositoryPath);
+ return "git+" + scmUri.substring(SCM_GIT_PREFIX.length()) + "@" + currentBranch;
+ }
+
+ /**
+ * No instances.
+ */
+ private GitUtils() {
+ // no instantiation
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/internal/package-info.java b/src/main/java/org/apache/commons/release/plugin/internal/package-info.java
new file mode 100644
index 000000000..44c988052
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/internal/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Internal utilities
+ *
+ * Should not be referenced by external artifacts. Their API can change at any moment
+ */
+package org.apache.commons.release.plugin.internal;
diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
new file mode 100644
index 000000000..39c398ff7
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -0,0 +1,490 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.mojos;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import javax.inject.Inject;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import org.apache.commons.release.plugin.internal.ArtifactUtils;
+import org.apache.commons.release.plugin.internal.BuildDefinitions;
+import org.apache.commons.release.plugin.internal.DsseUtils;
+import org.apache.commons.release.plugin.internal.GitUtils;
+import org.apache.commons.release.plugin.slsa.v1_2.BuildDefinition;
+import org.apache.commons.release.plugin.slsa.v1_2.BuildMetadata;
+import org.apache.commons.release.plugin.slsa.v1_2.Builder;
+import org.apache.commons.release.plugin.slsa.v1_2.DsseEnvelope;
+import org.apache.commons.release.plugin.slsa.v1_2.Provenance;
+import org.apache.commons.release.plugin.slsa.v1_2.ResourceDescriptor;
+import org.apache.commons.release.plugin.slsa.v1_2.RunDetails;
+import org.apache.commons.release.plugin.slsa.v1_2.Signature;
+import org.apache.commons.release.plugin.slsa.v1_2.Statement;
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.plugins.gpg.AbstractGpgSigner;
+import org.apache.maven.project.MavenProject;
+import org.apache.maven.project.MavenProjectHelper;
+import org.apache.maven.rtinfo.RuntimeInformation;
+
+/**
+ * Generates a SLSA v1.2 in-toto attestation covering all artifacts attached to the project.
+ *
+ * The goal binds to the {@code post-integration-test} phase so the following constraints are
+ * satisfied:
+ *
+ * - it runs after {@code package}, so every build artifact is already attached to the project;
+ * - it runs before {@code maven-gpg-plugin}'s {@code sign} goal (bound to {@code verify}), so
+ * the attestation file itself receives the detached {@code .asc} signature required by
+ * Maven Central;
+ * - it runs before {@code detach-distributions} (also bound to {@code verify}), so the
+ * distribution archives ({@code tar.gz}, {@code zip}) are covered by the attestation before
+ * they are removed from the list of artifacts to deploy.
+ *
+ *
+ * Binding to an earlier lifecycle phase than {@code verify} is needed because Maven 3 cannot
+ * order executions of different plugins within the same phase in a way that satisfies all three
+ * constraints at once.
+ */
+@Mojo(name = "build-attestation", defaultPhase = LifecyclePhase.POST_INTEGRATION_TEST, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
+public class BuildAttestationMojo extends AbstractMojo {
+
+ /**
+ * The file extension for in-toto attestation files.
+ */
+ private static final String ATTESTATION_EXTENSION = "intoto.jsonl";
+
+ /**
+ * Shared Jackson object mapper used to serialize SLSA statements and DSSE envelopes to JSON.
+ *
+ * Each attestation is written as a single JSON value followed by a line separator, matching
+ * the JSON Lines format used by {@code .intoto.jsonl}
+ * files. The mapper is configured not to auto-close the output stream so the caller can append
+ * the trailing newline, and to emit ISO-8601 timestamps rather than numeric ones.
+ */
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ static {
+ OBJECT_MAPPER.findAndRegisterModules();
+ OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ OBJECT_MAPPER.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
+ }
+
+ /**
+ * Checksum algorithms used in the generated attestation.
+ *
+ * The default list is:
+ *
+ * - {@code SHA-1} and {@code MD5} are easily available from Maven Central without downloading the artifact;
+ * - {@code SHA-512} and {@code SHA-256} provide more security to the signed attestation, if the artifact is downloaded.
+ *
+ */
+ @Parameter(property = "commons.release.checksums.algorithms", defaultValue = "SHA-512,SHA-256,SHA-1,MD5")
+ private String algorithmNames;
+ /**
+ * Whether to include the default GPG keyring.
+ *
+ * When {@code false}, passes {@code --no-default-keyring} to the GPG command.
+ */
+ @Parameter(property = "gpg.defaultKeyring", defaultValue = "true")
+ private boolean defaultKeyring;
+ /**
+ * Path to the GPG executable; if not set, {@code gpg} is resolved from {@code PATH}.
+ */
+ @Parameter(property = "gpg.executable")
+ private String executable;
+ /**
+ * Name or fingerprint of the GPG key to use for signing.
+ *
+ * Passed as {@code --local-user} to the GPG command; uses the default key when not set.
+ */
+ @Parameter(property = "gpg.keyname")
+ private String keyname;
+ /**
+ * GPG database lock mode passed via {@code --lock-once}, {@code --lock-multiple}, or
+ * {@code --lock-never}; no lock flag is added when not set.
+ */
+ @Parameter(property = "gpg.lockMode")
+ private String lockMode;
+ /**
+ * The Maven home directory.
+ */
+ @Parameter(defaultValue = "${maven.home}", readonly = true)
+ private File mavenHome;
+ /**
+ * Helper to attach artifacts to the project.
+ */
+ private final MavenProjectHelper mavenProjectHelper;
+ /**
+ * The output directory for the attestation file.
+ */
+ @Parameter(property = "commons.release.outputDirectory", defaultValue = "${project.build.directory}")
+ private File outputDirectory;
+ /**
+ * The current Maven project.
+ */
+ private final MavenProject project;
+ /**
+ * Runtime information.
+ */
+ private final RuntimeInformation runtimeInformation;
+ /**
+ * The SCM connection URL for the current project.
+ */
+ @Parameter(defaultValue = "${project.scm.connection}", readonly = true)
+ private String scmConnectionUrl;
+ /**
+ * Issue SCM actions at this local directory.
+ */
+ @Parameter(property = "commons.release.scmDirectory", defaultValue = "${basedir}")
+ private File scmDirectory;
+ /**
+ * The current Maven session, used to resolve plugin dependencies.
+ */
+ private final MavenSession session;
+ /**
+ * Whether to sign the attestation envelope with GPG.
+ */
+ @Parameter(property = "commons.release.signAttestation", defaultValue = "true")
+ private boolean signAttestation;
+ /**
+ * Descriptor of this plugin; used to fill in {@code builder.id} with the plugin's own
+ * Package URL so that consumers can resolve the exact code that produced the provenance.
+ */
+ @Parameter(defaultValue = "${plugin}", readonly = true)
+ private PluginDescriptor pluginDescriptor;
+ /**
+ * GPG signer used for signing; lazily initialized from plugin parameters when {@code null}.
+ */
+ private AbstractGpgSigner signer;
+ /**
+ * Whether to skip attaching the attestation artifact to the project.
+ */
+ @Parameter(property = "commons.release.skipAttach", defaultValue = "false")
+ private boolean skipAttach;
+ /**
+ * Whether to use gpg-agent for passphrase management.
+ *
+ * For GPG versions before 2.1, passes {@code --use-agent} or {@code --no-use-agent}
+ * accordingly; ignored for GPG 2.1 and later where the agent is always used.
+ */
+ @Parameter(property = "gpg.useagent", defaultValue = "true")
+ private boolean useAgent;
+
+ /**
+ * Creates a new instance with the given dependencies.
+ *
+ * @param project A Maven project.
+ * @param runtimeInformation Maven runtime information.
+ * @param session A Maven session.
+ * @param mavenProjectHelper A helper to attach artifacts to the project.
+ */
+ @Inject
+ public BuildAttestationMojo(final MavenProject project, final RuntimeInformation runtimeInformation,
+ final MavenSession session, final MavenProjectHelper mavenProjectHelper) {
+ this.project = project;
+ this.runtimeInformation = runtimeInformation;
+ this.session = session;
+ this.mavenProjectHelper = mavenProjectHelper;
+ }
+
+ /**
+ * Creates the output directory if it does not already exist and returns its path.
+ *
+ * @return the output directory path
+ * @throws MojoExecutionException if the directory cannot be created
+ */
+ private Path ensureOutputDirectory() throws MojoExecutionException {
+ final Path outputPath = outputDirectory.toPath();
+ try {
+ if (!Files.exists(outputPath)) {
+ Files.createDirectories(outputPath);
+ }
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Could not create output directory.", e);
+ }
+ return outputPath;
+ }
+
+ @Override
+ public void execute() throws MojoFailureException, MojoExecutionException {
+ final BuildDefinition buildDefinition = new BuildDefinition()
+ .setExternalParameters(BuildDefinitions.externalParameters(session))
+ .setResolvedDependencies(getBuildDependencies());
+ final String builderId = String.format("pkg:maven/%s/%s@%s",
+ pluginDescriptor.getGroupId(), pluginDescriptor.getArtifactId(), pluginDescriptor.getVersion());
+ final RunDetails runDetails = new RunDetails()
+ .setBuilder(new Builder().setId(builderId))
+ .setMetadata(getBuildMetadata());
+ final Provenance provenance = new Provenance()
+ .setBuildDefinition(buildDefinition)
+ .setRunDetails(runDetails);
+ final Statement statement = new Statement()
+ .setSubject(getSubjects())
+ .setPredicate(provenance);
+
+ final Path outputPath = ensureOutputDirectory();
+ final Path artifactPath = outputPath.resolve(ArtifactUtils.getFileName(project.getArtifact(), ATTESTATION_EXTENSION));
+ if (signAttestation) {
+ signAndWriteStatement(statement, outputPath, artifactPath);
+ } else {
+ writeStatement(statement, artifactPath);
+ }
+ }
+
+ /**
+ * Gets resource descriptors for the JVM, Maven installation, SCM source, and project dependencies.
+ *
+ * @return A list of resolved build dependencies.
+ * @throws MojoExecutionException If any dependency cannot be resolved or hashed.
+ */
+ private List getBuildDependencies() throws MojoExecutionException {
+ final List dependencies = new ArrayList<>();
+ try {
+ dependencies.add(BuildDefinitions.jvm(Paths.get(System.getProperty("java.home"))));
+ dependencies.add(BuildDefinitions.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath(),
+ runtimeInformation.getClass().getClassLoader()));
+ dependencies.add(getScmDescriptor());
+ } catch (final IOException e) {
+ throw new MojoExecutionException(e);
+ }
+ dependencies.addAll(getProjectDependencies());
+ return dependencies;
+ }
+
+ /**
+ * Gets build metadata derived from the current Maven session, including start and finish timestamps.
+ *
+ * @return The build metadata.
+ */
+ private BuildMetadata getBuildMetadata() {
+ final OffsetDateTime startedOn = session.getStartTime().toInstant().atOffset(ZoneOffset.UTC);
+ final OffsetDateTime finishedOn = OffsetDateTime.now(ZoneOffset.UTC);
+ return new BuildMetadata(null, startedOn, finishedOn);
+ }
+
+ /**
+ * Gets resource descriptors for all resolved project dependencies.
+ *
+ * @return A list of resource descriptors for the project's resolved artifacts.
+ * @throws MojoExecutionException If a dependency artifact cannot be described.
+ */
+ private List getProjectDependencies() throws MojoExecutionException {
+ final List dependencies = new ArrayList<>();
+ for (final Artifact artifact : project.getArtifacts()) {
+ dependencies.add(ArtifactUtils.toResourceDescriptor(artifact, algorithmNames));
+ }
+ return dependencies;
+ }
+
+ /**
+ * Gets a resource descriptor for the current SCM source, including the URI and Git commit digest.
+ *
+ * @return A resource descriptor for the SCM source.
+ * @throws IOException If the current branch or the HEAD commit cannot be determined.
+ */
+ private ResourceDescriptor getScmDescriptor() throws IOException {
+ final Path scmPath = scmDirectory.toPath();
+ return new ResourceDescriptor()
+ .setUri(GitUtils.scmToDownloadUri(scmConnectionUrl, scmPath))
+ .setDigest(Collections.singletonMap("gitCommit", GitUtils.getHeadCommit(scmPath)));
+ }
+
+ /**
+ * Gets the GPG signer, creating and preparing it from plugin parameters if not already set.
+ *
+ * @return the prepared signer
+ * @throws MojoFailureException if signer preparation fails
+ */
+ private AbstractGpgSigner getSigner() throws MojoFailureException {
+ if (signer == null) {
+ signer = DsseUtils.createGpgSigner(executable, defaultKeyring, lockMode, keyname, useAgent, getLog());
+ }
+ return signer;
+ }
+
+ /**
+ * Get the artifacts generated by the build.
+ *
+ * @return A list of resource descriptors for the build artifacts.
+ * @throws MojoExecutionException If artifact hashing fails.
+ */
+ private List getSubjects() throws MojoExecutionException {
+ final List subjects = new ArrayList<>();
+ subjects.add(ArtifactUtils.toResourceDescriptor(project.getArtifact(), algorithmNames));
+ for (final Artifact artifact : project.getAttachedArtifacts()) {
+ subjects.add(ArtifactUtils.toResourceDescriptor(artifact, algorithmNames));
+ }
+ return subjects;
+ }
+
+ /**
+ * Sets the list of checksum algorithms to use.
+ *
+ * @param algorithmNames A comma-separated list of {@link java.security.MessageDigest} algorithm names to use.
+ */
+ void setAlgorithmNames(final String algorithmNames) {
+ this.algorithmNames = algorithmNames;
+ }
+
+ /**
+ * Sets the Maven home directory.
+ *
+ * @param mavenHome The Maven home directory.
+ */
+ void setMavenHome(final File mavenHome) {
+ this.mavenHome = mavenHome;
+ }
+
+ /**
+ * Sets the output directory for the attestation file.
+ *
+ * @param outputDirectory The output directory.
+ */
+ void setOutputDirectory(final File outputDirectory) {
+ this.outputDirectory = outputDirectory;
+ }
+
+ /**
+ * Sets the public SCM connection URL.
+ *
+ * @param scmConnectionUrl The SCM connection URL.
+ */
+ void setScmConnectionUrl(final String scmConnectionUrl) {
+ this.scmConnectionUrl = scmConnectionUrl;
+ }
+
+ /**
+ * Sets the SCM directory.
+ *
+ * @param scmDirectory The SCM directory.
+ */
+ void setScmDirectory(final File scmDirectory) {
+ this.scmDirectory = scmDirectory;
+ }
+
+ /**
+ * Sets whether to sign the attestation envelope.
+ *
+ * @param signAttestation {@code true} to sign, {@code false} to skip signing
+ */
+ void setSignAttestation(final boolean signAttestation) {
+ this.signAttestation = signAttestation;
+ }
+
+ /**
+ * Sets the plugin descriptor. Intended for testing.
+ *
+ * @param pluginDescriptor the plugin descriptor
+ */
+ void setPluginDescriptor(final PluginDescriptor pluginDescriptor) {
+ this.pluginDescriptor = pluginDescriptor;
+ }
+
+ /**
+ * Sets the GPG signer used for signing. Intended for testing.
+ *
+ * @param signer the signer to use
+ */
+ void setSigner(final AbstractGpgSigner signer) {
+ this.signer = signer;
+ }
+
+ /**
+ * Signs the attestation statement with GPG and writes it to {@code artifactPath}.
+ *
+ * @param statement the attestation statement to sign and write
+ * @param outputPath directory used for intermediate PAE and signature files
+ * @param artifactPath the destination file path for the envelope
+ * @throws MojoExecutionException if serialization, signing, or file I/O fails
+ * @throws MojoFailureException if the GPG signer cannot be prepared
+ */
+ private void signAndWriteStatement(final Statement statement, final Path outputPath,
+ final Path artifactPath) throws MojoExecutionException, MojoFailureException {
+ final byte[] statementBytes;
+ try {
+ statementBytes = OBJECT_MAPPER.writeValueAsBytes(statement);
+ } catch (final JsonProcessingException e) {
+ throw new MojoExecutionException("Failed to serialize attestation statement", e);
+ }
+ final byte[] sigBytes = DsseUtils.signStatement(getSigner(), statementBytes, outputPath);
+
+ final Signature sig = new Signature()
+ .setKeyid(DsseUtils.getKeyId(sigBytes))
+ .setSig(sigBytes);
+ final DsseEnvelope envelope = new DsseEnvelope()
+ .setPayload(statementBytes)
+ .setSignatures(Collections.singletonList(sig));
+
+ getLog().info("Writing signed attestation envelope to: " + artifactPath);
+ writeAndAttach(envelope, artifactPath);
+ }
+
+ /**
+ * Writes {@code value} as a JSON line to {@code artifactPath} and optionally attaches it to the project.
+ *
+ * @param value the object to serialize
+ * @param artifactPath the destination file path
+ * @throws MojoExecutionException if the file cannot be written
+ */
+ private void writeAndAttach(final Object value, final Path artifactPath) throws MojoExecutionException {
+ try (OutputStream os = Files.newOutputStream(artifactPath)) {
+ OBJECT_MAPPER.writeValue(os, value);
+ os.write('\n');
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Could not write attestation to: " + artifactPath, e);
+ }
+ if (!skipAttach) {
+ final Artifact mainArtifact = project.getArtifact();
+ getLog().info(String.format("Attaching attestation as %s-%s.%s", mainArtifact.getArtifactId(), mainArtifact.getVersion(), ATTESTATION_EXTENSION));
+ mavenProjectHelper.attachArtifact(project, ATTESTATION_EXTENSION, null, artifactPath.toFile());
+ }
+ }
+
+ /**
+ * Serializes the attestation statement as a bare JSON line and writes it to {@code artifactPath}.
+ *
+ * @param statement the attestation statement to write
+ * @param artifactPath the destination file path
+ * @throws MojoExecutionException if the file cannot be written
+ */
+ private void writeStatement(final Statement statement, final Path artifactPath) throws MojoExecutionException {
+ getLog().info("Writing attestation statement to: " + artifactPath);
+ writeAndAttach(statement, artifactPath);
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java
index 022cd986e..542ff47a4 100644
--- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java
@@ -33,14 +33,14 @@
public class BuildMetadata {
/** Timestamp when the build completed. */
- @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC")
@JsonProperty("finishedOn")
private OffsetDateTime finishedOn;
/** Identifier for this build invocation. */
@JsonProperty("invocationId")
private String invocationId;
/** Timestamp when the build started. */
- @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC")
@JsonProperty("startedOn")
private OffsetDateTime startedOn;
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
index 9ac57bfc6..74a7c00d1 100644
--- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
@@ -65,6 +65,16 @@ public String getType() {
return TYPE;
}
+ /**
+ * Setter required for symmetric JSON round-tripping; the value is always {@link #TYPE} and is ignored.
+ *
+ * @param type ignored
+ */
+ @JsonProperty("_type")
+ private void setType(final String type) {
+ // _type is a constant; nothing to do.
+ }
+
/**
* Gets the provenance predicate.
*
diff --git a/src/site/markdown/build-attestation.md b/src/site/markdown/build-attestation.md
new file mode 100644
index 000000000..49b96a68c
--- /dev/null
+++ b/src/site/markdown/build-attestation.md
@@ -0,0 +1,105 @@
+
+# commons-release:build-attestation
+
+## Overview
+
+The `commons-release:build-attestation` goal produces a [SLSA](https://slsa.dev/) v1.2 provenance
+statement in the [in-toto](https://in-toto.io/) format. The attestation lists every artifact
+attached to the project as a subject, records the JDK, Maven installation, SCM source and
+resolved dependencies used during the build, and writes the result to
+`target/-.intoto.jsonl`. The envelope is signed with GPG by default and
+attached to the project so that it is deployed alongside the other artifacts.
+
+The structure of the `predicate.buildDefinition.buildType` field is documented at
+[SLSA build type v0.1.0](slsa/v0.1.0.html).
+
+## Phase ordering
+
+A Commons release relies on three goals running in a fixed order:
+
+1. `commons-release:build-attestation`, bound to `post-integration-test`. At this point every
+ build artifact, including the distribution archives, is already attached to the project.
+2. `maven-gpg-plugin:sign`, bound to `verify`. It signs every attached artifact with a detached
+ `.asc`, including the `.intoto.jsonl` produced in step 1. Maven Central requires this for
+ every uploaded file.
+3. `commons-release:detach-distributions`, bound to `verify`. It removes the `.tar.gz` and
+ `.zip` archives from the set of artifacts that will be uploaded to Nexus.
+
+Binding `build-attestation` to `post-integration-test` (rather than `verify`) puts it in an
+earlier lifecycle phase than the other two goals, so Maven 3 is guaranteed to run it first,
+regardless of the order in which plugins are declared in the POM. Within the `verify` phase,
+`sign` must run before `detach-distributions`; this is controlled by declaring
+`maven-gpg-plugin` before `commons-release-plugin` in the POM, since Maven executes plugins
+within a single phase in the order they appear.
+
+If the distribution archives should not be covered by the attestation, override the default
+phase binding and bind `build-attestation` to `verify` after `detach-distributions`.
+
+## Example configuration
+
+The snippet below wires the three goals in the recommended order.
+
+```xml
+
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+
+
+ sign-artifacts
+ verify
+
+ sign
+
+
+
+
+
+ org.apache.commons
+ commons-release-plugin
+
+
+
+ build-attestation
+
+ build-attestation
+
+
+
+ detach-distributions
+ verify
+
+ detach-distributions
+
+
+
+
+
+
+```
+
+See the [goal parameters](build-attestation-mojo.html) for the full list of configurable
+properties.
diff --git a/src/site/markdown/slsa/v0.1.0.md b/src/site/markdown/slsa/v0.1.0.md
new file mode 100644
index 000000000..adb6f0668
--- /dev/null
+++ b/src/site/markdown/slsa/v0.1.0.md
@@ -0,0 +1,281 @@
+
+
+# Build Type: Apache Commons Maven Release
+
+```jsonc
+"buildType": "https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0"
+```
+
+This document defines a [SLSA v1.2 Build Provenance](https://slsa.dev/spec/v1.2/build-provenance) **build type** for
+releases of Apache Commons components.
+
+Apache Commons releases are cut on a PMC release manager's workstation by invoking Maven against a checkout of the
+project's Git repository. The `commons-release-plugin` captures the build inputs and emits the result as an in-toto
+attestation covering every artifact attached to the project.
+
+Because the build runs on the release manager's own hardware rather than on a hosted build service, the provenance
+corresponds to [SLSA Build Level 1](https://slsa.dev/spec/v1.2/levels): it is generated by the same process that
+produces the artifacts and is signed with the release manager's OpenPGP key, but the build platform itself is not
+separately attested.
+
+The OpenPGP keys used to sign past and present artifacts are available at: https://downloads.apache.org/commons/KEYS
+
+Attestations are serialized in the [JSON Lines](https://jsonlines.org/) format used across the
+in-toto ecosystem, one JSON value per line, and published to Maven Central under the released
+artifact's coordinates with an `intoto.jsonl` type:
+
+```xml
+
+
+ org.apache.commons
+ ${artifactId}
+ intoto.jsonl
+ ${version}
+
+```
+
+## Build definition
+
+Artifacts are generated by a single Maven execution, typically of the form:
+
+```shell
+mvn -Prelease deploy
+```
+
+The provenance is recorded by the `build-attestation` goal of the
+`commons-release-plugin`, which runs in the `verify` phase.
+
+### External parameters
+
+External parameters capture everything supplied by the release manager at invocation time.
+All parameters are captured from the running Maven session.
+
+| Parameter | Type | Description |
+|-------------------------|----------|--------------------------------------------------------------------------------|
+| `maven.goals` | string[] | The list of Maven goals passed on the command line (for example `["deploy"]`). |
+| `maven.profiles` | string[] | The list of active profiles passed via `-P` (for example `["release"]`). |
+| `maven.user.properties` | object | User-defined properties passed via `-D` flags. |
+| `maven.cmdline` | string | The reconstructed Maven command line. |
+| `jvm.args` | string[] | JVM input arguments. |
+| `env` | object | A filtered subset of environment variables: `TZ` and locale variables. |
+
+### Internal parameters
+
+No internal parameters are recorded for this build type.
+
+### Resolved dependencies
+
+The `resolvedDependencies` list captures all inputs that contributed to the build output.
+It always contains the following entries, in order:
+
+#### JDK
+
+Represents the Java Development Kit used to run Maven (`"name": "JDK"`).
+To allow verification of the JDK's integrity, a `gitTree` digest is computed over the `java.home` directory.
+
+The following annotations are recorded from [
+`System.getProperties()`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/System.html#getProperties()):
+
+| Annotation key | System property | Description |
+|-------------------------------------|------------------------------------------|--------------------------------------------------------------------------|
+| `home` | `java.home` | Java installation directory. |
+| `specification.maintenance.version` | `java.specification.maintenance.version` | Java Runtime Environment specification maintenance version _(optional)_. |
+| `specification.name` | `java.specification.name` | Java Runtime Environment specification name. |
+| `specification.vendor` | `java.specification.vendor` | Java Runtime Environment specification vendor. |
+| `specification.version` | `java.specification.version` | Java Runtime Environment specification version. |
+| `vendor` | `java.vendor` | Java Runtime Environment vendor. |
+| `vendor.url` | `java.vendor.url` | Java vendor URL. |
+| `vendor.version` | `java.vendor.version` | Java vendor version _(optional)_. |
+| `version` | `java.version` | Java Runtime Environment version. |
+| `version.date` | `java.version.date` | Java Runtime Environment version date, in ISO-8601 YYYY-MM-DD format. |
+| `vm.name` | `java.vm.name` | Java Virtual Machine implementation name. |
+| `vm.specification.name` | `java.vm.specification.name` | Java Virtual Machine specification name. |
+| `vm.specification.vendor` | `java.vm.specification.vendor` | Java Virtual Machine specification vendor. |
+| `vm.specification.version` | `java.vm.specification.version` | Java Virtual Machine specification version. |
+| `vm.vendor` | `java.vm.vendor` | Java Virtual Machine implementation vendor. |
+| `vm.version` | `java.vm.version` | Java Virtual Machine implementation version. |
+
+#### Maven
+
+Represents the Maven installation used to run the build (`"name": "Maven"`).
+To allow verification of the installation's integrity, a `gitTree` hash is computed over the `maven.home` directory.
+
+The `uri` key contains the Package URL of the Maven distribution, as published to Maven Central.
+
+The following annotations are sourced from Maven's `build.properties`, bundled inside the Maven distribution.
+They are only present if the resource is accessible from Maven's Core Classloader at runtime.
+
+| Annotation key | Description |
+|-------------------------|--------------------------------------------------------------|
+| `distributionId` | The ID of the Maven distribution. |
+| `distributionName` | The full name of the Maven distribution. |
+| `distributionShortName` | The short name of the Maven distribution. |
+| `buildNumber` | The Git commit hash from which this Maven release was built. |
+| `version` | The Maven version string. |
+
+#### Source repository
+
+Represents the source code being built.
+The URI follows
+the [SPDX Download Location](https://spdx.github.io/spdx-spec/v2.3/package-information/#77-package-download-location-field)
+format.
+
+#### Project dependencies
+
+One entry per resolved Maven dependency (compile + runtime scope), as declared in the project's POM.
+These are appended after the build tool entries above.
+
+| Field | Value |
+|-----------------|------------------------------------------------------------|
+| `name` | Artifact filename, for example `commons-lang3-3.14.0.jar`. |
+| `uri` | Package URL. |
+| `digest.sha256` | SHA-256 hex digest of the artifact file on disk. |
+
+## Run details
+
+### Builder
+
+The `builder.id` is the [Package URL](https://github.com/package-url/purl-spec) of the
+`commons-release-plugin` release that produced the attestation, for example
+`pkg:maven/org.apache.commons/commons-release-plugin@1.9.3`. It identifies the trust boundary of
+the "build platform": the exact plugin code that emitted the provenance. Verifiers can resolve the
+PURL to the signed artifact on Maven Central to inspect the builder.
+
+## Subjects
+
+The [`subject`](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md#fields) array
+lists every artifact produced by the build. It has the following properties
+
+| Field | Value |
+|----------|-------------------------------------------------------------------------------------------------------------------------------------|
+| `name` | Artifact filename in the default Maven repository layout, for example `commons-text-1.4-sources.jar`. |
+| `uri` | [Package URL](https://github.com/package-url/purl-spec) identifying the artifact in the `maven` namespace. |
+| `digest` | Map of [in-toto digest names](https://github.com/in-toto/attestation/blob/main/spec/v1/digest_set.md) to hex-encoded digest values. |
+
+By default, every subject carries `md5`, `sha1`, `sha256` and `sha512` digests.
+
+## Example
+
+The following is the bare attestation statement produced for the `commons-text` 1.4 release
+(abridged: most subjects are elided, and the JDK annotations trimmed). The full fixture lives at
+[
+`src/test/resources/attestations/commons-text-1.4.intoto.json`](https://github.com/apache/commons-release-plugin/blob/main/src/test/resources/attestations/commons-text-1.4.intoto.json)
+in the plugin source tree.
+
+The statement shown below is wrapped in
+a [DSSE envelope](https://github.com/secure-systems-lab/dsse/blob/master/envelope.md)
+signed with the release manager's OpenPGP key, and the `.intoto.jsonl` file deployed to Maven Central
+contains that envelope.
+
+```json5
+{
+ "_type": "https://in-toto.io/Statement/v1",
+ "subject": [
+ {
+ "name": "commons-text-1.4.jar",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?type=jar",
+ "digest": {
+ "md5": "9cbe22bb0ce86c70779213dfb7f3eb5a",
+ "sha1": "c81f089b3542485d4d09b02aae822906e5d2f209",
+ "sha256": "ad2d2eacf15ab740c115294afc1192603d8342004a6d7d0ad35446f7dda8a134",
+ "sha512": "126302c5f6865733774eb41fecc10ba8d0bb5ba11d14b9562047429abeb13bf8cdcdbfdf5e7d7708e2a40f67f4265cbbce609164f57abcd676067a840aa48e6a"
+ }
+ },
+ // … one entry per attached artifact (POM, sources, javadoc, tests, and distribution archives) …
+ {
+ "name": "commons-text-1.4-src.zip",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=src&type=zip",
+ "digest": {
+ "md5": "fd65603e930f2b0805c809aa2deb1498",
+ "sha1": "ca1cc6fbb4e46b44f8bb09b70c9e3a2ae3c5fce8",
+ "sha256": "e4a6c992153faae4f7faff689b899073000364e376736b9746a5d0acb9d8b980",
+ "sha512": "79ca61ff7b287407428bbb6ae13c6d372dcd0665114c55cd5bc57978a6fa760305e32feabef62cfeb0c4181220a59406239f6cccaa9a25c68773eef0250cb3a9"
+ }
+ }
+ ],
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "predicate": {
+ "buildDefinition": {
+ "buildType": "https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0",
+ "externalParameters": {
+ "maven.goals": [
+ "deploy"
+ ],
+ "maven.profiles": [
+ "release"
+ ],
+ "maven.user.properties": {
+ "gpg.keyname": "3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9"
+ },
+ "maven.cmdline": "deploy -Prelease -Dgpg.keyname=3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9",
+ "jvm.args": [
+ "-Dfile.encoding=UTF-8",
+ "-Dsun.stdout.encoding=UTF-8",
+ "-Dsun.stderr.encoding=UTF-8"
+ ],
+ "env": {
+ "LANG": "pl_PL.UTF-8",
+ "TZ": "UTC"
+ }
+ },
+ "internalParameters": {},
+ "resolvedDependencies": [
+ // JDK that ran the build
+ {
+ "name": "JDK",
+ "digest": {
+ "gitTree": "bdb67e47c1b7df9c35ae045f29a348bb5bd32dc3"
+ },
+ "annotations": {
+ "home": "/usr/lib/jvm/temurin-25-jdk-amd64",
+ "specification.maintenance.version": null,
+ "specification.name": "Java Platform API Specification",
+ "specification.vendor": "Oracle Corporation",
+ "specification.version": "25",
+ // … remaining java.* system properties elided …
+ }
+ },
+ // Maven installation
+ {
+ "name": "Maven",
+ "uri": "pkg:maven/org.apache.maven/apache-maven@3.9.12",
+ "digest": {
+ "gitTree": "3cdb4a67690dc18373f70ead98dc86567cc5ad67"
+ },
+ "annotations": {
+ "distributionId": "apache-maven",
+ "distributionName": "Apache Maven",
+ "distributionShortName": "Maven",
+ "buildNumber": "848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1",
+ "version": "3.9.12"
+ }
+ },
+ // Source revision (branch or tag at release time)
+ {
+ "uri": "git+https://github.com/apache/commons-text.git@rel/commons-text-1.4",
+ "digest": {
+ "gitCommit": "f519b3670795da3fb4f43b6af1f727eadf8e6800"
+ }
+ }
+ ]
+ },
+ "runDetails": {
+ "builder": {
+ "id": "pkg:maven/org.apache.commons/commons-release-plugin@1.9.3",
+ "builderDependencies": [],
+ "version": {}
+ },
+ "metadata": {
+ "startedOn": "2026-04-20T09:28:44Z",
+ "finishedOn": "2026-04-20T09:38:12Z"
+ }
+ }
+ }
+}
+```
+
+## Version history
+
+### v0.1.0
+
+Initial version.
\ No newline at end of file
diff --git a/src/site/xdoc/index.xml b/src/site/xdoc/index.xml
index ed9ce8dbc..36792674f 100644
--- a/src/site/xdoc/index.xml
+++ b/src/site/xdoc/index.xml
@@ -52,6 +52,10 @@
code readability):
+ -
+ commons-release:build-attestation generates a signed
+ SLSA v1.2 in-toto attestation covering all build artifacts and attaches it to the project.
+
-
commons-release:detach-distributions - Remove
tar.gz, tar.gz.asc, zip, and zip.asc
diff --git a/src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java b/src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java
new file mode 100644
index 000000000..dac28a51c
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.internal;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+import java.util.Properties;
+import java.util.stream.Stream;
+
+import org.apache.maven.execution.DefaultMavenExecutionRequest;
+import org.apache.maven.execution.MavenExecutionRequest;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class BuildDefinitionsTest {
+
+ static Stream commandLineArguments() {
+ return Stream.of(
+ Arguments.of("empty", emptyList(), emptyList(), new Properties(), ""),
+ Arguments.of("single goal", singletonList("verify"), emptyList(), new Properties(), "verify"),
+ Arguments.of("multiple goals", asList("clean", "verify"), emptyList(), new Properties(), "clean verify"),
+ Arguments.of("single profile", singletonList("verify"), singletonList("release"), new Properties(), "verify -Prelease"),
+ Arguments.of("multiple profiles", singletonList("verify"), asList("release", "sign"), new Properties(), "verify -Prelease,sign"),
+ Arguments.of("user property", singletonList("verify"), emptyList(), toProperties("foo", "bar"), "verify -Dfoo=bar"),
+ Arguments.of("goals, profile and property", singletonList("verify"), singletonList("release"), toProperties("foo", "bar"),
+ "verify -Prelease -Dfoo=bar"),
+ Arguments.of("redacts gpg.passphrase", singletonList("verify"), emptyList(), toProperties("gpg.passphrase", "s3cr3t"), "verify"),
+ Arguments.of("redacts passphrase case-insensitively", singletonList("verify"), emptyList(), toProperties("GPG_PASSPHRASE", "s3cr3t"), "verify"),
+ Arguments.of("redacts any *password*", singletonList("verify"), emptyList(), toProperties("my.db.password", "hunter2"), "verify"),
+ Arguments.of("redacts *token*", singletonList("verify"), emptyList(), toProperties("github.token", "ghp_xxx"), "verify"),
+ Arguments.of("keeps safe property, drops sensitive one", singletonList("verify"), emptyList(),
+ toProperties("foo", "bar", "gpg.passphrase", "s3cr3t"), "verify -Dfoo=bar")
+ );
+ }
+
+ private static Properties toProperties(final String... keysAndValues) {
+ final Properties p = new Properties();
+ for (int i = 0; i < keysAndValues.length; i += 2) {
+ p.setProperty(keysAndValues[i], keysAndValues[i + 1]);
+ }
+ return p;
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("commandLineArguments")
+ void commandLineTest(final String description, final List goals, final List profiles,
+ final Properties userProperties, final String expected) {
+ final MavenExecutionRequest request = new DefaultMavenExecutionRequest();
+ request.setGoals(goals);
+ request.setActiveProfiles(profiles);
+ request.setUserProperties(userProperties);
+ assertEquals(expected, BuildDefinitions.commandLine(request));
+ }
+}
diff --git a/src/test/java/org/apache/commons/release/plugin/internal/GitFixture.java b/src/test/java/org/apache/commons/release/plugin/internal/GitFixture.java
new file mode 100644
index 000000000..8e534a8b0
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/internal/GitFixture.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.internal;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import org.apache.commons.exec.CommandLine;
+import org.apache.commons.exec.DefaultExecutor;
+import org.apache.commons.exec.Executor;
+import org.apache.commons.exec.PumpStreamHandler;
+import org.apache.commons.exec.environment.EnvironmentUtils;
+import org.apache.commons.io.output.NullOutputStream;
+
+/**
+ * Builds real Git fixtures on disk by invoking the {@code git} CLI via Commons Exec
+ */
+final class GitFixture {
+
+ static final String REPO_BRANCH = "foo";
+ static final String WORKTREE_BRANCH = "bar";
+ static final String SUBDIR = "subdir";
+ /**
+ * SHA-1 of the single commit produced by {@link #createRepoAndWorktree}; deterministic thanks to {@link #ENV}.
+ */
+ static final String INITIAL_COMMIT_SHA = "a2782b3461d2ed2a81193da1139f65bf9d2befc2";
+
+ /**
+ * Process environment with fixed author/committer dates so commit SHAs are stable across runs.
+ */
+ private static final Map ENV;
+
+ static {
+ try {
+ final Map env = EnvironmentUtils.getProcEnvironment();
+ env.put("GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z");
+ env.put("GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z");
+ ENV = env;
+ } catch (final IOException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
+ /**
+ * Creates a Git repo for testing.
+ *
+ * @param repo Path to the repository to create
+ * @param worktree Path to a separate worktree to create
+ */
+ static void createRepoAndWorktree(final Path repo, final Path worktree) throws IOException {
+ final Path subdir = repo.resolve(SUBDIR);
+ Files.createDirectories(subdir);
+ git(repo, "init", "-q", ".");
+ // Put HEAD on 'foo' before the first commit (portable to older git without --initial-branch).
+ git(repo, "symbolic-ref", "HEAD", "refs/heads/" + REPO_BRANCH);
+ git(repo, "config", "user.email", "test@example.invalid");
+ git(repo, "config", "user.name", "Test");
+ git(repo, "config", "commit.gpgsign", "false");
+ final Path readme = subdir.resolve("README");
+ Files.write(readme, "hi\n".getBytes(StandardCharsets.UTF_8));
+ git(repo, "add", repo.relativize(readme).toString());
+ git(repo, "commit", "-q", "-m", "init");
+ git(repo, "branch", WORKTREE_BRANCH);
+ git(repo, "worktree", "add", "-q", repo.relativize(worktree).toString(), "bar");
+ }
+
+ /**
+ * Runs {@code git} with the given args; stdout is discarded, stderr is forwarded to {@link System#err}.
+ */
+ static void git(final Path workingDir, final String... args) throws IOException {
+ final CommandLine cmd = new CommandLine("git");
+ for (final String a : args) {
+ cmd.addArgument(a, false);
+ }
+ final Executor exec = DefaultExecutor.builder().setWorkingDirectory(workingDir.toFile()).get();
+ exec.setStreamHandler(new PumpStreamHandler(NullOutputStream.INSTANCE, System.err));
+ exec.execute(cmd, ENV);
+ }
+
+ private GitFixture() {
+ }
+}
diff --git a/src/test/java/org/apache/commons/release/plugin/internal/GitUtilsTest.java b/src/test/java/org/apache/commons/release/plugin/internal/GitUtilsTest.java
new file mode 100644
index 000000000..b6e35ae9f
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/internal/GitUtilsTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class GitUtilsTest {
+
+ private static Path repo;
+ @TempDir
+ static Path tempDir;
+ private static Path worktree;
+
+ @BeforeAll
+ static void setUp() throws IOException {
+ repo = tempDir.resolve("repo");
+ worktree = tempDir.resolve("worktree");
+ GitFixture.createRepoAndWorktree(repo, worktree);
+ }
+
+ static Stream testGetCurrentBranch() {
+ return Stream.of(Arguments.of(repo, GitFixture.REPO_BRANCH), Arguments.of(repo.resolve(GitFixture.SUBDIR), GitFixture.REPO_BRANCH),
+ Arguments.of(worktree, GitFixture.WORKTREE_BRANCH), Arguments.of(worktree.resolve(GitFixture.SUBDIR), GitFixture.WORKTREE_BRANCH));
+ }
+
+ static Stream testGetHeadCommit() {
+ return Stream.of(
+ Arguments.of(repo, GitFixture.INITIAL_COMMIT_SHA),
+ Arguments.of(repo.resolve(GitFixture.SUBDIR), GitFixture.INITIAL_COMMIT_SHA),
+ Arguments.of(worktree, GitFixture.INITIAL_COMMIT_SHA),
+ Arguments.of(worktree.resolve(GitFixture.SUBDIR), GitFixture.INITIAL_COMMIT_SHA));
+ }
+
+ static Stream testScmToDownloadUri() {
+ return Stream.of(
+ Arguments.of("scm:git:https://gitbox.apache.org/repos/asf/commons-release-plugin.git",
+ repo,
+ "git+https://gitbox.apache.org/repos/asf/commons-release-plugin.git@" + GitFixture.REPO_BRANCH),
+ Arguments.of("scm:git:git@github.com:apache/commons-release-plugin.git",
+ repo,
+ "git+git@github.com:apache/commons-release-plugin.git@" + GitFixture.REPO_BRANCH),
+ Arguments.of("scm:git:ssh://git@github.com/apache/commons-release-plugin.git",
+ worktree,
+ "git+ssh://git@github.com/apache/commons-release-plugin.git@" + GitFixture.WORKTREE_BRANCH));
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void testGetCurrentBranch(final Path repo, final String expectedBranchName) throws Exception {
+ assertEquals(expectedBranchName, GitUtils.getCurrentBranch(repo));
+ }
+
+ @Test
+ void testGetCurrentBranchDetachedHead() throws IOException {
+ // Build a fresh repo so we don't mutate HEAD shared with the parameterized tests.
+ final Path detachedRepo = tempDir.resolve("detached-repo");
+ final Path detachedWorktree = tempDir.resolve("detached-worktree");
+ GitFixture.createRepoAndWorktree(detachedRepo, detachedWorktree);
+ GitFixture.git(detachedRepo, "checkout", "-q", "--detach", "HEAD");
+ assertEquals(GitFixture.INITIAL_COMMIT_SHA, GitUtils.getCurrentBranch(detachedRepo));
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void testGetHeadCommit(final Path repositoryPath, final String expectedSha) throws IOException {
+ assertEquals(expectedSha, GitUtils.getHeadCommit(repositoryPath));
+ }
+
+ @Test
+ void testGetHeadCommitDetachedHead() throws IOException {
+ final Path detachedRepo = tempDir.resolve("detached-head-commit-repo");
+ final Path detachedWorktree = tempDir.resolve("detached-head-commit-worktree");
+ GitFixture.createRepoAndWorktree(detachedRepo, detachedWorktree);
+ GitFixture.git(detachedRepo, "checkout", "-q", "--detach", "HEAD");
+ assertEquals(GitFixture.INITIAL_COMMIT_SHA, GitUtils.getHeadCommit(detachedRepo));
+ }
+
+ @Test
+ void testGetHeadCommitPackedRefs() throws IOException {
+ final Path packedRepo = tempDir.resolve("packed-repo");
+ final Path packedWorktree = tempDir.resolve("packed-worktree");
+ GitFixture.createRepoAndWorktree(packedRepo, packedWorktree);
+ // Move all loose refs (branches, tags) into .git/packed-refs and delete the loose files.
+ GitFixture.git(packedRepo, "pack-refs", "--all", "--prune");
+ assertEquals(GitFixture.INITIAL_COMMIT_SHA, GitUtils.getHeadCommit(packedRepo));
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void testScmToDownloadUri(final String scmUri, final Path repositoryPath, final String expectedDownloadUri) throws IOException {
+ assertEquals(expectedDownloadUri, GitUtils.scmToDownloadUri(scmUri, repositoryPath));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "scm:svn:https://svn.apache.org/repos/asf/commons-release-plugin",
+ "scm:hg:https://example.com/repo",
+ "https://github.com/apache/commons-release-plugin.git",
+ "git:https://github.com/apache/commons-release-plugin.git",
+ ""
+ })
+ void testScmToDownloadUriRejectsNonGit(final String scmUri) {
+ assertThrows(IllegalArgumentException.class, () -> GitUtils.scmToDownloadUri(scmUri, repo));
+ }
+
+ @Test
+ void throwsWhenNoGitDirectoryFound() throws IOException {
+ final Path plain = Files.createDirectories(tempDir.resolve("plain"));
+ assertThrows(IOException.class, () -> GitUtils.getCurrentBranch(plain));
+ }
+}
diff --git a/src/test/java/org/apache/commons/release/plugin/internal/MojoUtils.java b/src/test/java/org/apache/commons/release/plugin/internal/MojoUtils.java
new file mode 100644
index 000000000..f751b114b
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/internal/MojoUtils.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.internal;
+
+import java.nio.file.Path;
+
+import org.codehaus.plexus.ContainerConfiguration;
+import org.codehaus.plexus.DefaultContainerConfiguration;
+import org.codehaus.plexus.DefaultPlexusContainer;
+import org.codehaus.plexus.PlexusConstants;
+import org.codehaus.plexus.PlexusContainer;
+import org.codehaus.plexus.PlexusContainerException;
+import org.codehaus.plexus.classworlds.ClassWorld;
+import org.codehaus.plexus.component.repository.exception.ComponentLookupException;
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.RepositoryException;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.LocalRepository;
+import org.eclipse.aether.repository.LocalRepositoryManager;
+import org.eclipse.aether.repository.RepositoryPolicy;
+import org.eclipse.aether.spi.localrepo.LocalRepositoryManagerFactory;
+
+/**
+ * Utilities to instantiate Mojos in a test environment.
+ */
+public final class MojoUtils {
+
+ public static RepositorySystemSession createRepositorySystemSession(
+ final PlexusContainer container, final Path localRepositoryPath) throws ComponentLookupException, RepositoryException {
+ final LocalRepositoryManagerFactory factory = container.lookup(LocalRepositoryManagerFactory.class, "simple");
+ final DefaultRepositorySystemSession repoSession = new DefaultRepositorySystemSession();
+ final LocalRepositoryManager manager =
+ factory.newInstance(repoSession, new LocalRepository(localRepositoryPath.toFile()));
+ repoSession.setLocalRepositoryManager(manager);
+ // Default policies
+ repoSession.setUpdatePolicy(RepositoryPolicy.UPDATE_POLICY_DAILY);
+ repoSession.setChecksumPolicy(RepositoryPolicy.CHECKSUM_POLICY_WARN);
+ return repoSession;
+ }
+
+ public static PlexusContainer setupContainer() throws PlexusContainerException {
+ return new DefaultPlexusContainer(setupContainerConfiguration());
+ }
+
+ private static ContainerConfiguration setupContainerConfiguration() {
+ final ClassWorld classWorld =
+ new ClassWorld("plexus.core", Thread.currentThread().getContextClassLoader());
+ return new DefaultContainerConfiguration()
+ .setClassWorld(classWorld)
+ .setClassPathScanning(PlexusConstants.SCANNING_INDEX)
+ .setAutoWiring(true)
+ .setName("maven");
+ }
+
+ private MojoUtils() {
+ }
+}
diff --git a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
new file mode 100644
index 000000000..fc507375a
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
@@ -0,0 +1,258 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.mojos;
+
+import static net.javacrumbs.jsonunit.JsonAssert.assertJsonEquals;
+import static net.javacrumbs.jsonunit.JsonAssert.assertJsonNodeAbsent;
+import static net.javacrumbs.jsonunit.JsonAssert.assertJsonNodePresent;
+import static net.javacrumbs.jsonunit.JsonAssert.assertJsonPartEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.File;
+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.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.Properties;
+import java.util.Set;
+import java.util.TreeSet;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import net.javacrumbs.jsonunit.JsonAssert;
+import net.javacrumbs.jsonunit.core.Option;
+import org.apache.commons.release.plugin.internal.MojoUtils;
+import org.apache.commons.release.plugin.slsa.v1_2.DsseEnvelope;
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.bridge.MavenRepositorySystem;
+import org.apache.maven.execution.DefaultMavenExecutionRequest;
+import org.apache.maven.execution.DefaultMavenExecutionResult;
+import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.execution.MavenExecutionResult;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.model.Model;
+import org.apache.maven.model.io.xpp3.MavenXpp3Reader;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugins.gpg.AbstractGpgSigner;
+import org.apache.maven.project.MavenProject;
+import org.apache.maven.project.MavenProjectHelper;
+import org.apache.maven.rtinfo.RuntimeInformation;
+import org.codehaus.plexus.PlexusContainer;
+import org.codehaus.plexus.component.repository.exception.ComponentLookupException;
+import org.eclipse.aether.RepositorySystemSession;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class BuildAttestationMojoTest {
+
+ private static final String ARTIFACTS_DIR = "src/test/resources/mojos/detach-distributions/target/";
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static PlexusContainer container;
+ private static JsonNode expectedStatement;
+ @TempDir
+ private static Path localRepositoryPath;
+ private static PluginDescriptor pluginDescriptor;
+ private static RepositorySystemSession repoSession;
+
+ private static void assertStatementContent(final JsonNode statement) {
+ // Check all fields except `predicate`
+ assertJsonEquals(expectedStatement, statement,
+ JsonAssert.whenIgnoringPaths("predicate"));
+
+ // Build definition except:
+ // - some external parameters we don't control
+ // - the resolved dependencies for which we check the structure, but ignore the values
+ assertJsonEquals(expectedStatement.at("/predicate/buildDefinition"), statement.at("/predicate/buildDefinition"),
+ JsonAssert.whenIgnoringPaths("externalParameters.jvm.args", "externalParameters.env", "resolvedDependencies",
+ "runDetails.metadata.finishedOn"));
+
+ // `[0].annotations` holds JVM system properties;
+ // Not all properties are available on all JDKs, so they are either null or strings, which json-unit treats as a structural mismatch.
+ // We will check them below
+ assertJsonEquals(expectedStatement.at("/predicate/buildDefinition/resolvedDependencies"),
+ statement.at("/predicate/buildDefinition/resolvedDependencies"),
+ JsonAssert.when(Option.IGNORING_VALUES).whenIgnoringPaths("[0].annotations"));
+
+ final Set expectedJdkFields = fieldNames(
+ expectedStatement.at("/predicate/buildDefinition/resolvedDependencies/0/annotations"));
+ final Set actualJdkFields = fieldNames(
+ statement.at("/predicate/buildDefinition/resolvedDependencies/0/annotations"));
+ assertEquals(expectedJdkFields, actualJdkFields);
+ }
+
+ private static void configureBuildAttestationMojo(final BuildAttestationMojo mojo, final boolean signAttestation) {
+ mojo.setOutputDirectory(new File("target/attestations"));
+ mojo.setScmDirectory(new File("."));
+ mojo.setScmConnectionUrl("scm:git:https://github.com/apache/commons-text.git");
+ mojo.setMavenHome(new File(System.getProperty("maven.home", ".")));
+ mojo.setAlgorithmNames("SHA-512,SHA-256,SHA-1,MD5");
+ mojo.setPluginDescriptor(pluginDescriptor);
+ mojo.setSignAttestation(signAttestation);
+ mojo.setSigner(createMockSigner());
+ }
+
+ private static BuildAttestationMojo createBuildAttestationMojo(final MavenProject project, final MavenProjectHelper projectHelper)
+ throws ComponentLookupException {
+ final RuntimeInformation runtimeInfo = container.lookup(RuntimeInformation.class);
+ return new BuildAttestationMojo(project, runtimeInfo,
+ createMavenSession(createMavenExecutionRequest(), new DefaultMavenExecutionResult()), projectHelper);
+ }
+
+ private static MavenExecutionRequest createMavenExecutionRequest() {
+ final DefaultMavenExecutionRequest request = new DefaultMavenExecutionRequest();
+ request.setStartTime(Date.from(Instant.parse("2026-04-20T09:28:44Z")));
+ request.setActiveProfiles(Collections.singletonList("release"));
+ request.setGoals(Collections.singletonList("deploy"));
+ final Properties userProperties = new Properties();
+ userProperties.setProperty("gpg.keyname", "3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9");
+ request.setUserProperties(userProperties);
+ return request;
+ }
+
+ private static MavenProject createMavenProject(final MavenProjectHelper projectHelper, final MavenRepositorySystem repoSystem) throws Exception {
+ final File pomFile = new File(ARTIFACTS_DIR + "commons-text-1.4.pom");
+ final Model model;
+ try (InputStream in = Files.newInputStream(pomFile.toPath())) {
+ model = new MavenXpp3Reader().read(in);
+ }
+ // Group id is inherited from the missing parent, so we override it
+ model.setGroupId("org.apache.commons");
+ final MavenProject project = new MavenProject(model);
+ final Artifact artifact = repoSystem.createArtifact(model.getArtifactId(), model.getArtifactId(), model.getVersion(), null, "jar");
+ artifact.setFile(new File(ARTIFACTS_DIR + "commons-text-1.4.jar"));
+ project.setArtifact(artifact);
+ projectHelper.attachArtifact(project, "pom", null, pomFile);
+ projectHelper.attachArtifact(project, "jar", "sources", new File(ARTIFACTS_DIR + "commons-text-1.4-sources.jar"));
+ projectHelper.attachArtifact(project, "jar", "javadoc", new File(ARTIFACTS_DIR + "commons-text-1.4-javadoc.jar"));
+ projectHelper.attachArtifact(project, "jar", "tests", new File(ARTIFACTS_DIR + "commons-text-1.4-tests.jar"));
+ projectHelper.attachArtifact(project, "jar", "test-sources", new File(ARTIFACTS_DIR + "commons-text-1.4-test-sources.jar"));
+ projectHelper.attachArtifact(project, "tar.gz", "bin", new File(ARTIFACTS_DIR + "commons-text-1.4-bin.tar.gz"));
+ projectHelper.attachArtifact(project, "zip", "bin", new File(ARTIFACTS_DIR + "commons-text-1.4-bin.zip"));
+ projectHelper.attachArtifact(project, "tar.gz", "src", new File(ARTIFACTS_DIR + "commons-text-1.4-src.tar.gz"));
+ projectHelper.attachArtifact(project, "zip", "src", new File(ARTIFACTS_DIR + "commons-text-1.4-src.zip"));
+ return project;
+ }
+
+ @SuppressWarnings("deprecation")
+ private static MavenSession createMavenSession(final MavenExecutionRequest request, final MavenExecutionResult result) {
+ return new MavenSession(container, repoSession, request, result);
+ }
+
+ private static AbstractGpgSigner createMockSigner() {
+ return new AbstractGpgSigner() {
+ @Override
+ protected void generateSignatureForFile(final File file, final File signature) throws MojoExecutionException {
+ try {
+ Files.copy(Paths.get(ARTIFACTS_DIR + "commons-text-1.4.jar.asc"), signature.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Failed to copy mock signature", e);
+ }
+ }
+
+ @Override
+ public String getKeyInfo() {
+ return "mock-key";
+ }
+
+ @Override
+ public String signerName() {
+ return "mock";
+ }
+ };
+ }
+
+ private static Set fieldNames(final JsonNode node) {
+ final Set names = new TreeSet<>();
+ final Iterator it = node.fieldNames();
+ while (it.hasNext()) {
+ names.add(it.next());
+ }
+ return names;
+ }
+
+ private static Artifact getAttestation(final MavenProject project) {
+ return project.getAttachedArtifacts().stream()
+ .filter(a -> "intoto.jsonl".equals(a.getType()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project"));
+ }
+
+ @BeforeAll
+ static void setup() throws Exception {
+ container = MojoUtils.setupContainer();
+ repoSession = MojoUtils.createRepositorySystemSession(container, localRepositoryPath);
+ try (InputStream in = BuildAttestationMojoTest.class.getResourceAsStream("/attestations/commons-text-1.4.intoto.json")) {
+ expectedStatement = OBJECT_MAPPER.readTree(in);
+ }
+ final Properties pluginProps = new Properties();
+ try (InputStream in = BuildAttestationMojoTest.class.getResourceAsStream("/plugin.properties")) {
+ pluginProps.load(in);
+ }
+ pluginDescriptor = new PluginDescriptor();
+ pluginDescriptor.setGroupId(pluginProps.getProperty("plugin.groupId"));
+ pluginDescriptor.setArtifactId(pluginProps.getProperty("plugin.artifactId"));
+ pluginDescriptor.setVersion(pluginProps.getProperty("plugin.version"));
+ }
+
+ @Test
+ void attestationTest() throws Exception {
+ final MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class);
+ final MavenRepositorySystem repoSystem = container.lookup(MavenRepositorySystem.class);
+ final MavenProject project = createMavenProject(projectHelper, repoSystem);
+
+ final BuildAttestationMojo mojo = createBuildAttestationMojo(project, projectHelper);
+ configureBuildAttestationMojo(mojo, false);
+ mojo.execute();
+
+ final JsonNode statement = OBJECT_MAPPER.readTree(getAttestation(project).getFile());
+ assertStatementContent(statement);
+ }
+
+ @Test
+ void signingTest() throws Exception {
+ final MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class);
+ final MavenRepositorySystem repoSystem = container.lookup(MavenRepositorySystem.class);
+ final MavenProject project = createMavenProject(projectHelper, repoSystem);
+
+ final BuildAttestationMojo mojo = createBuildAttestationMojo(project, projectHelper);
+ configureBuildAttestationMojo(mojo, true);
+ mojo.execute();
+
+ final String envelopeJson = new String(Files.readAllBytes(getAttestation(project).getFile().toPath()), StandardCharsets.UTF_8);
+
+ assertJsonPartEquals(DsseEnvelope.PAYLOAD_TYPE, envelopeJson, "payloadType");
+ assertJsonNodePresent(envelopeJson, "signatures[0]");
+ assertJsonNodeAbsent(envelopeJson, "signatures[1]");
+ assertJsonPartEquals("${json-unit.regex}.+", envelopeJson, "signatures[0].sig");
+ // Issuer fingerprint extracted from the canned commons-text-1.4.jar.asc.
+ assertJsonPartEquals("b6e73d84ea4fcc47166087253faad2cd5ecbb314", envelopeJson, "signatures[0].keyid");
+
+ final DsseEnvelope envelope = OBJECT_MAPPER.readValue(envelopeJson.trim(), DsseEnvelope.class);
+ final JsonNode statement = OBJECT_MAPPER.readTree(envelope.getPayload());
+ assertStatementContent(statement);
+ }
+}
diff --git a/src/test/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelopeTest.java b/src/test/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelopeTest.java
new file mode 100644
index 000000000..083beae51
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelopeTest.java
@@ -0,0 +1,109 @@
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.slsa.v1_2;
+
+import static net.javacrumbs.jsonunit.JsonAssert.assertJsonEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Collections;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class DsseEnvelopeTest {
+
+ private static final String FIXTURE = "/attestations/commons-text-1.4.intoto.dsse.json";
+
+ /** Same fake fingerprint used as {@code gpg.keyname} in the existing statement fixture. */
+ private static final String SAMPLE_KEY_ID = "3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9";
+
+ private static final byte[] SAMPLE_SIG_BYTES = {0x0A, 0x0B, 0x0C, 0x0D};
+
+ private static ObjectMapper objectMapper;
+
+ @BeforeAll
+ static void setUp() {
+ objectMapper = SlsaTestSupport.newObjectMapper();
+ }
+
+ @Test
+ void deserializeThenSerialize() throws Exception {
+ final JsonNode envelopeNode = SlsaTestSupport.readJsonResource(objectMapper, FIXTURE);
+ final DsseEnvelope envelope = objectMapper.treeToValue(envelopeNode, DsseEnvelope.class);
+ final JsonNode serialized = objectMapper.valueToTree(envelope);
+ assertJsonEquals(envelopeNode, serialized);
+ }
+
+ @Test
+ void checkDeserialized() throws Exception {
+ final DsseEnvelope envelope = objectMapper.treeToValue(
+ SlsaTestSupport.readJsonResource(objectMapper, FIXTURE), DsseEnvelope.class);
+
+ assertEquals(DsseEnvelope.PAYLOAD_TYPE, envelope.getPayloadType());
+
+ final List signatures = envelope.getSignatures();
+ assertNotNull(signatures);
+ assertEquals(1, signatures.size());
+ final Signature signature = signatures.get(0);
+ assertEquals(SAMPLE_KEY_ID, signature.getKeyid());
+ assertNotNull(signature.getSig());
+ assertTrue(signature.getSig().length > 0);
+
+ // The wrapped statement bytes should round-trip into the same object verified by StatementTest.
+ final byte[] payload = envelope.getPayload();
+ assertNotNull(payload);
+ final Statement wrapped = objectMapper.readValue(payload, Statement.class);
+ SlsaTestSupport.verifyDeserializedStatement(wrapped);
+ }
+
+ @Test
+ void serializeThenDeserialize() throws Exception {
+ final Provenance provenance = SlsaTestSupport.sampleProvenance();
+ final byte[] payload = objectMapper.writeValueAsBytes(provenance);
+
+ final Signature signature = new Signature()
+ .setKeyid(SAMPLE_KEY_ID)
+ .setSig(SAMPLE_SIG_BYTES.clone());
+
+ final DsseEnvelope original = new DsseEnvelope()
+ .setPayload(payload)
+ .setPayloadType(DsseEnvelope.PAYLOAD_TYPE)
+ .setSignatures(Collections.singletonList(signature));
+
+ final String json = objectMapper.writeValueAsString(original);
+ final DsseEnvelope deserialized = objectMapper.readValue(json, DsseEnvelope.class);
+
+ assertEquals(DsseEnvelope.PAYLOAD_TYPE, deserialized.getPayloadType());
+ assertEquals(1, deserialized.getSignatures().size());
+ final Signature deserSignature = deserialized.getSignatures().get(0);
+ assertEquals(SAMPLE_KEY_ID, deserSignature.getKeyid());
+ assertNotNull(deserSignature.getSig());
+
+ // The payload round-trips back to the sample Provenance.
+ final Provenance deserProvenance = objectMapper.readValue(deserialized.getPayload(), Provenance.class);
+ SlsaTestSupport.verifyDeserializedSampleProvenance(deserProvenance);
+
+ assertEquals(original, deserialized);
+ assertEquals(original.hashCode(), deserialized.hashCode());
+ }
+}
diff --git a/src/test/java/org/apache/commons/release/plugin/slsa/v1_2/SlsaTestSupport.java b/src/test/java/org/apache/commons/release/plugin/slsa/v1_2/SlsaTestSupport.java
new file mode 100644
index 000000000..3534c3530
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/slsa/v1_2/SlsaTestSupport.java
@@ -0,0 +1,282 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.slsa.v1_2;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.InputStream;
+import java.time.OffsetDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+
+/**
+ * Shared helpers for SLSA v1.2 fixture-based tests.
+ */
+final class SlsaTestSupport {
+
+ static final Map SAMPLE_DIGEST;
+ static final Map SAMPLE_ANNOTATIONS;
+ static final Map SAMPLE_EXTERNAL_PARAMETERS;
+ static final Map SAMPLE_INTERNAL_PARAMETERS;
+ static final Map SAMPLE_BUILDER_VERSION;
+ static final byte[] SAMPLE_CONTENT = {1, 2, 3, 4};
+ static final String SAMPLE_BUILDER_ID = "pkg:maven/org.apache.commons/commons-release-plugin@1.10.0";
+ static final OffsetDateTime SAMPLE_STARTED_ON = OffsetDateTime.parse("2026-04-20T09:28:44Z");
+ static final OffsetDateTime SAMPLE_FINISHED_ON = OffsetDateTime.parse("2026-04-20T09:38:12Z");
+
+ static {
+ final Map digest = new LinkedHashMap<>();
+ digest.put("sha256", "ad2d2eacf15ab740c115294afc1192603d8342004a6d7d0ad35446f7dda8a134");
+ digest.put("sha512", "126302c5f6865733774eb41fecc10ba8d0bb5ba11d14b9562047429abeb13bf8cdcdbfdf5e7d7708e2a40f67f4265cbbce609164f57"
+ + "abcd676067a840aa48e6a");
+ SAMPLE_DIGEST = Collections.unmodifiableMap(digest);
+
+ final Map annotations = new LinkedHashMap<>();
+ annotations.put("vendor", "Eclipse Adoptium");
+ annotations.put("version", "25.0.2");
+ SAMPLE_ANNOTATIONS = Collections.unmodifiableMap(annotations);
+
+ final Map external = new LinkedHashMap<>();
+ external.put("maven.profiles", Collections.singletonList("release"));
+ external.put("maven.cmdline", "deploy -Prelease");
+ SAMPLE_EXTERNAL_PARAMETERS = Collections.unmodifiableMap(external);
+
+ final Map internal = new LinkedHashMap<>();
+ internal.put("ci", Boolean.TRUE);
+ SAMPLE_INTERNAL_PARAMETERS = Collections.unmodifiableMap(internal);
+
+ final Map builderVersion = new LinkedHashMap<>();
+ builderVersion.put("commons-release-plugin", "1.10.0");
+ SAMPLE_BUILDER_VERSION = Collections.unmodifiableMap(builderVersion);
+ }
+
+ private SlsaTestSupport() {
+ }
+
+ /** Creates an {@link ObjectMapper} configured for SLSA payloads (Java time module, ISO timestamps). */
+ static ObjectMapper newObjectMapper() {
+ final ObjectMapper objectMapper = new ObjectMapper();
+ objectMapper.findAndRegisterModules();
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ return objectMapper;
+ }
+
+ /** Reads a classpath resource and parses it into a {@link JsonNode}. */
+ static JsonNode readJsonResource(final ObjectMapper objectMapper, final String path) throws Exception {
+ try (InputStream in = SlsaTestSupport.class.getResourceAsStream(path)) {
+ assertNotNull(in, "Fixture resource not found: " + path);
+ return objectMapper.readTree(in);
+ }
+ }
+
+ /**
+ * Asserts that {@code statement} matches the {@code commons-text-1.4.intoto.json} fixture.
+ */
+ static void verifyDeserializedStatement(final Statement statement) {
+ assertEquals(Statement.TYPE, statement.getType());
+ assertEquals(Provenance.PREDICATE_TYPE, statement.getPredicateType());
+
+ // Subject — only the first subject is checked exhaustively; the remaining nine follow the same shape.
+ final List subjects = statement.getSubject();
+ assertNotNull(subjects);
+ assertEquals(10, subjects.size());
+
+ final ResourceDescriptor firstSubject = subjects.get(0);
+ assertEquals("commons-text-1.4.jar", firstSubject.getName());
+ assertEquals("pkg:maven/commons-text/commons-text@1.4?type=jar", firstSubject.getUri());
+ final Map firstDigest = firstSubject.getDigest();
+ assertNotNull(firstDigest);
+ assertEquals(4, firstDigest.size());
+ assertEquals("9cbe22bb0ce86c70779213dfb7f3eb5a", firstDigest.get("md5"));
+ assertEquals("c81f089b3542485d4d09b02aae822906e5d2f209", firstDigest.get("sha1"));
+ assertEquals("ad2d2eacf15ab740c115294afc1192603d8342004a6d7d0ad35446f7dda8a134", firstDigest.get("sha256"));
+ assertEquals("126302c5f6865733774eb41fecc10ba8d0bb5ba11d14b9562047429abeb13bf8"
+ + "cdcdbfdf5e7d7708e2a40f67f4265cbbce609164f57abcd676067a840aa48e6a", firstDigest.get("sha512"));
+
+ // Predicate
+ final Provenance provenance = statement.getPredicate();
+ assertNotNull(provenance);
+
+ final BuildDefinition buildDefinition = provenance.getBuildDefinition();
+ assertEquals("https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0", buildDefinition.getBuildType());
+
+ final Map externalParameters = buildDefinition.getExternalParameters();
+ assertEquals(6, externalParameters.size());
+ assertEquals(Collections.singletonList("release"), externalParameters.get("maven.profiles"));
+ assertEquals("deploy -Prelease -Dgpg.keyname=3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9", externalParameters.get("maven.cmdline"));
+ assertEquals(Arrays.asList("-Dfile.encoding=UTF-8", "-Dsun.stdout.encoding=UTF-8", "-Dsun.stderr.encoding=UTF-8"),
+ externalParameters.get("jvm.args"));
+ assertEquals(Collections.singletonMap("gpg.keyname", "3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9"),
+ externalParameters.get("maven.user.properties"));
+ assertEquals(Collections.singletonList("deploy"), externalParameters.get("maven.goals"));
+ assertEquals(Collections.singletonMap("LANG", "pl_PL.UTF-8"), externalParameters.get("env"));
+
+ assertEquals(Collections.emptyMap(), buildDefinition.getInternalParameters());
+
+ final List deps = buildDefinition.getResolvedDependencies();
+ assertNotNull(deps);
+ assertEquals(3, deps.size());
+
+ // JDK dependency — annotations include a null value
+ final ResourceDescriptor jdk = deps.get(0);
+ assertEquals("JDK", jdk.getName());
+ assertNull(jdk.getUri());
+ assertEquals(Collections.singletonMap("gitTree", "bdb67e47c1b7df9c35ae045f29a348bb5bd32dc3"), jdk.getDigest());
+ final Map jdkAnnotations = jdk.getAnnotations();
+ assertNotNull(jdkAnnotations);
+ assertEquals("/usr/lib/jvm/temurin-25-jdk-amd64", jdkAnnotations.get("home"));
+ assertTrue(jdkAnnotations.containsKey("specification.maintenance.version"));
+ assertNull(jdkAnnotations.get("specification.maintenance.version"));
+ assertEquals("Java Platform API Specification", jdkAnnotations.get("specification.name"));
+ assertEquals("Oracle Corporation", jdkAnnotations.get("specification.vendor"));
+ assertEquals("25", jdkAnnotations.get("specification.version"));
+ assertEquals("Eclipse Adoptium", jdkAnnotations.get("vendor"));
+ assertEquals("https://adoptium.net/", jdkAnnotations.get("vendor.url"));
+ assertEquals("Temurin-25.0.2+10", jdkAnnotations.get("vendor.version"));
+ assertEquals("25.0.2", jdkAnnotations.get("version"));
+ assertEquals("2026-01-20", jdkAnnotations.get("version.date"));
+ assertEquals("OpenJDK 64-Bit Server VM", jdkAnnotations.get("vm.name"));
+ assertEquals("Java Virtual Machine Specification", jdkAnnotations.get("vm.specification.name"));
+ assertEquals("Oracle Corporation", jdkAnnotations.get("vm.specification.vendor"));
+ assertEquals("25", jdkAnnotations.get("vm.specification.version"));
+ assertEquals("Eclipse Adoptium", jdkAnnotations.get("vm.vendor"));
+ assertEquals("25.0.2+10-LTS", jdkAnnotations.get("vm.version"));
+
+ // Maven dependency
+ final ResourceDescriptor maven = deps.get(1);
+ assertEquals("Maven", maven.getName());
+ assertEquals("pkg:maven/org.apache.maven/apache-maven@3.9.12", maven.getUri());
+ assertEquals(Collections.singletonMap("gitTree", "3cdb4a67690dc18373f70ead98dc86567cc5ad67"), maven.getDigest());
+ final Map mavenAnnotations = maven.getAnnotations();
+ assertNotNull(mavenAnnotations);
+ assertEquals("apache-maven", mavenAnnotations.get("distributionId"));
+ assertEquals("Apache Maven", mavenAnnotations.get("distributionName"));
+ assertEquals("Maven", mavenAnnotations.get("distributionShortName"));
+ assertEquals("848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1", mavenAnnotations.get("buildNumber"));
+ assertEquals("3.9.12", mavenAnnotations.get("version"));
+
+ // Git source dependency
+ final ResourceDescriptor source = deps.get(2);
+ assertNull(source.getName());
+ assertEquals("git+https://github.com/apache/commons-text.git@feat/slsa", source.getUri());
+ assertEquals(Collections.singletonMap("gitCommit", "f519b3670795da3fb4f43b6af1f727eadf8e6800"), source.getDigest());
+ assertNull(source.getAnnotations());
+
+ // Run details
+ final RunDetails runDetails = provenance.getRunDetails();
+ final Builder builder = runDetails.getBuilder();
+ assertNotNull(builder.getId());
+ // Resource filtering substitutes ${project.groupId} / ${project.artifactId} / ${project.version}, so match the prefix only.
+ assertTrue(builder.getId().startsWith("pkg:maven/org.apache.commons/commons-release-plugin@"));
+ assertEquals(Collections.emptyList(), builder.getBuilderDependencies());
+ assertEquals(Collections.emptyMap(), builder.getVersion());
+
+ assertNull(runDetails.getByproducts());
+
+ final BuildMetadata metadata = runDetails.getMetadata();
+ assertEquals(OffsetDateTime.parse("2026-04-20T09:28:44Z"), metadata.getStartedOn());
+ assertEquals(OffsetDateTime.parse("2026-04-20T09:38:12Z"), metadata.getFinishedOn());
+ assertNull(metadata.getInvocationId());
+ }
+
+ /** Builds a synthetic {@link Provenance} used by the {@code serializeThenDeserialize} round-trips. */
+ static Provenance sampleProvenance() {
+ final ResourceDescriptor dependency = new ResourceDescriptor()
+ .setName("JDK")
+ .setUri("pkg:generic/jdk@25.0.2")
+ .setDigest(SAMPLE_DIGEST)
+ .setAnnotations(SAMPLE_ANNOTATIONS)
+ .setMediaType("application/java-archive")
+ .setDownloadLocation("https://example.com/jdk.tar.gz")
+ .setContent(SAMPLE_CONTENT.clone());
+
+ final BuildDefinition buildDefinition = new BuildDefinition()
+ .setBuildType("https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0")
+ .setExternalParameters(SAMPLE_EXTERNAL_PARAMETERS)
+ .setInternalParameters(SAMPLE_INTERNAL_PARAMETERS)
+ .setResolvedDependencies(Collections.singletonList(dependency));
+
+ final Builder builder = new Builder()
+ .setId(SAMPLE_BUILDER_ID)
+ .setBuilderDependencies(Collections.emptyList())
+ .setVersion(SAMPLE_BUILDER_VERSION);
+
+ final BuildMetadata metadata = new BuildMetadata()
+ .setInvocationId("invocation-1")
+ .setStartedOn(SAMPLE_STARTED_ON)
+ .setFinishedOn(SAMPLE_FINISHED_ON);
+
+ final ResourceDescriptor byproduct = new ResourceDescriptor("pkg:generic/build-log", SAMPLE_DIGEST);
+
+ final RunDetails runDetails = new RunDetails()
+ .setBuilder(builder)
+ .setMetadata(metadata)
+ .setByproducts(Collections.singletonList(byproduct));
+
+ return new Provenance()
+ .setBuildDefinition(buildDefinition)
+ .setRunDetails(runDetails);
+ }
+
+ /**
+ * Asserts that {@code deserialized} matches the object returned by {@link #sampleProvenance()}.
+ */
+ static void verifyDeserializedSampleProvenance(final Provenance deserialized) {
+ final BuildDefinition deserBuildDef = deserialized.getBuildDefinition();
+ assertNotNull(deserBuildDef);
+ assertEquals("https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0", deserBuildDef.getBuildType());
+ assertEquals(SAMPLE_EXTERNAL_PARAMETERS, deserBuildDef.getExternalParameters());
+ assertEquals(SAMPLE_INTERNAL_PARAMETERS, deserBuildDef.getInternalParameters());
+
+ assertEquals(1, deserBuildDef.getResolvedDependencies().size());
+ final ResourceDescriptor deserDep = deserBuildDef.getResolvedDependencies().get(0);
+ assertEquals("JDK", deserDep.getName());
+ assertEquals("pkg:generic/jdk@25.0.2", deserDep.getUri());
+ assertEquals(SAMPLE_DIGEST, deserDep.getDigest());
+ assertEquals(SAMPLE_ANNOTATIONS, deserDep.getAnnotations());
+ assertEquals("application/java-archive", deserDep.getMediaType());
+ assertEquals("https://example.com/jdk.tar.gz", deserDep.getDownloadLocation());
+ assertArrayEquals(SAMPLE_CONTENT, deserDep.getContent());
+
+ final RunDetails deserRunDetails = deserialized.getRunDetails();
+ assertNotNull(deserRunDetails);
+ final Builder deserBuilder = deserRunDetails.getBuilder();
+ assertEquals(SAMPLE_BUILDER_ID, deserBuilder.getId());
+ assertEquals(Collections.emptyList(), deserBuilder.getBuilderDependencies());
+ assertEquals(SAMPLE_BUILDER_VERSION, deserBuilder.getVersion());
+
+ final BuildMetadata deserMetadata = deserRunDetails.getMetadata();
+ assertEquals("invocation-1", deserMetadata.getInvocationId());
+ assertEquals(SAMPLE_STARTED_ON, deserMetadata.getStartedOn());
+ assertEquals(SAMPLE_FINISHED_ON, deserMetadata.getFinishedOn());
+
+ assertNotNull(deserRunDetails.getByproducts());
+ assertEquals(1, deserRunDetails.getByproducts().size());
+ assertEquals("pkg:generic/build-log", deserRunDetails.getByproducts().get(0).getUri());
+ }
+}
diff --git a/src/test/java/org/apache/commons/release/plugin/slsa/v1_2/StatementTest.java b/src/test/java/org/apache/commons/release/plugin/slsa/v1_2/StatementTest.java
new file mode 100644
index 000000000..7589224cd
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/slsa/v1_2/StatementTest.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.release.plugin.slsa.v1_2;
+
+import static net.javacrumbs.jsonunit.JsonAssert.assertJsonEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class StatementTest {
+
+ private static final String FIXTURE = "/attestations/commons-text-1.4.intoto.json";
+
+ private static ObjectMapper objectMapper;
+
+ @BeforeAll
+ static void setUp() {
+ objectMapper = SlsaTestSupport.newObjectMapper();
+ }
+
+ @Test
+ void deserializeThenSerialize() throws Exception {
+ final JsonNode statementNode = SlsaTestSupport.readJsonResource(objectMapper, FIXTURE);
+ final Statement statement = objectMapper.treeToValue(statementNode, Statement.class);
+ final JsonNode serialized = objectMapper.valueToTree(statement);
+ assertJsonEquals(statementNode, serialized);
+ }
+
+ @Test
+ void checkDeserialized() throws Exception {
+ final Statement statement = objectMapper.treeToValue(
+ SlsaTestSupport.readJsonResource(objectMapper, FIXTURE), Statement.class);
+ SlsaTestSupport.verifyDeserializedStatement(statement);
+ }
+
+ @Test
+ void serializeThenDeserialize() throws Exception {
+ final Provenance original = SlsaTestSupport.sampleProvenance();
+
+ final String json = objectMapper.writeValueAsString(original);
+ final Provenance deserialized = objectMapper.readValue(json, Provenance.class);
+
+ SlsaTestSupport.verifyDeserializedSampleProvenance(deserialized);
+ assertEquals(original, deserialized);
+ assertEquals(original.hashCode(), deserialized.hashCode());
+ }
+}
diff --git a/src/test/resources/attestations/README.md b/src/test/resources/attestations/README.md
new file mode 100644
index 000000000..abfedaff1
--- /dev/null
+++ b/src/test/resources/attestations/README.md
@@ -0,0 +1,23 @@
+
+# Attestation examples
+
+Golden files used as expected values by the tests.
+
+Real build attestations are single-line JSON Lines (`.intoto.jsonl`). The files here are pretty-printed JSON (`.intoto.json`) for readability.
+
+The tests compare structurally via `json-unit`, ignoring environment-dependent fields (JVM arguments, timestamps, `resolvedDependencies` digests), so formatting differences are not significant.
\ No newline at end of file
diff --git a/src/test/resources/attestations/commons-text-1.4.intoto.dsse.json b/src/test/resources/attestations/commons-text-1.4.intoto.dsse.json
new file mode 100644
index 000000000..d818256e8
--- /dev/null
+++ b/src/test/resources/attestations/commons-text-1.4.intoto.dsse.json
@@ -0,0 +1,10 @@
+{
+ "payload": "ewogICJfdHlwZSI6ICJodHRwczovL2luLXRvdG8uaW8vU3RhdGVtZW50L3YxIiwKICAic3ViamVjdCI6IFsKICAgIHsKICAgICAgIm5hbWUiOiAiY29tbW9ucy10ZXh0LTEuNC5qYXIiLAogICAgICAidXJpIjogInBrZzptYXZlbi9jb21tb25zLXRleHQvY29tbW9ucy10ZXh0QDEuND90eXBlPWphciIsCiAgICAgICJkaWdlc3QiOiB7CiAgICAgICAgIm1kNSI6ICI5Y2JlMjJiYjBjZTg2YzcwNzc5MjEzZGZiN2YzZWI1YSIsCiAgICAgICAgInNoYTEiOiAiYzgxZjA4OWIzNTQyNDg1ZDRkMDliMDJhYWU4MjI5MDZlNWQyZjIwOSIsCiAgICAgICAgInNoYTI1NiI6ICJhZDJkMmVhY2YxNWFiNzQwYzExNTI5NGFmYzExOTI2MDNkODM0MjAwNGE2ZDdkMGFkMzU0NDZmN2RkYThhMTM0IiwKICAgICAgICAic2hhNTEyIjogIjEyNjMwMmM1ZjY4NjU3MzM3NzRlYjQxZmVjYzEwYmE4ZDBiYjViYTExZDE0Yjk1NjIwNDc0MjlhYmViMTNiZjhjZGNkYmZkZjVlN2Q3NzA4ZTJhNDBmNjdmNDI2NWNiYmNlNjA5MTY0ZjU3YWJjZDY3NjA2N2E4NDBhYTQ4ZTZhIgogICAgICB9CiAgICB9LAogICAgewogICAgICAibmFtZSI6ICJjb21tb25zLXRleHQtMS40LnBvbSIsCiAgICAgICJ1cmkiOiAicGtnOm1hdmVuL2NvbW1vbnMtdGV4dC9jb21tb25zLXRleHRAMS40P3R5cGU9cG9tIiwKICAgICAgImRpZ2VzdCI6IHsKICAgICAgICAibWQ1IjogIjAwMDQ1ZjY1MmUzZGM4OTcwNDQyY2U4MTk4MDZkYjM0IiwKICAgICAgICAic2hhMSI6ICIyNmZhMzBlNDk2MzIxZTc0Yzc3YWQ2Njc4MWJhNTM0NDhlNmUzYTY4IiwKICAgICAgICAic2hhMjU2IjogIjRkNjI3N2IxZTA3MjBiYjA1NGM2NDA2MjA2NzlhOWRhMTIwZjc1MzAyOTM0MjE1MGU3MTQwOTVmNDg5MzRkNzYiLAogICAgICAgICJzaGE1MTIiOiAiZGI4OTM0ZjMwNjJlYTljOTY1ZWEyN2NmZTQ1MTdhMjU1MTNmYjdjZWJlMzVlZDAyYmVkYzFkODI4N2IwMWM3YmE2NGM5M2E4YTI2MTMyNWZlMTJhYjY5NTdjYmQ4MGRiYzhjMDZlYzM0YzlhMjNjNWE1Yzg5ZWY2YmFjZTg4ZmUiCiAgICAgIH0KICAgIH0sCiAgICB7CiAgICAgICJuYW1lIjogImNvbW1vbnMtdGV4dC0xLjQtc291cmNlcy5qYXIiLAogICAgICAidXJpIjogInBrZzptYXZlbi9jb21tb25zLXRleHQvY29tbW9ucy10ZXh0QDEuND9jbGFzc2lmaWVyPXNvdXJjZXMmdHlwZT1qYXIiLAogICAgICAiZGlnZXN0IjogewogICAgICAgICJtZDUiOiAiMjFhZjEwOTAyY2VhMTBjZjU0YmM5YWNmOTU2ODYzZDQiLAogICAgICAgICJzaGExIjogImNhZGJlOWQzOTgwYTIxZTZlYWVjM2FhZDYyOWJiY2RiNzcxNGFhM2YiLAogICAgICAgICJzaGEyNTYiOiAiNThhOTU1OTFmZTdmYzk0ZGI5NGEwYTllNjRiNGE1YmNjMWM0OWVkZjE3ZjJiMjRkN2MwNzQ3MzU3ZDg1NTc2MSIsCiAgICAgICAgInNoYTUxMiI6ICJjOTFlZDIwOWZhOTdjNWU2OWUyMWQzYTI5ZDFmMmVhOTBmMmY3NzQ1MTc2MmIzYzM4N2E4Y2I5NGRlYTE2N2I0ZDNmMDRlYTFhODYzNTIzMjQ3NmQ4MDRmNDA5MzU2OThiNGY4ODg0ZmQ0MzUyMGJjYzc5YjNhMGY5YTc1NzcxNiIKICAgICAgfQogICAgfSwKICAgIHsKICAgICAgIm5hbWUiOiAiY29tbW9ucy10ZXh0LTEuNC1qYXZhZG9jLmphciIsCiAgICAgICJ1cmkiOiAicGtnOm1hdmVuL2NvbW1vbnMtdGV4dC9jb21tb25zLXRleHRAMS40P2NsYXNzaWZpZXI9amF2YWRvYyZ0eXBlPWphciIsCiAgICAgICJkaWdlc3QiOiB7CiAgICAgICAgIm1kNSI6ICIyYWQ5M2UxZDk5YzBiODBjYmY2ODcyZDE3NjJkNzI5NyIsCiAgICAgICAgInNoYTEiOiAiOWYwNmNkZjc1M2UxYmI1MTJlNzY0MGVjNWZjY2U4M2Y1YTE5YmEyYyIsCiAgICAgICAgInNoYTI1NiI6ICI0MmY1YjM0MWQwZmJlYWEzMGIwNmFlZDkwNjEyODQwYmM1MTNmYjM5NzkyYzNkMzk0NDY1MTA2NzAyMTZlOGIxIiwKICAgICAgICAic2hhNTEyIjogIjA1MmFiNGU5ZmFjZmM2NGYyNjVhNTY3NjA3ZDQwZDM3NjIwYjYxNjkwOGNlNzE0MDVjYWU5Y2QzMGFkNmFjOWYyNTU4NjYzMDI5OTMzZjkwZGY2N2MxZTc0OGFjODE0NTFhMzJlMjg5NzVkYzc3M2MyYzc0NzZjNDg5MTQ2YzMwIgogICAgICB9CiAgICB9LAogICAgewogICAgICAibmFtZSI6ICJjb21tb25zLXRleHQtMS40LXRlc3RzLmphciIsCiAgICAgICJ1cmkiOiAicGtnOm1hdmVuL2NvbW1vbnMtdGV4dC9jb21tb25zLXRleHRAMS40P2NsYXNzaWZpZXI9dGVzdHMmdHlwZT1qYXIiLAogICAgICAiZGlnZXN0IjogewogICAgICAgICJtZDUiOiAiOTgyMDU0NzczNGFmZjJjMTdjNGE2OTZiYmQ3ZDhkNWUiLAogICAgICAgICJzaGExIjogImRiZmI5NDVhMTIzNzVlOWZlMDY1NTg4NDBiNDNmMzUzNzRjMzkxZmYiLAogICAgICAgICJzaGEyNTYiOiAiZTRlMzY1ZDA4ZDYwMWE0YmRhNDRiZTJhMzFmNzQ4Yjk2NzYyNTA0ZDMwMTc0MmQ0YTBmN2Y1OTUzZDRjNzkzYSIsCiAgICAgICAgInNoYTUxMiI6ICJhOGUzNzNjZjEwYTlkYzJkM2MxY2ZkZDQzYjIzOTEwYzljYTlkODNkZWE0ZTU2Njc3MmRkMWViZTVlMjg0MTk3NzdlMGQ4M2IwMDJlOGNmOTUwZjdiZGUzMjE4YjcxNGUwY2MzNzE4NDY0ZWUzNGVjZGE3N2Y2MDNlMjYwYWIyMCIKICAgICAgfQogICAgfSwKICAgIHsKICAgICAgIm5hbWUiOiAiY29tbW9ucy10ZXh0LTEuNC10ZXN0LXNvdXJjZXMuamFyIiwKICAgICAgInVyaSI6ICJwa2c6bWF2ZW4vY29tbW9ucy10ZXh0L2NvbW1vbnMtdGV4dEAxLjQ/Y2xhc3NpZmllcj10ZXN0LXNvdXJjZXMmdHlwZT1qYXIiLAogICAgICAiZGlnZXN0IjogewogICAgICAgICJtZDUiOiAiMzQwMjMxYTY5N2UyODYyYzhkODIwZGU0MTlhOTFkM2UiLAogICAgICAgICJzaGExIjogIjVlMTA3ZmM4NzdjNjc5OTI5NDg2MjBhOGEyZDliYjk0Y2FiMjFhYTAiLAogICAgICAgICJzaGEyNTYiOiAiOTIwMGEyYTQxYjM1ZjJkNmQzMGMxYzY5ODMwODU5MWNmNTc3NTQ3ZWMzOTUxNDY1N2RmZjBlMmY3ZGZmMThjYSIsCiAgICAgICAgInNoYTUxMiI6ICIwMmI0MDVlYjlhZmY1Nzk1OWEzZTA2NjAzMDc0NWJlNDdiNzZjNzFhZjcxZjg2ZTA2ZmQ1MDA0NjAyYTM5OWNkZjg2YjExNDk1NTRlOTJlOTkyMmQ5ZGEyZmQyMjM5ZGNmNmU4Yzc4MzQ2MjE2NzMyMDgzOGRkNzY2MjQwNWIzMCIKICAgICAgfQogICAgfSwKICAgIHsKICAgICAgIm5hbWUiOiAiY29tbW9ucy10ZXh0LTEuNC1iaW4udGFyLmd6IiwKICAgICAgInVyaSI6ICJwa2c6bWF2ZW4vY29tbW9ucy10ZXh0L2NvbW1vbnMtdGV4dEAxLjQ/Y2xhc3NpZmllcj1iaW4mdHlwZT10YXIuZ3oiLAogICAgICAiZGlnZXN0IjogewogICAgICAgICJtZDUiOiAiOWZlMjUxNjI1OTBiZTZmYTY4NGE5ZDljZGMwYjUwNWQiLAogICAgICAgICJzaGExIjogIjZmNDZmZDgyZDVjY2Y5NjQ0YTM3YmNlYzZmMjE1OGE1MmViZGJhYjgiLAogICAgICAgICJzaGEyNTYiOiAiOGI5MzkzZjdkZGMyZWZiNjlkOGMyYjZmNGQ4NWQ4NzExZGRkZmU3NzAwOTc5OWNmMjE2MTlmYzliODQxMTg5NyIsCiAgICAgICAgInNoYTUxMiI6ICJjNzZmNGU1ODE0YzA1MzMwMzBmZWNmMGVmNzYzOWNlNjhkZjU0Yjc2ZWJjMTMyMGQ5YzRlM2I4ZGZmMGE5MGM5NWNlMGE0MjViOGRmNmEwZDc0MmY2ZTlmY2E4ZGNlNmYwODYzMmQ1NzZjNmE5OTM1N2ZkZDU0Yjk0MzVmZWQ2YyIKICAgICAgfQogICAgfSwKICAgIHsKICAgICAgIm5hbWUiOiAiY29tbW9ucy10ZXh0LTEuNC1iaW4uemlwIiwKICAgICAgInVyaSI6ICJwa2c6bWF2ZW4vY29tbW9ucy10ZXh0L2NvbW1vbnMtdGV4dEAxLjQ/Y2xhc3NpZmllcj1iaW4mdHlwZT16aXAiLAogICAgICAiZGlnZXN0IjogewogICAgICAgICJtZDUiOiAiZTI5ZTUyOTBiNzQyMGJhMTkwOGM0MThmYzViYzdmOGEiLAogICAgICAgICJzaGExIjogIjQ0N2EwNTFkNmM4MjkyYzdlNGQ3NjQxY2E2NTg2YjMzNWVmMTNiZDYiLAogICAgICAgICJzaGEyNTYiOiAiYWQzNzMyZGNiMzhlNTEwYjFkYmIxNTQ0MTE1ZDBlYjc5N2ZhYjYxYWZlMDAwOGZkYjE4N2NkNGVmMTcwNmNkNyIsCiAgICAgICAgInNoYTUxMiI6ICIwNDA2MzRiMjcxNDZlMjAwOGJmOTUzZjFiYjYzZjhjY2JiNjNjZGFhYWYyNGJlYjE3YWNjNjc4MmQzMTQ1MjIxNGU3NTU1MzljMmRmNzM3ZTQ2ZTdlZGU1ZDRlMzc2Y2IzZGU1YmJmNzJjZWJhNTQwOWI0M2YyNTYxMmMyYmY0MCIKICAgICAgfQogICAgfSwKICAgIHsKICAgICAgIm5hbWUiOiAiY29tbW9ucy10ZXh0LTEuNC1zcmMudGFyLmd6IiwKICAgICAgInVyaSI6ICJwa2c6bWF2ZW4vY29tbW9ucy10ZXh0L2NvbW1vbnMtdGV4dEAxLjQ/Y2xhc3NpZmllcj1zcmMmdHlwZT10YXIuZ3oiLAogICAgICAiZGlnZXN0IjogewogICAgICAgICJtZDUiOiAiYmUxMzBjMjNkM2I2NjMwODI0ZTJhMDg1MzBiZDQ1ODEiLAogICAgICAgICJzaGExIjogIjBkY2VlNDIxYzRlMDNkNmJjMDk4YTYxYTVjZGNjOTA2NTY4NTY2MTEiLAogICAgICAgICJzaGEyNTYiOiAiMWNiODUzNmMzNzVjM2NmZjY2NzU3ZmQ0MGMyYmY4Nzg5OTgyNTRiYTBhMjQ3ODY2YTY1MzZiZDQ4YmEyZTg4YSIsCiAgICAgICAgInNoYTUxMiI6ICI4Mjc5ZWI3ZjQ1MDA5ZjExNjU4YzI1NmIwN2NmYjA2YzVhNDVlZjg5OTQ5ZTc4YTY3ZTVkNjQ1MjBkOTU3NzA3ZjkwYTk2MTRjOTVlOGFhYzM1MGEwN2E1NGY5MTYwZjA4MDJkYmQxZWZjMDc2OWE1YjEzOTFlNTJlZTRjZDUxYiIKICAgICAgfQogICAgfSwKICAgIHsKICAgICAgIm5hbWUiOiAiY29tbW9ucy10ZXh0LTEuNC1zcmMuemlwIiwKICAgICAgInVyaSI6ICJwa2c6bWF2ZW4vY29tbW9ucy10ZXh0L2NvbW1vbnMtdGV4dEAxLjQ/Y2xhc3NpZmllcj1zcmMmdHlwZT16aXAiLAogICAgICAiZGlnZXN0IjogewogICAgICAgICJtZDUiOiAiZmQ2NTYwM2U5MzBmMmIwODA1YzgwOWFhMmRlYjE0OTgiLAogICAgICAgICJzaGExIjogImNhMWNjNmZiYjRlNDZiNDRmOGJiMDliNzBjOWUzYTJhZTNjNWZjZTgiLAogICAgICAgICJzaGEyNTYiOiAiZTRhNmM5OTIxNTNmYWFlNGY3ZmFmZjY4OWI4OTkwNzMwMDAzNjRlMzc2NzM2Yjk3NDZhNWQwYWNiOWQ4Yjk4MCIsCiAgICAgICAgInNoYTUxMiI6ICI3OWNhNjFmZjdiMjg3NDA3NDI4YmJiNmFlMTNjNmQzNzJkY2QwNjY1MTE0YzU1Y2Q1YmM1Nzk3OGE2ZmE3NjAzMDVlMzJmZWFiZWY2MmNmZWIwYzQxODEyMjBhNTk0MDYyMzlmNmNjY2FhOWEyNWM2ODc3M2VlZjAyNTBjYjNhOSIKICAgICAgfQogICAgfQogIF0sCiAgInByZWRpY2F0ZVR5cGUiOiAiaHR0cHM6Ly9zbHNhLmRldi9wcm92ZW5hbmNlL3YxIiwKICAicHJlZGljYXRlIjogewogICAgImJ1aWxkRGVmaW5pdGlvbiI6IHsKICAgICAgImJ1aWxkVHlwZSI6ICJodHRwczovL2NvbW1vbnMuYXBhY2hlLm9yZy9wcm9wZXIvY29tbW9ucy1yZWxlYXNlLXBsdWdpbi9zbHNhL3YwLjEuMCIsCiAgICAgICJleHRlcm5hbFBhcmFtZXRlcnMiOiB7CiAgICAgICAgIm1hdmVuLnByb2ZpbGVzIjogWwogICAgICAgICAgInJlbGVhc2UiCiAgICAgICAgXSwKICAgICAgICAibWF2ZW4uY21kbGluZSI6ICJkZXBsb3kgLVByZWxlYXNlIC1EZ3BnLmtleW5hbWU9M0M4RDU3RTBBMkI1QzZEN0U4RjlBMEIxQzJEM0U0RjVBNkI3QzhEOSIsCiAgICAgICAgImp2bS5hcmdzIjogWwogICAgICAgICAgIi1EZmlsZS5lbmNvZGluZz1VVEYtOCIsCiAgICAgICAgICAiLURzdW4uc3Rkb3V0LmVuY29kaW5nPVVURi04IiwKICAgICAgICAgICItRHN1bi5zdGRlcnIuZW5jb2Rpbmc9VVRGLTgiCiAgICAgICAgXSwKICAgICAgICAibWF2ZW4udXNlci5wcm9wZXJ0aWVzIjogewogICAgICAgICAgImdwZy5rZXluYW1lIjogIjNDOEQ1N0UwQTJCNUM2RDdFOEY5QTBCMUMyRDNFNEY1QTZCN0M4RDkiCiAgICAgICAgfSwKICAgICAgICAibWF2ZW4uZ29hbHMiOiBbCiAgICAgICAgICAiZGVwbG95IgogICAgICAgIF0sCiAgICAgICAgImVudiI6IHsKICAgICAgICAgICJMQU5HIjogInBsX1BMLlVURi04IgogICAgICAgIH0KICAgICAgfSwKICAgICAgImludGVybmFsUGFyYW1ldGVycyI6IHt9LAogICAgICAicmVzb2x2ZWREZXBlbmRlbmNpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgIm5hbWUiOiAiSkRLIiwKICAgICAgICAgICJkaWdlc3QiOiB7CiAgICAgICAgICAgICJnaXRUcmVlIjogImJkYjY3ZTQ3YzFiN2RmOWMzNWFlMDQ1ZjI5YTM0OGJiNWJkMzJkYzMiCiAgICAgICAgICB9LAogICAgICAgICAgImFubm90YXRpb25zIjogewogICAgICAgICAgICAiaG9tZSI6ICIvdXNyL2xpYi9qdm0vdGVtdXJpbi0yNS1qZGstYW1kNjQiLAogICAgICAgICAgICAic3BlY2lmaWNhdGlvbi5tYWludGVuYW5jZS52ZXJzaW9uIjogbnVsbCwKICAgICAgICAgICAgInNwZWNpZmljYXRpb24ubmFtZSI6ICJKYXZhIFBsYXRmb3JtIEFQSSBTcGVjaWZpY2F0aW9uIiwKICAgICAgICAgICAgInNwZWNpZmljYXRpb24udmVuZG9yIjogIk9yYWNsZSBDb3Jwb3JhdGlvbiIsCiAgICAgICAgICAgICJzcGVjaWZpY2F0aW9uLnZlcnNpb24iOiAiMjUiLAogICAgICAgICAgICAidmVuZG9yIjogIkVjbGlwc2UgQWRvcHRpdW0iLAogICAgICAgICAgICAidmVuZG9yLnVybCI6ICJodHRwczovL2Fkb3B0aXVtLm5ldC8iLAogICAgICAgICAgICAidmVuZG9yLnZlcnNpb24iOiAiVGVtdXJpbi0yNS4wLjIrMTAiLAogICAgICAgICAgICAidmVyc2lvbiI6ICIyNS4wLjIiLAogICAgICAgICAgICAidmVyc2lvbi5kYXRlIjogIjIwMjYtMDEtMjAiLAogICAgICAgICAgICAidm0ubmFtZSI6ICJPcGVuSkRLIDY0LUJpdCBTZXJ2ZXIgVk0iLAogICAgICAgICAgICAidm0uc3BlY2lmaWNhdGlvbi5uYW1lIjogIkphdmEgVmlydHVhbCBNYWNoaW5lIFNwZWNpZmljYXRpb24iLAogICAgICAgICAgICAidm0uc3BlY2lmaWNhdGlvbi52ZW5kb3IiOiAiT3JhY2xlIENvcnBvcmF0aW9uIiwKICAgICAgICAgICAgInZtLnNwZWNpZmljYXRpb24udmVyc2lvbiI6ICIyNSIsCiAgICAgICAgICAgICJ2bS52ZW5kb3IiOiAiRWNsaXBzZSBBZG9wdGl1bSIsCiAgICAgICAgICAgICJ2bS52ZXJzaW9uIjogIjI1LjAuMisxMC1MVFMiCiAgICAgICAgICB9CiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAibmFtZSI6ICJNYXZlbiIsCiAgICAgICAgICAidXJpIjogInBrZzptYXZlbi9vcmcuYXBhY2hlLm1hdmVuL2FwYWNoZS1tYXZlbkAzLjkuMTIiLAogICAgICAgICAgImRpZ2VzdCI6IHsKICAgICAgICAgICAgImdpdFRyZWUiOiAiM2NkYjRhNjc2OTBkYzE4MzczZjcwZWFkOThkYzg2NTY3Y2M1YWQ2NyIKICAgICAgICAgIH0sCiAgICAgICAgICAiYW5ub3RhdGlvbnMiOiB7CiAgICAgICAgICAgICJkaXN0cmlidXRpb25JZCI6ICJhcGFjaGUtbWF2ZW4iLAogICAgICAgICAgICAiZGlzdHJpYnV0aW9uTmFtZSI6ICJBcGFjaGUgTWF2ZW4iLAogICAgICAgICAgICAiZGlzdHJpYnV0aW9uU2hvcnROYW1lIjogIk1hdmVuIiwKICAgICAgICAgICAgImJ1aWxkTnVtYmVyIjogIjg0OGZiYjRiZjJkNDI3YjcyYmRiMjQ3MWMyMmZjZWQ3ZWJkOWE3YTEiLAogICAgICAgICAgICAidmVyc2lvbiI6ICIzLjkuMTIiCiAgICAgICAgICB9CiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAidXJpIjogImdpdCtodHRwczovL2dpdGh1Yi5jb20vYXBhY2hlL2NvbW1vbnMtdGV4dC5naXRAZmVhdC9zbHNhIiwKICAgICAgICAgICJkaWdlc3QiOiB7CiAgICAgICAgICAgICJnaXRDb21taXQiOiAiZjUxOWIzNjcwNzk1ZGEzZmI0ZjQzYjZhZjFmNzI3ZWFkZjhlNjgwMCIKICAgICAgICAgIH0KICAgICAgICB9CiAgICAgIF0KICAgIH0sCiAgICAicnVuRGV0YWlscyI6IHsKICAgICAgImJ1aWxkZXIiOiB7CiAgICAgICAgImlkIjogInBrZzptYXZlbi9vcmcuYXBhY2hlLmNvbW1vbnMvY29tbW9ucy1yZWxlYXNlLXBsdWdpbkAxLjEwLjAtU05BUFNIT1QiLAogICAgICAgICJidWlsZGVyRGVwZW5kZW5jaWVzIjogW10sCiAgICAgICAgInZlcnNpb24iOiB7fQogICAgICB9LAogICAgICAibWV0YWRhdGEiOiB7CiAgICAgICAgInN0YXJ0ZWRPbiI6ICIyMDI2LTA0LTIwVDA5OjI4OjQ0WiIsCiAgICAgICAgImZpbmlzaGVkT24iOiAiMjAyNi0wNC0yMFQwOTozODoxMloiCiAgICAgIH0KICAgIH0KICB9Cn0K",
+ "payloadType": "application/vnd.in-toto+json",
+ "signatures": [
+ {
+ "keyid": "3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9",
+ "sig": "LS0tLS1CRUdJTiBQR1AgU0lHTkFUVVJFLS0tLS0KCmlRSXpCQUFCQ2dBZEZpRUV0dWM5aE9wUHpFY1dZSWNsUDZyU3pWN0xzeFFGQWxzaFBXVUFDZ2tRUDZyU3pWN0wKc3hRRVp3LzlHZG9TTHN5cmFrbkFSQVJEamdQenpsa3lWcG9oWXVSMWZCZHovUmllM2Q3WFViejNPUmNhYUlXTQpZSFZkM2JBTWtlTnlOdy85eXJkL0ZwMEViU3NsYklJZThZMGUrYnhPS1JtVHA1ajJ5Nzdva2EvRCtlUkkxemt5CjZHZHhwRlZpOWhPMXRxVTlsRnpCaGErRlRYWE9ERVZyUE8yZkE0QllQWUI5L3ZhcFQ4ZHZiZHV6VlZnVzM4T0oKbGtNQ2pzcWpiVVFFZ0NHTFQzSCs1Q0E2SmtmVEpEY0dNVHlLbjNMbjlSVER1NDdqR3A1UEZnOEc2NTB2SWFhVQpIN3VpdC9SVmc3Uy9wNW04Ry9TOGthUFpyd1FFRlZJYjlRY2ZFV0RBbUQvTytGRVZpZ29mZzBESkRJL2N4NlBlCkV3c3pBN3QrT1REUnFBSFRaY1lOWFZpazdTV1NLVjl5N1RML1NOeVQ1MjVDSklNMVU5TGxGSUdCbHAzUlcvaW8KRGVtYkc3QUIxY2dtVjRycHRKU1BLRWd3RWlqT3FJdEo3TnBPTG5mc24rRm1neXBzVWMzdVNoMzZ0elJwdVloZgpiVXMwWHMzWmhzN09haVhFeHdMc2JGbzltSjRNNlJINythYmFjallpaVNGSHlkQmFMRFJaZnlMcGN0SmF3KzMwCmhXQUNrYS9TbVVjZjdQd3FwN1pBMmRKanJNakd2ZzBEMVhGVlZQd0xaUUpoTFFOemZlUFhyY08rS3VZeHVxNXQKcUJ1M1p3Nmc2aEJqclVaNkFiNE1wNmFaSS9hMGVucHFDR3ZSaC9BMjhIaGZQVWFDOFRlM1pmSTc3aUJLLzFlWgoxNEM1bmhvOUhpd2kwNmlOaGEvSjlyTS9kbmZEdmtXcGpFd1dKdy81MTUxaDdvNyszMVE9Cj1VYThKCi0tLS0tRU5EIFBHUCBTSUdOQVRVUkUtLS0tLQo="
+ }
+ ]
+}
diff --git a/src/test/resources/attestations/commons-text-1.4.intoto.json b/src/test/resources/attestations/commons-text-1.4.intoto.json
new file mode 100644
index 000000000..1ddc4f2ed
--- /dev/null
+++ b/src/test/resources/attestations/commons-text-1.4.intoto.json
@@ -0,0 +1,189 @@
+{
+ "_type": "https://in-toto.io/Statement/v1",
+ "subject": [
+ {
+ "name": "commons-text-1.4.jar",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?type=jar",
+ "digest": {
+ "md5": "9cbe22bb0ce86c70779213dfb7f3eb5a",
+ "sha1": "c81f089b3542485d4d09b02aae822906e5d2f209",
+ "sha256": "ad2d2eacf15ab740c115294afc1192603d8342004a6d7d0ad35446f7dda8a134",
+ "sha512": "126302c5f6865733774eb41fecc10ba8d0bb5ba11d14b9562047429abeb13bf8cdcdbfdf5e7d7708e2a40f67f4265cbbce609164f57abcd676067a840aa48e6a"
+ }
+ },
+ {
+ "name": "commons-text-1.4.pom",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?type=pom",
+ "digest": {
+ "md5": "00045f652e3dc8970442ce819806db34",
+ "sha1": "26fa30e496321e74c77ad66781ba53448e6e3a68",
+ "sha256": "4d6277b1e0720bb054c640620679a9da120f753029342150e714095f48934d76",
+ "sha512": "db8934f3062ea9c965ea27cfe4517a25513fb7cebe35ed02bedc1d8287b01c7ba64c93a8a261325fe12ab6957cbd80dbc8c06ec34c9a23c5a5c89ef6bace88fe"
+ }
+ },
+ {
+ "name": "commons-text-1.4-sources.jar",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=sources&type=jar",
+ "digest": {
+ "md5": "21af10902cea10cf54bc9acf956863d4",
+ "sha1": "cadbe9d3980a21e6eaec3aad629bbcdb7714aa3f",
+ "sha256": "58a95591fe7fc94db94a0a9e64b4a5bcc1c49edf17f2b24d7c0747357d855761",
+ "sha512": "c91ed209fa97c5e69e21d3a29d1f2ea90f2f77451762b3c387a8cb94dea167b4d3f04ea1a8635232476d804f40935698b4f8884fd43520bcc79b3a0f9a757716"
+ }
+ },
+ {
+ "name": "commons-text-1.4-javadoc.jar",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=javadoc&type=jar",
+ "digest": {
+ "md5": "2ad93e1d99c0b80cbf6872d1762d7297",
+ "sha1": "9f06cdf753e1bb512e7640ec5fcce83f5a19ba2c",
+ "sha256": "42f5b341d0fbeaa30b06aed90612840bc513fb39792c3d39446510670216e8b1",
+ "sha512": "052ab4e9facfc64f265a567607d40d37620b616908ce71405cae9cd30ad6ac9f2558663029933f90df67c1e748ac81451a32e28975dc773c2c7476c489146c30"
+ }
+ },
+ {
+ "name": "commons-text-1.4-tests.jar",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=tests&type=jar",
+ "digest": {
+ "md5": "9820547734aff2c17c4a696bbd7d8d5e",
+ "sha1": "dbfb945a12375e9fe06558840b43f35374c391ff",
+ "sha256": "e4e365d08d601a4bda44be2a31f748b96762504d301742d4a0f7f5953d4c793a",
+ "sha512": "a8e373cf10a9dc2d3c1cfdd43b23910c9ca9d83dea4e566772dd1ebe5e28419777e0d83b002e8cf950f7bde3218b714e0cc3718464ee34ecda77f603e260ab20"
+ }
+ },
+ {
+ "name": "commons-text-1.4-test-sources.jar",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=test-sources&type=jar",
+ "digest": {
+ "md5": "340231a697e2862c8d820de419a91d3e",
+ "sha1": "5e107fc877c67992948620a8a2d9bb94cab21aa0",
+ "sha256": "9200a2a41b35f2d6d30c1c698308591cf577547ec39514657dff0e2f7dff18ca",
+ "sha512": "02b405eb9aff57959a3e066030745be47b76c71af71f86e06fd5004602a399cdf86b1149554e92e9922d9da2fd2239dcf6e8c783462167320838dd7662405b30"
+ }
+ },
+ {
+ "name": "commons-text-1.4-bin.tar.gz",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=bin&type=tar.gz",
+ "digest": {
+ "md5": "9fe25162590be6fa684a9d9cdc0b505d",
+ "sha1": "6f46fd82d5ccf9644a37bcec6f2158a52ebdbab8",
+ "sha256": "8b9393f7ddc2efb69d8c2b6f4d85d8711dddfe77009799cf21619fc9b8411897",
+ "sha512": "c76f4e5814c0533030fecf0ef7639ce68df54b76ebc1320d9c4e3b8dff0a90c95ce0a425b8df6a0d742f6e9fca8dce6f08632d576c6a99357fdd54b9435fed6c"
+ }
+ },
+ {
+ "name": "commons-text-1.4-bin.zip",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=bin&type=zip",
+ "digest": {
+ "md5": "e29e5290b7420ba1908c418fc5bc7f8a",
+ "sha1": "447a051d6c8292c7e4d7641ca6586b335ef13bd6",
+ "sha256": "ad3732dcb38e510b1dbb1544115d0eb797fab61afe0008fdb187cd4ef1706cd7",
+ "sha512": "040634b27146e2008bf953f1bb63f8ccbb63cdaaaf24beb17acc6782d31452214e755539c2df737e46e7ede5d4e376cb3de5bbf72ceba5409b43f25612c2bf40"
+ }
+ },
+ {
+ "name": "commons-text-1.4-src.tar.gz",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=src&type=tar.gz",
+ "digest": {
+ "md5": "be130c23d3b6630824e2a08530bd4581",
+ "sha1": "0dcee421c4e03d6bc098a61a5cdcc90656856611",
+ "sha256": "1cb8536c375c3cff66757fd40c2bf878998254ba0a247866a6536bd48ba2e88a",
+ "sha512": "8279eb7f45009f11658c256b07cfb06c5a45ef89949e78a67e5d64520d957707f90a9614c95e8aac350a07a54f9160f0802dbd1efc0769a5b1391e52ee4cd51b"
+ }
+ },
+ {
+ "name": "commons-text-1.4-src.zip",
+ "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=src&type=zip",
+ "digest": {
+ "md5": "fd65603e930f2b0805c809aa2deb1498",
+ "sha1": "ca1cc6fbb4e46b44f8bb09b70c9e3a2ae3c5fce8",
+ "sha256": "e4a6c992153faae4f7faff689b899073000364e376736b9746a5d0acb9d8b980",
+ "sha512": "79ca61ff7b287407428bbb6ae13c6d372dcd0665114c55cd5bc57978a6fa760305e32feabef62cfeb0c4181220a59406239f6cccaa9a25c68773eef0250cb3a9"
+ }
+ }
+ ],
+ "predicateType": "https://slsa.dev/provenance/v1",
+ "predicate": {
+ "buildDefinition": {
+ "buildType": "https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0",
+ "externalParameters": {
+ "maven.profiles": [
+ "release"
+ ],
+ "maven.cmdline": "deploy -Prelease -Dgpg.keyname=3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9",
+ "jvm.args": [
+ "-Dfile.encoding=UTF-8",
+ "-Dsun.stdout.encoding=UTF-8",
+ "-Dsun.stderr.encoding=UTF-8"
+ ],
+ "maven.user.properties": {
+ "gpg.keyname": "3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9"
+ },
+ "maven.goals": [
+ "deploy"
+ ],
+ "env": {
+ "LANG": "pl_PL.UTF-8"
+ }
+ },
+ "internalParameters": {},
+ "resolvedDependencies": [
+ {
+ "name": "JDK",
+ "digest": {
+ "gitTree": "bdb67e47c1b7df9c35ae045f29a348bb5bd32dc3"
+ },
+ "annotations": {
+ "home": "/usr/lib/jvm/temurin-25-jdk-amd64",
+ "specification.maintenance.version": null,
+ "specification.name": "Java Platform API Specification",
+ "specification.vendor": "Oracle Corporation",
+ "specification.version": "25",
+ "vendor": "Eclipse Adoptium",
+ "vendor.url": "https://adoptium.net/",
+ "vendor.version": "Temurin-25.0.2+10",
+ "version": "25.0.2",
+ "version.date": "2026-01-20",
+ "vm.name": "OpenJDK 64-Bit Server VM",
+ "vm.specification.name": "Java Virtual Machine Specification",
+ "vm.specification.vendor": "Oracle Corporation",
+ "vm.specification.version": "25",
+ "vm.vendor": "Eclipse Adoptium",
+ "vm.version": "25.0.2+10-LTS"
+ }
+ },
+ {
+ "name": "Maven",
+ "uri": "pkg:maven/org.apache.maven/apache-maven@3.9.12",
+ "digest": {
+ "gitTree": "3cdb4a67690dc18373f70ead98dc86567cc5ad67"
+ },
+ "annotations": {
+ "distributionId": "apache-maven",
+ "distributionName": "Apache Maven",
+ "distributionShortName": "Maven",
+ "buildNumber": "848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1",
+ "version": "3.9.12"
+ }
+ },
+ {
+ "uri": "git+https://github.com/apache/commons-text.git@feat/slsa",
+ "digest": {
+ "gitCommit": "f519b3670795da3fb4f43b6af1f727eadf8e6800"
+ }
+ }
+ ]
+ },
+ "runDetails": {
+ "builder": {
+ "id": "pkg:maven/${project.groupId}/${project.artifactId}@${project.version}",
+ "builderDependencies": [],
+ "version": {}
+ },
+ "metadata": {
+ "startedOn": "2026-04-20T09:28:44Z",
+ "finishedOn": "2026-04-20T09:38:12Z"
+ }
+ }
+ }
+}
diff --git a/src/test/resources/plugin.properties b/src/test/resources/plugin.properties
new file mode 100644
index 000000000..7e61707a5
--- /dev/null
+++ b/src/test/resources/plugin.properties
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+plugin.groupId=${project.groupId}
+plugin.artifactId=${project.artifactId}
+plugin.version=${project.version}