diff --git a/checkstyle.xml b/checkstyle.xml
index 8f329d35b..0f5a18551 100644
--- a/checkstyle.xml
+++ b/checkstyle.xml
@@ -185,7 +185,7 @@
-
+
diff --git a/fb-excludes.xml b/fb-excludes.xml
index 2cba28121..0a0a38438 100644
--- a/fb-excludes.xml
+++ b/fb-excludes.xml
@@ -18,6 +18,11 @@
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 0f7428f07..404d3c42f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -26,7 +26,8 @@
commons-release-plugin
maven-plugin
- 1.9.3-SNAPSHOT
+
+ 1.9.3.slsa-SNAPSHOT
Apache Commons Release Plugin
Apache Maven Mojo for Apache Commons Release tasks.
@@ -113,7 +114,29 @@
true
true
+
+ 2.21.2
+ 2.0.17
+
+
+
+ org.slf4j
+ slf4j-bom
+ ${commons.slf4j.version}
+ pom
+ import
+
+
+
+ com.fasterxml.jackson
+ jackson-bom
+ ${commons.jackson.version}
+ pom
+ import
+
+
+
org.apache.commons
@@ -171,6 +194,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
@@ -188,11 +234,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
@@ -223,6 +292,11 @@
+
+ org.slf4j
+ slf4j-simple
+ test
+
clean verify apache-rat:check checkstyle:check spotbugs:check javadoc:javadoc site
@@ -240,6 +314,25 @@
+
+
+
+ src/test/resources
+ true
+
+ attestations/**
+ plugin.properties
+
+
+
+ src/test/resources
+ false
+
+ attestations/**
+ plugin.properties
+
+
+
@@ -532,7 +625,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/BuildDefinition.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java
new file mode 100644
index 000000000..43bcb94d5
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java
@@ -0,0 +1,187 @@
+/*
+ * 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 java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Inputs that define the build: the build type, external and internal parameters, and resolved dependencies.
+ *
+ * Specifies everything that influenced the build output. Together with {@link RunDetails}, it forms the complete
+ * {@link Provenance} record.
+ *
+ * @see SLSA v1.2 Specification
+ */
+public class BuildDefinition {
+
+ /**
+ * URI indicating what type of build was performed.
+ */
+ @JsonProperty("buildType")
+ private String buildType = "https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0";
+
+ /**
+ * Inputs passed to the build.
+ */
+ @JsonProperty("externalParameters")
+ private Map externalParameters = new HashMap<>();
+
+ /**
+ * Parameters set by the build platform.
+ */
+ @JsonProperty("internalParameters")
+ private Map internalParameters = new HashMap<>();
+
+ /**
+ * Artifacts the build depends on, specified by URI and digest.
+ */
+ @JsonProperty("resolvedDependencies")
+ private List resolvedDependencies;
+
+ /**
+ * Creates a new BuildDefinition instance with the default build type.
+ */
+ public BuildDefinition() {
+ }
+
+ /**
+ * Creates a new BuildDefinition with the given build type and external parameters.
+ *
+ * @param buildType URI indicating what type of build was performed
+ * @param externalParameters inputs passed to the build
+ */
+ public BuildDefinition(String buildType, Map externalParameters) {
+ this.buildType = buildType;
+ this.externalParameters = externalParameters;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ BuildDefinition that = (BuildDefinition) o;
+ return Objects.equals(buildType, that.buildType) && Objects.equals(externalParameters, that.externalParameters) && Objects.equals(internalParameters,
+ that.internalParameters) && Objects.equals(resolvedDependencies, that.resolvedDependencies);
+ }
+
+ /**
+ * Gets the URI indicating what type of build was performed.
+ *
+ * Determines the meaning of {@code externalParameters} and {@code internalParameters}.
+ *
+ * @return the build type URI
+ */
+ public String getBuildType() {
+ return buildType;
+ }
+
+ /**
+ * Gets the inputs passed to the build, such as command-line arguments or environment variables.
+ *
+ * @return the external parameters map, or {@code null} if not set
+ */
+ public Map getExternalParameters() {
+ return externalParameters;
+ }
+
+ /**
+ * Gets the artifacts the build depends on, such as sources, dependencies, build tools, and base images,
+ * specified by URI and digest.
+ *
+ * @return the internal parameters map, or {@code null} if not set
+ */
+ public Map getInternalParameters() {
+ return internalParameters;
+ }
+
+ /**
+ * Gets the materials that influenced the build.
+ *
+ * Considered incomplete unless resolved materials are present.
+ *
+ * @return the list of resolved dependencies, or {@code null} if not set
+ */
+ public List getResolvedDependencies() {
+ return resolvedDependencies;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(buildType, externalParameters, internalParameters, resolvedDependencies);
+ }
+
+ /**
+ * Sets the URI indicating what type of build was performed.
+ *
+ * @param buildType the build type URI
+ * @return this for chaining
+ */
+ public BuildDefinition setBuildType(String buildType) {
+ this.buildType = buildType;
+ return this;
+ }
+
+ /**
+ * Sets the inputs passed to the build.
+ *
+ * @param externalParameters the external parameters map
+ * @return this for chaining
+ */
+ public BuildDefinition setExternalParameters(Map externalParameters) {
+ this.externalParameters = externalParameters;
+ return this;
+ }
+
+ /**
+ * Sets the artifacts the build depends on.
+ *
+ * @param internalParameters the internal parameters map
+ * @return this for chaining
+ */
+ public BuildDefinition setInternalParameters(Map internalParameters) {
+ this.internalParameters = internalParameters;
+ return this;
+ }
+
+ /**
+ * Sets the materials that influenced the build.
+ *
+ * @param resolvedDependencies the list of resolved dependencies
+ * @return this for chaining
+ */
+ public BuildDefinition setResolvedDependencies(List resolvedDependencies) {
+ this.resolvedDependencies = resolvedDependencies;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "BuildDefinition{buildType='" + buildType + '\''
+ + ", externalParameters=" + externalParameters
+ + ", internalParameters=" + internalParameters
+ + ", resolvedDependencies=" + resolvedDependencies + '}';
+ }
+}
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
new file mode 100644
index 000000000..595e0f714
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java
@@ -0,0 +1,141 @@
+/*
+ * 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 java.time.OffsetDateTime;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Metadata about a build invocation: its identifier and start and finish timestamps.
+ *
+ * @see SLSA v1.2 Specification
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class BuildMetadata {
+
+ /** Timestamp when the build completed. */
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
+ @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'")
+ @JsonProperty("startedOn")
+ private OffsetDateTime startedOn;
+
+ /** Creates a new BuildMetadata instance. */
+ public BuildMetadata() {
+ }
+
+ /**
+ * Creates a new BuildMetadata instance with all fields set.
+ *
+ * @param invocationId identifier for this build invocation
+ * @param startedOn timestamp when the build started
+ * @param finishedOn timestamp when the build completed
+ */
+ public BuildMetadata(String invocationId, OffsetDateTime startedOn, OffsetDateTime finishedOn) {
+ this.invocationId = invocationId;
+ this.startedOn = startedOn;
+ this.finishedOn = finishedOn;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof BuildMetadata)) {
+ return false;
+ }
+ BuildMetadata that = (BuildMetadata) o;
+ return Objects.equals(invocationId, that.invocationId) && Objects.equals(startedOn, that.startedOn) && Objects.equals(finishedOn, that.finishedOn);
+ }
+
+ /**
+ * Gets the timestamp of when the build completed, serialized as RFC 3339 in UTC ({@code "Z"} suffix).
+ *
+ * @return the completion timestamp, or {@code null} if not set
+ */
+ public OffsetDateTime getFinishedOn() {
+ return finishedOn;
+ }
+
+ /**
+ * Gets the identifier for this build invocation.
+ *
+ * @return the invocation identifier, or {@code null} if not set
+ */
+ public String getInvocationId() {
+ return invocationId;
+ }
+
+ /**
+ * Gets the timestamp of when the build started, serialized as RFC 3339 in UTC ({@code "Z"} suffix).
+ *
+ * @return the start timestamp, or {@code null} if not set
+ */
+ public OffsetDateTime getStartedOn() {
+ return startedOn;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(invocationId, startedOn, finishedOn);
+ }
+
+ /**
+ * Sets the timestamp of when the build completed.
+ *
+ * @param finishedOn the completion timestamp
+ * @return this for chaining
+ */
+ public BuildMetadata setFinishedOn(OffsetDateTime finishedOn) {
+ this.finishedOn = finishedOn;
+ return this;
+ }
+
+ /**
+ * Sets the identifier for this build invocation.
+ *
+ * @param invocationId the invocation identifier
+ * @return this for chaining
+ */
+ public BuildMetadata setInvocationId(String invocationId) {
+ this.invocationId = invocationId;
+ return this;
+ }
+
+ /**
+ * Sets the timestamp of when the build started.
+ *
+ * @param startedOn the start timestamp
+ * @return this for chaining
+ */
+ public BuildMetadata setStartedOn(OffsetDateTime startedOn) {
+ this.startedOn = startedOn;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "BuildMetadata{invocationId='" + invocationId + "', startedOn=" + startedOn + ", finishedOn=" + finishedOn + '}';
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java
new file mode 100644
index 000000000..508d622a8
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java
@@ -0,0 +1,129 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Entity that executed the build and is trusted to have correctly performed the operation and populated the provenance.
+ *
+ * @see SLSA v1.2 Specification
+ */
+public class Builder {
+
+ /** Orchestrator dependencies that may affect provenance generation. */
+ @JsonProperty("builderDependencies")
+ private List builderDependencies = new ArrayList<>();
+ /** Identifier URI of the builder. */
+ @JsonProperty("id")
+ private String id;
+ /** Map of build platform component names to their versions. */
+ @JsonProperty("version")
+ private Map version = new HashMap<>();
+
+ /** Creates a new Builder instance. */
+ public Builder() {
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Builder)) {
+ return false;
+ }
+ Builder that = (Builder) o;
+ return Objects.equals(id, that.id)
+ && Objects.equals(builderDependencies, that.builderDependencies)
+ && Objects.equals(version, that.version);
+ }
+
+ /**
+ * Gets orchestrator dependencies that do not run within the build workload and do not affect the build output,
+ * but may affect provenance generation or security guarantees.
+ *
+ * @return the list of builder dependencies, or {@code null} if not set
+ */
+ public List getBuilderDependencies() {
+ return builderDependencies;
+ }
+
+ /**
+ * Gets the identifier of the builder.
+ *
+ * @return the builder identifier URI
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Gets a map of build platform component names to their versions.
+ *
+ * @return the version map, or {@code null} if not set
+ */
+ public Map getVersion() {
+ return version;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, builderDependencies, version);
+ }
+
+ /**
+ * Sets the orchestrator dependencies that may affect provenance generation or security guarantees.
+ *
+ * @param builderDependencies the list of builder dependencies
+ * @return this for chaining
+ */
+ public Builder setBuilderDependencies(List builderDependencies) {
+ this.builderDependencies = builderDependencies;
+ return this;
+ }
+
+ /**
+ * Sets the identifier of the builder.
+ *
+ * @param id the builder identifier URI
+ * @return this for chaining
+ */
+ public Builder setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ /**
+ * Sets the map of build platform component names to their versions.
+ *
+ * @param version the version map
+ * @return this for chaining
+ */
+ public Builder setVersion(Map version) {
+ this.version = version;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "Builder{id='" + id + "', builderDependencies=" + builderDependencies + ", version=" + version + '}';
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java
new file mode 100644
index 000000000..e68e5757c
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java
@@ -0,0 +1,130 @@
+/*
+ * 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 java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * DSSE (Dead Simple Signing Envelope) that wraps a signed in-toto statement payload.
+ *
+ * @see DSSE Envelope specification
+ */
+public class DsseEnvelope {
+
+ /** The payload type URI for in-toto attestation statements. */
+ public static final String PAYLOAD_TYPE = "application/vnd.in-toto+json";
+ /** Serialized statement bytes, Base64-encoded in JSON. */
+ @JsonProperty("payload")
+ private byte[] payload;
+ /** Content type identifying the format of {@link #payload}. */
+ @JsonProperty("payloadType")
+ private String payloadType = PAYLOAD_TYPE;
+ /** One or more signatures over the PAE-encoded payload. */
+ @JsonProperty("signatures")
+ private List signatures;
+
+ /** Creates a new DsseEnvelope instance with {@code payloadType} pre-set to {@link #PAYLOAD_TYPE}. */
+ public DsseEnvelope() {
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof DsseEnvelope)) {
+ return false;
+ }
+ DsseEnvelope envelope = (DsseEnvelope) o;
+ return Objects.equals(payloadType, envelope.payloadType) && Arrays.equals(payload, envelope.payload)
+ && Objects.equals(signatures, envelope.signatures);
+ }
+
+ /**
+ * Gets the serialized payload bytes.
+ *
+ * When serialized to JSON the bytes are Base64-encoded.
+ *
+ * @return the payload bytes, or {@code null} if not set
+ */
+ public byte[] getPayload() {
+ return payload;
+ }
+
+ /**
+ * Gets the payload type URI.
+ *
+ * @return the payload type, never {@code null} in a valid envelope
+ */
+ public String getPayloadType() {
+ return payloadType;
+ }
+
+ /**
+ * Gets the list of signatures over the PAE-encoded payload.
+ *
+ * @return the signatures, or {@code null} if not set
+ */
+ public List getSignatures() {
+ return signatures;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(payloadType, Arrays.hashCode(payload), signatures);
+ }
+
+ /**
+ * Sets the serialized payload bytes.
+ *
+ * @param payload the payload bytes
+ * @return this for chaining
+ */
+ public DsseEnvelope setPayload(byte[] payload) {
+ this.payload = payload;
+ return this;
+ }
+
+ /**
+ * Sets the payload type URI.
+ *
+ * @param payloadType the payload type URI
+ * @return this for chaining
+ */
+ public DsseEnvelope setPayloadType(String payloadType) {
+ this.payloadType = payloadType;
+ return this;
+ }
+
+ /**
+ * Sets the list of signatures over the PAE-encoded payload.
+ *
+ * @param signatures the signatures
+ * @return this for chaining
+ */
+ public DsseEnvelope setSignatures(List signatures) {
+ this.signatures = signatures;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "DsseEnvelope{payloadType='" + payloadType + "', payload=<" + (payload != null ? payload.length : 0)
+ + " bytes>, signatures=" + signatures + '}';
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java
new file mode 100644
index 000000000..6002dce7e
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java
@@ -0,0 +1,124 @@
+/*
+ * 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 java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Root predicate of an SLSA v1.2 provenance attestation, describing what was built and how.
+ *
+ * Combines a {@link BuildDefinition} (the inputs) with {@link RunDetails} (the execution context). Intended to be
+ * used as the {@code predicate} field of an in-toto {@link Statement}.
+ *
+ * @see SLSA v1.2 Specification
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class Provenance {
+
+ /** Predicate type URI used in the in-toto {@link Statement} wrapping this provenance. */
+ public static final String PREDICATE_TYPE = "https://slsa.dev/provenance/v1";
+
+ /** Inputs that defined the build. */
+ @JsonProperty("buildDefinition")
+ private BuildDefinition buildDefinition;
+
+ /** Details about the build invocation. */
+ @JsonProperty("runDetails")
+ private RunDetails runDetails;
+
+ /** Creates a new Provenance instance. */
+ public Provenance() {
+ }
+
+ /**
+ * Creates a new Provenance with the given build definition and run details.
+ *
+ * @param buildDefinition inputs that defined the build
+ * @param runDetails details about the build invocation
+ */
+ public Provenance(BuildDefinition buildDefinition, RunDetails runDetails) {
+ this.buildDefinition = buildDefinition;
+ this.runDetails = runDetails;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Provenance that = (Provenance) o;
+ return Objects.equals(buildDefinition, that.buildDefinition) && Objects.equals(runDetails, that.runDetails);
+ }
+
+ /**
+ * Gets the build definition describing all inputs that produced the build output.
+ *
+ * Includes source code, dependencies, build tools, base images, and other materials.
+ *
+ * @return the build definition, or {@code null} if not set
+ */
+ public BuildDefinition getBuildDefinition() {
+ return buildDefinition;
+ }
+
+ /**
+ * Gets the details about the invocation of the build tool and the environment in which it was run.
+ *
+ * @return the run details, or {@code null} if not set
+ */
+ public RunDetails getRunDetails() {
+ return runDetails;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(buildDefinition, runDetails);
+ }
+
+ /**
+ * Sets the build definition describing all inputs that produced the build output.
+ *
+ * @param buildDefinition the build definition
+ * @return this for chaining
+ */
+ public Provenance setBuildDefinition(BuildDefinition buildDefinition) {
+ this.buildDefinition = buildDefinition;
+ return this;
+ }
+
+ /**
+ * Sets the details about the invocation of the build tool and the environment in which it was run.
+ *
+ * @param runDetails the run details
+ * @return this for chaining
+ */
+ public Provenance setRunDetails(RunDetails runDetails) {
+ this.runDetails = runDetails;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "Provenance{buildDefinition=" + buildDefinition + ", runDetails=" + runDetails + '}';
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java
new file mode 100644
index 000000000..cb3510ed1
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java
@@ -0,0 +1,235 @@
+/*
+ * 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 java.util.Map;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Description of an artifact or resource referenced in the build, identified by URI and cryptographic digest.
+ *
+ * Used to represent inputs to, outputs from, or byproducts of the build process.
+ *
+ * @see SLSA v1.2 Specification
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ResourceDescriptor {
+
+ /** Additional key-value metadata about the resource. */
+ @JsonProperty("annotations")
+ private Map annotations;
+ /** Raw contents of the resource, base64-encoded in JSON. */
+ @JsonProperty("content")
+ private byte[] content;
+ /** Map of digest algorithm names to hex-encoded values. */
+ @JsonProperty("digest")
+ private Map digest;
+ /** Download URI for the resource, if different from {@link #uri}. */
+ @JsonProperty("downloadLocation")
+ private String downloadLocation;
+ /** Media type of the resource. */
+ @JsonProperty("mediaType")
+ private String mediaType;
+ /** Human-readable name of the resource. */
+ @JsonProperty("name")
+ private String name;
+ /** URI identifying the resource. */
+ @JsonProperty("uri")
+ private String uri;
+
+ /** Creates a new ResourceDescriptor instance. */
+ public ResourceDescriptor() {
+ }
+
+ /**
+ * Creates a new ResourceDescriptor with the given URI and digest.
+ *
+ * @param uri URI identifying the resource
+ * @param digest map of digest algorithm names to their hex-encoded values
+ */
+ public ResourceDescriptor(String uri, Map digest) {
+ this.uri = uri;
+ this.digest = digest;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ResourceDescriptor that = (ResourceDescriptor) o;
+ return Objects.equals(uri, that.uri) && Objects.equals(digest, that.digest);
+ }
+
+ /**
+ * Gets additional key-value metadata about the resource, such as filename, size, or builder-specific attributes.
+ *
+ * @return the annotations map, or {@code null} if not set
+ */
+ public Map getAnnotations() {
+ return annotations;
+ }
+
+ /**
+ * Gets the raw contents of the resource, base64-encoded when serialized to JSON.
+ *
+ * @return the resource content, or {@code null} if not set
+ */
+ public byte[] getContent() {
+ return content;
+ }
+
+ /**
+ * Gets the map of cryptographic digest algorithms to their corresponding hex-encoded values for this resource.
+ *
+ * Common keys include {@code "sha256"} and {@code "sha512"}.
+ *
+ * @return the digest map, or {@code null} if not set
+ */
+ public Map getDigest() {
+ return digest;
+ }
+
+ /**
+ * Gets the download URI for the resource, if different from {@link #getUri()}.
+ *
+ * @return the download location URI, or {@code null} if not set
+ */
+ public String getDownloadLocation() {
+ return downloadLocation;
+ }
+
+ /**
+ * Gets the media type of the resource (e.g., {@code "application/octet-stream"}).
+ *
+ * @return the media type, or {@code null} if not set
+ */
+ public String getMediaType() {
+ return mediaType;
+ }
+
+ /**
+ * Gets the name of the resource.
+ *
+ * @return the resource name, or {@code null} if not set
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Gets the URI identifying the resource.
+ *
+ * @return the resource URI, or {@code null} if not set
+ */
+ public String getUri() {
+ return uri;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uri, digest);
+ }
+
+ /**
+ * Sets additional key-value metadata about the resource.
+ *
+ * @param annotations the annotations map
+ * @return this for chaining
+ */
+ public ResourceDescriptor setAnnotations(Map annotations) {
+ this.annotations = annotations;
+ return this;
+ }
+
+ /**
+ * Sets the raw contents of the resource.
+ *
+ * @param content the resource content
+ * @return this for chaining
+ */
+ public ResourceDescriptor setContent(byte[] content) {
+ this.content = content;
+ return this;
+ }
+
+ /**
+ * Sets the map of cryptographic digest algorithms to their hex-encoded values.
+ *
+ * @param digest the digest map
+ * @return this for chaining
+ */
+ public ResourceDescriptor setDigest(Map digest) {
+ this.digest = digest;
+ return this;
+ }
+
+ /**
+ * Sets the download URI for the resource.
+ *
+ * @param downloadLocation the download location URI
+ * @return this for chaining
+ */
+ public ResourceDescriptor setDownloadLocation(String downloadLocation) {
+ this.downloadLocation = downloadLocation;
+ return this;
+ }
+
+ /**
+ * Sets the media type of the resource.
+ *
+ * @param mediaType the media type
+ * @return this for chaining
+ */
+ public ResourceDescriptor setMediaType(String mediaType) {
+ this.mediaType = mediaType;
+ return this;
+ }
+
+ /**
+ * Sets the name of the resource.
+ *
+ * @param name the resource name
+ * @return this for chaining
+ */
+ public ResourceDescriptor setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ /**
+ * Sets the URI identifying the resource.
+ *
+ * @param uri the resource URI
+ * @return this for chaining
+ */
+ public ResourceDescriptor setUri(String uri) {
+ this.uri = uri;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "ResourceDescriptor{uri='" + uri + '\'' + ", digest=" + digest + '}';
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java
new file mode 100644
index 000000000..da14aefc4
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java
@@ -0,0 +1,149 @@
+/*
+ * 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 java.util.List;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Details about the build invocation: the builder identity, execution metadata, and any byproduct artifacts.
+ *
+ * @see SLSA v1.2 Specification
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class RunDetails {
+
+ /**
+ * Entity that executed the build.
+ */
+ @JsonProperty("builder")
+ private Builder builder;
+ /**
+ * Artifacts produced as a side effect of the build.
+ */
+ @JsonProperty("byproducts")
+ private List byproducts;
+ /**
+ * Metadata about the build invocation.
+ */
+ @JsonProperty("metadata")
+ private BuildMetadata metadata;
+
+ /**
+ * Creates a new RunDetails instance.
+ */
+ public RunDetails() {
+ }
+
+ /**
+ * Creates a new RunDetails with the given builder and metadata.
+ *
+ * @param builder entity that executed the build
+ * @param metadata metadata about the build invocation
+ */
+ public RunDetails(Builder builder, BuildMetadata metadata) {
+ this.builder = builder;
+ this.metadata = metadata;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ RunDetails that = (RunDetails) o;
+ return Objects.equals(builder, that.builder) && Objects.equals(metadata, that.metadata) && Objects.equals(byproducts, that.byproducts);
+ }
+
+ /**
+ * Gets the builder that executed the invocation.
+ *
+ * Trusted to have correctly performed the operation and populated this provenance.
+ *
+ * @return the builder, or {@code null} if not set
+ */
+ public Builder getBuilder() {
+ return builder;
+ }
+
+ /**
+ * Gets artifacts produced as a side effect of the build that are not the primary output.
+ *
+ * @return the list of byproduct artifacts, or {@code null} if not set
+ */
+ public List getByproducts() {
+ return byproducts;
+ }
+
+ /**
+ * Gets the metadata about the build invocation, including its identifier and timing.
+ *
+ * @return the build metadata, or {@code null} if not set
+ */
+ public BuildMetadata getMetadata() {
+ return metadata;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(builder, metadata, byproducts);
+ }
+
+ /**
+ * Sets the builder that executed the invocation.
+ *
+ * @param builder the builder
+ * @return this for chaining
+ */
+ public RunDetails setBuilder(Builder builder) {
+ this.builder = builder;
+ return this;
+ }
+
+ /**
+ * Sets the artifacts produced as a side effect of the build that are not the primary output.
+ *
+ * @param byproducts the list of byproduct artifacts
+ * @return this for chaining
+ */
+ public RunDetails setByproducts(List byproducts) {
+ this.byproducts = byproducts;
+ return this;
+ }
+
+ /**
+ * Sets the metadata about the build invocation.
+ *
+ * @param metadata the build metadata
+ * @return this for chaining
+ */
+ public RunDetails setMetadata(BuildMetadata metadata) {
+ this.metadata = metadata;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "RunDetails{builder=" + builder + ", metadata=" + metadata + ", byproducts=" + byproducts + '}';
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java
new file mode 100644
index 000000000..77e769805
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java
@@ -0,0 +1,107 @@
+/*
+ * 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 java.util.Arrays;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * A single cryptographic signature within a DSSE envelope.
+ *
+ * @see DSSE Envelope specification
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class Signature {
+
+ /**
+ * Hint for which key was used to sign; unset is treated as empty.
+ *
+ * Consumers MUST NOT require this field to be set, and MUST NOT use it for security decisions.
+ */
+ @JsonProperty("keyid")
+ private String keyid;
+
+ /** Raw signature bytes of the PAE-encoded payload. */
+ @JsonProperty("sig")
+ private byte[] sig;
+
+ /** Creates a new Signature instance. */
+ public Signature() {
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Signature)) {
+ return false;
+ }
+ Signature signature = (Signature) o;
+ return Objects.equals(keyid, signature.keyid) && Arrays.equals(sig, signature.sig);
+ }
+
+ /**
+ * Gets the key identifier hint, or {@code null} if not set.
+ *
+ * @return the key identifier, or {@code null}
+ */
+ public String getKeyid() {
+ return keyid;
+ }
+
+ /**
+ * Gets the raw signature bytes.
+ *
+ * @return the signature bytes, or {@code null} if not set
+ */
+ public byte[] getSig() {
+ return sig;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(keyid, Arrays.hashCode(sig));
+ }
+
+ /**
+ * Sets the key identifier hint.
+ *
+ * @param keyid the key identifier, or {@code null} to leave unset
+ * @return this for chaining
+ */
+ public Signature setKeyid(String keyid) {
+ this.keyid = keyid;
+ return this;
+ }
+
+ /**
+ * Sets the raw signature bytes.
+ *
+ * @param sig the signature bytes
+ * @return this for chaining
+ */
+ public Signature setSig(byte[] sig) {
+ this.sig = sig;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "Signature{keyid='" + keyid + "', sig=<" + (sig != null ? sig.length : 0) + " bytes>}";
+ }
+}
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
new file mode 100644
index 000000000..1d779c63d
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
@@ -0,0 +1,131 @@
+/*
+ * 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 java.util.List;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * In-toto v1 attestation envelope that binds a set of subject artifacts to an SLSA provenance predicate.
+ *
+ * @see in-toto Statement v1
+ */
+public class Statement {
+
+ /** The in-toto statement schema URI. */
+ public static final String TYPE = "https://in-toto.io/Statement/v1";
+ /** The provenance predicate. */
+ @JsonProperty("predicate")
+ private Provenance predicate;
+ /** URI identifying the type of the predicate. */
+ @JsonProperty("predicateType")
+ private String predicateType;
+ /** Software artifacts that the attestation applies to. */
+ @JsonProperty("subject")
+ private List subject;
+
+ /** Creates a new Statement instance. */
+ public Statement() {
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Statement)) {
+ return false;
+ }
+ Statement statement = (Statement) o;
+ return Objects.equals(subject, statement.subject) && Objects.equals(predicateType, statement.predicateType) && Objects.equals(predicate,
+ statement.predicate);
+ }
+
+ /**
+ * Type of JSON object.
+ *
+ * @return Always {@value TYPE}
+ */
+ @JsonProperty("_type")
+ public String getType() {
+ return TYPE;
+ }
+
+ /**
+ * Gets the provenance predicate.
+ *
+ * Unset is treated the same as set-but-empty. May be omitted if {@code predicateType} fully describes the
+ * predicate.
+ *
+ * @return the provenance predicate, or {@code null} if not set
+ */
+ public Provenance getPredicate() {
+ return predicate;
+ }
+
+ /**
+ * Gets the URI identifying the type of the predicate.
+ *
+ * @return the predicate type URI, or {@code null} if no predicate has been set
+ */
+ public String getPredicateType() {
+ return predicateType;
+ }
+
+ /**
+ * Gets the set of software artifacts that the attestation applies to.
+ *
+ * Each element represents a single artifact. Artifacts are matched purely by digest, regardless of content type.
+ *
+ * @return the list of subject artifacts, or {@code null} if not set
+ */
+ public List getSubject() {
+ return subject;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(subject, predicateType, predicate);
+ }
+
+ /**
+ * Sets the provenance predicate and automatically assigns {@code predicateType} to the SLSA provenance v1 URI.
+ *
+ * @param predicate the provenance predicate
+ * @return this for chaining
+ */
+ public Statement setPredicate(Provenance predicate) {
+ this.predicate = predicate;
+ this.predicateType = Provenance.PREDICATE_TYPE;
+ return this;
+ }
+
+ /**
+ * Sets the set of software artifacts that the attestation applies to.
+ *
+ * @param subject the list of subject artifacts
+ * @return this for chaining
+ */
+ public Statement setSubject(List subject) {
+ this.subject = subject;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "Statement{_type='" + TYPE + "', subject=" + subject + ", predicateType='" + predicateType + "', predicate=" + predicate + '}';
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/package-info.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/package-info.java
new file mode 100644
index 000000000..69a5ce287
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/package-info.java
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ */
+
+/**
+ * SLSA 1.2 Build Attestation Models.
+ *
+ * This package provides Jackson-annotated model classes that implement the Supply-chain Levels for Software Artifacts
+ * (SLSA) v1.2 specification.
+ *
+ * Overview
+ *
+ * SLSA is a framework for evaluating and improving the security posture of build systems. SLSA v1.2 defines a standard for recording build provenance:
+ * information about how software artifacts were produced.
+ *
+ * @see SLSA v1.2 Specification
+ * @see In-toto Attestation Framework
+ * @see Jackson JSON processor
+ */
+package org.apache.commons.release.plugin.slsa.v1_2;
+
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/resources/artifacts/artifact-jar.txt b/src/test/resources/artifacts/artifact-jar.txt
new file mode 100644
index 000000000..103a21e64
--- /dev/null
+++ b/src/test/resources/artifacts/artifact-jar.txt
@@ -0,0 +1,2 @@
+// SPDX-License-Identifier: Apache-2.0
+A mock-up of a JAR file
\ No newline at end of file
diff --git a/src/test/resources/artifacts/artifact-pom.txt b/src/test/resources/artifacts/artifact-pom.txt
new file mode 100644
index 000000000..48d1bbc32
--- /dev/null
+++ b/src/test/resources/artifacts/artifact-pom.txt
@@ -0,0 +1,2 @@
+// SPDX-License-Identifier: Apache-2.0
+A mock-up of a POM file
\ No newline at end of file
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.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}