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