rmannibucau commented on issue #8608:
URL: https://github.com/apache/hop/issues/8608#issuecomment-5832626222

   @hansva maven does it "outside" the asf (but I got a mail that I was not a 
committer so not possible even if it is the case for apache maven), not sure if 
anything is ASF owned. Basically it is a curl after the release or a cronjob 
based on archive to sync it automatically (GH action?. Technically it is just  
https://sdkman.io/vendors/ .
   
   typically something like that in a scheduled action can be sufficient (not 
tested but you get the idea):
   
   ```
   import java.io.IOException;
   import java.net.URI;
   import java.net.http.HttpClient;
   import java.net.http.HttpRequest;
   import java.net.http.HttpResponse;
   import java.nio.charset.StandardCharsets;
   import java.nio.file.Files;
   import java.nio.file.Path;
   import java.nio.file.StandardOpenOption;
   import java.security.GeneralSecurityException;
   import java.security.MessageDigest;
   import java.time.Duration;
   import java.util.ArrayList;
   import java.util.Arrays;
   import java.util.Base64;
   import java.util.HashSet;
   import java.util.List;
   import java.util.Set;
   import java.util.regex.Pattern;
   
   import javax.crypto.Cipher;
   import javax.crypto.spec.IvParameterSpec;
   import javax.crypto.spec.SecretKeySpec;
   import javax.xml.parsers.DocumentBuilderFactory;
   
   import org.w3c.dom.Document;
   import org.w3c.dom.Element;
   import org.w3c.dom.NodeList;
   
   /**
    * Publishes Apache Hop releases from the Apache archive to SDKMAN! (vendor 
API
    * documented at https://sdkman.io/vendors/).
    *
    * <p>Each version directory of the archive 
(https://archive.apache.org/dist/hop/)
    * contains the archive to publish {@code apache-hop-client-<version>.zip}; 
the SDKMAN
    * version is derived from the <b>zip name</b> (e.g. {@code 
apache-hop-client-1.0.0-incubating.zip}
    * -> {@code 1.0.0-incubating}) and the official {@code .sha512} sidecar is 
forwarded as
    * the SHA-512 checksum of the release. Versions are processed in natural 
ascending
    * order ({@code 2.9.0} before {@code 2.18.0}).
    *
    * <p>The tool is <b>resumable</b>: each successfully published version is 
added to the
    * local state file (--state, one version per line, written after each 2xx),
    * and versions already visible on SDKMAN
    * ({@code candidates/<candidate>/linux/versions/all}) are ignored. A 
failure in
    * the middle of the run can therefore simply be re-run: only the missing 
versions
    * are republished (the vendor POST is itself idempotent).
    *
    * <p>Credentials are read from {@code ~/.m2/settings.xml} (a {@code 
<server>} entry
    * whose id equals --server-id; {@code <username>} = Consumer-Key, {@code 
<password>} =
    * Consumer-Token). A password encrypted by Maven ({@code {...}}) is 
decrypted
    * with the master of {@code ~/.m2/settings-security.xml}, exactly as Maven 
does
    * (port of the plexus-cipher format: EVP-style SHA-256 derivation, AES/CBC,
    * master passphrase = {@code "settings.security"}). As a fallback, the 
environment
    * variables {@code SDKMAN_CONSUMER_KEY} / {@code SDKMAN_CONSUMER_TOKEN}.
    *
    * <p>Usage: {@code java SdkManHopSync.java [options]} (source-file mode, 
Java 25+),
    * see {@code --help}. Exit code: 0 = ok, 1 = failure (can be re-run),
    * 2 = usage or configuration error.
    */
   public final class SdkManHopSync {
   
       private static final String DEFAULT_ARCHIVE_URL = 
"https://archive.apache.org/dist/hop/";;
       private static final String DEFAULT_CANDIDATE = "hop";
       private static final String VENDOR_API = "https://vendors.sdkman.io";;
       private static final String CANDIDATES_API = "https://api.sdkman.io/2";;
       private static final String RELEASE_PATH = "/release";
       private static final String DEFAULT_PATH = "/default";
       private static final String STATE_PLATFORM = "linux";
       private static final String MASTER_PASSPHRASE = "settings.security";
       private static final String USER_AGENT = "SdkManHopSync/1.0";
       private static final String ZIP_PREFIX = "apache-hop-client-";
       private static final String ZIP_SUFFIX = ".zip";
       private static final int MAX_ATTEMPTS = 3;
   
       private static final Pattern HREF = 
Pattern.compile("<a\\s+href=\"([^\"]+)\"", Pattern.CASE_INSENSITIVE);
       private static final Pattern VERSION = 
Pattern.compile("[0-9][A-Za-z0-9._-]*");
   
       private static final HttpClient HTTP = HttpClient.newBuilder()
               .connectTimeout(Duration.ofSeconds(30))
               .followRedirects(HttpClient.Redirect.NORMAL)
               .build();
   
       private SdkManHopSync() {
           // utility class, no instance
       }
   
       public static void main(String[] args) {
           int code = run(args);
           if (code != 0) {
               System.exit(code);
           }
       }
   
       private static int run(String[] args) {
           Config config;
           try {
               config = Config.parse(args);
           } catch (UsageException e) {
               System.err.println("error: " + e.getMessage());
               System.err.println("run with --help for usage");
               return 2;
           }
           if (config.help()) {
               printHelp();
               return 0;
           }
           try {
               if (config.verifyOnly()) {
                   Credentials credentials = credentials(config);
                   System.out.println("credentials OK: consumer key " + 
mask(credentials.key())
                           + ", consumer token " + mask(credentials.token())
                           + " (from " + credentials.source() + ")");
                   return 0;
               }
               if (config.debug()) {
                   System.out.println("archive: " + config.archiveUrl());
                   System.out.println("candidate: " + config.candidate());
                   System.out.println("vendor API: " + config.vendorApiUrl());
                   System.out.println("candidates API: " + 
config.candidatesApiUrl());
                   System.out.println("settings: " + config.settings());
                   System.out.println("security settings: " + 
config.securitySettings());
                   System.out.println("state file: " + config.stateFile());
                   System.out.println("platform: " + STATE_PLATFORM);
               }
               if (!candidateRegistered(config)) {
                   System.out.println("warn: candidate '" + config.candidate() 
+ "' is not registered on SDKMAN"
                           + " (missing from " + config.candidatesApiUrl() + 
"/candidates/all).");
                   System.out.println("      Complete the vendor onboarding 
first:"
                           + " 
https://github.com/sdkman/sdkman-cli/wiki/Vendor-onboarding-process";);
                   if (!config.dryRun()) {
                       throw new SyncException("candidate '" + 
config.candidate() + "' is not registered on SDKMAN;"
                               + " contact [email protected] to onboard the 
candidate and get your"
                               + " Consumer-Key/Consumer-Token, then re-run");
                   }
               }
               List<HopRelease> releases = discover(config);
               if (releases.isEmpty()) {
                   System.out.println("no releases found under " + 
config.archiveUrl());
                   return 0;
               }
               Set<String> local = loadPublished(config);
               Set<String> remote = remotePublished(config);
               List<HopRelease> pending = new ArrayList<>();
               for (HopRelease release : releases) {
                   if (!local.contains(release.version()) && 
!remote.contains(release.version())) {
                       pending.add(release);
                   }
               }
               if (config.dryRun()) {
                   System.out.println();
                   System.out.println("DRY-RUN - " + pending.size() + " 
release(s) would be published:");
                   for (HopRelease release : pending) {
                       System.out.println("  " + release.version() + "  " + 
release.zipUrl()
                               + (release.sha512() == null ? "  (no sha512 
checksum)" : ""));
                   }
                   System.out.println("skipped: " + (releases.size() - 
pending.size()) + " (already published or recorded)");
                   if (config.setDefault() != null) {
                       if (releases.stream().noneMatch(r -> 
r.version().equals(config.setDefault()))) {
                           System.out.println("warn: --set-default version '" + 
config.setDefault()
                                   + "' is not among the discovered releases");
                       } else {
                           System.out.println("would set default for " + 
config.candidate() + " " + config.setDefault());
                       }
                   }
                   return 0;
               }
               Credentials credentials = credentials(config);
               int published = 0;
               int skipped = 0;
               for (HopRelease release : releases) {
                   if (local.contains(release.version())) {
                       skipped++;
                       continue;
                   }
                   if (remote.contains(release.version())) {
                       skipped++;
                       System.out.println("skip: " + release.version() + " 
already published on SDKMAN (recording locally)");
                       recordPublished(config, release.version());
                       continue;
                   }
                   publish(config, credentials, release);
                   recordPublished(config, release.version());
                   published++;
               }
               if (config.setDefault() != null) {
                   if (releases.stream().noneMatch(r -> 
r.version().equals(config.setDefault()))) {
                       throw new SyncException("--set-default version '" + 
config.setDefault()
                               + "' is not among the discovered releases");
                   }
                   setDefault(config, credentials, config.setDefault());
                   System.out.println("default set: " + config.candidate() + " 
" + config.setDefault());
               }
               System.out.println();
               System.out.println("done: " + published + " published, " + 
skipped + " skipped,"
                       + " " + releases.size() + " release(s) in the archive");
               return 0;
           } catch (SyncException e) {
               System.err.println();
               System.err.println("error: " + e.getMessage());
               System.err.println("the run is resumable: fix the cause if any 
and re-run;"
                       + " versions already published are skipped");
               return 1;
           }
       }
   
       private static void printHelp() {
           System.out.println("""
                   Usage: java SdkManHopSync.java [options]
   
                   Publishes Apache Hop releases from the Apache distribution 
archive to SDKMAN!.
                   Each version directory of the archive must contain an
                   apache-hop-client-<version>.zip (the SDKMAN version is 
derived from the zip
                   filename, e.g. apache-hop-client-1.0.0-incubating.zip -> 
1.0.0-incubating);
                   the matching .sha512 sidecar is sent as the SHA-512 checksum 
of the release.
   
                   Options:
                     --archive-url <url>          archive base URL (default: 
https://archive.apache.org/dist/hop/)
                     --candidate <name>           SDKMAN candidate (default: 
hop)
                     --server-id <id>             id of the <server> holding 
the SDKMAN credentials in the
                                                  Maven settings (default: 
sdkman); <username> is the
                                                  Consumer-Key and <password> 
the Consumer-Token. A Maven
                                                  encrypted <password> ({...}) 
is decrypted with the master
                                                  of --security-settings, 
exactly like Maven does.
                     --settings <path>            Maven settings file (default: 
~/.m2/settings.xml)
                     --security-settings <path>   Maven settings-security file 
(default: ~/.m2/settings-security.xml)
                     --state <path>               local file recording 
published versions, one per line
                                                  (default: 
~/.cache/sdkman-hop-sync/published.txt)
                     --set-default <version>      after the release, set this 
version as the SDKMAN default
                     --master-passphrase <pwd>    passphrase to decrypt the 
<master> of settings-security
                                                  when it is itself 
Maven-encrypted (only needed if the
                                                  master was created with 
-Dsettings.security; Maven would
                                                  prompt for it interactively)
                     --vendor-api-url <url>       SDKMAN vendor API base URL 
(default: https://vendors.sdkman.io)
                     --candidates-api-url <url>   SDKMAN public API base URL 
(default: https://api.sdkman.io/2)
                     --dry-run                    discover releases and print 
what would be published; no
                                                  call to the vendor API, no 
credentials needed
                     --verify-settings            resolve and check the 
credentials, print a masked summary
                     --debug                      print the resolved 
configuration and vendor API responses
                     --help                       show this help
   
                   Environment fallback: when no <server> matches --server-id, 
the
                   SDKMAN_CONSUMER_KEY and SDKMAN_CONSUMER_TOKEN environment 
variables are used.
   
                   Example settings.xml server entry:
                     <server>
                       <id>sdkman</id>
                       <username>CONSUMER_KEY</username>
                       <password>{ENCRYPTED_CONSUMER_TOKEN}</password>
                     </server>
   
                   Behaviour:
                     - versions are processed in natural ascending order (2.9.0 
before 2.18.0)
                     - a version already recorded in --state or already visible 
on SDKMAN
                       (candidates/<candidate>/linux/versions/all) is skipped; 
the state file is
                       updated right after each successful release, so a failed 
run can simply
                       be re-run and only the missing versions are published 
(the vendor POST
                       itself is idempotent)
                     - any rejected request (4xx) or persistent failure aborts 
the run with a
                       clear message; retries happen on 429/5xx and network 
errors
   
                   Exit codes: 0 = ok, 1 = failure (safe to re-run), 2 = usage 
error.
                   """);
       }
   
       // ---------------------------------------------------------------------
       // Configuration
       // ---------------------------------------------------------------------
   
       private record Config(
               String archiveUrl,
               String candidate,
               String serverId,
               String vendorApiUrl,
               String candidatesApiUrl,
               Path settings,
               Path securitySettings,
               Path stateFile,
               String setDefault,
               String masterPassphrase,
               boolean dryRun,
               boolean verifyOnly,
               boolean debug,
               boolean help) {
   
           static Config parse(String... args) throws UsageException {
               String archiveUrl = DEFAULT_ARCHIVE_URL;
               String candidate = DEFAULT_CANDIDATE;
               String serverId = "sdkman";
               String vendorApiUrl = VENDOR_API;
               String candidatesApiUrl = CANDIDATES_API;
               Path settings = Path.of(resolveHome("~/.m2/settings.xml"));
               Path securitySettings = 
Path.of(resolveHome("~/.m2/settings-security.xml"));
               Path stateFile = 
Path.of(resolveHome("~/.cache/sdkman-hop-sync/published.txt"));
               String setDefault = null;
               String masterPassphrase = null;
               boolean dryRun = false;
               boolean verifyOnly = false;
               boolean debug = false;
               boolean help = false;
               for (int i = 0; i < args.length; i++) {
                   String arg = args[i];
                   switch (arg) {
                       case "--help" -> help = true;
                       case "--dry-run" -> dryRun = true;
                       case "--verify-settings" -> verifyOnly = true;
                       case "--debug" -> debug = true;
                       case "--archive-url" -> archiveUrl = value(args, ++i, 
arg);
                       case "--candidate" -> candidate = value(args, ++i, arg);
                       case "--server-id" -> serverId = value(args, ++i, arg);
                       case "--vendor-api-url" -> vendorApiUrl = 
stripTrailingSlash(value(args, ++i, arg));
                       case "--candidates-api-url" -> candidatesApiUrl = 
stripTrailingSlash(value(args, ++i, arg));
                       case "--settings" -> settings = 
Path.of(resolveHome(value(args, ++i, arg)));
                       case "--security-settings" -> securitySettings = 
Path.of(resolveHome(value(args, ++i, arg)));
                       case "--state" -> stateFile = 
Path.of(resolveHome(value(args, ++i, arg)));
                       case "--set-default" -> setDefault = value(args, ++i, 
arg);
                       case "--master-passphrase" -> masterPassphrase = 
value(args, ++i, arg);
                       default -> throw new UsageException("unknown option: " + 
arg);
                   }
               }
               return new Config(archiveUrl, candidate, serverId, vendorApiUrl, 
candidatesApiUrl,
                       settings, securitySettings, stateFile, setDefault, 
masterPassphrase,
                       dryRun, verifyOnly, debug, help);
           }
   
           private static String stripTrailingSlash(String url) {
               return url.endsWith("/") ? url.substring(0, url.length() - 1) : 
url;
           }
   
           private static String value(String[] args, int index, String option) 
throws UsageException {
               if (index >= args.length) {
                   throw new UsageException("missing value for " + option);
               }
               return args[index];
           }
       }
   
       private record Credentials(String key, String token, String source) {
       }
   
       private record HopRelease(String version, String zipUrl, String sha512) {
       }
   
       private static final class UsageException extends Exception {
           private UsageException(String message) {
               super(message);
           }
       }
   
       private static final class SyncException extends Exception {
           private SyncException(String message) {
               super(message);
           }
       }
   
       // ---------------------------------------------------------------------
       // Credentials (settings.xml + settings-security.xml, decryption
       // identical to Maven / plexus-cipher)
       // ---------------------------------------------------------------------
   
       private static Credentials credentials(Config config) throws 
SyncException {
           String envKey = System.getenv("SDKMAN_CONSUMER_KEY");
           String envToken = System.getenv("SDKMAN_CONSUMER_TOKEN");
           if (envKey != null || envToken != null) {
               if (envKey == null || envToken == null || envKey.isBlank() || 
envToken.isBlank()) {
                   throw new SyncException("SDKMAN_CONSUMER_KEY and 
SDKMAN_CONSUMER_TOKEN must both be set");
               }
               return new Credentials(envKey, envToken, "environment");
           }
           if (!Files.isRegularFile(config.settings())) {
               throw new SyncException("Maven settings file not found: " + 
config.settings()
                       + " (use --settings, or set 
SDKMAN_CONSUMER_KEY/SDKMAN_CONSUMER_TOKEN)");
           }
           Document settingsDoc = parseXml(config.settings(), "settings");
           NodeList servers = settingsDoc.getElementsByTagName("server");
           for (int i = 0; i < servers.getLength(); i++) {
               Element server = (Element) servers.item(i);
               if (config.serverId().equals(childText(server, "id"))) {
                   String key = childText(server, "username");
                   String token = childText(server, "password");
                   if (key == null || token == null || key.isBlank() || 
token.isBlank()) {
                       throw new SyncException("server '" + config.serverId() + 
"' in " + config.settings()
                               + " must define <username> (consumer key) and 
<password> (consumer token)");
                   }
                   return new Credentials(key, decryptIfNeeded(token, config),
                           "server '" + config.serverId() + "' (" + 
config.settings() + ")");
               }
           }
           throw new SyncException("no server with id '" + config.serverId() + 
"' in " + config.settings()
                   + " (add one, or set 
SDKMAN_CONSUMER_KEY/SDKMAN_CONSUMER_TOKEN)");
       }
   
       private static String decryptIfNeeded(String value, Config config) 
throws SyncException {
           if (!looksEncrypted(value)) {
               return value;
           }
           String master = loadMaster(config);
           try {
               return decryptPlexus(unDecorate(value), master);
           } catch (GeneralSecurityException e) {
               throw new SyncException("cannot decrypt the password of server 
'" + config.serverId()
                       + "' with the master of " + config.securitySettings() + 
": " + e.getMessage());
           }
       }
   
       private static String loadMaster(Config config) throws SyncException {
           if (!Files.isRegularFile(config.securitySettings())) {
               throw new SyncException("the password of server '" + 
config.serverId() + "' is Maven-encrypted but "
                       + config.securitySettings() + " is missing (create it 
with: mvn --encrypt-master-password)");
           }
           Document doc = parseXml(config.securitySettings(), 
"settings-security");
           String master = childText(doc.getDocumentElement(), "master");
           if (master == null || master.isBlank()) {
               throw new SyncException("no <master> found in " + 
config.securitySettings());
           }
           if (!looksEncrypted(master)) {
               return master;
           }
           if (config.masterPassphrase() != null) {
               try {
                   return decryptPlexus(unDecorate(master), 
config.masterPassphrase());
               } catch (GeneralSecurityException e) {
                   throw new SyncException("cannot decrypt <master> of " + 
config.securitySettings()
                           + " with --master-passphrase: " + e.getMessage());
               }
           }
           try {
               return decryptPlexus(unDecorate(master), MASTER_PASSPHRASE);
           } catch (GeneralSecurityException e) {
               throw new SyncException("cannot decrypt <master> of " + 
config.securitySettings() + ": " + e.getMessage()
                       + " (Maven would prompt for the master passphrase 
interactively; either pass it with"
                       + " --master-passphrase or re-create it with: mvn 
--encrypt-master-password)");
           }
       }
   
       private static boolean looksEncrypted(String value) {
           return value.length() > 2 && value.startsWith("{") && 
value.endsWith("}");
       }
   
       private static String unDecorate(String value) {
           return value.substring(1, value.length() - 1);
       }
   
       /**
        * Faithful port of the plexus-cipher format (PBECipher from 
plexus-cipher 2.0,
        * used by Maven for settings.xml):
        * {@code base64(salt(8) || padLen(1) || ciphertext)}, AES-128 key + IV 
derived
        * from SHA-256(password || salt[0..8)) the EVP_BytesToKey way.
        */
       static String decryptPlexus(String base64Value, String passphrase) 
throws GeneralSecurityException {
           byte[] all = Base64.getDecoder().decode(base64Value.trim());
           if (all.length < 16) {
               throw new GeneralSecurityException("encrypted payload too 
short");
           }
           byte[] salt = Arrays.copyOfRange(all, 0, 8);
           int padLen = all[8] & 0xFF;
           int payloadLength = all.length - 9 - padLen;
           if (payloadLength <= 0) {
               throw new GeneralSecurityException("invalid encrypted payload");
           }
           byte[] encrypted = Arrays.copyOfRange(all, 9, 9 + payloadLength);
           byte[] keyAndIv = 
deriveKeyAndIv(passphrase.getBytes(StandardCharsets.UTF_8), salt);
           Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
           cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyAndIv, 0, 16, 
"AES"),
                   new IvParameterSpec(keyAndIv, 16, 16));
           return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
       }
   
       private static byte[] deriveKeyAndIv(byte[] password, byte[] salt) 
throws GeneralSecurityException {
           byte[] keyAndIv = new byte[32];
           int position = 0;
           byte[] previous = null;
           while (position < keyAndIv.length) {
               MessageDigest digester = MessageDigest.getInstance("SHA-256");
               if (previous != null) {
                   digester.update(previous);
               }
               digester.update(password);
               digester.update(salt, 0, 8);
               previous = digester.digest();
               int copied = Math.min(previous.length, keyAndIv.length - 
position);
               System.arraycopy(previous, 0, keyAndIv, position, copied);
               position += copied;
           }
           return keyAndIv;
       }
   
       private static Document parseXml(Path file, String label) throws 
SyncException {
           try {
               DocumentBuilderFactory factory = 
DocumentBuilderFactory.newInstance();
               
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl";, 
true);
               
factory.setFeature("http://xml.org/sax/features/external-general-entities";, 
false);
               
factory.setFeature("http://xml.org/sax/features/external-parameter-entities";, 
false);
               factory.setXIncludeAware(false);
               factory.setExpandEntityReferences(false);
               return factory.newDocumentBuilder().parse(file.toFile());
           } catch (Exception e) {
               throw new SyncException("cannot parse " + label + " file " + 
file + ": " + e.getMessage());
           }
       }
   
       private static String childText(Element parent, String name) {
           NodeList nodes = parent.getElementsByTagName(name);
           if (nodes.getLength() == 0) {
               return null;
           }
           String text = nodes.item(0).getTextContent();
           return text == null ? null : text.trim();
       }
   
       // ---------------------------------------------------------------------
       // Published state (local file + public SDKMAN API)
       // ---------------------------------------------------------------------
   
       private static Set<String> loadPublished(Config config) throws 
SyncException {
           if (!Files.isRegularFile(config.stateFile())) {
               return Set.of();
           }
           try {
               Set<String> versions = new HashSet<>();
               for (String line : Files.readAllLines(config.stateFile(), 
StandardCharsets.UTF_8)) {
                   String version = line.trim();
                   if (!version.isEmpty() && !version.startsWith("#")) {
                       versions.add(version);
                   }
               }
               return versions;
           } catch (IOException e) {
               throw new SyncException("cannot read state file " + 
config.stateFile() + ": " + e.getMessage());
           }
       }
   
       private static void recordPublished(Config config, String version) 
throws SyncException {
           try {
               Path parent = config.stateFile().getParent();
               if (parent != null) {
                   Files.createDirectories(parent);
               }
               Files.writeString(config.stateFile(), version + 
System.lineSeparator(),
                       StandardCharsets.UTF_8, StandardOpenOption.CREATE, 
StandardOpenOption.APPEND);
           } catch (IOException e) {
               throw new SyncException("cannot write state file " + 
config.stateFile() + ": " + e.getMessage());
           }
       }
   
       private static Set<String> remotePublished(Config config) {
           Set<String> versions = new HashSet<>();
           try {
               String csv = getBody(config.candidatesApiUrl() + "/candidates/" 
+ config.candidate() + "/" + STATE_PLATFORM
                       + "/versions/all");
               for (String version : csv.split(",")) {
                   if (!version.isBlank()) {
                       versions.add(version.trim());
                   }
               }
           } catch (SyncException e) {
               System.out.println("warn: cannot query already published 
versions from SDKMAN (" + e.getMessage()
                       + "), relying on the local state file only");
           }
           return versions;
       }
   
       private static boolean candidateRegistered(Config config) {
           try {
               String list = getBody(config.candidatesApiUrl() + 
"/candidates/all");
               return 
Arrays.stream(list.split(",")).map(String::trim).anyMatch(config.candidate()::equals);
           } catch (SyncException e) {
               System.out.println("warn: cannot check candidate registration on 
SDKMAN (" + e.getMessage()
                       + "), assuming the candidate is registered");
               return true;
           }
       }
   
       // ---------------------------------------------------------------------
       // Archive discovery
       // ---------------------------------------------------------------------
   
       private static List<HopRelease> discover(Config config) throws 
SyncException {
           String base = config.archiveUrl().endsWith("/") ? 
config.archiveUrl() : config.archiveUrl() + "/";
           String index = getBody(base);
           Set<String> versionsSeen = new HashSet<>();
           List<HopRelease> releases = new ArrayList<>();
           for (String dir : hrefs(index).stream()
                   .filter(h -> h.endsWith("/"))
                   .filter(h -> !h.startsWith("/") && !h.startsWith("?") && 
!h.startsWith("http"))
                   .map(h -> h.substring(0, h.length() - 1))
                   .filter(h -> !h.isEmpty())
                   .sorted(SdkManHopSync::compareNatural)
                   .toList()) {
               String dirUrl = base + dir + "/";
               HttpResponse<String> page = get(dirUrl);
               if (page.statusCode() != 200) {
                   System.out.println("warn: cannot list " + dirUrl + " (HTTP " 
+ page.statusCode() + "), skipping");
                   continue;
               }
               String zip = hrefs(page.body()).stream()
                       .filter(f -> f.startsWith(ZIP_PREFIX) && 
f.endsWith(ZIP_SUFFIX))
                       .findFirst()
                       .orElse(null);
               if (zip == null) {
                   System.out.println("warn: no " + ZIP_PREFIX + "<version>" + 
ZIP_SUFFIX + " in " + dirUrl + ", skipping");
                   continue;
               }
               String version = zip.substring(ZIP_PREFIX.length(), zip.length() 
- ZIP_SUFFIX.length());
               if (!VERSION.matcher(version).matches()) {
                   System.out.println("warn: invalid version derived from " + 
zip + ", skipping");
                   continue;
               }
               if (!versionsSeen.add(version)) {
                   System.out.println("warn: duplicate version " + version + " 
(from " + dirUrl + "), keeping the first one");
                   continue;
               }
               String sha512 = null;
               HttpResponse<String> shaResponse = get(dirUrl + zip + ".sha512");
               if (shaResponse.statusCode() == 200) {
                   sha512 = parseSha512(shaResponse.body());
                   if (sha512 == null) {
                       System.out.println("warn: cannot parse the sha512 
sidecar of " + zip
                               + ", publishing without checksum");
                   }
               } else {
                   System.out.println("warn: no sha512 sidecar for " + zip + " 
(HTTP " + shaResponse.statusCode()
                           + "), publishing without checksum");
               }
               releases.add(new HopRelease(version, dirUrl + zip, sha512));
           }
           return releases;
       }
   
       private static List<String> hrefs(String html) {
           List<String> out = new ArrayList<>();
           var matcher = HREF.matcher(html);
           while (matcher.find()) {
               out.add(matcher.group(1));
           }
           return out;
       }
   
       private static String parseSha512(String content) {
           String first = content.trim().split("\\s+")[0];
           boolean hex = first.length() == 128
                   && first.chars().allMatch(c -> (c >= '0' && c <= '9') || (c 
>= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'));
           return hex ? first.toLowerCase() : null;
       }
   
       /** Natural sort: numeric segments compare numerically (2.9.0 < 2.18.0). 
*/
       private static int compareNatural(String left, String right) {
           int i = 0;
           int j = 0;
           while (i < left.length() && j < right.length()) {
               char lc = left.charAt(i);
               char rc = right.charAt(j);
               if (Character.isDigit(lc) && Character.isDigit(rc)) {
                   int startLeft = i;
                   int startRight = j;
                   while (i < left.length() && 
Character.isDigit(left.charAt(i))) {
                       i++;
                   }
                   while (j < right.length() && 
Character.isDigit(right.charAt(j))) {
                       j++;
                   }
                   String numberLeft = stripZeros(left.substring(startLeft, i));
                   String numberRight = stripZeros(right.substring(startRight, 
j));
                   int compared = numberLeft.length() != numberRight.length()
                           ? Integer.compare(numberLeft.length(), 
numberRight.length())
                           : numberLeft.compareTo(numberRight);
                   if (compared != 0) {
                       return compared;
                   }
               } else {
                   int compared = Character.compare(lc, rc);
                   if (compared != 0) {
                       return compared;
                   }
                   i++;
                   j++;
               }
           }
           return Integer.compare(left.length(), right.length());
       }
   
       private static String stripZeros(String number) {
           int firstNonZero = 0;
           while (firstNonZero < number.length() - 1 && 
number.charAt(firstNonZero) == '0') {
               firstNonZero++;
           }
           return number.substring(firstNonZero);
       }
   
       // ---------------------------------------------------------------------
       // Publication sur l'API vendor SDKMAN
       // ---------------------------------------------------------------------
   
       private static void publish(Config config, Credentials credentials, 
HopRelease release) throws SyncException {
           System.out.println("publishing " + config.candidate() + " " + 
release.version() + " from " + release.zipUrl());
           String response = sendJson("POST", config.vendorApiUrl() + 
RELEASE_PATH, credentials,
                   json(config.candidate(), release.version(), 
release.zipUrl(), release.sha512()));
           if (config.debug()) {
               System.out.println("  vendor API response: " + trimTo(response, 
500));
           }
       }
   
       private static void setDefault(Config config, Credentials credentials, 
String version) throws SyncException {
           String response = sendJson("PUT", config.vendorApiUrl() + 
DEFAULT_PATH, credentials,
                   json(config.candidate(), version, null, null));
           if (config.debug()) {
               System.out.println("  vendor API response: " + trimTo(response, 
500));
           }
       }
   
       private static String json(String candidate, String version, String url, 
String sha512) {
           StringBuilder builder = new StringBuilder();
           builder.append("{\"candidate\":\"").append(jsonEscape(candidate))
                   
.append("\",\"version\":\"").append(jsonEscape(version)).append('"');
           if (url != null) {
               
builder.append(",\"url\":\"").append(jsonEscape(url)).append('"');
           }
           if (sha512 != null) {
               
builder.append(",\"checksums\":{\"SHA-512\":\"").append(sha512).append("\"}");
           }
           return builder.append('}').toString();
       }
   
       private static String jsonEscape(String value) {
           return value.replace("\\", "\\\\").replace("\"", "\\\"");
       }
   
       private static String sendJson(String method, String url, Credentials 
credentials, String json)
               throws SyncException {
           HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
                   .timeout(Duration.ofMinutes(5))
                   .header("Consumer-Key", credentials.key())
                   .header("Consumer-Token", credentials.token())
                   .header("Content-Type", "application/json")
                   .header("Accept", "application/json")
                   .header("User-Agent", USER_AGENT);
           HttpRequest request = switch (method) {
               case "POST" -> 
builder.POST(HttpRequest.BodyPublishers.ofString(json)).build();
               case "PUT" -> 
builder.PUT(HttpRequest.BodyPublishers.ofString(json)).build();
               default -> throw new SyncException("unsupported method " + 
method);
           };
           SyncException last = null;
           for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
               try {
                   HttpResponse<String> response = HTTP.send(request, 
HttpResponse.BodyHandlers.ofString());
                   int status = response.statusCode();
                   if (status >= 200 && status < 300) {
                       return response.body();
                   }
                   String detail = trimTo(response.body(), 400);
                   if (status == 429 || status >= 500) {
                       if (attempt < MAX_ATTEMPTS) {
                           System.out.println("  got HTTP " + status + " 
(attempt " + attempt + "/" + MAX_ATTEMPTS
                                   + "), retrying in " + 
backoffSeconds(attempt) + "s");
                           sleepMillis(backoffSeconds(attempt) * 1000L);
                           continue;
                       }
                       throw new SyncException("SDKMAN vendor API error after " 
+ MAX_ATTEMPTS
                               + " attempts (HTTP " + status + "): " + detail);
                   }
                   throw new SyncException("SDKMAN vendor API rejected the 
request (HTTP " + status + "): " + detail);
               } catch (SyncException e) {
                   throw e;
               } catch (InterruptedException e) {
                   Thread.currentThread().interrupt();
                   throw new SyncException("interrupted");
               } catch (IOException e) {
                   last = new SyncException("HTTP " + method + " " + url + " 
failed: " + e.getMessage());
                   if (attempt < MAX_ATTEMPTS) {
                       sleepMillis(backoffSeconds(attempt) * 1000L);
                   }
               }
           }
           throw last;
       }
   
       private static HttpResponse<String> get(String url) throws SyncException 
{
           HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                   .timeout(Duration.ofMinutes(2))
                   .header("User-Agent", USER_AGENT)
                   .GET()
                   .build();
           SyncException last = null;
           for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
               try {
                   HttpResponse<String> response = HTTP.send(request, 
HttpResponse.BodyHandlers.ofString());
                   int status = response.statusCode();
                   if (status == 429 || status >= 500) {
                       if (attempt < MAX_ATTEMPTS) {
                           sleepMillis(backoffSeconds(attempt) * 1000L);
                           continue;
                       }
                   }
                   return response;
               } catch (InterruptedException e) {
                   Thread.currentThread().interrupt();
                   throw new SyncException("interrupted");
               } catch (IOException e) {
                   last = new SyncException("GET " + url + " failed: " + 
e.getMessage());
                   if (attempt < MAX_ATTEMPTS) {
                       sleepMillis(backoffSeconds(attempt) * 1000L);
                   }
               }
           }
           throw last;
       }
   
       private static String getBody(String url) throws SyncException {
           HttpResponse<String> response = get(url);
           if (response.statusCode() != 200) {
               throw new SyncException("GET " + url + " returned HTTP " + 
response.statusCode());
           }
           return response.body();
       }
   
       // ---------------------------------------------------------------------
       // Helpers
       // ---------------------------------------------------------------------
   
       private static String mask(String value) {
           if (value == null || value.isEmpty()) {
               return "(empty)";
           }
           if (value.length() <= 6) {
               return "****";
           }
           return value.substring(0, 3) + "..." + 
value.substring(value.length() - 3);
       }
   
       private static String trimTo(String value, int max) {
           if (value == null) {
               return "";
           }
           String trimmed = value.trim();
           if (trimmed.length() <= max) {
               return trimmed;
           }
           return trimmed.substring(0, max) + "...";
       }
   
       private static long backoffSeconds(int attempt) {
           return attempt == 1 ? 5 : 15;
       }
   
       private static void sleepMillis(long milliseconds) throws SyncException {
           try {
               Thread.sleep(milliseconds);
           } catch (InterruptedException e) {
               Thread.currentThread().interrupt();
               throw new SyncException("interrupted");
           }
       }
   
       private static String resolveHome(String path) {
           return path.startsWith("~/") ? System.getProperty("user.home") + 
path.substring(1) : path;
       }
   }
   ```


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to