From 1074cbcd20d5e31d7e096675ff04b5f693802905 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Mon, 30 Mar 2026 16:47:37 +0200
Subject: [PATCH 01/14] Add `build-attestation` target
This PR was moved from apache/commons-build-plugin#417
It adds a goal to generate a [SLSA](https://slsa.dev/) build attestation and attaches it to the build as a file with the `.intoto.json` extension.
The attestation records the following information about the build environment:
- The Java version used (vendor, version string)
- The Maven version used
- The `gitTree` hash of the unpacked Java distribution
- The `gitTree` hash of the unpacked Maven distribution
---
checkstyle.xml | 2 +-
fb-excludes.xml | 5 +
pom.xml | 65 +++
.../plugin/internal/ArtifactUtils.java | 118 +++++
.../plugin/internal/BuildToolDescriptors.java | 89 ++++
.../release/plugin/internal/GitUtils.java | 117 +++++
.../release/plugin/internal/package-info.java | 23 +
.../plugin/mojos/BuildAttestationMojo.java | 454 ++++++++++++++++++
.../plugin/slsa/v1_2/BuildDefinition.java | 173 +++++++
.../plugin/slsa/v1_2/BuildMetadata.java | 140 ++++++
.../release/plugin/slsa/v1_2/Builder.java | 125 +++++
.../release/plugin/slsa/v1_2/Provenance.java | 120 +++++
.../plugin/slsa/v1_2/ResourceDescriptor.java | 227 +++++++++
.../release/plugin/slsa/v1_2/RunDetails.java | 137 ++++++
.../release/plugin/slsa/v1_2/Statement.java | 122 +++++
.../plugin/slsa/v1_2/package-info.java | 34 ++
.../release/plugin/internal/MojoUtils.java | 71 +++
.../mojos/BuildAttestationMojoTest.java | 129 +++++
src/test/resources/artifacts/artifact-jar.txt | 2 +
src/test/resources/artifacts/artifact-pom.txt | 2 +
20 files changed, 2154 insertions(+), 1 deletion(-)
create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/package-info.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/package-info.java
create mode 100644 src/test/java/org/apache/commons/release/plugin/internal/MojoUtils.java
create mode 100644 src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
create mode 100644 src/test/resources/artifacts/artifact-jar.txt
create mode 100644 src/test/resources/artifacts/artifact-pom.txt
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 faa5b1ae0..e0c6767e7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -113,7 +113,22 @@
true
true
+
+ 2.21.1
+ 2.21
+ 2.0.17
+
+
+
+ org.slf4j
+ slf4j-bom
+ ${commons.slf4j.version}
+ pom
+ import
+
+
+
org.apache.commons
@@ -151,6 +166,18 @@
maven-scm-api
${maven-scm.version}
+
+ org.apache.maven.scm
+ maven-scm-manager-plexus
+ ${maven-scm.version}
+ compile
+
+
+ org.apache.maven.scm
+ maven-scm-provider-gitexe
+ ${maven-scm.version}
+ runtime
+
org.apache.maven.scm
maven-scm-provider-svnexe
@@ -171,6 +198,22 @@
commons-compress
1.28.0
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${commons.jackson.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ ${commons.jackson.annotations.version}
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+ ${commons.jackson.version}
+ runtime
+
org.apache.maven.plugin-testing
maven-plugin-testing-harness
@@ -188,11 +231,28 @@
junit-jupiter
test
+
+ net.javacrumbs.json-unit
+ json-unit-assertj
+ 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.maven
@@ -223,6 +283,11 @@
+
+ org.slf4j
+ slf4j-simple
+ test
+
clean verify apache-rat:check checkstyle:check spotbugs:check javadoc:javadoc site
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..7f07244e2
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java
@@ -0,0 +1,118 @@
+/*
+ * 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.HashMap;
+import java.util.Map;
+
+import org.apache.commons.codec.digest.DigestUtils;
+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 {
+
+ /** No instances. */
+ private ArtifactUtils() {
+ // prevent instantiation
+ }
+
+ /**
+ * Returns the conventional filename for the given artifact.
+ *
+ * @param artifact A Maven artifact.
+ * @return A filename.
+ */
+ public static String getFileName(Artifact artifact) {
+ return getFileName(artifact, artifact.getArtifactHandler().getExtension());
+ }
+
+ /**
+ * Returns the filename for the given artifact with a changed extension.
+ *
+ * @param artifact A Maven artifact.
+ * @param extension The file name extension.
+ * @return A filename.
+ */
+ public static String getFileName(Artifact artifact, String extension) {
+ 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();
+ }
+
+ /**
+ * Returns the Package URL corresponding to this artifact.
+ *
+ * @param artifact A maven artifact.
+ * @return A PURL for the given artifact.
+ */
+ public static String getPackageUrl(Artifact artifact) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("pkg:maven/").append(artifact.getGroupId()).append("/").append(artifact.getArtifactId()).append("@").append(artifact.getVersion())
+ .append("?");
+ String classifier = artifact.getClassifier();
+ if (classifier != null) {
+ sb.append("classifier=").append(classifier).append("&");
+ }
+ sb.append("type=").append(artifact.getType());
+ return sb.toString();
+ }
+
+ /**
+ * Returns a map of checksum algorithm names to hex-encoded digest values for the given artifact file.
+ *
+ * @param artifact A Maven artifact.
+ * @return A map of checksum algorithm names to hex-encoded digest values.
+ * @throws IOException If an I/O error occurs reading the artifact file.
+ */
+ private static Map getChecksums(Artifact artifact) throws IOException {
+ Map checksums = new HashMap<>();
+ DigestUtils digest = new DigestUtils(DigestUtils.getSha256Digest());
+ String sha256sum = digest.digestAsHex(artifact.getFile());
+ checksums.put("sha256", sha256sum);
+ return checksums;
+ }
+
+ /**
+ * Converts a Maven artifact to a SLSA {@link ResourceDescriptor}.
+ *
+ * @param artifact A Maven artifact.
+ * @return A SLSA resource descriptor.
+ * @throws MojoExecutionException If an I/O error occurs retrieving the artifact.
+ */
+ public static ResourceDescriptor toResourceDescriptor(Artifact artifact) throws MojoExecutionException {
+ ResourceDescriptor descriptor = new ResourceDescriptor();
+ descriptor.setName(getFileName(artifact));
+ descriptor.setUri(getPackageUrl(artifact));
+ if (artifact.getFile() != null) {
+ try {
+ descriptor.setDigest(getChecksums(artifact));
+ } catch (IOException e) {
+ throw new MojoExecutionException("Unable to compute hash for artifact file: " + artifact.getFile(), e);
+ }
+ }
+ return descriptor;
+ }
+}
diff --git a/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java b/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java
new file mode 100644
index 000000000..15be8d73e
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java
@@ -0,0 +1,89 @@
+/*
+ * 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.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+
+import org.apache.commons.release.plugin.slsa.v1_2.ResourceDescriptor;
+
+/**
+ * Factory methods for {@link ResourceDescriptor} instances representing build-tool dependencies.
+ */
+public final class BuildToolDescriptors {
+
+ /** No instances. */
+ private BuildToolDescriptors() {
+ // no instantiation
+ }
+
+ /**
+ * 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(Path javaHome) throws IOException {
+ ResourceDescriptor descriptor = new ResourceDescriptor();
+ descriptor.setName("JDK");
+ Map digest = new HashMap<>();
+ digest.put("gitTree", GitUtils.gitTree(javaHome));
+ descriptor.setDigest(digest);
+ String[] propertyNames = {"java.version", "java.vendor", "java.vendor.version", "java.vm.name", "java.vm.version", "java.vm.vendor",
+ "java.runtime.name", "java.runtime.version", "java.specification.version"};
+ Map annotations = new HashMap<>();
+ for (String prop : propertyNames) {
+ annotations.put(prop.substring("java.".length()), System.getProperty(prop));
+ }
+ descriptor.setAnnotations(annotations);
+ return descriptor;
+ }
+
+ /**
+ * Creates a {@link ResourceDescriptor} for the Maven installation used during the build.
+ *
+ * @param version Maven version string
+ * @param mavenHome path to the Maven home directory
+ * @return a descriptor for the Maven installation
+ * @throws IOException if hashing the Maven home directory fails
+ */
+ public static ResourceDescriptor maven(String version, Path mavenHome) throws IOException {
+ ResourceDescriptor descriptor = new ResourceDescriptor();
+ descriptor.setName("Maven");
+ descriptor.setUri("pkg:maven/org.apache.maven/apache-maven@" + version);
+ Map digest = new HashMap<>();
+ digest.put("gitTree", GitUtils.gitTree(mavenHome));
+ descriptor.setDigest(digest);
+ Properties buildProps = new Properties();
+ try (InputStream in = BuildToolDescriptors.class.getResourceAsStream("/org/apache/maven/messages/build.properties")) {
+ if (in != null) {
+ buildProps.load(in);
+ }
+ }
+ if (!buildProps.isEmpty()) {
+ Map annotations = new HashMap<>();
+ buildProps.forEach((key, value) -> annotations.put((String) key, value));
+ descriptor.setAnnotations(annotations);
+ }
+ return descriptor;
+ }
+}
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..246027c49
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java
@@ -0,0 +1,117 @@
+/*
+ * 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.nio.file.Paths;
+import java.security.MessageDigest;
+
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.codec.digest.DigestUtils;
+
+/**
+ * Utilities for Git operations.
+ */
+public final class GitUtils {
+
+ /** The SCM URI prefix for Git repositories. */
+ private static final String SCM_GIT_PREFIX = "scm:git:";
+
+ /**
+ * 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(Path path) throws IOException {
+ if (!Files.isDirectory(path)) {
+ throw new IOException("Path is not a directory: " + path);
+ }
+ MessageDigest digest = DigestUtils.getSha1Digest();
+ return Hex.encodeHexString(DigestUtils.gitTree(digest, path));
+ }
+
+ /**
+ * 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(String scmUri, Path repositoryPath) throws IOException {
+ if (!scmUri.startsWith(SCM_GIT_PREFIX)) {
+ throw new IllegalArgumentException("Invalid scmUri: " + scmUri);
+ }
+ String currentBranch = getCurrentBranch(repositoryPath);
+ return "git+" + scmUri.substring(SCM_GIT_PREFIX.length()) + "@" + currentBranch;
+ }
+
+ /**
+ * Returns 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(Path repositoryPath) throws IOException {
+ Path gitDir = findGitDir(repositoryPath);
+ String head = new String(Files.readAllBytes(gitDir.resolve("HEAD")), StandardCharsets.UTF_8).trim();
+ if (head.startsWith("ref: refs/heads/")) {
+ return head.substring("ref: refs/heads/".length());
+ }
+ // detached HEAD — return the commit SHA
+ return head;
+ }
+
+ /**
+ * 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(Path path) throws IOException {
+ Path current = path.toAbsolutePath();
+ while (current != null) {
+ 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"
+ String content = new String(Files.readAllBytes(candidate), StandardCharsets.UTF_8).trim();
+ if (content.startsWith("gitdir: ")) {
+ return Paths.get(content.substring("gitdir: ".length()));
+ }
+ }
+ current = current.getParent();
+ }
+ throw new IOException("No .git directory found above: " + path);
+ }
+
+ /** 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..9218ebff4
--- /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 for the commons-release-plugin.
+ *
+ *
Should not be referenced by external artifacts.
+ */
+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..0260e22e1
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -0,0 +1,454 @@
+/*
+ * 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.lang.management.ManagementFactory;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+
+import javax.inject.Inject;
+
+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.BuildToolDescriptors;
+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.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.Statement;
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.execution.MavenExecutionRequest;
+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.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.project.MavenProject;
+import org.apache.maven.project.MavenProjectHelper;
+import org.apache.maven.rtinfo.RuntimeInformation;
+import org.apache.maven.scm.CommandParameters;
+import org.apache.maven.scm.ScmException;
+import org.apache.maven.scm.ScmFileSet;
+import org.apache.maven.scm.command.info.InfoItem;
+import org.apache.maven.scm.command.info.InfoScmResult;
+import org.apache.maven.scm.manager.ScmManager;
+import org.apache.maven.scm.repository.ScmRepository;
+
+/**
+ * This plugin generates an in-toto attestation for all the artifacts.
+ */
+@Mojo(name = "build-attestation", defaultPhase = LifecyclePhase.VERIFY, 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.json";
+
+ /** Shared Jackson object mapper for serializing attestation statements. */
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ static {
+ OBJECT_MAPPER.findAndRegisterModules();
+ OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ }
+
+ /** The SCM connection URL for the current project. */
+ @Parameter(defaultValue = "${project.scm.connection}", readonly = true)
+ private String scmConnectionUrl;
+
+ /** The Maven home directory. */
+ @Parameter(defaultValue = "${maven.home}", readonly = true)
+ private File mavenHome;
+
+ /**
+ * Issue SCM actions at this local directory.
+ */
+ @Parameter(property = "commons.release.scmDirectory", defaultValue = "${basedir}")
+ private File scmDirectory;
+
+ /** The output directory for the attestation file. */
+ @Parameter(property = "commons.release.outputDirectory", defaultValue = "${project.build.directory}")
+ private File outputDirectory;
+
+ /** Whether to skip attaching the attestation artifact to the project. */
+ @Parameter(property = "commons.release.skipAttach")
+ private boolean skipAttach;
+
+ /**
+ * The current Maven project.
+ */
+ private final MavenProject project;
+
+ /**
+ * SCM manager to detect the Git revision.
+ */
+ private final ScmManager scmManager;
+
+ /**
+ * Runtime information.
+ */
+ private final RuntimeInformation runtimeInformation;
+
+ /**
+ * The current Maven session, used to resolve plugin dependencies.
+ */
+ private final MavenSession session;
+
+ /**
+ * Helper to attach artifacts to the project.
+ */
+ private final MavenProjectHelper mavenProjectHelper;
+
+ /**
+ * Creates a new instance with the given dependencies.
+ *
+ * @param project A Maven project.
+ * @param scmManager A SCM manager.
+ * @param runtimeInformation Maven runtime information.
+ * @param session A Maven session.
+ * @param mavenProjectHelper A helper to attach artifacts to the project.
+ */
+ @Inject
+ public BuildAttestationMojo(MavenProject project, ScmManager scmManager, RuntimeInformation runtimeInformation, MavenSession session,
+ MavenProjectHelper mavenProjectHelper) {
+ this.project = project;
+ this.scmManager = scmManager;
+ this.runtimeInformation = runtimeInformation;
+ this.session = session;
+ this.mavenProjectHelper = mavenProjectHelper;
+ }
+
+ /**
+ * Sets the output directory for the attestation file.
+ *
+ * @param outputDirectory The output directory.
+ */
+ void setOutputDirectory(final File outputDirectory) {
+ this.outputDirectory = outputDirectory;
+ }
+
+ /**
+ * Returns the SCM directory.
+ *
+ * @return The SCM directory.
+ */
+ public File getScmDirectory() {
+ return scmDirectory;
+ }
+
+ /**
+ * Sets the SCM directory.
+ *
+ * @param scmDirectory The SCM directory.
+ */
+ public void setScmDirectory(File scmDirectory) {
+ this.scmDirectory = scmDirectory;
+ }
+
+ /**
+ * Sets the public SCM connection URL.
+ *
+ * @param scmConnectionUrl The SCM connection URL.
+ */
+ void setScmConnectionUrl(final String scmConnectionUrl) {
+ this.scmConnectionUrl = scmConnectionUrl;
+ }
+
+ /**
+ * Sets the Maven home directory.
+ *
+ * @param mavenHome The Maven home directory.
+ */
+ void setMavenHome(final File mavenHome) {
+ this.mavenHome = mavenHome;
+ }
+
+ @Override
+ public void execute() throws MojoFailureException, MojoExecutionException {
+ // Build definition
+ BuildDefinition buildDefinition = new BuildDefinition();
+ buildDefinition.setExternalParameters(getExternalParameters());
+ buildDefinition.setResolvedDependencies(getBuildDependencies());
+ // Builder
+ Builder builder = new Builder();
+ // RunDetails
+ RunDetails runDetails = new RunDetails();
+ runDetails.setBuilder(builder);
+ runDetails.setMetadata(getBuildMetadata());
+ // Provenance
+ Provenance provenance = new Provenance();
+ provenance.setBuildDefinition(buildDefinition);
+ provenance.setRunDetails(runDetails);
+ // Statement
+ Statement statement = new Statement();
+ statement.setSubject(getSubjects());
+ statement.setPredicate(provenance);
+
+ writeStatement(statement);
+ }
+
+ /**
+ * Serializes the attestation statement to a file and optionally attaches it to the project.
+ *
+ * @param statement The attestation statement to write.
+ * @throws MojoExecutionException If the output directory cannot be created or the file cannot be written.
+ */
+ private void writeStatement(final Statement statement) throws MojoExecutionException {
+ final Path outputPath = outputDirectory.toPath();
+ try {
+ if (!Files.exists(outputPath)) {
+ Files.createDirectories(outputPath);
+ }
+ } catch (IOException e) {
+ throw new MojoExecutionException("Could not create output directory.", e);
+ }
+ final Artifact mainArtifact = project.getArtifact();
+ final Path artifactPath = outputPath.resolve(ArtifactUtils.getFileName(mainArtifact, ATTESTATION_EXTENSION));
+ getLog().info("Writing attestation statement to: " + artifactPath);
+ try (OutputStream os = Files.newOutputStream(artifactPath)) {
+ OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValue(os, statement);
+ } catch (IOException e) {
+ throw new MojoExecutionException("Could not write attestation statement to: " + artifactPath, e);
+ }
+ if (!skipAttach) {
+ getLog().info(String.format("Attaching attestation statement as %s-%s.%s", mainArtifact.getArtifactId(), mainArtifact.getVersion(),
+ ATTESTATION_EXTENSION));
+ mavenProjectHelper.attachArtifact(project, ATTESTATION_EXTENSION, null, artifactPath.toFile());
+ }
+ }
+
+ /**
+ * 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 {
+ List subjects = new ArrayList<>();
+ subjects.add(ArtifactUtils.toResourceDescriptor(project.getArtifact()));
+ for (Artifact artifact : project.getAttachedArtifacts()) {
+ subjects.add(ArtifactUtils.toResourceDescriptor(artifact));
+ }
+ return subjects;
+ }
+
+ /**
+ * Gets map of external build parameters captured from the current JVM and Maven session.
+ *
+ * @return A map of parameter names to values.
+ */
+ private Map getExternalParameters() {
+ Map params = new HashMap<>();
+ params.put("jvm.args", ManagementFactory.getRuntimeMXBean().getInputArguments());
+ MavenExecutionRequest request = session.getRequest();
+ params.put("maven.goals", request.getGoals());
+ params.put("maven.profiles", request.getActiveProfiles());
+ params.put("maven.user.properties", request.getUserProperties());
+ params.put("maven.cmdline", getCommandLine(request));
+ Map env = new HashMap<>();
+ params.put("env", env);
+ for (Map.Entry entry : System.getenv().entrySet()) {
+ String key = entry.getKey();
+ if ("TZ".equals(key) || "LANG".equals(key) || key.startsWith("LC_")) {
+ env.put(key, entry.getValue());
+ }
+ }
+ return params;
+ }
+
+ /**
+ * Reconstructs the Maven command line string from the given execution request.
+ *
+ * @param request The Maven execution request.
+ * @return A string representation of the Maven command line.
+ */
+ private String getCommandLine(final MavenExecutionRequest request) {
+ StringBuilder sb = new StringBuilder();
+ for (String goal : request.getGoals()) {
+ sb.append(goal);
+ sb.append(" ");
+ }
+ List activeProfiles = request.getActiveProfiles();
+ if (activeProfiles != null && !activeProfiles.isEmpty()) {
+ sb.append("-P");
+ for (String profile : activeProfiles) {
+ sb.append(profile);
+ sb.append(",");
+ }
+ removeLast(sb);
+ sb.append(" ");
+ }
+ Properties userProperties = request.getUserProperties();
+ for (String propertyName : userProperties.stringPropertyNames()) {
+ sb.append("-D");
+ sb.append(propertyName);
+ sb.append("=");
+ sb.append(userProperties.get(propertyName));
+ sb.append(" ");
+ }
+ removeLast(sb);
+ return sb.toString();
+ }
+
+ /**
+ * Removes the last character from the given {@link StringBuilder} if it is non-empty.
+ *
+ * @param sb The string builder to trim.
+ */
+ private static void removeLast(final StringBuilder sb) {
+ if (sb.length() > 0) {
+ sb.setLength(sb.length() - 1);
+ }
+ }
+
+ /**
+ * Returns 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 {
+ List dependencies = new ArrayList<>();
+ try {
+ dependencies.add(BuildToolDescriptors.jvm(Paths.get(System.getProperty("java.home"))));
+ dependencies.add(BuildToolDescriptors.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath()));
+ dependencies.add(getScmDescriptor());
+ } catch (IOException e) {
+ throw new MojoExecutionException(e);
+ }
+ dependencies.addAll(getProjectDependencies());
+ return dependencies;
+ }
+
+ /**
+ * Returns 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 {
+ List dependencies = new ArrayList<>();
+ for (Artifact artifact : project.getArtifacts()) {
+ dependencies.add(ArtifactUtils.toResourceDescriptor(artifact));
+ }
+ return dependencies;
+ }
+
+ /**
+ * Returns 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 cannot be determined.
+ * @throws MojoExecutionException If the SCM revision cannot be retrieved.
+ */
+ private ResourceDescriptor getScmDescriptor() throws IOException, MojoExecutionException {
+ ResourceDescriptor scmDescriptor = new ResourceDescriptor();
+ String scmUri = GitUtils.scmToDownloadUri(scmConnectionUrl, scmDirectory.toPath());
+ scmDescriptor.setUri(scmUri);
+ // Compute the revision
+ Map digest = new HashMap<>();
+ digest.put("gitCommit", getScmRevision());
+ scmDescriptor.setDigest(digest);
+ return scmDescriptor;
+ }
+
+ /**
+ * Creates and returns an SCM repository from the configured connection URL.
+ *
+ * @return The SCM repository.
+ * @throws MojoExecutionException If the SCM repository cannot be created.
+ */
+ private ScmRepository getScmRepository() throws MojoExecutionException {
+ try {
+ return scmManager.makeScmRepository(scmConnectionUrl);
+ } catch (ScmException e) {
+ throw new MojoExecutionException("Failed to create SCM repository", e);
+ }
+ }
+
+ /**
+ * Returns the current SCM revision (commit hash) for the configured SCM directory.
+ *
+ * @return The current SCM revision string.
+ * @throws MojoExecutionException If the revision cannot be retrieved from SCM.
+ */
+ private String getScmRevision() throws MojoExecutionException {
+ ScmRepository scmRepository = getScmRepository();
+ CommandParameters commandParameters = new CommandParameters();
+ try {
+ InfoScmResult result = scmManager.getProviderByRepository(scmRepository).info(scmRepository.getProviderRepository(),
+ new ScmFileSet(scmDirectory), commandParameters);
+
+ return getScmRevision(result);
+ } catch (ScmException e) {
+ throw new MojoExecutionException("Failed to retrieve SCM revision", e);
+ }
+ }
+
+ /**
+ * Extracts the revision string from an SCM info result.
+ *
+ * @param result The SCM info result.
+ * @return The revision string.
+ * @throws MojoExecutionException If the result is unsuccessful or contains no revision.
+ */
+ private String getScmRevision(final InfoScmResult result) throws MojoExecutionException {
+ if (!result.isSuccess()) {
+ throw new MojoExecutionException("Failed to retrieve SCM revision: " + result.getProviderMessage());
+ }
+
+ if (result.getInfoItems() == null || result.getInfoItems().isEmpty()) {
+ throw new MojoExecutionException("No SCM revision information found for " + scmDirectory);
+ }
+
+ InfoItem item = result.getInfoItems().get(0);
+
+ String revision = item.getRevision();
+ if (revision == null) {
+ throw new MojoExecutionException("Empty SCM revision returned for " + scmDirectory);
+ }
+ return revision;
+ }
+
+ /**
+ * Returns build metadata derived from the current Maven session, including start and finish timestamps.
+ *
+ * @return The build metadata.
+ */
+ private BuildMetadata getBuildMetadata() {
+ OffsetDateTime startedOn = session.getStartTime().toInstant().atOffset(ZoneOffset.UTC);
+ OffsetDateTime finishedOn = OffsetDateTime.now(ZoneOffset.UTC);
+ return new BuildMetadata(session.getRequest().getBuilderId(), startedOn, finishedOn);
+ }
+}
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..843bc0e17
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java
@@ -0,0 +1,173 @@
+/*
+ * 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/builds/0.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;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the URI indicating what type of build was performed.
+ *
+ * @param buildType the build type URI
+ */
+ public void setBuildType(String buildType) {
+ this.buildType = buildType;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the inputs passed to the build.
+ *
+ * @param externalParameters the external parameters map
+ */
+ public void setExternalParameters(Map externalParameters) {
+ this.externalParameters = externalParameters;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the artifacts the build depends on.
+ *
+ * @param internalParameters the internal parameters map
+ */
+ public void setInternalParameters(Map internalParameters) {
+ this.internalParameters = internalParameters;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the materials that influenced the build.
+ *
+ * @param resolvedDependencies the list of resolved dependencies
+ */
+ public void setResolvedDependencies(List resolvedDependencies) {
+ this.resolvedDependencies = resolvedDependencies;
+ }
+
+ @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);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(buildType, externalParameters, internalParameters, resolvedDependencies);
+ }
+
+ @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..345eb91ee
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java
@@ -0,0 +1,140 @@
+/*
+ * 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 {
+
+ /** 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;
+
+ /** Timestamp when the build completed. */
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
+ @JsonProperty("finishedOn")
+ private OffsetDateTime finishedOn;
+
+ /** 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;
+ }
+
+ /**
+ * Returns the identifier for this build invocation.
+ *
+ * Useful for finding associated logs or other ad-hoc analysis. The exact meaning and format is defined by the
+ * builder and is treated as opaque and case-sensitive. The value SHOULD be globally unique.
+ *
+ * @return the invocation identifier, or {@code null} if not set
+ */
+ public String getInvocationId() {
+ return invocationId;
+ }
+
+ /**
+ * Sets the identifier for this build invocation.
+ *
+ * @param invocationId the invocation identifier
+ */
+ public void setInvocationId(String invocationId) {
+ this.invocationId = invocationId;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the timestamp of when the build started.
+ *
+ * @param startedOn the start timestamp
+ */
+ public void setStartedOn(OffsetDateTime startedOn) {
+ this.startedOn = startedOn;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the timestamp of when the build completed.
+ *
+ * @param finishedOn the completion timestamp
+ */
+ public void setFinishedOn(OffsetDateTime finishedOn) {
+ 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);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(invocationId, startedOn, finishedOn);
+ }
+
+ @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..36e0f1a89
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java
@@ -0,0 +1,125 @@
+/*
+ * 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 {
+
+ /** Identifier URI of the builder. */
+ @JsonProperty("id")
+ private String id = "https://commons.apache.org/builds/0.1.0";
+
+ /** Orchestrator dependencies that may affect provenance generation. */
+ @JsonProperty("builderDependencies")
+ private List builderDependencies = new ArrayList<>();
+
+ /** Map of build platform component names to their versions. */
+ @JsonProperty("version")
+ private Map version = new HashMap<>();
+
+ /** Creates a new Builder instance. */
+ public Builder() {
+ }
+
+ /**
+ * Returns the identifier of the builder.
+ *
+ * @return the builder identifier URI
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Sets the identifier of the builder.
+ *
+ * @param id the builder identifier URI
+ */
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the orchestrator dependencies that may affect provenance generation or security guarantees.
+ *
+ * @param builderDependencies the list of builder dependencies
+ */
+ public void setBuilderDependencies(List builderDependencies) {
+ this.builderDependencies = builderDependencies;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the map of build platform component names to their versions.
+ *
+ * @param version the version map
+ */
+ public void setVersion(Map version) {
+ this.version = version;
+ }
+
+ @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);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, builderDependencies, version);
+ }
+
+ @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/Provenance.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java
new file mode 100644
index 000000000..884246006
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java
@@ -0,0 +1,120 @@
+/*
+ * 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;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the build definition describing all inputs that produced the build output.
+ *
+ * @param buildDefinition the build definition
+ */
+ public void setBuildDefinition(BuildDefinition buildDefinition) {
+ this.buildDefinition = buildDefinition;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the details about the invocation of the build tool and the environment in which it was run.
+ *
+ * @param runDetails the run details
+ */
+ public void setRunDetails(RunDetails runDetails) {
+ 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);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(buildDefinition, runDetails);
+ }
+
+ @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..55333f220
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java
@@ -0,0 +1,227 @@
+/*
+ * 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 {
+
+ /** Human-readable name of the resource. */
+ @JsonProperty("name")
+ private String name;
+
+ /** URI identifying the resource. */
+ @JsonProperty("uri")
+ private String uri;
+
+ /** Map of digest algorithm names to hex-encoded values. */
+ @JsonProperty("digest")
+ private Map digest;
+
+ /** Raw contents of the resource, base64-encoded in JSON. */
+ @JsonProperty("content")
+ private byte[] content;
+
+ /** 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;
+
+ /** Additional key-value metadata about the resource. */
+ @JsonProperty("annotations")
+ private Map annotations;
+
+ /** 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;
+ }
+
+ /**
+ * Returns the name of the resource.
+ *
+ * @return the resource name, or {@code null} if not set
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the name of the resource.
+ *
+ * @param name the resource name
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Returns the URI identifying the resource.
+ *
+ * @return the resource URI, or {@code null} if not set
+ */
+ public String getUri() {
+ return uri;
+ }
+
+ /**
+ * Sets the URI identifying the resource.
+ *
+ * @param uri the resource URI
+ */
+ public void setUri(String uri) {
+ this.uri = uri;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the map of cryptographic digest algorithms to their hex-encoded values.
+ *
+ * @param digest the digest map
+ */
+ public void setDigest(Map digest) {
+ this.digest = digest;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the raw contents of the resource.
+ *
+ * @param content the resource content
+ */
+ public void setContent(byte[] content) {
+ this.content = content;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the download URI for the resource.
+ *
+ * @param downloadLocation the download location URI
+ */
+ public void setDownloadLocation(String downloadLocation) {
+ this.downloadLocation = downloadLocation;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the media type of the resource.
+ *
+ * @param mediaType the media type
+ */
+ public void setMediaType(String mediaType) {
+ this.mediaType = mediaType;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets additional key-value metadata about the resource.
+ *
+ * @param annotations the annotations map
+ */
+ public void setAnnotations(Map annotations) {
+ this.annotations = annotations;
+ }
+
+ @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);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uri, digest);
+ }
+
+ @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..ffb118677
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java
@@ -0,0 +1,137 @@
+/*
+ * 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;
+
+ /** Metadata about the build invocation. */
+ @JsonProperty("metadata")
+ private BuildMetadata metadata;
+
+ /** Artifacts produced as a side effect of the build. */
+ @JsonProperty("byproducts")
+ private List byproducts;
+
+ /** 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;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the builder that executed the invocation.
+ *
+ * @param builder the builder
+ */
+ public void setBuilder(Builder builder) {
+ this.builder = builder;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the metadata about the build invocation.
+ *
+ * @param metadata the build metadata
+ */
+ public void setMetadata(BuildMetadata metadata) {
+ this.metadata = metadata;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the artifacts produced as a side effect of the build that are not the primary output.
+ *
+ * @param byproducts the list of byproduct artifacts
+ */
+ public void setByproducts(List byproducts) {
+ this.byproducts = byproducts;
+ }
+
+ @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);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(builder, metadata, byproducts);
+ }
+
+ @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/Statement.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
new file mode 100644
index 000000000..88aeb8ae8
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
@@ -0,0 +1,122 @@
+/*
+ * 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. */
+ @JsonProperty("_type")
+ public static final String TYPE = "https://in-toto.io/Statement/v1";
+
+ /** Software artifacts that the attestation applies to. */
+ @JsonProperty("subject")
+ private List subject;
+
+ /** URI identifying the type of the predicate. */
+ @JsonProperty("predicateType")
+ private String predicateType;
+
+ /** The provenance predicate. */
+ @JsonProperty("predicate")
+ private Provenance predicate;
+
+ /** Creates a new Statement instance. */
+ public Statement() {
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the set of software artifacts that the attestation applies to.
+ *
+ * @param subject the list of subject artifacts
+ */
+ public void setSubject(List subject) {
+ this.subject = subject;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the provenance predicate and automatically assigns {@code predicateType} to the SLSA provenance v1 URI.
+ *
+ * @param predicate the provenance predicate
+ */
+ public void setPredicate(Provenance predicate) {
+ this.predicate = predicate;
+ this.predicateType = Provenance.PREDICATE_TYPE;
+ }
+
+ @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);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(subject, predicateType, predicate);
+ }
+
+ @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/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..6a6c3f5bf
--- /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 {
+
+ private static ContainerConfiguration setupContainerConfiguration() {
+ ClassWorld classWorld =
+ new ClassWorld("plexus.core", Thread.currentThread().getContextClassLoader());
+ return new DefaultContainerConfiguration()
+ .setClassWorld(classWorld)
+ .setClassPathScanning(PlexusConstants.SCANNING_INDEX)
+ .setAutoWiring(true)
+ .setName("maven");
+ }
+
+ public static PlexusContainer setupContainer() throws PlexusContainerException {
+ return new DefaultPlexusContainer(setupContainerConfiguration());
+ }
+
+ public static RepositorySystemSession createRepositorySystemSession(
+ PlexusContainer container, Path localRepositoryPath) throws ComponentLookupException, RepositoryException {
+ LocalRepositoryManagerFactory factory = container.lookup(LocalRepositoryManagerFactory.class, "simple");
+ DefaultRepositorySystemSession repoSession = new DefaultRepositorySystemSession();
+ 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;
+ }
+
+ 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..5f7cb6a3f
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.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.mojos;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Date;
+
+import org.apache.commons.release.plugin.internal.MojoUtils;
+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.project.MavenProject;
+import org.apache.maven.project.MavenProjectHelper;
+import org.apache.maven.rtinfo.RuntimeInformation;
+import org.apache.maven.scm.manager.ScmManager;
+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 {
+
+ @TempDir
+ private static Path localRepositoryPath;
+
+ private static PlexusContainer container;
+ private static RepositorySystemSession repoSession;
+
+ @BeforeAll
+ static void setup() throws Exception {
+ container = MojoUtils.setupContainer();
+ repoSession = MojoUtils.createRepositorySystemSession(container, localRepositoryPath);
+ }
+
+ private static MavenExecutionRequest createMavenExecutionRequest() {
+ DefaultMavenExecutionRequest request = new DefaultMavenExecutionRequest();
+ request.setStartTime(new Date());
+ return request;
+ }
+
+ @SuppressWarnings("deprecation")
+ private static MavenSession createMavenSession(MavenExecutionRequest request, MavenExecutionResult result) {
+ return new MavenSession(container, repoSession, request, result);
+ }
+
+ private static BuildAttestationMojo createBuildAttestationMojo(MavenProject project, MavenProjectHelper projectHelper) throws ComponentLookupException {
+ ScmManager scmManager = container.lookup(ScmManager.class);
+ RuntimeInformation runtimeInfo = container.lookup(RuntimeInformation.class);
+ return new BuildAttestationMojo(project, scmManager, runtimeInfo,
+ createMavenSession(createMavenExecutionRequest(), new DefaultMavenExecutionResult()), projectHelper);
+ }
+
+ private static MavenProject createMavenProject(MavenProjectHelper projectHelper, MavenRepositorySystem repoSystem) throws ComponentLookupException {
+ MavenProject project = new MavenProject(new Model());
+ Artifact artifact = repoSystem.createArtifact("groupId", "artifactId", "1.2.3", null, "jar");
+ project.setArtifact(artifact);
+ project.setGroupId("groupId");
+ project.setArtifactId("artifactId");
+ project.setVersion("1.2.3");
+ // Attach a couple of artifacts
+ projectHelper.attachArtifact(project, "pom", null, new File("src/test/resources/artifacts/artifact-pom.txt"));
+ artifact.setFile(new File("src/test/resources/artifacts/artifact-jar.txt"));
+ return project;
+ }
+
+ @Test
+ void attestationTest() throws Exception {
+ MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class);
+ MavenRepositorySystem repoSystem = container.lookup(MavenRepositorySystem.class);
+ MavenProject project = createMavenProject(projectHelper, repoSystem);
+
+ BuildAttestationMojo mojo = createBuildAttestationMojo(project, projectHelper);
+ mojo.setOutputDirectory(new File("target/attestations"));
+ mojo.setScmDirectory(new File("."));
+ mojo.setScmConnectionUrl("scm:git:https://github.com/apache/commons-lang.git");
+ mojo.setMavenHome(new File(System.getProperty("maven.home", ".")));
+ mojo.execute();
+
+ Artifact attestation = project.getAttachedArtifacts().stream()
+ .filter(a -> "intoto.json".equals(a.getType()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No intoto.json artifact attached to project"));
+ String json = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8);
+
+ String resolvedDeps = "predicate.buildDefinition.resolvedDependencies";
+ String javaVersion = System.getProperty("java.version");
+
+ assertThatJson(json)
+ .node(resolvedDeps).isArray()
+ .anySatisfy(dep -> {
+ assertThatJson(dep).node("name").isEqualTo("JDK");
+ assertThatJson(dep).node("annotations.version").isEqualTo(javaVersion);
+ });
+
+ assertThatJson(json)
+ .node(resolvedDeps).isArray()
+ .anySatisfy(dep -> assertThatJson(dep).node("name").isEqualTo("Maven"));
+
+ assertThatJson(json)
+ .node(resolvedDeps).isArray()
+ .anySatisfy(dep -> assertThatJson(dep).node("uri").isString().startsWith("git+https://github.com/apache/commons-lang.git"));
+ }
+}
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
From 54e34b72f3cde12acadccbb94ac7973dfe65a79e Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Mon, 30 Mar 2026 20:35:09 +0200
Subject: [PATCH 02/14] Add documentation of `buildType`
---
.../plugin/internal/BuildToolDescriptors.java | 24 +++-
.../plugin/mojos/BuildAttestationMojo.java | 3 +-
src/site/markdown/slsa/v0.1.0.md | 131 ++++++++++++++++++
3 files changed, 151 insertions(+), 7 deletions(-)
create mode 100644 src/site/markdown/slsa/v0.1.0.md
diff --git a/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java b/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java
index 15be8d73e..5dac9a192 100644
--- a/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java
+++ b/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java
@@ -48,8 +48,15 @@ public static ResourceDescriptor jvm(Path javaHome) throws IOException {
Map digest = new HashMap<>();
digest.put("gitTree", GitUtils.gitTree(javaHome));
descriptor.setDigest(digest);
- String[] propertyNames = {"java.version", "java.vendor", "java.vendor.version", "java.vm.name", "java.vm.version", "java.vm.vendor",
- "java.runtime.name", "java.runtime.version", "java.specification.version"};
+ String[] propertyNames = {
+ "java.version", "java.version.date",
+ "java.vendor", "java.vendor.url", "java.vendor.version",
+ "java.home",
+ "java.vm.specification.version", "java.vm.specification.vendor", "java.vm.specification.name",
+ "java.vm.version", "java.vm.vendor", "java.vm.name",
+ "java.specification.version", "java.specification.maintenance.version",
+ "java.specification.vendor", "java.specification.name",
+ };
Map annotations = new HashMap<>();
for (String prop : propertyNames) {
annotations.put(prop.substring("java.".length()), System.getProperty(prop));
@@ -61,12 +68,17 @@ public static ResourceDescriptor jvm(Path javaHome) throws IOException {
/**
* Creates a {@link ResourceDescriptor} for the Maven installation used during the build.
*
- * @param version Maven version string
- * @param mavenHome path to the Maven home directory
+ * {@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 see that 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(String version, Path mavenHome) throws IOException {
+ public static ResourceDescriptor maven(String version, Path mavenHome, ClassLoader coreClassLoader) throws IOException {
ResourceDescriptor descriptor = new ResourceDescriptor();
descriptor.setName("Maven");
descriptor.setUri("pkg:maven/org.apache.maven/apache-maven@" + version);
@@ -74,7 +86,7 @@ public static ResourceDescriptor maven(String version, Path mavenHome) throws IO
digest.put("gitTree", GitUtils.gitTree(mavenHome));
descriptor.setDigest(digest);
Properties buildProps = new Properties();
- try (InputStream in = BuildToolDescriptors.class.getResourceAsStream("/org/apache/maven/messages/build.properties")) {
+ try (InputStream in = coreClassLoader.getResourceAsStream("org/apache/maven/messages/build.properties")) {
if (in != null) {
buildProps.load(in);
}
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
index 0260e22e1..6841193db 100644
--- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -342,7 +342,8 @@ private List getBuildDependencies() throws MojoExecutionExce
List dependencies = new ArrayList<>();
try {
dependencies.add(BuildToolDescriptors.jvm(Paths.get(System.getProperty("java.home"))));
- dependencies.add(BuildToolDescriptors.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath()));
+ dependencies.add(BuildToolDescriptors.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath(),
+ runtimeInformation.getClass().getClassLoader()));
dependencies.add(getScmDescriptor());
} catch (IOException e) {
throw new MojoExecutionException(e);
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..b9a569ef2
--- /dev/null
+++ b/src/site/markdown/slsa/v0.1.0.md
@@ -0,0 +1,131 @@
+
+
+# Build Type: Apache Commons Maven Release
+
+```jsonc
+"buildType": "https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0"
+```
+
+This is a [SLSA Build Provenance](https://slsa.dev/spec/v1.2/build-provenance) build type
+that describes releases produced by Apache Commons PMC release managers running Maven on their own equipment.
+
+## 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 (e.g. `["deploy"]`). |
+| `maven.profiles` | string[] | The list of active profiles passed via `-P` (e.g. `["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 |
+|-------------------------------------|------------------------------------------|--------------------------------------------------------------------------|
+| `version` | `java.version` | Java Runtime Environment version. |
+| `version.date` | `java.version.date` | Java Runtime Environment version date, in ISO-8601 YYYY-MM-DD format. |
+| `vendor` | `java.vendor` | Java Runtime Environment vendor. |
+| `vendor.url` | `java.vendor.url` | Java vendor URL. |
+| `vendor.version` | `java.vendor.version` | Java vendor version _(optional)_. |
+| `home` | `java.home` | Java installation directory. |
+| `vm.specification.version` | `java.vm.specification.version` | Java Virtual Machine specification version. |
+| `vm.specification.vendor` | `java.vm.specification.vendor` | Java Virtual Machine specification vendor. |
+| `vm.specification.name` | `java.vm.specification.name` | Java Virtual Machine specification name. |
+| `vm.version` | `java.vm.version` | Java Virtual Machine implementation version. |
+| `vm.vendor` | `java.vm.vendor` | Java Virtual Machine implementation vendor. |
+| `vm.name` | `java.vm.name` | Java Virtual Machine implementation name. |
+| `specification.version` | `java.specification.version` | Java Runtime Environment specification version. |
+| `specification.maintenance.version` | `java.specification.maintenance.version` | Java Runtime Environment specification maintenance version _(optional)_. |
+| `specification.vendor` | `java.specification.vendor` | Java Runtime Environment specification vendor. |
+| `specification.name` | `java.specification.name` | Java Runtime Environment specification name. |
+
+#### 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 Mavendistribution. |
+| `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, e.g. `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 always `https://commons.apache.org/builds/0.1.0`.
+It represents the commons-release-plugin acting as the build platform.
+
+## Subjects
+
+The attestation covers all artifacts attached to the Maven project at the time the `verify` phase runs:
+the primary artifact (e.g. the JAR) and any attached artifacts (e.g. sources JAR, javadoc JAR, POM).
+
+| Field | Value |
+|-----------------|------------------------------------------|
+| `name` | Artifact filename. |
+| `uri` | Package URL. |
+| `digest.sha256` | SHA-256 hex digest of the artifact file. |
+
+## Version history
+
+### v0.1.0
+
+Initial version.
\ No newline at end of file
From f005ea1de32e9142adebff215490b87f601d655c Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Thu, 2 Apr 2026 15:07:49 +0200
Subject: [PATCH 03/14] Temporarily change version to publish snapshot
---
pom.xml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/pom.xml b/pom.xml
index e0c6767e7..d07c6218a 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.
From 5167d7397f88b5755a6c2d071ee2439456f408bb Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Fri, 3 Apr 2026 19:10:18 +0200
Subject: [PATCH 04/14] fix: output attestation in JSON Line format
---
.../commons/release/plugin/mojos/BuildAttestationMojo.java | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
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
index 6841193db..030f6587d 100644
--- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -73,7 +73,7 @@
public class BuildAttestationMojo extends AbstractMojo {
/** The file extension for in-toto attestation files. */
- private static final String ATTESTATION_EXTENSION = "intoto.json";
+ private static final String ATTESTATION_EXTENSION = "intoto.jsonl";
/** Shared Jackson object mapper for serializing attestation statements. */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@@ -237,7 +237,8 @@ private void writeStatement(final Statement statement) throws MojoExecutionExcep
final Path artifactPath = outputPath.resolve(ArtifactUtils.getFileName(mainArtifact, ATTESTATION_EXTENSION));
getLog().info("Writing attestation statement to: " + artifactPath);
try (OutputStream os = Files.newOutputStream(artifactPath)) {
- OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValue(os, statement);
+ OBJECT_MAPPER.writeValue(os, statement);
+ os.write('\n');
} catch (IOException e) {
throw new MojoExecutionException("Could not write attestation statement to: " + artifactPath, e);
}
From 1ace65c7f76b447bd63f9c39d3244d86c940de82 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Fri, 3 Apr 2026 19:21:22 +0200
Subject: [PATCH 05/14] fix: disable Jackson auto-close option
---
.../commons/release/plugin/mojos/BuildAttestationMojo.java | 2 ++
.../release/plugin/mojos/BuildAttestationMojoTest.java | 4 ++--
2 files changed, 4 insertions(+), 2 deletions(-)
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
index 030f6587d..254779981 100644
--- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -33,6 +33,7 @@
import javax.inject.Inject;
+import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.apache.commons.release.plugin.internal.ArtifactUtils;
@@ -81,6 +82,7 @@ public class BuildAttestationMojo extends AbstractMojo {
static {
OBJECT_MAPPER.findAndRegisterModules();
OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ OBJECT_MAPPER.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
}
/** The SCM connection URL for the current project. */
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
index 5f7cb6a3f..dae20c43c 100644
--- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
+++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
@@ -103,9 +103,9 @@ void attestationTest() throws Exception {
mojo.execute();
Artifact attestation = project.getAttachedArtifacts().stream()
- .filter(a -> "intoto.json".equals(a.getType()))
+ .filter(a -> "intoto.jsonl".equals(a.getType()))
.findFirst()
- .orElseThrow(() -> new AssertionError("No intoto.json artifact attached to project"));
+ .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project"));
String json = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8);
String resolvedDeps = "predicate.buildDefinition.resolvedDependencies";
From 02ad5af3c39fa77be621f52275462a0f2936a080 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Sun, 12 Apr 2026 21:07:53 +0200
Subject: [PATCH 06/14] fix: adapt to Commons Code API change
---
.../org/apache/commons/release/plugin/internal/GitUtils.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
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
index 246027c49..c4d4aecec 100644
--- a/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java
+++ b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java
@@ -25,6 +25,7 @@
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.
@@ -46,7 +47,7 @@ public static String gitTree(Path path) throws IOException {
throw new IOException("Path is not a directory: " + path);
}
MessageDigest digest = DigestUtils.getSha1Digest();
- return Hex.encodeHexString(DigestUtils.gitTree(digest, path));
+ return Hex.encodeHexString(GitIdentifiers.treeId(digest, path));
}
/**
From 16f776f54baa725884d8af5b338dc42d5569bf39 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Wed, 15 Apr 2026 14:18:59 +0200
Subject: [PATCH 07/14] feat: add support for DSSE signing
Artifacts are signed using the Maven GPG Plugin and the results are wrapped in the DSSE envelope.
---
pom.xml | 5 +
.../release/plugin/internal/DsseUtils.java | 190 ++++++++++++++++++
.../plugin/mojos/BuildAttestationMojo.java | 164 +++++++++++++--
.../plugin/slsa/v1_2/DsseEnvelope.java | 133 ++++++++++++
.../release/plugin/slsa/v1_2/Signature.java | 108 ++++++++++
5 files changed, 579 insertions(+), 21 deletions(-)
create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java
create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java
diff --git a/pom.xml b/pom.xml
index d07c6218a..26a71467f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -199,6 +199,11 @@
commons-compress
1.28.0
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 3.2.8
+
com.fasterxml.jackson.core
jackson-databind
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..e4e362f01
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
@@ -0,0 +1,190 @@
+/*
+ * 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 com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.release.plugin.slsa.v1_2.DsseEnvelope;
+import org.apache.commons.release.plugin.slsa.v1_2.Statement;
+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 {
+
+ /**
+ * Not instantiable.
+ */
+ private 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 #signPaeFile(AbstractGpgSigner, Path)}.
+ *
+ * @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;
+ }
+
+ /**
+ * Serializes {@code statement} to JSON, computes the DSSE Pre-Authentication Encoding (PAE), and writes
+ * the result to {@code buildDirectory/statement.pae}.
+ *
+ * The PAE format is:
+ * {@code "DSSEv1" SP LEN(payloadType) SP payloadType SP LEN(payload) SP payload},
+ * where {@code LEN} is the ASCII decimal byte-length of the operand.
+ *
+ * @param statement the attestation statement to encode
+ * @param objectMapper the Jackson mapper used to serialize {@code statement}
+ * @param buildDirectory directory in which the PAE file is created
+ * @return path to the written PAE file
+ * @throws MojoExecutionException if serialization or I/O fails
+ */
+ public static Path writePaeFile(final Statement statement, final ObjectMapper objectMapper, final Path buildDirectory) throws MojoExecutionException {
+ try {
+ return writePaeFile(objectMapper.writeValueAsBytes(statement), buildDirectory);
+ } catch (final JsonProcessingException e) {
+ throw new MojoExecutionException("Failed to serialize attestation statement", e);
+ }
+ }
+
+ /**
+ * Computes the DSSE Pre-Authentication Encoding (PAE) for {@code statementBytes} and writes it to
+ * {@code buildDirectory/statement.pae}.
+ *
+ * Use this overload when the statement has already been serialized to bytes, so the same byte array
+ * can be reused as the {@link DsseEnvelope#setPayload(byte[]) envelope payload} without a second
+ * serialization pass.
+ *
+ * @param statementBytes the already-serialized JSON statement bytes to encode
+ * @param buildDirectory directory in which the PAE file is created
+ * @return path to the written PAE file
+ * @throws MojoExecutionException if I/O fails
+ */
+ public static Path writePaeFile(final byte[] statementBytes, final Path buildDirectory) throws MojoExecutionException {
+ try {
+ final byte[] payloadTypeBytes = DsseEnvelope.PAYLOAD_TYPE.getBytes(StandardCharsets.UTF_8);
+
+ final ByteArrayOutputStream pae = new ByteArrayOutputStream();
+ pae.write(("DSSEv1 " + payloadTypeBytes.length + " ").getBytes(StandardCharsets.UTF_8));
+ pae.write(payloadTypeBytes);
+ pae.write((" " + statementBytes.length + " ").getBytes(StandardCharsets.UTF_8));
+ pae.write(statementBytes);
+
+ final Path paeFile = buildDirectory.resolve("statement.pae");
+ Files.write(paeFile, pae.toByteArray());
+ return paeFile;
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Failed to write PAE file", e);
+ }
+ }
+
+ /**
+ * Signs {@code paeFile} using {@link AbstractGpgSigner#generateSignatureForArtifact(File)},
+ * then decodes the resulting ASCII-armored {@code .asc} file with BouncyCastle and returns the raw
+ * binary PGP signature bytes.
+ *
+ * The signer must already have {@link AbstractGpgSigner#prepare()} called before this method is
+ * invoked. The {@code .asc} file produced by the signer is not deleted; callers may remove it once
+ * the raw bytes have been consumed.
+ *
+ * @param signer the configured, prepared signer
+ * @param paeFile path to the PAE-encoded file to sign
+ * @return raw binary PGP signature bytes (suitable for storing in {@link org.apache.commons.release.plugin.slsa.v1_2.Signature#setSig})
+ * @throws MojoExecutionException if signing or signature decoding fails
+ */
+ public static byte[] signPaeFile(final AbstractGpgSigner signer, final Path paeFile) throws MojoExecutionException {
+ final File signatureFile = signer.generateSignatureForArtifact(paeFile.toFile());
+ try (InputStream in = Files.newInputStream(signatureFile.toPath()); ArmoredInputStream armoredIn = new ArmoredInputStream(in)) {
+ return IOUtils.toByteArray(armoredIn);
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Failed to read signature file: " + signatureFile, e);
+ }
+ }
+
+ /**
+ * Extracts the key identifier from a binary OpenPGP Signature Packet.
+ *
+ * Inspects the hashed subpackets for an {@code IssuerFingerprint} subpacket (type 33),
+ * which carries the full public-key fingerprint and is present in all signatures produced by
+ * GPG 2.1+. Falls back to the 8-byte {@code IssuerKeyID} from the unhashed subpackets
+ * when no fingerprint subpacket is found.
+ *
+ * @param sigBytes raw binary OpenPGP Signature Packet bytes, as returned by
+ * {@link #signPaeFile(AbstractGpgSigner, Path)}
+ * @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()).toUpperCase(Locale.ROOT);
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Failed to extract key ID from signature", e);
+ }
+ }
+}
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
index 254779981..7bcabda8d 100644
--- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -26,6 +26,7 @@
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -34,17 +35,21 @@
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.BuildToolDescriptors;
+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.MavenExecutionRequest;
@@ -56,6 +61,7 @@
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;
@@ -73,10 +79,14 @@
@Mojo(name = "build-attestation", defaultPhase = LifecyclePhase.VERIFY, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
public class BuildAttestationMojo extends AbstractMojo {
- /** The file extension for in-toto attestation files. */
+ /**
+ * The file extension for in-toto attestation files.
+ */
private static final String ATTESTATION_EXTENSION = "intoto.jsonl";
- /** Shared Jackson object mapper for serializing attestation statements. */
+ /**
+ * Shared Jackson object mapper for serializing attestation statements.
+ */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static {
@@ -85,11 +95,15 @@ public class BuildAttestationMojo extends AbstractMojo {
OBJECT_MAPPER.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
}
- /** The SCM connection URL for the current project. */
+ /**
+ * The SCM connection URL for the current project.
+ */
@Parameter(defaultValue = "${project.scm.connection}", readonly = true)
private String scmConnectionUrl;
- /** The Maven home directory. */
+ /**
+ * The Maven home directory.
+ */
@Parameter(defaultValue = "${maven.home}", readonly = true)
private File mavenHome;
@@ -99,14 +113,62 @@ public class BuildAttestationMojo extends AbstractMojo {
@Parameter(property = "commons.release.scmDirectory", defaultValue = "${basedir}")
private File scmDirectory;
- /** The output directory for the attestation file. */
+ /**
+ * The output directory for the attestation file.
+ */
@Parameter(property = "commons.release.outputDirectory", defaultValue = "${project.build.directory}")
private File outputDirectory;
- /** Whether to skip attaching the attestation artifact to the project. */
+ /**
+ * Whether to skip attaching the attestation artifact to the project.
+ */
@Parameter(property = "commons.release.skipAttach")
private boolean skipAttach;
+ /**
+ * Whether to sign the attestation envelope with GPG.
+ */
+ @Parameter(property = "commons.release.signAttestation", defaultValue = "true")
+ private boolean signAttestation;
+
+ /**
+ * Path to the GPG executable; if not set, {@code gpg} is resolved from {@code PATH}.
+ */
+ @Parameter(property = "gpg.executable")
+ private String executable;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
/**
* The current Maven project.
*/
@@ -135,10 +197,10 @@ public class BuildAttestationMojo extends AbstractMojo {
/**
* Creates a new instance with the given dependencies.
*
- * @param project A Maven project.
- * @param scmManager A SCM manager.
+ * @param project A Maven project.
+ * @param scmManager A SCM manager.
* @param runtimeInformation Maven runtime information.
- * @param session A Maven session.
+ * @param session A Maven session.
* @param mavenProjectHelper A helper to attach artifacts to the project.
*/
@Inject
@@ -217,16 +279,22 @@ public void execute() throws MojoFailureException, MojoExecutionException {
statement.setSubject(getSubjects());
statement.setPredicate(provenance);
- writeStatement(statement);
+ 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);
+ }
}
/**
- * Serializes the attestation statement to a file and optionally attaches it to the project.
+ * Creates the output directory if it does not already exist and returns its path.
*
- * @param statement The attestation statement to write.
- * @throws MojoExecutionException If the output directory cannot be created or the file cannot be written.
+ * @return the output directory path
+ * @throws MojoExecutionException if the directory cannot be created
*/
- private void writeStatement(final Statement statement) throws MojoExecutionException {
+ private Path ensureOutputDirectory() throws MojoExecutionException {
final Path outputPath = outputDirectory.toPath();
try {
if (!Files.exists(outputPath)) {
@@ -235,18 +303,72 @@ private void writeStatement(final Statement statement) throws MojoExecutionExcep
} catch (IOException e) {
throw new MojoExecutionException("Could not create output directory.", e);
}
- final Artifact mainArtifact = project.getArtifact();
- final Path artifactPath = outputPath.resolve(ArtifactUtils.getFileName(mainArtifact, ATTESTATION_EXTENSION));
+ return outputPath;
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Signs the attestation statement with GPG, wraps it in a DSSE envelope, 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 (JsonProcessingException e) {
+ throw new MojoExecutionException("Failed to serialize attestation statement", e);
+ }
+ final AbstractGpgSigner signer = DsseUtils.createGpgSigner(executable, defaultKeyring, lockMode, keyname, useAgent, getLog());
+ final Path paeFile = DsseUtils.writePaeFile(statementBytes, outputPath);
+ final byte[] sigBytes = DsseUtils.signPaeFile(signer, paeFile);
+
+ final Signature sig = new Signature();
+ sig.setKeyid(DsseUtils.getKeyId(sigBytes));
+ sig.setSig(sigBytes);
+
+ final DsseEnvelope envelope = new DsseEnvelope();
+ envelope.setPayload(statementBytes);
+ envelope.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, statement);
+ OBJECT_MAPPER.writeValue(os, value);
os.write('\n');
} catch (IOException e) {
- throw new MojoExecutionException("Could not write attestation statement to: " + artifactPath, e);
+ throw new MojoExecutionException("Could not write attestation to: " + artifactPath, e);
}
if (!skipAttach) {
- getLog().info(String.format("Attaching attestation statement as %s-%s.%s", mainArtifact.getArtifactId(), mainArtifact.getVersion(),
- ATTESTATION_EXTENSION));
+ 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());
}
}
@@ -373,7 +495,7 @@ private List getProjectDependencies() throws MojoExecutionEx
* Returns 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 cannot be determined.
+ * @throws IOException If the current branch cannot be determined.
* @throws MojoExecutionException If the SCM revision cannot be retrieved.
*/
private ResourceDescriptor getScmDescriptor() throws IOException, MojoExecutionException {
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..604286a28
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java
@@ -0,0 +1,133 @@
+/*
+ * 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.
+ *
+ * The {@code payload} field holds the serialized {@link Statement} bytes; Jackson serializes them as Base64. The
+ * {@code payloadType} identifies the content type of the payload. The {@code signatures} list contains one or more
+ * cryptographic signatures over the PAE-encoded payload.
+ *
+ * All three fields are REQUIRED and MUST be set, even if empty.
+ *
+ * @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";
+
+ /** Content type identifying the format of {@link #payload}. */
+ @JsonProperty("payloadType")
+ private String payloadType = PAYLOAD_TYPE;
+
+ /** Serialized statement bytes, Base64-encoded in JSON. */
+ @JsonProperty("payload")
+ private byte[] payload;
+
+ /** 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() {
+ }
+
+ /**
+ * Returns the payload type URI.
+ *
+ * @return the payload type, never {@code null} in a valid envelope
+ */
+ public String getPayloadType() {
+ return payloadType;
+ }
+
+ /**
+ * Sets the payload type URI.
+ *
+ * @param payloadType the payload type URI
+ */
+ public void setPayloadType(String payloadType) {
+ this.payloadType = payloadType;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ /**
+ * Sets the serialized payload bytes.
+ *
+ * @param payload the payload bytes
+ */
+ public void setPayload(byte[] payload) {
+ this.payload = payload;
+ }
+
+ /**
+ * Returns the list of signatures over the PAE-encoded payload.
+ *
+ * @return the signatures, or {@code null} if not set
+ */
+ public List getSignatures() {
+ return signatures;
+ }
+
+ /**
+ * Sets the list of signatures over the PAE-encoded payload.
+ *
+ * @param signatures the signatures
+ */
+ public void setSignatures(List signatures) {
+ this.signatures = signatures;
+ }
+
+ @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);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(payloadType, Arrays.hashCode(payload), signatures);
+ }
+
+ @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/Signature.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java
new file mode 100644
index 000000000..1a3d381bf
--- /dev/null
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java
@@ -0,0 +1,108 @@
+/*
+ * 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.
+ *
+ * The {@code sig} field holds the raw signature bytes; Jackson serializes them as Base64. The optional
+ * {@code keyid} field identifies which key produced the signature.
+ *
+ * @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 over the PAE-encoded payload, Base64-encoded in JSON. */
+ @JsonProperty("sig")
+ private byte[] sig;
+
+ /** Creates a new Signature instance. */
+ public Signature() {
+ }
+
+ /**
+ * Returns the key identifier hint, or {@code null} if not set.
+ *
+ * @return the key identifier, or {@code null}
+ */
+ public String getKeyid() {
+ return keyid;
+ }
+
+ /**
+ * Sets the key identifier hint.
+ *
+ * @param keyid the key identifier, or {@code null} to leave unset
+ */
+ public void setKeyid(String keyid) {
+ this.keyid = keyid;
+ }
+
+ /**
+ * Returns the raw signature bytes.
+ *
+ * When serialized to JSON the bytes are Base64-encoded.
+ *
+ * @return the signature bytes, or {@code null} if not set
+ */
+ public byte[] getSig() {
+ return sig;
+ }
+
+ /**
+ * Sets the raw signature bytes.
+ *
+ * @param sig the signature bytes
+ */
+ public void setSig(byte[] sig) {
+ this.sig = sig;
+ }
+
+ @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);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(keyid, Arrays.hashCode(sig));
+ }
+
+ @Override
+ public String toString() {
+ return "Signature{keyid='" + keyid + "', sig=<" + (sig != null ? sig.length : 0) + " bytes>}";
+ }
+}
From c1644fa32581b429d364ebe36eba7e93c25122f7 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Thu, 16 Apr 2026 18:58:23 +0200
Subject: [PATCH 08/14] fix: Javadoc of the model
---
.../commons/release/plugin/internal/DsseUtils.java | 6 ++----
.../release/plugin/slsa/v1_2/BuildDefinition.java | 8 ++++----
.../release/plugin/slsa/v1_2/BuildMetadata.java | 9 +++------
.../commons/release/plugin/slsa/v1_2/Builder.java | 6 +++---
.../release/plugin/slsa/v1_2/DsseEnvelope.java | 12 +++---------
.../release/plugin/slsa/v1_2/Provenance.java | 4 ++--
.../plugin/slsa/v1_2/ResourceDescriptor.java | 14 +++++++-------
.../release/plugin/slsa/v1_2/RunDetails.java | 6 +++---
.../release/plugin/slsa/v1_2/Signature.java | 11 +++--------
.../release/plugin/slsa/v1_2/Statement.java | 9 ++++-----
10 files changed, 34 insertions(+), 51 deletions(-)
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
index e4e362f01..99283d80d 100644
--- a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
+++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
@@ -57,13 +57,11 @@ private 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 #signPaeFile(AbstractGpgSigner, Path)}.
+ * The returned signer has {@link AbstractGpgSigner#prepare()} already called and is ready for use with {@link #signPaeFile(AbstractGpgSigner, Path)}.
*
* @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 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
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
index 843bc0e17..661d0ccd9 100644
--- 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
@@ -65,7 +65,7 @@ public BuildDefinition(String buildType, Map externalParameters)
}
/**
- * Returns the URI indicating what type of build was performed.
+ * Gets the URI indicating what type of build was performed.
*
* Determines the meaning of {@code externalParameters} and {@code internalParameters}.
*
@@ -85,7 +85,7 @@ public void setBuildType(String buildType) {
}
/**
- * Returns the inputs passed to the build, such as command-line arguments or environment variables.
+ * 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
*/
@@ -103,7 +103,7 @@ public void setExternalParameters(Map externalParameters) {
}
/**
- * Returns the artifacts the build depends on, such as sources, dependencies, build tools, and base images,
+ * 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
@@ -122,7 +122,7 @@ public void setInternalParameters(Map internalParameters) {
}
/**
- * Returns the materials that influenced the build.
+ * Gets the materials that influenced the build.
*
* Considered incomplete unless resolved materials are present.
*
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java
index 345eb91ee..35d04e412 100644
--- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java
@@ -63,10 +63,7 @@ public BuildMetadata(String invocationId, OffsetDateTime startedOn, OffsetDateTi
}
/**
- * Returns the identifier for this build invocation.
- *
- * Useful for finding associated logs or other ad-hoc analysis. The exact meaning and format is defined by the
- * builder and is treated as opaque and case-sensitive. The value SHOULD be globally unique.
+ * Gets the identifier for this build invocation.
*
* @return the invocation identifier, or {@code null} if not set
*/
@@ -84,7 +81,7 @@ public void setInvocationId(String invocationId) {
}
/**
- * Returns the timestamp of when the build started, serialized as RFC 3339 in UTC ({@code "Z"} suffix).
+ * 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
*/
@@ -102,7 +99,7 @@ public void setStartedOn(OffsetDateTime startedOn) {
}
/**
- * Returns the timestamp of when the build completed, serialized as RFC 3339 in UTC ({@code "Z"} suffix).
+ * 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
*/
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
index 36e0f1a89..635e75cfb 100644
--- 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
@@ -48,7 +48,7 @@ public Builder() {
}
/**
- * Returns the identifier of the builder.
+ * Gets the identifier of the builder.
*
* @return the builder identifier URI
*/
@@ -66,7 +66,7 @@ public void setId(String id) {
}
/**
- * Returns orchestrator dependencies that do not run within the build workload and do not affect the build output,
+ * 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
@@ -85,7 +85,7 @@ public void setBuilderDependencies(List builderDependencies)
}
/**
- * Returns a map of build platform component names to their versions.
+ * Gets a map of build platform component names to their versions.
*
* @return the version map, or {@code null} if not set
*/
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
index 604286a28..fdb2353f3 100644
--- 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
@@ -25,12 +25,6 @@
/**
* DSSE (Dead Simple Signing Envelope) that wraps a signed in-toto statement payload.
*
- * The {@code payload} field holds the serialized {@link Statement} bytes; Jackson serializes them as Base64. The
- * {@code payloadType} identifies the content type of the payload. The {@code signatures} list contains one or more
- * cryptographic signatures over the PAE-encoded payload.
- *
- * All three fields are REQUIRED and MUST be set, even if empty.
- *
* @see DSSE Envelope specification
*/
public class DsseEnvelope {
@@ -55,7 +49,7 @@ public DsseEnvelope() {
}
/**
- * Returns the payload type URI.
+ * Gets the payload type URI.
*
* @return the payload type, never {@code null} in a valid envelope
*/
@@ -73,7 +67,7 @@ public void setPayloadType(String payloadType) {
}
/**
- * Returns the serialized payload bytes.
+ * Gets the serialized payload bytes.
*
* When serialized to JSON the bytes are Base64-encoded.
*
@@ -93,7 +87,7 @@ public void setPayload(byte[] payload) {
}
/**
- * Returns the list of signatures over the PAE-encoded payload.
+ * Gets the list of signatures over the PAE-encoded payload.
*
* @return the signatures, or {@code null} if not set
*/
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
index 884246006..c9dfd2e28 100644
--- 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
@@ -59,7 +59,7 @@ public Provenance(BuildDefinition buildDefinition, RunDetails runDetails) {
}
/**
- * Returns the build definition describing all inputs that produced the build output.
+ * Gets the build definition describing all inputs that produced the build output.
*
* Includes source code, dependencies, build tools, base images, and other materials.
*
@@ -79,7 +79,7 @@ public void setBuildDefinition(BuildDefinition buildDefinition) {
}
/**
- * Returns the details about the invocation of the build tool and the environment in which it was run.
+ * 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
*/
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
index 55333f220..2ce42ce25 100644
--- 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
@@ -76,7 +76,7 @@ public ResourceDescriptor(String uri, Map digest) {
}
/**
- * Returns the name of the resource.
+ * Gets the name of the resource.
*
* @return the resource name, or {@code null} if not set
*/
@@ -94,7 +94,7 @@ public void setName(String name) {
}
/**
- * Returns the URI identifying the resource.
+ * Gets the URI identifying the resource.
*
* @return the resource URI, or {@code null} if not set
*/
@@ -112,7 +112,7 @@ public void setUri(String uri) {
}
/**
- * Returns the map of cryptographic digest algorithms to their corresponding hex-encoded values for this resource.
+ * Gets the map of cryptographic digest algorithms to their corresponding hex-encoded values for this resource.
*
* Common keys include {@code "sha256"} and {@code "sha512"}.
*
@@ -132,7 +132,7 @@ public void setDigest(Map digest) {
}
/**
- * Returns the raw contents of the resource, base64-encoded when serialized to JSON.
+ * Gets the raw contents of the resource, base64-encoded when serialized to JSON.
*
* @return the resource content, or {@code null} if not set
*/
@@ -150,7 +150,7 @@ public void setContent(byte[] content) {
}
/**
- * Returns the download URI for the resource, if different from {@link #getUri()}.
+ * Gets the download URI for the resource, if different from {@link #getUri()}.
*
* @return the download location URI, or {@code null} if not set
*/
@@ -168,7 +168,7 @@ public void setDownloadLocation(String downloadLocation) {
}
/**
- * Returns the media type of the resource (e.g., {@code "application/octet-stream"}).
+ * Gets the media type of the resource (e.g., {@code "application/octet-stream"}).
*
* @return the media type, or {@code null} if not set
*/
@@ -186,7 +186,7 @@ public void setMediaType(String mediaType) {
}
/**
- * Returns additional key-value metadata about the resource, such as filename, size, or builder-specific attributes.
+ * 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
*/
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
index ffb118677..7b20b5a1d 100644
--- 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
@@ -58,7 +58,7 @@ public RunDetails(Builder builder, BuildMetadata metadata) {
}
/**
- * Returns the builder that executed the invocation.
+ * Gets the builder that executed the invocation.
*
* Trusted to have correctly performed the operation and populated this provenance.
*
@@ -78,7 +78,7 @@ public void setBuilder(Builder builder) {
}
/**
- * Returns the metadata about the build invocation, including its identifier and timing.
+ * Gets the metadata about the build invocation, including its identifier and timing.
*
* @return the build metadata, or {@code null} if not set
*/
@@ -96,7 +96,7 @@ public void setMetadata(BuildMetadata metadata) {
}
/**
- * Returns artifacts produced as a side effect of the build that are not the primary output.
+ * 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
*/
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
index 1a3d381bf..c2caf8000 100644
--- 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
@@ -25,9 +25,6 @@
/**
* A single cryptographic signature within a DSSE envelope.
*
- * The {@code sig} field holds the raw signature bytes; Jackson serializes them as Base64. The optional
- * {@code keyid} field identifies which key produced the signature.
- *
* @see DSSE Envelope specification
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
@@ -41,7 +38,7 @@ public class Signature {
@JsonProperty("keyid")
private String keyid;
- /** Raw signature bytes over the PAE-encoded payload, Base64-encoded in JSON. */
+ /** Raw signature bytes of the PAE-encoded payload. */
@JsonProperty("sig")
private byte[] sig;
@@ -50,7 +47,7 @@ public Signature() {
}
/**
- * Returns the key identifier hint, or {@code null} if not set.
+ * Gets the key identifier hint, or {@code null} if not set.
*
* @return the key identifier, or {@code null}
*/
@@ -68,9 +65,7 @@ public void setKeyid(String keyid) {
}
/**
- * Returns the raw signature bytes.
- *
- * When serialized to JSON the bytes are Base64-encoded.
+ * Gets the raw signature bytes.
*
* @return the signature bytes, or {@code null} if not set
*/
diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
index 88aeb8ae8..4f3506e0b 100644
--- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
+++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java
@@ -49,10 +49,9 @@ public Statement() {
}
/**
- * Returns the set of software artifacts that the attestation applies to.
+ * 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.
+ * 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
*/
@@ -70,7 +69,7 @@ public void setSubject(List subject) {
}
/**
- * Returns the URI identifying the type of the predicate.
+ * Gets the URI identifying the type of the predicate.
*
* @return the predicate type URI, or {@code null} if no predicate has been set
*/
@@ -79,7 +78,7 @@ public String getPredicateType() {
}
/**
- * Returns the provenance predicate.
+ * Gets the provenance predicate.
*
* Unset is treated the same as set-but-empty. May be omitted if {@code predicateType} fully describes the
* predicate.
From 722edc03d3c552966c162a15007ffb33bfc45315 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Thu, 16 Apr 2026 20:32:33 +0200
Subject: [PATCH 09/14] fix: Javadoc of `DsseUtils`
---
.../release/plugin/internal/DsseUtils.java | 54 ++++++++-----------
.../plugin/mojos/BuildAttestationMojo.java | 2 +-
2 files changed, 23 insertions(+), 33 deletions(-)
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
index 99283d80d..23cac277c 100644
--- a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
+++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
@@ -17,7 +17,6 @@
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;
@@ -57,7 +56,7 @@ private 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 #signPaeFile(AbstractGpgSigner, Path)}.
+ * The returned signer has {@link AbstractGpgSigner#prepare()} already called and is ready for use with {@link #signFile(AbstractGpgSigner, Path)}.
*
* @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
@@ -81,12 +80,9 @@ public static AbstractGpgSigner createGpgSigner(final String executable, final b
}
/**
- * Serializes {@code statement} to JSON, computes the DSSE Pre-Authentication Encoding (PAE), and writes
- * the result to {@code buildDirectory/statement.pae}.
+ * Serializes {@code statement} to JSON using the DSSE Pre-Authentication Encoding (PAE)
*
- * The PAE format is:
- * {@code "DSSEv1" SP LEN(payloadType) SP payloadType SP LEN(payload) SP payload},
- * where {@code LEN} is the ASCII decimal byte-length of the operand.
+ * PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
*
* @param statement the attestation statement to encode
* @param objectMapper the Jackson mapper used to serialize {@code statement}
@@ -103,12 +99,9 @@ public static Path writePaeFile(final Statement statement, final ObjectMapper ob
}
/**
- * Computes the DSSE Pre-Authentication Encoding (PAE) for {@code statementBytes} and writes it to
- * {@code buildDirectory/statement.pae}.
+ * Writes serialized JSON to a file using the DSSE Pre-Authentication Encoding (PAE)
*
- * Use this overload when the statement has already been serialized to bytes, so the same byte array
- * can be reused as the {@link DsseEnvelope#setPayload(byte[]) envelope payload} without a second
- * serialization pass.
+ * PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
*
* @param statementBytes the already-serialized JSON statement bytes to encode
* @param buildDirectory directory in which the PAE file is created
@@ -134,38 +127,35 @@ public static Path writePaeFile(final byte[] statementBytes, final Path buildDir
}
/**
- * Signs {@code paeFile} using {@link AbstractGpgSigner#generateSignatureForArtifact(File)},
- * then decodes the resulting ASCII-armored {@code .asc} file with BouncyCastle and returns the raw
- * binary PGP signature bytes.
+ * Signs {@code paeFile} and returns the raw OpenPGP signature bytes.
*
- * The signer must already have {@link AbstractGpgSigner#prepare()} called before this method is
- * invoked. The {@code .asc} file produced by the signer is not deleted; callers may remove it once
- * the raw bytes have been consumed.
+ * The signer must already have {@link AbstractGpgSigner#prepare()} called before this method is invoked.
*
* @param signer the configured, prepared signer
- * @param paeFile path to the PAE-encoded file to sign
- * @return raw binary PGP signature bytes (suitable for storing in {@link org.apache.commons.release.plugin.slsa.v1_2.Signature#setSig})
+ * @param path path to the file to sign
+ * @return raw binary PGP signature bytes
* @throws MojoExecutionException if signing or signature decoding fails
*/
- public static byte[] signPaeFile(final AbstractGpgSigner signer, final Path paeFile) throws MojoExecutionException {
- final File signatureFile = signer.generateSignatureForArtifact(paeFile.toFile());
- try (InputStream in = Files.newInputStream(signatureFile.toPath()); ArmoredInputStream armoredIn = new ArmoredInputStream(in)) {
- return IOUtils.toByteArray(armoredIn);
+ public static byte[] signFile(final AbstractGpgSigner signer, final Path path) throws MojoExecutionException {
+ final Path signaturePath = signer.generateSignatureForArtifact(path.toFile()).toPath();
+ final byte[] signatureBytes;
+ try (InputStream in = Files.newInputStream(signaturePath); ArmoredInputStream armoredIn = new ArmoredInputStream(in)) {
+ signatureBytes = IOUtils.toByteArray(armoredIn);
} catch (final IOException e) {
- throw new MojoExecutionException("Failed to read signature file: " + signatureFile, e);
+ throw new MojoExecutionException("Failed to read signature file: " + signaturePath, e);
}
+ try {
+ Files.delete(signaturePath);
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Failed to delete signature file: " + signaturePath, e);
+ }
+ return signatureBytes;
}
/**
* Extracts the key identifier from a binary OpenPGP Signature Packet.
*
- * Inspects the hashed subpackets for an {@code IssuerFingerprint} subpacket (type 33),
- * which carries the full public-key fingerprint and is present in all signatures produced by
- * GPG 2.1+. Falls back to the 8-byte {@code IssuerKeyID} from the unhashed subpackets
- * when no fingerprint subpacket is found.
- *
- * @param sigBytes raw binary OpenPGP Signature Packet bytes, as returned by
- * {@link #signPaeFile(AbstractGpgSigner, Path)}
+ * @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
*/
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
index 7bcabda8d..a72853075 100644
--- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -338,7 +338,7 @@ private void signAndWriteStatement(final Statement statement, final Path outputP
}
final AbstractGpgSigner signer = DsseUtils.createGpgSigner(executable, defaultKeyring, lockMode, keyname, useAgent, getLog());
final Path paeFile = DsseUtils.writePaeFile(statementBytes, outputPath);
- final byte[] sigBytes = DsseUtils.signPaeFile(signer, paeFile);
+ final byte[] sigBytes = DsseUtils.signFile(signer, paeFile);
final Signature sig = new Signature();
sig.setKeyid(DsseUtils.getKeyId(sigBytes));
From ec90db88aa6035d05df3890a72b5f2d5990cd73a Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Thu, 16 Apr 2026 20:36:03 +0200
Subject: [PATCH 10/14] fix: Javadoc of `BuildAttestationMojo`
---
.../commons/release/plugin/mojos/BuildAttestationMojo.java | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
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
index a72853075..06326f0c1 100644
--- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -319,8 +319,7 @@ private void writeStatement(final Statement statement, final Path artifactPath)
}
/**
- * Signs the attestation statement with GPG, wraps it in a DSSE envelope, and writes it to
- * {@code artifactPath}.
+ * 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
@@ -533,8 +532,8 @@ private String getScmRevision() throws MojoExecutionException {
ScmRepository scmRepository = getScmRepository();
CommandParameters commandParameters = new CommandParameters();
try {
- InfoScmResult result = scmManager.getProviderByRepository(scmRepository).info(scmRepository.getProviderRepository(),
- new ScmFileSet(scmDirectory), commandParameters);
+ InfoScmResult result = scmManager.getProviderByRepository(scmRepository).info(scmRepository.getProviderRepository(), new ScmFileSet(scmDirectory)
+ , commandParameters);
return getScmRevision(result);
} catch (ScmException e) {
From 2ad0751624a3e23fc2357c42760d413a5d14d373 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Thu, 16 Apr 2026 21:10:22 +0200
Subject: [PATCH 11/14] fix: add sign test
---
.../plugin/mojos/BuildAttestationMojo.java | 40 ++++++-
.../mojos/BuildAttestationMojoTest.java | 107 ++++++++++++++----
.../commons-release-plugin-1.9.2.jar.asc | 7 ++
3 files changed, 133 insertions(+), 21 deletions(-)
create mode 100644 src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc
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
index 06326f0c1..75346ab82 100644
--- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -169,6 +169,11 @@ public class BuildAttestationMojo extends AbstractMojo {
@Parameter(property = "gpg.useagent", defaultValue = "true")
private boolean useAgent;
+ /**
+ * GPG signer used for signing; lazily initialized from plugin parameters when {@code null}.
+ */
+ private AbstractGpgSigner signer;
+
/**
* The current Maven project.
*/
@@ -258,6 +263,37 @@ void setMavenHome(final File mavenHome) {
this.mavenHome = mavenHome;
}
+ /**
+ * 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;
+ }
+
+ /**
+ * Overrides the GPG signer used for signing. Intended for testing.
+ *
+ * @param signer the signer to use
+ */
+ void setSigner(final AbstractGpgSigner signer) {
+ this.signer = signer;
+ }
+
+ /**
+ * Returns 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;
+ }
+
@Override
public void execute() throws MojoFailureException, MojoExecutionException {
// Build definition
@@ -335,7 +371,7 @@ private void signAndWriteStatement(final Statement statement, final Path outputP
} catch (JsonProcessingException e) {
throw new MojoExecutionException("Failed to serialize attestation statement", e);
}
- final AbstractGpgSigner signer = DsseUtils.createGpgSigner(executable, defaultKeyring, lockMode, keyname, useAgent, getLog());
+ final AbstractGpgSigner signer = getSigner();
final Path paeFile = DsseUtils.writePaeFile(statementBytes, outputPath);
final byte[] sigBytes = DsseUtils.signFile(signer, paeFile);
@@ -417,7 +453,7 @@ private Map getExternalParameters() {
* @param request The Maven execution request.
* @return A string representation of the Maven command line.
*/
- private String getCommandLine(final MavenExecutionRequest request) {
+ private static String getCommandLine(final MavenExecutionRequest request) {
StringBuilder sb = new StringBuilder();
for (String goal : request.getGoals()) {
sb.append(goal);
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
index dae20c43c..c52de3993 100644
--- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
+++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
@@ -19,12 +19,17 @@
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import java.io.File;
+import java.io.IOException;
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.util.Date;
+import com.fasterxml.jackson.databind.ObjectMapper;
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;
@@ -33,8 +38,10 @@
import org.apache.maven.execution.MavenExecutionResult;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.model.Model;
+import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.MavenProjectHelper;
+import org.apache.maven.plugins.gpg.AbstractGpgSigner;
import org.apache.maven.rtinfo.RuntimeInformation;
import org.apache.maven.scm.manager.ScmManager;
import org.codehaus.plexus.PlexusContainer;
@@ -46,6 +53,8 @@
public class BuildAttestationMojoTest {
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
@TempDir
private static Path localRepositoryPath;
@@ -89,6 +98,50 @@ private static MavenProject createMavenProject(MavenProjectHelper projectHelper,
return project;
}
+ private static AbstractGpgSigner createMockSigner() {
+ return new AbstractGpgSigner() {
+ @Override
+ public String signerName() {
+ return "mock";
+ }
+
+ @Override
+ public String getKeyInfo() {
+ return "mock-key";
+ }
+
+ @Override
+ protected void generateSignatureForFile(final File file, final File signature) throws MojoExecutionException {
+ try {
+ Files.copy(Paths.get("src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc"),
+ signature.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ } catch (final IOException e) {
+ throw new MojoExecutionException("Failed to copy mock signature", e);
+ }
+ }
+ };
+ }
+
+ private static void assertResolvedDependencies(final String statementJson) {
+ String resolvedDeps = "predicate.buildDefinition.resolvedDependencies";
+ String javaVersion = System.getProperty("java.version");
+
+ assertThatJson(statementJson)
+ .node(resolvedDeps).isArray()
+ .anySatisfy(dep -> {
+ assertThatJson(dep).node("name").isEqualTo("JDK");
+ assertThatJson(dep).node("annotations.version").isEqualTo(javaVersion);
+ });
+
+ assertThatJson(statementJson)
+ .node(resolvedDeps).isArray()
+ .anySatisfy(dep -> assertThatJson(dep).node("name").isEqualTo("Maven"));
+
+ assertThatJson(statementJson)
+ .node(resolvedDeps).isArray()
+ .anySatisfy(dep -> assertThatJson(dep).node("uri").isString().startsWith("git+https://github.com/apache/commons-lang.git"));
+ }
+
@Test
void attestationTest() throws Exception {
MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class);
@@ -102,28 +155,44 @@ void attestationTest() throws Exception {
mojo.setMavenHome(new File(System.getProperty("maven.home", ".")));
mojo.execute();
- Artifact attestation = project.getAttachedArtifacts().stream()
- .filter(a -> "intoto.jsonl".equals(a.getType()))
- .findFirst()
- .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project"));
+ Artifact attestation = getAttestation(project);
String json = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8);
- String resolvedDeps = "predicate.buildDefinition.resolvedDependencies";
- String javaVersion = System.getProperty("java.version");
+ assertResolvedDependencies(json);
+ }
- assertThatJson(json)
- .node(resolvedDeps).isArray()
- .anySatisfy(dep -> {
- assertThatJson(dep).node("name").isEqualTo("JDK");
- assertThatJson(dep).node("annotations.version").isEqualTo(javaVersion);
- });
+ @Test
+ void signingTest() throws Exception {
+ MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class);
+ MavenRepositorySystem repoSystem = container.lookup(MavenRepositorySystem.class);
+ MavenProject project = createMavenProject(projectHelper, repoSystem);
- assertThatJson(json)
- .node(resolvedDeps).isArray()
- .anySatisfy(dep -> assertThatJson(dep).node("name").isEqualTo("Maven"));
+ BuildAttestationMojo mojo = createBuildAttestationMojo(project, projectHelper);
+ mojo.setOutputDirectory(new File("target/attestations"));
+ mojo.setScmDirectory(new File("."));
+ mojo.setScmConnectionUrl("scm:git:https://github.com/apache/commons-lang.git");
+ mojo.setMavenHome(new File(System.getProperty("maven.home", ".")));
+ mojo.setSignAttestation(true);
+ mojo.setSigner(createMockSigner());
+ mojo.execute();
- assertThatJson(json)
- .node(resolvedDeps).isArray()
- .anySatisfy(dep -> assertThatJson(dep).node("uri").isString().startsWith("git+https://github.com/apache/commons-lang.git"));
+ Artifact attestation = getAttestation(project);
+ String envelopeJson = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8);
+
+ assertThatJson(envelopeJson).node("payloadType").isEqualTo(DsseEnvelope.PAYLOAD_TYPE);
+ assertThatJson(envelopeJson).node("signatures").isArray().hasSize(1);
+ assertThatJson(envelopeJson).node("signatures[0].sig").isString().isNotEmpty();
+
+ DsseEnvelope envelope = OBJECT_MAPPER.readValue(envelopeJson.trim(), DsseEnvelope.class);
+ String statementJson = new String(envelope.getPayload(), StandardCharsets.UTF_8);
+ assertResolvedDependencies(statementJson);
+ }
+
+ private static Artifact getAttestation(MavenProject project) {
+ return project.getAttachedArtifacts()
+ .stream()
+ .filter(a -> "intoto.jsonl".equals(a.getType()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project"));
}
-}
+}
\ No newline at end of file
diff --git a/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc b/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc
new file mode 100644
index 000000000..c8e60938a
--- /dev/null
+++ b/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc
@@ -0,0 +1,7 @@
+-----BEGIN PGP SIGNATURE-----
+
+iHUEABYKAB0WIQT03VnJAUi9xSvrkKRTCqXyXCUBHwUCaXYyIwAKCRBTCqXyXCUB
+H9E7AQDHPmR05PZJGDiGzz1qJnqTuu/Jo3mS3A9AoHWSJbT6HwD+InboQcE6tCGT
+5MpDNFzs2aNqyb9klElvKE2c6o8cJw4=
+=Nf0G
+-----END PGP SIGNATURE-----
From f8876c412112e22a5555e7d142766bb5e968d543 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Thu, 16 Apr 2026 21:18:53 +0200
Subject: [PATCH 12/14] fix: build errors
---
.../apache/commons/release/plugin/internal/DsseUtils.java | 4 ++--
.../commons/release/plugin/mojos/BuildAttestationMojo.java | 4 ++--
.../release/plugin/mojos/BuildAttestationMojoTest.java | 6 +++---
.../signatures/commons-release-plugin-1.9.2.jar.asc | 7 -------
4 files changed, 7 insertions(+), 14 deletions(-)
delete mode 100644 src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc
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
index 23cac277c..20267a497 100644
--- a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
+++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java
@@ -80,7 +80,7 @@ public static AbstractGpgSigner createGpgSigner(final String executable, final b
}
/**
- * Serializes {@code statement} to JSON using the DSSE Pre-Authentication Encoding (PAE)
+ * Serializes {@code statement} to JSON using the DSSE Pre-Authentication Encoding (PAE).
*
* PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
*
@@ -99,7 +99,7 @@ public static Path writePaeFile(final Statement statement, final ObjectMapper ob
}
/**
- * Writes serialized JSON to a file using the DSSE Pre-Authentication Encoding (PAE)
+ * Writes serialized JSON to a file using the DSSE Pre-Authentication Encoding (PAE).
*
* PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
*
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
index 75346ab82..e08cbe11e 100644
--- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -568,8 +568,8 @@ private String getScmRevision() throws MojoExecutionException {
ScmRepository scmRepository = getScmRepository();
CommandParameters commandParameters = new CommandParameters();
try {
- InfoScmResult result = scmManager.getProviderByRepository(scmRepository).info(scmRepository.getProviderRepository(), new ScmFileSet(scmDirectory)
- , commandParameters);
+ InfoScmResult result = scmManager.getProviderByRepository(scmRepository).info(scmRepository.getProviderRepository(),
+ new ScmFileSet(scmDirectory), commandParameters);
return getScmRevision(result);
} catch (ScmException e) {
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
index c52de3993..ee90bd8d2 100644
--- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
+++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
@@ -39,9 +39,9 @@
import org.apache.maven.execution.MavenSession;
import org.apache.maven.model.Model;
import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugins.gpg.AbstractGpgSigner;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.MavenProjectHelper;
-import org.apache.maven.plugins.gpg.AbstractGpgSigner;
import org.apache.maven.rtinfo.RuntimeInformation;
import org.apache.maven.scm.manager.ScmManager;
import org.codehaus.plexus.PlexusContainer;
@@ -113,7 +113,7 @@ public String getKeyInfo() {
@Override
protected void generateSignatureForFile(final File file, final File signature) throws MojoExecutionException {
try {
- Files.copy(Paths.get("src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc"),
+ Files.copy(Paths.get("src/test/resources/mojos/detach-distributions/target/commons-text-1.4.jar.asc"),
signature.toPath(), StandardCopyOption.REPLACE_EXISTING);
} catch (final IOException e) {
throw new MojoExecutionException("Failed to copy mock signature", e);
@@ -195,4 +195,4 @@ private static Artifact getAttestation(MavenProject project) {
.findFirst()
.orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project"));
}
-}
\ No newline at end of file
+}
diff --git a/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc b/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc
deleted file mode 100644
index c8e60938a..000000000
--- a/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc
+++ /dev/null
@@ -1,7 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iHUEABYKAB0WIQT03VnJAUi9xSvrkKRTCqXyXCUBHwUCaXYyIwAKCRBTCqXyXCUB
-H9E7AQDHPmR05PZJGDiGzz1qJnqTuu/Jo3mS3A9AoHWSJbT6HwD+InboQcE6tCGT
-5MpDNFzs2aNqyb9klElvKE2c6o8cJw4=
-=Nf0G
------END PGP SIGNATURE-----
From affb0a77124beeb896e30bf6175ae498c9ce668a Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Thu, 16 Apr 2026 21:36:16 +0200
Subject: [PATCH 13/14] fix: base tests on already existing test resources
---
.../mojos/BuildAttestationMojoTest.java | 97 +++++++++++++------
1 file changed, 65 insertions(+), 32 deletions(-)
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
index ee90bd8d2..210b27f67 100644
--- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
+++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
@@ -53,6 +53,8 @@
public class BuildAttestationMojoTest {
+ private static final String ARTIFACTS_DIR = "src/test/resources/mojos/detach-distributions/target/";
+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@TempDir
@@ -85,16 +87,23 @@ private static BuildAttestationMojo createBuildAttestationMojo(MavenProject proj
createMavenSession(createMavenExecutionRequest(), new DefaultMavenExecutionResult()), projectHelper);
}
- private static MavenProject createMavenProject(MavenProjectHelper projectHelper, MavenRepositorySystem repoSystem) throws ComponentLookupException {
+ private static MavenProject createMavenProject(MavenProjectHelper projectHelper, MavenRepositorySystem repoSystem) {
MavenProject project = new MavenProject(new Model());
- Artifact artifact = repoSystem.createArtifact("groupId", "artifactId", "1.2.3", null, "jar");
+ Artifact artifact = repoSystem.createArtifact("org.apache.commons", "commons-text", "1.4", null, "jar");
+ artifact.setFile(new File(ARTIFACTS_DIR + "commons-text-1.4.jar"));
project.setArtifact(artifact);
- project.setGroupId("groupId");
- project.setArtifactId("artifactId");
- project.setVersion("1.2.3");
- // Attach a couple of artifacts
- projectHelper.attachArtifact(project, "pom", null, new File("src/test/resources/artifacts/artifact-pom.txt"));
- artifact.setFile(new File("src/test/resources/artifacts/artifact-jar.txt"));
+ project.setGroupId("org.apache.commons");
+ project.setArtifactId("commons-text");
+ project.setVersion("1.4");
+ projectHelper.attachArtifact(project, "pom", null, new File(ARTIFACTS_DIR + "commons-text-1.4.pom"));
+ 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;
}
@@ -113,8 +122,7 @@ public String getKeyInfo() {
@Override
protected void generateSignatureForFile(final File file, final File signature) throws MojoExecutionException {
try {
- Files.copy(Paths.get("src/test/resources/mojos/detach-distributions/target/commons-text-1.4.jar.asc"),
- signature.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ 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);
}
@@ -122,7 +130,44 @@ protected void generateSignatureForFile(final File file, final File signature) t
};
}
- private static void assertResolvedDependencies(final String statementJson) {
+ 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"));
+ }
+
+ private static void assertSubject(final String statementJson, final String name, final String sha256) {
+ assertThatJson(statementJson)
+ .node("subject").isArray()
+ .anySatisfy(s -> {
+ assertThatJson(s).node("name").isEqualTo(name);
+ assertThatJson(s).node("digest.sha256").isEqualTo(sha256);
+ });
+ }
+
+ private static void assertStatementContent(final String statementJson) {
+ assertSubject(statementJson, "commons-text-1.4.jar",
+ "ad2d2eacf15ab740c115294afc1192603d8342004a6d7d0ad35446f7dda8a134");
+ assertSubject(statementJson, "commons-text-1.4.pom",
+ "4d6277b1e0720bb054c640620679a9da120f753029342150e714095f48934d76");
+ assertSubject(statementJson, "commons-text-1.4-sources.jar",
+ "58a95591fe7fc94db94a0a9e64b4a5bcc1c49edf17f2b24d7c0747357d855761");
+ assertSubject(statementJson, "commons-text-1.4-javadoc.jar",
+ "42f5b341d0fbeaa30b06aed90612840bc513fb39792c3d39446510670216e8b1");
+ assertSubject(statementJson, "commons-text-1.4-tests.jar",
+ "e4e365d08d601a4bda44be2a31f748b96762504d301742d4a0f7f5953d4c793a");
+ assertSubject(statementJson, "commons-text-1.4-test-sources.jar",
+ "9200a2a41b35f2d6d30c1c698308591cf577547ec39514657dff0e2f7dff18ca");
+ assertSubject(statementJson, "commons-text-1.4-bin.tar.gz",
+ "8b9393f7ddc2efb69d8c2b6f4d85d8711dddfe77009799cf21619fc9b8411897");
+ assertSubject(statementJson, "commons-text-1.4-bin.zip",
+ "ad3732dcb38e510b1dbb1544115d0eb797fab61afe0008fdb187cd4ef1706cd7");
+ assertSubject(statementJson, "commons-text-1.4-src.tar.gz",
+ "1cb8536c375c3cff66757fd40c2bf878998254ba0a247866a6536bd48ba2e88a");
+ assertSubject(statementJson, "commons-text-1.4-src.zip",
+ "e4a6c992153faae4f7faff689b899073000364e376736b9746a5d0acb9d8b980");
+
String resolvedDeps = "predicate.buildDefinition.resolvedDependencies";
String javaVersion = System.getProperty("java.version");
@@ -132,14 +177,13 @@ private static void assertResolvedDependencies(final String statementJson) {
assertThatJson(dep).node("name").isEqualTo("JDK");
assertThatJson(dep).node("annotations.version").isEqualTo(javaVersion);
});
-
assertThatJson(statementJson)
.node(resolvedDeps).isArray()
.anySatisfy(dep -> assertThatJson(dep).node("name").isEqualTo("Maven"));
-
assertThatJson(statementJson)
.node(resolvedDeps).isArray()
- .anySatisfy(dep -> assertThatJson(dep).node("uri").isString().startsWith("git+https://github.com/apache/commons-lang.git"));
+ .anySatisfy(dep -> assertThatJson(dep).node("uri").isString()
+ .startsWith("git+https://github.com/apache/commons-text.git"));
}
@Test
@@ -151,14 +195,12 @@ void attestationTest() throws Exception {
BuildAttestationMojo mojo = createBuildAttestationMojo(project, projectHelper);
mojo.setOutputDirectory(new File("target/attestations"));
mojo.setScmDirectory(new File("."));
- mojo.setScmConnectionUrl("scm:git:https://github.com/apache/commons-lang.git");
+ mojo.setScmConnectionUrl("scm:git:https://github.com/apache/commons-text.git");
mojo.setMavenHome(new File(System.getProperty("maven.home", ".")));
mojo.execute();
- Artifact attestation = getAttestation(project);
- String json = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8);
-
- assertResolvedDependencies(json);
+ String json = new String(Files.readAllBytes(getAttestation(project).getFile().toPath()), StandardCharsets.UTF_8);
+ assertStatementContent(json);
}
@Test
@@ -170,14 +212,13 @@ void signingTest() throws Exception {
BuildAttestationMojo mojo = createBuildAttestationMojo(project, projectHelper);
mojo.setOutputDirectory(new File("target/attestations"));
mojo.setScmDirectory(new File("."));
- mojo.setScmConnectionUrl("scm:git:https://github.com/apache/commons-lang.git");
+ mojo.setScmConnectionUrl("scm:git:https://github.com/apache/commons-text.git");
mojo.setMavenHome(new File(System.getProperty("maven.home", ".")));
mojo.setSignAttestation(true);
mojo.setSigner(createMockSigner());
mojo.execute();
- Artifact attestation = getAttestation(project);
- String envelopeJson = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8);
+ String envelopeJson = new String(Files.readAllBytes(getAttestation(project).getFile().toPath()), StandardCharsets.UTF_8);
assertThatJson(envelopeJson).node("payloadType").isEqualTo(DsseEnvelope.PAYLOAD_TYPE);
assertThatJson(envelopeJson).node("signatures").isArray().hasSize(1);
@@ -185,14 +226,6 @@ void signingTest() throws Exception {
DsseEnvelope envelope = OBJECT_MAPPER.readValue(envelopeJson.trim(), DsseEnvelope.class);
String statementJson = new String(envelope.getPayload(), StandardCharsets.UTF_8);
- assertResolvedDependencies(statementJson);
- }
-
- private static Artifact getAttestation(MavenProject project) {
- return project.getAttachedArtifacts()
- .stream()
- .filter(a -> "intoto.jsonl".equals(a.getType()))
- .findFirst()
- .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project"));
+ assertStatementContent(statementJson);
}
-}
+}
\ No newline at end of file
From 3e4deda6991d7786f54c9330c6ea2a062c8d1253 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Thu, 16 Apr 2026 21:59:37 +0200
Subject: [PATCH 14/14] fix: refactor external parameters from
BuildAttestationMojo
---
...Descriptors.java => BuildDefinitions.java} | 85 +++++++++++++++++--
.../plugin/mojos/BuildAttestationMojo.java | 80 +----------------
.../plugin/internal/BuildDefinitionsTest.java | 65 ++++++++++++++
.../mojos/BuildAttestationMojoTest.java | 2 +-
4 files changed, 147 insertions(+), 85 deletions(-)
rename src/main/java/org/apache/commons/release/plugin/internal/{BuildToolDescriptors.java => BuildDefinitions.java} (56%)
create mode 100644 src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java
diff --git a/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java b/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java
similarity index 56%
rename from src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java
rename to src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java
index 5dac9a192..dc51d0730 100644
--- a/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java
+++ b/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java
@@ -18,21 +18,26 @@
import java.io.IOException;
import java.io.InputStream;
+import java.lang.management.ManagementFactory;
import java.nio.file.Path;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Properties;
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 {@link ResourceDescriptor} instances representing build-tool dependencies.
+ * Factory methods for the SLSA {@code BuildDefinition} fields: JVM, Maven descriptors and external build parameters.
*/
-public final class BuildToolDescriptors {
+public final class BuildDefinitions {
- /** No instances. */
- private BuildToolDescriptors() {
- // no instantiation
+ /**
+ * No instances.
+ */
+ private BuildDefinitions() {
}
/**
@@ -72,9 +77,9 @@ public static ResourceDescriptor jvm(Path javaHome) throws IOException {
* Plugin code runs in an isolated Plugin Classloader, which does see that 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
+ * @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
*/
@@ -98,4 +103,68 @@ public static ResourceDescriptor maven(String version, Path mavenHome, ClassLoad
}
return descriptor;
}
+
+ /**
+ * 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) {
+ Map params = new HashMap<>();
+ params.put("jvm.args", ManagementFactory.getRuntimeMXBean().getInputArguments());
+ MavenExecutionRequest request = session.getRequest();
+ params.put("maven.goals", request.getGoals());
+ params.put("maven.profiles", request.getActiveProfiles());
+ params.put("maven.user.properties", request.getUserProperties());
+ params.put("maven.cmdline", commandLine(request));
+ Map env = new HashMap<>();
+ params.put("env", env);
+ for (Map.Entry entry : System.getenv().entrySet()) {
+ String key = entry.getKey();
+ if ("TZ".equals(key) || "LANG".equals(key) || key.startsWith("LC_")) {
+ env.put(key, entry.getValue());
+ }
+ }
+ return params;
+ }
+
+ /**
+ * Reconstructs the Maven command line string from the given execution request.
+ *
+ * @param request the Maven execution request
+ * @return a string representation of the Maven command line
+ */
+ static String commandLine(final MavenExecutionRequest request) {
+ StringBuilder sb = new StringBuilder();
+ for (String goal : request.getGoals()) {
+ sb.append(goal).append(" ");
+ }
+ List activeProfiles = request.getActiveProfiles();
+ if (activeProfiles != null && !activeProfiles.isEmpty()) {
+ sb.append("-P");
+ for (String profile : activeProfiles) {
+ sb.append(profile).append(",");
+ }
+ removeLast(sb);
+ sb.append(" ");
+ }
+ Properties userProperties = request.getUserProperties();
+ for (String propertyName : userProperties.stringPropertyNames()) {
+ sb.append("-D").append(propertyName).append("=").append(userProperties.get(propertyName)).append(" ");
+ }
+ removeLast(sb);
+ return sb.toString();
+ }
+
+ /**
+ * Removes last character from a {@link StringBuilder}.
+ *
+ * @param sb The {@link StringBuilder} to use
+ */
+ private static void removeLast(final StringBuilder sb) {
+ if (sb.length() > 0) {
+ sb.setLength(sb.length() - 1);
+ }
+ }
}
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
index e08cbe11e..32fe3b341 100644
--- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
+++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java
@@ -19,7 +19,6 @@
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
-import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -30,7 +29,6 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Properties;
import javax.inject.Inject;
@@ -39,7 +37,7 @@
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.BuildToolDescriptors;
+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;
@@ -52,7 +50,6 @@
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.MavenExecutionRequest;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
@@ -298,7 +295,7 @@ private AbstractGpgSigner getSigner() throws MojoFailureException {
public void execute() throws MojoFailureException, MojoExecutionException {
// Build definition
BuildDefinition buildDefinition = new BuildDefinition();
- buildDefinition.setExternalParameters(getExternalParameters());
+ buildDefinition.setExternalParameters(BuildDefinitions.externalParameters(session));
buildDefinition.setResolvedDependencies(getBuildDependencies());
// Builder
Builder builder = new Builder();
@@ -423,75 +420,6 @@ private List getSubjects() throws MojoExecutionException {
return subjects;
}
- /**
- * Gets map of external build parameters captured from the current JVM and Maven session.
- *
- * @return A map of parameter names to values.
- */
- private Map getExternalParameters() {
- Map params = new HashMap<>();
- params.put("jvm.args", ManagementFactory.getRuntimeMXBean().getInputArguments());
- MavenExecutionRequest request = session.getRequest();
- params.put("maven.goals", request.getGoals());
- params.put("maven.profiles", request.getActiveProfiles());
- params.put("maven.user.properties", request.getUserProperties());
- params.put("maven.cmdline", getCommandLine(request));
- Map env = new HashMap<>();
- params.put("env", env);
- for (Map.Entry entry : System.getenv().entrySet()) {
- String key = entry.getKey();
- if ("TZ".equals(key) || "LANG".equals(key) || key.startsWith("LC_")) {
- env.put(key, entry.getValue());
- }
- }
- return params;
- }
-
- /**
- * Reconstructs the Maven command line string from the given execution request.
- *
- * @param request The Maven execution request.
- * @return A string representation of the Maven command line.
- */
- private static String getCommandLine(final MavenExecutionRequest request) {
- StringBuilder sb = new StringBuilder();
- for (String goal : request.getGoals()) {
- sb.append(goal);
- sb.append(" ");
- }
- List activeProfiles = request.getActiveProfiles();
- if (activeProfiles != null && !activeProfiles.isEmpty()) {
- sb.append("-P");
- for (String profile : activeProfiles) {
- sb.append(profile);
- sb.append(",");
- }
- removeLast(sb);
- sb.append(" ");
- }
- Properties userProperties = request.getUserProperties();
- for (String propertyName : userProperties.stringPropertyNames()) {
- sb.append("-D");
- sb.append(propertyName);
- sb.append("=");
- sb.append(userProperties.get(propertyName));
- sb.append(" ");
- }
- removeLast(sb);
- return sb.toString();
- }
-
- /**
- * Removes the last character from the given {@link StringBuilder} if it is non-empty.
- *
- * @param sb The string builder to trim.
- */
- private static void removeLast(final StringBuilder sb) {
- if (sb.length() > 0) {
- sb.setLength(sb.length() - 1);
- }
- }
-
/**
* Returns resource descriptors for the JVM, Maven installation, SCM source, and project dependencies.
*
@@ -501,8 +429,8 @@ private static void removeLast(final StringBuilder sb) {
private List getBuildDependencies() throws MojoExecutionException {
List dependencies = new ArrayList<>();
try {
- dependencies.add(BuildToolDescriptors.jvm(Paths.get(System.getProperty("java.home"))));
- dependencies.add(BuildToolDescriptors.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath(),
+ 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 (IOException e) {
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..aa9081d71
--- /dev/null
+++ b/src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.assertj.core.api.Assertions.assertThat;
+
+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(), props("foo", "bar"), "verify -Dfoo=bar"),
+ Arguments.of("goals, profile and property", singletonList("verify"), singletonList("release"), props("foo", "bar"),
+ "verify -Prelease -Dfoo=bar")
+ );
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("commandLineArguments")
+ void commandLineTest(final String description, final List goals, final List profiles,
+ final Properties userProperties, final String expected) {
+ MavenExecutionRequest request = new DefaultMavenExecutionRequest();
+ request.setGoals(goals);
+ request.setActiveProfiles(profiles);
+ request.setUserProperties(userProperties);
+ assertThat(BuildDefinitions.commandLine(request)).isEqualTo(expected);
+ }
+
+ private static Properties props(final String key, final String value) {
+ Properties p = new Properties();
+ p.setProperty(key, value);
+ return p;
+ }
+}
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
index 210b27f67..b5d527294 100644
--- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
+++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java
@@ -228,4 +228,4 @@ void signingTest() throws Exception {
String statementJson = new String(envelope.getPayload(), StandardCharsets.UTF_8);
assertStatementContent(statementJson);
}
-}
\ No newline at end of file
+}