| // VENDORED COPY - do not edit here. |
| // Source of truth: apache/camel |
| // dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java |
| // To update: re-copy that file over this one, then re-apply this banner. The body below is |
| // kept byte-identical to the upstream source so the sync stays a mechanical copy. |
| |
| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| // Intentionally in the default package so camel-package.sh can run this JDK-only source-file |
| // tool directly by path without compiling or loading a package-qualified launcher class. |
| |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.nio.charset.StandardCharsets; |
| import java.nio.file.AtomicMoveNotSupportedException; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.nio.file.Paths; |
| import java.nio.file.StandardCopyOption; |
| import java.security.MessageDigest; |
| import java.security.NoSuchAlgorithmException; |
| import java.util.Arrays; |
| import java.util.HexFormat; |
| import java.util.LinkedHashMap; |
| import java.util.LinkedHashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| /** |
| * JDK-only tool that computes SHA-256 checksums for a release TAR/ZIP pair and writes the |
| * website's four-field release manifest ({@code format}, {@code version}, {@code tar_sha256}, |
| * {@code zip_sha256}). Per-version manifests are immutable; {@code latest.properties} can only |
| * move forward to a higher semantic version and cannot silently change checksums for the same |
| * version. Writes are atomic. |
| * |
| * <p> |
| * Usage: {@code java WebsiteManifestGenerator.java --version X.Y.Z --tar <path> --zip <path> |
| * --output <dir> --latest <true|false>} |
| */ |
| public class WebsiteManifestGenerator { |
| |
| private static final Pattern VERSION_PATTERN = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)$"); |
| private static final Set<String> REQUIRED_OPTIONS |
| = new LinkedHashSet<>(List.of("--version", "--tar", "--zip", "--output", "--latest")); |
| private static final Set<String> OPTIONAL_OPTIONS = Set.of("--install-sh", "--install-ps1"); |
| private static final Set<String> MANIFEST_KEYS |
| = new LinkedHashSet<>(List.of("format", "version", "tar_sha256", "zip_sha256")); |
| private static final String MANIFEST_FORMAT = "1"; |
| |
| // Properties-style ASF license header prepended to every generated manifest. These '#' comment |
| // lines carry no data and are skipped by this tool's own strict re-parser (parseStrictManifest) |
| // and by the installers (install.sh / install.ps1), which tolerate a commented manifest. |
| private static final String LICENSE_HEADER |
| = "## ---------------------------------------------------------------------------\n" |
| + "## Licensed to the Apache Software Foundation (ASF) under one or more\n" |
| + "## contributor license agreements. See the NOTICE file distributed with\n" |
| + "## this work for additional information regarding copyright ownership.\n" |
| + "## The ASF licenses this file to You under the Apache License, Version 2.0\n" |
| + "## (the \"License\"); you may not use this file except in compliance with\n" |
| + "## the License. You may obtain a copy of the License at\n" |
| + "##\n" |
| + "## http://www.apache.org/licenses/LICENSE-2.0\n" |
| + "##\n" |
| + "## Unless required by applicable law or agreed to in writing, software\n" |
| + "## distributed under the License is distributed on an \"AS IS\" BASIS,\n" |
| + "## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n" |
| + "## See the License for the specific language governing permissions and\n" |
| + "## limitations under the License.\n" |
| + "## ---------------------------------------------------------------------------\n"; |
| |
| public static void main(String[] args) { |
| try { |
| run(args); |
| } catch (UsageException e) { |
| System.err.println("Error: " + e.getMessage()); |
| System.exit(2); |
| } catch (ConflictException | IOException e) { |
| System.err.println("Error: " + e.getMessage()); |
| System.exit(1); |
| } |
| } |
| |
| private static void run(String[] args) throws IOException { |
| Map<String, String> options = parseArgs(args); |
| |
| String version = options.get("--version"); |
| if (!VERSION_PATTERN.matcher(version).matches()) { |
| throw new UsageException("'" + version + "' is not a valid release version (expected X.Y.Z)."); |
| } |
| |
| Path tar = Paths.get(options.get("--tar")); |
| Path zip = Paths.get(options.get("--zip")); |
| if (!Files.isRegularFile(tar)) { |
| throw new UsageException("TAR artifact not found: " + tar); |
| } |
| if (!Files.isRegularFile(zip)) { |
| throw new UsageException("ZIP artifact not found: " + zip); |
| } |
| |
| Path installSh = null; |
| Path installPs1 = null; |
| String installShValue = options.get("--install-sh"); |
| if (installShValue != null) { |
| installSh = Paths.get(installShValue); |
| if (!Files.isRegularFile(installSh)) { |
| throw new UsageException("install.sh not found: " + installSh); |
| } |
| } |
| String installPs1Value = options.get("--install-ps1"); |
| if (installPs1Value != null) { |
| installPs1 = Paths.get(installPs1Value); |
| if (!Files.isRegularFile(installPs1)) { |
| throw new UsageException("install.ps1 not found: " + installPs1); |
| } |
| } |
| |
| String latestValue = options.get("--latest"); |
| boolean latest; |
| if ("true".equals(latestValue)) { |
| latest = true; |
| } else if ("false".equals(latestValue)) { |
| latest = false; |
| } else { |
| throw new UsageException("--latest must be 'true' or 'false' (got '" + latestValue + "')."); |
| } |
| |
| Path output = Paths.get(options.get("--output")); |
| |
| String tarSha256 = sha256Hex(tar); |
| String zipSha256 = sha256Hex(zip); |
| byte[] manifest = renderManifest(version, tarSha256, zipSha256); |
| |
| Path releasesDir = output.resolve("releases"); |
| Files.createDirectories(releasesDir); |
| writeVersionManifest(releasesDir.resolve(version + ".properties"), manifest, version); |
| |
| if (latest) { |
| writeLatestManifest(releasesDir.resolve("latest.properties"), manifest, version); |
| } |
| |
| if (installSh != null && installPs1 != null) { |
| writeInstallChecksums(output.getParent(), installSh, installPs1); |
| } |
| } |
| |
| private static Map<String, String> parseArgs(String[] args) { |
| Map<String, String> options = new LinkedHashMap<>(); |
| int i = 0; |
| while (i < args.length) { |
| String key = args[i]; |
| if (!REQUIRED_OPTIONS.contains(key) && !OPTIONAL_OPTIONS.contains(key)) { |
| throw new UsageException("unknown option '" + key + "'."); |
| } |
| if (i + 1 >= args.length) { |
| throw new UsageException("option '" + key + "' requires a value."); |
| } |
| if (options.containsKey(key)) { |
| throw new UsageException("option '" + key + "' was specified more than once."); |
| } |
| options.put(key, args[i + 1]); |
| i += 2; |
| } |
| for (String required : REQUIRED_OPTIONS) { |
| if (!options.containsKey(required)) { |
| throw new UsageException("missing required option '" + required + "'."); |
| } |
| } |
| return options; |
| } |
| |
| private static byte[] renderManifest(String version, String tarSha256, String zipSha256) { |
| String content = LICENSE_HEADER |
| + "format=" + MANIFEST_FORMAT + "\n" |
| + "version=" + version + "\n" |
| + "tar_sha256=" + tarSha256 + "\n" |
| + "zip_sha256=" + zipSha256 + "\n"; |
| return content.getBytes(StandardCharsets.UTF_8); |
| } |
| |
| private static void writeVersionManifest(Path versionFile, byte[] manifest, String version) throws IOException { |
| if (Files.exists(versionFile)) { |
| byte[] existing = Files.readAllBytes(versionFile); |
| if (Arrays.equals(existing, manifest)) { |
| return; |
| } |
| throw new ConflictException("version manifest for " + version + " already exists with different content" |
| + " (" + versionFile + ") - version manifests are immutable."); |
| } |
| atomicWrite(versionFile, manifest); |
| } |
| |
| private static void writeLatestManifest(Path latestFile, byte[] manifest, String version) throws IOException { |
| if (!Files.exists(latestFile)) { |
| atomicWrite(latestFile, manifest); |
| return; |
| } |
| |
| byte[] existing = Files.readAllBytes(latestFile); |
| if (Arrays.equals(existing, manifest)) { |
| return; |
| } |
| |
| Map<String, String> existingFields = parseStrictManifest(existing, latestFile); |
| String existingVersion = existingFields.get("version"); |
| int cmp = compareSemver(version, existingVersion); |
| if (cmp < 0) { |
| throw new ConflictException( |
| "latest.properties already points at a newer version (" + existingVersion + "); refusing to" |
| + " move backward to " + version + "."); |
| } else if (cmp == 0) { |
| throw new ConflictException("latest.properties already has different checksums for version " + version |
| + " - refusing to overwrite."); |
| } |
| atomicWrite(latestFile, manifest); |
| } |
| |
| // install.sh/install.ps1 aren't versioned the way the release archive is - they always describe |
| // "how to install whatever is currently latest," so unlike writeVersionManifest/writeLatestManifest |
| // there's no immutability or monotonic-version invariant to enforce here: this file is simply |
| // overwritten every release with the checksums of whatever install.sh/install.ps1 currently are. |
| private static void writeInstallChecksums(Path websiteRoot, Path installSh, Path installPs1) throws IOException { |
| String content = "install_sh_sha256=" + sha256Hex(installSh) + "\n" |
| + "install_ps1_sha256=" + sha256Hex(installPs1) + "\n"; |
| atomicWrite(websiteRoot.resolve("install.sha256"), content.getBytes(StandardCharsets.UTF_8)); |
| } |
| |
| private static Map<String, String> parseStrictManifest(byte[] bytes, Path source) { |
| String content = new String(bytes, StandardCharsets.UTF_8); |
| Map<String, String> fields = new LinkedHashMap<>(); |
| for (String line : content.split("\n", -1)) { |
| // Skip blank lines and '#' comment lines (e.g. the ASF license header this tool now |
| // prepends), so re-reading a previously generated latest.properties stays valid. |
| if (line.isEmpty() || line.startsWith("#")) { |
| continue; |
| } |
| int eq = line.indexOf('='); |
| if (eq < 0) { |
| throw new ConflictException("malformed manifest line in " + source + ": '" + line + "'."); |
| } |
| String key = line.substring(0, eq); |
| if (fields.containsKey(key)) { |
| throw new ConflictException("malformed manifest in " + source + ": duplicate key '" + key + "'."); |
| } |
| fields.put(key, line.substring(eq + 1)); |
| } |
| if (!fields.keySet().equals(MANIFEST_KEYS)) { |
| throw new ConflictException("malformed manifest in " + source + ": expected keys " + MANIFEST_KEYS |
| + " but found " + fields.keySet() + "."); |
| } |
| if (!MANIFEST_FORMAT.equals(fields.get("format"))) { |
| throw new ConflictException("malformed manifest in " + source + ": unsupported format '" |
| + fields.get("format") + "'."); |
| } |
| if (!VERSION_PATTERN.matcher(fields.get("version")).matches()) { |
| throw new ConflictException("malformed manifest in " + source + ": invalid version '" |
| + fields.get("version") + "'."); |
| } |
| assertSha256(fields.get("tar_sha256"), source, "tar_sha256"); |
| assertSha256(fields.get("zip_sha256"), source, "zip_sha256"); |
| return fields; |
| } |
| |
| private static void assertSha256(String value, Path source, String key) { |
| if (!value.matches("[0-9a-f]{64}")) { |
| throw new ConflictException("malformed manifest in " + source + ": invalid " + key + " '" + value + "'."); |
| } |
| } |
| |
| private static int compareSemver(String a, String b) { |
| int[] pa = semverParts(a); |
| int[] pb = semverParts(b); |
| for (int i = 0; i < 3; i++) { |
| int c = Integer.compare(pa[i], pb[i]); |
| if (c != 0) { |
| return c; |
| } |
| } |
| return 0; |
| } |
| |
| private static int[] semverParts(String version) { |
| Matcher m = VERSION_PATTERN.matcher(version); |
| if (!m.matches()) { |
| throw new ConflictException("invalid semantic version '" + version + "'."); |
| } |
| return new int[] { Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3)) }; |
| } |
| |
| private static void atomicWrite(Path target, byte[] bytes) throws IOException { |
| Path dir = target.toAbsolutePath().getParent(); |
| Path tmp = Files.createTempFile(dir, "wmg-", ".tmp"); |
| try { |
| Files.write(tmp, bytes); |
| try { |
| Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); |
| } catch (AtomicMoveNotSupportedException e) { |
| Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); |
| } |
| } finally { |
| Files.deleteIfExists(tmp); |
| } |
| } |
| |
| private static String sha256Hex(Path file) throws IOException { |
| MessageDigest digest; |
| try { |
| digest = MessageDigest.getInstance("SHA-256"); |
| } catch (NoSuchAlgorithmException e) { |
| throw new IllegalStateException("SHA-256 is required by the JDK and must always be available.", e); |
| } |
| try (InputStream in = Files.newInputStream(file)) { |
| byte[] buffer = new byte[8192]; |
| int read; |
| while ((read = in.read(buffer)) != -1) { |
| digest.update(buffer, 0, read); |
| } |
| } |
| return HexFormat.of().formatHex(digest.digest()); |
| } |
| |
| private static final class UsageException extends RuntimeException { |
| UsageException(String message) { |
| super(message); |
| } |
| } |
| |
| private static final class ConflictException extends RuntimeException { |
| ConflictException(String message) { |
| super(message); |
| } |
| } |
| } |