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.function.Supplier;
051import java.util.stream.Stream;
052import java.util.zip.ZipEntry;
053import java.util.zip.ZipOutputStream;
054import org.apache.maven.model.building.DefaultModelBuilderFactory;
055import org.apache.maven.model.building.DefaultModelBuildingRequest;
056import org.apache.maven.model.building.ModelBuildingException;
057import org.apache.maven.model.building.ModelBuildingRequest;
058import org.bouncycastle.bcpg.ArmoredOutputStream;
059import org.bouncycastle.jce.provider.BouncyCastleProvider;
060import org.bouncycastle.openpgp.PGPException;
061import org.bouncycastle.openpgp.PGPPrivateKey;
062import org.bouncycastle.openpgp.PGPPublicKey;
063import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
064import org.bouncycastle.openpgp.PGPSignature;
065import org.bouncycastle.openpgp.PGPSignatureGenerator;
066import org.bouncycastle.openpgp.PGPUtil;
067import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
068import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder;
069import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder;
070import org.bouncycastle.util.encoders.Base64;
071import org.eclipse.aether.AbstractRepositoryListener;
072import org.eclipse.aether.DefaultRepositorySystemSession;
073import org.eclipse.aether.RepositoryEvent;
074import org.eclipse.aether.artifact.Artifact;
075import org.eclipse.aether.artifact.DefaultArtifact;
076import org.eclipse.aether.deployment.DeployRequest;
077import org.eclipse.aether.deployment.DeploymentException;
078import org.eclipse.aether.repository.RemoteRepository;
079import org.eclipse.aether.repository.RepositoryPolicy;
080import org.eclipse.aether.util.artifact.SubArtifact;
081import org.eclipse.aether.util.repository.AuthenticationBuilder;
082import org.jdrupes.builder.api.BuildContext;
083import org.jdrupes.builder.api.BuildException;
084import org.jdrupes.builder.api.Generator;
085import static org.jdrupes.builder.api.Intent.*;
086import org.jdrupes.builder.api.Project;
087import org.jdrupes.builder.api.Resource;
088import org.jdrupes.builder.api.ResourceRequest;
089import org.jdrupes.builder.core.AbstractGenerator;
090import org.jdrupes.builder.java.JavadocJarFile;
091import org.jdrupes.builder.java.LibraryJarFile;
092import org.jdrupes.builder.java.SourcesJarFile;
093import static org.jdrupes.builder.mvnrepo.MvnProperties.ArtifactId;
094import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
095
096/// A [Generator] for maven deployments in response to requests for
097/// [MvnPublication] It supports publishing releases using the
098/// [Publish Portal API](https://central.sonatype.org/publish/publish-portal-api/)
099/// and publishing snapshots using the "traditional" maven approach
100/// (uploading the files, including the appropriate `maven-metadata.xml`
101/// files).
102///
103/// The publisher requests the [PomFile] from the project and uses
104/// the groupId, artfactId and version as specified in this file.
105/// It also requests the [LibraryJarFile], the [SourcesJarFile] and
106/// the [JavadocJarFile]. The latter two are optional for snapshot
107/// releases.
108///
109/// Publishing requires credentials for the maven repository and a
110/// PGP/GPG secret key for signing the artifacts. They can be set by
111/// the respective methods. However, it is assumed that the credentials
112/// are usually made available as properties in the build context.
113///
114@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports",
115    "PMD.GodClass", "PMD.TooManyMethods" })
116public class MvnPublisher extends AbstractGenerator {
117
118    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
119    private URI uploadUri;
120    private URI snapshotUri;
121    private String repoUser;
122    private String repoPass;
123    private String signingKeyRing;
124    private String signingKeyId;
125    private String signingPassword;
126    private JcaPGPContentSignerBuilder signerBuilder;
127    private PGPPrivateKey privateKey;
128    private PGPPublicKey publicKey;
129    private Supplier<Path> artifactDirectory
130        = () -> project().buildDirectory().resolve("publications/maven");
131    private boolean keepSubArtifacts;
132    private boolean publishAutomatically;
133
134    /// Creates a new Maven publication generator.
135    ///
136    /// @param project the project
137    ///
138    public MvnPublisher(Project project) {
139        super(project);
140        uploadUri = URI
141            .create("https://central.sonatype.com/api/v1/publisher/upload");
142        snapshotUri = URI
143            .create("https://central.sonatype.com/repository/maven-snapshots/");
144    }
145
146    /// Sets the upload URI.
147    ///
148    /// @param uri the repository URI
149    /// @return the maven publication generator
150    ///
151    public MvnPublisher uploadUri(URI uri) {
152        this.uploadUri = uri;
153        return this;
154    }
155
156    /// Returns the upload URI. Defaults to 
157    /// `https://central.sonatype.com/api/v1/publisher/upload`.
158    ///
159    /// @return the uri
160    ///
161    public URI uploadUri() {
162        return uploadUri;
163    }
164
165    /// Sets the Maven snapshot repository URI.
166    ///
167    /// @param uri the snapshot repository URI
168    /// @return the maven publication generator
169    ///
170    public MvnPublisher snapshotRepository(URI uri) {
171        this.snapshotUri = uri;
172        return this;
173    }
174
175    /// Returns the snapshot repository. Defaults to
176    /// `https://central.sonatype.com/repository/maven-snapshots/`.
177    ///
178    /// @return the uri
179    ///
180    public URI snapshotRepository() {
181        return snapshotUri;
182    }
183
184    /// Sets the Maven repository credentials. If not specified, the
185    /// publisher looks for properties `mvnrepo.user` and
186    /// `mvnrepo.password` in the properties provided by the [BuildContext].
187    ///
188    /// @param user the username
189    /// @param pass the password
190    /// @return the maven publication generator
191    ///
192    public MvnPublisher credentials(String user, String pass) {
193        this.repoUser = user;
194        this.repoPass = pass;
195        return this;
196    }
197
198    /// Use the provided information to sign the artifacts. If no
199    /// information is specified, the publisher will use the [BuildContext]
200    /// to look up the properties `signing.secretKeyRingFile`,
201    /// `signing.secretKey` and `signing.password`.
202    ///
203    /// The publisher retrieves the secret key from the key ring using the
204    /// key ID. While this method makes signing in CI/CD pipelines
205    /// more complex, it is considered best practice. 
206    ///
207    /// @param secretKeyRing the secret key ring
208    /// @param keyId the key id
209    /// @param password the password
210    /// @return the mvn publisher
211    ///
212    public MvnPublisher signWith(String secretKeyRing, String keyId,
213            String password) {
214        this.signingKeyRing = secretKeyRing;
215        this.signingKeyId = keyId;
216        this.signingPassword = password;
217        return this;
218    }
219
220    /// Keep generated sub artifacts (checksums, signatures).
221    ///
222    /// @return the mvn publication generator
223    ///
224    public MvnPublisher keepSubArtifacts() {
225        keepSubArtifacts = true;
226        return this;
227    }
228
229    /// Publish the release automatically.
230    ///
231    /// @return the mvn publisher
232    ///
233    public MvnPublisher publishAutomatically() {
234        publishAutomatically = true;
235        return this;
236    }
237
238    /// Returns the directory where additional artifacts are created.
239    /// Defaults to sub directory `publications/maven` in the project's
240    /// build directory (see [Project#buildDirectory]).
241    ///
242    /// @return the directory
243    ///
244    public Path artifactDirectory() {
245        return artifactDirectory.get();
246    }
247
248    /// Sets the directory where additional artifacts are created.
249    /// The [Path] is resolved against the project's build directory
250    /// (see [Project#buildDirectory]). If `destination` is `null`,
251    /// the additional artifacts are created in the directory where
252    /// the base artifact is found.
253    ///
254    /// @param directory the new directory
255    /// @return the maven publication generator
256    ///
257    public MvnPublisher artifactDirectory(Path directory) {
258        if (directory == null) {
259            this.artifactDirectory = () -> null;
260            return this;
261        }
262        this.artifactDirectory
263            = () -> project().buildDirectory().resolve(directory);
264        return this;
265    }
266
267    /// Sets the directory where additional artifacts are created.
268    /// If the [Supplier] returns `null`, the additional artifacts
269    /// are created in the directory where the base artifact is found.
270    ///
271    /// @param directory the new directory
272    /// @return the maven publication generator
273    ///
274    public MvnPublisher artifactDirectory(Supplier<Path> directory) {
275        this.artifactDirectory = directory;
276        return this;
277    }
278
279    @Override
280    protected <T extends Resource> Stream<T>
281            doProvide(ResourceRequest<T> requested) {
282        if (!requested.accepts(MvnPublicationType)) {
283            return Stream.empty();
284        }
285        PomFile pomResource = resourceCheck(project()
286            .resources(of(PomFile.class).using(Supply)), "POM file");
287        if (pomResource == null) {
288            return Stream.empty();
289        }
290        var jarResource = resourceCheck(project()
291            .resources(of(LibraryJarFile.class).using(Supply)), "jar file");
292        if (jarResource == null) {
293            return Stream.empty();
294        }
295        var srcsIter = project()
296            .resources(of(SourcesJarFile.class).using(Supply)).iterator();
297        SourcesJarFile srcsFile = null;
298        if (srcsIter.hasNext()) {
299            srcsFile = srcsIter.next();
300            if (srcsIter.hasNext()) {
301                logger.atSevere()
302                    .log("More than one sources jar resources found.");
303                return Stream.empty();
304            }
305        }
306        var jdIter = project().resources(of(JavadocJarFile.class).using(Supply))
307            .iterator();
308        JavadocJarFile jdFile = null;
309        if (jdIter.hasNext()) {
310            jdFile = jdIter.next();
311            if (jdIter.hasNext()) {
312                logger.atSevere()
313                    .log("More than one javadoc jar resources found.");
314                return Stream.empty();
315            }
316        }
317
318        // Deploy what we've found
319        @SuppressWarnings("unchecked")
320        var result = (Stream<T>) deploy(pomResource, jarResource, srcsFile,
321            jdFile);
322        return result;
323    }
324
325    private <T extends Resource> T resourceCheck(Stream<T> resources,
326            String name) {
327        var iter = resources.iterator();
328        if (!iter.hasNext()) {
329            logger.atSevere().log("No %s resource available", name);
330            return null;
331        }
332        var result = iter.next();
333        if (iter.hasNext()) {
334            logger.atSevere().log("More than one %s resource found.", name);
335            return null;
336        }
337        return result;
338    }
339
340    private record Deployable(Artifact artifact, boolean temporary) {
341    }
342
343    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
344    private Stream<?> deploy(PomFile pomResource, LibraryJarFile jarResource,
345            SourcesJarFile srcsJar, JavadocJarFile javadocJar) {
346        Artifact mainArtifact;
347        try {
348            mainArtifact = mainArtifact(pomResource);
349        } catch (ModelBuildingException e) {
350            throw new BuildException("Cannot build model from POM: %s",
351                e).from(this).cause(e);
352        }
353        if (artifactDirectory() != null) {
354            artifactDirectory().toFile().mkdirs();
355        }
356        List<Deployable> toDeploy = new ArrayList<>();
357        addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "pom",
358            pomResource.path().toFile()));
359        addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "jar",
360            jarResource.path().toFile()));
361        if (srcsJar != null) {
362            addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "sources",
363                "jar", srcsJar.path().toFile()));
364        }
365        if (javadocJar != null) {
366            addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "javadoc",
367                "jar", javadocJar.path().toFile()));
368        }
369
370        try {
371            if (mainArtifact.isSnapshot()) {
372                deploySnapshot(toDeploy);
373            } else {
374                deployRelease(mainArtifact, toDeploy);
375            }
376        } catch (DeploymentException e) {
377            throw new BuildException("Deployment failed for %s: %s",
378                mainArtifact, e.getMessage()).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(project().newResource(MvnPublicationType,
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        session.setRepositoryListener(new AbstractRepositoryListener() {
557            @Override
558            public void artifactDeploying(RepositoryEvent event) {
559                if (!startMsgLogged.getAndSet(true)) {
560                    logger.atInfo().log("Start deploying artifacts...");
561                }
562            }
563
564            @Override
565            public void artifactDeployed(RepositoryEvent event) {
566                if (!"jar".equals(event.getArtifact().getExtension())) {
567                    return;
568                }
569                logger.atInfo().log("Deployed: %s", event.getArtifact());
570            }
571
572            @Override
573            public void metadataDeployed(RepositoryEvent event) {
574                logger.atInfo().log("Deployed: %s", event.getMetadata());
575            }
576
577        });
578        var user = Optional.ofNullable(repoUser)
579            .orElse(project().context().property("mvnrepo.user", ""));
580        var password = Optional.ofNullable(repoPass)
581            .orElse(project().context().property("mvnrepo.password", ""));
582        var repo = new RemoteRepository.Builder("mine", "default",
583            snapshotUri.toString())
584                .setAuthentication(new AuthenticationBuilder()
585                    .addUsername(user).addPassword(password).build())
586                .build();
587        var deployReq = new DeployRequest().setRepository(repo);
588        toDeploy.stream().map(d -> d.artifact).forEach(deployReq::addArtifact);
589        context.repositorySystem().deploy(session, deployReq);
590    }
591
592    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
593    private void deployRelease(Artifact mainArtifact,
594            List<Deployable> toDeploy) {
595        // Create zip file with all artifacts for release, see
596        // https://central.sonatype.org/publish/publish-portal-upload/
597        var zipName = Optional.ofNullable(project().get(ArtifactId))
598            .orElse(project().name()) + "-" + mainArtifact.getVersion()
599            + "-release.zip";
600        var zipPath = artifactDirectory().resolve(zipName);
601        try {
602            Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/'))
603                .resolve(mainArtifact.getArtifactId())
604                .resolve(mainArtifact.getVersion());
605            try (ZipOutputStream zos
606                = new ZipOutputStream(Files.newOutputStream(zipPath))) {
607                for (Deployable d : toDeploy) {
608                    var artifact = d.artifact();
609                    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
610                    var entry = new ZipEntry(praefix.resolve(
611                        artifact.getArtifactId() + "-" + artifact.getVersion()
612                            + (artifact.getClassifier().isEmpty()
613                                ? ""
614                                : "-" + artifact.getClassifier())
615                            + "." + artifact.getExtension())
616                        .toString());
617                    zos.putNextEntry(entry);
618                    try (var fis = Files.newInputStream(
619                        artifact.getFile().toPath())) {
620                        fis.transferTo(zos);
621                    }
622                    zos.closeEntry();
623                }
624            }
625        } catch (IOException e) {
626            throw new BuildException("Failed to create release zip: %s",
627                e).from(this).cause(e);
628        }
629
630        try (var client = HttpClient.newHttpClient()) {
631            var boundary = "===" + System.currentTimeMillis() + "===";
632            var user = Optional.ofNullable(repoUser)
633                .orElse(project().context().property("mvnrepo.user", ""));
634            var password = Optional.ofNullable(repoPass)
635                .orElse(project().context().property("mvnrepo.password", ""));
636            var token = new String(Base64.encode((user + ":" + password)
637                .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
638            var effectiveUri = uploadUri;
639            if (publishAutomatically) {
640                effectiveUri = addQueryParameter(
641                    uploadUri, "publishingType", "AUTOMATIC");
642            }
643            HttpRequest request = HttpRequest.newBuilder().uri(effectiveUri)
644                .header("Authorization", "Bearer " + token)
645                .header("Content-Type",
646                    "multipart/form-data; boundary=" + boundary)
647                .POST(HttpRequest.BodyPublishers
648                    .ofInputStream(() -> getAsMultipart(zipPath, boundary)))
649                .build();
650            logger.atInfo().log("Uploading release bundle...");
651            HttpResponse<String> response = client.send(request,
652                HttpResponse.BodyHandlers.ofString());
653            logger.atFinest().log("Upload response: %s", response.body());
654            if (response.statusCode() / 100 != 2) {
655                throw new BuildException(
656                    "Failed to upload release bundle: " + response.body());
657            }
658        } catch (IOException | InterruptedException e) {
659            throw new BuildException("Failed to upload release bundle: %s",
660                e.getMessage()).from(this).cause(e);
661        } finally {
662            if (!keepSubArtifacts) {
663                zipPath.toFile().delete();
664            }
665        }
666    }
667
668    @SuppressWarnings("PMD.UseTryWithResources")
669    private InputStream getAsMultipart(Path zipPath, String boundary) {
670        // Use Piped streams for streaming multipart content
671        var fromPipe = new PipedInputStream();
672
673        // Write multipart content to pipe
674        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
675        OutputStream toPipe;
676        try {
677            toPipe = new PipedOutputStream(fromPipe);
678        } catch (IOException e) {
679            throw new UncheckedIOException(e);
680        }
681        executor.submit(() -> {
682            try (var mpOut = new BufferedOutputStream(toPipe)) {
683                final String lineFeed = "\r\n";
684                @SuppressWarnings("PMD.InefficientStringBuffering")
685                StringBuilder intro = new StringBuilder(100)
686                    .append("--").append(boundary).append(lineFeed)
687                    .append("Content-Disposition: form-data; name=\"bundle\";"
688                        + " filename=\"%s\"".formatted(zipPath.getFileName()))
689                    .append(lineFeed)
690                    .append("Content-Type: application/octet-stream")
691                    .append(lineFeed).append(lineFeed);
692                mpOut.write(
693                    intro.toString().getBytes(StandardCharsets.US_ASCII));
694                Files.newInputStream(zipPath).transferTo(mpOut);
695                mpOut.write((lineFeed + "--" + boundary + "--")
696                    .getBytes(StandardCharsets.US_ASCII));
697            } catch (IOException e) {
698                throw new UncheckedIOException(e);
699            } finally {
700                executor.close();
701            }
702        });
703        return fromPipe;
704    }
705
706    private static URI addQueryParameter(URI uri, String key, String value) {
707        String query = uri.getQuery();
708        try {
709            String newQueryParam
710                = key + "=" + URLEncoder.encode(value, "UTF-8");
711            String newQuery = (query == null || query.isEmpty()) ? newQueryParam
712                : query + "&" + newQueryParam;
713
714            // Build a new URI with the new query string
715            return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(),
716                newQuery, uri.getFragment());
717        } catch (UnsupportedEncodingException | URISyntaxException e) {
718            // UnsupportedEncodingException cannot happen, UTF-8 is standard.
719            // URISyntaxException cannot happen when starting with a valid URI
720            throw new IllegalArgumentException(e);
721        }
722    }
723}