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 com.google.common.flogger.FluentLogger;
022import java.io.BufferedInputStream;
023import java.io.BufferedOutputStream;
024import java.io.FileNotFoundException;
025import java.io.IOException;
026import java.io.InputStream;
027import java.io.OutputStream;
028import java.io.PipedInputStream;
029import java.io.PipedOutputStream;
030import java.io.UncheckedIOException;
031import java.io.UnsupportedEncodingException;
032import java.net.URI;
033import java.net.URISyntaxException;
034import java.net.URLEncoder;
035import java.net.http.HttpClient;
036import java.net.http.HttpRequest;
037import java.net.http.HttpResponse;
038import java.nio.charset.StandardCharsets;
039import java.nio.file.Files;
040import java.nio.file.Path;
041import java.security.MessageDigest;
042import java.security.NoSuchAlgorithmException;
043import java.security.Security;
044import java.util.ArrayList;
045import java.util.List;
046import java.util.Optional;
047import java.util.concurrent.ExecutorService;
048import java.util.concurrent.Executors;
049import java.util.concurrent.atomic.AtomicBoolean;
050import java.util.concurrent.atomic.AtomicInteger;
051import java.util.function.Supplier;
052import java.util.stream.Stream;
053import java.util.zip.ZipEntry;
054import java.util.zip.ZipOutputStream;
055import org.apache.maven.model.building.DefaultModelBuilderFactory;
056import org.apache.maven.model.building.DefaultModelBuildingRequest;
057import org.apache.maven.model.building.ModelBuildingException;
058import org.apache.maven.model.building.ModelBuildingRequest;
059import org.bouncycastle.bcpg.ArmoredOutputStream;
060import org.bouncycastle.jce.provider.BouncyCastleProvider;
061import org.bouncycastle.openpgp.PGPException;
062import org.bouncycastle.openpgp.PGPPrivateKey;
063import org.bouncycastle.openpgp.PGPPublicKey;
064import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
065import org.bouncycastle.openpgp.PGPSignature;
066import org.bouncycastle.openpgp.PGPSignatureGenerator;
067import org.bouncycastle.openpgp.PGPUtil;
068import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
069import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder;
070import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder;
071import org.bouncycastle.util.encoders.Base64;
072import org.eclipse.aether.AbstractRepositoryListener;
073import org.eclipse.aether.DefaultRepositorySystemSession;
074import org.eclipse.aether.RepositoryEvent;
075import org.eclipse.aether.artifact.Artifact;
076import org.eclipse.aether.artifact.DefaultArtifact;
077import org.eclipse.aether.deployment.DeployRequest;
078import org.eclipse.aether.deployment.DeploymentException;
079import org.eclipse.aether.repository.RemoteRepository;
080import org.eclipse.aether.repository.RepositoryPolicy;
081import org.eclipse.aether.util.artifact.SubArtifact;
082import org.eclipse.aether.util.repository.AuthenticationBuilder;
083import org.jdrupes.builder.api.BuildContext;
084import org.jdrupes.builder.api.BuildException;
085import org.jdrupes.builder.api.ConfigurationException;
086import org.jdrupes.builder.api.Generator;
087import static org.jdrupes.builder.api.Intent.*;
088import org.jdrupes.builder.api.Project;
089import org.jdrupes.builder.api.Resource;
090import org.jdrupes.builder.api.ResourceRequest;
091import org.jdrupes.builder.core.AbstractGenerator;
092import org.jdrupes.builder.java.JavadocJarFile;
093import org.jdrupes.builder.java.LibraryJarFile;
094import org.jdrupes.builder.java.SourcesJarFile;
095import static org.jdrupes.builder.mvnrepo.MvnProperties.ArtifactId;
096import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
097
098/// A [Generator] for maven deployments in response to requests for
099/// [MvnPublication] It supports publishing releases using the
100/// [Publish Portal API](https://central.sonatype.org/publish/publish-portal-api/)
101/// and publishing snapshots using the "traditional" maven approach
102/// (uploading the files, including the appropriate `maven-metadata.xml`
103/// files).
104///
105/// The publisher requests the [PomFile] from the project and uses
106/// the groupId, artfactId and version as specified in this file.
107/// It also requests the [LibraryJarFile], the [SourcesJarFile] and
108/// the [JavadocJarFile]. The latter two are optional for snapshot
109/// releases.
110///
111/// Publishing requires credentials for the maven repository and a
112/// PGP/GPG secret key for signing the artifacts. They can be set by
113/// the respective methods. However, it is assumed that the credentials
114/// are usually made available as properties in the build context.
115///
116@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports",
117    "PMD.GodClass", "PMD.TooManyMethods" })
118public class MvnPublisher extends AbstractGenerator {
119
120    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
121    private URI uploadUri;
122    private URI snapshotUri;
123    private String repoUser;
124    private String repoPass;
125    private String signingKeyRing;
126    private String signingKeyId;
127    private String signingPassword;
128    private JcaPGPContentSignerBuilder signerBuilder;
129    private PGPPrivateKey privateKey;
130    private PGPPublicKey publicKey;
131    private Supplier<Path> artifactDirectory
132        = () -> project().buildDirectory().resolve("publications/maven");
133    private boolean keepSubArtifacts;
134    private boolean publishAutomatically;
135
136    /// Creates a new Maven publication generator.
137    ///
138    /// @param project the project
139    ///
140    public MvnPublisher(Project project) {
141        super(project);
142        uploadUri = URI
143            .create("https://central.sonatype.com/api/v1/publisher/upload");
144        snapshotUri = URI
145            .create("https://central.sonatype.com/repository/maven-snapshots/");
146    }
147
148    /// Sets the upload URI.
149    ///
150    /// @param uri the repository URI
151    /// @return the maven publication generator
152    ///
153    public MvnPublisher uploadUri(URI uri) {
154        this.uploadUri = uri;
155        return this;
156    }
157
158    /// Returns the upload URI. Defaults to 
159    /// `https://central.sonatype.com/api/v1/publisher/upload`.
160    ///
161    /// @return the uri
162    ///
163    public URI uploadUri() {
164        return uploadUri;
165    }
166
167    /// Sets the Maven snapshot repository URI.
168    ///
169    /// @param uri the snapshot repository URI
170    /// @return the maven publication generator
171    ///
172    public MvnPublisher snapshotRepository(URI uri) {
173        this.snapshotUri = uri;
174        return this;
175    }
176
177    /// Returns the snapshot repository. Defaults to
178    /// `https://central.sonatype.com/repository/maven-snapshots/`.
179    ///
180    /// @return the uri
181    ///
182    public URI snapshotRepository() {
183        return snapshotUri;
184    }
185
186    /// Sets the Maven repository credentials. If not specified, the
187    /// publisher looks for properties `mvnrepo.user` and
188    /// `mvnrepo.password` in the properties provided by the [BuildContext].
189    ///
190    /// @param user the username
191    /// @param pass the password
192    /// @return the maven publication generator
193    ///
194    public MvnPublisher credentials(String user, String pass) {
195        this.repoUser = user;
196        this.repoPass = pass;
197        return this;
198    }
199
200    /// Use the provided information to sign the artifacts. If no
201    /// information is specified, the publisher will use the [BuildContext]
202    /// to look up the properties `signing.secretKeyRingFile`,
203    /// `signing.secretKey` and `signing.password`.
204    ///
205    /// The publisher retrieves the secret key from the key ring using the
206    /// key ID. While this method makes signing in CI/CD pipelines
207    /// more complex, it is considered best practice. 
208    ///
209    /// @param secretKeyRing the secret key ring
210    /// @param keyId the key id
211    /// @param password the password
212    /// @return the mvn publisher
213    ///
214    public MvnPublisher signWith(String secretKeyRing, String keyId,
215            String password) {
216        this.signingKeyRing = secretKeyRing;
217        this.signingKeyId = keyId;
218        this.signingPassword = password;
219        return this;
220    }
221
222    /// Keep generated sub artifacts (checksums, signatures).
223    ///
224    /// @return the mvn publication generator
225    ///
226    public MvnPublisher keepSubArtifacts() {
227        keepSubArtifacts = true;
228        return this;
229    }
230
231    /// Publish the release automatically.
232    ///
233    /// @return the mvn publisher
234    ///
235    public MvnPublisher publishAutomatically() {
236        publishAutomatically = true;
237        return this;
238    }
239
240    /// Returns the directory where additional artifacts are created.
241    /// Defaults to sub directory `publications/maven` in the project's
242    /// build directory (see [Project#buildDirectory]).
243    ///
244    /// @return the directory
245    ///
246    public Path artifactDirectory() {
247        return artifactDirectory.get();
248    }
249
250    /// Sets the directory where additional artifacts are created.
251    /// The [Path] is resolved against the project's build directory
252    /// (see [Project#buildDirectory]). If `destination` is `null`,
253    /// the additional artifacts are created in the directory where
254    /// the base artifact is found.
255    ///
256    /// @param directory the new directory
257    /// @return the maven publication generator
258    ///
259    public MvnPublisher artifactDirectory(Path directory) {
260        if (directory == null) {
261            this.artifactDirectory = () -> null;
262            return this;
263        }
264        this.artifactDirectory
265            = () -> project().buildDirectory().resolve(directory);
266        return this;
267    }
268
269    /// Sets the directory where additional artifacts are created.
270    /// If the [Supplier] returns `null`, the additional artifacts
271    /// are created in the directory where the base artifact is found.
272    ///
273    /// @param directory the new directory
274    /// @return the maven publication generator
275    ///
276    public MvnPublisher artifactDirectory(Supplier<Path> directory) {
277        this.artifactDirectory = directory;
278        return this;
279    }
280
281    @Override
282    protected <T extends Resource> Stream<T>
283            doProvide(ResourceRequest<T> requested) {
284        if (!requested.accepts(MvnPublicationType)) {
285            return Stream.empty();
286        }
287        PomFile pomResource = resourceCheck(project()
288            .resources(of(PomFile.class).using(Supply)), "POM file");
289        if (pomResource == null) {
290            return Stream.empty();
291        }
292        var jarResource = resourceCheck(project()
293            .resources(of(LibraryJarFile.class).using(Supply)), "jar file");
294        if (jarResource == null) {
295            return Stream.empty();
296        }
297        var srcsIter = project()
298            .resources(of(SourcesJarFile.class).using(Supply)).iterator();
299        SourcesJarFile srcsFile = null;
300        if (srcsIter.hasNext()) {
301            srcsFile = srcsIter.next();
302            if (srcsIter.hasNext()) {
303                logger.atSevere()
304                    .log("More than one sources jar resources found.");
305                return Stream.empty();
306            }
307        }
308        var jdIter = project().resources(of(JavadocJarFile.class).using(Supply))
309            .iterator();
310        JavadocJarFile jdFile = null;
311        if (jdIter.hasNext()) {
312            jdFile = jdIter.next();
313            if (jdIter.hasNext()) {
314                logger.atSevere()
315                    .log("More than one javadoc jar resources found.");
316                return Stream.empty();
317            }
318        }
319
320        // Deploy what we've found
321        @SuppressWarnings("unchecked")
322        var result = (Stream<T>) deploy(pomResource, jarResource, srcsFile,
323            jdFile);
324        return result;
325    }
326
327    private <T extends Resource> T resourceCheck(Stream<T> resources,
328            String name) {
329        var iter = resources.iterator();
330        if (!iter.hasNext()) {
331            logger.atSevere().log("No %s resource available", name);
332            return null;
333        }
334        var result = iter.next();
335        if (iter.hasNext()) {
336            logger.atSevere().log("More than one %s resource found.", name);
337            return null;
338        }
339        return result;
340    }
341
342    private record Deployable(Artifact artifact, boolean temporary) {
343    }
344
345    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
346    private Stream<?> deploy(PomFile pomResource, LibraryJarFile jarResource,
347            SourcesJarFile srcsJar, JavadocJarFile javadocJar) {
348        Artifact mainArtifact;
349        try {
350            mainArtifact = mainArtifact(pomResource);
351        } catch (ModelBuildingException e) {
352            throw new BuildException().from(this).cause(e);
353        }
354        if (artifactDirectory() != null) {
355            artifactDirectory().toFile().mkdirs();
356        }
357        List<Deployable> toDeploy = new ArrayList<>();
358        addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "pom",
359            pomResource.path().toFile()));
360        addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "jar",
361            jarResource.path().toFile()));
362        if (srcsJar != null) {
363            addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "sources",
364                "jar", srcsJar.path().toFile()));
365        }
366        if (javadocJar != null) {
367            addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "javadoc",
368                "jar", javadocJar.path().toFile()));
369        }
370
371        try {
372            if (mainArtifact.isSnapshot()) {
373                deploySnapshot(toDeploy);
374            } else {
375                deployRelease(mainArtifact, toDeploy);
376            }
377        } catch (DeploymentException e) {
378            throw new BuildException().from(this).cause(e);
379        } finally {
380            if (!keepSubArtifacts) {
381                toDeploy.stream().filter(Deployable::temporary).forEach(d -> {
382                    d.artifact().getFile().delete();
383                });
384            }
385        }
386        return Stream.of(MvnPublication.of(String.format("%s:%s:%s",
387            mainArtifact.getGroupId(), mainArtifact.getArtifactId(),
388            mainArtifact.getVersion())));
389    }
390
391    private Artifact mainArtifact(PomFile pomResource)
392            throws ModelBuildingException {
393        var repoSystem = MvnRepoLookup.rootContext().repositorySystem();
394        var repoSession = MvnRepoLookup.rootContext().repositorySystemSession();
395        var repos
396            = new ArrayList<>(MvnRepoLookup.rootContext().remoteRepositories());
397        if (snapshotUri != null) {
398            repos.add(createSnapshotRepository());
399        }
400        var pomFile = pomResource.path().toFile();
401        var buildingRequest = new DefaultModelBuildingRequest()
402            .setPomFile(pomFile).setProcessPlugins(false)
403            .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL)
404            .setModelResolver(
405                new MvnModelResolver(repoSystem, repoSession, repos));
406        var model = new DefaultModelBuilderFactory().newInstance()
407            .build(buildingRequest).getEffectiveModel();
408        return new DefaultArtifact(model.getGroupId(), model.getArtifactId(),
409            "jar", model.getVersion());
410    }
411
412    private RemoteRepository createSnapshotRepository() {
413        return new RemoteRepository.Builder(
414            "snapshots", "default", snapshotUri.toString())
415                .setSnapshotPolicy(new RepositoryPolicy(
416                    true,  // enable snapshots
417                    RepositoryPolicy.UPDATE_POLICY_ALWAYS,
418                    RepositoryPolicy.CHECKSUM_POLICY_WARN))
419                .setReleasePolicy(new RepositoryPolicy(
420                    false,
421                    RepositoryPolicy.UPDATE_POLICY_NEVER,
422                    RepositoryPolicy.CHECKSUM_POLICY_IGNORE))
423                .build();
424    }
425
426    private void addWithGenerated(List<Deployable> toDeploy,
427            Artifact artifact) {
428        // Add main artifact
429        toDeploy.add(new Deployable(artifact, false));
430
431        // Generate .md5 and .sha1 checksum files
432        var artifactFile = artifact.getFile().toPath();
433        try {
434            MessageDigest md5 = MessageDigest.getInstance("MD5");
435            MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
436            try (var fis = Files.newInputStream(artifactFile)) {
437                byte[] buffer = new byte[8192];
438                while (true) {
439                    int read = fis.read(buffer);
440                    if (read < 0) {
441                        break;
442                    }
443                    md5.update(buffer, 0, read);
444                    sha1.update(buffer, 0, read);
445                }
446            }
447            var fileName = artifactFile.getFileName().toString();
448
449            // Handle generated md5
450            var md5Path = destinationPath(artifactFile, fileName + ".md5");
451            Files.writeString(md5Path, toHex(md5.digest()));
452            toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.md5",
453                md5Path.toFile()), true));
454
455            // Handle generated sha1
456            var sha1Path = destinationPath(artifactFile, fileName + ".sha1");
457            Files.writeString(sha1Path, toHex(sha1.digest()));
458            toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.sha1",
459                sha1Path.toFile()), true));
460
461            // Add signature as yet another artifact
462            var sigPath = signResource(artifactFile);
463            toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.asc",
464                sigPath.toFile()), true));
465        } catch (NoSuchAlgorithmException | IOException | PGPException e) {
466            throw new BuildException().from(this).cause(e);
467        }
468    }
469
470    private Path destinationPath(Path base, String fileName) {
471        var dir = artifactDirectory();
472        if (dir == null) {
473            base.resolveSibling(fileName);
474        }
475        return dir.resolve(fileName);
476    }
477
478    private static String toHex(byte[] bytes) {
479        char[] hexDigits = "0123456789abcdef".toCharArray();
480        char[] result = new char[bytes.length * 2];
481
482        for (int i = 0; i < bytes.length; i++) {
483            int unsigned = bytes[i] & 0xFF;
484            result[i * 2] = hexDigits[unsigned >>> 4];
485            result[i * 2 + 1] = hexDigits[unsigned & 0x0F];
486        }
487        return new String(result);
488    }
489
490    private void initSigning()
491            throws FileNotFoundException, IOException, PGPException {
492        if (signerBuilder != null) {
493            return;
494        }
495        var keyRingFileName = Optional.ofNullable(signingKeyRing).orElse(
496            project().context().property("signing.secretKeyRingFile", null));
497        var keyId = Optional.ofNullable(signingKeyId)
498            .orElse(project().context().property("signing.keyId", null));
499        var passphrase = Optional.ofNullable(signingPassword)
500            .orElse(project().context().property("signing.password", null))
501            .toCharArray();
502        if (keyRingFileName == null || keyId == null || passphrase == null) {
503            logger.atWarning()
504                .log("Cannot sign artifacts: properties not set.");
505            return;
506        }
507        Security.addProvider(new BouncyCastleProvider());
508        var secretKeyRingCollection = new PGPSecretKeyRingCollection(
509            PGPUtil.getDecoderStream(
510                Files.newInputStream(Path.of(keyRingFileName))),
511            new JcaKeyFingerprintCalculator());
512        var secretKey = secretKeyRingCollection
513            .getSecretKey(Long.parseUnsignedLong(keyId, 16));
514        publicKey = secretKey.getPublicKey();
515        privateKey = secretKey.extractPrivateKey(
516            new JcePBESecretKeyDecryptorBuilder().setProvider("BC")
517                .build(passphrase));
518        signerBuilder = new JcaPGPContentSignerBuilder(
519            publicKey.getAlgorithm(), PGPUtil.SHA256).setProvider("BC");
520    }
521
522    private Path signResource(Path resource)
523            throws PGPException, IOException {
524        initSigning();
525        PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator(
526            signerBuilder, publicKey);
527        signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey);
528        var sigPath = destinationPath(resource,
529            resource.getFileName() + ".asc");
530        try (InputStream fileInput = new BufferedInputStream(
531            Files.newInputStream(resource));
532                OutputStream sigOut
533                    = new ArmoredOutputStream(Files.newOutputStream(sigPath))) {
534            byte[] buffer = new byte[8192];
535            while (true) {
536                int read = fileInput.read(buffer);
537                if (read < 0) {
538                    break;
539                }
540                signatureGenerator.update(buffer, 0, read);
541            }
542            PGPSignature signature = signatureGenerator.generate();
543            signature.encode(sigOut);
544        }
545        return sigPath;
546    }
547
548    private void deploySnapshot(List<Deployable> toDeploy)
549            throws DeploymentException {
550        // Now deploy everything
551        @SuppressWarnings("PMD.CloseResource")
552        var context = MvnRepoLookup.rootContext();
553        var session = new DefaultRepositorySystemSession(
554            context.repositorySystemSession());
555        var startMsgLogged = new AtomicBoolean(false);
556        var deployedCount = new AtomicInteger(0);
557        session.setRepositoryListener(new AbstractRepositoryListener() {
558            @Override
559            public void artifactDeploying(RepositoryEvent event) {
560                if (!startMsgLogged.getAndSet(true)) {
561                    logger.atInfo().log("Start deploying artifacts...");
562                    context().statusLine().update(
563                        MvnPublisher.this + " starts deploying artifacts");
564                }
565            }
566
567            @Override
568            public void artifactDeployed(RepositoryEvent event) {
569                if (!"jar".equals(event.getArtifact().getExtension())) {
570                    return;
571                }
572                logger.atInfo().log("Deployed: %s", event.getArtifact());
573                context().statusLine().update(
574                    "%s deployed %d/%d", MvnPublisher.this,
575                    deployedCount.incrementAndGet(), toDeploy.size());
576            }
577
578            @Override
579            public void metadataDeployed(RepositoryEvent event) {
580                logger.atInfo().log("Deployed: %s", event.getMetadata());
581                context().statusLine().update(
582                    "%s deployed %d/%d", MvnPublisher.this,
583                    deployedCount.incrementAndGet(), toDeploy.size());
584            }
585
586        });
587        var user = Optional.ofNullable(repoUser)
588            .orElse(project().context().property("mvnrepo.user", ""));
589        var password = Optional.ofNullable(repoPass)
590            .orElse(project().context().property("mvnrepo.password", ""));
591        var repo = new RemoteRepository.Builder("mine", "default",
592            snapshotUri.toString())
593                .setAuthentication(new AuthenticationBuilder()
594                    .addUsername(user).addPassword(password).build())
595                .build();
596        var deployReq = new DeployRequest().setRepository(repo);
597        toDeploy.stream().map(d -> d.artifact).forEach(deployReq::addArtifact);
598        context.repositorySystem().deploy(session, deployReq);
599    }
600
601    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
602    private void deployRelease(Artifact mainArtifact,
603            List<Deployable> toDeploy) {
604        // Create zip file with all artifacts for release, see
605        // https://central.sonatype.org/publish/publish-portal-upload/
606        var zipName = Optional.ofNullable(project().get(ArtifactId))
607            .orElse(project().name()) + "-" + mainArtifact.getVersion()
608            + "-release.zip";
609        var zipPath = artifactDirectory().resolve(zipName);
610        try {
611            Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/'))
612                .resolve(mainArtifact.getArtifactId())
613                .resolve(mainArtifact.getVersion());
614            try (ZipOutputStream zos
615                = new ZipOutputStream(Files.newOutputStream(zipPath))) {
616                for (Deployable d : toDeploy) {
617                    var artifact = d.artifact();
618                    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
619                    var entry = new ZipEntry(praefix.resolve(
620                        artifact.getArtifactId() + "-" + artifact.getVersion()
621                            + (artifact.getClassifier().isEmpty()
622                                ? ""
623                                : "-" + artifact.getClassifier())
624                            + "." + artifact.getExtension())
625                        .toString());
626                    zos.putNextEntry(entry);
627                    try (var fis = Files.newInputStream(
628                        artifact.getFile().toPath())) {
629                        fis.transferTo(zos);
630                    }
631                    zos.closeEntry();
632                }
633            }
634        } catch (IOException e) {
635            throw new BuildException().from(this).cause(e);
636        }
637
638        try (var client = HttpClient.newHttpClient()) {
639            var boundary = "===" + System.currentTimeMillis() + "===";
640            var user = Optional.ofNullable(repoUser)
641                .orElse(project().context().property("mvnrepo.user", ""));
642            var password = Optional.ofNullable(repoPass)
643                .orElse(project().context().property("mvnrepo.password", ""));
644            var token = new String(Base64.encode((user + ":" + password)
645                .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
646            var effectiveUri = uploadUri;
647            if (publishAutomatically) {
648                effectiveUri = addQueryParameter(
649                    uploadUri, "publishingType", "AUTOMATIC");
650            }
651            HttpRequest request = HttpRequest.newBuilder().uri(effectiveUri)
652                .header("Authorization", "Bearer " + token)
653                .header("Content-Type",
654                    "multipart/form-data; boundary=" + boundary)
655                .POST(HttpRequest.BodyPublishers
656                    .ofInputStream(() -> getAsMultipart(zipPath, boundary)))
657                .build();
658            logger.atInfo().log("Uploading release bundle...");
659            HttpResponse<String> response = client.send(request,
660                HttpResponse.BodyHandlers.ofString());
661            logger.atFinest().log("Upload response: %s", response.body());
662            if (response.statusCode() / 100 != 2) {
663                throw new ConfigurationException().from(this).message(
664                    "Failed to upload release bundle: " + response.body());
665            }
666        } catch (IOException | InterruptedException e) {
667            throw new BuildException().from(this).cause(e);
668        } finally {
669            if (!keepSubArtifacts) {
670                zipPath.toFile().delete();
671            }
672        }
673    }
674
675    @SuppressWarnings("PMD.UseTryWithResources")
676    private InputStream getAsMultipart(Path zipPath, String boundary) {
677        // Use Piped streams for streaming multipart content
678        var fromPipe = new PipedInputStream();
679
680        // Write multipart content to pipe
681        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
682        OutputStream toPipe;
683        try {
684            toPipe = new PipedOutputStream(fromPipe);
685        } catch (IOException e) {
686            throw new UncheckedIOException(e);
687        }
688        executor.submit(() -> {
689            try (var mpOut = new BufferedOutputStream(toPipe)) {
690                final String lineFeed = "\r\n";
691                @SuppressWarnings("PMD.InefficientStringBuffering")
692                StringBuilder intro = new StringBuilder(100)
693                    .append("--").append(boundary).append(lineFeed)
694                    .append("Content-Disposition: form-data; name=\"bundle\";"
695                        + " filename=\"%s\"".formatted(zipPath.getFileName()))
696                    .append(lineFeed)
697                    .append("Content-Type: application/octet-stream")
698                    .append(lineFeed).append(lineFeed);
699                mpOut.write(
700                    intro.toString().getBytes(StandardCharsets.US_ASCII));
701                Files.newInputStream(zipPath).transferTo(mpOut);
702                mpOut.write((lineFeed + "--" + boundary + "--")
703                    .getBytes(StandardCharsets.US_ASCII));
704            } catch (IOException e) {
705                throw new UncheckedIOException(e);
706            } finally {
707                executor.close();
708            }
709        });
710        return fromPipe;
711    }
712
713    private static URI addQueryParameter(URI uri, String key, String value) {
714        String query = uri.getQuery();
715        try {
716            String newQueryParam
717                = key + "=" + URLEncoder.encode(value, "UTF-8");
718            String newQuery = (query == null || query.isEmpty()) ? newQueryParam
719                : query + "&" + newQueryParam;
720
721            // Build a new URI with the new query string
722            return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(),
723                newQuery, uri.getFragment());
724        } catch (UnsupportedEncodingException | URISyntaxException e) {
725            // UnsupportedEncodingException cannot happen, UTF-8 is standard.
726            // URISyntaxException cannot happen when starting with a valid URI
727            throw new IllegalArgumentException(e);
728        }
729    }
730}