blob: 64ad8f1597b5b611b22dfc484f874df41c7d45b2 [file]
// 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);
}
}
}