001/*
002 * JDrupes Builder
003 * Copyright (C) 2025 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 java.io.BufferedInputStream;
022import java.io.BufferedOutputStream;
023import java.io.FileNotFoundException;
024import java.io.IOException;
025import java.io.InputStream;
026import java.io.OutputStream;
027import java.io.PipedInputStream;
028import java.io.PipedOutputStream;
029import java.io.UncheckedIOException;
030import java.net.URI;
031import java.net.http.HttpClient;
032import java.net.http.HttpRequest;
033import java.net.http.HttpResponse;
034import java.nio.charset.StandardCharsets;
035import java.nio.file.Files;
036import java.nio.file.Path;
037import java.security.MessageDigest;
038import java.security.NoSuchAlgorithmException;
039import java.security.Security;
040import java.util.ArrayList;
041import java.util.List;
042import java.util.Optional;
043import java.util.concurrent.ExecutorService;
044import java.util.concurrent.Executors;
045import java.util.concurrent.atomic.AtomicBoolean;
046import java.util.function.Supplier;
047import java.util.stream.Stream;
048import java.util.zip.ZipEntry;
049import java.util.zip.ZipOutputStream;
050import org.apache.maven.model.building.DefaultModelBuilderFactory;
051import org.apache.maven.model.building.DefaultModelBuildingRequest;
052import org.apache.maven.model.building.ModelBuildingException;
053import org.bouncycastle.bcpg.ArmoredOutputStream;
054import org.bouncycastle.jce.provider.BouncyCastleProvider;
055import org.bouncycastle.openpgp.PGPException;
056import org.bouncycastle.openpgp.PGPPrivateKey;
057import org.bouncycastle.openpgp.PGPPublicKey;
058import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
059import org.bouncycastle.openpgp.PGPSignature;
060import org.bouncycastle.openpgp.PGPSignatureGenerator;
061import org.bouncycastle.openpgp.PGPUtil;
062import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
063import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder;
064import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder;
065import org.bouncycastle.util.encoders.Base64;
066import org.eclipse.aether.AbstractRepositoryListener;
067import org.eclipse.aether.DefaultRepositorySystemSession;
068import org.eclipse.aether.RepositoryEvent;
069import org.eclipse.aether.artifact.Artifact;
070import org.eclipse.aether.artifact.DefaultArtifact;
071import org.eclipse.aether.deployment.DeployRequest;
072import org.eclipse.aether.deployment.DeploymentException;
073import org.eclipse.aether.repository.RemoteRepository;
074import org.eclipse.aether.util.artifact.SubArtifact;
075import org.eclipse.aether.util.repository.AuthenticationBuilder;
076import org.jdrupes.builder.api.BuildException;
077import static org.jdrupes.builder.api.Intend.*;
078import org.jdrupes.builder.api.Project;
079import org.jdrupes.builder.api.Resource;
080import org.jdrupes.builder.api.ResourceRequest;
081import static org.jdrupes.builder.api.ResourceRequest.requestFor;
082import org.jdrupes.builder.core.AbstractGenerator;
083import org.jdrupes.builder.java.JavadocJarFile;
084import org.jdrupes.builder.java.LibraryJarFile;
085import org.jdrupes.builder.java.SourcesJarFile;
086import static org.jdrupes.builder.mvnrepo.MvnProperties.ArtifactId;
087import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
088
089/// The Class MvnPublicationGenerator.
090///
091@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports",
092    "PMD.GodClass", "PMD.TooManyMethods" })
093public class MvnPublicationGenerator extends AbstractGenerator {
094
095    private URI uploadUri;
096    private URI snapshotUri;
097    private String repoUser;
098    private String repoPass;
099    private JcaPGPContentSignerBuilder signerBuilder;
100    private PGPPrivateKey privateKey;
101    private PGPPublicKey publicKey;
102    private Supplier<Path> artifactDirectory
103        = () -> project().buildDirectory().resolve("publications/maven");
104    private boolean keepSubArtifacts;
105
106    /// Creates a new Maven publication generator.
107    ///
108    /// @param project the project
109    ///
110    public MvnPublicationGenerator(Project project) {
111        super(project);
112        uploadUri = URI
113            .create("https://central.sonatype.com/api/v1/publisher/upload");
114    }
115
116    /// Sets the upload URI.
117    ///
118    /// @param uri the repository URI
119    /// @return the maven publication generator
120    ///
121    public MvnPublicationGenerator uploadUri(URI uri) {
122        this.uploadUri = uri;
123        return this;
124    }
125
126    /// Returns the upload URI. Defaults to 
127    /// `https://central.sonatype.com/api/v1/publisher/upload`.
128    ///
129    /// @return the uri
130    ///
131    public URI uploadUri() {
132        return uploadUri;
133    }
134
135    /// Sets the Maven snapshot repository URI.
136    ///
137    /// @param uri the snapshot repository URI
138    /// @return the maven publication generator
139    ///
140    public MvnPublicationGenerator snapshotRepository(URI uri) {
141        this.snapshotUri = uri;
142        return this;
143    }
144
145    /// Returns the snapshot repository. Defaults to
146    /// `https://central.sonatype.com/repository/maven-snapshots/`.
147    ///
148    /// @return the uri
149    ///
150    public URI snapshotRepository() {
151        return snapshotUri;
152    }
153
154    /// Sets the Maven repository credentials.
155    ///
156    /// @param user the username
157    /// @param pass the password
158    /// @return the maven publication generator
159    ///
160    public MvnPublicationGenerator credentials(String user, String pass) {
161        this.repoUser = user;
162        this.repoPass = pass;
163        return this;
164    }
165
166    /// Keep generated sub artifacts (checksums, signatures)
167    ///
168    /// @return the mvn publication generator
169    ///
170    public MvnPublicationGenerator keepSubArtifacts() {
171        keepSubArtifacts = true;
172        return this;
173    }
174
175    /// Returns the directory where additional artifacts are created.
176    /// Defaults to sub directory `publications/maven` in the project's
177    /// build directory (see [Project#buildDirectory]).
178    ///
179    /// @return the directory
180    ///
181    public Path artifactDirectory() {
182        return artifactDirectory.get();
183    }
184
185    /// Sets the directory where additional artifacts are created.
186    /// The [Path] is resolved against the project's build directory
187    /// (see [Project#buildDirectory]). If `destination` is `null`,
188    /// the additional artifacts are created in the directory where
189    /// the base artifact is found.
190    ///
191    /// @param directory the new directory
192    /// @return the maven publication generator
193    ///
194    public MvnPublicationGenerator artifactDirectory(Path directory) {
195        if (directory == null) {
196            this.artifactDirectory = () -> null;
197            return this;
198        }
199        this.artifactDirectory
200            = () -> project().buildDirectory().resolve(directory);
201        return this;
202    }
203
204    /// Sets the directory where additional artifacts are created.
205    /// If the [Supplier] returns `null`, the additional artifacts
206    /// are created in the directory where the base artifact is found.
207    ///
208    /// @param directory the new directory
209    /// @return the maven publication generator
210    ///
211    public MvnPublicationGenerator artifactDirectory(Supplier<Path> directory) {
212        this.artifactDirectory = directory;
213        return this;
214    }
215
216    @Override
217    protected <T extends Resource> Stream<T>
218            doProvide(ResourceRequest<T> requested) {
219        if (!requested.includes(MvnPublicationType)) {
220            return Stream.empty();
221        }
222        PomFile pomResource = resourceCheck(project().supplied(
223            requestFor(PomFile.class)), "POM file");
224        if (pomResource == null) {
225            return Stream.empty();
226        }
227        var jarResource = resourceCheck(project().getFrom(project()
228            .providers(Expose, Forward), requestFor(LibraryJarFile.class)),
229            "jar file");
230        if (jarResource == null) {
231            return Stream.empty();
232        }
233        var srcsIter = project().supplied(requestFor(SourcesJarFile.class))
234            .iterator();
235        SourcesJarFile srcsFile = null;
236        if (srcsIter.hasNext()) {
237            srcsFile = srcsIter.next();
238            if (srcsIter.hasNext()) {
239                log.severe(() -> "More than one sources jar resources found.");
240                return Stream.empty();
241            }
242        }
243        var jdIter = project().supplied(requestFor(JavadocJarFile.class))
244            .iterator();
245        JavadocJarFile jdFile = null;
246        if (jdIter.hasNext()) {
247            jdFile = jdIter.next();
248            if (jdIter.hasNext()) {
249                log.severe(() -> "More than one javadoc jar resources found.");
250                return Stream.empty();
251            }
252        }
253
254        // Deploy what we've found
255        @SuppressWarnings("unchecked")
256        var result = (Stream<T>) deploy(pomResource, jarResource, srcsFile,
257            jdFile);
258        return result;
259    }
260
261    private <T extends Resource> T resourceCheck(Stream<T> resources,
262            String name) {
263        var iter = resources.iterator();
264        if (!iter.hasNext()) {
265            log.severe(() -> "No " + name + " resource available.");
266            return null;
267        }
268        var result = iter.next();
269        if (iter.hasNext()) {
270            log.severe(() -> "More than one " + name + " resource found.");
271            return null;
272        }
273        return result;
274    }
275
276    private record Deployable(Artifact artifact, boolean temporary) {
277    }
278
279    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
280    private Stream<?> deploy(PomFile pomResource, LibraryJarFile jarResource,
281            SourcesJarFile srcsJar, JavadocJarFile javadocJar) {
282        Artifact mainArtifact;
283        try {
284            mainArtifact = mainArtifact(pomResource);
285        } catch (ModelBuildingException e) {
286            throw new BuildException(
287                "Cannot build model from POM: " + e.getMessage(), e);
288        }
289        if (artifactDirectory() != null) {
290            artifactDirectory().toFile().mkdirs();
291        }
292        List<Deployable> toDeploy = new ArrayList<>();
293        addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "pom",
294            pomResource.path().toFile()));
295        addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "jar",
296            jarResource.path().toFile()));
297        if (srcsJar != null) {
298            addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "sources",
299                "jar", srcsJar.path().toFile()));
300        }
301        if (javadocJar != null) {
302            addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "javadoc",
303                "jar", javadocJar.path().toFile()));
304        }
305
306        try {
307            if (mainArtifact.isSnapshot()) {
308                deploySnapshot(toDeploy);
309            } else {
310                deployRelease(mainArtifact, toDeploy);
311            }
312        } catch (DeploymentException e) {
313            throw new BuildException(
314                "Deployment failed for " + mainArtifact, e);
315        } finally {
316            if (!keepSubArtifacts) {
317                toDeploy.stream().filter(Deployable::temporary).forEach(d -> {
318                    d.artifact().getFile().delete();
319                });
320            }
321        }
322        return Stream.of(project().newResource(MvnPublicationType,
323            mainArtifact.getGroupId() + ":" + mainArtifact.getArtifactId()
324                + ":" + mainArtifact.getVersion()));
325    }
326
327    private Artifact mainArtifact(PomFile pomResource)
328            throws ModelBuildingException {
329        var pomFile = pomResource.path().toFile();
330        var req = new DefaultModelBuildingRequest().setPomFile(pomFile);
331        var model = new DefaultModelBuilderFactory().newInstance()
332            .build(req).getEffectiveModel();
333        return new DefaultArtifact(model.getGroupId(), model.getArtifactId(),
334            "jar", model.getVersion());
335    }
336
337    private void addWithGenerated(List<Deployable> toDeploy,
338            Artifact artifact) {
339        // Add main artifact
340        toDeploy.add(new Deployable(artifact, false));
341
342        // Generate .md5 and .sha1 checksum files
343        var artifactFile = artifact.getFile().toPath();
344        try {
345            MessageDigest md5 = MessageDigest.getInstance("MD5");
346            MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
347            try (var fis = Files.newInputStream(artifactFile)) {
348                byte[] buffer = new byte[8192];
349                while (true) {
350                    int read = fis.read(buffer);
351                    if (read < 0) {
352                        break;
353                    }
354                    md5.update(buffer, 0, read);
355                    sha1.update(buffer, 0, read);
356                }
357            }
358            var fileName = artifactFile.getFileName().toString();
359
360            // Handle generated md5
361            var md5Path = destinationPath(artifactFile, fileName + ".md5");
362            Files.writeString(md5Path, toHex(md5.digest()));
363            toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.md5",
364                md5Path.toFile()), true));
365
366            // Handle generated sha1
367            var sha1Path = destinationPath(artifactFile, fileName + ".sha1");
368            Files.writeString(sha1Path, toHex(sha1.digest()));
369            toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.sha1",
370                sha1Path.toFile()), true));
371
372            // Add signature as yet another artifact
373            var sigPath = signResource(artifactFile);
374            toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.asc",
375                sigPath.toFile()), true));
376        } catch (NoSuchAlgorithmException | IOException | PGPException e) {
377            throw new BuildException(e);
378        }
379    }
380
381    private Path destinationPath(Path base, String fileName) {
382        var dir = artifactDirectory();
383        if (dir == null) {
384            base.resolveSibling(fileName);
385        }
386        return dir.resolve(fileName);
387    }
388
389    private static String toHex(byte[] bytes) {
390        char[] hexDigits = "0123456789abcdef".toCharArray();
391        char[] result = new char[bytes.length * 2];
392
393        for (int i = 0; i < bytes.length; i++) {
394            int unsigned = bytes[i] & 0xFF;
395            result[i * 2] = hexDigits[unsigned >>> 4];
396            result[i * 2 + 1] = hexDigits[unsigned & 0x0F];
397        }
398        return new String(result);
399    }
400
401    private void initSigning()
402            throws FileNotFoundException, IOException, PGPException {
403        if (signerBuilder != null) {
404            return;
405        }
406        var keyRingFileName
407            = project().context().property("signing.secretKeyRingFile");
408        var keyId = project().context().property("signing.keyId");
409        var passphrase
410            = project().context().property("signing.password").toCharArray();
411        if (keyRingFileName == null || keyId == null || passphrase == null) {
412            log.warning(() -> "Cannot sign artifacts: properties not set.");
413            return;
414        }
415        Security.addProvider(new BouncyCastleProvider());
416        var secretKeyRingCollection = new PGPSecretKeyRingCollection(
417            PGPUtil.getDecoderStream(
418                Files.newInputStream(Path.of(keyRingFileName))),
419            new JcaKeyFingerprintCalculator());
420        var secretKey = secretKeyRingCollection
421            .getSecretKey(Long.parseUnsignedLong(keyId, 16));
422        publicKey = secretKey.getPublicKey();
423        privateKey = secretKey.extractPrivateKey(
424            new JcePBESecretKeyDecryptorBuilder().setProvider("BC")
425                .build(passphrase));
426        signerBuilder = new JcaPGPContentSignerBuilder(
427            publicKey.getAlgorithm(), PGPUtil.SHA256).setProvider("BC");
428
429    }
430
431    private Path signResource(Path resource)
432            throws PGPException, IOException {
433        initSigning();
434        PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator(
435            signerBuilder, publicKey);
436        signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey);
437        var sigPath = destinationPath(resource,
438            resource.getFileName() + ".asc");
439        try (InputStream fileInput = new BufferedInputStream(
440            Files.newInputStream(resource));
441                OutputStream sigOut
442                    = new ArmoredOutputStream(Files.newOutputStream(sigPath))) {
443            byte[] buffer = new byte[8192];
444            while (true) {
445                int read = fileInput.read(buffer);
446                if (read < 0) {
447                    break;
448                }
449                signatureGenerator.update(buffer, 0, read);
450            }
451            PGPSignature signature = signatureGenerator.generate();
452            signature.encode(sigOut);
453        }
454        return sigPath;
455    }
456
457    private void deploySnapshot(List<Deployable> toDeploy)
458            throws DeploymentException {
459        // Now deploy everything
460        @SuppressWarnings("PMD.CloseResource")
461        var context = MvnRepoLookup.rootContext();
462        var session = new DefaultRepositorySystemSession(
463            context.repositorySystemSession());
464        var startMsgLogged = new AtomicBoolean(false);
465        session.setRepositoryListener(new AbstractRepositoryListener() {
466            @Override
467            public void artifactDeploying(RepositoryEvent event) {
468                if (!startMsgLogged.getAndSet(true)) {
469                    log.info(() -> "Start deploying artifacts...");
470                }
471            }
472
473            @Override
474            public void artifactDeployed(RepositoryEvent event) {
475                if (!"jar".equals(event.getArtifact().getExtension())) {
476                    return;
477                }
478                log.info(() -> "Deployed: " + event.getArtifact());
479            }
480
481            @Override
482            public void metadataDeployed(RepositoryEvent event) {
483                log.info(() -> "Deployed: " + event.getMetadata());
484            }
485
486        });
487        var repo = new RemoteRepository.Builder("mine", "default",
488            snapshotUri.toString())
489                .setAuthentication(new AuthenticationBuilder()
490                    .addUsername(repoUser).addPassword(repoPass).build())
491                .build();
492        var deployReq = new DeployRequest().setRepository(repo);
493        toDeploy.stream().map(d -> d.artifact).forEach(deployReq::addArtifact);
494        context.repositorySystem().deploy(session, deployReq);
495    }
496
497    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
498    private void deployRelease(Artifact mainArtifact,
499            List<Deployable> toDeploy) {
500        // Create zip file with all artifacts for release, see
501        // https://central.sonatype.org/publish/publish-portal-upload/
502        var zipName = Optional.ofNullable(project().get(ArtifactId))
503            .orElse(project().name()) + "-" + mainArtifact.getVersion()
504            + "-release.zip";
505        var zipPath = artifactDirectory().resolve(zipName);
506        try {
507            Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/'))
508                .resolve(mainArtifact.getArtifactId())
509                .resolve(mainArtifact.getVersion());
510            try (ZipOutputStream zos
511                = new ZipOutputStream(Files.newOutputStream(zipPath))) {
512                for (Deployable d : toDeploy) {
513                    var artifact = d.artifact();
514                    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
515                    var entry = new ZipEntry(praefix.resolve(
516                        artifact.getArtifactId() + "-" + artifact.getVersion()
517                            + (artifact.getClassifier().isEmpty()
518                                ? ""
519                                : "-" + artifact.getClassifier())
520                            + "." + artifact.getExtension())
521                        .toString());
522                    zos.putNextEntry(entry);
523                    try (var fis = Files.newInputStream(
524                        artifact.getFile().toPath())) {
525                        fis.transferTo(zos);
526                    }
527                    zos.closeEntry();
528                }
529            }
530        } catch (IOException e) {
531            throw new BuildException(
532                "Failed to create release zip: " + e.getMessage(), e);
533        }
534
535        try (var client = HttpClient.newHttpClient()) {
536            var boundary = "===" + System.currentTimeMillis() + "===";
537            var token = new String(Base64.encode((repoUser + ":" + repoPass)
538                .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
539            HttpRequest request = HttpRequest.newBuilder().uri(URI.create(
540                "https://central.sonatype.com/api/v1/publisher/upload"))
541                .header("Authorization", "Bearer " + token)
542                .header("Content-Type",
543                    "multipart/form-data; boundary=" + boundary)
544                .POST(HttpRequest.BodyPublishers
545                    .ofInputStream(() -> getAsMultipart(zipPath, boundary)))
546                .build();
547            HttpResponse<String> response = client.send(request,
548                HttpResponse.BodyHandlers.ofString());
549            if (response.statusCode() / 100 != 2) {
550                throw new BuildException(
551                    "Failed to upload release bundle: " + response.body());
552            }
553        } catch (IOException | InterruptedException e) {
554            throw new BuildException(
555                "Failed to upload release bundle: " + e.getMessage(), e);
556        }
557    }
558
559    @SuppressWarnings("PMD.UseTryWithResources")
560    private InputStream getAsMultipart(Path zipPath, String boundary) {
561        // Use Piped streams for streaming multipart content
562        var fromPipe = new PipedInputStream();
563        final String lineFeed = "\r\n";
564
565        // Write multipart content to pipe
566        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
567        executor.submit(() -> {
568            try (var mpOut = new BufferedOutputStream(
569                new PipedOutputStream(fromPipe))) {
570                @SuppressWarnings("PMD.InefficientStringBuffering")
571                StringBuilder intro = new StringBuilder(100)
572                    .append("--").append(boundary).append(lineFeed)
573                    .append("Content-Disposition: form-data; name=\"bundle\";"
574                        + " filename=\"%s\"".formatted(zipPath.getFileName()))
575                    .append(lineFeed)
576                    .append("Content-Type: application/octet-stream")
577                    .append(lineFeed).append(lineFeed);
578                mpOut.write(
579                    intro.toString().getBytes(StandardCharsets.US_ASCII));
580                Files.newInputStream(zipPath).transferTo(mpOut);
581                mpOut.write((lineFeed + "--" + boundary + "--")
582                    .getBytes(StandardCharsets.US_ASCII));
583            } catch (IOException e) {
584                throw new UncheckedIOException(e);
585            } finally {
586                executor.close();
587            }
588        });
589        return fromPipe;
590    }
591
592}