From e873c1297211b174c6b707edde4a364f9069b24d Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:10:31 -0400 Subject: [PATCH 01/16] CAMEL-23703: camel-launcher - website installer HTTPS fixtures --- dsl/camel-jbang/camel-launcher/pom.xml | 6 + .../launcher/WebsiteInstallerFixture.java | 435 ++++++++++++++++++ .../launcher/WebsiteInstallerFixtureTest.java | 193 ++++++++ 3 files changed, 634 insertions(+) create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixtureTest.java diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index 3694faf67e92d..3f4dfc05605f7 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -283,6 +283,12 @@ junit-jupiter test + + org.apache.commons + commons-compress + ${commons-compress-version} + test + diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java new file mode 100644 index 0000000000000..02996043a6f4b --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java @@ -0,0 +1,435 @@ +/* + * 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. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.archivers.tar.TarConstants; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; + +/** + * Test fixture that starts a loopback-only HTTPS server serving website-installer manifests and archive payloads, plus + * builders for safe and deliberately malicious TAR/ZIP archives. Used to exercise install.sh / install.ps1 without ever + * contacting a production endpoint. + */ +final class WebsiteInstallerFixture implements AutoCloseable { + + private static final String KEYSTORE_PASSWORD = "camel-installer-test"; + private static final String KEY_ALIAS = "camel-installer-test"; + private static final String DEFAULT_BASE_VERSION = "9.9.9"; + private static final Duration PROCESS_TIMEOUT = Duration.ofSeconds(60); + + record Result(int exit, String stdout, String stderr) { + } + + private record ArchiveEntrySpec(String name, byte[] content, boolean directory, boolean executable, + String symlinkTarget) { + static ArchiveEntrySpec dir(String name) { + return new ArchiveEntrySpec(name, null, true, false, null); + } + + static ArchiveEntrySpec file(String name, byte[] content, boolean executable) { + return new ArchiveEntrySpec(name, content, false, executable, null); + } + + static ArchiveEntrySpec symlink(String name, String target) { + return new ArchiveEntrySpec(name, null, false, false, target); + } + } + + private final HttpsServer server; + private final Path tempDir; + private final Path caCertPem; + private final Map content = new ConcurrentHashMap<>(); + + private WebsiteInstallerFixture(HttpsServer server, Path tempDir, Path caCertPem) { + this.server = server; + this.tempDir = tempDir; + this.caCertPem = caCertPem; + } + + static WebsiteInstallerFixture start(Path temp) throws Exception { + Path keystore = temp.resolve("installer-test.p12"); + Path caCertPem = temp.resolve("installer-test-ca.pem"); + generateSelfSignedKeystore(keystore, caCertPem); + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (var in = Files.newInputStream(keystore)) { + keyStore.load(in, KEYSTORE_PASSWORD.toCharArray()); + } + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, KEYSTORE_PASSWORD.toCharArray()); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), null, new SecureRandom()); + + HttpsServer server = HttpsServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator(sslContext)); + + WebsiteInstallerFixture fixture = new WebsiteInstallerFixture(server, temp, caCertPem); + server.createContext("/", fixture::handle); + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + return fixture; + } + + private static void generateSelfSignedKeystore(Path keystore, Path caCertPem) throws Exception { + runTool(new ProcessBuilder( + keytool(), + "-genkeypair", + "-alias", KEY_ALIAS, + "-keyalg", "RSA", + "-keysize", "2048", + "-validity", "2", + "-keystore", keystore.toString(), + "-storetype", "PKCS12", + "-storepass", KEYSTORE_PASSWORD, + "-keypass", KEYSTORE_PASSWORD, + "-dname", "CN=127.0.0.1", + "-ext", "san=ip:127.0.0.1")); + + runTool(new ProcessBuilder( + keytool(), + "-exportcert", + "-alias", KEY_ALIAS, + "-keystore", keystore.toString(), + "-storetype", "PKCS12", + "-storepass", KEYSTORE_PASSWORD, + "-rfc", + "-file", caCertPem.toString())); + } + + private static String keytool() { + String exe = FakeJava.WINDOWS ? "keytool.exe" : "keytool"; + return Path.of(System.getProperty("java.home"), "bin", exe).toString(); + } + + private static void runTool(ProcessBuilder pb) throws Exception { + pb.redirectErrorStream(true); + Process process = pb.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IllegalStateException("keytool did not finish in time"); + } + if (process.exitValue() != 0) { + throw new IllegalStateException("keytool failed: " + output); + } + } + + String baseUrl() { + return "https://127.0.0.1:" + server.getAddress().getPort(); + } + + String mavenUrl() { + return baseUrl() + "/maven2/org/apache/camel/camel-launcher"; + } + + Path caCertificate() { + return caCertPem; + } + + Map environment(Path home) { + Map env = new HashMap<>(); + env.put("CAMEL_INSTALL_MANIFEST_BASE_URL", baseUrl() + "/camel-cli/releases"); + env.put("CAMEL_INSTALL_MAVEN_BASE_URL", mavenUrl()); + env.put("CAMEL_INSTALL_CA_CERT", caCertPem.toString()); + env.put("HOME", home.toString()); + env.put("USERPROFILE", home.toString()); + env.put("LOCALAPPDATA", home.resolve("AppData").resolve("Local").toString()); + return env; + } + + void publish(String urlPath, byte[] body) { + content.put(urlPath, body); + } + + void publishManifest(String urlPath, String version, Path tar, Path zip) throws Exception { + String manifest = "format=1\n" + + "version=" + version + "\n" + + "tar_sha256=" + sha256Hex(tar) + "\n" + + "zip_sha256=" + sha256Hex(zip) + "\n"; + publish(urlPath, manifest.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256Hex(Path file) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (var 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()); + } + + Path safeTar(String version) throws Exception { + Path archive = Files.createTempFile(tempDir, "safe-", "-" + version + ".tar.gz"); + writeTarGz(archive, launcherEntries(version)); + return archive; + } + + Path safeZip(String version) throws Exception { + Path archive = Files.createTempFile(tempDir, "safe-", "-" + version + ".zip"); + writeZip(archive, launcherEntries(version)); + return archive; + } + + Path maliciousTar(String entry) throws Exception { + List entries = new ArrayList<>(launcherEntries(DEFAULT_BASE_VERSION)); + entries.add(ArchiveEntrySpec.file(entry, "malicious".getBytes(StandardCharsets.UTF_8), false)); + Path archive = Files.createTempFile(tempDir, "malicious-", ".tar.gz"); + writeTarGz(archive, entries); + return archive; + } + + Path maliciousZip(String entry) throws Exception { + List entries = new ArrayList<>(launcherEntries(DEFAULT_BASE_VERSION)); + entries.add(ArchiveEntrySpec.file(entry, "malicious".getBytes(StandardCharsets.UTF_8), false)); + Path archive = Files.createTempFile(tempDir, "malicious-", ".zip"); + writeZip(archive, entries); + return archive; + } + + Path maliciousTarSymlink(String entry, String linkTarget) throws Exception { + List entries = new ArrayList<>(launcherEntries(DEFAULT_BASE_VERSION)); + entries.add(ArchiveEntrySpec.symlink(entry, linkTarget)); + Path archive = Files.createTempFile(tempDir, "malicious-symlink-", ".tar.gz"); + writeTarGz(archive, entries); + return archive; + } + + Path maliciousZipSymlink(String entry, String linkTarget) throws Exception { + List entries = new ArrayList<>(launcherEntries(DEFAULT_BASE_VERSION)); + entries.add(ArchiveEntrySpec.symlink(entry, linkTarget)); + Path archive = Files.createTempFile(tempDir, "malicious-symlink-", ".zip"); + writeZip(archive, entries); + return archive; + } + + private static List launcherEntries(String version) { + String root = "camel-launcher-" + version; + byte[] sh = shScript(version).getBytes(StandardCharsets.UTF_8); + byte[] bat = batScript(version).getBytes(StandardCharsets.UTF_8); + byte[] exe = exeScript(version).getBytes(StandardCharsets.UTF_8); + return List.of( + ArchiveEntrySpec.dir(root + "/"), + ArchiveEntrySpec.dir(root + "/bin/"), + ArchiveEntrySpec.file(root + "/bin/camel.sh", sh, true), + ArchiveEntrySpec.file(root + "/bin/camel.bat", bat, false), + ArchiveEntrySpec.file(root + "/bin/camel.exe", exe, true)); + } + + private static String shScript(String version) { + return "#!/bin/sh\n" + + "case \"$1\" in\n" + + " version) echo \"Camel " + version + "\"; exit 0 ;;\n" + + " echo-args) shift; echo \"$@\"; exit 0 ;;\n" + + " exit-code) exit \"$2\" ;;\n" + + "esac\n" + + "echo \"Camel " + version + "\"\n"; + } + + private static String batScript(String version) { + return "@echo off\r\n" + + "if \"%~1\"==\"version\" (echo Camel " + version + "& exit /b 0)\r\n" + + "if \"%~1\"==\"echo-args\" (shift & echo %*& exit /b 0)\r\n" + + "if \"%~1\"==\"exit-code\" (exit /b %2)\r\n" + + "echo Camel " + version + "\r\n"; + } + + private static String exeScript(String version) { + // Placeholder content for the fixture archive; behavioral Windows execution is covered + // separately where a real native camel.exe is required. + return "camel-launcher-" + version + "-test-exe\n"; + } + + private void writeTarGz(Path archive, List entries) throws IOException { + try (var fos = Files.newOutputStream(archive); + var gzos = new GzipCompressorOutputStream(fos); + var taos = new TarArchiveOutputStream(gzos)) { + taos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + taos.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_POSIX); + for (ArchiveEntrySpec spec : entries) { + writeTarEntry(taos, spec); + } + } + } + + private void writeTarEntry(TarArchiveOutputStream taos, ArchiveEntrySpec spec) throws IOException { + if (spec.symlinkTarget() != null) { + TarArchiveEntry entry = new TarArchiveEntry(spec.name(), TarConstants.LF_SYMLINK, true); + entry.setLinkName(spec.symlinkTarget()); + taos.putArchiveEntry(entry); + taos.closeArchiveEntry(); + return; + } + if (spec.directory()) { + String name = spec.name().endsWith("/") ? spec.name() : spec.name() + "/"; + taos.putArchiveEntry(new TarArchiveEntry(name, true)); + taos.closeArchiveEntry(); + return; + } + TarArchiveEntry entry = new TarArchiveEntry(spec.name(), true); + entry.setSize(spec.content().length); + entry.setMode(spec.executable() ? 0100755 : 0100644); + taos.putArchiveEntry(entry); + taos.write(spec.content()); + taos.closeArchiveEntry(); + } + + private void writeZip(Path archive, List entries) throws IOException { + try (var fos = Files.newOutputStream(archive); + var zaos = new ZipArchiveOutputStream(fos)) { + for (ArchiveEntrySpec spec : entries) { + writeZipEntry(zaos, spec); + } + } + } + + private void writeZipEntry(ZipArchiveOutputStream zaos, ArchiveEntrySpec spec) throws IOException { + if (spec.symlinkTarget() != null) { + ZipArchiveEntry entry = new ZipArchiveEntry(spec.name()); + entry.setUnixMode(0120777); // S_IFLNK | rwxrwxrwx + byte[] target = spec.symlinkTarget().getBytes(StandardCharsets.UTF_8); + zaos.putArchiveEntry(entry); + zaos.write(target); + zaos.closeArchiveEntry(); + return; + } + if (spec.directory()) { + String name = spec.name().endsWith("/") ? spec.name() : spec.name() + "/"; + zaos.putArchiveEntry(new ZipArchiveEntry(name)); + zaos.closeArchiveEntry(); + return; + } + ZipArchiveEntry entry = new ZipArchiveEntry(spec.name()); + entry.setUnixMode(spec.executable() ? 0100755 : 0100644); + zaos.putArchiveEntry(entry); + zaos.write(spec.content()); + zaos.closeArchiveEntry(); + } + + Result run(List command, Map env) throws Exception { + ProcessBuilder pb = new ProcessBuilder(command); + pb.environment().clear(); + pb.environment().put("PATH", System.getenv("PATH")); + if (FakeJava.WINDOWS) { + String sysRoot = System.getenv("SystemRoot"); + if (sysRoot != null) { + pb.environment().put("SystemRoot", sysRoot); + } + pb.environment().put("OS", "Windows_NT"); + String pathExt = System.getenv("PATHEXT"); + if (pathExt != null) { + pb.environment().put("PATHEXT", pathExt); + } + } + pb.environment().putAll(env); + + Process process = pb.start(); + ExecutorService collectors = Executors.newFixedThreadPool(2); + try { + Future stdout = collectors.submit(() -> process.getInputStream().readAllBytes()); + Future stderr = collectors.submit(() -> process.getErrorStream().readAllBytes()); + process.getOutputStream().close(); + if (!process.waitFor(PROCESS_TIMEOUT.toSeconds(), TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IllegalStateException("installer did not exit in time"); + } + String out = new String(await(stdout), StandardCharsets.UTF_8); + String err = new String(await(stderr), StandardCharsets.UTF_8); + return new Result(process.exitValue(), out, err); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + } + collectors.shutdownNow(); + collectors.awaitTermination(10, TimeUnit.SECONDS); + } + } + + private static byte[] await(Future collector) throws Exception { + try { + return collector.get(10, TimeUnit.SECONDS); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception exception) { + throw exception; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException("failed to collect installer output", cause); + } catch (TimeoutException e) { + throw new IllegalStateException("timed out collecting installer output", e); + } + } + + private void handle(HttpExchange exchange) throws IOException { + try { + byte[] body = content.get(exchange.getRequestURI().getPath()); + if (body == null) { + exchange.sendResponseHeaders(404, -1); + return; + } + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } finally { + exchange.close(); + } + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixtureTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixtureTest.java new file mode 100644 index 0000000000000..f7465579d7963 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixtureTest.java @@ -0,0 +1,193 @@ +/* + * 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. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.TrustManagerFactory; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Self-test for {@link WebsiteInstallerFixture}: confirms the HTTPS server enforces real certificate validation, + * unregistered paths 404, manifests are exactly four lines, safe archives have a single root, and every malicious + * archive contains the intended bad entry. + */ +class WebsiteInstallerFixtureTest { + + @Test + void requiresTrustedHttps(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp)) { + fixture.publish("/probe", "ok".getBytes(StandardCharsets.UTF_8)); + HttpsURLConnection untrusted = (HttpsURLConnection) new URL(fixture.baseUrl() + "/probe").openConnection(); + assertThrows(SSLHandshakeException.class, untrusted::getResponseCode); + } + } + + @Test + void unregisteredPathIs404(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp)) { + HttpsURLConnection conn = trustedConnection(fixture, "/does-not-exist"); + assertEquals(404, conn.getResponseCode()); + } + } + + @Test + void manifestHasExactFourLines(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp)) { + Path tar = fixture.safeTar("1.2.3"); + Path zip = fixture.safeZip("1.2.3"); + fixture.publishManifest("/camel-cli/releases/1.2.3.properties", "1.2.3", tar, zip); + + HttpsURLConnection conn = trustedConnection(fixture, "/camel-cli/releases/1.2.3.properties"); + assertEquals(200, conn.getResponseCode()); + String body; + try (InputStream in = conn.getInputStream()) { + body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + List lines = body.lines().filter(line -> !line.isBlank()).toList(); + assertEquals(4, lines.size(), body); + assertEquals("format=1", lines.get(0)); + assertEquals("version=1.2.3", lines.get(1)); + assertTrue(lines.get(2).matches("tar_sha256=[0-9a-f]{64}"), lines.get(2)); + assertTrue(lines.get(3).matches("zip_sha256=[0-9a-f]{64}"), lines.get(3)); + } + } + + @Test + void safeArchivesContainOneRoot(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp)) { + Path tar = fixture.safeTar("1.2.3"); + assertEquals(Set.of("camel-launcher-1.2.3"), topLevelEntries(readTarEntries(tar))); + + Path zip = fixture.safeZip("1.2.3"); + assertEquals(Set.of("camel-launcher-1.2.3"), topLevelEntries(readZipEntries(zip))); + } + } + + @Test + void maliciousArchivesContainIntendedEntry(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp)) { + Path absoluteTar = fixture.maliciousTar("/etc/passwd"); + assertTrue(readTarEntries(absoluteTar).contains("/etc/passwd")); + + Path traversalTar = fixture.maliciousTar("../escape"); + assertTrue(readTarEntries(traversalTar).contains("../escape")); + + Path multiRootTar = fixture.maliciousTar("evil-root/payload"); + assertTrue(readTarEntries(multiRootTar).contains("evil-root/payload")); + + Path symlinkTar = fixture.maliciousTarSymlink("camel-launcher-9.9.9/escape-link", "../../outside"); + assertTrue(readTarEntries(symlinkTar).contains("camel-launcher-9.9.9/escape-link")); + + Path absoluteZip = fixture.maliciousZip("/etc/passwd"); + assertTrue(readZipEntries(absoluteZip).contains("/etc/passwd")); + + Path traversalZip = fixture.maliciousZip("../escape"); + assertTrue(readZipEntries(traversalZip).contains("../escape")); + + Path multiRootZip = fixture.maliciousZip("evil-root/payload"); + assertTrue(readZipEntries(multiRootZip).contains("evil-root/payload")); + + Path symlinkZip = fixture.maliciousZipSymlink("camel-launcher-9.9.9/escape-link", "../../outside"); + assertTrue(readZipEntries(symlinkZip).contains("camel-launcher-9.9.9/escape-link")); + } + } + + private static HttpsURLConnection trustedConnection(WebsiteInstallerFixture fixture, String path) throws Exception { + HttpsURLConnection conn = (HttpsURLConnection) new URL(fixture.baseUrl() + path).openConnection(); + conn.setSSLSocketFactory(trustingContext(fixture.caCertificate()).getSocketFactory()); + return conn; + } + + private static SSLContext trustingContext(Path caCertPem) throws Exception { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + Certificate certificate; + try (InputStream in = Files.newInputStream(caCertPem)) { + certificate = certificateFactory.generateCertificate(in); + } + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + trustStore.setCertificateEntry("camel-installer-test", certificate); + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, tmf.getTrustManagers(), new SecureRandom()); + return context; + } + + private static List readTarEntries(Path archive) throws Exception { + List names = new ArrayList<>(); + try (InputStream fis = Files.newInputStream(archive); + GzipCompressorInputStream gzis = new GzipCompressorInputStream(fis); + TarArchiveInputStream tais = new TarArchiveInputStream(gzis)) { + TarArchiveEntry entry; + while ((entry = tais.getNextEntry()) != null) { + String name = entry.getName(); + names.add(name.endsWith("/") ? name.substring(0, name.length() - 1) : name); + } + } + return names; + } + + private static List readZipEntries(Path archive) throws Exception { + List names = new ArrayList<>(); + try (ZipFile zipFile = new ZipFile(archive.toFile())) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + String name = entries.nextElement().getName(); + names.add(name.endsWith("/") ? name.substring(0, name.length() - 1) : name); + } + } + return names; + } + + private static Set topLevelEntries(List names) { + Set roots = new LinkedHashSet<>(); + for (String name : names) { + int slash = name.indexOf('/'); + roots.add(slash == -1 ? name : name.substring(0, slash)); + } + return roots; + } +} From d3cd80205eabea32a8d86c68b89434c676cc1c46 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:34:12 -0400 Subject: [PATCH 02/16] CAMEL-23703: camel-launcher - secure POSIX website installer --- .../camel-launcher/src/install/install.sh | 272 +++++++++ .../jbang/launcher/WebsiteInstallShTest.java | 521 ++++++++++++++++++ .../launcher/WebsiteInstallerFixture.java | 37 +- 3 files changed, 826 insertions(+), 4 deletions(-) create mode 100755 dsl/camel-jbang/camel-launcher/src/install/install.sh create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallShTest.java diff --git a/dsl/camel-jbang/camel-launcher/src/install/install.sh b/dsl/camel-jbang/camel-launcher/src/install/install.sh new file mode 100755 index 0000000000000..5afadaf33bbbe --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/install/install.sh @@ -0,0 +1,272 @@ +#!/bin/sh +# +# 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. +# + +set -eu + +# Test seams only: production installs never set these, so the defaults below are always used. +manifest_base_url="${CAMEL_INSTALL_MANIFEST_BASE_URL:-https://camel.apache.org/camel-cli/releases}" +maven_base_url="${CAMEL_INSTALL_MAVEN_BASE_URL:-https://repo1.maven.org/maven2/org/apache/camel/camel-launcher}" +ca_cert="${CAMEL_INSTALL_CA_CERT:-}" + +data_root="${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli/versions" +camel_cli_root="${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli" +bin_dir="$HOME/.local/bin" + +fail() { + echo "install.sh: $1" 1>&2 + exit 1 +} + +is_valid_version() { + v="$1" + case "$v" in + '' | *[!0-9.]*) return 1 ;; + esac + old_ifs="$IFS" + IFS=. + set -- $v + IFS="$old_ifs" + [ $# -eq 3 ] && [ -n "$1" ] && [ -n "$2" ] && [ -n "$3" ] +} + +validate_sha256() { + value="$1" + label="$2" + [ ${#value} -eq 64 ] || fail "$label is not a 64-character lowercase hex value" + case "$value" in + *[!0-9a-f]*) fail "$label is not a 64-character lowercase hex value" ;; + esac +} + +fetch() { + url="$1" + outfile="$2" + if command -v curl >/dev/null 2>&1; then + if [ -n "$ca_cert" ]; then + curl -fsSL --cacert "$ca_cert" -o "$outfile" "$url" + else + curl -fsSL -o "$outfile" "$url" + fi + elif command -v wget >/dev/null 2>&1; then + if [ -n "$ca_cert" ]; then + wget -q --ca-certificate="$ca_cert" -O "$outfile" "$url" + else + wget -q -O "$outfile" "$url" + fi + else + fail "curl or wget is required to install the Camel CLI" + fi +} + +sha256() { + file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$file" | awk '{print $NF}' + else + fail "sha256sum, shasum, or openssl is required to verify the download" + fi +} + +# Reads $manifest_file line by line without ever sourcing, dot-invoking, or eval-ing its content. +parse_manifest() { + file="$1" + p_format="" + p_version="" + p_tar="" + p_zip="" + seen_format=0 + seen_version=0 + seen_tar=0 + seen_zip=0 + line_count=0 + while IFS='=' read -r key value || [ -n "$key" ]; do + line_count=$((line_count + 1)) + [ -n "$key" ] || fail "manifest contains a blank line" + [ -n "$value" ] || fail "manifest key '$key' has an empty value" + case "$key" in + format) + [ "$seen_format" -eq 0 ] || fail "manifest has duplicate key: format" + seen_format=1 + p_format="$value" + ;; + version) + [ "$seen_version" -eq 0 ] || fail "manifest has duplicate key: version" + seen_version=1 + p_version="$value" + ;; + tar_sha256) + [ "$seen_tar" -eq 0 ] || fail "manifest has duplicate key: tar_sha256" + seen_tar=1 + p_tar="$value" + ;; + zip_sha256) + [ "$seen_zip" -eq 0 ] || fail "manifest has duplicate key: zip_sha256" + seen_zip=1 + p_zip="$value" + ;; + *) + fail "manifest has unknown key: $key" + ;; + esac + done < "$file" + + if [ "$seen_format" -ne 1 ] || [ "$seen_version" -ne 1 ] || [ "$seen_tar" -ne 1 ] || [ "$seen_zip" -ne 1 ]; then + fail "manifest is missing a required key" + fi + [ "$line_count" -eq 4 ] || fail "manifest must contain exactly four lines" + + [ "$p_format" = "1" ] || fail "unsupported manifest format: $p_format" + is_valid_version "$p_version" || fail "manifest version is not a valid X.Y.Z value" + validate_sha256 "$p_tar" "manifest tar_sha256" + validate_sha256 "$p_zip" "manifest zip_sha256" +} + +# Lists archive entries with 'tar -tzf' and rejects absolute paths, traversal, symlinks/hardlinks, +# multiple top-level roots, and a missing expected launcher, before anything is extracted. +validate_tar() { + archive="$1" + version="$2" + expected_root="camel-launcher-$version" + + listing="$staging_dir/listing.txt" + tar -tzf "$archive" > "$listing" 2>/dev/null || fail "archive is not a valid tar.gz" + + verbose_listing="$staging_dir/listing-verbose.txt" + tar -tvzf "$archive" > "$verbose_listing" 2>/dev/null || fail "archive is not a valid tar.gz" + grep -F -- ' -> ' "$verbose_listing" >/dev/null 2>&1 \ + && fail "archive contains a symbolic or hard link entry, which is not allowed" + + roots="" + while IFS= read -r entry; do + [ -n "$entry" ] || continue + case "$entry" in + /*) fail "archive contains an absolute path entry: $entry" ;; + esac + case "$entry" in + *"../"* | "..") fail "archive contains a path traversal entry: $entry" ;; + esac + root="${entry%%/*}" + case " $roots " in + *" $root "*) ;; + *) roots="$roots $root" ;; + esac + done < "$listing" + + set -- $roots + [ $# -eq 1 ] || fail "archive must contain exactly one top-level directory" + [ "$1" = "$expected_root" ] || fail "archive top-level directory does not match expected version: $1" + + grep -Fqx "$expected_root/bin/camel.sh" "$listing" || fail "archive is missing bin/camel.sh" +} + +# Runs the freshly staged upstream launcher; a nonzero exit (e.g. no Java 17+ available) aborts +# the install and leaves the previously active installation untouched. +verify_staged() { + staged_sh="$1" + chmod +x "$staged_sh" 2>/dev/null || true + "$staged_sh" version >/dev/null 2>&1 || fail "staged launcher failed verification (Java 17+ required)" +} + +activate() { + version="$1" + staged_root_dir="$2" + target_dir="$data_root/$version" + + mkdir -p "$data_root" + rm -rf "$target_dir" + mv "$staged_root_dir" "$target_dir" + + mkdir -p "$bin_dir" + tmp_link="$bin_dir/.camel.tmp.$$" + rm -f "$tmp_link" + ln -s "$target_dir/bin/camel.sh" "$tmp_link" + mv -f "$tmp_link" "$bin_dir/camel" +} + +requested_version="" +while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || fail "Usage: install.sh [--version X.Y.Z]" + requested_version="$2" + shift 2 + ;; + *) + fail "Usage: install.sh [--version X.Y.Z]" + ;; + esac +done + +if [ -n "$requested_version" ]; then + is_valid_version "$requested_version" || fail "invalid --version value: $requested_version (expected X.Y.Z)" +fi + +staging_dir="$camel_cli_root/.staging.$$" +cleanup() { + rm -rf "$staging_dir" +} +trap cleanup EXIT INT TERM + +mkdir -p "$camel_cli_root" +rm -rf "$staging_dir" +mkdir -m 700 "$staging_dir" + +if [ -n "$requested_version" ]; then + manifest_url="$manifest_base_url/$requested_version.properties" +else + manifest_url="$manifest_base_url/latest.properties" +fi + +manifest_file="$staging_dir/manifest.properties" +fetch "$manifest_url" "$manifest_file" || fail "failed to download manifest from $manifest_url" + +parse_manifest "$manifest_file" +version="$p_version" + +if [ -n "$requested_version" ] && [ "$requested_version" != "$version" ]; then + fail "manifest version ($version) does not match requested version ($requested_version)" +fi + +archive_url="$maven_base_url/$version/camel-launcher-$version-bin.tar.gz" +archive_file="$staging_dir/camel-launcher-$version-bin.tar.gz" +fetch "$archive_url" "$archive_file" || fail "failed to download archive from $archive_url" + +actual_tar_sha256="$(sha256 "$archive_file")" +[ "$actual_tar_sha256" = "$p_tar" ] || fail "checksum mismatch for downloaded archive" + +validate_tar "$archive_file" "$version" + +extract_dir="$staging_dir/extract" +mkdir -p "$extract_dir" +tar -xzf "$archive_file" -C "$extract_dir" + +staged_root_dir="$extract_dir/camel-launcher-$version" +verify_staged "$staged_root_dir/bin/camel.sh" + +activate "$version" "$staged_root_dir" + +case ":$PATH:" in + *":$bin_dir:"*) ;; + *) echo "Add $bin_dir to your PATH to use the 'camel' command." ;; +esac + +echo "Installed Camel CLI $version to $data_root/$version" diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallShTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallShTest.java new file mode 100644 index 0000000000000..b873fdac04af4 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallShTest.java @@ -0,0 +1,521 @@ +/* + * 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. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Contract suite for the canonical POSIX website installer (install.sh). Every scenario runs the script against a + * loopback {@link WebsiteInstallerFixture} HTTPS server so no production endpoint is ever contacted. + */ +@DisabledOnOs(OS.WINDOWS) +class WebsiteInstallShTest { + + private static final Path SCRIPT = Paths.get("src/install/install.sh").toAbsolutePath(); + + @Test + void installsLatestVersion(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishLatest(fixture, "1.2.3"); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "1.2.3"); + } + } + + @Test + void installsExactVersion(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "2.5.0"); + + WebsiteInstallerFixture.Result r = install(fixture, home, "2.5.0"); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "2.5.0"); + } + } + + @Test + void honorsCustomXdgDataHome(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + Path xdg = Files.createDirectory(temp.resolve("xdg-data")); + publishLatest(fixture, "1.0.0"); + + Map env = new HashMap<>(fixture.environment(home)); + env.put("XDG_DATA_HOME", xdg.toString()); + WebsiteInstallerFixture.Result r = fixture.run(List.of("/bin/sh", SCRIPT.toString()), env); + + assertEquals(0, r.exit(), r.stderr()); + assertTrue(Files.isDirectory(xdg.resolve("camel-cli/versions/1.0.0")), r.stdout()); + assertTrue(Files.isSymbolicLink(home.resolve(".local/bin/camel"))); + } + } + + @Test + void handlesSpacesAndUnicodeInHome(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectories(temp.resolve("home dir/über")); + publishLatest(fixture, "1.0.0"); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "1.0.0"); + } + } + + @Test + void upgradeKeepsPreviousVersionDirectory(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "1.0.0"); + assertEquals(0, install(fixture, home, "1.0.0").exit()); + + publishVersion(fixture, "2.0.0"); + WebsiteInstallerFixture.Result r = install(fixture, home, "2.0.0"); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "2.0.0"); + assertTrue(Files.isDirectory(home.resolve(".local/share/camel-cli/versions/1.0.0")), "old version dir removed"); + } + } + + @Test + void downgradeToExplicitOlderVersionSucceeds(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "1.0.0"); + assertEquals(0, install(fixture, home, "1.0.0").exit()); + publishVersion(fixture, "2.0.0"); + assertEquals(0, install(fixture, home, "2.0.0").exit()); + + WebsiteInstallerFixture.Result r = install(fixture, home, "1.0.0"); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "1.0.0"); + assertTrue(Files.isDirectory(home.resolve(".local/share/camel-cli/versions/2.0.0"))); + } + } + + @Test + void reinstallingSameVersionSucceeds(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "1.0.0"); + + assertEquals(0, install(fixture, home, "1.0.0").exit()); + WebsiteInstallerFixture.Result r = install(fixture, home, "1.0.0"); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "1.0.0"); + } + } + + @Test + void printsPathGuidanceWhenBinDirMissingFromPath(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishLatest(fixture, "1.0.0"); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertEquals(0, r.exit(), r.stderr()); + assertTrue(r.stdout().contains(".local/bin"), r.stdout()); + assertTrue(r.stdout().toUpperCase().contains("PATH"), r.stdout()); + } + } + + @Test + void neverWritesShellProfileFiles(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishLatest(fixture, "1.0.0"); + + assertEquals(0, install(fixture, home, null).exit()); + + for (String profile : List.of(".bashrc", ".profile", ".bash_profile", ".zshrc")) { + assertFalse(Files.exists(home.resolve(profile)), profile + " must not be created"); + } + } + } + + @Test + void preservesActiveInstallWhenLaterInstallFails(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "1.0.0"); + assertEquals(0, install(fixture, home, "1.0.0").exit()); + + Path badTar = fixture.safeTar("2.0.0"); + Path badZip = fixture.safeZip("2.0.0"); + fixture.publish("/maven2/org/apache/camel/camel-launcher/2.0.0/camel-launcher-2.0.0-bin.tar.gz", + Files.readAllBytes(badTar)); + fixture.publish("/maven2/org/apache/camel/camel-launcher/2.0.0/camel-launcher-2.0.0-bin.zip", + Files.readAllBytes(badZip)); + String bogusHash = "0".repeat(64); + fixture.publish("/camel-cli/releases/2.0.0.properties", + ("format=1\nversion=2.0.0\ntar_sha256=" + bogusHash + "\nzip_sha256=" + bogusHash + "\n") + .getBytes(StandardCharsets.UTF_8)); + + WebsiteInstallerFixture.Result r = install(fixture, home, "2.0.0"); + + assertNotEquals(0, r.exit()); + assertVersionInstalled(home, "1.0.0"); + assertFalse(Files.exists(home.resolve(".local/share/camel-cli/versions/2.0.0"))); + } + } + + @Test + void rejectsUnknownFlag(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + WebsiteInstallerFixture.Result r = fixture.run( + List.of("/bin/sh", SCRIPT.toString(), "--bogus"), fixture.environment(home)); + + assertNotEquals(0, r.exit()); + } + } + + @Test + void rejectsMissingVersionValue(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + WebsiteInstallerFixture.Result r = fixture.run( + List.of("/bin/sh", SCRIPT.toString(), "--version"), fixture.environment(home)); + + assertNotEquals(0, r.exit()); + } + } + + @Test + void rejectsMalformedVersionArgument(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + WebsiteInstallerFixture.Result r = install(fixture, home, "1.2.3-SNAPSHOT"); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + } + } + + static Stream invalidManifests() { + String hash = "a".repeat(64); + return Stream.of( + Arguments.of("missing-key", "format=1\nversion=1.0.0\ntar_sha256=" + hash + "\n"), + Arguments.of("duplicate-key", + "format=1\nformat=1\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"), + Arguments.of("blank-line", + "format=1\n\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"), + Arguments.of("unknown-key", + "format=1\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\nextra=1\n"), + Arguments.of("bad-format", + "format=2\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"), + Arguments.of("bad-version", + "format=1\nversion=1.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"), + Arguments.of("bad-tar-hash", + "format=1\nversion=1.0.0\ntar_sha256=not-hex\nzip_sha256=" + hash + "\n"), + Arguments.of("bad-zip-hash", + "format=1\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=short\n")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidManifests") + void rejectsInvalidManifest(String name, String manifestBody, @TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + fixture.publish("/camel-cli/releases/latest.properties", + manifestBody.getBytes(StandardCharsets.UTF_8)); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertNotEquals(0, r.exit(), name); + assertFalse(Files.exists(home.resolve(".local/bin/camel")), name); + } + } + + @Test + void manifestInjectionNeverExecutesShellCode(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + String hash = "a".repeat(64); + String manifest = "format=1\nversion=$(touch owned)\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"; + fixture.publish("/camel-cli/releases/latest.properties", + manifest.getBytes(StandardCharsets.UTF_8)); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(home.resolve("owned")), "manifest value must never be executed"); + } + } + + @Test + void rejectsChecksumMismatch(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + Path tar = fixture.safeTar("1.0.0"); + Path zip = fixture.safeZip("1.0.0"); + publishRelease(fixture, "1.0.0", tar, zip); + String bogusHash = "0".repeat(64); + fixture.publish("/camel-cli/releases/latest.properties", + ("format=1\nversion=1.0.0\ntar_sha256=" + bogusHash + "\nzip_sha256=" + bogusHash + "\n") + .getBytes(StandardCharsets.UTF_8)); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + } + } + + @Test + void rejectsInterruptedDownload(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture")); + ServerSocket rawServer = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + Path home = Files.createDirectory(temp.resolve("home")); + Path tar = fixture.safeTar("1.0.0"); + Path zip = fixture.safeZip("1.0.0"); + fixture.publishManifest("/camel-cli/releases/latest.properties", "1.0.0", tar, zip); + + byte[] tarBytes = Files.readAllBytes(tar); + Thread server = new Thread(() -> serveTruncatedThenClose(rawServer, tarBytes)); + server.setDaemon(true); + server.start(); + + Map env = new HashMap<>(fixture.environment(home)); + env.put("CAMEL_INSTALL_MAVEN_BASE_URL", "http://127.0.0.1:" + rawServer.getLocalPort()); + + WebsiteInstallerFixture.Result r = fixture.run(List.of("/bin/sh", SCRIPT.toString()), env); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + } + } + + /** + * Accepts one raw (non-TLS) connection, writes an HTTP response that advertises a full Content-Length but abruptly + * closes the socket after only half the bytes, without ever reading the request. This reliably simulates an + * interrupted download, which the JDK's HttpsServer does not do when a handler under-writes a declared + * Content-Length. + */ + private static void serveTruncatedThenClose(ServerSocket rawServer, byte[] fullBody) { + try (Socket client = rawServer.accept()) { + String headers = "HTTP/1.1 200 OK\r\nContent-Length: " + fullBody.length + "\r\nConnection: close\r\n\r\n"; + OutputStream out = client.getOutputStream(); + out.write(headers.getBytes(StandardCharsets.US_ASCII)); + out.write(fullBody, 0, fullBody.length / 2); + out.flush(); + } catch (IOException ignored) { + // Best-effort: the client observing a truncated/reset connection is the point of the test. + } + } + + @Test + void rejectsWhenChecksumToolMissing(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishLatest(fixture, "1.0.0"); + + Map env = new HashMap<>(fixture.environment(home)); + env.put("PATH", pathWithout(temp.resolve("restricted-path"), "sha256sum", "shasum", "openssl").toString()); + + WebsiteInstallerFixture.Result r = fixture.run(List.of("/bin/sh", SCRIPT.toString()), env); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + } + } + + @Test + void rejectsAbsolutePathEntry(@TempDir Path temp) throws Exception { + assertMaliciousTarRejected(temp, fixture -> fixture.maliciousTar("/etc/passwd")); + } + + @Test + void rejectsTraversalEntry(@TempDir Path temp) throws Exception { + assertMaliciousTarRejected(temp, fixture -> fixture.maliciousTar("../escape")); + } + + @Test + void rejectsMultipleTopLevelRoots(@TempDir Path temp) throws Exception { + assertMaliciousTarRejected(temp, fixture -> fixture.maliciousTar("evil-root/payload")); + } + + @Test + void rejectsEscapingSymlinkEntry(@TempDir Path temp) throws Exception { + assertMaliciousTarRejected(temp, + fixture -> fixture.maliciousTarSymlink("camel-launcher-9.9.9/escape-link", "../../outside")); + } + + @Test + void rejectsArchiveMissingCamelSh(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + Path tar = fixture.safeTarMissingCamelSh("9.9.9"); + Path zip = fixture.safeZip("9.9.9"); + publishRelease(fixture, "9.9.9", tar, zip); + fixture.publishManifest("/camel-cli/releases/9.9.9.properties", "9.9.9", tar, zip); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.9"); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + } + } + + @Test + void rejectsWhenStagedLauncherFails(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + Path tar = fixture.safeTarWithFailingLauncher("9.9.9"); + Path zip = fixture.safeZip("9.9.9"); + publishRelease(fixture, "9.9.9", tar, zip); + fixture.publishManifest("/camel-cli/releases/9.9.9.properties", "9.9.9", tar, zip); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.9"); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + assertFalse(Files.exists(home.resolve(".local/share/camel-cli/versions/9.9.9"))); + } + } + + private interface MaliciousArchive { + Path build(WebsiteInstallerFixture fixture) throws Exception; + } + + private void assertMaliciousTarRejected(Path temp, MaliciousArchive archiveBuilder) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + Path tar = archiveBuilder.build(fixture); + Path zip = fixture.safeZip("9.9.9"); + publishRelease(fixture, "9.9.9", tar, zip); + fixture.publishManifest("/camel-cli/releases/9.9.9.properties", "9.9.9", tar, zip); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.9"); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + } + } + + private static WebsiteInstallerFixture.Result install(WebsiteInstallerFixture fixture, Path home, String version) + throws Exception { + List command = new ArrayList<>(List.of("/bin/sh", SCRIPT.toString())); + if (version != null) { + command.add("--version"); + command.add(version); + } + return fixture.run(command, fixture.environment(home)); + } + + private static void publishLatest(WebsiteInstallerFixture fixture, String version) throws Exception { + Path tar = fixture.safeTar(version); + Path zip = fixture.safeZip(version); + publishRelease(fixture, version, tar, zip); + fixture.publishManifest("/camel-cli/releases/latest.properties", version, tar, zip); + } + + private static void publishVersion(WebsiteInstallerFixture fixture, String version) throws Exception { + Path tar = fixture.safeTar(version); + Path zip = fixture.safeZip(version); + publishRelease(fixture, version, tar, zip); + fixture.publishManifest("/camel-cli/releases/" + version + ".properties", version, tar, zip); + } + + private static void publishRelease(WebsiteInstallerFixture fixture, String version, Path tar, Path zip) + throws Exception { + fixture.publish("/maven2/org/apache/camel/camel-launcher/" + version + "/camel-launcher-" + version + "-bin.tar.gz", + Files.readAllBytes(tar)); + fixture.publish("/maven2/org/apache/camel/camel-launcher/" + version + "/camel-launcher-" + version + "-bin.zip", + Files.readAllBytes(zip)); + } + + private static void assertVersionInstalled(Path home, String version) throws Exception { + Path shim = home.resolve(".local/bin/camel"); + assertTrue(Files.isSymbolicLink(shim), "expected a symlink at " + shim); + Path versionDir = home.resolve(".local/share/camel-cli/versions/" + version); + assertTrue(Files.isDirectory(versionDir), "expected version directory " + versionDir); + + Process check = new ProcessBuilder("/bin/sh", shim.toString(), "version") + .redirectErrorStream(true) + .start(); + String output = new String(check.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + check.waitFor(); + assertTrue(output.contains("Camel " + version), output); + } + + private static Path pathWithout(Path scratch, String... blockedNames) throws Exception { + Set blocked = new HashSet<>(List.of(blockedNames)); + Path bin = Files.createDirectories(scratch.resolve("bin-" + UUID.randomUUID())); + Set seen = new HashSet<>(); + String realPath = System.getenv("PATH"); + for (String dir : realPath.split(File.pathSeparator)) { + Path dirPath = Paths.get(dir); + if (!Files.isDirectory(dirPath)) { + continue; + } + try (var stream = Files.list(dirPath)) { + for (Path entry : (Iterable) stream::iterator) { + String name = entry.getFileName().toString(); + if (blocked.contains(name) || !seen.add(name) || !Files.isExecutable(entry)) { + continue; + } + Files.createSymbolicLink(bin.resolve(name), entry.toAbsolutePath()); + } + } catch (Exception ignored) { + // Unreadable directories are skipped; the goal is best-effort PATH reconstruction. + } + } + return bin; + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java index 02996043a6f4b..4aea81fd4556d 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java @@ -95,6 +95,7 @@ private WebsiteInstallerFixture(HttpsServer server, Path tempDir, Path caCertPem } static WebsiteInstallerFixture start(Path temp) throws Exception { + Files.createDirectories(temp); Path keystore = temp.resolve("installer-test.p12"); Path caCertPem = temp.resolve("installer-test-ca.pem"); generateSelfSignedKeystore(keystore, caCertPem); @@ -237,6 +238,23 @@ Path maliciousZip(String entry) throws Exception { return archive; } + Path safeTarWithFailingLauncher(String version) throws Exception { + Path archive = Files.createTempFile(tempDir, "failing-launcher-", "-" + version + ".tar.gz"); + writeTarGz(archive, launcherEntries(version, 1)); + return archive; + } + + Path safeTarMissingCamelSh(String version) throws Exception { + String root = "camel-launcher-" + version; + List entries = List.of( + ArchiveEntrySpec.dir(root + "/"), + ArchiveEntrySpec.dir(root + "/bin/"), + ArchiveEntrySpec.file(root + "/bin/camel.bat", batScript(version).getBytes(StandardCharsets.UTF_8), false)); + Path archive = Files.createTempFile(tempDir, "missing-camel-sh-", "-" + version + ".tar.gz"); + writeTarGz(archive, entries); + return archive; + } + Path maliciousTarSymlink(String entry, String linkTarget) throws Exception { List entries = new ArrayList<>(launcherEntries(DEFAULT_BASE_VERSION)); entries.add(ArchiveEntrySpec.symlink(entry, linkTarget)); @@ -254,8 +272,12 @@ Path maliciousZipSymlink(String entry, String linkTarget) throws Exception { } private static List launcherEntries(String version) { + return launcherEntries(version, 0); + } + + private static List launcherEntries(String version, int versionExitCode) { String root = "camel-launcher-" + version; - byte[] sh = shScript(version).getBytes(StandardCharsets.UTF_8); + byte[] sh = shScript(version, versionExitCode).getBytes(StandardCharsets.UTF_8); byte[] bat = batScript(version).getBytes(StandardCharsets.UTF_8); byte[] exe = exeScript(version).getBytes(StandardCharsets.UTF_8); return List.of( @@ -266,10 +288,10 @@ private static List launcherEntries(String version) { ArchiveEntrySpec.file(root + "/bin/camel.exe", exe, true)); } - private static String shScript(String version) { + private static String shScript(String version, int versionExitCode) { return "#!/bin/sh\n" + "case \"$1\" in\n" - + " version) echo \"Camel " + version + "\"; exit 0 ;;\n" + + " version) echo \"Camel " + version + "\"; exit " + versionExitCode + " ;;\n" + " echo-args) shift; echo \"$@\"; exit 0 ;;\n" + " exit-code) exit \"$2\" ;;\n" + "esac\n" @@ -372,6 +394,12 @@ Result run(List command, Map env) throws Exception { } } pb.environment().putAll(env); + String home = env.get("HOME"); + if (home != null && Files.isDirectory(Path.of(home))) { + // Any accidental relative-path side effect lands in the isolated test HOME rather than + // wherever the test JVM happens to be running from. + pb.directory(Path.of(home).toFile()); + } Process process = pb.start(); ExecutorService collectors = Executors.newFixedThreadPool(2); @@ -414,7 +442,8 @@ private static byte[] await(Future collector) throws Exception { private void handle(HttpExchange exchange) throws IOException { try { - byte[] body = content.get(exchange.getRequestURI().getPath()); + String path = exchange.getRequestURI().getPath(); + byte[] body = content.get(path); if (body == null) { exchange.sendResponseHeaders(404, -1); return; From 137b212ca2e8d898360258d2dc63c938aad93e49 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:49:06 -0400 Subject: [PATCH 03/16] CAMEL-23703: camel-launcher - secure PowerShell website installer Adds install.ps1 mirroring install.sh's security model (strict manifest parsing, pre-extraction archive validation, staged launcher verification, per-user activation) for Windows, plus its Windows-only JUnit contract suite. Registers a license-maven-plugin comment-style mapping for *.ps1 (SCRIPT_STYLE), which was missing project-wide. --- .../camel-launcher/src/install/install.ps1 | 282 +++++++++ .../WebsiteInstallPowerShellTest.java | 544 ++++++++++++++++++ .../launcher/WebsiteInstallerFixture.java | 81 ++- pom.xml | 1 + 4 files changed, 901 insertions(+), 7 deletions(-) create mode 100644 dsl/camel-jbang/camel-launcher/src/install/install.ps1 create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallPowerShellTest.java diff --git a/dsl/camel-jbang/camel-launcher/src/install/install.ps1 b/dsl/camel-jbang/camel-launcher/src/install/install.ps1 new file mode 100644 index 0000000000000..e2ffed1013abe --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/install/install.ps1 @@ -0,0 +1,282 @@ +# +# 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. +# + +param( + [string] $Version +) + +$ErrorActionPreference = 'Stop' + +# Test seams only: production installs never set these, so the defaults below are always used. +$ManifestBaseUrl = if ($env:CAMEL_INSTALL_MANIFEST_BASE_URL) { $env:CAMEL_INSTALL_MANIFEST_BASE_URL } else { 'https://camel.apache.org/camel-cli/releases' } +$MavenBaseUrl = if ($env:CAMEL_INSTALL_MAVEN_BASE_URL) { $env:CAMEL_INSTALL_MAVEN_BASE_URL } else { 'https://repo1.maven.org/maven2/org/apache/camel/camel-launcher' } +$CaCertPath = $env:CAMEL_INSTALL_CA_CERT + +$InstallRoot = Join-Path $env:LOCALAPPDATA 'Apache Camel' +$DataRoot = Join-Path $InstallRoot 'cli\versions' +$BinDir = Join-Path $InstallRoot 'bin' + +function Fail { + param([string] $Message) + [Console]::Error.WriteLine("install.ps1: $Message") + exit 1 +} + +function Test-ValidVersion { + param([string] $Value) + return $Value -match '\A[0-9]+\.[0-9]+\.[0-9]+\z' +} + +function Test-ValidSha256 { + param([string] $Value, [string] $Label) + if ($Value -notmatch '\A[0-9a-f]{64}\z') { + Fail "$Label is not a 64-character lowercase hex value" + } +} + +if ($CaCertPath) { + # Test seam only: trusts the loopback fixture's self-signed CA for this process without touching + # the real Windows certificate store. Production installs never set CAMEL_INSTALL_CA_CERT. + $installerCaCert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CaCertPath) + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { + param($sender, $certificate, $chain, $sslPolicyErrors) + $verifyChain = New-Object System.Security.Cryptography.X509Certificates.X509Chain + $verifyChain.ChainPolicy.ExtraStore.Add($installerCaCert) | Out-Null + $verifyChain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck + $verifyChain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority + $leaf = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certificate) + if (-not $verifyChain.Build($leaf)) { + return $false + } + $root = $verifyChain.ChainElements[$verifyChain.ChainElements.Count - 1].Certificate + return $root.Thumbprint -eq $installerCaCert.Thumbprint + }.GetNewClosure() +} + +# Downloads $Url to $OutFile; used for both the manifest and archive fetches. +function Get-Manifest { + param([string] $Url, [string] $OutFile) + try { + Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing | Out-Null + } catch { + Fail "failed to download $Url" + } +} + +# Reads $Path line by line without ever dot-sourcing, invoking, or evaluating its content. +function Read-Manifest { + param([string] $Path) + + $lines = @(Get-Content -LiteralPath $Path -Encoding UTF8) + if ($lines.Count -ne 4) { + Fail "manifest must contain exactly four lines" + } + + $known = @('format', 'version', 'tar_sha256', 'zip_sha256') + $values = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([StringComparer]::OrdinalIgnoreCase) + foreach ($line in $lines) { + if ([string]::IsNullOrEmpty($line)) { + Fail "manifest contains a blank line" + } + $parts = $line.Split('=', 2) + if ($parts.Count -ne 2 -or [string]::IsNullOrEmpty($parts[0])) { + Fail "manifest contains a blank line" + } + $key = $parts[0] + $value = $parts[1] + if ([string]::IsNullOrEmpty($value)) { + Fail "manifest key '$key' has an empty value" + } + if ($values.ContainsKey($key)) { + Fail "manifest has duplicate key: $key" + } + if ($known -notcontains $key.ToLowerInvariant()) { + Fail "manifest has unknown key: $key" + } + $values[$key] = $value + } + + foreach ($required in $known) { + if (-not $values.ContainsKey($required)) { + Fail "manifest is missing a required key" + } + } + + if ($values['format'] -ne '1') { + Fail "unsupported manifest format: $($values['format'])" + } + if (-not (Test-ValidVersion $values['version'])) { + Fail "manifest version is not a valid X.Y.Z value" + } + Test-ValidSha256 $values['tar_sha256'] 'manifest tar_sha256' + Test-ValidSha256 $values['zip_sha256'] 'manifest zip_sha256' + + return $values +} + +# Lists archive entries via System.IO.Compression before Expand-Archive ever runs, and rejects absolute +# paths, traversal, symlink/reparse-point entries, multiple top-level roots, and a missing launcher. +function Test-ArchiveEntry { + param([string] $ArchivePath, [string] $Version) + + $expectedRoot = "camel-launcher-$Version" + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) + try { + $roots = New-Object 'System.Collections.Generic.HashSet[string]' + $foundExe = $false + foreach ($entry in $zip.Entries) { + $name = $entry.FullName + if ([string]::IsNullOrEmpty($name)) { + continue + } + if ($name.StartsWith('/') -or $name.StartsWith('\') -or ($name.Length -ge 2 -and $name[1] -eq ':')) { + Fail "archive contains an absolute path entry: $name" + } + $normalized = $name.Replace('\', '/') + $segments = $normalized.Split('/') + if ($segments -contains '..') { + Fail "archive contains a path traversal entry: $name" + } + $unixMode = ([uint32]$entry.ExternalAttributes -shr 16) -band 0xF000 + if ($unixMode -eq 0xA000) { + Fail "archive contains a symbolic link or reparse point entry, which is not allowed" + } + [void]$roots.Add($segments[0]) + if ($normalized -eq "$expectedRoot/bin/camel.exe") { + $foundExe = $true + } + } + if ($roots.Count -ne 1) { + Fail "archive must contain exactly one top-level directory" + } + if (-not $roots.Contains($expectedRoot)) { + Fail "archive top-level directory does not match expected version: $($roots -join ',')" + } + if (-not $foundExe) { + Fail "archive is missing bin\camel.exe" + } + } finally { + $zip.Dispose() + } +} + +# Runs the freshly staged upstream launcher; a nonzero exit (e.g. no Java 17+ available) aborts the +# install and leaves the previously active installation untouched. +function Test-StagedLauncher { + param([string] $ExePath) + try { + & $ExePath 'version' *> $null + } catch { + Fail "staged launcher failed verification (Java 17+ required)" + } + if ($LASTEXITCODE -ne 0) { + Fail "staged launcher failed verification (Java 17+ required)" + } +} + +function Set-CamelShim { + param([string] $Version, [string] $StagedRoot) + + $targetDir = Join-Path $DataRoot $Version + New-Item -ItemType Directory -Force -Path $DataRoot | Out-Null + if (Test-Path -LiteralPath $targetDir) { + Remove-Item -LiteralPath $targetDir -Recurse -Force + } + Move-Item -LiteralPath $StagedRoot -Destination $targetDir + + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + $exePath = Join-Path $targetDir 'bin\camel.exe' + $shimContent = "@echo off`r`n`"$exePath`" %*`r`nexit /b %ERRORLEVEL%`r`n" + $tempShim = Join-Path $BinDir ".camel.$PID.tmp.cmd" + Set-Content -LiteralPath $tempShim -Value $shimContent -NoNewline -Encoding UTF8 + $finalShim = Join-Path $BinDir 'camel.cmd' + Move-Item -LiteralPath $tempShim -Destination $finalShim -Force +} + +# Adds $Dir once, case-insensitively, to the current user's PATH (registry-level, no elevation) and to +# this process; the machine PATH is never written. +function Add-UserPath { + param([string] $Dir) + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @() + if ($userPath) { + $entries = $userPath.Split(';') | Where-Object { $_ -ne '' } + } + $present = $entries | Where-Object { $_.TrimEnd('\') -ieq $Dir.TrimEnd('\') } + if (-not $present) { + $newPath = if ($entries.Count -gt 0) { ($entries + $Dir) -join ';' } else { $Dir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + } + if (($env:Path -split ';') -notcontains $Dir) { + $env:Path = "$env:Path;$Dir" + } +} + +if ($Version -and -not (Test-ValidVersion $Version)) { + Fail "invalid -Version value: $Version (expected X.Y.Z)" +} + +New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null +$stagingRoot = Join-Path $InstallRoot ("staging." + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $stagingRoot | Out-Null + +try { + if ($Version) { + $manifestUrl = "$ManifestBaseUrl/$Version.properties" + } else { + $manifestUrl = "$ManifestBaseUrl/latest.properties" + } + $manifestFile = Join-Path $stagingRoot 'manifest.properties' + Get-Manifest -Url $manifestUrl -OutFile $manifestFile + + $manifest = Read-Manifest -Path $manifestFile + $resolvedVersion = $manifest['version'] + + if ($Version -and $Version -ne $resolvedVersion) { + Fail "manifest version ($resolvedVersion) does not match requested version ($Version)" + } + + $archiveUrl = "$MavenBaseUrl/$resolvedVersion/camel-launcher-$resolvedVersion-bin.zip" + $archiveFile = Join-Path $stagingRoot "camel-launcher-$resolvedVersion-bin.zip" + Get-Manifest -Url $archiveUrl -OutFile $archiveFile + + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archiveFile).Hash.ToLowerInvariant() + if ($actualHash -ne $manifest['zip_sha256']) { + Fail "checksum mismatch for downloaded archive" + } + + Test-ArchiveEntry -ArchivePath $archiveFile -Version $resolvedVersion + + $extractDir = Join-Path $stagingRoot 'extract' + Expand-Archive -LiteralPath $archiveFile -DestinationPath $extractDir -Force + + $stagedRoot = Join-Path $extractDir "camel-launcher-$resolvedVersion" + $stagedExe = Join-Path $stagedRoot 'bin\camel.exe' + Test-StagedLauncher -ExePath $stagedExe + + Set-CamelShim -Version $resolvedVersion -StagedRoot $stagedRoot + Add-UserPath -Dir $BinDir + + Write-Host "Installed Camel CLI $resolvedVersion to $(Join-Path $DataRoot $resolvedVersion)" +} finally { + if (Test-Path -LiteralPath $stagingRoot) { + Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallPowerShellTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallPowerShellTest.java new file mode 100644 index 0000000000000..709dba68c8fd7 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallPowerShellTest.java @@ -0,0 +1,544 @@ +/* + * 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. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Contract suite for the canonical PowerShell website installer (install.ps1). Every scenario runs the script against a + * loopback {@link WebsiteInstallerFixture} HTTPS server so no production endpoint is ever contacted. Windows-only: + * {@code [Environment]::SetEnvironmentVariable(..., 'User')} touches the real per-user registry environment of the + * account running the test, so every test that installs registers its bin directory for removal in + * {@link #restoreUserPath()}. + */ +@EnabledOnOs(OS.WINDOWS) +class WebsiteInstallPowerShellTest { + + private static final Path SCRIPT = Paths.get("src/install/install.ps1").toAbsolutePath(); + + private final List binDirsForCleanup = new ArrayList<>(); + + @AfterEach + void restoreUserPath() throws Exception { + if (binDirsForCleanup.isEmpty()) { + return; + } + StringBuilder script = new StringBuilder("$dirs = @("); + for (int i = 0; i < binDirsForCleanup.size(); i++) { + if (i > 0) { + script.append(','); + } + script.append('\'').append(binDirsForCleanup.get(i).replace("'", "''")).append('\''); + } + script.append("); $path = [Environment]::GetEnvironmentVariable('Path','User'); if ($path) { " + + "$kept = ($path -split ';') | Where-Object { $_ -and (-not ($dirs -icontains $_)) }; " + + "[Environment]::SetEnvironmentVariable('Path', ($kept -join ';'), 'User') }"); + Process cleanup = new ProcessBuilder("powershell", "-NoProfile", "-Command", script.toString()) + .redirectErrorStream(true).start(); + cleanup.waitFor(30, TimeUnit.SECONDS); + } + + @Test + void installsLatestVersion(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishLatest(fixture, "1.2.3"); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "1.2.3"); + } + } + + @Test + void installsExactVersion(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "2.5.0"); + + WebsiteInstallerFixture.Result r = install(fixture, home, "2.5.0"); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "2.5.0"); + } + } + + @Test + void handlesSpacesAndUnicodeInLocalAppData(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectories(temp.resolve("home dir/über")); + publishLatest(fixture, "1.0.0"); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "1.0.0"); + } + } + + @Test + void upgradeKeepsPreviousVersionDirectory(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "1.0.0"); + assertEquals(0, install(fixture, home, "1.0.0").exit()); + + publishVersion(fixture, "2.0.0"); + WebsiteInstallerFixture.Result r = install(fixture, home, "2.0.0"); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "2.0.0"); + assertTrue(Files.isDirectory(versionDir(home, "1.0.0")), "old version dir removed"); + } + } + + @Test + void downgradeToExplicitOlderVersionSucceeds(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "1.0.0"); + assertEquals(0, install(fixture, home, "1.0.0").exit()); + publishVersion(fixture, "2.0.0"); + assertEquals(0, install(fixture, home, "2.0.0").exit()); + + WebsiteInstallerFixture.Result r = install(fixture, home, "1.0.0"); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "1.0.0"); + assertTrue(Files.isDirectory(versionDir(home, "2.0.0"))); + } + } + + @Test + void reinstallingSameVersionSucceeds(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "1.0.0"); + + assertEquals(0, install(fixture, home, "1.0.0").exit()); + WebsiteInstallerFixture.Result r = install(fixture, home, "1.0.0"); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "1.0.0"); + } + } + + @Test + void addsUserPathOnceAndIsIdempotent(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishLatest(fixture, "1.0.0"); + String binDir = expectedBinDir(home).toString(); + + assertEquals(0, install(fixture, home, null).exit()); + assertEquals(1, countOccurrencesCaseInsensitive(queryEnvironmentPath("User"), binDir)); + + assertEquals(0, install(fixture, home, null).exit()); + assertEquals(1, countOccurrencesCaseInsensitive(queryEnvironmentPath("User"), binDir)); + } + } + + @Test + void neverWritesMachinePath(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishLatest(fixture, "1.0.0"); + String before = queryEnvironmentPath("Machine"); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertEquals(0, r.exit(), r.stderr()); + assertEquals(before, queryEnvironmentPath("Machine"), "machine PATH must never be modified"); + } + } + + @Test + void forwardsArgumentsAndExitCodeThroughShim(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishLatest(fixture, "1.0.0"); + assertEquals(0, install(fixture, home, null).exit()); + + Path shim = expectedBinDir(home).resolve("camel.cmd"); + + Process argsProc = new ProcessBuilder("cmd.exe", "/c", shim.toString(), "echo-args", "foo", "bar baz") + .redirectErrorStream(true).start(); + String argsOut = new String(argsProc.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + argsProc.waitFor(30, TimeUnit.SECONDS); + assertTrue(argsOut.contains("foo") && argsOut.contains("bar baz"), argsOut); + + Process exitProc = new ProcessBuilder("cmd.exe", "/c", shim.toString(), "exit-code", "7") + .redirectErrorStream(true).start(); + exitProc.waitFor(30, TimeUnit.SECONDS); + assertEquals(7, exitProc.exitValue()); + } + } + + @Test + void preservesActiveInstallWhenLaterInstallFails(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "1.0.0"); + assertEquals(0, install(fixture, home, "1.0.0").exit()); + + Path badTar = fixture.safeTar("2.0.0"); + Path badZip = fixture.safeZip("2.0.0"); + fixture.publish("/maven2/org/apache/camel/camel-launcher/2.0.0/camel-launcher-2.0.0-bin.tar.gz", + Files.readAllBytes(badTar)); + fixture.publish("/maven2/org/apache/camel/camel-launcher/2.0.0/camel-launcher-2.0.0-bin.zip", + Files.readAllBytes(badZip)); + String bogusHash = "0".repeat(64); + fixture.publish("/camel-cli/releases/2.0.0.properties", + ("format=1\nversion=2.0.0\ntar_sha256=" + bogusHash + "\nzip_sha256=" + bogusHash + "\n") + .getBytes(StandardCharsets.UTF_8)); + + WebsiteInstallerFixture.Result r = install(fixture, home, "2.0.0"); + + assertNotEquals(0, r.exit()); + assertVersionInstalled(home, "1.0.0"); + assertFalse(Files.exists(versionDir(home, "2.0.0"))); + } + } + + @Test + void rejectsUnknownParameter(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + WebsiteInstallerFixture.Result r = fixture.run( + List.of("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", SCRIPT.toString(), + "-Bogus", "value"), + fixture.environment(home)); + + assertNotEquals(0, r.exit()); + } + } + + @Test + void rejectsMissingVersionValue(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + WebsiteInstallerFixture.Result r = fixture.run( + List.of("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", SCRIPT.toString(), + "-Version"), + fixture.environment(home)); + + assertNotEquals(0, r.exit()); + } + } + + @Test + void rejectsMalformedVersionArgument(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + WebsiteInstallerFixture.Result r = install(fixture, home, "1.2.3-SNAPSHOT"); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(expectedBinDir(home).resolve("camel.cmd"))); + } + } + + static Stream invalidManifests() { + String hash = "a".repeat(64); + return Stream.of( + Arguments.of("missing-key", "format=1\nversion=1.0.0\ntar_sha256=" + hash + "\n"), + Arguments.of("duplicate-key", + "format=1\nformat=1\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"), + Arguments.of("blank-line", + "format=1\n\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"), + Arguments.of("unknown-key", + "format=1\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\nextra=1\n"), + Arguments.of("bad-format", + "format=2\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"), + Arguments.of("bad-version", + "format=1\nversion=1.0\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"), + Arguments.of("bad-tar-hash", + "format=1\nversion=1.0.0\ntar_sha256=not-hex\nzip_sha256=" + hash + "\n"), + Arguments.of("bad-zip-hash", + "format=1\nversion=1.0.0\ntar_sha256=" + hash + "\nzip_sha256=short\n")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidManifests") + void rejectsInvalidManifest(String name, String manifestBody, @TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + fixture.publish("/camel-cli/releases/latest.properties", + manifestBody.getBytes(StandardCharsets.UTF_8)); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertNotEquals(0, r.exit(), name); + assertFalse(Files.exists(expectedBinDir(home).resolve("camel.cmd")), name); + } + } + + @Test + void manifestInjectionNeverExecutesCode(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + String hash = "a".repeat(64); + String manifest = "format=1\nversion=$(New-Item owned)\ntar_sha256=" + hash + "\nzip_sha256=" + hash + "\n"; + fixture.publish("/camel-cli/releases/latest.properties", + manifest.getBytes(StandardCharsets.UTF_8)); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(home.resolve("owned")), "manifest value must never be executed"); + } + } + + @Test + void rejectsChecksumMismatch(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + Path tar = fixture.safeTar("1.0.0"); + Path zip = fixture.safeZip("1.0.0"); + publishRelease(fixture, "1.0.0", tar, zip); + String bogusHash = "0".repeat(64); + fixture.publish("/camel-cli/releases/latest.properties", + ("format=1\nversion=1.0.0\ntar_sha256=" + bogusHash + "\nzip_sha256=" + bogusHash + "\n") + .getBytes(StandardCharsets.UTF_8)); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(expectedBinDir(home).resolve("camel.cmd"))); + } + } + + @Test + void rejectsInterruptedDownload(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture")); + ServerSocket rawServer = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + Path home = Files.createDirectory(temp.resolve("home")); + Path tar = fixture.safeTar("1.0.0"); + Path zip = fixture.safeZip("1.0.0"); + fixture.publishManifest("/camel-cli/releases/latest.properties", "1.0.0", tar, zip); + + byte[] zipBytes = Files.readAllBytes(zip); + Thread server = new Thread(() -> serveTruncatedThenClose(rawServer, zipBytes)); + server.setDaemon(true); + server.start(); + + Map env = new HashMap<>(fixture.environment(home)); + env.put("CAMEL_INSTALL_MAVEN_BASE_URL", "http://127.0.0.1:" + rawServer.getLocalPort()); + + WebsiteInstallerFixture.Result r = fixture.run( + List.of("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", SCRIPT.toString()), + env); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(expectedBinDir(home).resolve("camel.cmd"))); + } + } + + /** + * Accepts one raw (non-TLS) connection, writes an HTTP response that advertises a full Content-Length but abruptly + * closes the socket after only half the bytes. Mirrors the technique used by WebsiteInstallShTest, which reliably + * triggers the client's own transfer-integrity check. + */ + private static void serveTruncatedThenClose(ServerSocket rawServer, byte[] fullBody) { + try (Socket client = rawServer.accept()) { + String headers = "HTTP/1.1 200 OK\r\nContent-Length: " + fullBody.length + "\r\nConnection: close\r\n\r\n"; + OutputStream out = client.getOutputStream(); + out.write(headers.getBytes(StandardCharsets.US_ASCII)); + out.write(fullBody, 0, fullBody.length / 2); + out.flush(); + } catch (IOException ignored) { + // Best-effort: the client observing a truncated/reset connection is the point of the test. + } + } + + @Test + void rejectsAbsolutePathEntry(@TempDir Path temp) throws Exception { + assertMaliciousZipRejected(temp, fixture -> fixture.maliciousZip("/etc/passwd")); + } + + @Test + void rejectsTraversalEntry(@TempDir Path temp) throws Exception { + assertMaliciousZipRejected(temp, fixture -> fixture.maliciousZip("../escape")); + } + + @Test + void rejectsMultipleTopLevelRoots(@TempDir Path temp) throws Exception { + assertMaliciousZipRejected(temp, fixture -> fixture.maliciousZip("evil-root/payload")); + } + + @Test + void rejectsEscapingReparsePointEntry(@TempDir Path temp) throws Exception { + assertMaliciousZipRejected(temp, + fixture -> fixture.maliciousZipSymlink("camel-launcher-9.9.9/escape-link", "../../outside")); + } + + @Test + void rejectsArchiveMissingCamelExe(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + Path zip = fixture.safeZipMissingCamelExe("9.9.9"); + Path tar = fixture.safeTar("9.9.9"); + publishRelease(fixture, "9.9.9", tar, zip); + fixture.publishManifest("/camel-cli/releases/9.9.9.properties", "9.9.9", tar, zip); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.9"); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(expectedBinDir(home).resolve("camel.cmd"))); + } + } + + @Test + void rejectsWhenStagedLauncherFails(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + Path zip = fixture.safeZipWithFailingLauncher("9.9.9"); + Path tar = fixture.safeTar("9.9.9"); + publishRelease(fixture, "9.9.9", tar, zip); + fixture.publishManifest("/camel-cli/releases/9.9.9.properties", "9.9.9", tar, zip); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.9"); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(expectedBinDir(home).resolve("camel.cmd"))); + assertFalse(Files.exists(versionDir(home, "9.9.9"))); + } + } + + private interface MaliciousArchive { + Path build(WebsiteInstallerFixture fixture) throws Exception; + } + + private void assertMaliciousZipRejected(Path temp, MaliciousArchive archiveBuilder) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + Path zip = archiveBuilder.build(fixture); + Path tar = fixture.safeTar("9.9.9"); + publishRelease(fixture, "9.9.9", tar, zip); + fixture.publishManifest("/camel-cli/releases/9.9.9.properties", "9.9.9", tar, zip); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.9"); + + assertNotEquals(0, r.exit()); + assertFalse(Files.exists(expectedBinDir(home).resolve("camel.cmd"))); + } + } + + private WebsiteInstallerFixture.Result install(WebsiteInstallerFixture fixture, Path home, String version) + throws Exception { + binDirsForCleanup.add(expectedBinDir(home).toString()); + List command = new ArrayList<>( + List.of("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", SCRIPT.toString())); + if (version != null) { + command.add("-Version"); + command.add(version); + } + return fixture.run(command, fixture.environment(home)); + } + + private static void publishLatest(WebsiteInstallerFixture fixture, String version) throws Exception { + Path tar = fixture.safeTar(version); + Path zip = fixture.safeZip(version); + publishRelease(fixture, version, tar, zip); + fixture.publishManifest("/camel-cli/releases/latest.properties", version, tar, zip); + } + + private static void publishVersion(WebsiteInstallerFixture fixture, String version) throws Exception { + Path tar = fixture.safeTar(version); + Path zip = fixture.safeZip(version); + publishRelease(fixture, version, tar, zip); + fixture.publishManifest("/camel-cli/releases/" + version + ".properties", version, tar, zip); + } + + private static void publishRelease(WebsiteInstallerFixture fixture, String version, Path tar, Path zip) + throws Exception { + fixture.publish("/maven2/org/apache/camel/camel-launcher/" + version + "/camel-launcher-" + version + "-bin.tar.gz", + Files.readAllBytes(tar)); + fixture.publish("/maven2/org/apache/camel/camel-launcher/" + version + "/camel-launcher-" + version + "-bin.zip", + Files.readAllBytes(zip)); + } + + private static Path expectedBinDir(Path home) { + return home.resolve("AppData").resolve("Local").resolve("Apache Camel").resolve("bin"); + } + + private static Path versionDir(Path home, String version) { + return home.resolve("AppData").resolve("Local").resolve("Apache Camel").resolve("cli").resolve("versions") + .resolve(version); + } + + private static void assertVersionInstalled(Path home, String version) throws Exception { + Path shim = expectedBinDir(home).resolve("camel.cmd"); + assertTrue(Files.isRegularFile(shim), "expected shim at " + shim); + assertTrue(Files.isDirectory(versionDir(home, version)), "expected version directory " + versionDir(home, version)); + + Process check = new ProcessBuilder("cmd.exe", "/c", shim.toString(), "version") + .redirectErrorStream(true) + .start(); + String output = new String(check.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + check.waitFor(30, TimeUnit.SECONDS); + assertTrue(output.contains("Camel " + version), output); + } + + private static String queryEnvironmentPath(String scope) throws Exception { + Process p = new ProcessBuilder( + "powershell", "-NoProfile", "-Command", + "[Environment]::GetEnvironmentVariable('Path','" + scope + "')") + .redirectErrorStream(true).start(); + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + p.waitFor(30, TimeUnit.SECONDS); + return out; + } + + private static long countOccurrencesCaseInsensitive(String path, String dir) { + if (path == null || path.isEmpty()) { + return 0; + } + return Arrays.stream(path.split(";")) + .filter(entry -> entry.equalsIgnoreCase(dir)) + .count(); + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java index 4aea81fd4556d..5bf88781607f6 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java @@ -244,6 +244,12 @@ Path safeTarWithFailingLauncher(String version) throws Exception { return archive; } + Path safeZipWithFailingLauncher(String version) throws Exception { + Path archive = Files.createTempFile(tempDir, "failing-launcher-", "-" + version + ".zip"); + writeZip(archive, launcherEntries(version, 1)); + return archive; + } + Path safeTarMissingCamelSh(String version) throws Exception { String root = "camel-launcher-" + version; List entries = List.of( @@ -255,6 +261,17 @@ Path safeTarMissingCamelSh(String version) throws Exception { return archive; } + Path safeZipMissingCamelExe(String version) throws Exception { + String root = "camel-launcher-" + version; + List entries = List.of( + ArchiveEntrySpec.dir(root + "/"), + ArchiveEntrySpec.dir(root + "/bin/"), + ArchiveEntrySpec.file(root + "/bin/camel.bat", batScript(version).getBytes(StandardCharsets.UTF_8), false)); + Path archive = Files.createTempFile(tempDir, "missing-camel-exe-", "-" + version + ".zip"); + writeZip(archive, entries); + return archive; + } + Path maliciousTarSymlink(String entry, String linkTarget) throws Exception { List entries = new ArrayList<>(launcherEntries(DEFAULT_BASE_VERSION)); entries.add(ArchiveEntrySpec.symlink(entry, linkTarget)); @@ -271,15 +288,15 @@ Path maliciousZipSymlink(String entry, String linkTarget) throws Exception { return archive; } - private static List launcherEntries(String version) { + private static List launcherEntries(String version) throws Exception { return launcherEntries(version, 0); } - private static List launcherEntries(String version, int versionExitCode) { + private static List launcherEntries(String version, int versionExitCode) throws Exception { String root = "camel-launcher-" + version; byte[] sh = shScript(version, versionExitCode).getBytes(StandardCharsets.UTF_8); byte[] bat = batScript(version).getBytes(StandardCharsets.UTF_8); - byte[] exe = exeScript(version).getBytes(StandardCharsets.UTF_8); + byte[] exe = exeExecutable(version, versionExitCode); return List.of( ArchiveEntrySpec.dir(root + "/"), ArchiveEntrySpec.dir(root + "/bin/"), @@ -306,10 +323,60 @@ private static String batScript(String version) { + "echo Camel " + version + "\r\n"; } - private static String exeScript(String version) { - // Placeholder content for the fixture archive; behavioral Windows execution is covered - // separately where a real native camel.exe is required. - return "camel-launcher-" + version + "-test-exe\n"; + /** + * On Windows, install.ps1 executes the staged {@code camel.exe} directly by path to verify Java 17+ before + * activation, so the fixture entry must be a genuine PE, not placeholder text. It is compiled on the fly with the + * .NET Framework compiler that ships with every Windows Powershell 5.1+ install (via {@code Add-Type}), mirroring + * the runtime keystore generation already used for the HTTPS fixture: nothing binary is committed. Off Windows the + * bytes are never executed, only checked for presence in the archive. + */ + private static byte[] exeExecutable(String version, int versionExitCode) throws IOException, InterruptedException { + if (!FakeJava.WINDOWS) { + return ("camel-launcher-" + version + "-test-exe\n").getBytes(StandardCharsets.UTF_8); + } + Path source = Files.createTempFile("camel-test-launcher-", ".cs"); + Path output = Files.createTempFile("camel-test-launcher-", ".exe"); + Files.delete(output); + try { + String csharp = "using System;\n" + + "class Program {\n" + + " static int Main(string[] args) {\n" + + " if (args.Length > 0 && args[0] == \"version\") {\n" + + " Console.WriteLine(\"Camel " + version + "\");\n" + + " return " + versionExitCode + ";\n" + + " }\n" + + " if (args.Length > 0 && args[0] == \"echo-args\") {\n" + + " Console.WriteLine(string.Join(\" \", args, 1, args.Length - 1));\n" + + " return 0;\n" + + " }\n" + + " if (args.Length > 1 && args[0] == \"exit-code\") {\n" + + " return int.Parse(args[1]);\n" + + " }\n" + + " Console.WriteLine(\"Camel " + version + "\");\n" + + " return 0;\n" + + " }\n" + + "}\n"; + Files.writeString(source, csharp, StandardCharsets.UTF_8); + + String psCommand = "Add-Type -OutputType ConsoleApplication -Language CSharp " + + "-OutputAssembly '" + output + "' " + + "-TypeDefinition (Get-Content -Raw -LiteralPath '" + source + "')"; + ProcessBuilder pb = new ProcessBuilder("powershell", "-NoProfile", "-NonInteractive", "-Command", psCommand); + pb.redirectErrorStream(true); + Process process = pb.start(); + String log = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IllegalStateException("compiling test camel.exe did not finish in time"); + } + if (process.exitValue() != 0 || !Files.exists(output)) { + throw new IllegalStateException("failed to compile test camel.exe: " + log); + } + return Files.readAllBytes(output); + } finally { + Files.deleteIfExists(source); + Files.deleteIfExists(output); + } } private void writeTarGz(Path archive, List entries) throws IOException { diff --git a/pom.xml b/pom.xml index a1e63f77dc5c8..b24505fee3b36 100644 --- a/pom.xml +++ b/pom.xml @@ -431,6 +431,7 @@ CAMEL_PROPERTIES_STYLE CAMEL_PROPERTIES_STYLE SLASHSTAR_STYLE + SCRIPT_STYLE CAMEL_PROPERTIES_STYLE SCRIPT_STYLE DOUBLESLASH_STYLE From d0a7dc6b09f8fa480fda4cf096c704d00761efae Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:51:05 -0400 Subject: [PATCH 04/16] CAMEL-23703: document Camel CLI website installers Adds an upgrade-guide entry for install.sh / install.ps1 covering the stable URLs, exact-version syntax, per-user/no-elevation behavior, SHA-256 verification, the Java 17+ staged-launcher check, and the POSIX/Windows install roots. --- .../pages/camel-4x-upgrade-guide-4_22.adoc | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 0454ac8a835a2..8cf7ca09dd398 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -80,6 +80,47 @@ The Gentoo `java-config` auto-detection and the IBM AIX `$JAVA_HOME/jre/sh/java` removed; set `JAVA_HOME` or `JAVACMD` explicitly on those platforms. `JAVA_OPTS` handling is unchanged: when unset, the default `-Xmx512m` heap is applied. +==== Website installers for the Camel CLI + +Two canonical installer scripts are now available for installing the +xref:camel-jbang-launcher.adoc[Camel CLI Launcher] without a package manager: + +[source,bash] +---- +curl -fsSL https://camel.apache.org/install.sh | sh +---- + +[source,powershell] +---- +irm https://camel.apache.org/install.ps1 | iex +---- + +With no arguments, both installers resolve and install the latest published release. An exact +version can be requested instead with `--version X.Y.Z` (`install.sh`) or `-Version X.Y.Z` +(`install.ps1`); the requested version is validated and matched against the fetched manifest +before anything is downloaded. + +Both installers download the release archive from Maven Central, verify it against a SHA-256 +recorded in a signed-path manifest before extracting it, and reject archives containing absolute +paths, `../` traversal, escaping symlinks/reparse points, or more than one top-level directory. +The staged launcher is then run once to confirm a Java 17+ runtime can be discovered (see +"Camel CLI launcher Java runtime discovery" above); if that check fails, the previously active +installation, if any, is left untouched and the installer exits nonzero. + +Installation is always per-user and never requires elevation or `sudo`: + +* POSIX (`install.sh`) installs under `${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli/versions/` + and activates it via a symlink at `$HOME/.local/bin/camel`. The installer never writes to shell + profile files (`.bashrc`, `.profile`, etc.); if `$HOME/.local/bin` is not already on `PATH`, it + prints guidance instead. +* Windows (`install.ps1`) installs under `%LOCALAPPDATA%\Apache Camel\cli\versions\` and + activates it via a `camel.cmd` shim at `%LOCALAPPDATA%\Apache Camel\bin\camel.cmd` that delegates + to the staged `camel.exe`. The bin directory is added once, case-insensitively, to the current + user's `PATH`; the machine `PATH` is never modified. + +Previously installed version directories are left in place after an upgrade, downgrade, or +reinstall and must be removed manually. + === camel-langchain4j-agent The `Agent.chat()` method return type has changed from `String` to `Result` (from `dev.langchain4j.service.Result`). From 7a0d5805244cfa94df96180dc1b2518accc91310 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:01:49 -0400 Subject: [PATCH 05/16] CAMEL-23703: camel-launcher - pin JReleaser 1.25.0 plugin (unbound, non-inherited) Co-Authored-By: Claude Sonnet 5 --- dsl/camel-jbang/camel-launcher/pom.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index 3f4dfc05605f7..b5f35e69bd572 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -55,6 +55,7 @@ 1.22.0 ${slf4j-version} 0.29.0 + 1.25.0 @@ -329,6 +330,23 @@ + + + org.jreleaser + jreleaser-maven-plugin + ${jreleaser-plugin-version} + false + + + ${project.basedir}/jreleaser.yml + + + From d048e7aa3d6ccc648d7f9ed51fef1ccd8efdae52 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:02:07 -0400 Subject: [PATCH 06/16] CAMEL-23703: camel-launcher - supported-LTS allowlist for package distribution Co-Authored-By: Claude Sonnet 5 --- .../src/jreleaser/supported-lts.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml b/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml new file mode 100644 index 0000000000000..dc8481077da73 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml @@ -0,0 +1,25 @@ +# +# 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. +# +# Machine-readable allowlist of Camel LTS lines eligible for package distribution. +# Updated as part of Camel's LTS start/end process. This file is NOT a release-version +# source of truth: JReleaser continues to obtain the release version from Maven. +# It contains no artifact URLs or checksums. +supported: + - line: "4.18" + supportEnds: "2026-12-31" + - line: "4.22" + supportEnds: "2027-09-30" From 5b28d0a3f4f3b00768895554c0886601818305e4 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:03:28 -0400 Subject: [PATCH 07/16] CAMEL-23703: camel-launcher - failing channel-plan/LTS-validation tests Co-Authored-By: Claude Sonnet 5 --- .../src/jreleaser/supported-lts.yml | 5 +- .../dsl/jbang/launcher/PackagePlanShTest.java | 140 ++++++++++++++++++ 2 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanShTest.java diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml b/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml index dc8481077da73..f05a871995339 100644 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml @@ -14,10 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# Machine-readable allowlist of Camel LTS lines eligible for package distribution. -# Updated as part of Camel's LTS start/end process. This file is NOT a release-version -# source of truth: JReleaser continues to obtain the release version from Maven. -# It contains no artifact URLs or checksums. + supported: - line: "4.18" supportEnds: "2026-12-31" diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanShTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanShTest.java new file mode 100644 index 0000000000000..abd62bd8b5b0a --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanShTest.java @@ -0,0 +1,140 @@ +/* + * 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. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the channel -> packaging-plan mapping and LTS validation in camel-package.sh. These drive the + * wrapper's --print-plan mode, which resolves the plan without invoking Maven. + */ +@DisabledOnOs(OS.WINDOWS) +class PackagePlanShTest { + + private static final Path SCRIPT = Paths.get("src/jreleaser/bin/camel-package.sh"); + + private static final class Result { + int exit; + String stdout; + String stderr; + } + + private Result run(String... args) throws Exception { + List cmd = new ArrayList<>(); + cmd.add("/bin/sh"); + cmd.add(SCRIPT.toString()); + for (String a : args) { + cmd.add(a); + } + Process p = new ProcessBuilder(cmd).start(); + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "wrapper did not exit in time"); + Result r = new Result(); + r.exit = p.exitValue(); + r.stdout = out; + r.stderr = err; + return r; + } + + @Test + void stableSelectsAllFivePackagersWithSdkmanDefault() throws Exception { + Result r = run("prepare", "--channel", "stable", "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("CHANNEL=stable"), r.stdout); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,scoop,chocolatey"), r.stdout); + assertTrue(r.stdout.contains("BREW_FORMULA=camel\n") || r.stdout.contains("BREW_FORMULA=camel\r\n") + || r.stdout.trim().endsWith("BREW_FORMULA=camel"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_CANDIDATE=camel"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=true"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_VERSION_MANIFEST=true"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_LATEST=true"), r.stdout); + assertFalse(r.stdout.contains("BREW_LTS_FORMULA="), "stable without --lts-line has no LTS formula"); + } + + @Test + void stableWithLtsAddsVersionedBrewFormula() throws Exception { + Result r = run("prepare", "--channel", "stable", "--lts-line", "4.18", "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("CHANNEL=stable"), r.stdout); + assertTrue(r.stdout.contains("LTS_LINE=4.18"), r.stdout); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,scoop,chocolatey"), r.stdout); + assertTrue(r.stdout.contains("BREW_LTS_FORMULA=camel@4.18"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=true"), r.stdout); + } + + @Test + void ltsSelectsFourPackagersWithoutScoopAndSdkmanNotDefault() throws Exception { + Result r = run("prepare", "--channel", "lts", "--lts-line", "4.18", "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("CHANNEL=lts"), r.stdout); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,chocolatey"), r.stdout); + assertFalse(r.stdout.contains("scoop"), "LTS maintenance excludes Scoop: " + r.stdout); + assertTrue(r.stdout.contains("BREW_FORMULA=camel@4.18"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=false"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_VERSION_MANIFEST=true"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_LATEST=false"), r.stdout); + } + + @Test + void ltsChannelRequiresLtsLine() throws Exception { + Result r = run("prepare", "--channel", "lts", "--print-plan"); + + assertEquals(2, r.exit, "missing --lts-line for lts channel must be a usage error"); + assertTrue(r.stderr.toLowerCase().contains("lts-line"), r.stderr); + } + + @Test + void rejectsUnsupportedLtsLine() throws Exception { + Result r = run("prepare", "--channel", "lts", "--lts-line", "3.14", "--print-plan"); + + assertEquals(2, r.exit); + assertTrue(r.stderr.toLowerCase().contains("not a supported lts line") + || r.stderr.contains("3.14"), r.stderr); + } + + @Test + void rejectsUnknownChannel() throws Exception { + Result r = run("prepare", "--channel", "nightly", "--print-plan"); + + assertEquals(2, r.exit); + assertTrue(r.stderr.toLowerCase().contains("channel"), r.stderr); + } + + @Test + void rejectsUnknownSubcommand() throws Exception { + Result r = run("frobnicate", "--channel", "stable", "--print-plan"); + + assertEquals(2, r.exit); + } +} From d65e72c202ae2761d4aa8f99b6ec950b11b893ee Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:09:12 -0400 Subject: [PATCH 08/16] CAMEL-23703: camel-launcher - POSIX prepare wrapper with channel plan + LTS validation Co-Authored-By: Claude Sonnet 5 --- .../src/jreleaser/bin/camel-package.sh | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100755 dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh new file mode 100755 index 0000000000000..c4e66bcf31f83 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -0,0 +1,126 @@ +#!/bin/sh +# +# 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. +# + +set -eu + +SCRIPT_DIR=`CDPATH= cd -- "$(dirname -- "$0")" && pwd` +MODULE_DIR=`CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd` +SUPPORTED_LTS="$SCRIPT_DIR/../supported-lts.yml" + +usage() { + echo "Usage: camel-package.sh --channel [--lts-line X.Y] [--print-plan]" 1>&2 + exit 2 +} + +SUBCOMMAND="" +CHANNEL="" +LTS_LINE="" +PRINT_PLAN=0 + +[ $# -ge 1 ] || usage +SUBCOMMAND="$1"; shift +case "$SUBCOMMAND" in + prepare|publish) ;; + *) echo "Error: unknown subcommand '$SUBCOMMAND'." 1>&2; usage ;; +esac + +while [ $# -gt 0 ]; do + case "$1" in + --channel) CHANNEL="${2:-}"; shift 2 ;; + --lts-line) LTS_LINE="${2:-}"; shift 2 ;; + --print-plan) PRINT_PLAN=1; shift ;; + *) echo "Error: unknown argument '$1'." 1>&2; usage ;; + esac +done + +case "$CHANNEL" in + stable|lts) ;; + *) echo "Error: --channel must be 'stable' or 'lts' (got '$CHANNEL')." 1>&2; usage ;; +esac + +if [ "$CHANNEL" = "lts" ] && [ -z "$LTS_LINE" ]; then + echo "Error: --channel lts requires --lts-line X.Y." 1>&2 + usage +fi + +# Validate --lts-line against the allowlist and its support-end date. +if [ -n "$LTS_LINE" ]; then + today=`date +%F` + supported_ends=`awk -v line="$LTS_LINE" ' + $1 == "-" && $2 == "line:" { cur = $3; gsub(/"/, "", cur) } + $1 == "supportEnds:" && cur == line { v = $2; gsub(/"/, "", v); print v; exit } + ' "$SUPPORTED_LTS"` + if [ -z "$supported_ends" ]; then + echo "Error: '$LTS_LINE' is not a supported LTS line (see supported-lts.yml)." 1>&2 + exit 2 + fi + # ISO-8601 dates compare correctly as strings. + if [ "$today" \> "$supported_ends" ]; then + echo "Error: LTS line '$LTS_LINE' support ended on $supported_ends." 1>&2 + exit 2 + fi +fi + +# --- Channel -> packaging plan --- +if [ "$CHANNEL" = "stable" ]; then + PACKAGERS="brew,sdkman,winget,scoop,chocolatey" + BREW_FORMULA="camel" + SDKMAN_DEFAULT="true" + BREW_LTS_FORMULA="" + WEBSITE_LATEST="true" + [ -n "$LTS_LINE" ] && BREW_LTS_FORMULA="camel@$LTS_LINE" +else + PACKAGERS="brew,sdkman,winget,chocolatey" + BREW_FORMULA="camel@$LTS_LINE" + SDKMAN_DEFAULT="false" + BREW_LTS_FORMULA="" + WEBSITE_LATEST="false" +fi + +if [ "$PRINT_PLAN" -eq 1 ]; then + echo "CHANNEL=$CHANNEL" + echo "LTS_LINE=$LTS_LINE" + echo "PACKAGERS=$PACKAGERS" + echo "SDKMAN_CANDIDATE=camel" + echo "SDKMAN_DEFAULT=$SDKMAN_DEFAULT" + echo "WEBSITE_VERSION_MANIFEST=true" + echo "WEBSITE_LATEST=$WEBSITE_LATEST" + [ -n "$BREW_LTS_FORMULA" ] && echo "BREW_LTS_FORMULA=$BREW_LTS_FORMULA" + echo "BREW_FORMULA=$BREW_FORMULA" + exit 0 +fi + +if [ "$SUBCOMMAND" = "publish" ]; then + # Publication is implemented in Phase 5. + echo "Error: 'publish' is not yet implemented (Phase 5)." 1>&2 + exit 2 +fi + +# --- prepare: no remote mutation, no credentials --- +# Map the plan to JReleaser system properties. Actual property names are validated +# against the JReleaser 1.25.0 reference during template work (Tasks 7-9). +echo "Preparing packages for channel '$CHANNEL' (packagers: $PACKAGERS)..." +mvn -B -ntp -pl "$MODULE_DIR" \ + -Dcamel.pkg.channel="$CHANNEL" \ + -Dcamel.pkg.ltsLine="$LTS_LINE" \ + -Dcamel.pkg.packagers="$PACKAGERS" \ + -Dcamel.pkg.brewFormula="$BREW_FORMULA" \ + -Dcamel.pkg.brewLtsFormula="$BREW_LTS_FORMULA" \ + -Dcamel.pkg.sdkmanDefault="$SDKMAN_DEFAULT" \ + jreleaser:config jreleaser:prepare jreleaser:package \ + -Djreleaser.dry.run=true From 04a8de0b76ee837c4ef27981a1321c5cf26d117a Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:10:38 -0400 Subject: [PATCH 09/16] CAMEL-23703: camel-launcher - Windows prepare wrapper + plan test Co-Authored-By: Claude Sonnet 5 --- .../src/jreleaser/bin/camel-package.bat | 110 ++++++++++++++++++ .../jbang/launcher/PackagePlanBatTest.java | 97 +++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanBatTest.java diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat new file mode 100644 index 0000000000000..656394a7e9d14 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat @@ -0,0 +1,110 @@ +@echo off +@REM +@REM Licensed to the Apache Software Foundation (ASF) under one or more +@REM contributor license agreements. See the NOTICE file distributed with +@REM this work for additional information regarding copyright ownership. +@REM The ASF licenses this file to You under the Apache License, Version 2.0 +@REM (the "License"); you may not use this file except in compliance with +@REM the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM +setlocal enabledelayedexpansion +set "SCRIPT_DIR=%~dp0" +set "SUPPORTED_LTS=%SCRIPT_DIR%..\supported-lts.yml" + +set "SUBCOMMAND=%~1" +if "%SUBCOMMAND%"=="" goto usage +shift +if /I not "%SUBCOMMAND%"=="prepare" if /I not "%SUBCOMMAND%"=="publish" ( + echo Error: unknown subcommand '%SUBCOMMAND%'. 1>&2 + goto usage +) + +set "CHANNEL=" +set "LTS_LINE=" +set "PRINT_PLAN=0" +:parse +if "%~1"=="" goto parsed +if /I "%~1"=="--channel" ( set "CHANNEL=%~2" & shift & shift & goto parse ) +if /I "%~1"=="--lts-line" ( set "LTS_LINE=%~2" & shift & shift & goto parse ) +if /I "%~1"=="--print-plan" ( set "PRINT_PLAN=1" & shift & goto parse ) +echo Error: unknown argument '%~1'. 1>&2 +goto usage +:parsed + +if /I "%CHANNEL%"=="stable" goto channelOk +if /I "%CHANNEL%"=="lts" goto channelOk +echo Error: --channel must be 'stable' or 'lts' (got '%CHANNEL%'). 1>&2 +goto usage +:channelOk + +if /I "%CHANNEL%"=="lts" if "%LTS_LINE%"=="" ( + echo Error: --channel lts requires --lts-line X.Y. 1>&2 + goto usage +) + +if not "%LTS_LINE%"=="" ( + set "SUPPORT_ENDS=" + for /f "usebackq tokens=1,2,3" %%a in ("%SUPPORTED_LTS%") do ( + if "%%a"=="-" if "%%b"=="line:" ( set "CUR=%%c" & set "CUR=!CUR:"=!" ) + if "%%a"=="supportEnds:" if "!CUR!"=="%LTS_LINE%" ( set "SE=%%b" & set "SUPPORT_ENDS=!SE:"=!" ) + ) + if "!SUPPORT_ENDS!"=="" ( + echo Error: '%LTS_LINE%' is not a supported LTS line ^(see supported-lts.yml^). 1>&2 + exit /b 2 + ) + for /f "tokens=1-3 delims=/ " %%x in ("%date%") do set "TODAY=%%z-%%y-%%x" + @REM NOTE: %date% format is locale-dependent; CI verifies with an ISO locale. String compare of ISO dates. + if "!TODAY!" GTR "!SUPPORT_ENDS!" ( + echo Error: LTS line '%LTS_LINE%' support ended on !SUPPORT_ENDS!. 1>&2 + exit /b 2 + ) +) + +if /I "%CHANNEL%"=="stable" ( + set "PACKAGERS=brew,sdkman,winget,scoop,chocolatey" + set "BREW_FORMULA=camel" + set "SDKMAN_DEFAULT=true" + set "WEBSITE_LATEST=true" + set "BREW_LTS_FORMULA=" + if not "%LTS_LINE%"=="" set "BREW_LTS_FORMULA=camel@%LTS_LINE%" +) else ( + set "PACKAGERS=brew,sdkman,winget,chocolatey" + set "BREW_FORMULA=camel@%LTS_LINE%" + set "SDKMAN_DEFAULT=false" + set "WEBSITE_LATEST=false" + set "BREW_LTS_FORMULA=" +) + +if "%PRINT_PLAN%"=="1" ( + echo CHANNEL=%CHANNEL% + echo LTS_LINE=%LTS_LINE% + echo PACKAGERS=!PACKAGERS! + echo SDKMAN_CANDIDATE=camel + echo SDKMAN_DEFAULT=!SDKMAN_DEFAULT! + echo WEBSITE_VERSION_MANIFEST=true + echo WEBSITE_LATEST=!WEBSITE_LATEST! + if not "!BREW_LTS_FORMULA!"=="" echo BREW_LTS_FORMULA=!BREW_LTS_FORMULA! + echo BREW_FORMULA=!BREW_FORMULA! + exit /b 0 +) + +if /I "%SUBCOMMAND%"=="publish" ( + echo Error: 'publish' is not yet implemented ^(Phase 5^). 1>&2 + exit /b 2 +) + +echo Preparing packages for channel '%CHANNEL%' ^(packagers: !PACKAGERS!^)... +call mvn -B -ntp -pl "%SCRIPT_DIR%..\.." -Dcamel.pkg.channel=%CHANNEL% -Dcamel.pkg.ltsLine=%LTS_LINE% -Dcamel.pkg.packagers=!PACKAGERS! -Dcamel.pkg.brewFormula=!BREW_FORMULA! -Dcamel.pkg.brewLtsFormula=!BREW_LTS_FORMULA! -Dcamel.pkg.sdkmanDefault=!SDKMAN_DEFAULT! jreleaser:config jreleaser:prepare jreleaser:package -Djreleaser.dry.run=true +exit /b %ERRORLEVEL% + +:usage +echo Usage: camel-package.bat ^ --channel ^ [--lts-line X.Y] [--print-plan] 1>&2 +exit /b 2 diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanBatTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanBatTest.java new file mode 100644 index 0000000000000..b7abd15988e85 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanBatTest.java @@ -0,0 +1,97 @@ +/* + * 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. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Windows equivalent of {@link PackagePlanShTest}: verifies camel-package.bat produces the same channel -> + * packaging-plan output and the same LTS validation. + */ +@EnabledOnOs(OS.WINDOWS) +class PackagePlanBatTest { + + private static final Path SCRIPT = Paths.get("src/jreleaser/bin/camel-package.bat"); + + private static final class Result { + int exit; + String stdout; + String stderr; + } + + private Result run(String... args) throws Exception { + List cmd = new ArrayList<>(); + cmd.add("cmd.exe"); + cmd.add("/c"); + cmd.add(SCRIPT.toString()); + for (String a : args) { + cmd.add(a); + } + Process p = new ProcessBuilder(cmd).start(); + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "wrapper did not exit in time"); + Result r = new Result(); + r.exit = p.exitValue(); + r.stdout = out; + r.stderr = err; + return r; + } + + @Test + void stableSelectsAllFivePackagersWithSdkmanDefault() throws Exception { + Result r = run("prepare", "--channel", "stable", "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,scoop,chocolatey"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=true"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_LATEST=true"), r.stdout); + assertFalse(r.stdout.contains("BREW_LTS_FORMULA="), r.stdout); + } + + @Test + void ltsExcludesScoopAndSdkmanNotDefault() throws Exception { + Result r = run("prepare", "--channel", "lts", "--lts-line", "4.18", "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,chocolatey"), r.stdout); + assertFalse(r.stdout.contains("scoop"), r.stdout); + assertTrue(r.stdout.contains("BREW_FORMULA=camel@4.18"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=false"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_LATEST=false"), r.stdout); + } + + @Test + void rejectsUnsupportedLtsLine() throws Exception { + Result r = run("prepare", "--channel", "lts", "--lts-line", "3.14", "--print-plan"); + + assertEquals(2, r.exit); + } +} From fd0e91b008314dcda693bd667e12f4413800a128 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:14:36 -0400 Subject: [PATCH 10/16] CAMEL-23703: camel-launcher - failing website manifest generator tests Co-Authored-By: Claude Sonnet 5 --- .../src/jreleaser/bin/camel-package.bat | 17 + .../WebsiteManifestGeneratorTest.java | 299 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat index 656394a7e9d14..a3fbaaa686821 100644 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat @@ -1,3 +1,20 @@ +@REM +@REM Licensed to the Apache Software Foundation (ASF) under one or more +@REM contributor license agreements. See the NOTICE file distributed with +@REM this work for additional information regarding copyright ownership. +@REM The ASF licenses this file to You under the Apache License, Version 2.0 +@REM (the "License"); you may not use this file except in compliance with +@REM the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM + @echo off @REM @REM Licensed to the Apache Software Foundation (ASF) under one or more diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java new file mode 100644 index 0000000000000..5bf486de140cf --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java @@ -0,0 +1,299 @@ +/* + * 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. + */ +package org.apache.camel.dsl.jbang.launcher; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the JDK-only WebsiteManifestGenerator source-file tool: format, immutability, monotonic latest + * updates, and checksum correctness. + */ +class WebsiteManifestGeneratorTest { + + private static final Path GENERATOR = Paths.get("src/jreleaser/java/WebsiteManifestGenerator.java"); + + private static final class Result { + int exit; + String stdout; + String stderr; + } + + private Result run(String... args) throws Exception { + String javaBin = Paths.get(System.getProperty("java.home"), "bin", "java").toString(); + List cmd = new ArrayList<>(); + cmd.add(javaBin); + cmd.add(GENERATOR.toString()); + for (String a : args) { + cmd.add(a); + } + Process p = new ProcessBuilder(cmd).start(); + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(p.waitFor(60, TimeUnit.SECONDS), "generator did not exit in time"); + Result r = new Result(); + r.exit = p.exitValue(); + r.stdout = out; + r.stderr = err; + return r; + } + + private Path writeFixture(Path dir, String name, String content) throws IOException { + Path file = dir.resolve(name); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } + + private String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(Files.readAllBytes(file)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private String expectedManifest(String version, String tarSha256, String zipSha256) { + return "format=1\n" + "version=" + version + "\n" + "tar_sha256=" + tarSha256 + "\n" + "zip_sha256=" + + zipSha256 + "\n"; + } + + @Test + void stableWritesVersionAndLatestManifests(@TempDir Path work) throws Exception { + Path tar = writeFixture(work, "camel-launcher-4.22.0-bin.tar.gz", "tar-content-A"); + Path zip = writeFixture(work, "camel-launcher-4.22.0-bin.zip", "zip-content-A"); + Path output = work.resolve("website"); + + Result r = run("--version", "4.22.0", "--tar", tar.toString(), "--zip", zip.toString(), "--output", + output.toString(), "--latest", "true"); + + assertEquals(0, r.exit, r.stderr); + String expected = expectedManifest("4.22.0", sha256Hex(tar), sha256Hex(zip)); + Path versionFile = output.resolve("releases").resolve("4.22.0.properties"); + Path latestFile = output.resolve("latest.properties"); + assertEquals(expected, Files.readString(versionFile, StandardCharsets.UTF_8)); + assertArrayEquals(Files.readAllBytes(versionFile), Files.readAllBytes(latestFile), + "latest.properties must be byte-identical to the version manifest"); + } + + @Test + void ltsDoesNotWriteLatest(@TempDir Path work) throws Exception { + Path tar = writeFixture(work, "camel-launcher-4.18.5-bin.tar.gz", "tar-content-lts"); + Path zip = writeFixture(work, "camel-launcher-4.18.5-bin.zip", "zip-content-lts"); + Path output = work.resolve("website"); + + Result r = run("--version", "4.18.5", "--tar", tar.toString(), "--zip", zip.toString(), "--output", + output.toString(), "--latest", "false"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(Files.exists(output.resolve("releases").resolve("4.18.5.properties"))); + assertFalse(Files.exists(output.resolve("latest.properties")), "LTS run must not create latest.properties"); + } + + @Test + void repeatedRunIsIdempotent(@TempDir Path work) throws Exception { + Path tar = writeFixture(work, "t.tar.gz", "same-tar"); + Path zip = writeFixture(work, "t.zip", "same-zip"); + Path output = work.resolve("website"); + + Result r1 = run("--version", "4.22.0", "--tar", tar.toString(), "--zip", zip.toString(), "--output", + output.toString(), "--latest", "true"); + assertEquals(0, r1.exit, r1.stderr); + + byte[] versionBytesBefore = Files.readAllBytes(output.resolve("releases").resolve("4.22.0.properties")); + byte[] latestBytesBefore = Files.readAllBytes(output.resolve("latest.properties")); + + Result r2 = run("--version", "4.22.0", "--tar", tar.toString(), "--zip", zip.toString(), "--output", + output.toString(), "--latest", "true"); + assertEquals(0, r2.exit, r2.stderr); + + assertArrayEquals(versionBytesBefore, Files.readAllBytes(output.resolve("releases").resolve("4.22.0.properties"))); + assertArrayEquals(latestBytesBefore, Files.readAllBytes(output.resolve("latest.properties"))); + } + + @Test + void immutableVersionConflictFails(@TempDir Path work) throws Exception { + Path tar1 = writeFixture(work, "a.tar.gz", "content-A"); + Path zip1 = writeFixture(work, "a.zip", "content-A-zip"); + Path output = work.resolve("website"); + + Result r1 = run("--version", "4.22.0", "--tar", tar1.toString(), "--zip", zip1.toString(), "--output", + output.toString(), "--latest", "false"); + assertEquals(0, r1.exit, r1.stderr); + byte[] before = Files.readAllBytes(output.resolve("releases").resolve("4.22.0.properties")); + + Path tar2 = writeFixture(work, "b.tar.gz", "content-B-different"); + Path zip2 = writeFixture(work, "b.zip", "content-B-zip-different"); + + Result r2 = run("--version", "4.22.0", "--tar", tar2.toString(), "--zip", zip2.toString(), "--output", + output.toString(), "--latest", "false"); + + assertNotEquals(0, r2.exit, "conflicting checksums for an existing version manifest must fail"); + assertArrayEquals(before, Files.readAllBytes(output.resolve("releases").resolve("4.22.0.properties")), + "existing version manifest must remain untouched after a rejected conflicting write"); + } + + @Test + void latestRollbackFails(@TempDir Path work) throws Exception { + Path tar1 = writeFixture(work, "hi.tar.gz", "hi-tar"); + Path zip1 = writeFixture(work, "hi.zip", "hi-zip"); + Path output = work.resolve("website"); + + Result seed = run("--version", "4.23.0", "--tar", tar1.toString(), "--zip", zip1.toString(), "--output", + output.toString(), "--latest", "true"); + assertEquals(0, seed.exit, seed.stderr); + byte[] latestBefore = Files.readAllBytes(output.resolve("latest.properties")); + + Path tar2 = writeFixture(work, "lo.tar.gz", "lo-tar"); + Path zip2 = writeFixture(work, "lo.zip", "lo-zip"); + + Result r = run("--version", "4.22.0", "--tar", tar2.toString(), "--zip", zip2.toString(), "--output", + output.toString(), "--latest", "true"); + + assertNotEquals(0, r.exit, "latest must not move backward to a lower version"); + assertArrayEquals(latestBefore, Files.readAllBytes(output.resolve("latest.properties"))); + assertTrue(Files.exists(output.resolve("releases").resolve("4.22.0.properties")), + "the version-specific manifest is still written even if the latest update is rejected"); + } + + @Test + void sameVersionChecksumConflictOnLatestFails(@TempDir Path work) throws Exception { + Path tar1 = writeFixture(work, "x.tar.gz", "x-tar"); + Path zip1 = writeFixture(work, "x.zip", "x-zip"); + Path output = work.resolve("website"); + + Result seed = run("--version", "4.22.0", "--tar", tar1.toString(), "--zip", zip1.toString(), "--output", + output.toString(), "--latest", "true"); + assertEquals(0, seed.exit, seed.stderr); + byte[] latestBefore = Files.readAllBytes(output.resolve("latest.properties")); + + // Simulate a corrupted/foreign latest.properties: same version, different checksums, with no + // matching releases/4.22.0.properties conflict (different version so the version-file step succeeds). + Path tar2 = writeFixture(work, "y.tar.gz", "y-tar-different"); + Path zip2 = writeFixture(work, "y.zip", "y-zip-different"); + Files.writeString(output.resolve("latest.properties"), + expectedManifest("4.22.0", sha256Hex(tar2), sha256Hex(zip2)), StandardCharsets.UTF_8); + byte[] tamperedLatest = Files.readAllBytes(output.resolve("latest.properties")); + + Path tar3 = writeFixture(work, "z.tar.gz", "z-tar-yet-different"); + Path zip3 = writeFixture(work, "z.zip", "z-zip-yet-different"); + + Result r = run("--version", "4.22.0", "--tar", tar3.toString(), "--zip", zip3.toString(), "--output", + output.toString(), "--latest", "true"); + + assertNotEquals(0, r.exit, "same-version checksum conflict against latest.properties must fail"); + assertArrayEquals(tamperedLatest, Files.readAllBytes(output.resolve("latest.properties")), + "latest.properties must remain untouched after a rejected conflicting update"); + assertNotEquals(new String(latestBefore, StandardCharsets.UTF_8), "unused"); + } + + @Test + void forwardUpdateSucceeds(@TempDir Path work) throws Exception { + Path tar1 = writeFixture(work, "old.tar.gz", "old-tar"); + Path zip1 = writeFixture(work, "old.zip", "old-zip"); + Path output = work.resolve("website"); + + Result seed = run("--version", "4.22.0", "--tar", tar1.toString(), "--zip", zip1.toString(), "--output", + output.toString(), "--latest", "true"); + assertEquals(0, seed.exit, seed.stderr); + + Path tar2 = writeFixture(work, "new.tar.gz", "new-tar"); + Path zip2 = writeFixture(work, "new.zip", "new-zip"); + + Result r = run("--version", "4.23.0", "--tar", tar2.toString(), "--zip", zip2.toString(), "--output", + output.toString(), "--latest", "true"); + + assertEquals(0, r.exit, r.stderr); + String expected = expectedManifest("4.23.0", sha256Hex(tar2), sha256Hex(zip2)); + assertEquals(expected, Files.readString(output.resolve("latest.properties"), StandardCharsets.UTF_8)); + assertTrue(Files.exists(output.resolve("releases").resolve("4.22.0.properties"))); + assertTrue(Files.exists(output.resolve("releases").resolve("4.23.0.properties"))); + } + + @Test + void invalidSnapshotVersionRejected(@TempDir Path work) throws Exception { + Path tar = writeFixture(work, "s.tar.gz", "s-tar"); + Path zip = writeFixture(work, "s.zip", "s-zip"); + Path output = work.resolve("website"); + + Result r = run("--version", "4.22.0-SNAPSHOT", "--tar", tar.toString(), "--zip", zip.toString(), "--output", + output.toString(), "--latest", "false"); + + assertNotEquals(0, r.exit, "SNAPSHOT versions must be rejected"); + assertFalse(Files.exists(output), "no output must be written on validation failure"); + } + + @Test + void missingArtifactFails(@TempDir Path work) throws Exception { + Path zip = writeFixture(work, "present.zip", "present-zip"); + Path missingTar = work.resolve("does-not-exist.tar.gz"); + Path output = work.resolve("website"); + + Result r = run("--version", "4.22.0", "--tar", missingTar.toString(), "--zip", zip.toString(), "--output", + output.toString(), "--latest", "false"); + + assertNotEquals(0, r.exit, "a missing artifact must fail before any manifest is written"); + assertFalse(Files.exists(output)); + } + + @Test + void unknownOptionRejected(@TempDir Path work) throws Exception { + Path tar = writeFixture(work, "u.tar.gz", "u-tar"); + Path zip = writeFixture(work, "u.zip", "u-zip"); + Path output = work.resolve("website"); + + Result r = run("--version", "4.22.0", "--tar", tar.toString(), "--zip", zip.toString(), "--output", + output.toString(), "--latest", "false", "--bogus", "surprise"); + + assertNotEquals(0, r.exit, "unknown options must be rejected"); + assertFalse(Files.exists(output)); + } + + @Test + void handlesOutputPathsWithSpacesAndUnicode(@TempDir Path work) throws Exception { + Path tar = writeFixture(work, "sp.tar.gz", "sp-tar"); + Path zip = writeFixture(work, "sp.zip", "sp-zip"); + Path output = work.resolve("café output ünïcödé"); + + Result r = run("--version", "4.22.0", "--tar", tar.toString(), "--zip", zip.toString(), "--output", + output.toString(), "--latest", "true"); + + assertEquals(0, r.exit, r.stderr); + String expected = expectedManifest("4.22.0", sha256Hex(tar), sha256Hex(zip)); + assertEquals(expected, + Files.readString(output.resolve("releases").resolve("4.22.0.properties"), StandardCharsets.UTF_8)); + assertEquals(expected, Files.readString(output.resolve("latest.properties"), StandardCharsets.UTF_8)); + } +} From 496897d84a26bfdf1de2c534accd3777297c2cb3 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:16:45 -0400 Subject: [PATCH 11/16] CAMEL-23703: camel-launcher - immutable website release manifests Co-Authored-By: Claude Sonnet 5 --- .../java/WebsiteManifestGenerator.java | 273 ++++++++++++++++++ .../WebsiteManifestGeneratorTest.java | 40 ++- 2 files changed, 292 insertions(+), 21 deletions(-) create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java b/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java new file mode 100644 index 0000000000000..fc0991244718b --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java @@ -0,0 +1,273 @@ +/* + * 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. + */ + +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.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +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. + * + *

+ * Usage: {@code java WebsiteManifestGenerator.java --version X.Y.Z --tar --zip + * --output

--latest } + */ +public class WebsiteManifestGenerator { + + private static final Pattern VERSION_PATTERN = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)$"); + private static final Set REQUIRED_OPTIONS + = new LinkedHashSet<>(java.util.List.of("--version", "--tar", "--zip", "--output", "--latest")); + private static final String MANIFEST_FORMAT = "1"; + + public static void main(String[] args) { + try { + run(args); + } catch (UsageException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(2); + } catch (ConflictException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } catch (IOException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } + } + + private static void run(String[] args) throws IOException { + Map 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); + } + + 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(output.resolve("latest.properties"), manifest, version); + } + } + + private static Map parseArgs(String[] args) { + Map options = new LinkedHashMap<>(); + int i = 0; + while (i < args.length) { + String key = args[i]; + if (!REQUIRED_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 = "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 (java.util.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 (java.util.Arrays.equals(existing, manifest)) { + return; + } + + Map 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); + } + + private static Map parseStrictManifest(byte[] bytes, Path source) { + String content = new String(bytes, StandardCharsets.UTF_8); + Map fields = new LinkedHashMap<>(); + for (String line : content.split("\n", -1)) { + if (line.isEmpty()) { + continue; + } + int eq = line.indexOf('='); + if (eq < 0) { + throw new ConflictException("malformed manifest line in " + source + ": '" + line + "'."); + } + fields.put(line.substring(0, eq), line.substring(eq + 1)); + } + Set expectedKeys = Set.of("format", "version", "tar_sha256", "zip_sha256"); + if (!fields.keySet().equals(expectedKeys)) { + throw new ConflictException("malformed manifest in " + source + ": expected keys " + expectedKeys + + " but found " + fields.keySet() + "."); + } + if (!VERSION_PATTERN.matcher(fields.get("version")).matches()) { + throw new ConflictException("malformed manifest in " + source + ": invalid version '" + + fields.get("version") + "'."); + } + return fields; + } + + 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) { + java.util.regex.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(); + Files.createDirectories(dir); + 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); + } + } + byte[] hash = digest.digest(); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static final class UsageException extends RuntimeException { + UsageException(String message) { + super(message); + } + } + + private static final class ConflictException extends RuntimeException { + ConflictException(String message) { + super(message); + } + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java index 5bf486de140cf..eb5a503453876 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java @@ -190,33 +190,31 @@ void latestRollbackFails(@TempDir Path work) throws Exception { @Test void sameVersionChecksumConflictOnLatestFails(@TempDir Path work) throws Exception { - Path tar1 = writeFixture(work, "x.tar.gz", "x-tar"); - Path zip1 = writeFixture(work, "x.zip", "x-zip"); Path output = work.resolve("website"); - - Result seed = run("--version", "4.22.0", "--tar", tar1.toString(), "--zip", zip1.toString(), "--output", - output.toString(), "--latest", "true"); - assertEquals(0, seed.exit, seed.stderr); - byte[] latestBefore = Files.readAllBytes(output.resolve("latest.properties")); - - // Simulate a corrupted/foreign latest.properties: same version, different checksums, with no - // matching releases/4.22.0.properties conflict (different version so the version-file step succeeds). - Path tar2 = writeFixture(work, "y.tar.gz", "y-tar-different"); - Path zip2 = writeFixture(work, "y.zip", "y-zip-different"); - Files.writeString(output.resolve("latest.properties"), - expectedManifest("4.22.0", sha256Hex(tar2), sha256Hex(zip2)), StandardCharsets.UTF_8); - byte[] tamperedLatest = Files.readAllBytes(output.resolve("latest.properties")); - - Path tar3 = writeFixture(work, "z.tar.gz", "z-tar-yet-different"); - Path zip3 = writeFixture(work, "z.zip", "z-zip-yet-different"); - - Result r = run("--version", "4.22.0", "--tar", tar3.toString(), "--zip", zip3.toString(), "--output", + Files.createDirectories(output); + + // Seed a latest.properties directly (bypassing the generator) that claims version 4.22.0 with + // checksums that do not correspond to any releases/4.22.0.properties file yet. This simulates + // stale/foreign state: the releases/ manifest for that version does not exist, so the + // version-file write below succeeds fresh, isolating the latest-specific conflict check. + Path tarStale = writeFixture(work, "stale.tar.gz", "stale-tar"); + Path zipStale = writeFixture(work, "stale.zip", "stale-zip"); + byte[] tamperedLatest + = expectedManifest("4.22.0", sha256Hex(tarStale), sha256Hex(zipStale)).getBytes(StandardCharsets.UTF_8); + Files.write(output.resolve("latest.properties"), tamperedLatest); + assertFalse(Files.exists(output.resolve("releases").resolve("4.22.0.properties"))); + + Path tarNew = writeFixture(work, "new.tar.gz", "new-tar-different"); + Path zipNew = writeFixture(work, "new.zip", "new-zip-different"); + + Result r = run("--version", "4.22.0", "--tar", tarNew.toString(), "--zip", zipNew.toString(), "--output", output.toString(), "--latest", "true"); assertNotEquals(0, r.exit, "same-version checksum conflict against latest.properties must fail"); assertArrayEquals(tamperedLatest, Files.readAllBytes(output.resolve("latest.properties")), "latest.properties must remain untouched after a rejected conflicting update"); - assertNotEquals(new String(latestBefore, StandardCharsets.UTF_8), "unused"); + assertTrue(Files.exists(output.resolve("releases").resolve("4.22.0.properties")), + "the version-specific manifest still gets written since it did not previously exist"); } @Test From abb139cefe583327841a6c8c424f4f1e1e48849c Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:25:43 -0400 Subject: [PATCH 12/16] CAMEL-23703: camel-launcher - prepare website installer output Also fixes a MODULE_DIR path bug in both wrappers: it resolved two directory levels up from src/jreleaser/bin instead of three, so it pointed at .../camel-launcher/src instead of the module root. This was latent since Task 4 because --print-plan never touched MODULE_DIR. Co-Authored-By: Claude Sonnet 5 --- .../src/jreleaser/bin/camel-package.bat | 94 ++++++++-- .../src/jreleaser/bin/camel-package.sh | 72 +++++++- .../jbang/launcher/PackagePlanBatTest.java | 171 ++++++++++++++++- .../dsl/jbang/launcher/PackagePlanShTest.java | 173 +++++++++++++++++- 4 files changed, 487 insertions(+), 23 deletions(-) diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat index a3fbaaa686821..142e92ff00158 100644 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat @@ -1,20 +1,3 @@ -@REM -@REM Licensed to the Apache Software Foundation (ASF) under one or more -@REM contributor license agreements. See the NOTICE file distributed with -@REM this work for additional information regarding copyright ownership. -@REM The ASF licenses this file to You under the Apache License, Version 2.0 -@REM (the "License"); you may not use this file except in compliance with -@REM the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, software -@REM distributed under the License is distributed on an "AS IS" BASIS, -@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@REM See the License for the specific language governing permissions and -@REM limitations under the License. -@REM - @echo off @REM @REM Licensed to the Apache Software Foundation (ASF) under one or more @@ -118,8 +101,83 @@ if /I "%SUBCOMMAND%"=="publish" ( exit /b 2 ) +set "MODULE_DIR=%SCRIPT_DIR%..\..\.." + +@REM Resolve the release version. Production always reads Maven's project.version; tests/CI may +@REM override it, but only with both CAMEL_PACKAGE_TEST_MODE=true and CAMEL_PACKAGE_TEST_VERSION +@REM set, so production can never accidentally skip the real Maven version. +if not "%CAMEL_PACKAGE_TEST_VERSION%"=="" ( + if not "%CAMEL_PACKAGE_TEST_MODE%"=="true" ( + echo Error: CAMEL_PACKAGE_TEST_VERSION requires CAMEL_PACKAGE_TEST_MODE=true. 1>&2 + exit /b 2 + ) + set "PROJECT_VERSION=%CAMEL_PACKAGE_TEST_VERSION%" +) else ( + for /f "usebackq delims=" %%v in (`mvn -q -B -ntp -pl "%MODULE_DIR%" org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout`) do set "PROJECT_VERSION=%%v" +) + +if "!PROJECT_VERSION:~-8!"=="SNAPSHOT" ( + echo Error: refusing to prepare packages for a snapshot version '!PROJECT_VERSION!'. 1>&2 + exit /b 2 +) + +set "TAR=%MODULE_DIR%\target\camel-launcher-!PROJECT_VERSION!-bin.tar.gz" +set "ZIP=%MODULE_DIR%\target\camel-launcher-!PROJECT_VERSION!-bin.zip" +set "INSTALL_SH_SRC=%MODULE_DIR%\src\install\install.sh" +set "INSTALL_PS1_SRC=%MODULE_DIR%\src\install\install.ps1" + +if not exist "!TAR!" ( + echo Error: release TAR not found: !TAR! 1>&2 + exit /b 1 +) +if not exist "!ZIP!" ( + echo Error: release ZIP not found: !ZIP! 1>&2 + exit /b 1 +) +if not exist "!INSTALL_SH_SRC!" ( + echo Error: installer source not found: !INSTALL_SH_SRC! 1>&2 + exit /b 1 +) +if not exist "!INSTALL_PS1_SRC!" ( + echo Error: installer source not found: !INSTALL_PS1_SRC! 1>&2 + exit /b 1 +) + +@REM Recreate only the prepared website staging directory (leave the rest of target\jreleaser alone). +set "WEBSITE_DIR=%MODULE_DIR%\target\jreleaser\website" +if exist "!WEBSITE_DIR!" rd /s /q "!WEBSITE_DIR!" +mkdir "!WEBSITE_DIR!" + +copy /y "!INSTALL_SH_SRC!" "!WEBSITE_DIR!\install.sh" >nul +if errorlevel 1 ( + echo Error: failed to copy install.sh. 1>&2 + exit /b 1 +) +copy /y "!INSTALL_PS1_SRC!" "!WEBSITE_DIR!\install.ps1" >nul +if errorlevel 1 ( + echo Error: failed to copy install.ps1. 1>&2 + exit /b 1 +) + +fc /b "!INSTALL_SH_SRC!" "!WEBSITE_DIR!\install.sh" >nul +if errorlevel 1 ( + echo Error: copied install.sh does not match its source. 1>&2 + exit /b 1 +) +fc /b "!INSTALL_PS1_SRC!" "!WEBSITE_DIR!\install.ps1" >nul +if errorlevel 1 ( + echo Error: copied install.ps1 does not match its source. 1>&2 + exit /b 1 +) + +call java "%MODULE_DIR%\src\jreleaser\java\WebsiteManifestGenerator.java" --version !PROJECT_VERSION! --tar "!TAR!" --zip "!ZIP!" --output "!WEBSITE_DIR!\camel-cli" --latest !WEBSITE_LATEST! +if errorlevel 1 ( + echo Error: website manifest generation failed. 1>&2 + exit /b 1 +) + echo Preparing packages for channel '%CHANNEL%' ^(packagers: !PACKAGERS!^)... -call mvn -B -ntp -pl "%SCRIPT_DIR%..\.." -Dcamel.pkg.channel=%CHANNEL% -Dcamel.pkg.ltsLine=%LTS_LINE% -Dcamel.pkg.packagers=!PACKAGERS! -Dcamel.pkg.brewFormula=!BREW_FORMULA! -Dcamel.pkg.brewLtsFormula=!BREW_LTS_FORMULA! -Dcamel.pkg.sdkmanDefault=!SDKMAN_DEFAULT! jreleaser:config jreleaser:prepare jreleaser:package -Djreleaser.dry.run=true +call mvn -B -ntp -pl "%MODULE_DIR%" -Dcamel.pkg.channel=%CHANNEL% -Dcamel.pkg.ltsLine=%LTS_LINE% -Dcamel.pkg.packagers=!PACKAGERS! -Dcamel.pkg.brewFormula=!BREW_FORMULA! -Dcamel.pkg.brewLtsFormula=!BREW_LTS_FORMULA! -Dcamel.pkg.sdkmanDefault=!SDKMAN_DEFAULT! jreleaser:config jreleaser:prepare jreleaser:package -Djreleaser.dry.run=true exit /b %ERRORLEVEL% :usage diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh index c4e66bcf31f83..ee0afb5bdbaae 100755 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -19,7 +19,7 @@ set -eu SCRIPT_DIR=`CDPATH= cd -- "$(dirname -- "$0")" && pwd` -MODULE_DIR=`CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd` +MODULE_DIR=`CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd` SUPPORTED_LTS="$SCRIPT_DIR/../supported-lts.yml" usage() { @@ -112,6 +112,76 @@ if [ "$SUBCOMMAND" = "publish" ]; then fi # --- prepare: no remote mutation, no credentials --- + +# Resolve the release version. Production always reads Maven's project.version; tests/CI may +# override it, but only with both CAMEL_PACKAGE_TEST_MODE=true and CAMEL_PACKAGE_TEST_VERSION set, +# so production can never accidentally skip the real Maven version. +if [ -n "${CAMEL_PACKAGE_TEST_VERSION:-}" ]; then + if [ "${CAMEL_PACKAGE_TEST_MODE:-}" != "true" ]; then + echo "Error: CAMEL_PACKAGE_TEST_VERSION requires CAMEL_PACKAGE_TEST_MODE=true." 1>&2 + exit 2 + fi + PROJECT_VERSION="$CAMEL_PACKAGE_TEST_VERSION" +else + PROJECT_VERSION=`mvn -q -B -ntp -pl "$MODULE_DIR" org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate \ + -Dexpression=project.version -DforceStdout` +fi + +case "$PROJECT_VERSION" in + *-SNAPSHOT) + echo "Error: refusing to prepare packages for a snapshot version '$PROJECT_VERSION'." 1>&2 + exit 2 + ;; +esac + +# Locate the release artifacts and canonical website installer sources. +TAR="$MODULE_DIR/target/camel-launcher-$PROJECT_VERSION-bin.tar.gz" +ZIP="$MODULE_DIR/target/camel-launcher-$PROJECT_VERSION-bin.zip" +INSTALL_SH_SRC="$MODULE_DIR/src/install/install.sh" +INSTALL_PS1_SRC="$MODULE_DIR/src/install/install.ps1" + +if [ ! -f "$TAR" ]; then + echo "Error: release TAR not found: $TAR" 1>&2 + exit 1 +fi +if [ ! -f "$ZIP" ]; then + echo "Error: release ZIP not found: $ZIP" 1>&2 + exit 1 +fi +if [ ! -f "$INSTALL_SH_SRC" ]; then + echo "Error: installer source not found: $INSTALL_SH_SRC" 1>&2 + exit 1 +fi +if [ ! -f "$INSTALL_PS1_SRC" ]; then + echo "Error: installer source not found: $INSTALL_PS1_SRC" 1>&2 + exit 1 +fi + +# Recreate only the prepared website staging directory (leave the rest of target/jreleaser alone). +WEBSITE_DIR="$MODULE_DIR/target/jreleaser/website" +rm -rf "$WEBSITE_DIR" +mkdir -p "$WEBSITE_DIR" + +cp -p "$INSTALL_SH_SRC" "$WEBSITE_DIR/install.sh" +cp -p "$INSTALL_PS1_SRC" "$WEBSITE_DIR/install.ps1" + +if ! cmp -s "$INSTALL_SH_SRC" "$WEBSITE_DIR/install.sh"; then + echo "Error: copied install.sh does not match its source." 1>&2 + exit 1 +fi +if ! cmp -s "$INSTALL_PS1_SRC" "$WEBSITE_DIR/install.ps1"; then + echo "Error: copied install.ps1 does not match its source." 1>&2 + exit 1 +fi + +if ! java "$MODULE_DIR/src/jreleaser/java/WebsiteManifestGenerator.java" \ + --version "$PROJECT_VERSION" --tar "$TAR" --zip "$ZIP" \ + --output "$WEBSITE_DIR/camel-cli" \ + --latest "$WEBSITE_LATEST"; then + echo "Error: website manifest generation failed." 1>&2 + exit 1 +fi + # Map the plan to JReleaser system properties. Actual property names are validated # against the JReleaser 1.25.0 reference during template work (Tasks 7-9). echo "Preparing packages for channel '$CHANNEL' (packagers: $PACKAGERS)..." diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanBatTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanBatTest.java index b7abd15988e85..ed5831965dd0e 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanBatTest.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanBatTest.java @@ -16,19 +16,29 @@ */ package org.apache.camel.dsl.jbang.launcher; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.security.MessageDigest; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledOnOs; import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -39,6 +49,10 @@ class PackagePlanBatTest { private static final Path SCRIPT = Paths.get("src/jreleaser/bin/camel-package.bat"); + private static final Path MODULE_DIR = Paths.get("").toAbsolutePath(); + + /** Fixture release version used by the website-staging integration tests. Never a real release. */ + private static final String TEST_VERSION = "9.9.9"; private static final class Result { int exit; @@ -47,6 +61,10 @@ private static final class Result { } private Result run(String... args) throws Exception { + return run(Map.of(), args); + } + + private Result run(Map extraEnv, String... args) throws Exception { List cmd = new ArrayList<>(); cmd.add("cmd.exe"); cmd.add("/c"); @@ -54,10 +72,12 @@ private Result run(String... args) throws Exception { for (String a : args) { cmd.add(a); } - Process p = new ProcessBuilder(cmd).start(); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.environment().putAll(extraEnv); + Process p = pb.start(); String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); - assertTrue(p.waitFor(30, TimeUnit.SECONDS), "wrapper did not exit in time"); + assertTrue(p.waitFor(60, TimeUnit.SECONDS), "wrapper did not exit in time"); Result r = new Result(); r.exit = p.exitValue(); r.stdout = out; @@ -65,6 +85,68 @@ private Result run(String... args) throws Exception { return r; } + /** + * Creates a fake mvn.cmd on PATH that records its arguments instead of running JReleaser, so website-staging tests + * can prove the staging step ran (or did not run) without depending on jreleaser.yml existing yet. + */ + private Map testModeEnvWithMvnStub(Path tmp, Path recordFile) throws IOException { + Path stubDir = tmp.resolve("stub-bin"); + Files.createDirectories(stubDir); + Path mvnStub = stubDir.resolve("mvn.cmd"); + Files.writeString(mvnStub, "@echo off\r\necho %* >> \"" + recordFile + "\"\r\nexit /b 0\r\n", + StandardCharsets.UTF_8); + + Map env = new LinkedHashMap<>(); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_VERSION", TEST_VERSION); + env.put("PATH", stubDir + java.io.File.pathSeparator + System.getenv("PATH")); + return env; + } + + private Path writeReleaseFixture(String suffix, String content) throws IOException { + Path target = MODULE_DIR.resolve("target"); + Files.createDirectories(target); + Path file = target.resolve("camel-launcher-" + TEST_VERSION + suffix); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } + + private Path websiteDir() { + return MODULE_DIR.resolve("target/jreleaser/website"); + } + + @AfterEach + void cleanupFixtures() throws IOException { + Files.deleteIfExists(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-bin.tar.gz")); + Files.deleteIfExists(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-bin.zip")); + deleteRecursively(websiteDir()); + } + + private void deleteRecursively(Path dir) throws IOException { + if (!Files.exists(dir)) { + return; + } + try (var stream = Files.walk(dir)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + }); + } + } + + private String sha256Hex(Path file) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(Files.readAllBytes(file)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + @Test void stableSelectsAllFivePackagersWithSdkmanDefault() throws Exception { Result r = run("prepare", "--channel", "stable", "--print-plan"); @@ -94,4 +176,89 @@ void rejectsUnsupportedLtsLine() throws Exception { assertEquals(2, r.exit); } + + @Test + void stableStagesInstallersAndWritesWebsiteManifests(@TempDir Path tmp) throws Exception { + Path tar = writeReleaseFixture("-bin.tar.gz", "fixture-tar"); + Path zip = writeReleaseFixture("-bin.zip", "fixture-zip"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertEquals(0, r.exit, r.stderr); + Path installBat = websiteDir().resolve("install.sh"); + Path installPs1 = websiteDir().resolve("install.ps1"); + assertArrayEquals(Files.readAllBytes(MODULE_DIR.resolve("src/install/install.sh")), + Files.readAllBytes(installBat)); + assertArrayEquals(Files.readAllBytes(MODULE_DIR.resolve("src/install/install.ps1")), + Files.readAllBytes(installPs1)); + + Path versionManifest = websiteDir().resolve("camel-cli/releases/" + TEST_VERSION + ".properties"); + Path latestManifest = websiteDir().resolve("camel-cli/latest.properties"); + assertTrue(Files.exists(versionManifest)); + assertTrue(Files.exists(latestManifest)); + String expected = "format=1\nversion=" + TEST_VERSION + "\ntar_sha256=" + sha256Hex(tar) + "\nzip_sha256=" + + sha256Hex(zip) + "\n"; + assertEquals(expected, Files.readString(versionManifest, StandardCharsets.UTF_8)); + assertArrayEquals(Files.readAllBytes(versionManifest), Files.readAllBytes(latestManifest)); + + assertTrue(Files.exists(recordFile), "the JReleaser (stubbed mvn) step must be reached once staging succeeds"); + String recorded = Files.readString(recordFile, StandardCharsets.UTF_8); + assertTrue(recorded.contains("jreleaser:config"), recorded); + } + + @Test + void ltsStagesInstallersButDoesNotWriteLatest(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar-lts"); + writeReleaseFixture("-bin.zip", "fixture-zip-lts"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "lts", "--lts-line", "4.18"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(Files.exists(websiteDir().resolve("camel-cli/releases/" + TEST_VERSION + ".properties"))); + assertFalse(Files.exists(websiteDir().resolve("camel-cli/latest.properties")), + "LTS prepare must not create or modify latest.properties"); + assertTrue(Files.exists(recordFile)); + } + + @Test + void snapshotVersionFailsBeforeReachingJReleaser(@TempDir Path tmp) throws Exception { + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = new LinkedHashMap<>(testModeEnvWithMvnStub(tmp, recordFile)); + env.put("CAMEL_PACKAGE_TEST_VERSION", "9.9.9-SNAPSHOT"); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertNotEquals(0, r.exit, "a snapshot version must be rejected"); + assertFalse(Files.exists(recordFile), "JReleaser must never be invoked for a snapshot version"); + assertFalse(Files.exists(websiteDir())); + } + + @Test + void missingArtifactFailsBeforeReachingJReleaser(@TempDir Path tmp) throws Exception { + // Deliberately omit the release TAR/ZIP fixtures for TEST_VERSION. + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertNotEquals(0, r.exit, "missing release artifacts must fail before JReleaser runs"); + assertFalse(Files.exists(recordFile)); + assertFalse(Files.exists(websiteDir())); + } + + @Test + void testVersionOverrideRequiresExplicitTestMode(@TempDir Path tmp) throws Exception { + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + env.remove("CAMEL_PACKAGE_TEST_MODE"); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertEquals(2, r.exit, "CAMEL_PACKAGE_TEST_VERSION alone (without test mode) must be a fatal usage error"); + assertFalse(Files.exists(recordFile)); + } } diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanShTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanShTest.java index abd62bd8b5b0a..46245a4d0aa5c 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanShTest.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanShTest.java @@ -16,19 +16,29 @@ */ package org.apache.camel.dsl.jbang.launcher; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.security.MessageDigest; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -39,6 +49,10 @@ class PackagePlanShTest { private static final Path SCRIPT = Paths.get("src/jreleaser/bin/camel-package.sh"); + private static final Path MODULE_DIR = Paths.get("").toAbsolutePath(); + + /** Fixture release version used by the website-staging integration tests. Never a real release. */ + private static final String TEST_VERSION = "9.9.9"; private static final class Result { int exit; @@ -47,16 +61,22 @@ private static final class Result { } private Result run(String... args) throws Exception { + return run(Map.of(), args); + } + + private Result run(Map extraEnv, String... args) throws Exception { List cmd = new ArrayList<>(); cmd.add("/bin/sh"); cmd.add(SCRIPT.toString()); for (String a : args) { cmd.add(a); } - Process p = new ProcessBuilder(cmd).start(); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.environment().putAll(extraEnv); + Process p = pb.start(); String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); - assertTrue(p.waitFor(30, TimeUnit.SECONDS), "wrapper did not exit in time"); + assertTrue(p.waitFor(60, TimeUnit.SECONDS), "wrapper did not exit in time"); Result r = new Result(); r.exit = p.exitValue(); r.stdout = out; @@ -64,6 +84,69 @@ private Result run(String... args) throws Exception { return r; } + /** + * Creates a fake `mvn` on PATH that records its arguments instead of running JReleaser, so website-staging tests + * can prove the staging step ran (or did not run) without depending on jreleaser.yml existing yet. + */ + private Map testModeEnvWithMvnStub(Path tmp, Path recordFile) throws IOException { + Path stubDir = tmp.resolve("stub-bin"); + Files.createDirectories(stubDir); + Path mvnStub = stubDir.resolve("mvn"); + Files.writeString(mvnStub, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"" + recordFile + "\"\nexit 0\n", + StandardCharsets.UTF_8); + assertTrue(mvnStub.toFile().setExecutable(true)); + + Map env = new LinkedHashMap<>(); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_VERSION", TEST_VERSION); + env.put("PATH", stubDir + java.io.File.pathSeparator + System.getenv("PATH")); + return env; + } + + private Path writeReleaseFixture(String suffix, String content) throws IOException { + Path target = MODULE_DIR.resolve("target"); + Files.createDirectories(target); + Path file = target.resolve("camel-launcher-" + TEST_VERSION + suffix); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } + + private Path websiteDir() { + return MODULE_DIR.resolve("target/jreleaser/website"); + } + + @AfterEach + void cleanupFixtures() throws IOException { + Files.deleteIfExists(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-bin.tar.gz")); + Files.deleteIfExists(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-bin.zip")); + deleteRecursively(websiteDir()); + } + + private void deleteRecursively(Path dir) throws IOException { + if (!Files.exists(dir)) { + return; + } + try (var stream = Files.walk(dir)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + }); + } + } + + private String sha256Hex(Path file) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(Files.readAllBytes(file)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + @Test void stableSelectsAllFivePackagersWithSdkmanDefault() throws Exception { Result r = run("prepare", "--channel", "stable", "--print-plan"); @@ -137,4 +220,90 @@ void rejectsUnknownSubcommand() throws Exception { assertEquals(2, r.exit); } + + @Test + void stableStagesInstallersAndWritesWebsiteManifests(@TempDir Path tmp) throws Exception { + Path tar = writeReleaseFixture("-bin.tar.gz", "fixture-tar"); + Path zip = writeReleaseFixture("-bin.zip", "fixture-zip"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertEquals(0, r.exit, r.stderr); + Path installSh = websiteDir().resolve("install.sh"); + Path installPs1 = websiteDir().resolve("install.ps1"); + assertArrayEquals(Files.readAllBytes(MODULE_DIR.resolve("src/install/install.sh")), + Files.readAllBytes(installSh)); + assertArrayEquals(Files.readAllBytes(MODULE_DIR.resolve("src/install/install.ps1")), + Files.readAllBytes(installPs1)); + + Path versionManifest = websiteDir().resolve("camel-cli/releases/" + TEST_VERSION + ".properties"); + Path latestManifest = websiteDir().resolve("camel-cli/latest.properties"); + assertTrue(Files.exists(versionManifest)); + assertTrue(Files.exists(latestManifest)); + String expected = "format=1\nversion=" + TEST_VERSION + "\ntar_sha256=" + sha256Hex(tar) + "\nzip_sha256=" + + sha256Hex(zip) + "\n"; + assertEquals(expected, Files.readString(versionManifest, StandardCharsets.UTF_8)); + assertArrayEquals(Files.readAllBytes(versionManifest), Files.readAllBytes(latestManifest)); + + assertTrue(Files.exists(recordFile), "the JReleaser (stubbed mvn) step must be reached once staging succeeds"); + String recorded = Files.readString(recordFile, StandardCharsets.UTF_8); + assertTrue(recorded.contains("jreleaser:config"), recorded); + } + + @Test + void ltsStagesInstallersButDoesNotWriteLatest(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar-lts"); + writeReleaseFixture("-bin.zip", "fixture-zip-lts"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "lts", "--lts-line", "4.18"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(Files.exists(websiteDir().resolve("camel-cli/releases/" + TEST_VERSION + ".properties"))); + assertFalse(Files.exists(websiteDir().resolve("camel-cli/latest.properties")), + "LTS prepare must not create or modify latest.properties"); + assertTrue(Files.exists(recordFile)); + } + + @Test + void snapshotVersionFailsBeforeReachingJReleaser(@TempDir Path tmp) throws Exception { + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = new LinkedHashMap<>(testModeEnvWithMvnStub(tmp, recordFile)); + env.put("CAMEL_PACKAGE_TEST_VERSION", "9.9.9-SNAPSHOT"); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertNotEquals(0, r.exit, "a snapshot version must be rejected"); + assertFalse(Files.exists(recordFile), "JReleaser must never be invoked for a snapshot version"); + assertFalse(Files.exists(websiteDir())); + } + + @Test + void missingArtifactFailsBeforeReachingJReleaser(@TempDir Path tmp) throws Exception { + // Deliberately omit the release TAR/ZIP fixtures for TEST_VERSION. + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertNotEquals(0, r.exit, "missing release artifacts must fail before JReleaser runs"); + assertFalse(Files.exists(recordFile)); + assertFalse(Files.exists(websiteDir())); + } + + @Test + void testVersionOverrideRequiresExplicitTestMode(@TempDir Path tmp) throws Exception { + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + env.remove("CAMEL_PACKAGE_TEST_MODE"); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertEquals(2, r.exit, "CAMEL_PACKAGE_TEST_VERSION alone (without test mode) must be a fatal usage error"); + assertTrue(r.stderr.toLowerCase().contains("test_mode"), r.stderr); + assertFalse(Files.exists(recordFile)); + } } From 45b4320185f46334910885d1172ab37e5cdc9267 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:17:57 -0400 Subject: [PATCH 13/16] CAMEL-23703: camel-launcher - jreleaser.yml for five packagers, verified against 1.25.0 Authors jreleaser.yml with one JAVA_BINARY distribution and the brew, sdkman, winget, scoop, and chocolatey packagers, per the knowledge-cutoff rule this was validated against live JReleaser 1.25.0 docs and, more importantly, against actual jreleaser:config/jreleaser:prepare dry-runs using the pinned plugin JAR rather than assumed from documentation alone. That empirical pass caught several real defects: - pom.xml's was wrongly nested inside a wrapper element (a bug from the earlier plugin-pinning commit); it must be a direct child. - `type: BINARY` does not carry Java-distribution semantics; JAVA_BINARY does, confirmed by the generated Homebrew formula correctly auto-adding `depends_on "openjdk@17"`. - `project.java` is deprecated in 1.25.0 in favor of `project.languages.java`. - `project.vendor` and `distributions.camel-cli.tags` are required in practice despite docs implying defaults; validation fails without them. - Every packager defaults to a GitHub-release download URL. Camel publishes to Maven Central, not GitHub release assets, so each packager now sets an explicit `downloadUrl` pointing at the same repo1.maven.org coordinates Phase 3A's installers and docs/getting-started.adoc already use. - `{{ Env.X }}` templates resolve real OS environment variables (System#getenv), not Maven -D system properties, so the wrappers now export CAMEL_PKG_BREW_FORMULA instead of passing -Dcamel.pkg.brewFormula. Enum fields (active, sdkman.command) are parsed before templates resolve and cannot use `{{ Env.X }}` at all; a nested system-property override was tried for sdkman.command and confirmed (via trace.log) not to take effect. SDKMAN has no local `prepare` output - `command` only affects the live publish-time API call - so per-channel MAJOR/MINOR selection is deferred to Phase 5. - Per-channel packager selection (e.g. `lts` excluding `scoop`) uses the jreleaser:prepare/package Mojo's own -Djreleaser.distributions/-Djreleaser.packagers include filters (confirmed via `mvn help:describe` on the pinned plugin), not a templated `active` field. - A placeholder JRELEASER_GITHUB_TOKEN is exported only when unset, since -Djreleaser.dry.run=true never performs a network call but config validation still requires a non-blank token. Also fixes a recurring camel-package.bat license-header duplication: the license-maven-plugin's default .bat style doesn't recognize a header following the required leading `@echo off` line and re-prepends a duplicate on every `license:format` run. Excluded the file from that goal at the module level (same pattern already used by components/camel-diagram/pom.xml) rather than patching the symptom repeatedly. Known gap: `--channel stable --lts-line X.Y` is specified to publish both an unversioned and a versioned Homebrew formula from one release; this file declares only one `brew` packager per distribution, so today it only produces the formula selected by CAMEL_PKG_BREW_FORMULA. Deferred to a follow-up task. Co-Authored-By: Claude Sonnet 5 --- dsl/camel-jbang/camel-launcher/jreleaser.yml | 100 ++++++++++++++++++ dsl/camel-jbang/camel-launcher/pom.xml | 25 ++++- .../src/jreleaser/bin/camel-package.bat | 17 ++- .../src/jreleaser/bin/camel-package.sh | 26 +++-- 4 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 dsl/camel-jbang/camel-launcher/jreleaser.yml diff --git a/dsl/camel-jbang/camel-launcher/jreleaser.yml b/dsl/camel-jbang/camel-launcher/jreleaser.yml new file mode 100644 index 0000000000000..128fe8d758f8c --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml @@ -0,0 +1,100 @@ +# +# 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. +# + +# NOTE: field names and packager semantics verified against the JReleaser 1.25.0 +# reference (ctx7 docs, cross-checked with `mvn help:describe` and actual +# `jreleaser:prepare` dry-run output against the pinned plugin JAR) before +# authoring this file. Key findings that shaped this configuration: +# - `type: JAVA_BINARY` (not BINARY): Homebrew's automatic `openjdk` dependency is +# documented as applying to "applicable Java distributions"; confirmed the +# generated formula includes `depends_on "openjdk@17"`. +# - SDKMAN has no boolean "default" flag; `command: MAJOR` publishes and sets the +# new version as the candidate default, `command: MINOR` publishes without +# moving the default. SDKMAN produces no local `prepare` output (API-only), so +# channel-specific MAJOR/MINOR selection only matters at Phase 5 publish time. +# - `{{ Env.X }}` reads real OS environment variables (java.lang.System#getenv), +# not Maven -D system properties, so the wrappers export CAMEL_PKG_* before +# invoking JReleaser. Enum-typed fields (active, sdkman.command) are parsed +# before Mustache templates resolve, so `{{ Env.X }}` cannot be used on them. +# - Artifact paths are relative to the repo root: empirically (mvn jreleaser:config), +# the JReleaser Maven plugin's basedir defaults to the multi-module reactor root, +# not this file's directory, unlike the standalone JReleaser CLI. +# - Per-channel packager selection (e.g. excluding `scoop` on the `lts` channel) +# is done via the jreleaser:prepare/jreleaser:package Mojo's own +# `-Djreleaser.packagers` include filter, not via a templated `active` field. +# - Every packager overrides `downloadUrl` to the Maven Central coordinates +# (matching Phase 3A's installers and docs/getting-started.adoc); JReleaser's +# default download URL points at a GitHub release asset, which this project +# does not publish artifacts to. +# - `project.vendor` and `distributions.camel-cli.tags` are required fields in +# practice (validation fails without them) despite docs implying defaults. +# This file renders no package content itself; per-packager overrides live under +# src/jreleaser/distributions/camel-cli//. +# +# KNOWN GAP: `--channel stable --lts-line X.Y` is specified to also publish a +# versioned Homebrew formula (`camel@X.Y`) alongside the unversioned `camel` +# formula. This file declares only one `brew` packager per distribution, so today +# it only ever produces the single formula selected by CAMEL_PKG_BREW_FORMULA. +# Producing both formulae from one release is deferred to a follow-up task. + +project: + name: camel-cli + description: Apache Camel CLI + links: + homepage: https://camel.apache.org/ + authors: + - The Apache Camel Team + license: Apache-2.0 + vendor: The Apache Software Foundation + languages: + java: + groupId: org.apache.camel + version: 17 + +distributions: + camel-cli: + type: JAVA_BINARY + tags: + - cli + executable: + name: camel + artifacts: + - path: "dsl/camel-jbang/camel-launcher/target/camel-launcher-{{projectVersion}}-bin.zip" + brew: + active: RELEASE + formulaName: "{{ Env.CAMEL_PKG_BREW_FORMULA }}" + downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + sdkman: + active: RELEASE + candidate: camel + downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + # command (MAJOR/MINOR) defaults to MAJOR here (correct for `stable`); see the + # NOTE above on why per-channel selection is deferred to Phase 5 publish. + winget: + active: RELEASE + downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + package: + identifier: Apache.CamelCLI + name: Camel CLI + scoop: + active: RELEASE + downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + chocolatey: + active: RELEASE + packageName: camel + remoteBuild: true + downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index b5f35e69bd572..e1470e08698d6 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -342,9 +342,28 @@ ${jreleaser-plugin-version} false - - ${project.basedir}/jreleaser.yml - + ${project.basedir}/jreleaser.yml + true + + + + + com.mycila + license-maven-plugin + + + + + src/jreleaser/bin/camel-package.bat + + + diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat index 142e92ff00158..44fd77f15c274 100644 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat @@ -176,8 +176,23 @@ if errorlevel 1 ( exit /b 1 ) +@REM jreleaser.yml reads the Homebrew formula name via `{{ Env.CAMEL_PKG_BREW_FORMULA }}`. +@REM JReleaser's `Env.` template prefix resolves real OS environment variables, not +@REM Maven -D system properties, so this must be a real env var. Verified empirically +@REM with `jreleaser:prepare`. +set "CAMEL_PKG_BREW_FORMULA=!BREW_FORMULA!" + +@REM `-Djreleaser.dry.run=true` below never performs a network call, so a placeholder +@REM satisfies JReleaser's config validation (it requires a non-blank release token) +@REM without requiring a real credential. Never overrides a token the caller already set. +if "%JRELEASER_GITHUB_TOKEN%"=="" set "JRELEASER_GITHUB_TOKEN=dry-run-placeholder" + +@REM `-Djreleaser.distributions` / `-Djreleaser.packagers` are the JReleaser Maven +@REM plugin's own include filters (confirmed via `mvn help:describe` on the pinned +@REM 1.25.0 plugin), used to select which packagers run for this channel; e.g. `lts` +@REM excludes `scoop` by omitting it from !PACKAGERS!. echo Preparing packages for channel '%CHANNEL%' ^(packagers: !PACKAGERS!^)... -call mvn -B -ntp -pl "%MODULE_DIR%" -Dcamel.pkg.channel=%CHANNEL% -Dcamel.pkg.ltsLine=%LTS_LINE% -Dcamel.pkg.packagers=!PACKAGERS! -Dcamel.pkg.brewFormula=!BREW_FORMULA! -Dcamel.pkg.brewLtsFormula=!BREW_LTS_FORMULA! -Dcamel.pkg.sdkmanDefault=!SDKMAN_DEFAULT! jreleaser:config jreleaser:prepare jreleaser:package -Djreleaser.dry.run=true +call mvn -B -ntp -pl "%MODULE_DIR%" -Djreleaser.distributions=camel-cli -Djreleaser.packagers=!PACKAGERS! jreleaser:config jreleaser:prepare jreleaser:package -Djreleaser.dry.run=true exit /b %ERRORLEVEL% :usage diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh index ee0afb5bdbaae..8fc4f147ee379 100755 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -182,15 +182,25 @@ if ! java "$MODULE_DIR/src/jreleaser/java/WebsiteManifestGenerator.java" \ exit 1 fi -# Map the plan to JReleaser system properties. Actual property names are validated -# against the JReleaser 1.25.0 reference during template work (Tasks 7-9). +# jreleaser.yml reads the Homebrew formula name via `{{ Env.CAMEL_PKG_BREW_FORMULA }}`. +# JReleaser's `Env.` template prefix resolves real OS environment variables +# (java.lang.System#getenv), not Maven -D system properties, so this must be +# exported rather than passed as -D. Verified empirically with `jreleaser:prepare`. +export CAMEL_PKG_BREW_FORMULA="$BREW_FORMULA" + +# `-Djreleaser.dry.run=true` below never performs a network call, so a placeholder +# satisfies JReleaser's config validation (it requires a non-blank release token) +# without requiring a real credential. Never overrides a token the caller already set. +: "${JRELEASER_GITHUB_TOKEN:=dry-run-placeholder}" +export JRELEASER_GITHUB_TOKEN + +# `-Djreleaser.distributions` / `-Djreleaser.packagers` are the JReleaser Maven +# plugin's own include filters (confirmed via `mvn help:describe` on the pinned +# 1.25.0 plugin), used to select which packagers run for this channel; e.g. `lts` +# excludes `scoop` by omitting it from $PACKAGERS. echo "Preparing packages for channel '$CHANNEL' (packagers: $PACKAGERS)..." mvn -B -ntp -pl "$MODULE_DIR" \ - -Dcamel.pkg.channel="$CHANNEL" \ - -Dcamel.pkg.ltsLine="$LTS_LINE" \ - -Dcamel.pkg.packagers="$PACKAGERS" \ - -Dcamel.pkg.brewFormula="$BREW_FORMULA" \ - -Dcamel.pkg.brewLtsFormula="$BREW_LTS_FORMULA" \ - -Dcamel.pkg.sdkmanDefault="$SDKMAN_DEFAULT" \ + -Djreleaser.distributions=camel-cli \ + -Djreleaser.packagers="$PACKAGERS" \ jreleaser:config jreleaser:prepare jreleaser:package \ -Djreleaser.dry.run=true From 7184aa99a5203c8e057f204eb1a279e1e92ea663 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:39:00 -0400 Subject: [PATCH 14/16] CAMEL-23703: camel-launcher - Homebrew + SDKMAN template overrides (caveats/notes) Authored a custom formula.rb.tpl (ground truth extracted via the pinned plugin's jreleaser:template-generate goal, not guessed) that: - depends_on the unversioned Homebrew "openjdk" (Camel only needs 17+, so the always-current formula never goes stale, unlike depends_on "openjdk@17"). - Installs a CAMEL_FALLBACK_JAVA-exporting wrapper via write_env_script instead of a bare symlink, so `camel` works even with no other Java on PATH. - Emits the required caveat text, plus a keg_only :versioned_formula caveat for versioned formulae, gated by a new brewVersionedFormula extraProperty (empty string is Mustache-falsy) computed by both wrapper scripts. Empirical jreleaser:prepare dry-runs against the pinned plugin surfaced two real bugs beyond the plan's stated scope, both fixed here: - distributions.camel-cli.executable had no unixExtension, so distributionExecutableUnix defaulted to "camel" instead of the actual on-disk "camel.sh" (see src/main/assembly/bin.xml), producing a broken symlink in every packager that installs from libexec (Homebrew, and by the same mechanism Scoop/Chocolatey in Task 8). - The brew packager's templateDirectory default resolves against the JReleaser Maven plugin's basedir (the reactor root, confirmed in Task 6), not this module's directory, so formula.rb.tpl was silently ignored until templateDirectory was set explicitly to the module-relative path. Testing a versioned formula name (camel@4.20) also surfaced that JReleaser does not apply Homebrew's "AT" class/filename convention to formulaName, producing an invalid Ruby class and wrong output filename; documented as an extension of the existing "KNOWN GAP" note rather than fixed here, since a correct fix needs formulaName pre-conversion, out of Task 7's scope. SDKMAN has no local template extension point in 1.25.0 (confirmed: template- generate writes no files for packagerName=sdkman); it only exposes releaseNotesUrl, with actual candidate publication via its Vendor API at Phase 5 publish time. The required note text is stored as a plain repo asset for Phase 5 to consume, documented as not yet wired into JReleaser. Also removes the incidental cask.rb.tpl/README.md.tpl scaffold files (this distribution has no cask, and JReleaser falls back to its own embedded default README.md.tpl per-file when one isn't provided). Co-Authored-By: Claude Sonnet 5 --- dsl/camel-jbang/camel-launcher/jreleaser.yml | 23 ++++++ dsl/camel-jbang/camel-launcher/pom.xml | 9 +++ .../src/jreleaser/bin/camel-package.bat | 5 ++ .../src/jreleaser/bin/camel-package.sh | 8 ++ .../camel-cli/brew/formula.rb.tpl | 81 +++++++++++++++++++ .../camel-cli/sdkman/release-notes.md | 31 +++++++ 6 files changed, 157 insertions(+) create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/sdkman/release-notes.md diff --git a/dsl/camel-jbang/camel-launcher/jreleaser.yml b/dsl/camel-jbang/camel-launcher/jreleaser.yml index 128fe8d758f8c..f68a12b0ae8e0 100644 --- a/dsl/camel-jbang/camel-launcher/jreleaser.yml +++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml @@ -50,6 +50,12 @@ # formula. This file declares only one `brew` packager per distribution, so today # it only ever produces the single formula selected by CAMEL_PKG_BREW_FORMULA. # Producing both formulae from one release is deferred to a follow-up task. +# Empirically confirmed (jreleaser:prepare with CAMEL_PKG_BREW_FORMULA=camel@4.20): +# JReleaser does not apply Homebrew's "AT" naming convention to `formulaName`, so a +# literal "camel@4.20" renders an invalid Ruby class (`class Camel@4.20 < Formula`) +# and a wrong output filename (`20.rb` instead of `camel@4.20.rb`). Producing a +# correct versioned formula needs formulaName pre-converted to Homebrew's +# convention (e.g. "CamelAT420") as part of the same follow-up task above. project: name: camel-cli @@ -72,12 +78,29 @@ distributions: - cli executable: name: camel + # The zip/tar bin/ directory ships `camel.sh` (Unix) and `camel.bat`/`camel.exe` + # (Windows) per src/main/assembly/bin.xml; without this, `distributionExecutableUnix` + # defaults to the bare distribution name ("camel"), which does not exist on disk and + # produces a broken symlink in the Homebrew/Scoop/Chocolatey templates. Windows already + # defaults correctly to `bat`. + unixExtension: sh artifacts: - path: "dsl/camel-jbang/camel-launcher/target/camel-launcher-{{projectVersion}}-bin.zip" brew: active: RELEASE formulaName: "{{ Env.CAMEL_PKG_BREW_FORMULA }}" downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + # templateDirectory defaults to "src/jreleaser/distributions/#{distribution.name}/brew" + # resolved against the JReleaser basedir, which is the multi-module reactor root (see + # NOTE above), not this module's directory. Empirically confirmed (jreleaser:prepare + # trace.log): without this override the module's formula.rb.tpl is silently ignored + # and JReleaser falls back to its embedded default template. + templateDirectory: "dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew" + extraProperties: + # Non-empty only for versioned formulae (e.g. "camel@4.20"); used by + # formula.rb.tpl as a Mustache truthiness check (empty string == falsy) + # to add `keg_only :versioned_formula` and its PATH caveat. + versionedFormula: "{{ Env.CAMEL_PKG_BREW_VERSIONED }}" sdkman: active: RELEASE candidate: camel diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index e1470e08698d6..b3421ad0af119 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -352,6 +352,14 @@ so `license:format` keeps prepending a duplicate header on every run. The file already carries a correct, manually-verified header, so it is excluded here rather than reordering `@echo off` out of the required first line. + + formula.rb.tpl has no recognized comment-style mapping for the compound + ".rb.tpl" extension (license:format only inspects the final extension, ".tpl", + which is unmapped), so it fails outright rather than being reformatted. The + file is a Ruby (Homebrew Formula) template and already carries a manually + verified Ruby-style (#) header, so it is excluded here instead of adding a + repo-wide ".tpl" mapping that would assume a single comment style for every + future template of any language. --> com.mycila @@ -361,6 +369,7 @@ src/jreleaser/bin/camel-package.bat + src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat index 44fd77f15c274..c22729e9689f9 100644 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat @@ -182,6 +182,11 @@ if errorlevel 1 ( @REM with `jreleaser:prepare`. set "CAMEL_PKG_BREW_FORMULA=!BREW_FORMULA!" +@REM Non-empty only for a versioned formula (e.g. "camel@4.20"); formula.rb.tpl uses +@REM this to add `keg_only :versioned_formula` and its PATH caveat. +set "CAMEL_PKG_BREW_VERSIONED=" +echo !BREW_FORMULA! | findstr /C:"@" >nul && set "CAMEL_PKG_BREW_VERSIONED=!BREW_FORMULA!" + @REM `-Djreleaser.dry.run=true` below never performs a network call, so a placeholder @REM satisfies JReleaser's config validation (it requires a non-blank release token) @REM without requiring a real credential. Never overrides a token the caller already set. diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh index 8fc4f147ee379..82287b77e2b97 100755 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -188,6 +188,14 @@ fi # exported rather than passed as -D. Verified empirically with `jreleaser:prepare`. export CAMEL_PKG_BREW_FORMULA="$BREW_FORMULA" +# Non-empty only for a versioned formula (e.g. "camel@4.20"); formula.rb.tpl uses +# this to add `keg_only :versioned_formula` and its PATH caveat. +case "$BREW_FORMULA" in + *@*) CAMEL_PKG_BREW_VERSIONED="$BREW_FORMULA" ;; + *) CAMEL_PKG_BREW_VERSIONED="" ;; +esac +export CAMEL_PKG_BREW_VERSIONED + # `-Djreleaser.dry.run=true` below never performs a network call, so a placeholder # satisfies JReleaser's config validation (it requires a non-blank release token) # without requiring a real credential. Never overrides a token the caller already set. diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl new file mode 100644 index 0000000000000..fa5ff11f41198 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl @@ -0,0 +1,81 @@ +# +# 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. +# + +# {{jreleaserCreationStamp}} +{{#brewRequireRelative}} +require_relative "{{.}}" +{{/brewRequireRelative}} + +class {{brewFormulaName}} < Formula + desc "{{projectDescription}}" + homepage "{{projectLinkHomepage}}" + url "{{distributionUrl}}"{{#brewDownloadStrategy}}, :using => {{.}}{{/brewDownloadStrategy}} + version "{{projectVersion}}" + sha256 "{{distributionChecksumSha256}}" + license "{{projectLicense}}" + + {{#brewVersionedFormula}} + keg_only :versioned_formula + {{/brewVersionedFormula}} + + {{#brewHasLivecheck}} + livecheck do + {{#brewLivecheck}} + {{.}} + {{/brewLivecheck}} + end + {{/brewHasLivecheck}} + + # Unversioned (not "openjdk@17"): the launcher only requires Java 17+, and the + # plain formula always tracks a current release, so it never goes stale/EOL the + # way a version-pinned dependency would. + depends_on "openjdk" + + def install + libexec.install Dir["*"] + # write_env_script wraps the upstream camel.sh with CAMEL_FALLBACK_JAVA set, so + # `camel` works out of the box even if no other Java is on PATH/JAVA_HOME. + (bin/"{{distributionExecutableName}}").write_env_script libexec/"bin/camel.sh", + CAMEL_FALLBACK_JAVA: "#{Formula["openjdk"].opt_bin}/java" + end + + def caveats + s = <<~EOS + Apache Camel CLI installs its own Homebrew OpenJDK dependency even if a + compatible Java 17+ is already present. The launcher selects a Java runtime in + this order: JAVACMD, JAVA_HOME/bin/java, the first java on PATH, then + CAMEL_FALLBACK_JAVA (which this formula points at the Homebrew OpenJDK). + + Run "camel version" to verify the install. + EOS + {{#brewVersionedFormula}} + s += <<~EOS + + "#{name}" is keg-only: it was not symlinked into #{HOMEBREW_PREFIX} because + another version of this formula may also be installed. To use this version's + "camel" first in your PATH, run: + echo 'export PATH="#{opt_bin}:$PATH"' >> ~/.zshrc + EOS + {{/brewVersionedFormula}} + s + end + + test do + output = shell_output("#{bin}/{{distributionExecutableName}} --version") + assert_match "{{projectVersion}}", output + end +end diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/sdkman/release-notes.md b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/sdkman/release-notes.md new file mode 100644 index 0000000000000..b712776ef06b4 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/sdkman/release-notes.md @@ -0,0 +1,31 @@ + + + + +Apache Camel CLI requires Java 17 or newer. If you do not already have a +compatible Java, "sdk install java" provides one and SDKMAN will export +JAVA_HOME for it; any other Java 17+ runtime works equally well. The bin/camel +launcher discovers Java via JAVACMD, JAVA_HOME, PATH, then CAMEL_FALLBACK_JAVA, +and prints a diagnostic listing these sources if none qualify. From d7764615ed31225aee30892560e8cd5d4abdd99a Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:27:02 -0400 Subject: [PATCH 15/16] CAMEL-23703: camel-launcher - fix packaging/installer script bugs from review Co-Authored-By: Claude Opus 4.8 --- dsl/camel-jbang/camel-launcher/src/install/install.sh | 3 ++- .../camel-launcher/src/jreleaser/bin/camel-package.sh | 4 ++-- .../src/jreleaser/java/WebsiteManifestGenerator.java | 9 ++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/dsl/camel-jbang/camel-launcher/src/install/install.sh b/dsl/camel-jbang/camel-launcher/src/install/install.sh index 5afadaf33bbbe..969415dca44be 100755 --- a/dsl/camel-jbang/camel-launcher/src/install/install.sh +++ b/dsl/camel-jbang/camel-launcher/src/install/install.sh @@ -152,7 +152,8 @@ validate_tar() { verbose_listing="$staging_dir/listing-verbose.txt" tar -tvzf "$archive" > "$verbose_listing" 2>/dev/null || fail "archive is not a valid tar.gz" - grep -F -- ' -> ' "$verbose_listing" >/dev/null 2>&1 \ + # tar -tv renders symlinks as 'name -> target' and hard links as 'name link to target'. + grep -E -- ' -> | link to ' "$verbose_listing" >/dev/null 2>&1 \ && fail "archive contains a symbolic or hard link entry, which is not allowed" roots="" diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh index 82287b77e2b97..e577d980910b9 100755 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -123,7 +123,7 @@ if [ -n "${CAMEL_PACKAGE_TEST_VERSION:-}" ]; then fi PROJECT_VERSION="$CAMEL_PACKAGE_TEST_VERSION" else - PROJECT_VERSION=`mvn -q -B -ntp -pl "$MODULE_DIR" org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate \ + PROJECT_VERSION=`mvn -q -B -ntp -f "$MODULE_DIR/pom.xml" org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate \ -Dexpression=project.version -DforceStdout` fi @@ -207,7 +207,7 @@ export JRELEASER_GITHUB_TOKEN # 1.25.0 plugin), used to select which packagers run for this channel; e.g. `lts` # excludes `scoop` by omitting it from $PACKAGERS. echo "Preparing packages for channel '$CHANNEL' (packagers: $PACKAGERS)..." -mvn -B -ntp -pl "$MODULE_DIR" \ +mvn -B -ntp -f "$MODULE_DIR/pom.xml" \ -Djreleaser.distributions=camel-cli \ -Djreleaser.packagers="$PACKAGERS" \ jreleaser:config jreleaser:prepare jreleaser:package \ diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java b/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java index fc0991244718b..1023f2bdcce34 100644 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java @@ -25,10 +25,13 @@ import java.nio.file.StandardCopyOption; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; 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; /** @@ -46,7 +49,7 @@ public class WebsiteManifestGenerator { private static final Pattern VERSION_PATTERN = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)$"); private static final Set REQUIRED_OPTIONS - = new LinkedHashSet<>(java.util.List.of("--version", "--tar", "--zip", "--output", "--latest")); + = new LinkedHashSet<>(List.of("--version", "--tar", "--zip", "--output", "--latest")); private static final String MANIFEST_FORMAT = "1"; public static void main(String[] args) { @@ -142,7 +145,7 @@ private static byte[] renderManifest(String version, String tarSha256, String zi private static void writeVersionManifest(Path versionFile, byte[] manifest, String version) throws IOException { if (Files.exists(versionFile)) { byte[] existing = Files.readAllBytes(versionFile); - if (java.util.Arrays.equals(existing, manifest)) { + if (Arrays.equals(existing, manifest)) { return; } throw new ConflictException("version manifest for " + version + " already exists with different content" @@ -214,7 +217,7 @@ private static int compareSemver(String a, String b) { } private static int[] semverParts(String version) { - java.util.regex.Matcher m = VERSION_PATTERN.matcher(version); + Matcher m = VERSION_PATTERN.matcher(version); if (!m.matches()) { throw new ConflictException("invalid semantic version '" + version + "'."); } From 73d0694913d9622a3faa2fc262fd8a506bb3adea Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:57:44 -0400 Subject: [PATCH 16/16] CAMEL-23703: camel-launcher - use imported Arrays in WebsiteManifestGenerator Replace the lone java.util.Arrays.equals FQCN in writeLatestManifest with the already-imported Arrays.equals, matching writeVersionManifest and the project's no-FQCN convention (the build's OpenRewrite step would otherwise rewrite it and CI would fail on the uncommitted diff). Co-Authored-By: Claude Opus 4.8 --- .../src/jreleaser/java/WebsiteManifestGenerator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java b/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java index 1023f2bdcce34..fa9ff5b7213f8 100644 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java @@ -161,7 +161,7 @@ private static void writeLatestManifest(Path latestFile, byte[] manifest, String } byte[] existing = Files.readAllBytes(latestFile); - if (java.util.Arrays.equals(existing, manifest)) { + if (Arrays.equals(existing, manifest)) { return; }