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}