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`). diff --git a/dsl/camel-jbang/camel-launcher/jreleaser.yml b/dsl/camel-jbang/camel-launcher/jreleaser.yml new file mode 100644 index 0000000000000..f68a12b0ae8e0 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml @@ -0,0 +1,123 @@ +# +# 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. +# 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 + 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 + # 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 + 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 3694faf67e92d..b3421ad0af119 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 @@ -283,6 +284,12 @@ junit-jupiter test + + org.apache.commons + commons-compress + ${commons-compress-version} + test + @@ -323,6 +330,51 @@ + + + org.jreleaser + jreleaser-maven-plugin + ${jreleaser-plugin-version} + false + + ${project.basedir}/jreleaser.yml + true + + + + + com.mycila + license-maven-plugin + + + + + src/jreleaser/bin/camel-package.bat + src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl + + + + + 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/install/install.sh b/dsl/camel-jbang/camel-launcher/src/install/install.sh new file mode 100755 index 0000000000000..969415dca44be --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/install/install.sh @@ -0,0 +1,273 @@ +#!/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" + # 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="" + 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/jreleaser/bin/camel-package.bat b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat new file mode 100644 index 0000000000000..c22729e9689f9 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat @@ -0,0 +1,205 @@ +@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 +) + +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 +) + +@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 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. +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%" -Djreleaser.distributions=camel-cli -Djreleaser.packagers=!PACKAGERS! 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/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh new file mode 100755 index 0000000000000..e577d980910b9 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -0,0 +1,214 @@ +#!/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 --- + +# 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 -f "$MODULE_DIR/pom.xml" 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 + +# 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" + +# 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. +: "${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 -f "$MODULE_DIR/pom.xml" \ + -Djreleaser.distributions=camel-cli \ + -Djreleaser.packagers="$PACKAGERS" \ + jreleaser:config jreleaser:prepare jreleaser:package \ + -Djreleaser.dry.run=true 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. 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..fa9ff5b7213f8 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java @@ -0,0 +1,276 @@ +/* + * 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.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; + +/** + * 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<>(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 (Arrays.equals(existing, manifest)) { + return; + } + throw new ConflictException("version manifest for " + version + " already exists with different content" + + " (" + versionFile + ") - version manifests are immutable."); + } + atomicWrite(versionFile, manifest); + } + + private static void writeLatestManifest(Path latestFile, byte[] manifest, String version) throws IOException { + if (!Files.exists(latestFile)) { + atomicWrite(latestFile, manifest); + return; + } + + byte[] existing = Files.readAllBytes(latestFile); + if (Arrays.equals(existing, manifest)) { + return; + } + + Map 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) { + 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/jreleaser/supported-lts.yml b/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml new file mode 100644 index 0000000000000..f05a871995339 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml @@ -0,0 +1,22 @@ +# +# 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. +# + +supported: + - line: "4.18" + supportEnds: "2026-12-31" + - line: "4.22" + supportEnds: "2027-09-30" 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..ed5831965dd0e --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanBatTest.java @@ -0,0 +1,264 @@ +/* + * 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.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; + +/** + * 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 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; + String stdout; + String stderr; + } + + 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"); + cmd.add(SCRIPT.toString()); + for (String a : args) { + cmd.add(a); + } + 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(60, TimeUnit.SECONDS), "wrapper did not exit in time"); + Result r = new Result(); + r.exit = p.exitValue(); + r.stdout = out; + r.stderr = err; + 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"); + + 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); + } + + @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 new file mode 100644 index 0000000000000..46245a4d0aa5c --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanShTest.java @@ -0,0 +1,309 @@ +/* + * 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.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; + +/** + * 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 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; + String stdout; + String stderr; + } + + 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); + } + 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(60, TimeUnit.SECONDS), "wrapper did not exit in time"); + Result r = new Result(); + r.exit = p.exitValue(); + r.stdout = out; + r.stderr = err; + 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"); + + 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); + } + + @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)); + } +} 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/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 new file mode 100644 index 0000000000000..5bf88781607f6 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java @@ -0,0 +1,531 @@ +/* + * 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 { + Files.createDirectories(temp); + 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 safeTarWithFailingLauncher(String version) throws Exception { + Path archive = Files.createTempFile(tempDir, "failing-launcher-", "-" + version + ".tar.gz"); + writeTarGz(archive, launcherEntries(version, 1)); + 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( + 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 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)); + 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) throws Exception { + return launcherEntries(version, 0); + } + + 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 = exeExecutable(version, versionExitCode); + 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, int versionExitCode) { + return "#!/bin/sh\n" + + "case \"$1\" in\n" + + " version) echo \"Camel " + version + "\"; exit " + versionExitCode + " ;;\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"; + } + + /** + * 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 { + 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); + 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); + 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 { + String path = exchange.getRequestURI().getPath(); + byte[] body = content.get(path); + 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; + } +} 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..eb5a503453876 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java @@ -0,0 +1,297 @@ +/* + * 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 output = work.resolve("website"); + 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"); + 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 + 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)); + } +} 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