From af8095b0f2b5451210592a1a9a531e4342efe70d Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 4 Nov 2026 10:29:10 +0100 Subject: [PATCH 1/7] feat(installer): add user-wide installers and publication workflows Install verified release payloads with ownership checks, recovery journals and separate consent for persistent tool and Java environment setup. Share Windows publication and registry handling between native PowerShell and Bash. Add offline installer coverage, exact-byte release packaging, gated Pages deployment and maintainer publishing instructions. --- .github/workflows/pages.yml | 54 +++ .github/workflows/release.yml | 63 +++ .github/workflows/test.yml | 42 +- INSTALLER_PLAN.md | 352 +++++++++++++++ README.md | 88 +++- RELEASING.md | 90 ++++ SPEC.md | 203 +++++++-- tests/README.md | 81 ++++ tests/ci.sh | 6 +- tests/release-tooling.py | 29 ++ tests/tool-installer.ps1 | 235 ++++++++++ tests/tool-installer.sh | 294 +++++++++++++ tools/embed-installer.py | 15 + tools/package-release.py | 46 ++ tools/pages-preflight.py | 39 ++ web/.nojekyll | 0 web/index.html | 16 + web/install.ps1 | 362 ++++++++++++++++ web/install.sh | 786 ++++++++++++++++++++++++++++++++++ web/latest.txt | 1 + 20 files changed, 2765 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/release.yml create mode 100644 INSTALLER_PLAN.md create mode 100644 RELEASING.md create mode 100644 tests/release-tooling.py create mode 100644 tests/tool-installer.ps1 create mode 100644 tests/tool-installer.sh create mode 100644 tools/embed-installer.py create mode 100644 tools/package-release.py create mode 100644 tools/pages-preflight.py create mode 100644 web/.nojekyll create mode 100644 web/index.html create mode 100644 web/install.ps1 create mode 100644 web/install.sh create mode 100644 web/latest.txt diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..5fbf636 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,54 @@ +name: Publish installer site +on: + push: + paths: + - 'web/**' + - 'tools/**' + - 'tests/**' + - '.github/workflows/**' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: pages + cancel-in-progress: false +jobs: + checks: + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + uses: ./.github/workflows/test.yml + with: + revision: ${{ github.sha }} + stage: + needs: checks + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Verify public release before exposing latest.txt + run: | + python3 tools/pages-preflight.py + python3 tools/package-release.py "$(cat web/latest.txt)" "$RUNNER_TEMP/site" --site + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: ${{ runner.temp }}/site + deploy: + needs: stage + runs-on: ubuntu-24.04 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Refuse stale queued revision + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + BRANCH: ${{ github.event.repository.default_branch }} + REVISION: ${{ github.sha }} + run: test "$(gh api "repos/$GH_REPO/commits/$BRANCH" --jq .sha)" = "$REVISION" + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + id: deployment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9ab7da4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Publish versioned tool +on: + workflow_dispatch: + inputs: + tag: + description: Existing reviewed tag, e.g. v0.2.0 (never overwritten) + required: true + type: string +permissions: + contents: read +concurrency: + group: release-${{ inputs.tag }} + cancel-in-progress: false +jobs: + resolve: + runs-on: ubuntu-24.04 + outputs: + revision: ${{ steps.tag.outputs.revision }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + persist-credentials: false + - id: tag + env: + TAG: ${{ inputs.tag }} + run: | + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + revision=$(git rev-parse "refs/tags/$TAG^{commit}") + test "$revision" = "$(git rev-parse HEAD)" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + checks: + needs: resolve + uses: ./.github/workflows/test.yml + with: + revision: ${{ needs.resolve.outputs.revision }} + publish: + needs: [resolve, checks] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.resolve.outputs.revision }} + fetch-depth: 0 + persist-credentials: false + - name: Validate tag and prepare exact-byte assets + env: + TAG: ${{ inputs.tag }} + run: | + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + python3 tools/package-release.py "${TAG#v}" "$RUNNER_TEMP/assets" + - name: Create draft with all assets, then publish + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + gh release create "$TAG" "$RUNNER_TEMP/assets/"* --verify-tag --draft --title "$TAG" --notes "Verified jvm tool payloads. See README for installation instructions." + gh release edit "$TAG" --draft=false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36f85c8..bd46d22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,14 @@ name: Bash portability on: push: pull_request: + workflow_call: + inputs: + revision: + type: string + default: '' + real_jdk: + type: boolean + default: false workflow_dispatch: inputs: real_jdk: @@ -13,11 +21,32 @@ on: permissions: contents: read -concurrency: - group: bash-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: + packaging: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.revision || github.sha }} + persist-credentials: false + - run: python3 tests/release-tooling.py + + shells: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.revision || github.sha }} + persist-credentials: false + - run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y zsh fish csh tcsh shellcheck + csh_bin=$(dpkg -L csh | awk '/\/bsd-csh$/ {print; exit}') + test -x "$csh_bin" + sudo update-alternatives --set csh "$csh_bin" + shellcheck -S warning web/install.sh + bash tests/tool-installer.sh --native-shells + unix: name: ${{ matrix.os }} / Bash ${{ matrix.bash }} runs-on: ${{ matrix.os }} @@ -34,6 +63,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + ref: ${{ inputs.revision || github.sha }} - name: Bash 3.2 build dependencies if: runner.os == 'Linux' && matrix.bash == '3.2' run: | @@ -91,11 +121,14 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + ref: ${{ inputs.revision || github.sha }} - name: Windows PowerShell 5.1 smoke suite run: | $PSVersionTable.PSVersion powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/powershell.ps1 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/tool-installer.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } windows: name: Windows / ${{ matrix.environment }} / Bash ${{ matrix.bash }} @@ -121,6 +154,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + ref: ${{ inputs.revision || github.sha }} - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 if: matrix.environment == 'MSYS2' with: diff --git a/INSTALLER_PLAN.md b/INSTALLER_PLAN.md new file mode 100644 index 0000000..d7f5871 --- /dev/null +++ b/INSTALLER_PLAN.md @@ -0,0 +1,352 @@ +# Installer implementation handoff + +Date: 2026-11-03. Implementation: 2026-11-04, approved defaults applied. +The installer sources, tests and publication workflows are implemented locally; +publication and hosted verification remain maintainer steps in RELEASING.md. +SPEC.md records completed behavior and recovery boundaries. +Read `AGENTS.md` and `SPEC.md` before implementation. This document defines the +work breakdown; `SPEC.md` remains the product contract. When implementing, +update the contract to distinguish completed behavior from remaining proposals. + +## Objective and boundaries + +Deliver user-wide installers at `https://getjvm.dev/install.sh` and +`https://getjvm.dev/install.ps1`. Install the tool into the resolved user's +`~/.local/bin`, offer persistent PATH setup, and separately offer persistent +JAVA_HOME/default-JDK PATH setup. Keep JDK storage under the existing XDG roots. + +User decisions already established: + +- The installer is essential. `jvm use` is independent, optional and excluded. +- Executable placement is `~/.local/bin`, independent of XDG_DATA_HOME, on all + platforms. Windows uses native paths beneath the same home-resolution policy + as `jvm.ps1` (HOME, then USERPROFILE when HOME is unavailable). +- Offer tool PATH setup when needed and default-JDK environment setup separately. +- Windows persists these settings in the current user's environment registry. +- Put public installer sources and optional website content in root-level + `web/`. Deploy its contents as the Pages site root through GitHub Actions. +- The domain now reaches GitHub Pages through Cloudflare. On 2026-11-03 the + root returned 404, but a concrete `/index/metadata/...` URL still redirected + to the existing upstream index. Do not change that metadata routing. + +Do not implement `use`, change `env`, create shell functions for dispatch, +clear GRAALVM_HOME, install a JDK, set a persistent JDK default, edit project +copies, add runtime dependencies, or add automatic updates. No new JDK-manager +command is necessary. A landing page is optional and must not delay installers. + +## Proposed defaults for this plan + +These defaults originally filled gaps in the discussion. The user approved +them on 2026-11-04 for this implementation. The remaining text preserves the +implementation handoff; SPEC.md describes the resulting contract, and +RELEASING.md records publication steps and verification boundaries. + +1. Publish versioned GitHub Release assets; default to a stable version recorded + in a small Pages `latest.txt`. Support explicit version selection. Never fetch + tool payloads from the moving default branch or scrape JSON/HTML for versions. +2. Ship `jvm` on Unix and all three scripts (`jvm`, `jvm.ps1`, `jvm.cmd`) on + Windows, regardless of which installer is used. Require no Bash installation + for the native PowerShell route. +3. Support persistent setup for Bash, Zsh, Fish, csh and tcsh, plus Windows user + environment values. Unknown/custom shells get manual instructions. Unix + shell setup is not a prerequisite for installing the executable. +4. Rerunning an installer updates an installation it owns; matching installed + content is a no-op. Refuse unrelated or locally modified destination files + with an actionable explanation. No overwrite/force flag in version one. +5. Permit default-JDK setup before a default exists, explaining that Java becomes + available after the first JDK installation. Do not resolve it through `home` + or `env`, since those commands can install JDKs. +6. Document manual tool removal initially. Do not add an uninstall command. + +## Public installer interface + +Proposed Bash options: + +```text +install.sh [--version ] + [--path | --no-path] [--java | --no-java] + [--shell bash|zsh|fish|csh|tcsh] [--profile ] + [--non-interactive] +install.sh --help +``` + +Proposed PowerShell equivalents: `-Version`, `-Path` / `-NoPath`, `-Java` / +`-NoJava`, `-NonInteractive`, and `-Help`. Shell/profile options are Unix-only; +the native installer is Windows-only. Reject conflicting or unsupported options +before networking, directory creation or edits. Help requires no network. + +Defaults and consent: + +- Installing/updating the tool is the requested action and needs no extra prompt. +- Ask separately about missing tool PATH setup and default-JDK setup; show the + exact target files/registry values and intended changes. Default answers are no. +- Detect an already-correct managed setup and avoid repeat prompts and edits. +- Explicit positive options consent to their respective edits. Negative options + suppress them. Never silently replace an unrelated JAVA_HOME without showing + the old/new value interactively or receiving explicit `--java` / `-Java`. +- With no terminal or in noninteractive mode, install the tool and skip unspecified + environment edits, printing manual instructions. Explicit positive options still + work unattended; unresolved custom profile selection fails that requested step. +- Read Bash prompts from `/dev/tty`, not piped script stdin. PowerShell must detect + inability to prompt and follow the same noninteractive policy. +- `--shell` selects one shell per invocation. Detection may suggest the login + shell, but the interpreter running install.sh is not proof of caller shell. + Do not edit every installed shell automatically. +- `--profile` explicitly selects a startup file for the chosen shell. Reject it + without a shell selection. Unknown shell detection skips automatic setup with + instructions rather than guessing a profile. + +Examples to document once implemented: + +```sh +curl -fsSL https://getjvm.dev/install.sh | bash +curl -fsSL https://getjvm.dev/install.sh | bash -s -- --path --no-java --non-interactive +``` + +```powershell +iex (irm 'https://getjvm.dev/install.ps1') +# For options, download to a file and invoke that script with parameters. +``` + +CMD can launch the PowerShell installer with `powershell.exe -NoProfile -Command`. +Test exact quoting of the documented invocation. Do not change machine execution +policy. Use the existing Windows PowerShell 5.1 baseline and built-in .NET. + +Exit statuses: 0 for completed installation with requested setup successful (or +optional setup declined), 2 for invalid arguments, 1 for operational failure. +If requested environment setup fails after successful tool publication, return 1 +and explicitly report that the tool was installed and which setup step failed. + +## Step 1: Distribution and publication contract + +Add release packaging outside the runtime scripts, with an offline validation +mode. Proposed tag convention: `v`, with `` a documented strict +numeric dotted version. First inspect existing tags and version constants; do +not rename existing releases or publish a release without authorization. + +For each release, produce exact-byte assets: + +```text +jvm +jvm.ps1 +jvm.cmd +SHA256SUMS +``` + +The checksum file has one SHA-256 digest and one allowlisted basename per line. +Use a fixed small parser. Reject duplicate/missing entries, invalid digests, +unexpected paths and HTML/error responses. Hash the downloaded bytes before +setting permissions or publishing. Do not execute downloaded scripts to discover +their versions or verify their identity. + +Proposed URLs: + +```text +https://getjvm.dev/latest.txt +https://github.com/codejive/jvm/releases/download/v/ +``` + +Read latest.txt once, validate its single version value, then pin every payload +request to that version. Explicit versions bypass latest.txt. HTTPS redirects +must be followed with failures checked; reuse the project's capability approach +for curl/wget, SHA-256 alternatives and Windows .NET TLS support. No archive +extraction, jq or GitHub API parsing is required. + +Checksums fetched through the same HTTPS release channel detect inconsistent or +corrupted downloads; they do not authenticate against a compromised publisher. +The bootstrap script itself is trusted through HTTPS. Do not claim independent +signature verification. Signing is outside this initial proposal. + +Add release and Pages workflows using least required permissions, pinned actions +following repository practice, and the GitHub Pages deployment environment. +Consult current official action documentation when implementing. + +- Release publication builds assets from a checked, explicit tag/revision and + runs the relevant tests. Do not overwrite assets of a published version. +- Maintain reviewed `web/latest.txt`; its version advances only after the release + assets exist. A Pages preflight verifies the referenced release is complete. +- Deploy Pages on default-branch changes to `web/` or the Pages workflow, plus + manual dispatch. Gate deployment on installer checks for that same revision. +- Upload only a staging copy of `web/`, not the repository. Run no site generator + for installer scripts. Exclude contributor/test files and preserve script bytes. +- Concurrent deployments must not let an older queued run replace newer content. +- Account for the domain's actual custom-domain/proxy configuration. Do not assume + a visible HTTP redirect or change Cloudflare/DNS during repository work. +- Explain the first-release bootstrap sequence: release assets first, then the + corresponding latest.txt and Pages deployment. Do not publish a broken pointer. + +## Step 2: Installer transaction and ownership + +Implement `web/install.sh` and `web/install.ps1` as standalone files. Keep helpers +readable and separated by concern. No runtime helper downloads except the +version/checksum/payload data. Preserve LF in Bash and the project script bytes. + +Suggested ownership state: `$XDG_DATA_HOME/jvm/tool-install/` with the existing +data-home fallback and absolute-path rules. This is NEW proposed storage; add +its exact schema and lifecycle to SPEC when implementing. Store installed version, +destination and hashes for owned filenames as literal data. Do not source it. + +Algorithm: + +1. Validate arguments, host, home, roots and required capabilities. +2. Resolve/pin version, stage downloads in a private temporary directory, and + verify the complete selected payload. Nothing edits profiles or registry yet. +3. Before publication, serialize installer mutations with a small installer-only + lock in the ownership directory; recheck destination state under that lock. + This does not introduce a lock for JDK installation or inventory operations. + A concurrent invocation can fail clearly and be retried; no busy waiting needed. +4. Treat symlinks/reparse points, directories and unowned existing destination + files as conflicts. Detect locally modified owned scripts using saved hashes. +5. Stage replacement files on the destination filesystem and preserve old owned + versions plus a literal transaction record before renaming anything. +6. Publish the complete payload and ownership record with checked operations. + On failure or a catchable interruption, restore the old payload. A Windows + multi-file replacement is not atomic: record that limitation honestly. + The next invocation must recognize interrupted publication and recover before + proceeding; never delete recovery material blindly or guess at a stale lock. +7. Apply consented environment setup after successful publication. Back up each + changed profile or registry value first and restore that step on failure. + Do not roll back a usable tool installation merely because setup failed. +8. Clean only owned staging, report installed version/path, and explain how to + open a refreshed terminal or apply the displayed setup instructions. + +A process killed between file renames can leave a temporary mixed installation +until recovery. Do not promise atomic multi-file visibility or power-loss durability. +Keep the recovery design small; do not add a version-switching launcher framework. + +## Step 3: Unix persistent setup + +Generate native shell syntax containing resolved literal paths, properly quoted. +Never evaluate user metadata or an existing profile. No JDK commands, network +requests or inventory scans at shell startup. The tool bin and default-JDK bin +are separate whole PATH entries; add only if absent. Preserve existing entries. +JAVA_HOME names the stable default reference, including on macOS bundle layouts +as defined by the existing reference contract. No GRAALVM_HOME setup is needed. + +Use separate marked blocks for tool PATH and default Java, so reruns can enable +one without duplicating or removing the other. Preserve surrounding content, +encoding/line endings where practical, permissions and an exact backup. Stop +on duplicate/corrupt markers. Do not replace a symlinked profile with a regular +file: resolve and show its target or require an explicit target override. + +Shell policies: + +- Bash: update `.bashrc` and the active readable login file from `.bash_profile`, + `.bash_login`, `.profile`. If none exists, propose `.profile`. Never create a + higher-priority login file that hides an existing one. Idempotent checks allow + both files to run without duplicate entries. Keep `.profile` content POSIX. +- Zsh: use `.zshrc` beneath known ZDOTDIR or HOME. ZDOTDIR can be set in startup + code and not exported; show the assumed path and allow an explicit override. +- Fish: use a dedicated user `conf.d/jvm.fish` under XDG_CONFIG_HOME (fallback + `.config`). Treat an unowned existing file as a conflict. Use exported variables + and the shell's list PATH semantics, not POSIX shell statements. +- Csh/tcsh: use `.cshrc`, or existing `.tcshrc` for tcsh with `.cshrc` fallback. + Implement native `setenv`/`path` handling, including unset variables. Test it + in actual shells in hosted CI; do not substitute string-only assertions. + +Check both the current PATH and managed startup configuration: current membership +does not establish persistence. When ambiguous, explain that it is present now +and offer persistent setup rather than silently claiming it is installed there. +Later user startup statements can override managed values; document this boundary. +Profile setup does not configure every GUI application or noninteractive process. + +## Step 4: Windows persistent setup + +PowerShell owns registry updates. When install.sh runs under Git Bash/MSYS2/Cygwin, +use native path conversion and delegate consented registry setup to the verified +PowerShell installer without recursion, duplicate download/publication or duplicate +prompts. Design a private, validated setup-only invocation if necessary; document +it as internal and do not expose an arbitrary command-evaluation interface. +If this introduces unnecessary complexity, share a private staged-data contract, +not duplicated registry mutation code in Bash. Test the selected design end to end. + +Use `HKCU\Environment`, not machine values. Read raw values without expansion; +preserve existing REG_SZ/REG_EXPAND_SZ types and unrelated PATH tokens. Never +persist the process's merged PATH. Use semicolon entry parsing and appropriate +case/path comparison; preserve original spelling of untouched entries. Do not +use setx. Add concrete native absolute paths without quotes inside PATH entries. +Notify applications of environment changes after successful writes. + +Tool PATH setup adds `.local\bin`. Java setup sets JAVA_HOME to the existing +resolved data root's `jvm\jdks\default` and adds its `bin` path. Preview both +changes together. Handle a missing PATH value and an existing unrelated JAVA_HOME. +User PATH membership cannot guarantee precedence over a system Java entry; +report observed shadowing without modifying machine PATH or deleting other tools. + +Persisted edits do not refresh existing terminals/IDEs. Do not promise that a +new tab in an old host inherits fresh values. No PowerShell profiles are needed +for this milestone. Keep Bash-specific conversion instructions available if a +Windows shell startup file subsequently overrides the inherited PATH. + +## Step 5: Verification and acceptance + +Add `tests/tool-installer.sh` and `tests/tool-installer.ps1` (names deliberately +distinct from existing JDK installation tests). Extend `tests/ci.sh`, the hosted +matrix and `tests/README.md` with actual runnable commands when created. + +Every test uses temporary HOME/XDG/destination/profile locations and fake +downloaders or local fixture services. Registry operations need an injectable +backend/key for tests; never exercise the developer's real HKCU environment. +Verify exact raw types/values using disposable keys where native registry tests +are needed, and clean only those keys. No CI test installs a real JDK. + +Acceptance cases: + +- Help and invalid/conflicting options have no side effects; piped bootstrap + prompts never consume script bytes. Interactive and unattended choices agree. +- Latest and pinned versions, HTTPS redirects, downloader alternatives, HTTP + errors, malformed version/checksum data, missing files and checksum mismatch. +- Fresh install, matching reinstall, update, locally modified/unowned conflicts, + two concurrent installers, publication failures and interrupted-run recovery. +- Paths containing spaces and supported shell metacharacters, native Windows + paths, non-ASCII homes, XDG overrides and invalid/relative home overrides. + Reject unrepresentable PATH separator/newline paths clearly before mutation. +- Each consent combination; existing tool PATH; missing default JDK; existing + JAVA_HOME; no duplicate PATH entries; no JDK storage/default mutation. +- Correct Bash login precedence, custom ZDOTDIR/profile override, Fish config + roots, csh/tcsh unset variables, profile backups and symlink handling. +- A real fresh shell loads generated setup in hosted tests; no startup download, + accidental output or command evaluation occurs. Shell text is not merely + compared against private helper output. +- Windows registry value preservation, expandable references, missing PATH, + setup failures/rollback and repeated invocation through PowerShell and Bash. +- Launcher payload bytes remain mutually consistent; ordinary existing `jvm` + commands and project-local copies retain behavior. +- Pages artifact contains only intended public files, installer URLs have no + `/web/` prefix, and latest.txt references available verified release assets. + +Use hosted Linux/macOS Bash 3.2/current and current Git Bash/MSYS2/Cygwin plus +Windows PowerShell 5.1. Add hosted Zsh/Fish/csh/tcsh setup checks. Observe AGENTS.md: +do not install alternative shells, run WSL/containers or broaden local platform +validation without express confirmation. Report native gaps rather than claiming +simulations prove support. Run syntax checks, relevant existing regressions, +ShellCheck if available, and `git diff --check`; measure shipped script sizes. + +After authorized deployment, separately smoke-check public bootstrap downloads, +checksums, landing-page response if included, and an actual `/index/metadata/...` +redirect. Live checks are optional deployment validation, not default tests. + +## Step 6: Documentation and delivery + +Update SPEC's commands/current behavior only when implementation exists. Add +installer interfaces, ownership storage, failure/recovery rules, supported shell +setup and Windows boundaries. Update the manual-only setup statement to distinguish +the installer from ordinary manager commands. Keep `use` deferred. + +Update README with interactive and unattended installation examples, first JDK +installation, terminal refresh, repeat-install updates, pinned versions, and +manual tool removal. Removal instructions must identify only installer-owned +files/blocks/registry entries, preserve JDKs/config/cache, and avoid restoring +old environment values over subsequent user edits. Keep project-copy instructions. + +Suggested reviewable implementation sequence: + +1. Versioned payload/checksum contract, packaging fixtures and installer core. +2. Unix persistent setup and shell tests. +3. Windows persistent setup and Bash-on-Windows integration tests. +4. Release/Pages workflows and documentation, then authorized publication. + +Deliver code and tests, a list of checks actually run, remaining platform gaps, +and concrete repository/Pages settings needed by the maintainer. Do not create +commits, releases, change remote settings or deploy as a side effect of local +implementation unless separately authorized. Do not start work on `use`. diff --git a/README.md b/README.md index 2ad7ad4..2f9206c 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,66 @@ sure it's the correct version! ## Using it installed -Copy the scripts to a directory on your `PATH`, for example `~/.local/bin`: +The standalone installers place the tool in `~/.local/bin` on every platform. +They do not install a JDK. The public URLs become available after the maintainer +publishes the first release and Pages site (see [publishing](RELEASING.md)). + +```sh +curl -fsSL https://getjvm.dev/install.sh | bash +# Unattended, with explicit consent to persistent tool PATH only: +curl -fsSL https://getjvm.dev/install.sh | bash -s -- --path --no-java --non-interactive +# Pin a release; rerunning without --version updates to the reviewed latest: +curl -fsSL https://getjvm.dev/install.sh | bash -s -- --version 0.2.0 --no-path --no-java +``` + +Windows PowerShell 5.1 (no Bash required): + +```powershell +iex (irm 'https://getjvm.dev/install.ps1') +# For parameters, download and invoke the script: +irm 'https://getjvm.dev/install.ps1' -OutFile "$env:TEMP\jvm-install.ps1" +& "$env:TEMP\jvm-install.ps1" -Version 0.2.0 -Path -NoJava -NonInteractive +``` + +From CMD: + +```cmd +powershell.exe -NoProfile -Command "iex (irm 'https://getjvm.dev/install.ps1')" +``` + +The installer asks separately about persistent tool PATH and default-JDK +JAVA_HOME/PATH setup, showing the target profiles or user registry changes. +Answers default to no. Without a terminal, unspecified setup is skipped; +`--path`/`--java` (PowerShell `-Path`/`-Java`) explicitly consent to edits. +`--no-path`/`--no-java` suppress them. `--non-interactive` suppresses prompts. +Tool downloads are checked against SHA-256 values from the same HTTPS release +channel; this checks consistency, not an independent publisher signature. + +Unix supports Bash, Zsh, Fish, csh and tcsh. Choose one with `--shell bash` +(or another supported name). Use `--profile /absolute/file` with `--shell` for +custom startup files or an explicit symlink target. Bash normally updates +`.bashrc` and the existing active login file; Zsh assumes exported `ZDOTDIR` or +HOME; Fish uses XDG_CONFIG_HOME's `fish/conf.d/jvm.fish`. Unknown shells need +manual setup. Exact profile backups are reported. Windows persists only +current-user environment values, with raw value/type backups; machine PATH +can still take precedence over user Java. No PowerShell profiles are edited. + +Open a **fresh terminal application or IDE**, since a new tab in an old host +may inherit stale values. Later startup statements can override setup. This +does not configure all GUI or noninteractive programs. Then install Java: + +```sh +jvm install +jvm x _ java -version +``` + +Default-JDK setup can precede the first JDK installation. It names the stable +`jvm/jdks/default` reference under the existing data root. Rerunning the +installer updates only its own unmodified files; unrelated or locally modified +files cause a conflict. A matching reinstall leaves the payload unchanged. +Installer recovery is described in [SPEC.md](SPEC.md#tool-installer). + +You can also copy the scripts manually to a directory on your PATH: ```sh mkdir -p ~/.local/bin @@ -89,8 +148,8 @@ cp jvm jvm.cmd jvm.ps1 ~/.local/bin/ chmod +x ~/.local/bin/jvm ``` -On Windows you will probably need to add that directory (for example -`%USERPROFILE%\.local\bin`) to your `PATH` yourself. +With a manual copy, add that directory to PATH yourself. An installer refuses +to take ownership of a manual copy; move it aside before using the installer. To make the default JDK available in every shell, add this once to your shell profile: @@ -105,6 +164,29 @@ export JAVA_HOME="$(cygpath -m "${XDG_DATA_HOME:-$HOME/.local/share}/jvm/jdks/de export PATH="$(cygpath -u "${JAVA_HOME}")/bin:$PATH" ``` +### Removing the user-wide tool + +Read `${XDG_DATA_HOME:-$HOME/.local/share}/jvm/tool-install/record` (native +Windows path separators on Windows). It records the destination and hashes of +owned files. After checking that they still match, remove **only** its listed +`jvm` file on Unix, or `jvm`, `jvm.ps1`, `jvm.cmd` on Windows. Remove the +`tool-install` directory only when no installer is active and no recovery is +pending. Preserve sibling `jdks`, configuration and cache directories. + +On Unix, remove only the `# >>> jvm path >>>` / `# <<< jvm path <<<` and +`# >>> jvm java >>>` / `# <<< jvm java <<<` blocks from the profiles the +installer reported. Leave all surrounding user content; remove Fish's +`jvm.fish` only if those blocks are its entire content. Backups are for +inspection; do not restore an old profile over later edits. + +On Windows, use the user Environment Variables editor to remove only the exact +tool-bin and default-JDK-bin entries that the installer added. Remove +JAVA_HOME only if it still equals the installer-reported stable default path +and you want Java setup removed. Consult the reported raw registry backups to +distinguish pre-existing entries. Preserve all other tokens/types; do not +restore old environment values over subsequent changes. Refresh terminal +applications afterwards. Project copies continue to work independently. + ## Usage ``` diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..a9cfb23 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,90 @@ +# Publishing the installers + +This repository contains publication workflows; no release, deployment or remote +setting is created by building or running the offline tests. Version `0.2.0` is +the initial reviewed pointer in `web/latest.txt`; no tags existed locally or on +origin when inspected on 2026-11-04. The installer defaults from +[INSTALLER_PLAN.md](INSTALLER_PLAN.md) were approved for this implementation. + +## Maintainer sequence + +1. Review and merge the implementation onto the default branch. Require the + Bash portability checks to pass: Linux/macOS Bash 3.2/current, Windows Git + Bash/MSYS2/Cygwin, Windows PowerShell 5.1, shell startup and packaging checks. + The initial automatic Pages preflight will refuse deployment until the + release exists. Do not bypass this check or publish a placeholder pointer. +2. In repository Settings → Pages, select **GitHub Actions** as the build source. + Verify the `github-pages` environment permits only the default branch and + configure any required reviewers. Ensure Actions policy permits the pinned + actions and the release job's `contents: write` permission. These are + maintainer actions; the workflows do not change repository settings. +3. Confirm the two runtime version constants and `web/latest.txt` are `0.2.0`. + Tag the reviewed commit `v0.2.0` and push that tag. Do not reuse an existing + version. For later versions, update both constants, and regenerate the + embedded installer only if `web/install.ps1` changed. A version is exactly + three numeric components with no leading zeros, except the component `0`. +4. Dispatch **Publish versioned tool** with `tag=v0.2.0` from the reviewed + default-branch workflow. It checks out and tests that tag, validates the + version constants, builds `jvm`, `jvm.ps1`, `jvm.cmd`, `SHA256SUMS`, creates + a draft with all assets, then publishes it. Existing releases are never + overwritten. If upload fails, inspect/delete the incomplete draft before + retrying; never edit assets of an already published version. +5. After all four public release assets exist, dispatch **Publish installer + site** on the current default branch. For later releases, only now advance + the reviewed `web/latest.txt` pointer and merge it; that change triggers + Pages. The preflight downloads and verifies all referenced assets before + uploading the allowlisted public files. A queued old revision cannot deploy + over a newer default-branch revision. If an unrelated commit makes a run + stale, dispatch a fresh run at the current default-branch head. +6. Verify the existing custom-domain/proxy configuration rather than replacing + it. The user reported GitHub Pages behind Cloudflare on 2026-11-03; at that + time `/` returned 404 and the concrete metadata route redirected upstream. + Preserve the Cloudflare `/index/metadata/...` routing and existing DNS/custom + domain settings. No CNAME is introduced by this change: the correct Pages + origin hostname depends on the existing proxy configuration. +7. After deployment, download `/install.sh`, `/install.ps1`, `/latest.txt` and + the referenced release checksums/payloads over HTTPS, verify exact bytes, + and review `/` plus an actual + `/index/metadata/all/linux/x86_64/jdk-latest.tsv` redirect. Installer URLs are + at the site root, **without `/web/`**. Test the README bootstrap commands + in disposable homes before announcing availability. + +After review/merge and the settings check, the first-release commands are: + +```sh +# Replace REVIEWED_COMMIT with the tested, merged commit containing these files. +git tag v0.2.0 REVIEWED_COMMIT +git push origin refs/tags/v0.2.0 +gh workflow run release.yml --repo codejive/jvm --ref main -f tag=v0.2.0 +# Wait for that run to succeed and verify all four assets are publicly available. +gh workflow run pages.yml --repo codejive/jvm --ref main +``` + +`main` was the remote default branch when inspected on 2026-11-04. If it changes, +use the new default branch. The commands above are maintainer instructions; +they have not been executed as part of implementation. + +## Offline preparation + +```sh +python tools/embed-installer.py --check +python tests/release-tooling.py +python tools/package-release.py 0.2.0 /absolute/new/assets +python tools/package-release.py 0.2.0 /absolute/new/site --site +``` + +These commands do not publish anything. Destination directories must not exist. +After editing the Windows installer, run `python tools/embed-installer.py` and +review both standalone files. The embedded Windows source is checked byte for +byte so registry mutation logic has one maintained source. + +The site staging allowlist is `install.sh`, `install.ps1`, `latest.txt`, +`index.html`, `.nojekyll`; no source tree, test fixtures or site generator is +uploaded. The Pages preflight is intentionally online and is separate from the +offline suite. Release checksums use the same HTTPS trust channel as payloads; +there is no independent signature verification or automatic updater. + +Action documentation consulted on 2026-11-04: +[custom Pages workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages), +[upload-pages-artifact](https://github.com/actions/upload-pages-artifact), +[deploy-pages](https://github.com/actions/deploy-pages). diff --git a/SPEC.md b/SPEC.md index 091a02f..e3eee1e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -365,7 +365,7 @@ links and hardlinks are preserved. Unrecognized listings fail before extraction. On Windows, case aliases, reserved device names and trailing dots are rejected. -Installers use no shared lock; each stages independently. A complete, +JDK installations use no shared lock; each stages independently. A complete, compatible `` that appears during publication wins: the loser discards only its own copy and reports success. A uniquely marked nested copy left by a lost `mv` race is renamed back into the private staging directory before deletion; @@ -470,7 +470,8 @@ Lifecycle: `jvm/tmp/default.XXXXXXXX/previous`. Concurrent explicit changes: last publication wins. -Shell setup is manual; the tool never edits profiles or the registry. Unix: +Ordinary manager commands never edit profiles or the registry. The separate +[tool installer](#tool-installer) offers consented persistent setup. Manual Unix setup: ```bash export JAVA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/jvm/jdks/default" @@ -625,35 +626,179 @@ Last-Modified and last-check metadata. Windows `tar.exe` and rejects links and special entries. Both are stricter than Bash, which preserves verified internal links. +## Tool installer + +`web/install.sh` and `web/install.ps1` implement standalone user-wide tool +installation; public URLs require the maintainer publication sequence in +[RELEASING.md](RELEASING.md). They install no JDK, never select a default, never +modify project copies and introduce no `use` command. The defaults proposed in +[INSTALLER_PLAN.md](INSTALLER_PLAN.md) were approved for this implementation. + +```text +install.sh [--version major.minor.patch] + [--path | --no-path] [--java | --no-java] + [--shell bash|zsh|fish|csh|tcsh] [--profile /absolute/file] + [--non-interactive] +install.sh --help +install.ps1 [-Version major.minor.patch] [-Path | -NoPath] + [-Java | -NoJava] [-NonInteractive] +install.ps1 -Help +``` + +Options are unique; conflicting/unknown options and invalid versions exit 2 +before downloads or directory creation. Profile requires an explicit shell. +Shell/profile options are Unix-only; PowerShell is Windows-only. Help is offline. +Versions have exactly three numeric dotted components, each `0` or a positive +integer without leading zeros. Exit 0 means the tool and any requested setup +succeeded (optional setup may be declined); operational failure exits 1. If +setup fails after publication, the tool remains installed and the diagnostic +identifies this partial success. + +### Downloads and placement + +Without a version, read `https://getjvm.dev/latest.txt` once and validate its +single version (optional final LF). Pin all payload requests to +`https://github.com/codejive/jvm/releases/download/v/`. +Explicit versions bypass latest.txt. Allow only HTTPS, including redirects; +Bash supports curl or HTTPS-capable wget and sha256sum/shasum/openssl; +PowerShell uses built-in .NET with TLS 1.2 support. No helper downloads, +archive extraction, JSON parsing or execution of downloaded tool scripts is +needed. SHA256SUMS must contain exactly three unique allowlisted basenames +(`jvm`, `jvm.ps1`, `jvm.cmd`) with 64-digit SHA-256 digests. Hash downloaded +bytes before setting permissions or publishing. Checksums detect inconsistent +or corrupt release content; they share the HTTPS publisher trust channel and +are not independent signatures. + +Downloader detail verified 2026-11-04: GNU wget's +[`--https-only`](https://www.gnu.org/software/wget/manual/html_node/HTTPS-_0028SSL_002fTLS_0029-Options.html) +applies to recursive links. The tool installer instead uses disabled automatic +redirects plus validated HTTPS hops with a bounded redirect count. A wget +without the needed server-response/redirect controls fails clearly; use curl. + +The destination is resolved HOME/.local/bin, independent of XDG_DATA_HOME. +Unix requires absolute HOME; Windows uses HOME, falling back to USERPROFILE +only when HOME is absent, and requires a native absolute path. Relative home +values are errors. Data storage follows the existing absolute XDG override and +fallback policy. Newline, CR, tab and PATH-separator paths are rejected before +mutation (Unix also rejects semicolons for Windows interoperability). Unix +ships only `jvm` with executable permissions; Windows ships all three exact-byte +scripts. Windows Bash requires cygpath and Windows PowerShell and converts +paths before delegating to the embedded PowerShell installer. Its embedded +source is generated from `web/install.ps1`, not downloaded or separately +maintained registry code. No private command-evaluation interface exists. + +### Ownership, failure and recovery + +New storage: `${XDG_DATA_HOME:-$HOME/.local/share}/jvm/tool-install/`. +`record` is literal UTF-8/LF data: `jvm-tool-v1`, version, absolute destination, +then one `sha256basename` line per owned file, in `jvm`, `jvm.ps1`, +`jvm.cmd` order (only `jvm` on Unix). It is never sourced. Missing/modified +owned files, unowned destinations, directory links/reparse points, directories +in place of scripts and invalid records fail clearly. Matching reinstall is a +payload no-op; rerunning otherwise replaces only verified installer-owned +files. Moving an owned installation or changing its data root requires manual +ownership review; there is no force/adoption option. + +Downloads are staged privately before publication. An installer-only lock +serializes publication and environment setup; JDK operations remain lock-free. +Bash uses `lock/pid`; a live owner fails immediately. A leftover lock is never +guessed stale: confirm the recorded process exited, remove only `lock/pid` and +the empty `lock/` directory, then rerun. An incomplete/unknown lock needs manual +inspection. PowerShell holds an exclusive handle on the `lock` file; the OS +releases it when the process exits, leaving a harmless empty file. + +`transaction/` holds `new-record`, optional `old-record` and `old-jvm` (plus +`old-jvm.ps1`/`old-jvm.cmd` on Windows). `ready` marks the point at which all +backups exist and publication may start. Windows `stage-name` names only an +allowlisted `.jvm-next-<32 hex digits>` destination-side directory; Bash reserves +`.jvm-next` and `.jvm-recover` files. Replacement files are staged on the +destination filesystem. Checked renames/copies publish the payload and record; +catchable failures restore old bytes and ownership. A subsequent invocation +recognizes and recovers pending transactions under the lock before checking +ownership and republishing. Damaged/unrecognized recovery material is retained +with a diagnostic rather than deleted. Do not remove the ownership directory +while recovery is pending. Successful transactions discard their backups; +there is no version history or switching launcher. + +Windows publication is a sequence of file replacements, not an atomic multi-file +transaction. An uncatchable kill can leave a temporary mixed installation until +recovery. Neither implementation promises power-loss durability. On Unix, +profile edits have exact sibling `.jvm-backup.` backups. A failing or +catchably interrupted profile setup step restores that step's changed files; +completed earlier setup steps remain. Windows raw user registry values/types +are backed up to `environment-backup-<32 hex digits>.clixml` before changes; +a failed setup restores the previous values. An uncatchable kill during +persistent setup may require manual inspection of the retained backups. + +### Persistent environment setup + +Tool PATH and default-Java setup have independent consent. Positive options +consent to the corresponding changes; negative options suppress them. Otherwise +show exact target files/registry values and old/new JAVA_HOME, then ask with a +no default. Bash reads `/dev/tty`, never the piped bootstrap stdin. Without a +usable terminal, or in noninteractive mode, unspecified setup is skipped with +manual instructions. An unknown shell skips optional setup but fails explicitly +requested setup. An already-correct managed setup avoids repeated prompts and +edits. Current process PATH membership alone is not persistent Unix setup. + +Unix uses separate `# >>> jvm path >>>`/`# <<< jvm path <<<` and +`# >>> jvm java >>>`/`# <<< jvm java <<<` blocks containing literal resolved +paths and native shell syntax, with whole-entry PATH deduplication. JAVA_HOME +names the stable data-root `jvm/jdks/default`, even before a default exists; +its bin directory and the tool bin are separate entries. No GRAALVM_HOME edits, +network requests, JDK execution or inventory scans occur at startup. + +- Bash: `.bashrc` and the first existing readable `.bash_profile`, `.bash_login`, + `.profile`; absent all three, create `.profile`. Generated content is POSIX + compatible, so no higher-priority login file hides an existing profile. +- Zsh: `.zshrc` beneath exported ZDOTDIR or HOME; show the assumed path. Use + `--profile` if startup code sets an unexported/custom ZDOTDIR. +- Fish: XDG_CONFIG_HOME (fallback HOME/.config) `fish/conf.d/jvm.fish`; + unowned existing files conflict. Explicit profile overrides are supported. +- Csh/tcsh: `.cshrc`, or existing `.tcshrc` for tcsh; native `path` list and + `setenv` syntax handle initially unset variables. + +Profile contents outside managed blocks and permissions are preserved where +practical (a missing trailing newline is normalized); exact original bytes are +backed up. Duplicate/corrupt markers stop setup. Symlink profiles are refused; +select the resolved target explicitly with `--shell` and `--profile`. + +Windows modifies only HKCU\Environment. Read raw values without expansion and +preserve REG_SZ/REG_EXPAND_SZ kinds and untouched PATH tokens, including empty +entries; never persist the merged process PATH or use setx. Tool setup adds the +concrete native tool directory; Java setup sets JAVA_HOME and adds the native +stable-default bin entry. Whole-entry comparison is case-insensitive and +recognizes expandable references; new entries contain no quotes. Broadcast an +environment-change notification after writes. Report observed machine Java +shadowing without changing machine PATH. No PowerShell profile edits occur. + +Open a fresh terminal application/IDE afterwards; a new tab in an old host may +still inherit stale values. Later startup statements may override setup, and +not every GUI/noninteractive process reads these profiles. Manual removal is +documented in README: remove only owned payloads/blocks/entries, preserve +JDKs/config/cache, and never restore old backups over subsequent user changes. + +### Publication + +Release and Pages workflows are checked in, not yet run or deployed. Releases +are built from an explicit existing `v` tag after the offline test +matrix passes; exact-byte assets are published via a draft and are never +overwritten. `web/latest.txt` is reviewed source and advances only after the +release exists. Pages runs installer checks for the same revision and verifies +the complete public referenced release before staging only the public-file +allowlist. It uses the `github-pages` environment and refuses stale queued +revisions. The site root has no `/web/` prefix. See RELEASING.md for first-release +ordering, settings, action documentation and post-deployment checks. + +Hosting status (2026-11-03): the user reports `https://getjvm.dev/` now +points to this repository's GitHub Pages site. An HTTP check reached a +GitHub response through Cloudflare, currently returning 404 at the root; +a working landing page is not yet verified. The concrete metadata path +`/index/metadata/all/linux/x86_64/jdk-latest.tsv` still returns a 302 to +`https://jbangdev.github.io/jdkdb-index/metadata/all/linux/x86_64/jdk-latest.tsv`. + ## Open items -- **Roadmap idea: installer for the `jvm` tool.** Provide a hosted bootstrap - installer for Bash and Windows PowerShell, plus an invocation from CMD. - Proposed commands (endpoints are not yet provided or verified): - - ```bash - curl -fsSL https://getjvm.dev/sh | bash - ``` - - ```powershell - iex (irm 'https://getjvm.dev/ps1') - ``` - - CMD could invoke the PowerShell installer through Windows PowerShell: - - ```cmd - powershell.exe -NoProfile -Command "iex (irm 'https://getjvm.dev/ps1')" - ``` - - These examples use the project's existing `getjvm.dev` domain. The installer - would install the tool itself, distinct from `jvm install` downloading a JDK. - Investigate project-local versus user-wide placement, PATH setup and the - existing manual shell-setup policy, installing `jvm.ps1` together with - `jvm.cmd` on Windows, release/version selection, updates and removal, and - download verification and interrupted-install recovery. Validate quoting and - bootstrap behavior on supported shells, including Windows PowerShell 5.1 and - CMD. This is a roadmap proposal, not an available installer or a change to - current installation and shell-setup behavior. - **Roadmap investigation: minimum-major selectors by default.** Consider changing `25` from exactly major 25 to major 25 or higher (currently `25+`), with `25!` expressing exactly major 25. The motivation is that a requested diff --git a/tests/README.md b/tests/README.md index 905cb29..54ae6ab 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,86 @@ # Discovery, installation and uninstallation verification +## Tool installer verification (2026-11-04) + +The new tests are distinct from `tests/install.sh`, which installs synthetic +JDKs. Installer tests download only local fixtures through fake transfers and +use temporary HOME/XDG roots, destinations and profiles. Native registry tests +replace `Open-EnvironmentKey` with a disposable key beneath +`HKCU\Software\Codejive\InstallerTests\` and delete only that key. +They never change the contributor's real user environment, profile or JDKs. +Mocks are supplied by the harness in memory or in private script copies; +shipped installers have no alternate URL/registry backend or evaluation hook. + +```sh +bash -n ./jvm +bash -n web/install.sh +bash tests/tool-installer.sh +python tests/release-tooling.py +``` + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/tool-installer.ps1 +# Also test native Bash-to-PowerShell delegation with an already installed Bash: +$env:JVM_TEST_BASH = 'C:\Program Files\Git\bin\bash.exe' +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/tool-installer.ps1 +Remove-Item Env:JVM_TEST_BASH +``` + +`tests/ci.sh` includes installer syntax and offline tests. Hosted Windows jobs +also run the delegation test through the actual current Git Bash/MSYS2/Cygwin +interpreter. The hosted Linux shell job installs Zsh/Fish/csh/tcsh and runs: + +```sh +shellcheck -S warning web/install.sh +bash tests/tool-installer.sh --native-shells +``` + +Do not run this shell/platform tooling locally without the express confirmation +required by AGENTS.md. The native-shell option starts actual fresh shells and +checks both explicit sourcing (twice) and normal startup files. Ordinary tests +simulate Unix host detection on Windows; that is not native Unix verification. +Git Bash's `ln -s` can make a copy, so its symlink-profile assertion is explicitly +skipped when no real link exists. Hosted Unix exercises the actual link case. + +Checks cover latest/pinned releases, exact-byte payloads, curl/wget paths, +available checksum alternatives, HTTPS redirect/downgrade policy for wget, +malformed/missing/checksum-failing downloads, usage without side effects, +matching reinstall, updates, modified/unowned conflicts, lock contention, +publication error/interruption rollback, interrupted-run recovery and retention +of unknown journal material. Setup checks cover separate consent, literal +metacharacter/spaced paths, native non-ASCII Windows homes, XDG fallback, +relative HOME rejection, Bash login precedence, ZDOTDIR/Fish roots, profile +backups/rollback, all shell formats, registry raw values/types, missing PATH, +failed setup rollback, CMD bootstrap quoting and Windows Bash delegation. + +Local results: 23 Bash installer groups passed under installed Git Bash 5.3.15 +(Unix host simulated); 15 installer groups passed under Windows PowerShell 5.1 +including native Git Bash delegation and CMD. Exact-byte packaging, checksum +generation, embedded-source equality and public-file staging checks passed. +All workflow YAML parsed, Bash/PowerShell syntax checks and `git diff --check` +passed. Existing regressions passed: 56 Bash discovery groups, 35 installation +groups, home/env suite and 29 PowerShell smoke groups. The runtime manager +scripts are unchanged. ShellCheck is unavailable locally. +Measured sizes: `web/install.sh` 43,147 bytes (including the generated Windows +source), `web/install.ps1` 21,023 bytes; unchanged `jvm` 68,978 bytes, +`jvm.ps1` 78,630 bytes and `jvm.cmd` 116 bytes. New installer sources use LF. + +Hosted Linux/macOS Bash 3.2/current, real Zsh/Fish/csh/tcsh startup and MSYS2/ +Cygwin delegation remain unverified here. No alternative shells, WSL or +containers were installed/run, and no hosted jobs, releases, deployment or +GitHub/Cloudflare settings were changed. An early CMD fixture mistakenly let +the built-in `irm` alias make a failed public installer request; the corrected +fixture removes that alias with `-Force` and fails closed before execution if +mock setup fails. Final installer tests are offline; no live JDK installation +or bootstrap installation against a published release was performed. + +Release/Pages files are locally reviewed and YAML-parsed, not verified hosted +runs. `tools/pages-preflight.py` intentionally needs the public release and is +not part of offline tests. Follow [RELEASING.md](../RELEASING.md) for the first +release sequence and public download/metadata-route smoke checks afterwards. + +## Earlier manager verification + CI wrapper lookup repair (2026-11-03): capture real `tar` and `sha256sum` paths before isolating PATH, avoiding cached wrapper targets in Bash 3.2. Syntax checks for `jvm` and `tests/install.sh`, all 35 offline installation diff --git a/tests/ci.sh b/tests/ci.sh index 1f22757..2834545 100644 --- a/tests/ci.sh +++ b/tests/ci.sh @@ -23,8 +23,12 @@ export PATH="$TEMP/bin:$PATH" bash -c '[ "$BASH_VERSION" = "$1" ]' bash "$BASH_VERSION" cd "$ROOT" "$BASH_BIN" -n ./jvm +"$BASH_BIN" -n web/install.sh for script in tests/*.sh; do "$BASH_BIN" -n "$script"; done "$BASH_BIN" tests/run.sh +"$BASH_BIN" tests/tool-installer.sh case "$(uname -s)" in - CYGWIN*|MSYS*|MINGW*) "$BASH_BIN" tests/default-native-windows.sh ;; + CYGWIN*|MSYS*|MINGW*) + "$BASH_BIN" tests/default-native-windows.sh + JVM_TEST_BASH="$(cygpath -aw "$BASH_BIN")" powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -aw "$ROOT/tests/tool-installer.ps1")" ;; esac diff --git a/tests/release-tooling.py b/tests/release-tooling.py new file mode 100644 index 0000000..04812c5 --- /dev/null +++ b/tests/release-tooling.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Offline checks of exact-byte packaging and the Pages allowlist.""" +import hashlib +import pathlib +import re +import subprocess +import sys +import tempfile + +root = pathlib.Path(__file__).resolve().parent.parent +with tempfile.TemporaryDirectory(prefix='jvm-release-test-') as temp: + temp = pathlib.Path(temp) + subprocess.run([sys.executable, str(root / 'tools/embed-installer.py'), '--check'], check=True) + version = re.search(r'^VERSION=(.+)$', (root / 'jvm').read_text(), re.M)[1] + package = root / 'tools/package-release.py' + subprocess.run([sys.executable, str(package), version, str(temp / 'assets')], check=True) + expected = [] + for name in ('jvm', 'jvm.ps1', 'jvm.cmd'): + content = (root / name).read_bytes() + assert content == (temp / 'assets' / name).read_bytes() + expected.append(f'{hashlib.sha256(content).hexdigest()} {name}\n') + assert (temp / 'assets/SHA256SUMS').read_bytes() == ''.join(expected).encode() + latest = (root / 'web/latest.txt').read_text().strip() + subprocess.run([sys.executable, str(package), latest, str(temp / 'site'), '--site'], check=True) + assert {p.name for p in (temp / 'site').iterdir()} == {'install.sh', 'install.ps1', 'latest.txt', 'index.html', '.nojekyll'} + for path in (temp / 'site').iterdir(): + assert path.read_bytes() == (root / 'web' / path.name).read_bytes() + assert subprocess.run([sys.executable, str(package), 'v' + version, str(temp / 'bad')], capture_output=True).returncode == 2 +print('Exact-byte assets, checksums, embedded installer and Pages allowlist passed') diff --git a/tests/tool-installer.ps1 b/tests/tool-installer.ps1 new file mode 100644 index 0000000..b1e5548 --- /dev/null +++ b/tests/tool-installer.ps1 @@ -0,0 +1,235 @@ +# Offline Windows PowerShell 5.1 tests; registry access is confined to a disposable key. +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$temp = Join-Path ([IO.Path]::GetTempPath()) ('jvm-installer-test-' + [Guid]::NewGuid().ToString('N')) +$savedHome = $env:HOME; $savedData = $env:XDG_DATA_HOME +$registryPath = 'Software\Codejive\InstallerTests\' + [Guid]::NewGuid().ToString('N') +$count = 0 +function Assert($Condition, [string]$Message) { if (-not $Condition) { throw $Message } } +function Pass([string]$Message) { $script:count++; Write-Output "ok $script:count - $Message" } +function Expect-Failure([scriptblock]$Action, [int]$Code = 1) { + $failed = $false + try { & $Action } catch { + $failed = $true + if ($_.Exception.Data.Contains('InstallerCode')) { Assert ($_.Exception.Data['InstallerCode'] -eq $Code) 'unexpected error code' } + } + Assert $failed 'expected failure' +} +try { + [IO.Directory]::CreateDirectory($temp) | Out-Null + $env:HOME = Join-Path $temp "home space 'quote' caf$([char]0xe9)" + $env:XDG_DATA_HOME = Join-Path $temp 'data space' + $source = [IO.File]::ReadAllText((Join-Path $root 'web\install.ps1')) + # Load functions, excluding only the public entry point; no production test hooks. + . ([scriptblock]::Create($source.Substring(0, $source.LastIndexOf('try { Invoke-JvmInstaller @($args) }')))) + function Open-EnvironmentKey { return [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($registryPath) } + function Send-EnvironmentChange { if ($script:failNotification) { throw 'injected registry setup failure' } } + $script:failNotification = $false + $fixture = Join-Path $temp 'fixture' + [IO.Directory]::CreateDirectory($fixture) | Out-Null + $hashes = @{} + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + [IO.File]::Copy((Join-Path $root $name), (Join-Path $fixture $name)) + $hashes[$name] = Get-ToolHash (Join-Path $fixture $name) + } + $sums = @('jvm', 'jvm.ps1', 'jvm.cmd') | ForEach-Object { "$($hashes[$_]) $_" } + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), (($sums -join "`n") + "`n")) + [IO.File]::WriteAllText((Join-Path $fixture 'latest.txt'), "0.2.0`n") + $script:requests = @() + $script:downloadFailure = $false + function Get-Download([string]$Url, [string]$File) { + $script:requests += $Url + if ($script:downloadFailure) { throw 'injected HTTP failure' } + [IO.File]::Copy((Join-Path $fixture ($Url.Split('/')[-1])), $File) + } + Invoke-JvmInstaller @('-Help') | Out-Null + Expect-Failure { Invoke-JvmInstaller @('-Path', '-NoPath') } 2 + Expect-Failure { Invoke-JvmInstaller @('-Version', '01.2.3') } 2 + Expect-Failure { Invoke-JvmInstaller @('-Shell', 'bash') } 2 + Assert ($script:requests.Count -eq 0 -and -not (Test-Path -LiteralPath $env:HOME)) 'help/usage caused side effects' + Pass 'help and usage validate before networking/storage' + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $root 'web\install.ps1') -Help | Out-Null + Assert ($LASTEXITCODE -eq 0) 'public help exit status' + # Public invocation must report usage 2, including when launched from CMD. + $publicInvalid = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + (Join-Path $root 'web\install.ps1') + '" -Path -NoPath 2>nul' + & $env:ComSpec /d /c $publicInvalid + Assert ($LASTEXITCODE -eq 2) 'CMD public invalid argument exit status' + Pass 'public PowerShell/CMD entry points and exit statuses' + Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') + $destination = Join-Path $env:HOME '.local\bin' + $state = Join-Path $env:XDG_DATA_HOME 'jvm\tool-install' + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { Assert ((Get-ToolHash (Join-Path $destination $name)) -ceq $hashes[$name]) "payload differs: $name" } + Assert (-not (Test-Path -LiteralPath (Join-Path $env:XDG_DATA_HOME 'jvm\jdks'))) 'created JDK storage' + Assert ($script:requests.Count -eq 5) 'latest should resolve exactly once' + Pass 'latest, native paths, non-ASCII home and complete exact-byte Windows payload' + Invoke-JvmInstaller @('-NonInteractive') + $key = Open-EnvironmentKey + try { Assert (-not (Get-RegistryValue $key 'Path').Exists -and -not (Get-RegistryValue $key 'JAVA_HOME').Exists) 'unattended implicit edits' } finally { $key.Dispose() } + Pass 'unspecified unattended setup is skipped' + $script:requests = @() + Invoke-JvmInstaller @('-Version', '0.2.0', '-NonInteractive', '-Path', '-Java') + Assert (-not ($script:requests -like '*latest.txt')) 'pinned version fetched latest' + $key = Open-EnvironmentKey + try { + $before = Get-RegistryValue $key 'Path' + Assert ($before.Value.Split(';').Count -eq 2) 'expected two whole entries' + Assert ((Get-RegistryValue $key 'JAVA_HOME').Value -ceq (Join-Path $env:XDG_DATA_HOME 'jvm\jdks\default')) 'wrong stable Java home' + } finally { $key.Dispose() } + Invoke-JvmInstaller @('-NonInteractive', '-Path', '-Java') + $key = Open-EnvironmentKey + try { Assert ((Get-RegistryValue $key 'Path').Value -ceq $before.Value) 'repeat setup duplicated PATH' } finally { $key.Dispose() } + Pass 'explicit consent, missing PATH, registry Java setup and idempotence' + $key = Open-EnvironmentKey + try { + $key.SetValue('Path', '%USERPROFILE%\custom;C:\Other Java\bin;;', [Microsoft.Win32.RegistryValueKind]::ExpandString) + $key.SetValue('JAVA_HOME', 'C:\Old JDK', [Microsoft.Win32.RegistryValueKind]::String) + } finally { $key.Dispose() } + Invoke-JvmInstaller @('-NonInteractive', '-Path', '-Java') + $key = Open-EnvironmentKey + try { + Assert ((Get-RegistryValue $key 'Path').Value.EndsWith('%USERPROFILE%\custom;C:\Other Java\bin;;')) 'raw tokens changed' + Assert ((Get-RegistryValue $key 'Path').Kind -eq 2) 'expandable type changed' + Assert ((Get-RegistryValue $key 'JAVA_HOME').Kind -eq 1) 'string type changed' + $oldPath = Get-RegistryValue $key 'Path'; $oldJava = Get-RegistryValue $key 'JAVA_HOME' + $key.SetValue('Path', 'C:\untouched', [Microsoft.Win32.RegistryValueKind]::String) + $key.SetValue('JAVA_HOME', 'C:\untouched', [Microsoft.Win32.RegistryValueKind]::ExpandString) + } finally { $key.Dispose() } + $script:failNotification = $true + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive', '-Path', '-Java') } + $script:failNotification = $false + $key = Open-EnvironmentKey + try { + Assert ((Get-RegistryValue $key 'Path').Value -ceq 'C:\untouched') 'PATH rollback failed' + Assert ((Get-RegistryValue $key 'Path').Kind -eq 1) 'PATH type rollback failed' + Assert ((Get-RegistryValue $key 'JAVA_HOME').Value -ceq 'C:\untouched') 'JAVA_HOME rollback failed' + Assert ((Get-RegistryValue $key 'JAVA_HOME').Kind -eq 2) 'JAVA_HOME type rollback failed' + } finally { $key.Dispose() } + Pass 'raw registry values/types preserved and failed setup rolled back' + $file = Join-Path $destination 'jvm.cmd' + [IO.File]::AppendAllText($file, 'modified') + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') } + [IO.File]::Copy((Join-Path $fixture 'jvm.cmd'), $file, $true) + Pass 'locally modified payload refused' + $goodSums = [IO.File]::ReadAllText((Join-Path $fixture 'SHA256SUMS')) + foreach ($bad in @('error', ($goodSums + $sums[0]), ($goodSums.Replace('jvm.cmd', '../jvm.cmd')))) { + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), $bad) + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + } + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), $goodSums) + [IO.File]::AppendAllText((Join-Path $fixture 'jvm'), 'corrupt') + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + [IO.File]::Copy((Join-Path $root 'jvm'), (Join-Path $fixture 'jvm'), $true) + $script:downloadFailure = $true + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + $script:downloadFailure = $false + Assert ((Get-ToolHash (Join-Path $destination 'jvm')) -ceq $hashes['jvm']) 'failure damaged installed tool' + Pass 'malformed checksum data, duplicates, paths, checksum mismatch and download failure' + $lock = [IO.File]::Open((Join-Path $state 'lock'), [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + try { Expect-Failure { Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') } } finally { $lock.Dispose() } + Pass 'concurrent installer lock refuses a second publisher' + $tx = Join-Path $state 'transaction' + [IO.Directory]::CreateDirectory($tx) | Out-Null + [IO.File]::Copy((Join-Path $state 'record'), (Join-Path $tx 'old-record')) + [IO.File]::Copy((Join-Path $state 'record'), (Join-Path $tx 'new-record')) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $destination $name), (Join-Path $tx "old-$name")) } + [IO.File]::WriteAllText((Join-Path $tx 'ready'), 'ready') + [IO.File]::WriteAllText((Join-Path $destination 'jvm.ps1'), 'interrupted') + Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') + Assert ((Get-ToolHash (Join-Path $destination 'jvm.ps1')) -ceq $hashes['jvm.ps1']) 'recovery failed' + Assert (-not (Test-Path -LiteralPath $tx)) 'transaction not cleared' + Pass 'interrupted multi-file publication recovers before retrying' + $installedTime = [datetime]'2000-01-01' + [IO.File]::SetLastWriteTimeUtc((Join-Path $destination 'jvm'), $installedTime) + Invoke-JvmInstaller @('-Version', '0.2.0', '-NonInteractive', '-NoPath', '-NoJava') + Assert ([IO.File]::GetLastWriteTimeUtc((Join-Path $destination 'jvm')) -eq $installedTime) 'matching reinstall replaced bytes' + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + [IO.File]::AppendAllText((Join-Path $fixture $name), "`nnew fixture release`n") + $hashes[$name] = Get-ToolHash (Join-Path $fixture $name) + } + $newSums = @('jvm', 'jvm.ps1', 'jvm.cmd') | ForEach-Object { "$($hashes[$_]) $_" } + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), (($newSums -join "`n") + "`n")) + $held = [IO.File]::Open((Join-Path $destination 'jvm.ps1'), [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { Expect-Failure { Invoke-JvmInstaller @('-Version', '0.2.1', '-NonInteractive', '-NoPath', '-NoJava') } } + finally { $held.Dispose() } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { Assert ((Get-ToolHash (Join-Path $destination $name)) -ceq (Get-ToolHash (Join-Path $root $name))) 'failed replacement damaged old payload' } + Assert (-not (Test-Path -LiteralPath $tx)) 'rollback left a transaction' + Invoke-JvmInstaller @('-Version', '0.2.1', '-NonInteractive', '-NoPath', '-NoJava') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { Assert ((Get-ToolHash (Join-Path $destination $name)) -ceq $hashes[$name]) 'update did not replace full payload' } + # Reset fixture to a coherent release for subsequent integration checks. + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + [IO.File]::Copy((Join-Path $root $name), (Join-Path $fixture $name), $true) + $hashes[$name] = Get-ToolHash (Join-Path $fixture $name) + } + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), $goodSums) + Invoke-JvmInstaller @('-Version', '0.2.0', '-NonInteractive', '-NoPath', '-NoJava') + Pass 'matching reinstall is a no-op, locked-file failure rolls back and owned update succeeds' + [IO.Directory]::CreateDirectory($tx) | Out-Null + [IO.File]::WriteAllText((Join-Path $tx 'unknown'), 'preserve') + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') } + Assert ([IO.File]::ReadAllText((Join-Path $tx 'unknown')) -ceq 'preserve') 'unknown recovery material deleted' + [IO.File]::Delete((Join-Path $tx 'unknown')); [IO.Directory]::Delete($tx) + Pass 'unknown recovery material is retained' + $env:HOME = 'relative' + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + $env:HOME = Join-Path $temp 'fallback home' + $dataOverride = $env:XDG_DATA_HOME + $env:XDG_DATA_HOME = 'C:relative' + Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') + Assert (Test-Path -LiteralPath (Join-Path $env:HOME '.local\share\jvm\tool-install\record')) 'drive-relative XDG override did not fall back' + $env:XDG_DATA_HOME = $dataOverride + $env:HOME = Join-Path $temp 'unowned' + [IO.Directory]::CreateDirectory((Join-Path $env:HOME '.local\bin')) | Out-Null + [IO.File]::WriteAllText((Join-Path $env:HOME '.local\bin\jvm'), 'unowned') + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + Pass 'relative HOME and unowned destination refused' + $env:JVM_TEST_FIXTURE = $fixture + $env:JVM_TEST_REGISTRY = $registryPath + $shim = @' +function Open-EnvironmentKey { return [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($env:JVM_TEST_REGISTRY) } +function Send-EnvironmentChange {} +function Get-Download([string]$Url, [string]$File) { [IO.File]::Copy((Join-Path $env:JVM_TEST_FIXTURE ($Url.Split('/')[-1])), $File) } +'@ + $env:HOME = Join-Path $temp 'cmd home' + $env:XDG_DATA_HOME = Join-Path $temp 'cmd data' + $env:JVM_TEST_BOOTSTRAP = Join-Path $temp 'cmd-bootstrap.ps1' + [IO.File]::WriteAllText($env:JVM_TEST_BOOTSTRAP, $source.Replace('try { Invoke-JvmInstaller @($args) }', ($shim + "`ntry { Invoke-JvmInstaller @(`$args) }"))) + # Keep the documented bootstrap expression and CMD quote boundaries exact; + # replace only the HTTP command with a local fixture inside PowerShell. + $cmdBootstrap = 'powershell.exe -NoProfile -Command "$ErrorActionPreference = ''Stop''; Remove-Item Alias:irm -Force; function irm { param($Url) if ($Url -cne ''https://getjvm.dev/install.ps1'') { throw ''bad URL'' }; [IO.File]::ReadAllText($env:JVM_TEST_BOOTSTRAP) }; iex (irm ''https://getjvm.dev/install.ps1'')"' + & $env:ComSpec /d /c $cmdBootstrap + Assert ($LASTEXITCODE -eq 0) 'documented CMD bootstrap quoting failed' + Assert ((Get-ToolHash (Join-Path $env:HOME '.local\bin\jvm.cmd')) -ceq $hashes['jvm.cmd']) 'CMD bootstrap payload differs' + Pass 'documented CMD bootstrap expression and quoting with offline HTTP fixture' + if ($env:JVM_TEST_BASH) { + # Use the real Bash-to-PowerShell path with in-file test doubles; the shipped + # bootstrap has no download/registry overrides or arbitrary setup-only API. + $env:HOME = Join-Path $temp 'bash home' + $env:XDG_DATA_HOME = Join-Path $temp 'bash data' + $bootstrap = [IO.File]::ReadAllText((Join-Path $root 'web\install.sh')) + $bootstrap = $bootstrap.Replace('try { Invoke-JvmInstaller @($args) }', ($shim + "`ntry { Invoke-JvmInstaller @(`$args) }")) + $bootstrapPath = Join-Path $temp 'bootstrap.sh' + [IO.File]::WriteAllText($bootstrapPath, $bootstrap, (New-Object Text.UTF8Encoding $false)) + & $env:JVM_TEST_BASH $bootstrapPath --version 0.2.0 --path --java --non-interactive + Assert ($LASTEXITCODE -eq 0) 'Bash-to-PowerShell bootstrap failed' + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + Assert ((Get-ToolHash (Join-Path $env:HOME ".local\bin\$name")) -ceq $hashes[$name]) 'Bash payload differs' + } + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $env:JVM_TEST_BASH -c 'HOME=relative; source "$1" --version 0.2.0 --no-path --no-java --non-interactive' test $bootstrapPath 2> (Join-Path $temp 'bash-invalid.err') + $invalidStatus = $LASTEXITCODE + } finally { $ErrorActionPreference = $previousPreference } + Assert ($invalidStatus -eq 1) 'Windows Bash accepted relative HOME' + Assert ([IO.File]::ReadAllText((Join-Path $temp 'bash-invalid.err')) -like '*HOME must be absolute*') 'Windows Bash did not reject HOME before publication' + Pass 'real Windows Bash delegates publication and registry setup to embedded PowerShell' + } + Write-Output "$count PowerShell installer checks passed" +} finally { + $env:HOME = $savedHome; $env:XDG_DATA_HOME = $savedData + Remove-Item Env:JVM_TEST_FIXTURE, Env:JVM_TEST_REGISTRY, Env:JVM_TEST_BOOTSTRAP -ErrorAction SilentlyContinue + [Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($registryPath, $false) + if (([IO.Path]::GetFullPath($temp)).StartsWith([IO.Path]::GetFullPath([IO.Path]::GetTempPath()), [StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/tests/tool-installer.sh b/tests/tool-installer.sh new file mode 100644 index 0000000..60209e3 --- /dev/null +++ b/tests/tool-installer.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# Deterministic Unix-contract tests; Windows runs simulate uname, not native Unix. +set -eu +ROOT=$(cd "$(dirname "$0")/.." && pwd) +TEMP=$(mktemp -d "${TMPDIR:-/tmp}/jvm-tool-test.XXXXXXXX") +trap 'status=$?; if [ "$status" != 0 ]; then cat "$TEMP/err" >&2 2>/dev/null || :; fi; rm -rf -- "$TEMP"' EXIT +trap 'exit 1' HUP INT TERM +export HOME="$TEMP/home space 'quote' !"'$literal `data`' +export XDG_DATA_HOME="$TEMP/data space 'quote' !"'$literal `data`' +export XDG_CONFIG_HOME="$TEMP/config" SHELL=/bin/bash +unset JAVA_HOME ZDOTDIR +mkdir -p "$HOME" "$TEMP/fixtures" "$TEMP/bin" +cp "$ROOT/jvm" "$ROOT/jvm.ps1" "$ROOT/jvm.cmd" "$TEMP/fixtures/" +printf '0.2.0\n' > "$TEMP/fixtures/latest.txt" +REAL_HASH=$(command -v sha256sum) +(cd "$TEMP/fixtures"; "$REAL_HASH" -t jvm jvm.ps1 jvm.cmd > SHA256SUMS) +export FIXTURES="$TEMP/fixtures" LOG="$TEMP/requests" +cat > "$TEMP/bin/curl" <<'EOF' +#!/usr/bin/env bash +set -eu +out= url= +while [ "$#" -gt 0 ]; do + case $1 in -o) out=$2; shift ;; https:*) url=$1 ;; esac + shift +done +printf '%s\n' "$url" >> "$LOG" +[ "${DOWNLOAD_FAIL:-0}" = 0 ] || exit 22 +cp "$FIXTURES/${url##*/}" "$out" +EOF +cat > "$TEMP/bin/wget" <<'EOF' +#!/usr/bin/env bash +set -eu +out= url= +while [ "$#" -gt 0 ]; do + case $1 in -O) out=$2; shift ;; https:*) url=$1 ;; esac + shift +done +printf '%s\n' "$url" >> "$LOG" +[ "${DOWNLOAD_FAIL:-0}" = 0 ] || exit 8 +if [ "${WGET_REDIRECT:-}" = downgrade ]; then + printf ' HTTP/1.1 302 Found\n Location: http://insecure.example/payload\n' >&2 + exit 8 +fi +if [ "${WGET_REDIRECT:-}" = secure ]; then + case $url in + *://github.com/*) printf ' HTTP/1.1 302 Found\n Location: https://release.example/%s\n' "${url##*/}" >&2; exit 8 ;; + esac +fi +printf ' HTTP/1.1 200 OK\n' >&2 +cp "$FIXTURES/${url##*/}" "$out" +EOF +cat > "$TEMP/bin/uname" <<'EOF' +#!/usr/bin/env bash +printf 'Linux\n' +EOF +chmod +x "$TEMP/bin/"* +export PATH="$TEMP/bin:$PATH" +INSTALLER=$ROOT/web/install.sh +DEST=$HOME/.local/bin STATE=$XDG_DATA_HOME/jvm/tool-install +count=0 +ok() { count=$((count+1)); printf 'ok %s - %s\n' "$count" "$*"; } +run() { bash "$INSTALLER" "$@" > "$TEMP/out" 2> "$TEMP/err"; } +fails() { + local expected=$1 status=0; shift + run "$@" || status=$? + [ "$status" = "$expected" ] || { cat "$TEMP/err" >&2; echo "expected $expected, got $status" >&2; exit 1; } +} +run --help +fails 2 --path --no-path +fails 2 --version 01.2.3 +fails 2 --version "$(printf '0.2.0\ninvalid')" +fails 2 --profile /tmp/x +[ ! -e "$LOG" ] && [ ! -e "$HOME/.local" ] +ok 'help and invalid options have no side effects' +run --non-interactive --no-path --no-java +cmp "$DEST/jvm" "$ROOT/jvm" +[ ! -e "$HOME/.bashrc" ] && [ ! -e "$XDG_DATA_HOME/jvm/jdks" ] +[ "$(wc -l < "$LOG" | tr -d ' ')" = 3 ] +ok 'latest pins payload, exact bytes, unattended defaults preserve JDK storage' +: > "$LOG" +touch -t 200001010000 "$DEST/jvm" +touch "$TEMP/mtime-sentinel" +run --version 0.2.0 --non-interactive --no-path --no-java +! grep latest.txt "$LOG" +[ "$DEST/jvm" -ot "$TEMP/mtime-sentinel" ] +ok 'pinned matching reinstall bypasses latest' +run --path --java --shell bash --non-interactive +[ -f "$HOME/.profile" ] && [ ! -e "$HOME/.bash_profile" ] +# Start a real fresh Bash. Generated setup must treat metacharacters as data. +bash --noprofile --norc -c '. "$HOME/.profile"; . "$HOME/.bashrc"; . "$HOME/.profile"; [ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]; case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) exit 1 ;; esac' +bash --noprofile --norc -c 'PATH=; . "$HOME/.profile"; case "$PATH" in *:) ;; *) exit 1 ;; esac; unset PATH; . "$HOME/.profile"; case "$PATH" in *:) exit 1 ;; esac' +before=$(find "$HOME" -name '*.jvm-backup.*' | wc -l) +run --path --java --shell bash --non-interactive +[ "$before" = "$(find "$HOME" -name '*.jvm-backup.*' | wc -l)" ] +ok 'Bash startup executes safely, deduplicates and repeat setup preserves backups' +printf '\n# user edit\n' >> "$DEST/jvm" +fails 1 --no-path --no-java --non-interactive +grep -q 'locally modified' "$TEMP/err" +cp "$ROOT/jvm" "$DEST/jvm" +ok 'modified owned file refused' +cp "$FIXTURES/SHA256SUMS" "$TEMP/good-sums" +printf 'error\n' > "$FIXTURES/SHA256SUMS" +fails 1 --non-interactive +cp "$TEMP/good-sums" "$FIXTURES/SHA256SUMS" +printf bad >> "$FIXTURES/jvm" +fails 1 --non-interactive +cmp "$DEST/jvm" "$ROOT/jvm" +cp "$ROOT/jvm" "$FIXTURES/jvm" +DOWNLOAD_FAIL=1 fails 1 --non-interactive +printf '0.2.0\n0.3.0\n' > "$FIXTURES/latest.txt" +fails 1 --non-interactive +printf '0.2.0\ninvalid' > "$FIXTURES/latest.txt" +fails 1 --non-interactive +printf '0.2.0\n' > "$FIXTURES/latest.txt" +ok 'HTTP failures, malformed metadata and checksum mismatch preserve tool' +printf '# >>> jvm path >>>\n' >> "$HOME/.bashrc" +fails 1 --path --shell bash --non-interactive +grep -q 'Tool was installed' "$TEMP/err" +sed '$d' "$HOME/.bashrc" > "$TEMP/profile"; cp "$TEMP/profile" "$HOME/.bashrc" +ok 'corrupt markers fail setup after usable publication' +printf '# existing login\n' > "$HOME/.bash_login" +run --path --shell bash --no-java --non-interactive +grep -q '^# existing login' "$HOME/.bash_login" +[ ! -e "$HOME/.bash_profile" ] +ok 'Bash honors login precedence' +mkdir -p "$STATE/lock" +printf '%s\n' "$$" > "$STATE/lock/pid" +fails 1 --non-interactive --no-path --no-java +grep -q 'another installer' "$TEMP/err" +rm "$STATE/lock/pid"; rmdir "$STATE/lock" +ok 'live competing installer refused' +mkdir "$STATE/transaction" +cp "$STATE/record" "$STATE/transaction/old-record" +cp "$STATE/record" "$STATE/transaction/new-record" +cp -p "$DEST/jvm" "$STATE/transaction/old-jvm" +touch "$STATE/transaction/ready" +printf partial > "$DEST/jvm" +run --non-interactive --no-path --no-java +cmp "$DEST/jvm" "$ROOT/jvm" +[ ! -e "$STATE/transaction" ] +ok 'interrupted update restores prior payload and ownership before retry' +# Force failures after a replacement payload has been published, before record +# publication. Rollback must restore a prior usable version, not the new bytes. +REAL_MV=$(command -v mv) +export REAL_MV INJECT_TARGET="$STATE/record" INJECT_ONCE="$TEMP/injected" +cat > "$TEMP/bin/mv" <<'EOF' +#!/usr/bin/env bash +set -eu +last= +for arg in "$@"; do last=$arg; done +if [ -n "${INJECT_MODE:-}" ] && [ "$last" = "$INJECT_TARGET" ] && [ ! -e "$INJECT_ONCE" ]; then + : > "$INJECT_ONCE" + if [ "$INJECT_MODE" = signal ]; then kill -TERM "$PPID"; fi + exit 1 +fi +exec "$REAL_MV" "$@" +EOF +chmod +x "$TEMP/bin/mv" +printf '\n# new release fixture\n' >> "$FIXTURES/jvm" +(cd "$FIXTURES"; "$REAL_HASH" -t jvm jvm.ps1 jvm.cmd > SHA256SUMS) +for mode in fail signal; do + rm -f "$INJECT_ONCE" + INJECT_MODE=$mode fails 1 --version 0.2.1 --non-interactive --no-path --no-java + cmp "$DEST/jvm" "$ROOT/jvm" + [ "$(sed -n '2p' "$STATE/record")" = 0.2.0 ] +done +ok 'publication error and catchable interruption restore old payload/record' +run --version 0.2.1 --non-interactive --no-path --no-java +cmp "$DEST/jvm" "$FIXTURES/jvm" +[ "$(sed -n '2p' "$STATE/record")" = 0.2.1 ] +cp "$ROOT/jvm" "$FIXTURES/jvm" +(cd "$FIXTURES"; "$REAL_HASH" -t jvm jvm.ps1 jvm.cmd > SHA256SUMS) +run --version 0.2.0 --non-interactive --no-path --no-java +ok 'owned update and explicit version replacement' +# The first profile in a two-file step must be restored if the second fails. +cp "$HOME/.bashrc" "$TEMP/original-bashrc" +cp "$HOME/.bash_login" "$TEMP/original-login" +printf '# original login before failed publication\n' > "$HOME/.bash_login" +printf '# force path regeneration\n' > "$HOME/.bashrc" +cp "$HOME/.bashrc" "$TEMP/before-rollback" +rm -f "$INJECT_ONCE" +INJECT_TARGET="$HOME/.bash_login" INJECT_MODE=fail fails 1 --path --shell bash --no-java --non-interactive +cmp "$HOME/.bashrc" "$TEMP/before-rollback" +cp "$TEMP/original-bashrc" "$HOME/.bashrc" +cp "$TEMP/original-login" "$HOME/.bash_login" +ok 'multi-profile setup step rolls back earlier edits on failure' +mkdir "$STATE/transaction" +printf 'do not delete\n' > "$STATE/transaction/unknown" +fails 1 --non-interactive --no-path --no-java +grep -q 'do not delete' "$STATE/transaction/unknown" +rm "$STATE/transaction/unknown"; rmdir "$STATE/transaction" +ok 'unknown recovery material retained for inspection' +custom="$HOME/custom-profile" +printf '# preserved\n' > "$custom" +ln -s "$custom" "$HOME/link-profile" +if [ -L "$HOME/link-profile" ]; then + fails 1 --path --shell zsh --profile "$HOME/link-profile" --non-interactive +else + printf 'Native symlink assertion requires hosted Unix (ln made a copy here).\n' +fi +run --path --shell zsh --profile "$custom" --no-java --non-interactive +grep -q '^# preserved' "$custom" +ok 'custom profile preserves content and symlink profiles are refused' +# Force capability detection to exercise wget without contacting the network. +sed '/# BEGIN GENERATED WINDOWS INSTALLER/,$d' "$INSTALLER" > "$TEMP/wget-installer" +cat >> "$TEMP/wget-installer" <<'EOF' +command() { if [ "$1" = -v ] && [ "$2" = curl ]; then return 1; fi; builtin command "$@"; } +main "$@" +EOF +INSTALLER=$TEMP/wget-installer +run --no-path --no-java --non-interactive +ok 'wget-only capability path' +WGET_REDIRECT=secure run --no-path --no-java --non-interactive +WGET_REDIRECT=downgrade fails 1 --no-path --no-java --non-interactive +! grep '^http://' "$LOG" +ok 'wget follows verified HTTPS hops and rejects downgrade before any HTTP request' +INSTALLER=$ROOT/web/install.sh +# Capability alternatives are tested without installing utilities. +for hasher in shasum openssl; do + if ! command -v "$hasher" >/dev/null 2>&1; then continue; fi + sed '/# BEGIN GENERATED WINDOWS INSTALLER/,$d' "$INSTALLER" > "$TEMP/hash-installer" + cat >> "$TEMP/hash-installer" <<'EOF' +command() { + if [ "$1" = -v ]; then + case $2 in sha256sum) return 1 ;; shasum) [ "$TEST_HASHER" != openssl ] || return 1 ;; esac + fi + builtin command "$@" +} +main "$@" +EOF + INSTALLER=$TEMP/hash-installer + TEST_HASHER=$hasher run --no-path --no-java --non-interactive + INSTALLER=$ROOT/web/install.sh +done +ok 'available SHA-256 alternatives' +for shell in zsh fish csh tcsh; do + profile="$HOME/$shell-profile" + run --path --java --shell "$shell" --profile "$profile" --non-interactive + [ "$(grep -c '^# >>> jvm ' "$profile")" = 2 ] +done +ok 'all supported shell formats generated' +ZDOTDIR="$HOME/custom-zdot" run --shell zsh --path --no-java --non-interactive +[ -f "$HOME/custom-zdot/.zshrc" ] +run --shell fish --path --java --non-interactive +[ -f "$XDG_CONFIG_HOME/fish/conf.d/jvm.fish" ] +ok 'exported ZDOTDIR and Fish XDG configuration root' +request_count=$(wc -l < "$LOG") +HOME=relative fails 1 --non-interactive --no-path --no-java +[ "$request_count" = "$(wc -l < "$LOG")" ] +mkdir -p "$TEMP/conflict-home/.local/bin" +printf unowned > "$TEMP/conflict-home/.local/bin/jvm" +HOME="$TEMP/conflict-home" XDG_DATA_HOME="$TEMP/conflict-data" fails 1 --non-interactive --no-path --no-java +[ "$(cat "$TEMP/conflict-home/.local/bin/jvm")" = unowned ] +ok 'relative HOME rejected before networking and unowned file preserved' +HOME="$TEMP/fallback-home" XDG_DATA_HOME=relative run --non-interactive --no-path --no-java +[ -f "$TEMP/fallback-home/.local/share/jvm/tool-install/record" ] +ok 'relative XDG override falls back beneath isolated HOME' +# Never depend on a contributor's controlling terminal in the offline suite. +cat "$INSTALLER" | HOME="$TEMP/piped-home" XDG_DATA_HOME="$TEMP/piped-data" bash -s -- --version 0.2.0 --non-interactive > "$TEMP/out" 2> "$TEMP/err" +[ -f "$TEMP/piped-home/.local/bin/jvm" ] && [ ! -e "$TEMP/piped-home/.bashrc" ] +ok 'piped bootstrap skips unspecified setup without consuming script stdin' +if [ "${1:-}" = --native-shells ]; then + # Hosted Unix only: use actual shells, including paths with apostrophes/spaces. + for shell in zsh fish csh tcsh; do + command -v "$shell" >/dev/null + profile="$HOME/$shell-profile" + if [ "$shell" = fish ]; then + "$shell" --no-config -c 'source "$argv[1]"; source "$argv[1]"; test "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default"' "$profile" + elif [ "$shell" = zsh ]; then + "$shell" -f -c '. "$1"; . "$1"; [ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' test "$profile" + else + # csh needs :q for argument paths, and unset-path behavior is intentional. + "$shell" -f -c 'unset path; source "$argv[1]"; source "$argv[1]"; if ( "$JAVA_HOME" != "$XDG_DATA_HOME/jvm/jdks/default" ) exit 1' "$profile" + fi + done + ok 'actual Zsh/Fish/csh/tcsh source managed setup twice' + # Also launch normal startup paths, not just explicit source commands. + run --shell zsh --path --java --non-interactive + run --shell csh --path --java --non-interactive + printf '# tcsh startup\n' > "$HOME/.tcshrc" + run --shell tcsh --path --java --non-interactive + bash --noprofile --rcfile "$HOME/.bashrc" -i -c '[ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' > "$TEMP/startup-out" 2> "$TEMP/startup-err" + [ ! -s "$TEMP/startup-out" ] + zsh -d -i -c '[ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' > "$TEMP/startup-out" + [ ! -s "$TEMP/startup-out" ] + fish --private -c 'test "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default"' > "$TEMP/startup-out" + [ ! -s "$TEMP/startup-out" ] + for shell in csh tcsh; do + "$shell" -c 'if ( "$JAVA_HOME" != "$XDG_DATA_HOME/jvm/jdks/default" ) exit 1' > "$TEMP/startup-out" + [ ! -s "$TEMP/startup-out" ] + done + ok 'fresh shells load native startup files silently' +fi +printf '%s installer checks passed\n' "$count" diff --git a/tools/embed-installer.py b/tools/embed-installer.py new file mode 100644 index 0000000..234dccf --- /dev/null +++ b/tools/embed-installer.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Embed the single-source Windows installer in the standalone Bash bootstrap.""" +import pathlib +import sys + +root = pathlib.Path(__file__).resolve().parent.parent +target = root / 'web/install.sh' +marker = '# BEGIN GENERATED WINDOWS INSTALLER\n' +prefix = target.read_text().split(marker)[0] +result = prefix + marker + "windows_installer() {\ncat <<'JVM_POWERSHELL_INSTALLER'\n" + (root / 'web/install.ps1').read_text() + "JVM_POWERSHELL_INSTALLER\n}\nmain \"$@\"\n" +if '--check' in sys.argv: + if target.read_bytes() != result.encode(): + sys.exit('Run python tools/embed-installer.py') +else: + target.write_bytes(result.encode()) diff --git a/tools/package-release.py b/tools/package-release.py new file mode 100644 index 0000000..a820389 --- /dev/null +++ b/tools/package-release.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Offline exact-byte release packaging and public-site staging (contributor tool).""" +import argparse +import hashlib +import pathlib +import re +import shutil + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ASSETS = ('jvm', 'jvm.ps1', 'jvm.cmd') +VERSION = r'(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('version') + parser.add_argument('destination', type=pathlib.Path) + parser.add_argument('--site', action='store_true') + args = parser.parse_args() + if not re.fullmatch(VERSION, args.version): + parser.error('version must be numeric major.minor.patch without leading zeros') + if not args.site: + for name, pattern in [('jvm', r'^VERSION=([^\n]+)$'), + ('jvm.ps1', r"^\$script:Version = '([^']+)'$")]: + match = re.search(pattern, (ROOT / name).read_text(), re.M) + if not match or match[1] != args.version: + parser.error(f'{name} version does not match {args.version}') + elif (ROOT / 'web/latest.txt').read_bytes() != (args.version + '\n').encode(): + parser.error('site version must match latest.txt') + if args.destination.exists(): + parser.error('destination must not exist') + args.destination.mkdir(parents=True) + if args.site: + for name in ('install.sh', 'install.ps1', 'latest.txt', 'index.html', '.nojekyll'): + shutil.copyfile(ROOT / 'web' / name, args.destination / name) + else: + lines = [] + for name in ASSETS: + content = (ROOT / name).read_bytes() + (args.destination / name).write_bytes(content) + lines.append(f'{hashlib.sha256(content).hexdigest()} {name}\n') + (args.destination / 'SHA256SUMS').write_bytes(''.join(lines).encode('ascii')) + + +if __name__ == '__main__': + main() diff --git a/tools/pages-preflight.py b/tools/pages-preflight.py new file mode 100644 index 0000000..c5bb764 --- /dev/null +++ b/tools/pages-preflight.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Verify latest.txt against complete, public release assets before deploying Pages.""" +import hashlib +import pathlib +import re +import urllib.request + +root = pathlib.Path(__file__).resolve().parent.parent +version = (root / 'web/latest.txt').read_text() +assert re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\n', version), 'Invalid latest.txt' +base = f'https://github.com/codejive/jvm/releases/download/v{version.strip()}' + + +class HttpsRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + if not newurl.startswith('https://'): + raise ValueError('HTTPS downgrade') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +opener = urllib.request.build_opener(HttpsRedirect()) + + +def get(name): + with opener.open(f'{base}/{name}', timeout=60) as response: + assert response.status == 200 + return response.read() + + +lines = get('SHA256SUMS').decode('ascii').splitlines() +hashes = {} +for line in lines: + match = re.fullmatch(r'([0-9a-f]{64}) (jvm|jvm\.ps1|jvm\.cmd)', line) + assert match and match[2] not in hashes, 'Invalid SHA256SUMS' + hashes[match[2]] = match[1] +assert set(hashes) == {'jvm', 'jvm.ps1', 'jvm.cmd'}, 'Incomplete release' +for name, digest in hashes.items(): + assert hashlib.sha256(get(name)).hexdigest() == digest, f'Invalid {name}' +print(f'Complete verified public release: v{version.strip()}') diff --git a/web/.nojekyll b/web/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e196baa --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + +jvm — Java for your projects +
+

