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