001/*
002 * JDrupes Builder
003 * Copyright (C) 2025, 2026 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.builder.mvnrepo;
020
021import com.google.common.flogger.FluentLogger;
022import java.io.BufferedInputStream;
023import java.io.FileNotFoundException;
024import java.io.IOException;
025import java.io.InputStream;
026import java.io.OutputStream;
027import java.net.URI;
028import java.nio.file.Files;
029import java.nio.file.Path;
030import java.security.MessageDigest;
031import java.security.NoSuchAlgorithmException;
032import java.security.Security;
033import java.util.ArrayList;
034import java.util.Arrays;
035import java.util.Collection;
036import java.util.Collections;
037import java.util.List;
038import java.util.Objects;
039import java.util.Optional;
040import java.util.function.Supplier;
041import java.util.stream.Stream;
042import org.apache.maven.model.building.DefaultModelBuilderFactory;
043import org.apache.maven.model.building.DefaultModelBuildingRequest;
044import org.apache.maven.model.building.ModelBuildingException;
045import org.apache.maven.model.building.ModelBuildingRequest;
046import org.bouncycastle.bcpg.ArmoredOutputStream;
047import org.bouncycastle.jce.provider.BouncyCastleProvider;
048import org.bouncycastle.openpgp.PGPException;
049import org.bouncycastle.openpgp.PGPPrivateKey;
050import org.bouncycastle.openpgp.PGPPublicKey;
051import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
052import org.bouncycastle.openpgp.PGPSignature;
053import org.bouncycastle.openpgp.PGPSignatureGenerator;
054import org.bouncycastle.openpgp.PGPUtil;
055import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
056import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder;
057import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder;
058import org.eclipse.aether.DefaultRepositorySystemSession;
059import org.eclipse.aether.artifact.Artifact;
060import org.eclipse.aether.artifact.DefaultArtifact;
061import org.eclipse.aether.installation.InstallRequest;
062import org.eclipse.aether.installation.InstallationException;
063import org.eclipse.aether.util.artifact.SubArtifact;
064import org.jdrupes.builder.api.BuildContext;
065import org.jdrupes.builder.api.BuildException;
066import org.jdrupes.builder.api.Generator;
067import static org.jdrupes.builder.api.Intent.*;
068import org.jdrupes.builder.api.Project;
069import org.jdrupes.builder.api.Resource;
070import org.jdrupes.builder.api.ResourceRequest;
071import org.jdrupes.builder.core.AbstractGenerator;
072import static org.jdrupes.builder.java.JavaTypes.*;
073import org.jdrupes.builder.java.JavadocJarFile;
074import org.jdrupes.builder.java.LibraryJarFile;
075import org.jdrupes.builder.java.SourcesJarFile;
076import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
077
078/// A [Generator] for Maven deployments in response to requests for
079/// [MvnPublication] or [MvnInstallation]. It supports publishing
080/// releases using the
081/// [Publish Portal API](https://central.sonatype.org/publish/publish-portal-api/)
082/// and publishing snapshots (and local installations) using the
083/// "traditional" Maven approach (uploading the files individually,
084/// including the appropriate `maven-metadata.xml` files).
085///
086/// The publisher requests the [PomFile] from the project and uses
087/// the groupId, artfactId and version as specified in this file.
088/// It also requests the [LibraryJarFile], the [SourcesJarFile] and
089/// the [JavadocJarFile]. The latter two are optional for snapshot
090/// releases.
091///
092/// Publishing requires a PGP/GPG secret key for signing the artifacts.
093/// They can be set by the respective methods. However, it is assumed
094/// that the credentials are usually made available as properties in
095/// the build context.
096/// 
097/// Except for local installs, the publisher requires at least one
098/// [MvnPublishingDestination] to publish to. If none is set, the
099/// publisher adds an instance of [PortalPublisherDestination] for releases
100/// and an instance of [MvnDeployDestination] with id "central"
101/// for snapshots.
102///
103@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports",
104    "PMD.GodClass" })
105public class MvnPublisher extends AbstractGenerator {
106
107    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
108    private String signingKeyRing;
109    private String signingKeyId;
110    private String signingPassword;
111    private JcaPGPContentSignerBuilder signerBuilder;
112    private PGPPrivateKey privateKey;
113    private PGPPublicKey publicKey;
114    private Supplier<Path> artifactDirectory
115        = () -> project().buildDirectory().resolve("publications/maven");
116    private boolean keepSubArtifacts;
117    private final List<MvnPublishingDestination> destinations
118        = new ArrayList<>();
119
120    /// Initializes a new Maven publication generator.
121    ///
122    /// @param project the project
123    ///
124    public MvnPublisher(Project project) {
125        super(project);
126    }
127
128    /// Adds the given publishing destinations.
129    ///
130    /// @param destinations the destinations
131    /// @return the Maven publisher
132    ///
133    public MvnPublisher destinations(MvnPublishingDestination... destinations) {
134        this.destinations.addAll(Arrays.asList(destinations));
135        return this;
136    }
137
138    /// Create and add a [MvnPublishingDestination] from the given arguments.
139    ///
140    /// @param id the id. May be used to lookup credentials, see 
141    ///    [MvnPublishingDestination]
142    /// @param uri the location
143    /// @param types the supported version types
144    /// @return the Maven publisher
145    ///
146    public MvnPublisher destination(String id, URI uri,
147            MvnVersionType... types) {
148        destinations.add(new MvnDeployDestination(types)
149            .repositoryUri(Objects.requireNonNull(uri))
150            .id(Objects.requireNonNull(id)));
151        return this;
152    }
153
154    /// Use the provided information to sign the artifacts. If no
155    /// information is specified, the publisher will use the [BuildContext]
156    /// to look up the properties `signing.secretKeyRingFile`,
157    /// `signing.secretKey` and `signing.password`.
158    ///
159    /// The publisher retrieves the secret key from the key ring using the
160    /// key ID. While this method makes signing in CI/CD pipelines
161    /// more complex, it is considered best practice. 
162    ///
163    /// @param secretKeyRing the secret key ring
164    /// @param keyId the key id
165    /// @param password the password
166    /// @return the mvn publisher
167    ///
168    public MvnPublisher signWith(String secretKeyRing, String keyId,
169            String password) {
170        this.signingKeyRing = Objects.requireNonNull(secretKeyRing);
171        this.signingKeyId = Objects.requireNonNull(keyId);
172        this.signingPassword = Objects.requireNonNull(password);
173        return this;
174    }
175
176    /// Keep generated sub artifacts (checksums, signatures).
177    ///
178    /// @return the mvn publication generator
179    ///
180    public MvnPublisher keepSubArtifacts() {
181        keepSubArtifacts = true;
182        return this;
183    }
184
185    /// Returns the directory where additional artifacts are created.
186    /// Defaults to sub directory `publications/maven` in the project's
187    /// build directory (see [Project#buildDirectory]).
188    ///
189    /// @return the directory
190    ///
191    public Path artifactDirectory() {
192        return artifactDirectory.get();
193    }
194
195    /// Sets the directory where additional artifacts are created.
196    /// The [Path] is resolved against the project's build directory
197    /// (see [Project#buildDirectory]). If `destination` is `null`,
198    /// the additional artifacts are created in the directory where
199    /// the base artifact is found.
200    ///
201    /// @param directory the new directory
202    /// @return the maven publication generator
203    ///
204    public MvnPublisher artifactDirectory(Path directory) {
205        if (directory == null) {
206            this.artifactDirectory = () -> null;
207            return this;
208        }
209        this.artifactDirectory
210            = () -> project().buildDirectory().resolve(directory);
211        return this;
212    }
213
214    /// Sets the directory where additional artifacts are created.
215    /// If the [Supplier] returns `null`, the additional artifacts
216    /// are created in the directory where the base artifact is found.
217    ///
218    /// @param directory the new directory
219    /// @return the maven publication generator
220    ///
221    public MvnPublisher artifactDirectory(Supplier<Path> directory) {
222        this.artifactDirectory = directory;
223        return this;
224    }
225
226    @Override
227    protected <T extends Resource> Collection<T>
228            doProvide(ResourceRequest<T> requested) {
229        if (!requested.accepts(MvnPublicationType)
230            && !requested.accepts(MvnInstallationType)) {
231            return Collections.emptyList();
232        }
233        if (requested.accepts(MvnPublicationType) && destinations.isEmpty()) {
234            destinations(new PortalPublisherDestination(),
235                new MvnDeployDestination(
236                    MvnVersionType.SNAPSHOT).id("central"));
237        }
238        PomFile pomResource = resourceCheck(project()
239            .resources(of(PomFileType).using(Supply)), "POM file");
240        if (pomResource == null) {
241            return Collections.emptyList();
242        }
243        var jarResource = resourceCheck(project()
244            .resources(of(LibraryJarFileType).using(Supply)), "jar file");
245        if (jarResource == null) {
246            return Collections.emptyList();
247        }
248        var srcsIter = project()
249            .resources(of(SourcesJarFileType).using(Supply)).iterator();
250        SourcesJarFile srcsFile = null;
251        if (srcsIter.hasNext()) {
252            srcsFile = srcsIter.next();
253            if (srcsIter.hasNext()) {
254                logger.atSevere()
255                    .log("More than one sources jar resources found.");
256                return Collections.emptyList();
257            }
258        }
259        var jdIter = project().resources(of(JavadocJarFileType).using(Supply))
260            .iterator();
261        JavadocJarFile jdFile = null;
262        if (jdIter.hasNext()) {
263            jdFile = jdIter.next();
264            if (jdIter.hasNext()) {
265                logger.atSevere()
266                    .log("More than one javadoc jar resources found.");
267                return Collections.emptyList();
268            }
269        }
270
271        // Deploy what we've found
272        @SuppressWarnings("unchecked")
273        var result = (Collection<T>) publish(
274            pomResource, jarResource, srcsFile, jdFile,
275            requested.accepts(MvnInstallationType));
276        return result;
277    }
278
279    private <T extends Resource> T resourceCheck(Stream<T> resources,
280            String name) {
281        var iter = resources.iterator();
282        if (!iter.hasNext()) {
283            logger.atSevere().log("No %s resource available", name);
284            return null;
285        }
286        var result = iter.next();
287        if (iter.hasNext()) {
288            logger.atSevere().log("More than one %s resource found.", name);
289            return null;
290        }
291        return result;
292    }
293
294    private record Deployable(Artifact artifact, boolean isCheckum,
295            boolean temporary) {
296    }
297
298    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
299    private Collection<?> publish(PomFile pomResource,
300            LibraryJarFile jarResource, SourcesJarFile srcsJar,
301            JavadocJarFile javadocJar, boolean installOnly) {
302        Artifact mainArtifact;
303        try {
304            mainArtifact = mainArtifact(pomResource);
305        } catch (ModelBuildingException e) {
306            throw new BuildException().from(this).cause(e);
307        }
308        if (artifactDirectory() != null) {
309            artifactDirectory().toFile().mkdirs();
310        }
311        List<Deployable> toDeploy = new ArrayList<>();
312        var needChecksums = destinations.stream()
313            .filter(MvnPublishingDestination::requiresChecksumArtifacts)
314            .findAny().isPresent();
315        addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "pom",
316            pomResource.path().toFile()), needChecksums);
317        addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "jar",
318            jarResource.path().toFile()), needChecksums);
319        if (srcsJar != null) {
320            addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "sources",
321                "jar", srcsJar.path().toFile()), needChecksums);
322        }
323        if (javadocJar != null) {
324            addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "javadoc",
325                "jar", javadocJar.path().toFile()), needChecksums);
326        }
327
328        try {
329            if (installOnly) {
330                install(toDeploy);
331                return List.of(MvnInstallation.of(String.format("%s:%s:%s",
332                    mainArtifact.getGroupId(), mainArtifact.getArtifactId(),
333                    mainArtifact.getVersion())));
334            }
335            @SuppressWarnings("PMD.CloseResource")
336            var context = context();
337            destinations.stream().parallel().forEach(destination -> {
338                if (mainArtifact.isSnapshot()
339                    ? !destination.accepts(MvnVersionType.SNAPSHOT)
340                    : !destination.accepts(MvnVersionType.RELEASE)) {
341                    return;
342                }
343                var artifacts = toDeploy.stream().filter(d -> !d.isCheckum()
344                    || destination.requiresChecksumArtifacts())
345                    .map(d -> d.artifact).toList();
346                destination.publish(context, this, mainArtifact, artifacts);
347            });
348            return List.of(MvnPublication.of(String.format("%s:%s:%s",
349                mainArtifact.getGroupId(), mainArtifact.getArtifactId(),
350                mainArtifact.getVersion())));
351        } finally {
352            if (!keepSubArtifacts) {
353                toDeploy.stream().filter(Deployable::temporary).forEach(d -> {
354                    d.artifact().getPath().toFile().delete();
355                });
356            }
357        }
358    }
359
360    private Artifact mainArtifact(PomFile pomResource)
361            throws ModelBuildingException {
362        var pomFile = pomResource.path().toFile();
363        var buildingRequest = new DefaultModelBuildingRequest()
364            .setPomFile(pomFile).setProcessPlugins(false)
365            .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL);
366        var model = new DefaultModelBuilderFactory().newInstance()
367            .build(buildingRequest).getEffectiveModel();
368        return new DefaultArtifact(model.getGroupId(), model.getArtifactId(),
369            "jar", model.getVersion());
370    }
371
372    private void addWithGenerated(List<Deployable> toDeploy,
373            Artifact artifact, boolean withChecksums) {
374        // Add main artifact
375        toDeploy.add(new Deployable(artifact, false, false));
376
377        // Generate .md5 and .sha1 checksum files
378        try {
379            if (withChecksums) {
380                generateChecksums(toDeploy, artifact);
381            }
382
383            // Add signature as yet another artifact
384            var sigPath = signResource(artifact.getPath());
385            toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.asc",
386                sigPath.toFile()), false, true));
387        } catch (NoSuchAlgorithmException | IOException | PGPException e) {
388            throw new BuildException().from(this).cause(e);
389        }
390    }
391
392    private void generateChecksums(List<Deployable> toDeploy, Artifact artifact)
393            throws NoSuchAlgorithmException, IOException {
394        var artifactFile = artifact.getPath();
395        MessageDigest md5 = MessageDigest.getInstance("MD5");
396        MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
397        try (var fis = Files.newInputStream(artifactFile)) {
398            byte[] buffer = new byte[8192];
399            while (true) {
400                int read = fis.read(buffer);
401                if (read < 0) {
402                    break;
403                }
404                md5.update(buffer, 0, read);
405                sha1.update(buffer, 0, read);
406            }
407        }
408        var fileName = artifactFile.getFileName().toString();
409
410        // Handle generated md5
411        var md5Path = destinationPath(artifactFile, fileName + ".md5");
412        Files.writeString(md5Path, toHex(md5.digest()));
413        toDeploy
414            .add(new Deployable(new SubArtifact(artifact, "*", "*.md5",
415                md5Path.toFile()), true, true));
416
417        // Handle generated sha1
418        var sha1Path
419            = destinationPath(artifactFile, fileName + ".sha1");
420        Files.writeString(sha1Path, toHex(sha1.digest()));
421        toDeploy
422            .add(new Deployable(new SubArtifact(artifact, "*", "*.sha1",
423                sha1Path.toFile()), true, true));
424    }
425
426    private Path destinationPath(Path base, String fileName) {
427        var dir = artifactDirectory();
428        if (dir == null) {
429            base.resolveSibling(fileName);
430        }
431        return dir.resolve(fileName);
432    }
433
434    private static String toHex(byte[] bytes) {
435        char[] hexDigits = "0123456789abcdef".toCharArray();
436        char[] result = new char[bytes.length * 2];
437
438        for (int i = 0; i < bytes.length; i++) {
439            int unsigned = bytes[i] & 0xFF;
440            result[i * 2] = hexDigits[unsigned >>> 4];
441            result[i * 2 + 1] = hexDigits[unsigned & 0x0F];
442        }
443        return new String(result);
444    }
445
446    private void initSigning()
447            throws FileNotFoundException, IOException, PGPException {
448        if (signerBuilder != null) {
449            return;
450        }
451        var keyRingFileName = Optional.ofNullable(signingKeyRing).orElse(
452            project().context().property("signing.secretKeyRingFile", null));
453        var keyId = Optional.ofNullable(signingKeyId)
454            .orElse(project().context().property("signing.keyId", null));
455        var passphrase = Optional.ofNullable(signingPassword)
456            .or(() -> Optional.ofNullable(
457                project().context().property("signing.password", null)))
458            .map(String::toCharArray).orElse(null);
459        if (keyRingFileName == null || keyId == null || passphrase == null) {
460            logger.atWarning()
461                .log("Cannot sign artifacts: properties not set.");
462            return;
463        }
464        Security.addProvider(new BouncyCastleProvider());
465        var secretKeyRingCollection = new PGPSecretKeyRingCollection(
466            PGPUtil.getDecoderStream(
467                Files.newInputStream(Path.of(keyRingFileName))),
468            new JcaKeyFingerprintCalculator());
469        var secretKey = secretKeyRingCollection
470            .getSecretKey(Long.parseUnsignedLong(keyId, 16));
471        publicKey = secretKey.getPublicKey();
472        privateKey = secretKey.extractPrivateKey(
473            new JcePBESecretKeyDecryptorBuilder().setProvider("BC")
474                .build(passphrase));
475        signerBuilder = new JcaPGPContentSignerBuilder(
476            publicKey.getAlgorithm(), PGPUtil.SHA256).setProvider("BC");
477    }
478
479    private Path signResource(Path resource)
480            throws PGPException, IOException {
481        initSigning();
482        PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator(
483            signerBuilder, publicKey);
484        signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey);
485        var sigPath = destinationPath(resource,
486            resource.getFileName() + ".asc");
487        try (InputStream fileInput = new BufferedInputStream(
488            Files.newInputStream(resource));
489                OutputStream sigOut
490                    = new ArmoredOutputStream(Files.newOutputStream(sigPath))) {
491            byte[] buffer = new byte[8192];
492            while (true) {
493                int read = fileInput.read(buffer);
494                if (read < 0) {
495                    break;
496                }
497                signatureGenerator.update(buffer, 0, read);
498            }
499            PGPSignature signature = signatureGenerator.generate();
500            signature.encode(sigOut);
501        }
502        return sigPath;
503    }
504
505    private void install(List<Deployable> toDeploy) {
506        var session = new DefaultRepositorySystemSession(
507            MavenContext.repositorySession());
508        var installReq = new InstallRequest();
509        toDeploy.stream().map(d -> d.artifact).forEach(installReq::addArtifact);
510        try {
511            MavenContext.repositorySystem().install(session, installReq);
512        } catch (InstallationException e) {
513            throw new BuildException().from(this).cause(e);
514        }
515    }
516
517}