jvm

+

A small tool to download and select Java Development Kits.

+

Install the tool on Unix:

+
curl -fsSL https://getjvm.dev/install.sh | bash
+

Or in Windows PowerShell:

+
iex (irm 'https://getjvm.dev/install.ps1')
+

Then open a fresh terminal and run jvm install to install Java.

+

Instructions, options and source

+
+ diff --git a/web/install.ps1 b/web/install.ps1 new file mode 100644 index 0000000..2d1b12d --- /dev/null +++ b/web/install.ps1 @@ -0,0 +1,362 @@ +# Windows PowerShell 5.1 standalone bootstrap. No JDK commands are invoked. +$ErrorActionPreference = 'Stop' + +function Write-InstallerMessage([string]$Message) { [Console]::Error.WriteLine("jvm installer: $Message") } +function Stop-Installer([string]$Message, [int]$Code = 1) { + $exception = New-Object Exception $Message + $exception.Data['InstallerCode'] = $Code + throw $exception +} +function Test-ToolVersion([string]$Value) { return $Value -cmatch '\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z' } +function Get-ToolHash([string]$File) { + $stream = [IO.File]::OpenRead($File) + $sha = [Security.Cryptography.SHA256]::Create() + try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() } + finally { $sha.Dispose(); $stream.Dispose() } +} +function Test-PlainFile([string]$File) { + if (-not [IO.File]::Exists($File)) { return $false } + return -not ((Get-Item -LiteralPath $File -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) +} +function Assert-SafeRoot([string]$Value) { + if ($Value -notmatch '\A(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)' -or $Value -match '[;\r\n\t]') { + Stop-Installer "Expected an absolute native path without semicolons or line breaks: $Value" + } +} +function Assert-NoReparse([string]$File) { + if (Test-Path -LiteralPath $File) { + if ((Get-Item -LiteralPath $File -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { Stop-Installer "Reparse point conflict: $File" } + } +} +function Get-Download([string]$Url, [string]$File) { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + for ($i = 0; $i -lt 10; $i++) { + if (([Uri]$Url).Scheme -cne 'https') { Stop-Installer 'HTTPS is required, including redirects' } + $request = [Net.HttpWebRequest]::Create($Url) + $request.AllowAutoRedirect = $false + $request.Timeout = 45000 + $request.ReadWriteTimeout = 45000 + $response = $request.GetResponse() + try { + $status = [int]$response.StatusCode + if ($status -in @(301, 302, 303, 307, 308)) { + $Url = ([Uri]::new([Uri]$Url, $response.Headers['Location'])).AbsoluteUri + continue + } + if ($status -ne 200) { Stop-Installer "HTTP $status for $Url" } + $inputStream = $response.GetResponseStream() + $outputStream = [IO.File]::Create($File) + try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose(); $inputStream.Dispose() } + if ((Get-Item -LiteralPath $File).Length -eq 0) { Stop-Installer "Empty download: $Url" } + return + } finally { $response.Dispose() } + } + Stop-Installer 'Too many HTTPS redirects' +} +function Read-ToolChecksums([string]$File) { + $result = @{} + $lines = [IO.File]::ReadAllLines($File) + if ($lines.Count -ne 3) { Stop-Installer 'SHA256SUMS must contain exactly three entries' } + foreach ($line in $lines) { + if ($line -cnotmatch '\A([0-9a-fA-F]{64}) (jvm|jvm\.ps1|jvm\.cmd)\z') { Stop-Installer 'Invalid SHA256SUMS entry' } + $name = $Matches[2]; $digest = $Matches[1].ToLowerInvariant() + if ($result.ContainsKey($name)) { Stop-Installer 'Duplicate SHA256SUMS entry' } + $result[$name] = $digest + } + return $result +} +function Read-ToolRecord([string]$File, [string]$Destination) { + if (-not (Test-PlainFile $File)) { Stop-Installer "Unsafe ownership record: $File" } + $lines = [IO.File]::ReadAllLines($File) + if ($lines.Count -ne 6 -or $lines[0] -cne 'jvm-tool-v1' -or -not (Test-ToolVersion $lines[1]) -or $lines[2] -cne $Destination) { + Stop-Installer 'Invalid ownership record or destination mismatch' + } + $result = @{} + foreach ($line in $lines[3..5]) { + if ($line -cnotmatch '\A([0-9a-f]{64}) (jvm|jvm\.ps1|jvm\.cmd)\z' -or $result.ContainsKey($Matches[2])) { Stop-Installer 'Invalid ownership hashes' } + $result[$Matches[2]] = $Matches[1] + } + return $result +} +function Write-ToolRecord([string]$File, [string]$Version, [string]$Destination, [hashtable]$Hashes) { + $lines = @('jvm-tool-v1', $Version, $Destination) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { $lines += "$($Hashes[$name]) $name" } + [IO.File]::WriteAllText($File, (($lines -join "`n") + "`n"), (New-Object Text.UTF8Encoding $false)) +} +function Remove-ToolStage([string]$Stage) { + Assert-NoReparse $Stage + if (-not (Test-Path -LiteralPath $Stage)) { return } + foreach ($entry in @(Get-ChildItem -LiteralPath $Stage -Force)) { + if ($entry.Name -cnotin @('jvm', 'jvm.ps1', 'jvm.cmd') -or -not (Test-PlainFile $entry.FullName)) { Stop-Installer "Unrecognized staging material: $($entry.FullName)" } + } + foreach ($entry in @(Get-ChildItem -LiteralPath $Stage -Force)) { [IO.File]::Delete($entry.FullName) } + [IO.Directory]::Delete($Stage) +} +function Restore-ToolTransaction([string]$State, [string]$Destination) { + $tx = Join-Path $State 'transaction' + Assert-NoReparse $tx + if (-not (Test-Path -LiteralPath $tx)) { return } + foreach ($entry in @(Get-ChildItem -LiteralPath $tx -Force)) { + if ($entry.Name -cnotin @('new-record', 'old-record', 'old-jvm', 'old-jvm.ps1', 'old-jvm.cmd', 'ready', 'stage-name') -or -not (Test-PlainFile $entry.FullName)) { + Stop-Installer "Unrecognized recovery material: $($entry.FullName); inspect manually" + } + } + if (Test-Path -LiteralPath (Join-Path $tx 'stage-name')) { + if (-not (Test-PlainFile (Join-Path $tx 'stage-name'))) { Stop-Installer 'Unsafe staging journal' } + $stageName = [IO.File]::ReadAllText((Join-Path $tx 'stage-name')) + if ($stageName -cnotmatch '\A\.jvm-next-[a-f0-9]{32}\z') { Stop-Installer 'Invalid staging journal' } + $stage = Join-Path $Destination $stageName + Remove-ToolStage $stage + } + if (Test-Path -LiteralPath (Join-Path $tx 'ready')) { + $new = Read-ToolRecord (Join-Path $tx 'new-record') $Destination + $old = $null + if (Test-Path -LiteralPath (Join-Path $tx 'old-record')) { $old = Read-ToolRecord (Join-Path $tx 'old-record') $Destination } + # Verify every backup before restoring any member; recovery is repeatable. + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + Assert-NoReparse $file + if ((Test-Path -LiteralPath $file) -and -not (Test-PlainFile $file)) { Stop-Installer "Unsafe recovery destination: $file" } + if ($old) { + $backup = Join-Path $tx "old-$name" + if (-not (Test-PlainFile $backup) -or (Get-ToolHash $backup) -cne $old[$name]) { Stop-Installer "Damaged recovery material at $tx; inspect manually" } + } elseif ((Test-Path -LiteralPath $file) -and (Get-ToolHash $file) -cne $new[$name]) { Stop-Installer "Unknown recovery destination: $file" } + } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ($old) { + if (-not (Test-PlainFile $file) -or (Get-ToolHash $file) -cne $old[$name]) { [IO.File]::Copy((Join-Path $tx "old-$name"), $file, $true) } + } + elseif (Test-Path -LiteralPath $file) { [IO.File]::Delete($file) } + } + if ($old) { [IO.File]::Copy((Join-Path $tx 'old-record'), (Join-Path $State 'record'), $true) } + elseif (Test-Path -LiteralPath (Join-Path $State 'record')) { [IO.File]::Delete((Join-Path $State 'record')) } + Write-InstallerMessage 'Recovered interrupted tool publication.' + } + Remove-Item -LiteralPath $tx -Recurse -Force +} +function Publish-Tool([string]$Work, [string]$State, [string]$Destination, [string]$Version, [hashtable]$Hashes) { + [IO.Directory]::CreateDirectory($Destination) | Out-Null + [IO.Directory]::CreateDirectory($State) | Out-Null + Assert-NoReparse $State; Assert-NoReparse $Destination + Assert-NoReparse (Join-Path $State 'lock') + try { $lock = [IO.File]::Open((Join-Path $State 'lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } + catch { Stop-Installer 'Another installer holds the lock, or the lock is inaccessible; retry after it exits' } + $tx = Join-Path $State 'transaction' + try { + Restore-ToolTransaction $State $Destination + $record = Join-Path $State 'record' + $old = $null + Assert-NoReparse $record + if (Test-Path -LiteralPath $record) { $old = Read-ToolRecord $record $Destination } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ($old) { + if (-not (Test-PlainFile $file) -or (Get-ToolHash $file) -cne $old[$name]) { Stop-Installer "Owned file missing or locally modified: $file. Restore it or move the payload and ownership record aside before retrying." } + } elseif (Test-Path -LiteralPath $file) { Stop-Installer "Unowned destination conflict: $file. Move it aside before retrying." } + } + $matching = $null -ne $old + if ($old) { + $matching = ([IO.File]::ReadAllLines($record)[1] -ceq $Version) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { if ($old[$name] -cne $Hashes[$name]) { $matching = $false } } + } + if ($matching) { return } + [IO.Directory]::CreateDirectory($tx) | Out-Null + if ($old) { + [IO.File]::Copy($record, (Join-Path $tx 'old-record')) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $Destination $name), (Join-Path $tx "old-$name")) } + } + Write-ToolRecord (Join-Path $tx 'new-record') $Version $Destination $Hashes + # Destination-filesystem staging, before any published file is changed. + $next = Join-Path $Destination ('.jvm-next-' + [Guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllText((Join-Path $tx 'stage-name'), [IO.Path]::GetFileName($next)) + [IO.Directory]::CreateDirectory($next) | Out-Null + try { + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $Work $name), (Join-Path $next $name)) } + [IO.File]::WriteAllText((Join-Path $tx 'ready'), 'ready') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ([IO.File]::Exists($file)) { [IO.File]::Delete($file) } + [IO.File]::Move((Join-Path $next $name), $file) + } + [IO.File]::Copy((Join-Path $tx 'new-record'), $record, $true) + Remove-Item -LiteralPath $tx -Recurse -Force + } finally { Remove-ToolStage $next } + } catch { + $failure = $_ + try { Restore-ToolTransaction $State $Destination } + catch { Write-InstallerMessage "Recovery retained at $tx. $($_.Exception.Message)" } + throw $failure + } finally { + # Keep serialization through consented registry edits as well. + $script:InstallerLock = $lock + } +} +function Open-EnvironmentKey { return [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') } +function Get-RegistryValue($Key, [string]$Name) { + if ($Key.GetValueNames() -contains $Name) { + return @{ Exists = $true; Kind = [int]$Key.GetValueKind($Name); Value = $Key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } + } + return @{ Exists = $false; Kind = [int][Microsoft.Win32.RegistryValueKind]::ExpandString; Value = '' } +} +function Set-RegistrySnapshot($Key, [string]$Name, $Snapshot) { + if ($Snapshot.Exists) { $Key.SetValue($Name, $Snapshot.Value, [Microsoft.Win32.RegistryValueKind]$Snapshot.Kind) } + else { $Key.DeleteValue($Name, $false) } +} +function Test-PathEntry([string]$Entry, [string]$Target) { + $expanded = [Environment]::ExpandEnvironmentVariables($Entry.Trim().Trim('"')).TrimEnd('\', '/') + return $expanded.Replace('/', '\') -ieq $Target.TrimEnd('\', '/').Replace('/', '\') +} +function Add-UserPath([string]$Value, [string]$Entry) { + foreach ($token in $Value.Split(';')) { if (Test-PathEntry $token $Entry) { return $Value } } + if ([string]::IsNullOrEmpty($Value)) { return $Entry } + return "$Entry;$Value" +} +function Send-EnvironmentChange { + if (-not ('JvmInstaller.Native' -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace JvmInstaller { public static class Native { + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern IntPtr SendMessageTimeout(IntPtr h, uint m, UIntPtr w, string l, uint f, uint t, out UIntPtr r); +}} +'@ + } + $result = [UIntPtr]::Zero + [JvmInstaller.Native]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) | Out-Null +} +function Get-SetupConsent([string]$Choice, [bool]$Unattended, [string]$Prompt) { + if ($Choice -eq 'yes') { return $true } + if ($Choice -eq 'no' -or $Unattended -or [Console]::IsInputRedirected -or -not [Environment]::UserInteractive) { return $false } + try { return (Read-Host "$Prompt [y/N]") -match '\A(?i:y|yes)\z' } catch { return $false } +} +function Set-UserEnvironment([string]$State, [string]$Destination, [string]$JavaHome, [string]$PathChoice, [string]$JavaChoice, [bool]$Unattended) { + if ($PathChoice -eq 'no' -and $JavaChoice -eq 'no') { return } + if ($Unattended -and $PathChoice -ne 'yes' -and $JavaChoice -ne 'yes') { + Write-InstallerMessage "Unspecified environment setup skipped. Manual setup: user PATH adds $Destination; JAVA_HOME=$JavaHome and PATH adds its bin directory." + return + } + $key = Open-EnvironmentKey + try { + $oldPath = Get-RegistryValue $key 'Path' + $oldJava = Get-RegistryValue $key 'JAVA_HOME' + foreach ($snapshot in @($oldPath, $oldJava)) { + if ($snapshot.Exists -and $snapshot.Kind -notin @(1, 2)) { Stop-Installer 'Environment values must be REG_SZ or REG_EXPAND_SZ' } + } + $pathValue = [string]$oldPath.Value + $toolPresent = (Add-UserPath $pathValue $Destination) -ceq $pathValue + $javaBin = Join-Path $JavaHome 'bin' + $javaPresent = $oldJava.Exists -and (Test-PathEntry ([string]$oldJava.Value) $JavaHome) -and ((Add-UserPath $pathValue $javaBin) -ceq $pathValue) + $toolYes = $false; $javaYes = $false + if (-not $toolPresent) { + Write-InstallerMessage "HKCU\Environment\Path ($($oldPath.Kind)): add $Destination; preserve raw existing value: $pathValue" + $toolYes = Get-SetupConsent $PathChoice $Unattended 'Persist tool PATH?' + } + if (-not $javaPresent) { + Write-InstallerMessage "HKCU\Environment: JAVA_HOME '$($oldJava.Value)' -> '$JavaHome'; Path adds $javaBin. A default JDK may not exist yet." + $javaYes = Get-SetupConsent $JavaChoice $Unattended 'Persist default Java?' + } + if (-not $toolYes -and -not $javaYes) { + Write-InstallerMessage "Environment unchanged. Manual setup: user PATH adds $Destination; JAVA_HOME=$JavaHome and PATH adds $javaBin." + return + } + if ($toolYes) { $pathValue = Add-UserPath $pathValue $Destination } + if ($javaYes) { $pathValue = Add-UserPath $pathValue $javaBin } + $backup = Join-Path $State ('environment-backup-' + [Guid]::NewGuid().ToString('N') + '.clixml') + @{ Path = $oldPath; JAVA_HOME = $oldJava } | Export-Clixml -LiteralPath $backup -Encoding UTF8 + try { + if ($javaYes) { $key.SetValue('JAVA_HOME', $JavaHome, [Microsoft.Win32.RegistryValueKind]$oldJava.Kind) } + $key.SetValue('Path', $pathValue, [Microsoft.Win32.RegistryValueKind]$oldPath.Kind) + $key.Flush() + Send-EnvironmentChange + } catch { + $failure = $_ + try { Set-RegistrySnapshot $key 'Path' $oldPath; Set-RegistrySnapshot $key 'JAVA_HOME' $oldJava; $key.Flush() } + catch { Write-InstallerMessage "Registry rollback failed; exact raw backup: $backup" } + throw $failure + } + Write-InstallerMessage "Updated HKCU\Environment; raw value/type backup: $backup" + $systemPath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + foreach ($entry in $systemPath.Split(';')) { + $expanded = [Environment]::ExpandEnvironmentVariables($entry.Trim('"')) + if (-not [IO.Path]::IsPathRooted($expanded)) { continue } + if ([IO.File]::Exists([IO.Path]::Combine($expanded, 'java.exe'))) { Write-InstallerMessage "System Java at $expanded may precede user PATH. Machine PATH was preserved."; break } + } + } finally { $key.Dispose() } +} +function Invoke-JvmInstaller([string[]]$Arguments) { + $version = ''; $pathChoice = 'ask'; $javaChoice = 'ask'; $unattended = $false + $seen = @{} + for ($i = 0; $i -lt $Arguments.Count; $i++) { + $arg = $Arguments[$i] + if ($arg -ieq '-Help') { + if ($Arguments.Count -ne 1) { Stop-Installer '-Help must be used alone' 2 } + Write-Output 'Usage: install.ps1 [-Version major.minor.patch] [-Path | -NoPath] [-Java | -NoJava] [-NonInteractive]' + return + } + $group = $arg.ToLowerInvariant() + if ($group -in @('-path', '-nopath')) { $group = 'path' } + if ($group -in @('-java', '-nojava')) { $group = 'java' } + if ($seen.ContainsKey($group)) { Stop-Installer "Duplicate/conflicting option: $arg" 2 } + $seen[$group] = $true + switch ($arg.ToLowerInvariant()) { + '-version' { $i++; if ($i -ge $Arguments.Count -or -not (Test-ToolVersion $Arguments[$i])) { Stop-Installer 'Version must be numeric major.minor.patch without leading zeros' 2 }; $version = $Arguments[$i] } + '-path' { $pathChoice = 'yes' } + '-nopath' { $pathChoice = 'no' } + '-java' { $javaChoice = 'yes' } + '-nojava' { $javaChoice = 'no' } + '-noninteractive' { $unattended = $true } + default { Stop-Installer "Unknown option: $arg" 2 } + } + } + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { Stop-Installer 'The native installer supports Windows only' } + $userHome = $env:HOME + if ([string]::IsNullOrEmpty($userHome)) { $userHome = $env:USERPROFILE } + Assert-SafeRoot $userHome + $userHome = [IO.Path]::GetFullPath($userHome) + $data = $env:XDG_DATA_HOME + if ([string]::IsNullOrEmpty($data) -or $data -notmatch '\A(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)') { $data = Join-Path $userHome '.local\share' } + Assert-SafeRoot $data + $data = [IO.Path]::GetFullPath($data) + $destination = Join-Path $userHome '.local\bin' + $state = Join-Path $data 'jvm\tool-install' + $javaHome = Join-Path $data 'jvm\jdks\default' + $work = Join-Path ([IO.Path]::GetTempPath()) ('jvm-bootstrap-' + [Guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($work) | Out-Null + $published = $false + try { + if (-not $version) { + Get-Download 'https://getjvm.dev/latest.txt' (Join-Path $work 'latest.txt') + $raw = [IO.File]::ReadAllText((Join-Path $work 'latest.txt')) + if ($raw -cnotmatch '\A((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))\n?\z') { Stop-Installer 'Invalid latest.txt version' } + $version = $Matches[1] + } + $base = "https://github.com/codejive/jvm/releases/download/v$version" + Get-Download "$base/SHA256SUMS" (Join-Path $work 'SHA256SUMS') + $hashes = Read-ToolChecksums (Join-Path $work 'SHA256SUMS') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + Get-Download "$base/$name" (Join-Path $work $name) + if ((Get-ToolHash (Join-Path $work $name)) -cne $hashes[$name]) { Stop-Installer "Checksum mismatch: $name" } + } + Publish-Tool $work $state $destination $version $hashes + $published = $true + Write-InstallerMessage "Installed jvm $version in $destination" + Set-UserEnvironment $state $destination $javaHome $pathChoice $javaChoice $unattended + Write-InstallerMessage 'Open a fresh terminal application/IDE; existing hosts may retain old values. Bash startup files may override inherited PATH; use cygpath -u for native paths there.' + } catch { + if ($published) { Write-InstallerMessage 'Tool was installed; persistent environment setup failed.' } + throw + } finally { + if ($script:InstallerLock) { $script:InstallerLock.Dispose(); $script:InstallerLock = $null } + Remove-Item -LiteralPath $work -Recurse -Force + } +} + +try { Invoke-JvmInstaller @($args) } +catch { + Write-InstallerMessage $_.Exception.Message + $code = 1 + if ($_.Exception.Data.Contains('InstallerCode')) { $code = [int]$_.Exception.Data['InstallerCode'] } + exit $code +} diff --git a/web/install.sh b/web/install.sh new file mode 100644 index 0000000..9a90a2c --- /dev/null +++ b/web/install.sh @@ -0,0 +1,786 @@ +#!/usr/bin/env bash +# Standalone bootstrap. The Windows section is generated from install.ps1. +set -eu +VERSION= PATH_CHOICE= JAVA_CHOICE= SHELL_CHOICE= PROFILE= NONINTERACTIVE=0 +WORK= LOCKED=0 PUBLISHED=0 STATE= DEST= DATA= TX= +SETUP_FILES=() SETUP_BACKUPS=() SETUP_EXISTED=() +say() { printf '%s\n' "jvm installer: $*" >&2; } +die() { say "$*"; exit 1; } +usage_error() { say "$*"; exit 2; } +help_text() { + cat <<'EOF' +Install or update jvm in HOME/.local/bin (no JDK is installed). +Usage: install.sh [--version major.minor.patch] + [--path | --no-path] [--java | --no-java] + [--shell bash|zsh|fish|csh|tcsh] [--profile /absolute/file] + [--non-interactive] +Explicit positive options consent to persistent environment edits. +Otherwise separate prompts default to no; unattended setup is skipped. +EOF +} +valid_version() { + case $1 in ''|*[!0-9.]*) return 1 ;; esac + printf '%s\n' "$1" | LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +} +parse() { + while [ "$#" -gt 0 ]; do + case $1 in + --help) [ "$#" = 1 ] || usage_error '--help must be used alone'; help_text; exit 0 ;; + --version|--shell|--profile) + [ "$#" -ge 2 ] && [ -n "$2" ] || usage_error "$1 requires a value" + case $1 in + --version) [ -z "$VERSION" ] || usage_error 'duplicate version'; VERSION=$2 ;; + --shell) [ -z "$SHELL_CHOICE" ] || usage_error 'duplicate shell'; SHELL_CHOICE=$2 ;; + --profile) [ -z "$PROFILE" ] || usage_error 'duplicate profile'; PROFILE=$2 ;; + esac; shift ;; + --path|--no-path) [ -z "$PATH_CHOICE" ] || usage_error 'conflicting/duplicate PATH options'; PATH_CHOICE=$1 ;; + --java|--no-java) [ -z "$JAVA_CHOICE" ] || usage_error 'conflicting/duplicate Java options'; JAVA_CHOICE=$1 ;; + --non-interactive) [ "$NONINTERACTIVE" = 0 ] || usage_error 'duplicate non-interactive'; NONINTERACTIVE=1 ;; + *) usage_error "unknown option: $1" ;; + esac; shift + done + [ -z "$VERSION" ] || valid_version "$VERSION" || usage_error 'invalid version (expected major.minor.patch)' + case $SHELL_CHOICE in ''|bash|zsh|fish|csh|tcsh) ;; *) usage_error 'unsupported shell' ;; esac + if [ -n "$PROFILE" ]; then + [ -n "$SHELL_CHOICE" ] || usage_error '--profile requires --shell' + case $PROFILE in /*) ;; *) usage_error 'profile must be absolute' ;; esac + fi +} +safe_path() { + case $1 in /*) ;; *) die "absolute path required: $1" ;; esac + case $1 in *:*|*';'*|*"$(printf '\r')"*|*' +'*|*"$(printf '\t')"*) die "unrepresentable path: $1" ;; esac +} +hash() { + case $HASHER in + sha256sum) sha256sum < "$1" ;; + shasum) shasum -a 256 < "$1" ;; + openssl) openssl dgst -sha256 < "$1" ;; + esac | awk '{v=(length($1)==64 ? $1 : $NF); if(length(v)==64 && v !~ /[^0-9a-fA-F]/) {print tolower(v); found++}} END {if(found!=1) exit 1}' +} +fetch_wget() { + local url=$1 output=$2 status code location origin clean i=0 + # --https-only covers recursive links in GNU wget, not redirect targets. + # Disable automatic redirects and validate every hop before requesting it. + while [ "$i" -lt 10 ]; do + case $url in https://*) ;; *) die 'wget redirect attempted an HTTPS downgrade' ;; esac + status=0 + LC_ALL=C wget --max-redirect=0 --server-response --check-certificate --no-hsts --timeout=15 --tries=1 -O "$output" "$url" 2> "$WORK/wget-headers" || status=$? + code=$(awk '/^[ \t]+HTTP\/[0-9.]+ [0-9]+/ {code=$2} END {print code}' "$WORK/wget-headers") + if [ "$status" = 0 ] && [ "$code" = 200 ]; then return; fi + case $code in 301|302|303|307|308) ;; *) die "wget download failed: $url (requires server-response/max-redirect support)" ;; esac + location=$(awk 'tolower($0) ~ /^[ \t]+location:/ {v=$0; sub(/^[ \t]+[^:]+:[ \t]*/,"",v); sub(/\r$/,"",v)} END {print v}' "$WORK/wget-headers") + case $location in ''|*' '*|*"$(printf '\t')"*|*"$(printf '\r')"*|*' +'*) die 'invalid wget redirect location' ;; esac + clean=${url%%\#*}; clean=${clean%%\?*} + origin=${clean#https://}; origin=${origin%%/*} + case $location in + [Hh][Tt][Tt][Pp][Ss]://*) url=https://${location#*://} ;; + //*) url=https:$location ;; + /*) url=https://$origin$location ;; + \?*) url=$clean$location ;; + \#*) url=${url%%\#*}$location ;; + *:*) die 'wget redirect attempted a non-HTTPS URL' ;; + *) + case ${clean#https://} in */*) url=${clean%/*}/$location ;; *) url=$clean/$location ;; esac ;; + esac + i=$((i+1)) + done + die 'too many wget redirects' +} +fetch() { + case $DOWNLOADER in + curl) curl -q --proto '=https' --proto-redir '=https' -fLsS --connect-timeout 15 --max-time 180 -o "$2" "$1" || die "download failed: $1" ;; + wget) fetch_wget "$1" "$2" ;; + esac + [ -s "$2" ] || die "empty download: $1" +} +checksums() { + LC_ALL=C awk ' + NF!=2 || length($1)!=64 || $1 ~ /[^0-9a-fA-F]/ {exit 1} + $2!="jvm" && $2!="jvm.ps1" && $2!="jvm.cmd" {exit 1} + seen[$2]++ {exit 1} + END {if (NR!=3 || !seen["jvm"] || !seen["jvm.ps1"] || !seen["jvm.cmd"]) exit 1} + ' "$1" || die 'invalid SHA256SUMS (expected exactly the three payload basenames)' +} +verify() { + local expected actual + expected=$(awk -v n="$2" '$2==n && length($1)==64 {print tolower($1)}' "$1") + actual=$(hash "$3") || return 1 + [ "$expected" = "$actual" ] +} +plain_file() { [ -f "$1" ] && [ ! -L "$1" ]; } +read_record() { + plain_file "$1" || die "unsafe ownership record: $1" + [ "$(sed -n '1p' "$1")" = jvm-tool-v1 ] || die 'invalid ownership record' + valid_version "$(sed -n '2p' "$1")" || die 'invalid owned version' + [ "$(sed -n '3p' "$1")" = "$DEST" ] || die 'ownership destination mismatch' + [ "$(wc -l < "$1" | tr -d ' ')" = 4 ] || die 'invalid ownership record length' + tail -n 1 "$1" | LC_ALL=C grep -Eq '^[0-9a-f]{64} jvm$' || die 'invalid ownership digest' +} +recover() { + local entry + [ ! -L "$TX" ] || die 'unsafe transaction directory' + [ -d "$TX" ] || return 0 + for entry in "$TX/"* "$TX/".[!.]* "$TX/"..?*; do + [ -e "$entry" ] || [ -L "$entry" ] || continue + case ${entry##*/} in new-record|old-record|old-jvm|ready|published-record|restored-record) ;; *) die "Unrecognized recovery material: $entry; inspect manually" ;; esac + plain_file "$entry" || die "Unsafe recovery material: $entry" + done + if [ -e "$DEST/.jvm-recover" ] || [ -L "$DEST/.jvm-recover" ]; then + [ -e "$TX/ready" ] && plain_file "$DEST/.jvm-recover" || die 'recovery staging conflict; inspect .jvm-recover manually' + rm "$DEST/.jvm-recover" + fi + if [ -e "$TX/ready" ]; then + read_record "$TX/new-record" + if [ -e "$TX/old-record" ]; then + read_record "$TX/old-record" + plain_file "$TX/old-jvm" && verify "$TX/old-record" jvm "$TX/old-jvm" || die 'recovery backup is damaged; preserve transaction for manual recovery' + [ ! -e "$DEST/jvm" ] || plain_file "$DEST/jvm" || die 'unsafe recovery destination' + cp -p "$TX/old-jvm" "$DEST/.jvm-recover" && mv -f "$DEST/.jvm-recover" "$DEST/jvm" || die 'could not restore tool; preserve transaction' + cp "$TX/old-record" "$TX/restored-record" && mv -f "$TX/restored-record" "$STATE/record" || die 'could not restore ownership' + else + if [ -e "$DEST/jvm" ] || [ -L "$DEST/jvm" ]; then + plain_file "$DEST/jvm" && verify "$TX/new-record" jvm "$DEST/jvm" || die 'unrecognized file during recovery; preserve transaction' + rm "$DEST/jvm" || die 'could not remove interrupted publication' + fi + rm -f "$STATE/record" + fi + say 'Recovered interrupted tool publication.' + fi + if [ -e "$DEST/.jvm-next" ] || [ -L "$DEST/.jvm-next" ]; then + plain_file "$DEST/.jvm-next" || die 'unsafe publication staging file' + rm "$DEST/.jvm-next" + fi + rm -rf -- "$TX" +} +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + if [ "$status" != 0 ]; then + local i=0 file backup + for file in ${SETUP_FILES[@]+"${SETUP_FILES[@]}"}; do + backup=${SETUP_BACKUPS[$i]} + if [ "${SETUP_EXISTED[$i]}" = 1 ]; then + cp -p "$backup" "$file" || say "Profile rollback failed: $file; exact backup: $backup" + else rm -f "$file" || say "Could not remove new profile: $file"; fi + i=$((i+1)) + done + fi + if [ "$LOCKED" = 1 ]; then + if [ "$status" != 0 ] && [ -d "$TX" ]; then (recover) || say "Recovery retained at $TX; resolve it before retrying."; fi + rm -f "$STATE/lock/pid"; rmdir "$STATE/lock" || : + fi + [ -z "$WORK" ] || rm -rf -- "$WORK" + if [ "$status" != 0 ] && [ "$PUBLISHED" = 1 ]; then say 'Tool was installed; persistent environment setup failed.'; fi + exit "$status" +} +lock() { + if ! mkdir "$STATE/lock" 2>/dev/null; then + [ -d "$STATE/lock" ] && [ ! -L "$STATE/lock" ] && plain_file "$STATE/lock/pid" || die "Unrecognized installer lock at $STATE/lock; inspect manually." + local pid + pid=$(cat "$STATE/lock/pid") + case $pid in ''|*[!0-9]*) die 'invalid installer lock owner' ;; esac + if ps -p "$pid" >/dev/null 2>&1; then die 'another installer is running; retry later'; fi + die "Interrupted installer lock at $STATE/lock (recorded PID $pid). Confirm the process has exited, remove only lock/pid and the empty lock directory, then rerun to recover the transaction." + fi + LOCKED=1 + printf '%s\n' "$$" > "$STATE/lock/pid" +} +publish() { + mkdir -p "$DEST" "$STATE" + [ ! -L "$DEST" ] && [ ! -L "$STATE" ] || die 'destination/ownership directory must not be a symlink' + lock + recover + if [ -e "$STATE/record" ] || [ -L "$STATE/record" ]; then + read_record "$STATE/record" + plain_file "$DEST/jvm" && verify "$STATE/record" jvm "$DEST/jvm" || die 'owned jvm is missing or locally modified; restore it or move it aside with the ownership record before retrying' + elif [ -e "$DEST/jvm" ] || [ -L "$DEST/jvm" ]; then + die "unowned destination conflict: $DEST/jvm; move it aside before retrying" + fi + if [ -f "$STATE/record" ] && [ "$(sed -n '2p' "$STATE/record")" = "$VERSION" ] && verify "$STATE/record" jvm "$WORK/jvm"; then + PUBLISHED=1 + say "Already installed jvm $VERSION in $DEST" + return + fi + [ ! -e "$DEST/.jvm-next" ] && [ ! -L "$DEST/.jvm-next" ] && [ ! -e "$DEST/.jvm-recover" ] && [ ! -L "$DEST/.jvm-recover" ] || die 'reserved staging filename exists in destination' + mkdir "$TX" + if [ -e "$STATE/record" ]; then + cp "$STATE/record" "$TX/old-record" + cp -p "$DEST/jvm" "$TX/old-jvm" + fi + { printf 'jvm-tool-v1\n%s\n%s\n' "$VERSION" "$DEST"; printf '%s jvm\n' "$(hash "$WORK/jvm")"; } > "$TX/new-record" + cp "$WORK/jvm" "$DEST/.jvm-next" + chmod 755 "$DEST/.jvm-next" + : > "$TX/ready" + mv -f "$DEST/.jvm-next" "$DEST/jvm" + cp "$TX/new-record" "$TX/published-record" + mv -f "$TX/published-record" "$STATE/record" + rm -rf -- "$TX" + PUBLISHED=1 + say "Installed jvm $VERSION in $DEST" +} +quote() { + case ${SHELL_CHOICE:-} in + fish) printf "'%s'" "$(printf '%s' "$1" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g")" ;; + csh|tcsh) printf '%s' "$(printf '%s' "$1" | LC_ALL=C sed 's/[^a-zA-Z0-9_./-]/\\&/g')" ;; + *) printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" ;; + esac +} +consent() { + [ "$1" != no ] || return 1 + [ "$1" != yes ] || return 0 + [ "$NONINTERACTIVE" = 0 ] && [ -r /dev/tty ] && [ -w /dev/tty ] || return 1 + local answer + # Opening the controlling terminal may fail even when /dev/tty exists. + { printf '%s [y/N] ' "$2" > /dev/tty; IFS= read -r answer < /dev/tty; } 2>/dev/null || return 1 + case $answer in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac +} +block() { + local kind=$1 value=$2 q + q=$(quote "$value") + printf '# >>> jvm %s >>>\n' "$kind" + case $SHELL_CHOICE in + bash|zsh) + [ "$kind" != java ] || printf 'export JAVA_HOME=%s\n' "$(quote "$JAVA_HOME_TARGET")" + printf 'case ":${PATH-}:" in *:%s:*) ;; *) export PATH=%s${PATH+:"$PATH"} ;; esac\n' "$q" "$q" ;; + fish) + if [ "$kind" = java ]; then printf 'set -gx JAVA_HOME %s\n' "$(quote "$JAVA_HOME_TARGET")"; fi + printf 'if not contains -- %s $PATH\n set -gx PATH %s $PATH\nend\n' "$q" "$q" ;; + csh|tcsh) + [ "$kind" != java ] || printf 'setenv JAVA_HOME %s\n' "$(quote "$JAVA_HOME_TARGET")" + printf 'if ( ! $?path ) set path = ()\nset jvm_present = 0\nforeach jvm_entry ( $path:q )\n if ( "$jvm_entry" == %s ) set jvm_present = 1\nend\nif ( $jvm_present == 0 ) set path = ( %s $path:q )\nunset jvm_present jvm_entry\n' "$q" "$q" ;; + esac + printf '# <<< jvm %s <<<\n' "$kind" +} +validate_markers() { + awk ' + /^# >>> jvm (path|java) >>>$/ {if (active || seen[$0]++) exit 1; active=$0; next} + /^# <<< jvm (path|java) <<<$/{expected=$0; sub("<<<",">>>",expected); sub("<<<$",">>>",expected); if(active!=expected) exit 1; active=""; next} + /jvm (path|java) (>>>|<<<)/ {exit 1} + END {if(active) exit 1} + ' "$1" +} +edit_profile() { + local file=$1 kind=$2 value=$3 backup tmp + safe_path "$file" + [ ! -L "$file" ] || die "symlinked profile: $file; use --profile with its explicit target" + if [ -e "$file" ]; then plain_file "$file" || die "profile is not a regular file: $file"; fi + if [ "${FISH_MANAGED:-0}" = 1 ] && [ -e "$file" ] && ! grep -q '^# >>> jvm ' "$file"; then die "unowned Fish configuration: $file"; fi + mkdir -p "$(dirname "$file")" + tmp=$(mktemp "$(dirname "$file")/.jvm-profile.XXXXXXXX") + if [ -e "$file" ]; then cp -p "$file" "$tmp"; else : > "$tmp"; fi + # Validate both block types before replacing either, preserving all outside bytes. + validate_markers "$tmp" || { rm -f "$tmp"; die "corrupt/duplicate managed markers in $file"; } + block "$kind" "$value" > "$WORK/block" + # Avoid rewriting a correct block (and avoid repeated prompts via profile_correct). + awk -v k="$kind" ' + $0=="# >>> jvm " k " >>>" {skip=1} + !skip {print} + $0=="# <<< jvm " k " <<<" {skip=0} + ' "$tmp" > "$WORK/profile" + cat "$WORK/block" >> "$WORK/profile" + if cmp -s "$file" "$WORK/profile" 2>/dev/null; then rm -f "$tmp"; return; fi + backup=$(mktemp "$file.jvm-backup.XXXXXXXX") + if [ -e "$file" ]; then + cp -p "$file" "$backup" + SETUP_EXISTED+=( 1 ) + else : > "$backup"; SETUP_EXISTED+=( 0 ); fi + SETUP_FILES+=( "$file" ) SETUP_BACKUPS+=( "$backup" ) + # Append/replace only the marked portion; retain original CRLF outside blocks. + cat "$WORK/profile" > "$tmp" || die 'profile staging failed' + if ! mv -f "$tmp" "$file"; then rm -f "$tmp"; die "profile publication failed; backup: $backup"; fi + say "Updated $file; exact previous contents: $backup" +} +profile_correct() { + local file=$1 kind=$2 value=$3 + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + validate_markers "$file" || return 1 + block "$kind" "$value" > "$WORK/expected" + awk -v k="$kind" '$0=="# >>> jvm " k " >>>" {on=1} on {print} $0=="# <<< jvm " k " <<<" {on=0}' "$file" > "$WORK/actual" + cmp -s "$WORK/expected" "$WORK/actual" +} +setup() { + local files file login pc jc value kind correct + if [ "$NONINTERACTIVE" = 1 ]; then + if [ -z "$PATH_CHOICE" ]; then PATH_CHOICE=--no-path; say "Tool PATH setup skipped; manually add $DEST to PATH if desired."; fi + if [ -z "$JAVA_CHOICE" ]; then JAVA_CHOICE=--no-java; say "Java setup skipped; manually set JAVA_HOME=$JAVA_HOME_TARGET and add its bin to PATH if desired."; fi + fi + if [ "$PATH_CHOICE" = --no-path ] && [ "$JAVA_CHOICE" = --no-java ]; then return; fi + if [ -z "$SHELL_CHOICE" ]; then SHELL_CHOICE=${SHELL:-}; SHELL_CHOICE=${SHELL_CHOICE##*/}; fi + files=() + if [ -n "$PROFILE" ]; then files=("$PROFILE"); else + case $SHELL_CHOICE in + bash) + login=$HOME/.profile + for file in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + if [ -f "$file" ] && [ -r "$file" ]; then login=$file; break; fi + done + files=("$HOME/.bashrc" "$login") ;; + zsh) files=("${ZDOTDIR:-$HOME}/.zshrc") ;; + fish) + file=${XDG_CONFIG_HOME:-$HOME/.config} + case $file in /*) ;; *) file=$HOME/.config ;; esac + files=("$file/fish/conf.d/jvm.fish") + FISH_MANAGED=1 ;; + csh) files=("$HOME/.cshrc") ;; + tcsh) if [ -e "$HOME/.tcshrc" ]; then files=("$HOME/.tcshrc"); else files=("$HOME/.cshrc"); fi ;; + *) + [ "$PATH_CHOICE" != --path ] && [ "$JAVA_CHOICE" != --java ] || die 'requested setup needs --shell and optionally --profile' + say "Unknown shell; add $DEST to PATH; JAVA_HOME=$JAVA_HOME_TARGET and add its bin directory to PATH."; return ;; + esac + fi + pc=ask jc=ask + case $PATH_CHOICE in --path) pc=yes ;; --no-path) pc=no ;; esac + case $JAVA_CHOICE in --java) jc=yes ;; --no-java) jc=no ;; esac + for kind in path java; do + if [ "$kind" = path ] && [ "$pc" = no ]; then continue; fi + if [ "$kind" = java ] && [ "$jc" = no ]; then continue; fi + value=$DEST; [ "$kind" != java ] || value=$JAVA_HOME_TARGET/bin + correct=1 + for file in "${files[@]}"; do profile_correct "$file" "$kind" "$value" || correct=0; done + [ "$correct" = 0 ] || continue + say "Persistent $kind setup targets: ${files[*]}" + if [ "$kind" = java ]; then say "JAVA_HOME: ${JAVA_HOME-} -> $JAVA_HOME_TARGET; PATH adds $value (a default JDK may not exist yet)."; else say "PATH adds $value (current membership does not establish persistence)."; fi + if { [ "$kind" = path ] && consent "$pc" 'Configure persistent tool PATH?'; } || { [ "$kind" = java ] && consent "$jc" 'Configure persistent default Java?'; }; then + for file in "${files[@]}"; do edit_profile "$file" "$kind" "$value"; done + SETUP_FILES=() SETUP_BACKUPS=() SETUP_EXISTED=() + else say "Skipped $kind setup. Manually configure the paths above if desired."; fi + done +} +main() { + local original host name converted + # Preserve the original arguments for the embedded Windows entry point. + original=("$@") + parse "$@" + host=$(uname -s) + case $host in + CYGWIN*|MSYS*|MINGW*) + [ -z "$SHELL_CHOICE$PROFILE" ] || usage_error '--shell/--profile are Unix-only' + command -v cygpath >/dev/null && command -v powershell.exe >/dev/null || die 'Windows Bash needs cygpath and Windows PowerShell' + case ${HOME:-} in + '') ;; + /*|[A-Za-z]:[\\/]*|\\\\*) + converted=$(cygpath -aw "$HOME") || die 'could not convert HOME to a native path' + export HOME=$converted ;; + *) die 'HOME must be absolute' ;; + esac + if [ -n "${XDG_DATA_HOME:-}" ]; then + case $XDG_DATA_HOME in + /*|[A-Za-z]:[\\/]*|\\\\*) + converted=$(cygpath -aw "$XDG_DATA_HOME") || die 'could not convert XDG_DATA_HOME to a native path' + export XDG_DATA_HOME=$converted ;; + *) unset XDG_DATA_HOME ;; + esac + fi + WORK=$(mktemp -d "${TMPDIR:-/tmp}/jvm-bootstrap.XXXXXXXX") + trap cleanup EXIT; trap 'exit 1' HUP INT TERM + windows_installer > "$WORK/install.ps1" + local args arg + args=() + for arg in "${original[@]}"; do + case $arg in --version) args+=( -Version ) ;; --path) args+=( -Path ) ;; --no-path) args+=( -NoPath ) ;; --java) args+=( -Java ) ;; --no-java) args+=( -NoJava ) ;; --non-interactive) args+=( -NonInteractive ) ;; *) args+=( "$arg" ) ;; esac + done + MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -aw "$WORK/install.ps1")" "${args[@]}" + return ;; + Linux|Darwin) ;; *) die "unsupported host: $host" ;; + esac + [ -n "${HOME:-}" ] || die 'HOME is required' + safe_path "$HOME" + DATA=${XDG_DATA_HOME:-$HOME/.local/share} + case $DATA in /*) ;; *) DATA=$HOME/.local/share ;; esac + safe_path "$DATA" + DEST=$HOME/.local/bin STATE=$DATA/jvm/tool-install TX=$DATA/jvm/tool-install/transaction + JAVA_HOME_TARGET=$DATA/jvm/jdks/default + [ -z "$PROFILE" ] || safe_path "$PROFILE" + DOWNLOADER= HASHER= + for name in curl wget; do if command -v "$name" >/dev/null 2>&1; then DOWNLOADER=$name; break; fi; done + for name in sha256sum shasum openssl; do if command -v "$name" >/dev/null 2>&1; then HASHER=$name; break; fi; done + [ -n "$DOWNLOADER" ] && [ -n "$HASHER" ] || die 'need curl or HTTPS-capable wget and a SHA-256 utility' + umask 077 + WORK=$(mktemp -d "${TMPDIR:-/tmp}/jvm-bootstrap.XXXXXXXX") + trap cleanup EXIT; trap 'exit 1' HUP INT TERM + if [ -z "$VERSION" ]; then + fetch https://getjvm.dev/latest.txt "$WORK/latest" + [ "$(wc -l < "$WORK/latest" | tr -d ' ')" -le 1 ] || die 'invalid latest.txt' + VERSION=$(cat "$WORK/latest") + valid_version "$VERSION" || die 'invalid latest.txt version' + fi + local url + url=https://github.com/codejive/jvm/releases/download/v$VERSION + fetch "$url/SHA256SUMS" "$WORK/SHA256SUMS" + checksums "$WORK/SHA256SUMS" + fetch "$url/jvm" "$WORK/jvm" + verify "$WORK/SHA256SUMS" jvm "$WORK/jvm" || die 'jvm checksum mismatch' + publish + setup + say 'Open a fresh terminal application to inherit changes; later startup statements can override them.' +} +# BEGIN GENERATED WINDOWS INSTALLER +windows_installer() { +cat <<'JVM_POWERSHELL_INSTALLER' +# Windows PowerShell 5.1 standalone bootstrap. No JDK commands are invoked. +$ErrorActionPreference = 'Stop' + +function Write-InstallerMessage([string]$Message) { [Console]::Error.WriteLine("jvm installer: $Message") } +function Stop-Installer([string]$Message, [int]$Code = 1) { + $exception = New-Object Exception $Message + $exception.Data['InstallerCode'] = $Code + throw $exception +} +function Test-ToolVersion([string]$Value) { return $Value -cmatch '\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z' } +function Get-ToolHash([string]$File) { + $stream = [IO.File]::OpenRead($File) + $sha = [Security.Cryptography.SHA256]::Create() + try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() } + finally { $sha.Dispose(); $stream.Dispose() } +} +function Test-PlainFile([string]$File) { + if (-not [IO.File]::Exists($File)) { return $false } + return -not ((Get-Item -LiteralPath $File -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) +} +function Assert-SafeRoot([string]$Value) { + if ($Value -notmatch '\A(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)' -or $Value -match '[;\r\n\t]') { + Stop-Installer "Expected an absolute native path without semicolons or line breaks: $Value" + } +} +function Assert-NoReparse([string]$File) { + if (Test-Path -LiteralPath $File) { + if ((Get-Item -LiteralPath $File -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { Stop-Installer "Reparse point conflict: $File" } + } +} +function Get-Download([string]$Url, [string]$File) { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + for ($i = 0; $i -lt 10; $i++) { + if (([Uri]$Url).Scheme -cne 'https') { Stop-Installer 'HTTPS is required, including redirects' } + $request = [Net.HttpWebRequest]::Create($Url) + $request.AllowAutoRedirect = $false + $request.Timeout = 45000 + $request.ReadWriteTimeout = 45000 + $response = $request.GetResponse() + try { + $status = [int]$response.StatusCode + if ($status -in @(301, 302, 303, 307, 308)) { + $Url = ([Uri]::new([Uri]$Url, $response.Headers['Location'])).AbsoluteUri + continue + } + if ($status -ne 200) { Stop-Installer "HTTP $status for $Url" } + $inputStream = $response.GetResponseStream() + $outputStream = [IO.File]::Create($File) + try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose(); $inputStream.Dispose() } + if ((Get-Item -LiteralPath $File).Length -eq 0) { Stop-Installer "Empty download: $Url" } + return + } finally { $response.Dispose() } + } + Stop-Installer 'Too many HTTPS redirects' +} +function Read-ToolChecksums([string]$File) { + $result = @{} + $lines = [IO.File]::ReadAllLines($File) + if ($lines.Count -ne 3) { Stop-Installer 'SHA256SUMS must contain exactly three entries' } + foreach ($line in $lines) { + if ($line -cnotmatch '\A([0-9a-fA-F]{64}) (jvm|jvm\.ps1|jvm\.cmd)\z') { Stop-Installer 'Invalid SHA256SUMS entry' } + $name = $Matches[2]; $digest = $Matches[1].ToLowerInvariant() + if ($result.ContainsKey($name)) { Stop-Installer 'Duplicate SHA256SUMS entry' } + $result[$name] = $digest + } + return $result +} +function Read-ToolRecord([string]$File, [string]$Destination) { + if (-not (Test-PlainFile $File)) { Stop-Installer "Unsafe ownership record: $File" } + $lines = [IO.File]::ReadAllLines($File) + if ($lines.Count -ne 6 -or $lines[0] -cne 'jvm-tool-v1' -or -not (Test-ToolVersion $lines[1]) -or $lines[2] -cne $Destination) { + Stop-Installer 'Invalid ownership record or destination mismatch' + } + $result = @{} + foreach ($line in $lines[3..5]) { + if ($line -cnotmatch '\A([0-9a-f]{64}) (jvm|jvm\.ps1|jvm\.cmd)\z' -or $result.ContainsKey($Matches[2])) { Stop-Installer 'Invalid ownership hashes' } + $result[$Matches[2]] = $Matches[1] + } + return $result +} +function Write-ToolRecord([string]$File, [string]$Version, [string]$Destination, [hashtable]$Hashes) { + $lines = @('jvm-tool-v1', $Version, $Destination) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { $lines += "$($Hashes[$name]) $name" } + [IO.File]::WriteAllText($File, (($lines -join "`n") + "`n"), (New-Object Text.UTF8Encoding $false)) +} +function Remove-ToolStage([string]$Stage) { + Assert-NoReparse $Stage + if (-not (Test-Path -LiteralPath $Stage)) { return } + foreach ($entry in @(Get-ChildItem -LiteralPath $Stage -Force)) { + if ($entry.Name -cnotin @('jvm', 'jvm.ps1', 'jvm.cmd') -or -not (Test-PlainFile $entry.FullName)) { Stop-Installer "Unrecognized staging material: $($entry.FullName)" } + } + foreach ($entry in @(Get-ChildItem -LiteralPath $Stage -Force)) { [IO.File]::Delete($entry.FullName) } + [IO.Directory]::Delete($Stage) +} +function Restore-ToolTransaction([string]$State, [string]$Destination) { + $tx = Join-Path $State 'transaction' + Assert-NoReparse $tx + if (-not (Test-Path -LiteralPath $tx)) { return } + foreach ($entry in @(Get-ChildItem -LiteralPath $tx -Force)) { + if ($entry.Name -cnotin @('new-record', 'old-record', 'old-jvm', 'old-jvm.ps1', 'old-jvm.cmd', 'ready', 'stage-name') -or -not (Test-PlainFile $entry.FullName)) { + Stop-Installer "Unrecognized recovery material: $($entry.FullName); inspect manually" + } + } + if (Test-Path -LiteralPath (Join-Path $tx 'stage-name')) { + if (-not (Test-PlainFile (Join-Path $tx 'stage-name'))) { Stop-Installer 'Unsafe staging journal' } + $stageName = [IO.File]::ReadAllText((Join-Path $tx 'stage-name')) + if ($stageName -cnotmatch '\A\.jvm-next-[a-f0-9]{32}\z') { Stop-Installer 'Invalid staging journal' } + $stage = Join-Path $Destination $stageName + Remove-ToolStage $stage + } + if (Test-Path -LiteralPath (Join-Path $tx 'ready')) { + $new = Read-ToolRecord (Join-Path $tx 'new-record') $Destination + $old = $null + if (Test-Path -LiteralPath (Join-Path $tx 'old-record')) { $old = Read-ToolRecord (Join-Path $tx 'old-record') $Destination } + # Verify every backup before restoring any member; recovery is repeatable. + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + Assert-NoReparse $file + if ((Test-Path -LiteralPath $file) -and -not (Test-PlainFile $file)) { Stop-Installer "Unsafe recovery destination: $file" } + if ($old) { + $backup = Join-Path $tx "old-$name" + if (-not (Test-PlainFile $backup) -or (Get-ToolHash $backup) -cne $old[$name]) { Stop-Installer "Damaged recovery material at $tx; inspect manually" } + } elseif ((Test-Path -LiteralPath $file) -and (Get-ToolHash $file) -cne $new[$name]) { Stop-Installer "Unknown recovery destination: $file" } + } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ($old) { + if (-not (Test-PlainFile $file) -or (Get-ToolHash $file) -cne $old[$name]) { [IO.File]::Copy((Join-Path $tx "old-$name"), $file, $true) } + } + elseif (Test-Path -LiteralPath $file) { [IO.File]::Delete($file) } + } + if ($old) { [IO.File]::Copy((Join-Path $tx 'old-record'), (Join-Path $State 'record'), $true) } + elseif (Test-Path -LiteralPath (Join-Path $State 'record')) { [IO.File]::Delete((Join-Path $State 'record')) } + Write-InstallerMessage 'Recovered interrupted tool publication.' + } + Remove-Item -LiteralPath $tx -Recurse -Force +} +function Publish-Tool([string]$Work, [string]$State, [string]$Destination, [string]$Version, [hashtable]$Hashes) { + [IO.Directory]::CreateDirectory($Destination) | Out-Null + [IO.Directory]::CreateDirectory($State) | Out-Null + Assert-NoReparse $State; Assert-NoReparse $Destination + Assert-NoReparse (Join-Path $State 'lock') + try { $lock = [IO.File]::Open((Join-Path $State 'lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } + catch { Stop-Installer 'Another installer holds the lock, or the lock is inaccessible; retry after it exits' } + $tx = Join-Path $State 'transaction' + try { + Restore-ToolTransaction $State $Destination + $record = Join-Path $State 'record' + $old = $null + Assert-NoReparse $record + if (Test-Path -LiteralPath $record) { $old = Read-ToolRecord $record $Destination } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ($old) { + if (-not (Test-PlainFile $file) -or (Get-ToolHash $file) -cne $old[$name]) { Stop-Installer "Owned file missing or locally modified: $file. Restore it or move the payload and ownership record aside before retrying." } + } elseif (Test-Path -LiteralPath $file) { Stop-Installer "Unowned destination conflict: $file. Move it aside before retrying." } + } + $matching = $null -ne $old + if ($old) { + $matching = ([IO.File]::ReadAllLines($record)[1] -ceq $Version) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { if ($old[$name] -cne $Hashes[$name]) { $matching = $false } } + } + if ($matching) { return } + [IO.Directory]::CreateDirectory($tx) | Out-Null + if ($old) { + [IO.File]::Copy($record, (Join-Path $tx 'old-record')) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $Destination $name), (Join-Path $tx "old-$name")) } + } + Write-ToolRecord (Join-Path $tx 'new-record') $Version $Destination $Hashes + # Destination-filesystem staging, before any published file is changed. + $next = Join-Path $Destination ('.jvm-next-' + [Guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllText((Join-Path $tx 'stage-name'), [IO.Path]::GetFileName($next)) + [IO.Directory]::CreateDirectory($next) | Out-Null + try { + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $Work $name), (Join-Path $next $name)) } + [IO.File]::WriteAllText((Join-Path $tx 'ready'), 'ready') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ([IO.File]::Exists($file)) { [IO.File]::Delete($file) } + [IO.File]::Move((Join-Path $next $name), $file) + } + [IO.File]::Copy((Join-Path $tx 'new-record'), $record, $true) + Remove-Item -LiteralPath $tx -Recurse -Force + } finally { Remove-ToolStage $next } + } catch { + $failure = $_ + try { Restore-ToolTransaction $State $Destination } + catch { Write-InstallerMessage "Recovery retained at $tx. $($_.Exception.Message)" } + throw $failure + } finally { + # Keep serialization through consented registry edits as well. + $script:InstallerLock = $lock + } +} +function Open-EnvironmentKey { return [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') } +function Get-RegistryValue($Key, [string]$Name) { + if ($Key.GetValueNames() -contains $Name) { + return @{ Exists = $true; Kind = [int]$Key.GetValueKind($Name); Value = $Key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } + } + return @{ Exists = $false; Kind = [int][Microsoft.Win32.RegistryValueKind]::ExpandString; Value = '' } +} +function Set-RegistrySnapshot($Key, [string]$Name, $Snapshot) { + if ($Snapshot.Exists) { $Key.SetValue($Name, $Snapshot.Value, [Microsoft.Win32.RegistryValueKind]$Snapshot.Kind) } + else { $Key.DeleteValue($Name, $false) } +} +function Test-PathEntry([string]$Entry, [string]$Target) { + $expanded = [Environment]::ExpandEnvironmentVariables($Entry.Trim().Trim('"')).TrimEnd('\', '/') + return $expanded.Replace('/', '\') -ieq $Target.TrimEnd('\', '/').Replace('/', '\') +} +function Add-UserPath([string]$Value, [string]$Entry) { + foreach ($token in $Value.Split(';')) { if (Test-PathEntry $token $Entry) { return $Value } } + if ([string]::IsNullOrEmpty($Value)) { return $Entry } + return "$Entry;$Value" +} +function Send-EnvironmentChange { + if (-not ('JvmInstaller.Native' -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace JvmInstaller { public static class Native { + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern IntPtr SendMessageTimeout(IntPtr h, uint m, UIntPtr w, string l, uint f, uint t, out UIntPtr r); +}} +'@ + } + $result = [UIntPtr]::Zero + [JvmInstaller.Native]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) | Out-Null +} +function Get-SetupConsent([string]$Choice, [bool]$Unattended, [string]$Prompt) { + if ($Choice -eq 'yes') { return $true } + if ($Choice -eq 'no' -or $Unattended -or [Console]::IsInputRedirected -or -not [Environment]::UserInteractive) { return $false } + try { return (Read-Host "$Prompt [y/N]") -match '\A(?i:y|yes)\z' } catch { return $false } +} +function Set-UserEnvironment([string]$State, [string]$Destination, [string]$JavaHome, [string]$PathChoice, [string]$JavaChoice, [bool]$Unattended) { + if ($PathChoice -eq 'no' -and $JavaChoice -eq 'no') { return } + if ($Unattended -and $PathChoice -ne 'yes' -and $JavaChoice -ne 'yes') { + Write-InstallerMessage "Unspecified environment setup skipped. Manual setup: user PATH adds $Destination; JAVA_HOME=$JavaHome and PATH adds its bin directory." + return + } + $key = Open-EnvironmentKey + try { + $oldPath = Get-RegistryValue $key 'Path' + $oldJava = Get-RegistryValue $key 'JAVA_HOME' + foreach ($snapshot in @($oldPath, $oldJava)) { + if ($snapshot.Exists -and $snapshot.Kind -notin @(1, 2)) { Stop-Installer 'Environment values must be REG_SZ or REG_EXPAND_SZ' } + } + $pathValue = [string]$oldPath.Value + $toolPresent = (Add-UserPath $pathValue $Destination) -ceq $pathValue + $javaBin = Join-Path $JavaHome 'bin' + $javaPresent = $oldJava.Exists -and (Test-PathEntry ([string]$oldJava.Value) $JavaHome) -and ((Add-UserPath $pathValue $javaBin) -ceq $pathValue) + $toolYes = $false; $javaYes = $false + if (-not $toolPresent) { + Write-InstallerMessage "HKCU\Environment\Path ($($oldPath.Kind)): add $Destination; preserve raw existing value: $pathValue" + $toolYes = Get-SetupConsent $PathChoice $Unattended 'Persist tool PATH?' + } + if (-not $javaPresent) { + Write-InstallerMessage "HKCU\Environment: JAVA_HOME '$($oldJava.Value)' -> '$JavaHome'; Path adds $javaBin. A default JDK may not exist yet." + $javaYes = Get-SetupConsent $JavaChoice $Unattended 'Persist default Java?' + } + if (-not $toolYes -and -not $javaYes) { + Write-InstallerMessage "Environment unchanged. Manual setup: user PATH adds $Destination; JAVA_HOME=$JavaHome and PATH adds $javaBin." + return + } + if ($toolYes) { $pathValue = Add-UserPath $pathValue $Destination } + if ($javaYes) { $pathValue = Add-UserPath $pathValue $javaBin } + $backup = Join-Path $State ('environment-backup-' + [Guid]::NewGuid().ToString('N') + '.clixml') + @{ Path = $oldPath; JAVA_HOME = $oldJava } | Export-Clixml -LiteralPath $backup -Encoding UTF8 + try { + if ($javaYes) { $key.SetValue('JAVA_HOME', $JavaHome, [Microsoft.Win32.RegistryValueKind]$oldJava.Kind) } + $key.SetValue('Path', $pathValue, [Microsoft.Win32.RegistryValueKind]$oldPath.Kind) + $key.Flush() + Send-EnvironmentChange + } catch { + $failure = $_ + try { Set-RegistrySnapshot $key 'Path' $oldPath; Set-RegistrySnapshot $key 'JAVA_HOME' $oldJava; $key.Flush() } + catch { Write-InstallerMessage "Registry rollback failed; exact raw backup: $backup" } + throw $failure + } + Write-InstallerMessage "Updated HKCU\Environment; raw value/type backup: $backup" + $systemPath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + foreach ($entry in $systemPath.Split(';')) { + $expanded = [Environment]::ExpandEnvironmentVariables($entry.Trim('"')) + if (-not [IO.Path]::IsPathRooted($expanded)) { continue } + if ([IO.File]::Exists([IO.Path]::Combine($expanded, 'java.exe'))) { Write-InstallerMessage "System Java at $expanded may precede user PATH. Machine PATH was preserved."; break } + } + } finally { $key.Dispose() } +} +function Invoke-JvmInstaller([string[]]$Arguments) { + $version = ''; $pathChoice = 'ask'; $javaChoice = 'ask'; $unattended = $false + $seen = @{} + for ($i = 0; $i -lt $Arguments.Count; $i++) { + $arg = $Arguments[$i] + if ($arg -ieq '-Help') { + if ($Arguments.Count -ne 1) { Stop-Installer '-Help must be used alone' 2 } + Write-Output 'Usage: install.ps1 [-Version major.minor.patch] [-Path | -NoPath] [-Java | -NoJava] [-NonInteractive]' + return + } + $group = $arg.ToLowerInvariant() + if ($group -in @('-path', '-nopath')) { $group = 'path' } + if ($group -in @('-java', '-nojava')) { $group = 'java' } + if ($seen.ContainsKey($group)) { Stop-Installer "Duplicate/conflicting option: $arg" 2 } + $seen[$group] = $true + switch ($arg.ToLowerInvariant()) { + '-version' { $i++; if ($i -ge $Arguments.Count -or -not (Test-ToolVersion $Arguments[$i])) { Stop-Installer 'Version must be numeric major.minor.patch without leading zeros' 2 }; $version = $Arguments[$i] } + '-path' { $pathChoice = 'yes' } + '-nopath' { $pathChoice = 'no' } + '-java' { $javaChoice = 'yes' } + '-nojava' { $javaChoice = 'no' } + '-noninteractive' { $unattended = $true } + default { Stop-Installer "Unknown option: $arg" 2 } + } + } + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { Stop-Installer 'The native installer supports Windows only' } + $userHome = $env:HOME + if ([string]::IsNullOrEmpty($userHome)) { $userHome = $env:USERPROFILE } + Assert-SafeRoot $userHome + $userHome = [IO.Path]::GetFullPath($userHome) + $data = $env:XDG_DATA_HOME + if ([string]::IsNullOrEmpty($data) -or $data -notmatch '\A(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)') { $data = Join-Path $userHome '.local\share' } + Assert-SafeRoot $data + $data = [IO.Path]::GetFullPath($data) + $destination = Join-Path $userHome '.local\bin' + $state = Join-Path $data 'jvm\tool-install' + $javaHome = Join-Path $data 'jvm\jdks\default' + $work = Join-Path ([IO.Path]::GetTempPath()) ('jvm-bootstrap-' + [Guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($work) | Out-Null + $published = $false + try { + if (-not $version) { + Get-Download 'https://getjvm.dev/latest.txt' (Join-Path $work 'latest.txt') + $raw = [IO.File]::ReadAllText((Join-Path $work 'latest.txt')) + if ($raw -cnotmatch '\A((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))\n?\z') { Stop-Installer 'Invalid latest.txt version' } + $version = $Matches[1] + } + $base = "https://github.com/codejive/jvm/releases/download/v$version" + Get-Download "$base/SHA256SUMS" (Join-Path $work 'SHA256SUMS') + $hashes = Read-ToolChecksums (Join-Path $work 'SHA256SUMS') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + Get-Download "$base/$name" (Join-Path $work $name) + if ((Get-ToolHash (Join-Path $work $name)) -cne $hashes[$name]) { Stop-Installer "Checksum mismatch: $name" } + } + Publish-Tool $work $state $destination $version $hashes + $published = $true + Write-InstallerMessage "Installed jvm $version in $destination" + Set-UserEnvironment $state $destination $javaHome $pathChoice $javaChoice $unattended + Write-InstallerMessage 'Open a fresh terminal application/IDE; existing hosts may retain old values. Bash startup files may override inherited PATH; use cygpath -u for native paths there.' + } catch { + if ($published) { Write-InstallerMessage 'Tool was installed; persistent environment setup failed.' } + throw + } finally { + if ($script:InstallerLock) { $script:InstallerLock.Dispose(); $script:InstallerLock = $null } + Remove-Item -LiteralPath $work -Recurse -Force + } +} + +try { Invoke-JvmInstaller @($args) } +catch { + Write-InstallerMessage $_.Exception.Message + $code = 1 + if ($_.Exception.Data.Contains('InstallerCode')) { $code = [int]$_.Exception.Data['InstallerCode'] } + exit $code +} +JVM_POWERSHELL_INSTALLER +} +main "$@" diff --git a/web/latest.txt b/web/latest.txt new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/web/latest.txt @@ -0,0 +1 @@ +0.2.0 From 70a5c83dcae39c12bbb91623b1a38f22eaf182a3 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 4 Nov 2026 10:56:41 +0100 Subject: [PATCH 2/7] fix(installer): resolve PowerShell outside PATH and clear lint warnings Locate native PowerShell under the Windows system directory when a restricted Bash PATH omits it. Include that directory for nested CI calls and cover fallback in the delegation suite. Use explicit empty-string assignments to satisfy the hosted ShellCheck gate. --- SPEC.md | 4 +++- tests/README.md | 28 ++++++++++++++++++++++++---- tests/ci.sh | 4 ++++ tests/tool-installer.ps1 | 14 ++++++++++++++ web/install.sh | 17 +++++++++++------ 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/SPEC.md b/SPEC.md index e3eee1e..95b4d9e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -683,7 +683,9 @@ fallback policy. Newline, CR, tab and PATH-separator paths are rejected before mutation (Unix also rejects semicolons for Windows interoperability). Unix ships only `jvm` with executable permissions; Windows ships all three exact-byte scripts. Windows Bash requires cygpath and Windows PowerShell and converts -paths before delegating to the embedded PowerShell installer. Its embedded +paths before delegating to the embedded PowerShell installer. The Bash +bootstrap locates PowerShell on PATH or under the Windows system directory's +`WindowsPowerShell/v1.0`, allowing a restricted Cygwin PATH. The embedded source is generated from `web/install.ps1`, not downloaded or separately maintained registry code. No private command-evaluation interface exists. diff --git a/tests/README.md b/tests/README.md index 54ae6ab..18cdf94 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,24 @@ # Discovery, installation and uninstallation verification +## Installer CI repair (2026-11-04) + +The [installer branch run](https://github.com/codejive/jvm/actions/runs/41808043776) +passed packaging, native PowerShell, Git Bash, MSYS2, and Linux/macOS with both +Bash 3.2 and current Bash. Cygwin passed the existing manager suites, all 23 +installer groups and native junction smoke, then failed to locate PowerShell +for delegation. The shell job stopped at ShellCheck SC1007 warnings before +running its native startup tests. + +The bootstrap now uses explicit empty-string assignments and locates native +PowerShell under the system directory when it is absent from PATH. The CI +wrapper includes the PowerShell directory for the suite's nested native calls. +The delegation regression also removes PowerShell from its Bash PATH and +requires successful fallback. Local Windows PowerShell/Git Bash checks passed +(15 groups), as did the 23 Bash installer groups, packaging, syntax and +`git diff --check`. Native Cygwin rerun and the previously blocked ShellCheck/ +Zsh/Fish/csh/tcsh job still require hosted CI; no local alternative platforms +or shells were installed or run for this repair. + ## Tool installer verification (2026-11-04) The new tests are distinct from `tests/install.sh`, which installs synthetic @@ -61,12 +80,13 @@ All workflow YAML parsed, Bash/PowerShell syntax checks and `git diff --check` passed. Existing regressions passed: 56 Bash discovery groups, 35 installation groups, home/env suite and 29 PowerShell smoke groups. The runtime manager scripts are unchanged. ShellCheck is unavailable locally. -Measured sizes: `web/install.sh` 43,147 bytes (including the generated Windows -source), `web/install.ps1` 21,023 bytes; unchanged `jvm` 68,978 bytes, +Measured sizes after the CI repair: `web/install.sh` 43,501 bytes (including the +generated Windows source), `web/install.ps1` 21,023 bytes; unchanged `jvm` 68,978 bytes, `jvm.ps1` 78,630 bytes and `jvm.cmd` 116 bytes. New installer sources use LF. -Hosted Linux/macOS Bash 3.2/current, real Zsh/Fish/csh/tcsh startup and MSYS2/ -Cygwin delegation remain unverified here. No alternative shells, WSL or +At initial delivery, hosted Linux/macOS Bash 3.2/current, real Zsh/Fish/csh/tcsh +startup and MSYS2/Cygwin delegation were unverified; see the CI repair results +above for subsequent hosted evidence. No alternative shells, WSL or containers were installed/run, and no hosted jobs, releases, deployment or GitHub/Cloudflare settings were changed. An early CMD fixture mistakenly let the built-in `irm` alias make a failed public installer request; the corrected diff --git a/tests/ci.sh b/tests/ci.sh index 2834545..e825392 100644 --- a/tests/ci.sh +++ b/tests/ci.sh @@ -30,5 +30,9 @@ for script in tests/*.sh; do "$BASH_BIN" -n "$script"; done case "$(uname -s)" in CYGWIN*|MSYS*|MINGW*) "$BASH_BIN" tests/default-native-windows.sh + # A restricted Cygwin PATH may contain System32 without its PowerShell + # subdirectory. Include it for the suite's nested PowerShell/CMD calls. + system_directory=$(cygpath -u -S) + export PATH="$PATH:$system_directory/WindowsPowerShell/v1.0" JVM_TEST_BASH="$(cygpath -aw "$BASH_BIN")" powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -aw "$ROOT/tests/tool-installer.ps1")" ;; esac diff --git a/tests/tool-installer.ps1 b/tests/tool-installer.ps1 index b1e5548..a5dfc12 100644 --- a/tests/tool-installer.ps1 +++ b/tests/tool-installer.ps1 @@ -214,6 +214,20 @@ function Get-Download([string]$Url, [string]$File) { [IO.File]::Copy((Join-Path foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { Assert ((Get-ToolHash (Join-Path $env:HOME ".local\bin\$name")) -ceq $hashes[$name]) 'Bash payload differs' } + # Exercise the installed-system fallback without changing native tools + # or depending on a particular Windows Bash implementation's PATH. + $restrictedPath = Join-Path $temp 'restricted-path.sh' + $restrictedSource = @' +#!/usr/bin/env bash +set -eu +PATH="$(dirname "$(command -v cygpath)"):$(cygpath -u -S)" +export PATH +if command -v powershell.exe >/dev/null; then exit 99; fi +source "$1" --version 0.2.0 --no-path --no-java --non-interactive +'@ + [IO.File]::WriteAllText($restrictedPath, $restrictedSource.Replace("`r`n", "`n"), (New-Object Text.UTF8Encoding $false)) + & $env:JVM_TEST_BASH $restrictedPath $bootstrapPath + Assert ($LASTEXITCODE -eq 0) 'Windows Bash could not locate system PowerShell outside PATH' $previousPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' diff --git a/web/install.sh b/web/install.sh index 9a90a2c..c4091bc 100644 --- a/web/install.sh +++ b/web/install.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Standalone bootstrap. The Windows section is generated from install.ps1. set -eu -VERSION= PATH_CHOICE= JAVA_CHOICE= SHELL_CHOICE= PROFILE= NONINTERACTIVE=0 -WORK= LOCKED=0 PUBLISHED=0 STATE= DEST= DATA= TX= +VERSION='' PATH_CHOICE='' JAVA_CHOICE='' SHELL_CHOICE='' PROFILE='' NONINTERACTIVE=0 +WORK='' LOCKED=0 PUBLISHED=0 STATE='' DEST='' DATA='' TX='' SETUP_FILES=() SETUP_BACKUPS=() SETUP_EXISTED=() say() { printf '%s\n' "jvm installer: $*" >&2; } die() { say "$*"; exit 1; } @@ -349,7 +349,7 @@ setup() { done } main() { - local original host name converted + local original host name converted powershell_bin system_directory # Preserve the original arguments for the embedded Windows entry point. original=("$@") parse "$@" @@ -357,7 +357,12 @@ main() { case $host in CYGWIN*|MSYS*|MINGW*) [ -z "$SHELL_CHOICE$PROFILE" ] || usage_error '--shell/--profile are Unix-only' - command -v cygpath >/dev/null && command -v powershell.exe >/dev/null || die 'Windows Bash needs cygpath and Windows PowerShell' + command -v cygpath >/dev/null || die 'Windows Bash needs cygpath' + if ! powershell_bin=$(command -v powershell.exe); then + system_directory=$(cygpath -u -S) || die 'could not locate the Windows system directory' + powershell_bin=$system_directory/WindowsPowerShell/v1.0/powershell.exe + fi + [ -f "$powershell_bin" ] || die 'Windows Bash needs Windows PowerShell' case ${HOME:-} in '') ;; /*|[A-Za-z]:[\\/]*|\\\\*) @@ -381,7 +386,7 @@ main() { for arg in "${original[@]}"; do case $arg in --version) args+=( -Version ) ;; --path) args+=( -Path ) ;; --no-path) args+=( -NoPath ) ;; --java) args+=( -Java ) ;; --no-java) args+=( -NoJava ) ;; --non-interactive) args+=( -NonInteractive ) ;; *) args+=( "$arg" ) ;; esac done - MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -aw "$WORK/install.ps1")" "${args[@]}" + MSYS2_ARG_CONV_EXCL='*' "$powershell_bin" -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -aw "$WORK/install.ps1")" "${args[@]}" return ;; Linux|Darwin) ;; *) die "unsupported host: $host" ;; esac @@ -393,7 +398,7 @@ main() { DEST=$HOME/.local/bin STATE=$DATA/jvm/tool-install TX=$DATA/jvm/tool-install/transaction JAVA_HOME_TARGET=$DATA/jvm/jdks/default [ -z "$PROFILE" ] || safe_path "$PROFILE" - DOWNLOADER= HASHER= + DOWNLOADER='' HASHER='' for name in curl wget; do if command -v "$name" >/dev/null 2>&1; then DOWNLOADER=$name; break; fi; done for name in sha256sum shasum openssl; do if command -v "$name" >/dev/null 2>&1; then HASHER=$name; break; fi; done [ -n "$DOWNLOADER" ] && [ -n "$HASHER" ] || die 'need curl or HTTPS-capable wget and a SHA-256 utility' From d28081f0566d01995fca1f566462388e6ca182a9 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 4 Nov 2026 11:27:47 +0100 Subject: [PATCH 3/7] fix(installer): preserve literal paths in C-shell setup Quote C-shell path literals and protect PATH comparisons and source arguments from further substitution. Add an execution sentinel and report each hosted native shell result without stopping at the first failure. --- SPEC.md | 4 ++- tests/README.md | 20 +++++++++++++- tests/tool-installer.sh | 61 ++++++++++++++++++++++++++++++++--------- web/install.sh | 8 ++++-- 4 files changed, 76 insertions(+), 17 deletions(-) diff --git a/SPEC.md b/SPEC.md index 95b4d9e..1a478ed 100644 --- a/SPEC.md +++ b/SPEC.md @@ -758,7 +758,9 @@ network requests, JDK execution or inventory scans occur at startup. - Fish: XDG_CONFIG_HOME (fallback HOME/.config) `fish/conf.d/jvm.fish`; unowned existing files conflict. Explicit profile overrides are supported. - Csh/tcsh: `.cshrc`, or existing `.tcshrc` for tcsh; native `path` list and - `setenv` syntax handle initially unset variables. + `setenv` syntax handle initially unset variables. Literal paths use single + quotes with explicit apostrophe/history escaping; PATH-list comparisons use + quote-protected (`:q`) shell-variable expansions to preserve metacharacters. Profile contents outside managed blocks and permissions are preserved where practical (a missing trailing newline is normalized); exact original bytes are diff --git a/tests/README.md b/tests/README.md index 18cdf94..7aeecdd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,23 @@ # Discovery, installation and uninstallation verification +## Native C-shell repair (2026-11-04) + +The [follow-up run](https://github.com/codejive/jvm/actions/runs/41812403128) +passed every platform job, including Cygwin, and passed ShellCheck. The native +shell test then attempted to execute backticked fixture path text (`data`) +before BSD csh crashed. Native C-shell paths now use single-quoted literals; +list comparisons and the test's `source` arguments use `:q` protection. The +harness includes a command-execution sentinel and reports sourcing/startup +results for Zsh, Fish, BSD csh and tcsh before failing the hosted job, so one +failure cannot hide later shells. + +Hosted checks can select one shell with +`bash tests/tool-installer.sh --native-shells csh` (or `zsh`, `fish`, `tcsh`); +omitting the last argument still checks all four. Actual native validation for +this correction belongs to hosted CI; no local shells/platforms were added. +The [C-shell manual](https://man.openbsd.org/csh.1) documents single-quote +literals and `:q` preventing subsequent substitutions. + ## Installer CI repair (2026-11-04) The [installer branch run](https://github.com/codejive/jvm/actions/runs/41808043776) @@ -80,7 +98,7 @@ All workflow YAML parsed, Bash/PowerShell syntax checks and `git diff --check` passed. Existing regressions passed: 56 Bash discovery groups, 35 installation groups, home/env suite and 29 PowerShell smoke groups. The runtime manager scripts are unchanged. ShellCheck is unavailable locally. -Measured sizes after the CI repair: `web/install.sh` 43,501 bytes (including the +Measured sizes after the C-shell repair: `web/install.sh` 43,726 bytes (including the generated Windows source), `web/install.ps1` 21,023 bytes; unchanged `jvm` 68,978 bytes, `jvm.ps1` 78,630 bytes and `jvm.cmd` 116 bytes. New installer sources use LF. diff --git a/tests/tool-installer.sh b/tests/tool-installer.sh index 60209e3..1a660f8 100644 --- a/tests/tool-installer.sh +++ b/tests/tool-installer.sh @@ -15,6 +15,11 @@ printf '0.2.0\n' > "$TEMP/fixtures/latest.txt" REAL_HASH=$(command -v sha256sum) (cd "$TEMP/fixtures"; "$REAL_HASH" -t jvm jvm.ps1 jvm.cmd > SHA256SUMS) export FIXTURES="$TEMP/fixtures" LOG="$TEMP/requests" +export TEST_EVAL_MARKER="$TEMP/unexpected-command" +cat > "$TEMP/bin/data" <<'EOF' +#!/usr/bin/env bash +printf 'unexpected command execution\n' > "$TEST_EVAL_MARKER" +EOF cat > "$TEMP/bin/curl" <<'EOF' #!/usr/bin/env bash set -eu @@ -261,19 +266,41 @@ cat "$INSTALLER" | HOME="$TEMP/piped-home" XDG_DATA_HOME="$TEMP/piped-data" bash ok 'piped bootstrap skips unspecified setup without consuming script stdin' if [ "${1:-}" = --native-shells ]; then # Hosted Unix only: use actual shells, including paths with apostrophes/spaces. - for shell in zsh fish csh tcsh; do + native_shells='zsh fish csh tcsh' + if [ -n "${2:-}" ]; then + case $2 in zsh|fish|csh|tcsh) native_shells=$2 ;; *) echo 'Unsupported native test shell' >&2; exit 2 ;; esac + fi + native_failures=0 + for shell in $native_shells; do command -v "$shell" >/dev/null profile="$HOME/$shell-profile" + printf 'Checking native %s explicit sourcing\n' "$shell" + native_status=0 if [ "$shell" = fish ]; then - "$shell" --no-config -c 'source "$argv[1]"; source "$argv[1]"; test "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default"' "$profile" + "$shell" --no-config -c 'source "$argv[1]"; source "$argv[1]"; test "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default"' "$profile" || native_status=$? elif [ "$shell" = zsh ]; then - "$shell" -f -c '. "$1"; . "$1"; [ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' test "$profile" + "$shell" -f -c '. "$1"; . "$1"; [ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' test "$profile" || native_status=$? else - # csh needs :q for argument paths, and unset-path behavior is intentional. - "$shell" -f -c 'unset path; source "$argv[1]"; source "$argv[1]"; if ( "$JAVA_HOME" != "$XDG_DATA_HOME/jvm/jdks/default" ) exit 1' "$profile" + # Preserve literal words through C-shell substitution phases. The + # script path itself is simple; the sourced fixture path is not. + cat > "$TEMP/check-native.csh" <<'EOF' +unset path +source $argv[1]:q +source $argv[1]:q +set test_home = "$JAVA_HOME" +set test_expected = "$XDG_DATA_HOME/jvm/jdks/default" +if ( $test_home:q != $test_expected:q ) exit 1 +EOF + "$shell" -f "$TEMP/check-native.csh" "$profile" || native_status=$? + fi + [ ! -e "$TEST_EVAL_MARKER" ] || native_status=1 + if [ "$native_status" != 0 ]; then + printf 'FAIL: native %s sourcing (status %s); generated profile follows\n' "$shell" "$native_status" >&2 + cat "$profile" >&2 + native_failures=$((native_failures+1)) fi done - ok 'actual Zsh/Fish/csh/tcsh source managed setup twice' + if [ "$native_failures" = 0 ]; then ok 'actual native shells source managed setup twice'; fi # Also launch normal startup paths, not just explicit source commands. run --shell zsh --path --java --non-interactive run --shell csh --path --java --non-interactive @@ -281,14 +308,22 @@ if [ "${1:-}" = --native-shells ]; then run --shell tcsh --path --java --non-interactive bash --noprofile --rcfile "$HOME/.bashrc" -i -c '[ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' > "$TEMP/startup-out" 2> "$TEMP/startup-err" [ ! -s "$TEMP/startup-out" ] - zsh -d -i -c '[ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' > "$TEMP/startup-out" - [ ! -s "$TEMP/startup-out" ] - fish --private -c 'test "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default"' > "$TEMP/startup-out" - [ ! -s "$TEMP/startup-out" ] - for shell in csh tcsh; do - "$shell" -c 'if ( "$JAVA_HOME" != "$XDG_DATA_HOME/jvm/jdks/default" ) exit 1' > "$TEMP/startup-out" - [ ! -s "$TEMP/startup-out" ] + for shell in $native_shells; do + printf 'Checking native %s startup\n' "$shell" + native_status=0 + case $shell in + zsh) zsh -d -i -c '[ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' > "$TEMP/startup-out" ;; + fish) fish --private -c 'test "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default"' > "$TEMP/startup-out" ;; + csh|tcsh) "$shell" -c 'set test_home = "$JAVA_HOME"; set test_expected = "$XDG_DATA_HOME/jvm/jdks/default"; if ( $test_home:q != $test_expected:q ) exit 1' > "$TEMP/startup-out" ;; + esac || native_status=$? + [ ! -s "$TEMP/startup-out" ] && [ ! -e "$TEST_EVAL_MARKER" ] || native_status=1 + if [ "$native_status" != 0 ]; then + printf 'FAIL: native %s startup (status %s)\n' "$shell" "$native_status" >&2 + cat "$TEMP/startup-out" >&2 + native_failures=$((native_failures+1)) + fi done + [ "$native_failures" = 0 ] || { printf '%s native shell checks failed\n' "$native_failures" >&2; exit 1; } ok 'fresh shells load native startup files silently' fi printf '%s installer checks passed\n' "$count" diff --git a/web/install.sh b/web/install.sh index c4091bc..9810791 100644 --- a/web/install.sh +++ b/web/install.sh @@ -223,7 +223,11 @@ publish() { quote() { case ${SHELL_CHOICE:-} in fish) printf "'%s'" "$(printf '%s' "$1" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g")" ;; - csh|tcsh) printf '%s' "$(printf '%s' "$1" | LC_ALL=C sed 's/[^a-zA-Z0-9_./-]/\\&/g')" ;; + csh|tcsh) + # Keep command/variable metacharacters inside single quotes. Use a + # double-quoted apostrophe between segments; history needs \! even + # inside single quotes in interactive C shells. + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\"'\"'/g; s/!/\\\\!/g")" ;; *) printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" ;; esac } @@ -249,7 +253,7 @@ block() { printf 'if not contains -- %s $PATH\n set -gx PATH %s $PATH\nend\n' "$q" "$q" ;; csh|tcsh) [ "$kind" != java ] || printf 'setenv JAVA_HOME %s\n' "$(quote "$JAVA_HOME_TARGET")" - printf 'if ( ! $?path ) set path = ()\nset jvm_present = 0\nforeach jvm_entry ( $path:q )\n if ( "$jvm_entry" == %s ) set jvm_present = 1\nend\nif ( $jvm_present == 0 ) set path = ( %s $path:q )\nunset jvm_present jvm_entry\n' "$q" "$q" ;; + printf 'if ( ! $?path ) set path = ()\nset jvm_present = 0\nforeach jvm_entry ( $path:q )\n if ( $jvm_entry:q == %s ) set jvm_present = 1\nend\nif ( $jvm_present == 0 ) set path = ( %s $path:q )\nunset jvm_present jvm_entry\n' "$q" "$q" ;; esac printf '# <<< jvm %s <<<\n' "$kind" } From 71f74794a2f0ba6262bf357e1483f890ff6159b5 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 4 Nov 2026 11:33:12 +0100 Subject: [PATCH 4/7] test(installer): isolate BSD csh initialization from profile checks Start legacy BSD csh with a safe temporary HOME because it expands inherited HOME before parsing options or reading profiles. Retain metacharacter-rich tool and data paths in generated setup and document the upstream shell boundary. --- README.md | 4 ++++ SPEC.md | 7 +++++++ tests/README.md | 9 +++++++++ tests/tool-installer.sh | 14 ++++++++++++-- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2f9206c..2547396 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ manual setup. Exact profile backups are reported. Windows persists only current-user environment values, with raw value/type backups; machine PATH can still take precedence over user Java. No PowerShell profiles are edited. +Legacy BSD csh expands `HOME` before reading profiles and can crash on homes +containing backticks or glob syntax; use tcsh for those homes. Generated setup +keeps tool/data paths literal, but cannot change the shell's own initialization. + Open a **fresh terminal application or IDE**, since a new tab in an old host may inherit stale values. Later startup statements can override setup. This does not configure all GUI or noninteractive programs. Then install Java: diff --git a/SPEC.md b/SPEC.md index 1a478ed..f46d334 100644 --- a/SPEC.md +++ b/SPEC.md @@ -761,6 +761,13 @@ network requests, JDK execution or inventory scans occur at startup. `setenv` syntax handle initially unset variables. Literal paths use single quotes with explicit apostrophe/history escaping; PATH-list comparisons use quote-protected (`:q`) shell-variable expansions to preserve metacharacters. + Legacy BSD csh expands inherited HOME before processing options or profiles; + HOME values containing backticks/glob syntax can execute text or crash that + shell before setup is read. Use tcsh for such homes. Generated setup still + treats metacharacters in tool/data paths literally. This upstream boundary + was inspected on 2026-11-04 in + [`csh.c`](https://github.com/openbsd/src/blob/master/bin/csh/csh.c) and + [`set.c`](https://github.com/openbsd/src/blob/master/bin/csh/set.c). Profile contents outside managed blocks and permissions are preserved where practical (a missing trailing newline is normalized); exact original bytes are diff --git a/tests/README.md b/tests/README.md index 7aeecdd..7876d72 100644 --- a/tests/README.md +++ b/tests/README.md @@ -18,6 +18,15 @@ this correction belongs to hosted CI; no local shells/platforms were added. The [C-shell manual](https://man.openbsd.org/csh.1) documents single-quote literals and `:q` preventing subsequent substitutions. +Hosted follow-up isolated an upstream BSD csh initialization issue: `csh.c` +sets `home` from unquoted HOME before option processing, and `set.c` applies +glob/command substitution. BSD csh tests therefore use a separate safe +bootstrap HOME, while sourcing and starting up with the exact generated +profiles containing the original metacharacter-rich tool/data paths. The +execution sentinel, exact JAVA_HOME checks and repeated sourcing remain. +Zsh/Fish/tcsh retain the original hostile HOME. This compatibility boundary is +documented in SPEC/README; native-shell reruns verify the remaining behavior. + ## Installer CI repair (2026-11-04) The [installer branch run](https://github.com/codejive/jvm/actions/runs/41808043776) diff --git a/tests/tool-installer.sh b/tests/tool-installer.sh index 1a660f8..b4fcd73 100644 --- a/tests/tool-installer.sh +++ b/tests/tool-installer.sh @@ -266,6 +266,10 @@ cat "$INSTALLER" | HOME="$TEMP/piped-home" XDG_DATA_HOME="$TEMP/piped-data" bash ok 'piped bootstrap skips unspecified setup without consuming script stdin' if [ "${1:-}" = --native-shells ]; then # Hosted Unix only: use actual shells, including paths with apostrophes/spaces. + # BSD csh expands its inherited HOME before even parsing -f (csh.c/set.c). + # Keep its bootstrap HOME safe; sourced tool/data paths remain hostile. + csh_bootstrap_home=$TEMP/csh-bootstrap-home + mkdir "$csh_bootstrap_home" native_shells='zsh fish csh tcsh' if [ -n "${2:-}" ]; then case $2 in zsh|fish|csh|tcsh) native_shells=$2 ;; *) echo 'Unsupported native test shell' >&2; exit 2 ;; esac @@ -291,7 +295,11 @@ set test_home = "$JAVA_HOME" set test_expected = "$XDG_DATA_HOME/jvm/jdks/default" if ( $test_home:q != $test_expected:q ) exit 1 EOF - "$shell" -f "$TEMP/check-native.csh" "$profile" || native_status=$? + if [ "$shell" = csh ]; then + HOME="$csh_bootstrap_home" "$shell" -f "$TEMP/check-native.csh" "$profile" || native_status=$? + else + "$shell" -f "$TEMP/check-native.csh" "$profile" || native_status=$? + fi fi [ ! -e "$TEST_EVAL_MARKER" ] || native_status=1 if [ "$native_status" != 0 ]; then @@ -306,6 +314,7 @@ EOF run --shell csh --path --java --non-interactive printf '# tcsh startup\n' > "$HOME/.tcshrc" run --shell tcsh --path --java --non-interactive + cp "$HOME/.cshrc" "$csh_bootstrap_home/.cshrc" bash --noprofile --rcfile "$HOME/.bashrc" -i -c '[ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' > "$TEMP/startup-out" 2> "$TEMP/startup-err" [ ! -s "$TEMP/startup-out" ] for shell in $native_shells; do @@ -314,7 +323,8 @@ EOF case $shell in zsh) zsh -d -i -c '[ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' > "$TEMP/startup-out" ;; fish) fish --private -c 'test "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default"' > "$TEMP/startup-out" ;; - csh|tcsh) "$shell" -c 'set test_home = "$JAVA_HOME"; set test_expected = "$XDG_DATA_HOME/jvm/jdks/default"; if ( $test_home:q != $test_expected:q ) exit 1' > "$TEMP/startup-out" ;; + csh) HOME="$csh_bootstrap_home" "$shell" -c 'set test_home = "$JAVA_HOME"; set test_expected = "$XDG_DATA_HOME/jvm/jdks/default"; if ( $test_home:q != $test_expected:q ) exit 1' > "$TEMP/startup-out" ;; + tcsh) "$shell" -c 'set test_home = "$JAVA_HOME"; set test_expected = "$XDG_DATA_HOME/jvm/jdks/default"; if ( $test_home:q != $test_expected:q ) exit 1' > "$TEMP/startup-out" ;; esac || native_status=$? [ ! -s "$TEMP/startup-out" ] && [ ! -e "$TEST_EVAL_MARKER" ] || native_status=1 if [ "$native_status" != 0 ]; then From ffa48027d8b5d425853ebb0f2d997b3dee0fd4eb Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 4 Nov 2026 11:36:24 +0100 Subject: [PATCH 5/7] fix(installer): avoid C-shell foreach value re-expansion Compare PATH entries through indexed quote-protected expansion. Legacy BSD csh re-expands foreach values when assigning its loop variable, which can execute backticked path text despite quoting the original list. --- SPEC.md | 2 ++ tests/README.md | 6 ++++++ web/install.sh | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index f46d334..f12594f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -761,6 +761,8 @@ network requests, JDK execution or inventory scans occur at startup. `setenv` syntax handle initially unset variables. Literal paths use single quotes with explicit apostrophe/history escaping; PATH-list comparisons use quote-protected (`:q`) shell-variable expansions to preserve metacharacters. + Iterate PATH entries by index: legacy BSD csh's `foreach` re-expands its + loop-variable values, so it cannot safely carry literal backticked entries. Legacy BSD csh expands inherited HOME before processing options or profiles; HOME values containing backticks/glob syntax can execute text or crash that shell before setup is read. Use tcsh for such homes. Generated setup still diff --git a/tests/README.md b/tests/README.md index 7876d72..83cfc5a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -27,6 +27,12 @@ execution sentinel, exact JAVA_HOME checks and repeated sourcing remain. Zsh/Fish/tcsh retain the original hostile HOME. This compatibility boundary is documented in SPEC/README; native-shell reruns verify the remaining behavior. +The next hosted result exposed a separate BSD csh `foreach` behavior: its +internal loop-variable assignment applies `set()` expansion after stripping +quote protection. Setup now checks PATH entries by indexed `:q` expansion +inside a `while` loop, avoiding that implicit reassignment. Multiline `if` +blocks keep literal path assignments separate from expression reexecution. + ## Installer CI repair (2026-11-04) The [installer branch run](https://github.com/codejive/jvm/actions/runs/41808043776) diff --git a/web/install.sh b/web/install.sh index 9810791..4d58ad4 100644 --- a/web/install.sh +++ b/web/install.sh @@ -253,7 +253,10 @@ block() { printf 'if not contains -- %s $PATH\n set -gx PATH %s $PATH\nend\n' "$q" "$q" ;; csh|tcsh) [ "$kind" != java ] || printf 'setenv JAVA_HOME %s\n' "$(quote "$JAVA_HOME_TARGET")" - printf 'if ( ! $?path ) set path = ()\nset jvm_present = 0\nforeach jvm_entry ( $path:q )\n if ( $jvm_entry:q == %s ) set jvm_present = 1\nend\nif ( $jvm_present == 0 ) set path = ( %s $path:q )\nunset jvm_present jvm_entry\n' "$q" "$q" ;; + # BSD csh's foreach stores each item with set(), which re-expands + # backticks after the list's quote flags have been stripped. Read + # existing entries by index instead, preserving :q through comparison. + printf 'if ( ! $?path ) then\n set path = ()\nendif\nset jvm_present = 0\n@ jvm_index = 1\nwhile ( $jvm_index <= $#path )\n if ( $path[$jvm_index]:q == %s ) then\n set jvm_present = 1\n endif\n @ jvm_index++\nend\nif ( $jvm_present == 0 ) then\n set path = ( %s $path:q )\nendif\nunset jvm_present jvm_index\n' "$q" "$q" ;; esac printf '# <<< jvm %s <<<\n' "$kind" } From 33e811b6a79c7aeb925df542f6ec12937a1fa95e Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 4 Nov 2026 11:37:35 +0100 Subject: [PATCH 6/7] test(installer): assert exact C-shell PATH entries after repeated setup --- tests/README.md | 2 +- tests/tool-installer.sh | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/README.md b/tests/README.md index 83cfc5a..c8be851 100644 --- a/tests/README.md +++ b/tests/README.md @@ -113,7 +113,7 @@ All workflow YAML parsed, Bash/PowerShell syntax checks and `git diff --check` passed. Existing regressions passed: 56 Bash discovery groups, 35 installation groups, home/env suite and 29 PowerShell smoke groups. The runtime manager scripts are unchanged. ShellCheck is unavailable locally. -Measured sizes after the C-shell repair: `web/install.sh` 43,726 bytes (including the +Measured sizes after the C-shell repair: `web/install.sh` 44,068 bytes (including the generated Windows source), `web/install.ps1` 21,023 bytes; unchanged `jvm` 68,978 bytes, `jvm.ps1` 78,630 bytes and `jvm.cmd` 116 bytes. New installer sources use LF. diff --git a/tests/tool-installer.sh b/tests/tool-installer.sh index b4fcd73..2777fc6 100644 --- a/tests/tool-installer.sh +++ b/tests/tool-installer.sh @@ -294,11 +294,15 @@ source $argv[1]:q set test_home = "$JAVA_HOME" set test_expected = "$XDG_DATA_HOME/jvm/jdks/default" if ( $test_home:q != $test_expected:q ) exit 1 +if ( $#path != 2 ) exit 1 +set test_java_bin = "$XDG_DATA_HOME/jvm/jdks/default/bin" +if ( $path[1]:q != $test_java_bin:q ) exit 1 +if ( $path[2]:q != $argv[2]:q ) exit 1 EOF if [ "$shell" = csh ]; then - HOME="$csh_bootstrap_home" "$shell" -f "$TEMP/check-native.csh" "$profile" || native_status=$? + HOME="$csh_bootstrap_home" "$shell" -f "$TEMP/check-native.csh" "$profile" "$DEST" || native_status=$? else - "$shell" -f "$TEMP/check-native.csh" "$profile" || native_status=$? + "$shell" -f "$TEMP/check-native.csh" "$profile" "$DEST" || native_status=$? fi fi [ ! -e "$TEST_EVAL_MARKER" ] || native_status=1 From 1e1869d9b20043718319ba51af8e3cdec144b7e8 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Thu, 5 Nov 2026 00:21:41 +0100 Subject: [PATCH 7/7] docs: Added full app name to README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2547396..06f0436 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# jvm +# jvm - Java Version Manager `jvm` installs, manages and runs different JDKs on a user's system. It finds compatible JDK builds, downloads and verifies them, keeps them in user storage