From 8c7b158fd32d0808af89aa18273dc3e181b3b6a1 Mon Sep 17 00:00:00 2001 From: SSobol77 Date: Sun, 12 Jul 2026 18:40:07 +0200 Subject: [PATCH 1/2] feat(packaging): harden Debian installer and F4 provisioning --- Makefile | 71 +- docker/build-linux-deb.Dockerfile | 2 + .../ecli-f4-linter-microservices-design.md | 6 +- docs/contributor/install.md | 15 + .../f4-linter-manual-installation.md | 7 + docs/install/debian.md | 214 ++ docs/release/artifact-contract.md | 1 - packaging/debian/ecli-linter-lock.json | 188 ++ .../markdownlint-cli2/package-lock.json | 1262 ++++++++++ .../debian/markdownlint-cli2/package.json | 10 + .../debian/verify_deb_minimal_install.sh | 80 + packaging/linux/fpm-common/postinst | 17 + packaging/linux/fpm-common/postrm | 44 +- scripts/build_and_package_deb.py | 471 +++- scripts/install_ecli_linters.py | 2124 +++++++++++++++++ scripts/packaging_common.py | 4 +- scripts/verify_f4_toolchain_smoke.py | 563 +++++ src/ecli/__main__.py | 8 + .../linters/core/toolchain_check.py | 285 +++ src/ecli/integrations/LinterBridge.py | 5 + .../linters/test_toolchain_check.py | 136 ++ .../test_build_and_package_deb_script.py | 146 ++ .../test_debian_two_stage_install_contract.py | 342 +++ .../test_install_ecli_linters_script.py | 1501 ++++++++++++ 24 files changed, 7360 insertions(+), 142 deletions(-) create mode 100644 docs/install/debian.md create mode 100644 packaging/debian/ecli-linter-lock.json create mode 100644 packaging/debian/markdownlint-cli2/package-lock.json create mode 100644 packaging/debian/markdownlint-cli2/package.json create mode 100755 packaging/debian/verify_deb_minimal_install.sh create mode 100755 scripts/install_ecli_linters.py create mode 100644 scripts/verify_f4_toolchain_smoke.py create mode 100644 src/ecli/extensions/linters/core/toolchain_check.py create mode 100644 tests/extensions/linters/test_toolchain_check.py create mode 100644 tests/packaging/test_debian_two_stage_install_contract.py create mode 100644 tests/packaging/test_install_ecli_linters_script.py diff --git a/Makefile b/Makefile index c711dd2..48e4a0d 100755 --- a/Makefile +++ b/Makefile @@ -720,63 +720,24 @@ release-appimage: _confirm-release-action $(call block_partial_release) -# Optional Linux Snap package surface. -# Use: -# Build a Snap package -# `make package-snap` # Build snap (requires snapcraft) -# -# Verify produced artifacts -# `make show-snap-artifacts` -# -# Publish to Snap Store (requires authentication) -# `make release-snap` -# -# Note: Snap building requires snapcraft tool and assumes snapcraft.yaml exists +# Legacy Linux Snap package surface: HARD-DISABLED. +# Snap is not part of the canonical 21-artifact release contract +# (docs/release/artifact-contract.md). The legacy targets are kept only as +# explicit refusal stubs so stale automation fails fast instead of silently +# producing a 22nd artifact. There is no snapcraft.yaml in this repository. # --------------------------- -SNAP_VERSION ?= $(PACKAGE_VERSION) -SNAP_PKG_DIR ?= $(RELEASE_DIR) -SNAP_FILE ?= $(SNAP_PKG_DIR)/ecli_$(SNAP_VERSION)_linux_$(LINUX_ARCH).snap -SNAP_SHA_FILE ?= $(SNAP_FILE).sha256 - -.PHONY: package-snap -package-snap: clean validate-runtime-imports - @command -v snapcraft >/dev/null 2>&1 || (echo "snapcraft not found. Install: sudo snap install snapcraft --classic"; exit 1) - @test -f snapcraft.yaml || (echo "snapcraft.yaml not found in project root"; exit 1) - @echo "--> Building Snap..." - snapcraft - @mkdir -p $(RELEASE_DIR) - @set -- *.snap; \ - if [ "$$1" = "*.snap" ]; then \ - echo "Snap build did not produce a .snap artifact"; \ - exit 1; \ - fi; \ - if [ "$$#" -ne 1 ]; then \ - echo "Expected exactly one .snap artifact, found $$#"; \ - printf '%s\n' "$$@"; \ - exit 1; \ - fi; \ - mv "$$1" "$(SNAP_FILE)" - @cd "$(SNAP_PKG_DIR)" && sha256sum "$$(basename "$(SNAP_FILE)")" > "$$(basename "$(SNAP_SHA_FILE)")" - $(MAKE) package-snap-assert - -.PHONY: package-snap-assert -package-snap-assert: - $(call assert_current_release_file,$(SNAP_FILE)) - $(call verify_sha256,$(SNAP_FILE)) - @echo "--> OK: $(SNAP_FILE)" - @echo "--> OK: $(SNAP_SHA_FILE)" - -.PHONY: show-snap-artifacts -show-snap-artifacts: - @echo "Version: $(SNAP_VERSION)" - @ls -lh $(RELEASE_DIR)/*.snap 2>/dev/null || echo "(no snap artifacts yet)" - -.PHONY: release-snap -release-snap: package-snap-assert - @echo "--> Publishing to Snap Store (requires authentication)..." - @echo " Run: snapcraft login" - @echo " Then: snapcraft upload --release=stable $(RELEASE_DIR)/ecli_*.snap" +define snap_targets_disabled + @echo "ERROR: Snap targets are disabled." ; \ + echo "Snap is not part of the canonical 21-artifact release contract" ; \ + echo "(docs/release/artifact-contract.md). No Snap artifact may be" ; \ + echo "built, verified, or published from this repository." ; \ + exit 1 +endef + +.PHONY: package-snap package-snap-assert show-snap-artifacts release-snap +package-snap package-snap-assert show-snap-artifacts release-snap: + $(call snap_targets_disabled) # Linux archive package surface. diff --git a/docker/build-linux-deb.Dockerfile b/docker/build-linux-deb.Dockerfile index 4ccff4a..e0f2126 100755 --- a/docker/build-linux-deb.Dockerfile +++ b/docker/build-linux-deb.Dockerfile @@ -29,6 +29,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ curl \ + dpkg-dev \ file \ g++ \ gcc \ @@ -39,6 +40,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libncursesw6 \ libtinfo6 \ libyaml-dev \ + lintian \ make \ ncurses-bin \ ncurses-term \ diff --git a/docs/architecture/ecli-f4-linter-microservices-design.md b/docs/architecture/ecli-f4-linter-microservices-design.md index 4613f24..72f1655 100644 --- a/docs/architecture/ecli-f4-linter-microservices-design.md +++ b/docs/architecture/ecli-f4-linter-microservices-design.md @@ -996,7 +996,11 @@ Every entry must be tested according to the existing release contract. Do not de Different artifact types may deliver linters differently, but every Full installer/provisioner path must first detect the OS/artifact context, then check for already-installed required tools before installing or bundling missing tools: -- Debian/Ubuntu `.deb`: package dependencies or bundled tools. +- Debian/Ubuntu `.deb`: two-stage model on Debian 13 — the standalone + linter installer (`scripts/install_ecli_linters.py`) provisions the + 19-tool toolchain into `/opt/ecli/payload` first; the `.deb` installs + only ECLI itself and discovers tools through `PATH` + (see `docs/install/debian.md`). - RPM/openSUSE: package dependencies or bundled tools. - Arch: `depends`/`makedepends` or bundled tools. - Slackware: bundled tools or documented package metadata. diff --git a/docs/contributor/install.md b/docs/contributor/install.md index edb76b4..b61560d 100644 --- a/docs/contributor/install.md +++ b/docs/contributor/install.md @@ -177,6 +177,21 @@ Exact artifact names include the version and architecture. ### Debian / Ubuntu +On Debian 13 (Trixie) amd64, the recommended full sequence is two stages: +first provision the 19-tool F4 linter toolchain with the dedicated +installer, then install the ECLI package (see `docs/install/debian.md`): + +```bash +sudo python3 scripts/install_ecli_linters.py +sudo apt install ./releases/0.2.4/ecli_0.2.4_linux_x86_64.deb +``` + +The `.deb` installs ECLI itself and its direct runtime dependencies only; +it does not bundle or download linters. ECLI discovers installed linter +executables through `PATH`, and F4 diagnostics list any missing tools. + +Installing only the package also works: + ```bash sudo apt install ./ecli__linux_x86_64.deb ecli diff --git a/docs/extensions/f4-linter-manual-installation.md b/docs/extensions/f4-linter-manual-installation.md index 7679161..ad71da2 100644 --- a/docs/extensions/f4-linter-manual-installation.md +++ b/docs/extensions/f4-linter-manual-installation.md @@ -88,6 +88,13 @@ Chocolatey IDs are to be verified in packaging implementation. ## Debian / Ubuntu +On Debian 13 amd64 the supported path is the official interactive +installer, `sudo python3 scripts/install_ecli_linters.py`, which +provisions all 19 tools from the committed lock +`packaging/debian/ecli-linter-lock.json` into `/opt/ecli/payload` +(see `docs/install/debian.md`). The manual strategies below remain valid +for developer checkouts and custom environments. + Debian 13 testing used a valid mixed user-space strategy: Ruff already in the venv; npm custom prefix under `~/.local/share/ecli-linters/npm-global` for Biome and markdownlint-cli2; dedicated venvs for yamllint and Python-delivered diff --git a/docs/install/debian.md b/docs/install/debian.md new file mode 100644 index 0000000..22f3d7f --- /dev/null +++ b/docs/install/debian.md @@ -0,0 +1,214 @@ + +# Installing on Debian 13 (Trixie) + +ECLI installation on Debian 13 amd64 is a **two-stage** process: + +1. **Stage 1 — ECLI Linter Installer** provisions the complete 19-tool F4 + linter toolchain into the operating system and into the managed payload + tree `/opt/ecli/payload`. +2. **Stage 2 — ECLI Debian package** installs ECLI itself. + +The recommended full sequence is: + +```bash +sudo python3 scripts/install_ecli_linters.py +sudo apt install ./releases/0.2.4/ecli_0.2.4_linux_x86_64.deb +``` + +The `.deb` installs only ECLI and its direct runtime dependencies. It never +bundles, downloads, or installs the linter toolchains; ECLI discovers the +installed linter executables through `PATH`. If the linter payload is +absent, ECLI still installs and runs — F4 diagnostics list any missing +executables instead of failing. + +## Obtaining the linter installer bundle + +The linter installer is a standalone **four-file bundle**. Copying only +`install_ecli_linters.py` is **not sufficient** — it loads its production +lock and npm lock from paths next to its own location, so all four files +must be fetched together, preserving this exact flat layout: + +```text +install_ecli_linters.py +ecli-linter-lock.json +markdownlint-cli2/ + package.json + package-lock.json +``` + +(A full repository checkout already satisfies this layout automatically: +the script also looks one directory up, under +`packaging/debian/ecli-linter-lock.json` and +`packaging/debian/markdownlint-cli2/`.) + +### Prerequisite: Python 3 + +A minimal or netinst Debian 13 install does not ship Python. Install it +before running the bundle: + +```bash +sudo apt-get update && sudo apt-get install -y python3 +``` + +### Option A — clone the repository + +```bash +git clone --branch v0.2.4 --depth 1 https://github.com/SSobol77/ecli.git +cd ecli +sudo python3 scripts/install_ecli_linters.py +``` + +### Option B — fetch only the four bundle files + +```bash +mkdir -p ecli-linter-bundle/markdownlint-cli2 && cd ecli-linter-bundle +curl -fsSLO https://raw.githubusercontent.com/SSobol77/ecli/v0.2.4/scripts/install_ecli_linters.py +curl -fsSLO https://raw.githubusercontent.com/SSobol77/ecli/v0.2.4/packaging/debian/ecli-linter-lock.json +curl -fsSL -o markdownlint-cli2/package.json \ + https://raw.githubusercontent.com/SSobol77/ecli/v0.2.4/packaging/debian/markdownlint-cli2/package.json +curl -fsSL -o markdownlint-cli2/package-lock.json \ + https://raw.githubusercontent.com/SSobol77/ecli/v0.2.4/packaging/debian/markdownlint-cli2/package-lock.json +sudo python3 install_ecli_linters.py +``` + +The installer itself is never executed by any downloaded/piped shell +command — always fetch the four files, inspect them if desired, then run +`python3` directly against the local copy. + +## Stage 1 — ECLI Linter Installer + +Run the dedicated interactive installer as root (from a repository +checkout or the bundle fetched above): + +```bash +sudo python3 scripts/install_ecli_linters.py +``` + +The installer refuses to run unless all of the following hold: effective +UID 0, Linux, Debian, Debian major version 13, architecture `amd64`. + +It presents a 19-entry menu: + +```text +[ A ] - Install All Linters + +1. Ruff +2. Biome +3. markdownlint-cli2 +4. yamllint +5. shellcheck +6. Zig +7. Hadolint +8. Taplo +9. actionlint +10. clang-tidy +11. cppcheck +12. clang-format +13. Checkstyle +14. PMD +15. SpotBugs +16. cargo-clippy +17. golangci-lint +18. SQLFluff +19. TFLint +``` + +Enter `A` for all tools, or a comma-separated list of numbers (for example +`1,4,5`). Non-interactive automation can use +`--select A` or `--select 1,4,5`. + +What the installer does: + +- Resolves the complete APT package set for the selection (yamllint, + shellcheck, clang-tidy, cppcheck, clang-format, checkstyle, cargo, + rust-clippy, sqlfluff, plus runtime dependencies such as nodejs/npm, + default-jre-headless, golang-go) and installs it in **one** + `apt-get install --yes --no-install-recommends` transaction. +- Installs the standalone tools (Ruff, Biome, Zig, Hadolint, Taplo, + actionlint, PMD, SpotBugs, golangci-lint, TFLint, and the npm-locked + markdownlint-cli2) into `/opt/ecli/payload` from the committed + production lock `packaging/debian/ecli-linter-lock.json`: pinned + versions, HTTPS-only downloads, exact SHA-256 verification, safe + archive extraction, and atomic promotion. No `releases/latest` queries + and no Zig `master` builds. +- Configures `/etc/profile.d/ecli_payload.sh` so login shells put + `/opt/ecli/payload/bin` on `PATH` (idempotent — no duplicate entries). +- Verifies every selected tool with its version probe and prints a + per-tool `[OK] / [SKIPPED] / [FAILED]` report. The success message + `ECLI linter installation completed successfully: 19/19 tools verified.` + is printed only when all 19 tools pass. + +The installer is safely rerunnable: tools already at the locked version +are verified and skipped; outdated or corrupted managed installations are +replaced atomically; unmanaged files are never overwritten. A root-owned +log is written to `/var/log/ecli/linter-installer.log`. + +Managed payload layout: + +```text +/opt/ecli/payload/bin executable entry points (PATH surface) +/opt/ecli/payload/packages versioned distributions (zig, pmd, spotbugs, nodejs) +/opt/ecli/payload/state managed installation state +/opt/ecli/payload/cache verified download cache +``` + +## Stage 2 — ECLI Debian package + +After the linter installer completes, install ECLI: + +```bash +sudo apt install ./releases/0.2.4/ecli_0.2.4_linux_x86_64.deb +``` + +Verify the artifact checksum first when installing a downloaded release: + +```bash +sha256sum -c ecli_0.2.4_linux_x86_64.deb.sha256 +``` + +The package's maintainer scripts are intentionally conservative: + +- `remove` and `purge` never delete user configuration + (`~/.config/ecli`, including `/root/.config/ecli`). +- No maintainer script uses the network, invokes APT, or touches + `/opt/ecli/payload`. +- `postinst` only reports whether the optional linter payload is present; + its absence never fails the installation. + +## Verify + +```bash +ecli --version +``` + +Open a new login shell (or `source /etc/profile.d/ecli_payload.sh`) so +`/opt/ecli/payload/bin` is on `PATH`, then run the headless toolchain +verifier: + +```bash +ecli --f4-check +``` + +This proves, from the installed runtime, that all 19 provisioned +toolchain executables resolve from their approved managed or system +location (11 managed, 8 Debian-packaged). It reports two distinct counts +that must never be conflated: up to 19 **provisioned/verified +executables**, and a fixed **14 registered diagnostic providers** — the +number of linters F4 currently runs live diagnostics through inside the +editor. The remaining five provisioned tools (SpotBugs, golangci-lint, +SQLFluff, TFLint, clang-format) are installed and on `PATH` but do not +yet have a diagnostic provider wired into F4. Missing executables are +listed in the editor log by the startup toolchain check instead of +causing errors. diff --git a/docs/release/artifact-contract.md b/docs/release/artifact-contract.md index 66cb97b..60bd66f 100644 --- a/docs/release/artifact-contract.md +++ b/docs/release/artifact-contract.md @@ -269,7 +269,6 @@ Current builder output forms before release normalization: - Slackware package: `ecli__slackware_.txz` - AppImage: `ecli__linux_.AppImage` - Linux tarball: `ecli__linux_.tar.gz` -- Snap: `ecli__linux_.snap` - FreeBSD: `ecli__freebsd_.pkg` - Windows portable EXE: `ecli__win_.exe` - Windows NSIS installer EXE: `ecli__win__setup.exe` diff --git a/packaging/debian/ecli-linter-lock.json b/packaging/debian/ecli-linter-lock.json new file mode 100644 index 0000000..0875288 --- /dev/null +++ b/packaging/debian/ecli-linter-lock.json @@ -0,0 +1,188 @@ +{ + "schema_version": 1, + "description": "Production version lock for the ECLI Debian 13 linter installer (scripts/install_ecli_linters.py). Normal installation uses only this lock: no 'latest' release queries, no dynamic asset selection. Every version, URL, and checksum below was obtained from the named official upstream source on the recorded date.", + "generated_on": "2026-07-11", + "target": { + "os": "debian", + "debian_major_version": 13, + "architecture": "amd64" + }, + "tools": { + "ruff": { + "tool_id": "ruff", + "version": "0.15.21", + "source_url": "https://github.com/astral-sh/ruff/releases/tag/0.15.21", + "asset_url": "https://github.com/astral-sh/ruff/releases/download/0.15.21/ruff-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "7ddba1886f39ba918587f9ca37de9651008726834811c19ee83991705bd3e56b", + "sha256_verified_from": "Upstream release asset ruff-x86_64-unknown-linux-gnu.tar.gz.sha256 published by astral-sh/ruff on GitHub for tag 0.15.21; downloaded asset digest matched on 2026-07-11.", + "archive_type": "tar.gz", + "expected_member": "ruff-x86_64-unknown-linux-gnu/ruff", + "executable_name": "ruff", + "version_command": ["ruff", "--version"], + "install_directory": "/opt/ecli/payload/bin", + "architecture": "amd64", + "license": "MIT", + "max_download_bytes": 11156445 + }, + "biome": { + "tool_id": "biome", + "version": "2.5.3", + "source_url": "https://github.com/biomejs/biome/releases/tag/%40biomejs%2Fbiome%402.5.3", + "asset_url": "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%402.5.3/biome-linux-x64", + "sha256": "ab8e74af2366127306e250652d2f32bd193601f208fcd0604112080c0ca3245b", + "sha256_verified_from": "Upstream publishes no checksum manifest for raw binaries; digest computed on 2026-07-11 from the official biome-linux-x64 asset of GitHub release tag @biomejs/biome@2.5.3 downloaded over HTTPS from github.com.", + "archive_type": "binary", + "expected_member": "biome-linux-x64", + "executable_name": "biome", + "version_command": ["biome", "--version"], + "install_directory": "/opt/ecli/payload/bin", + "architecture": "amd64", + "license": "MIT OR Apache-2.0", + "max_download_bytes": 63745128 + }, + "markdownlint-cli2": { + "tool_id": "markdownlint-cli2", + "version": "0.22.1", + "source_url": "https://www.npmjs.com/package/markdownlint-cli2/v/0.22.1", + "asset_url": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.22.1.tgz", + "sha256": "57f59b92ce95a5d0beb9d484bf5ecede02fde29f913ad1a6cc74b15901650037", + "sha256_verified_from": "npm registry dist.integrity sha512-X14ZbytybDCXAViDmtN4DKLt9ZTrRn+oOrxTYlg3a65jS6QcYYbAkGPh/En2L/GDNbFYJ6lKaQSUNrrbN1bPrw== matched the downloaded tarball on 2026-07-11; sha256 computed from the same verified tarball. Installation itself uses the committed package-lock.json via 'npm ci --omit=dev', which enforces the registry integrity hashes. Version 0.22.1 is the newest release supporting Node.js >=20 (Debian 13 ships nodejs 20.19.x); 0.23.0 requires Node >=22 and is therefore not usable on Debian 13.", + "archive_type": "npm-lock", + "expected_member": "node_modules/.bin/markdownlint-cli2", + "executable_name": "markdownlint-cli2", + "version_command": ["markdownlint-cli2", "--version"], + "install_directory": "/opt/ecli/payload/packages/nodejs", + "architecture": "amd64", + "license": "MIT", + "max_download_bytes": 33554432 + }, + "zig": { + "tool_id": "zig", + "version": "0.16.0", + "source_url": "https://ziglang.org/download/", + "asset_url": "https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz", + "sha256": "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00", + "sha256_verified_from": "Official shasum for 0.16.0 x86_64-linux in https://ziglang.org/download/index.json; downloaded tarball digest matched on 2026-07-11. 0.16.0 is the newest stable release (2026-04-13); Zig 'master' development builds are never used.", + "archive_type": "tar.xz", + "expected_member": "zig-x86_64-linux-0.16.0/zig", + "executable_name": "zig", + "version_command": ["zig", "version"], + "install_directory": "/opt/ecli/payload/packages/zig/0.16.0", + "architecture": "amd64", + "license": "MIT", + "max_download_bytes": 55478392 + }, + "hadolint": { + "tool_id": "hadolint", + "version": "2.14.0", + "source_url": "https://github.com/hadolint/hadolint/releases/tag/v2.14.0", + "asset_url": "https://github.com/hadolint/hadolint/releases/download/v2.14.0/hadolint-linux-x86_64", + "sha256": "6bf226944684f56c84dd014e8b979d27425c0148f61b3bd99bcc6f39e9dc5a47", + "sha256_verified_from": "Upstream release asset hadolint-linux-x86_64.sha256 published by hadolint/hadolint on GitHub for tag v2.14.0; downloaded asset digest matched on 2026-07-11.", + "archive_type": "binary", + "expected_member": "hadolint-linux-x86_64", + "executable_name": "hadolint", + "version_command": ["hadolint", "--version"], + "install_directory": "/opt/ecli/payload/bin", + "architecture": "amd64", + "license": "GPL-3.0-only", + "max_download_bytes": 54727336 + }, + "taplo": { + "tool_id": "taplo", + "version": "0.10.0", + "source_url": "https://github.com/tamasfe/taplo/releases/tag/0.10.0", + "asset_url": "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-x86_64.gz", + "sha256": "8fe196b894ccf9072f98d4e1013a180306e17d244830b03986ee5e8eabeb6156", + "sha256_verified_from": "Upstream publishes no checksum manifest; digest computed on 2026-07-11 from the official taplo-linux-x86_64.gz asset of GitHub release tag 0.10.0 downloaded over HTTPS from github.com.", + "archive_type": "gz", + "expected_member": "taplo", + "executable_name": "taplo", + "version_command": ["taplo", "--version"], + "install_directory": "/opt/ecli/payload/bin", + "architecture": "amd64", + "license": "MIT", + "max_download_bytes": 5116068 + }, + "actionlint": { + "tool_id": "actionlint", + "version": "1.7.12", + "source_url": "https://github.com/rhysd/actionlint/releases/tag/v1.7.12", + "asset_url": "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz", + "sha256": "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8", + "sha256_verified_from": "Upstream release asset actionlint_1.7.12_checksums.txt published by rhysd/actionlint on GitHub for tag v1.7.12; downloaded asset digest matched on 2026-07-11.", + "archive_type": "tar.gz", + "expected_member": "actionlint", + "executable_name": "actionlint", + "version_command": ["actionlint", "-version"], + "install_directory": "/opt/ecli/payload/bin", + "architecture": "amd64", + "license": "MIT", + "max_download_bytes": 2353908 + }, + "pmd": { + "tool_id": "pmd", + "version": "7.26.0", + "source_url": "https://github.com/pmd/pmd/releases/tag/pmd_releases%2F7.26.0", + "asset_url": "https://github.com/pmd/pmd/releases/download/pmd_releases%2F7.26.0/pmd-dist-7.26.0-bin.zip", + "sha256": "9f55cb7ff0e9f9a66dd2f005eaa370e84c8a4cd971b134aa14a930c4a283ebc9", + "sha256_verified_from": "Upstream publishes a GPG signature (.asc) rather than a sha256 manifest; digest computed on 2026-07-11 from the official pmd-dist-7.26.0-bin.zip asset of GitHub release tag pmd_releases/7.26.0 downloaded over HTTPS from github.com.", + "archive_type": "zip", + "expected_member": "pmd-bin-7.26.0/bin/pmd", + "executable_name": "pmd", + "version_command": ["pmd", "--version"], + "install_directory": "/opt/ecli/payload/packages/pmd/7.26.0", + "architecture": "amd64", + "license": "BSD-2-Clause", + "max_download_bytes": 73646044 + }, + "spotbugs": { + "tool_id": "spotbugs", + "version": "4.10.2", + "source_url": "https://github.com/spotbugs/spotbugs/releases/tag/4.10.2", + "asset_url": "https://github.com/spotbugs/spotbugs/releases/download/4.10.2/spotbugs-4.10.2.tgz", + "sha256": "63d7687c35fba12cbc8e55ec2a889a2bbf1b9be299dea91f2b0d351dc285308a", + "sha256_verified_from": "Official CHECKSUM (sha256) table in the spotbugs/spotbugs GitHub release notes for tag 4.10.2; downloaded asset digest matched on 2026-07-11.", + "archive_type": "tar.gz", + "expected_member": "spotbugs-4.10.2/bin/spotbugs", + "executable_name": "spotbugs", + "version_command": ["spotbugs", "-version"], + "install_directory": "/opt/ecli/payload/packages/spotbugs/4.10.2", + "architecture": "amd64", + "license": "LGPL-2.1-only", + "max_download_bytes": 15832847 + }, + "golangci-lint": { + "tool_id": "golangci-lint", + "version": "2.12.2", + "source_url": "https://github.com/golangci/golangci-lint/releases/tag/v2.12.2", + "asset_url": "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-linux-amd64.tar.gz", + "sha256": "8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553", + "sha256_verified_from": "Upstream release asset golangci-lint-2.12.2-checksums.txt published by golangci/golangci-lint on GitHub for tag v2.12.2; downloaded asset digest matched on 2026-07-11.", + "archive_type": "tar.gz", + "expected_member": "golangci-lint-2.12.2-linux-amd64/golangci-lint", + "executable_name": "golangci-lint", + "version_command": ["golangci-lint", "--version"], + "install_directory": "/opt/ecli/payload/bin", + "architecture": "amd64", + "license": "GPL-3.0-only", + "max_download_bytes": 15022786 + }, + "tflint": { + "tool_id": "tflint", + "version": "0.63.1", + "source_url": "https://github.com/terraform-linters/tflint/releases/tag/v0.63.1", + "asset_url": "https://github.com/terraform-linters/tflint/releases/download/v0.63.1/tflint_linux_amd64.zip", + "sha256": "8441a7d97df20431f19c9b9d27ff4c63e308c964e86660bc7cc0cf7bbe0725e8", + "sha256_verified_from": "Upstream release asset checksums.txt published by terraform-linters/tflint on GitHub for tag v0.63.1; downloaded asset digest matched on 2026-07-11.", + "archive_type": "zip", + "expected_member": "tflint", + "executable_name": "tflint", + "version_command": ["tflint", "--version"], + "install_directory": "/opt/ecli/payload/bin", + "architecture": "amd64", + "license": "MPL-2.0", + "max_download_bytes": 16884467 + } + } +} diff --git a/packaging/debian/markdownlint-cli2/package-lock.json b/packaging/debian/markdownlint-cli2/package-lock.json new file mode 100644 index 0000000..302c618 --- /dev/null +++ b/packaging/debian/markdownlint-cli2/package-lock.json @@ -0,0 +1,1262 @@ +{ + "name": "ecli-markdownlint-cli2-payload", + "version": "0.2.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ecli-markdownlint-cli2-payload", + "version": "0.2.4", + "license": "GPL-2.0-only", + "dependencies": { + "markdownlint-cli2": "0.22.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdownlint": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.40.0.tgz", + "integrity": "sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg==", + "license": "MIT", + "dependencies": { + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", + "string-width": "8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.22.1.tgz", + "integrity": "sha512-X14ZbytybDCXAViDmtN4DKLt9ZTrRn+oOrxTYlg3a65jS6QcYYbAkGPh/En2L/GDNbFYJ6lKaQSUNrrbN1bPrw==", + "license": "MIT", + "dependencies": { + "globby": "16.2.0", + "js-yaml": "4.1.1", + "jsonc-parser": "3.3.1", + "jsonpointer": "5.0.1", + "markdown-it": "14.1.1", + "markdownlint": "0.40.0", + "markdownlint-cli2-formatter-default": "0.0.6", + "micromatch": "4.0.8", + "smol-toml": "1.6.1" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.6.tgz", + "integrity": "sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + }, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/packaging/debian/markdownlint-cli2/package.json b/packaging/debian/markdownlint-cli2/package.json new file mode 100644 index 0000000..75ef0b0 --- /dev/null +++ b/packaging/debian/markdownlint-cli2/package.json @@ -0,0 +1,10 @@ +{ + "name": "ecli-markdownlint-cli2-payload", + "version": "0.2.4", + "private": true, + "description": "Pinned markdownlint-cli2 payload for the ECLI Debian 13 linter installer. Installed into /opt/ecli/payload/packages/nodejs with 'npm ci --omit=dev' using the committed package-lock.json. markdownlint-cli2 0.22.1 is the newest release supporting Node.js >=20 (Debian 13 ships nodejs 20.19.x).", + "license": "GPL-2.0-only", + "dependencies": { + "markdownlint-cli2": "0.22.1" + } +} diff --git a/packaging/debian/verify_deb_minimal_install.sh b/packaging/debian/verify_deb_minimal_install.sh new file mode 100755 index 0000000..f44c60c --- /dev/null +++ b/packaging/debian/verify_deb_minimal_install.sh @@ -0,0 +1,80 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: packaging/debian/verify_deb_minimal_install.sh +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +# ============================================================================== +# Minimal .deb-only Debian 13 install proof. +# +# This is deliberately the SMALLEST possible clean-room test and must run +# BEFORE the full two-stage installer integration test. It proves the ECLI +# .deb is self-sufficient on a stock Debian 13 image: +# +# * does NOT run scripts/install_ecli_linters.py; +# * does NOT preinstall Python (the base debian:trixie image has none); +# * does NOT preinstall any F4 linter/toolchain package; +# * installs ONLY the local .deb through APT; +# * runs `ecli --version`; +# * proves APT resolved every ELF NEEDED entry (via `ldd`), i.e. that the +# declared Depends: field is complete and the binary is not missing a +# runtime library such as libz.so.1/zlib1g. +# +# Usage (inside a clean debian:trixie container, as root): +# sh packaging/debian/verify_deb_minimal_install.sh /path/to/ecli_*.deb +# ============================================================================== + +set -eu + +DEB="${1:?usage: verify_deb_minimal_install.sh }" + +step() { printf '\n===== [%s] =====\n' "$1"; } + +step "identity: Debian 13 amd64, no Python, no linter toolchain preinstalled" +. /etc/os-release +[ "$ID" = "debian" ] || { echo "FAIL: not Debian ($ID)"; exit 1; } +[ "${VERSION_ID%%.*}" = "13" ] || { echo "FAIL: not Debian 13 ($VERSION_ID)"; exit 1; } +[ "$(dpkg --print-architecture)" = "amd64" ] || { echo "FAIL: not amd64"; exit 1; } +if command -v python3 >/dev/null 2>&1; then + echo "FAIL: python3 is present; this must be a minimal image" + exit 1 +fi +for tool in ruff biome zig hadolint taplo actionlint pmd spotbugs \ + golangci-lint tflint markdownlint-cli2 yamllint shellcheck \ + clang-tidy cppcheck clang-format checkstyle cargo sqlfluff; do + if command -v "$tool" >/dev/null 2>&1; then + echo "FAIL: linter toolchain tool '$tool' is present; must be absent" + exit 1 + fi +done +echo "OK: Debian 13 amd64, no python3, no F4 linter/toolchain present" + +step "install ONLY the local .deb through APT (no other package selected)" +apt-get update -qq +apt-get install -y "$DEB" + +step "ecli --version" +VERSION_OUTPUT=$(ecli --version) +echo "$VERSION_OUTPUT" +case "$VERSION_OUTPUT" in + "ecli "*) ;; + *) echo "FAIL: unexpected ecli --version output: $VERSION_OUTPUT"; exit 1 ;; +esac + +step "prove APT resolved every ELF NEEDED entry (ldd: no 'not found')" +LDD_OUTPUT=$(ldd /usr/bin/ecli) +echo "$LDD_OUTPUT" +if echo "$LDD_OUTPUT" | grep -q "not found"; then + echo "FAIL: /usr/bin/ecli has an unresolved runtime dependency" + exit 1 +fi +echo "OK: every NEEDED shared library resolved (Depends: is complete)" + +printf '\n===== MINIMAL DEB-ONLY INSTALL: PASS =====\n' diff --git a/packaging/linux/fpm-common/postinst b/packaging/linux/fpm-common/postinst index f3c8de7..5e65875 100755 --- a/packaging/linux/fpm-common/postinst +++ b/packaging/linux/fpm-common/postinst @@ -16,6 +16,8 @@ set -e +# Idempotent, offline, APT-free. Never touches user configuration and +# never modifies the linter payload under /opt/ecli/payload. if [ "$1" = "configure" ]; then echo "----------------------------------------------------------------------" echo "ECLI has been successfully installed." @@ -26,6 +28,21 @@ if [ "$1" = "configure" ]; then echo echo "Please edit the '.env' file in that directory to add your API keys" echo "for AI features." + echo + # The F4 linter toolchain is provisioned separately (stage 1) by the + # standalone ECLI linter installer bundle. This machine may never have + # a repository checkout, so this message must never assume one is + # present -- see docs/install/debian.md for how to obtain the bundle. + # Its absence is not an error and must never fail this package's + # installation. + if [ -d /opt/ecli/payload/bin ]; then + echo "F4 linter payload detected at /opt/ecli/payload/bin." + else + echo "Note: the optional F4 linter toolchain is not installed." + echo "To provision it, obtain and run the ECLI linter installer:" + echo " https://ecli.io (see 'Linter installer' / docs/install/debian.md)" + echo "ECLI works without it; F4 diagnostics list any missing tools." + fi echo "----------------------------------------------------------------------" fi diff --git a/packaging/linux/fpm-common/postrm b/packaging/linux/fpm-common/postrm index 3158bdb..1f1b364 100755 --- a/packaging/linux/fpm-common/postrm +++ b/packaging/linux/fpm-common/postrm @@ -1,11 +1,10 @@ -#!/bin/bash +#!/bin/sh # SPDX-License-Identifier: GPL-2.0-only # # Project: Ecli # File: packaging/linux/fpm-common/postrm # Website: https://www.ecli.io # Repository: https://github.com/SSobol77/ecli -# PyPI: https://pypi.org/project/ecli-editor/0.0.1/ # # Copyright (c) 2026 Siergej Sobolewski # @@ -15,33 +14,26 @@ # ============================================================================== # postrm script for the ECLI package # -# This script is executed after the package is removed. -# Its primary purpose is to clean up user-specific configuration files -# ONLY when the 'purge' command is used. +# User configuration is never touched: ECLI deliberately preserves +# ~/.config/ecli (including /root/.config/ecli) on remove AND on purge. +# This script must never iterate over home directories, never delete +# user data, never use the network, and never invoke APT. Running +# it repeatedly for any dpkg action is safe (idempotent). # ============================================================================== set -e -# The first argument ($1) passed to this script by dpkg is the action being -# performed. We only want to delete user data on a complete 'purge'. -if [ "$1" = "purge" ]; then - echo "Purging ECLI: Removing user configuration directories..." - - # This script runs as root, so we need to find all potential user home - # directories to clean up their configs. We check /root and /home/*. - # A more complex system might query all users, but this is a robust - - # approach for typical desktop/server systems. - for homedir in /root /home/*; do - if [ -d "${homedir}" ]; then - ECLI_CONFIG_DIR="${homedir}/.config/ecli" - - if [ -d "${ECLI_CONFIG_DIR}" ]; then - echo "Removing ${ECLI_CONFIG_DIR}..." - rm -rf "${ECLI_CONFIG_DIR}" - fi - fi - done -fi +case "$1" in + purge) + # Intentionally no user-data removal. Users who want to delete + # their own configuration can remove ~/.config/ecli themselves. + # The linter payload under /opt/ecli/payload is managed by the + # separate linter installer and is left untouched as well. + ;; + remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) + ;; + *) + ;; +esac exit 0 diff --git a/scripts/build_and_package_deb.py b/scripts/build_and_package_deb.py index 62b4a52..a17bf76 100644 --- a/scripts/build_and_package_deb.py +++ b/scripts/build_and_package_deb.py @@ -17,27 +17,38 @@ Canonical Python replacement for ``scripts/build-and-package-deb.sh``. It builds a standalone executable with PyInstaller, stages a minimal FHS payload, and produces ``releases//ecli__linux_.deb`` with FPM plus a -SHA256 sidecar. Artifact naming, output locations, and the FPM dependency set are -preserved exactly. +SHA256 sidecar. Artifact naming and output locations are preserved exactly. -This script orchestrates the local packaging toolchain only. It never publishes, -uploads, signs with external keys, tags, pushes, or triggers any workflow. +The ``.deb`` itself is built, inspected, runtime-verified, and Lintian-gated +entirely in a temporary location; the final artifact and its checksum sidecar +are only ever written via one atomic promotion after every gate has passed. +A failed gate never leaves a new (partial or unverified) final artifact +behind, and never touches a previously promoted one. + +This script validates ECLI packaging only. It never provisions, downloads, or +verifies the F4 linter toolchain -- that is the sole responsibility of the +standalone stage-1 installer (``scripts/install_ecli_linters.py``) and its own +clean-room validation. It never publishes, uploads, signs with external keys, +tags, pushes, or triggers any workflow. Exit codes: -* ``0`` package built and verified -* ``1`` missing tool, missing version, or missing PyInstaller output +* ``0`` package built, gated, and promoted +* ``1`` missing tool, missing version, missing PyInstaller output, or a + failed pre-promotion gate (info/contents/runtime/Lintian) """ from __future__ import annotations import argparse +import hashlib import os import shutil import subprocess import sys +import tempfile +import time import tomllib -from datetime import datetime from pathlib import Path from f4_linter_packaging import ( @@ -52,7 +63,6 @@ install_file, install_icon, require_tool, - write_sha256, ) @@ -65,15 +75,79 @@ LICENSE = "GPL-2.0-only" CATEGORY = "editors" +# The PyInstaller executable dynamically links libc.so.6 and libz.so.1 +# (readelf -d: NEEDED libc.so.6, NEEDED libz.so.1), so libc6 and zlib1g are +# genuine runtime dependencies; without them lintian correctly reports +# missing-dependency-on-libc / a missing zlib dependency, and a system that +# lacks zlib1g (rare, but not guaranteed present transitively) would fail +# to start the binary at all. DEB_DEPENDS = ( + "libc6", "libncurses6", "libncursesw6", "libtinfo6", "ncurses-term", "libyaml-0-2", + "zlib1g", "xclip | xsel", ) +# Debian policy: the synopsis must not start with the package name; the +# extended description must be non-empty. FPM folds the newline-separated +# lines into a correct multi-line Description field. +DEB_DESCRIPTION = ( + "terminal-first DevOps editor with AI and Git integration\n" + "ECLI is a fast terminal code editor for engineering operations\n" + "work: editing, Git integration, F4 diagnostics, and optional AI\n" + "assistance. F4 verifies up to 19 provisioned linter/toolchain\n" + "executables and runs diagnostics through 14 registered providers.\n" + "The 19-tool toolchain is provisioned separately; see\n" + "https://ecli.io and the project documentation for the linter\n" + "installer. This package installs only ECLI itself." +) + +# Deterministic release timestamp used for the Debian changelog entry, the +# staged file mtimes, and generated gzip members, and exported as +# SOURCE_DATE_EPOCH to FPM and to the post-build dpkg-deb repack step so the +# archive metadata (ar/tar member timestamps) is byte-reproducible. +# Overridable via the SOURCE_DATE_EPOCH environment variable. +RELEASE_EPOCH_DEFAULT = 1783123200 # 2026-07-04T00:00:00Z + +# Narrow, documented lintian overrides shipped by the package. Only tags +# that are investigated and unavoidable belong here. +# +# Deliberately context-free (no "[usr/bin/ecli]" suffix): the override +# file syntax for a tag's context is NOT portable across the two lintian +# releases this pipeline runs -- bullseye's lintian 2.104 (the build-time +# gate) prints/matches contexts as bare "usr/bin/ecli", while trixie's +# lintian 2.122 (the downstream clean-room validation) prints/matches +# "[usr/bin/ecli]" with brackets; each rejects the other's format with +# "mismatched-override", turning the override into a no-op and failing +# the build. Evidence: both formats were verified empirically against +# both lintian versions (see the packaging round-3 evidence archive). A +# context-free override matches the tag regardless of that formatting +# difference and remains unambiguous in practice, since this package +# ships exactly one ELF binary the "hardening-no-pie" tag could ever fire +# against. +LINTIAN_OVERRIDES = ( + "# The executable is produced by PyInstaller, whose precompiled Linux\n" + "# bootloader is a non-PIE ELF (readelf -h: Type EXEC). Rebuilding the\n" + "# PyInstaller bootloader as PIE is not supported by this packaging\n" + "# pipeline. This package ships exactly one ELF binary (usr/bin/ecli),\n" + "# so this override is unambiguous even without a context suffix; a\n" + "# context is deliberately omitted because its required syntax is not\n" + "# portable across the lintian versions this pipeline runs (bullseye\n" + "# 2.104 build-time gate vs. trixie 2.122 downstream validation).\n" + "ecli: hardening-no-pie\n" +) +# Exactly this many lintian overrides are expected; any other count means +# either the override is missing or an unexpected additional one appeared. +EXPECTED_LINTIAN_OVERRIDE_COUNT = 1 + + +class GateError(RuntimeError): + """A mandatory pre-promotion gate failed; never promote on this.""" + def python_bin() -> str: return os.environ.get("PYTHON", "python3") @@ -81,7 +155,23 @@ def python_bin() -> str: def read_version(root: Path) -> str: with (root / "pyproject.toml").open("rb") as handle: - return tomllib.load(handle)["project"]["version"] + data = tomllib.load(handle) + return str(data["project"]["version"]) + + +def release_epoch() -> int: + """Deterministic build timestamp (SOURCE_DATE_EPOCH env or default).""" + raw = os.environ.get("SOURCE_DATE_EPOCH", "") + return int(raw) if raw.isdigit() else RELEASE_EPOCH_DEFAULT + + +def deterministic_env(epoch: int) -> dict[str, str]: + """Process environment with SOURCE_DATE_EPOCH pinned for every child + tool that participates in producing archive bytes (FPM, dpkg-deb). + """ + env = dict(os.environ) + env["SOURCE_DATE_EPOCH"] = str(epoch) + return env def desktop_entry() -> str: @@ -98,8 +188,11 @@ def desktop_entry() -> str: ) -def man_page(version: str) -> str: - date_str = datetime.now().strftime("%B %Y") +def man_page(version: str, epoch: int = RELEASE_EPOCH_DEFAULT) -> str: + """Render the man page. The date is derived only from ``epoch`` (never + from wall-clock time) so the rendered text is reproducible. + """ + date_str = time.strftime("%B %Y", time.gmtime(epoch)) author = MAINTAINER.split(" <", 1)[0] return ( f'.TH {PACKAGE_NAME.upper()} 1 "{date_str}" "{PACKAGE_NAME} {version}" ' @@ -121,6 +214,142 @@ def man_page(version: str) -> str: ) +def debian_changelog(version: str, epoch: int) -> str: + """Render a minimal valid Debian changelog for this native package.""" + stamp = time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(epoch)) + return ( + f"{PACKAGE_NAME} ({version}) unstable; urgency=medium\n" + "\n" + f" * ECLI release {version}. See README.md and the project\n" + " changelog for details.\n" + "\n" + f" -- {MAINTAINER} {stamp}\n" + ) + + +def debian_copyright() -> str: + """Render the machine-readable (DEP-5) Debian copyright file.""" + return ( + "Format: https://www.debian.org/doc/packaging-manuals/" + "copyright-format/1.0/\n" + f"Upstream-Name: {PACKAGE_NAME}\n" + "Source: https://github.com/SSobol77/ecli\n" + "\n" + "Files: *\n" + "Copyright: 2026 Siergej Sobolewski \n" + "License: GPL-2\n" + " This package is free software; you can redistribute it and/or\n" + " modify it under the terms of the GNU General Public License\n" + " version 2 only, as published by the Free Software Foundation.\n" + " .\n" + " This package is distributed in the hope that it will be useful,\n" + " but WITHOUT ANY WARRANTY; without even the implied warranty of\n" + " MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n" + " GNU General Public License for more details.\n" + " .\n" + " On Debian systems, the complete text of the GNU General Public\n" + ' License version 2 can be found in "/usr/share/common-licenses/' + 'GPL-2".\n' + ) + + +def normalize_tree_mtime(root: Path, epoch: int) -> None: + """Pin every file/dir mtime under ``root`` to ``epoch``. + + ``shutil.copy2`` (used by ``install_file``) preserves the source file's + original mtime, and a fresh git checkout's mtimes are "now" -- both + vary between machines and between runs on the same machine. Content + that is byte-identical across two builds must also have identical + metadata for the packaged archive to be byte-identical; this removes + mtime as a source of nondeterminism independent of whatever tar/ar + timestamp handling the downstream packaging tools apply on their own. + """ + for dirpath, dirnames, filenames in os.walk(root): + base = Path(dirpath) + for name in (*dirnames, *filenames): + path = base / name + os.utime(path, (epoch, epoch), follow_symlinks=False) + os.utime(root, (epoch, epoch)) + + +def strip_control_fields(deb_path: Path, fields: tuple[str, ...], epoch: int) -> None: + """Rebuild the .deb without FPM's non-policy control fields. + + fpm's deb control template emits ``License:`` unconditionally (and a + default ``Vendor:``); Debian policy keeps license data in + ``usr/share/doc//copyright`` and lintian flags both as + ``unknown-field``. dpkg-deb re-packs with ``--root-owner-group`` so + file ownership stays root:root, ``-Zxz`` preserves the xz compression + contract, and the same ``SOURCE_DATE_EPOCH`` used for the FPM build is + passed through so the rebuilt ar container members stay reproducible. + """ + env = deterministic_env(epoch) + with tempfile.TemporaryDirectory(dir=str(deb_path.parent)) as tmp: + extract = Path(tmp) / "pkg" + subprocess.run( + ["dpkg-deb", "-R", str(deb_path), str(extract)], check=True, env=env + ) + control = extract / "DEBIAN" / "control" + kept = [ + line + for line in control.read_text(encoding="utf-8").splitlines(keepends=True) + if not any(line.startswith(f"{field}:") for field in fields) + ] + control.write_text("".join(kept), encoding="utf-8") + normalize_tree_mtime(extract, epoch) + rebuilt = Path(tmp) / deb_path.name + subprocess.run( + [ + "dpkg-deb", + "-b", + "--root-owner-group", + "-Zxz", + str(extract), + str(rebuilt), + ], + check=True, + env=env, + ) + os.replace(rebuilt, deb_path) + + +def run_lintian_gate(deb_path: Path, root: Path) -> None: + """Mandatory pre-promotion Lintian gate. + + Fail-closed: any Lintian error, any warning, or a number of overrides + other than exactly ``EXPECTED_LINTIAN_OVERRIDE_COUNT`` aborts the build + before the candidate artifact is ever promoted to its final path. + """ + result = subprocess.run( + ["lintian", "--tag-display-limit", "0", "--show-overrides", str(deb_path)], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + output = result.stdout + result.stderr + print(output) + errors = [line for line in output.splitlines() if line.startswith("E:")] + warnings = [line for line in output.splitlines() if line.startswith("W:")] + overrides = [line for line in output.splitlines() if line.startswith("O:")] + if errors or warnings or len(overrides) != EXPECTED_LINTIAN_OVERRIDE_COUNT: + raise GateError( + "lintian gate failed: " + f"{len(errors)} error(s), {len(warnings)} warning(s), " + f"{len(overrides)} override(s) " + f"(expected exactly {EXPECTED_LINTIAN_OVERRIDE_COUNT})" + ) + + +def sha256_sidecar_text(artifact: Path) -> str: + r"""Coreutils-format ``sha256sum`` sidecar text: `` \n``.""" + digest = hashlib.sha256() + with artifact.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return f"{digest.hexdigest()} {artifact.name}\n" + + def find_executable(root: Path) -> Path | None: onedir = root / "dist" / PACKAGE_NAME / PACKAGE_NAME onefile = root / "dist" / PACKAGE_NAME @@ -131,7 +360,15 @@ def find_executable(root: Path) -> Path | None: return None -def run_pyinstaller(root: Path) -> None: +def run_pyinstaller(root: Path, epoch: int) -> None: + # PYTHONHASHSEED pins Python's hash randomization (affects iteration + # order of any set/dict PyInstaller's own module-collection code uses + # internally); SOURCE_DATE_EPOCH is passed through in case PyInstaller + # or its bootloader honors it for embedded build metadata. Neither is + # a complete reproducibility guarantee for PyInstaller output on its + # own -- see the reproducibility evidence in the release report. + env = deterministic_env(epoch) + env["PYTHONHASHSEED"] = "0" spec = root / "packaging" / "pyinstaller" / "ecli.spec" if spec.is_file(): subprocess.run( @@ -143,6 +380,7 @@ def run_pyinstaller(root: Path) -> None: ], cwd=root, check=True, + env=env, ) return subprocess.run( @@ -182,16 +420,19 @@ def run_pyinstaller(root: Path) -> None: ], cwd=root, check=True, + env=env, ) -def stage_payload(root: Path, staging: Path, executable: Path, version: str) -> None: +def stage_payload( + root: Path, staging: Path, executable: Path, version: str, epoch: int +) -> None: """Stage the FHS payload tree under ``staging`` (matches the shell layout).""" shutil.rmtree(staging, ignore_errors=True) for sub in ( "usr/bin", "usr/share/applications", - "usr/share/icons/hicolor/256x256/apps", + "usr/share/pixmaps", f"usr/share/doc/{PACKAGE_NAME}", "usr/share/man/man1", ): @@ -205,28 +446,50 @@ def stage_payload(root: Path, staging: Path, executable: Path, version: str) -> PACKAGE_NAME, desktop_entry(), ) - install_icon( - root, - staging / "usr/share/icons/hicolor/256x256/apps" / f"{PACKAGE_NAME}.png", - ) + # The project icon is 200x199; installing it under a hicolor size + # directory triggers lintian icon-size-and-directory-name-mismatch. + # /usr/share/pixmaps has no size contract and the desktop entry's + # Icon=ecli resolves there. + install_icon(root, staging / "usr/share/pixmaps" / f"{PACKAGE_NAME}.png") doc_dir = staging / "usr/share/doc" / PACKAGE_NAME install_docs(root, doc_dir) for name in ("LICENSE", "README.md"): if (doc_dir / name).is_file(): - gzip_file(doc_dir / name) + gzip_file(doc_dir / name, mtime=epoch) + # Debian policy requires an uncompressed copyright file. + (doc_dir / "copyright").write_text(debian_copyright(), encoding="utf-8") + os.chmod(doc_dir / "copyright", 0o644) + + overrides_dir = staging / "usr/share/lintian/overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / PACKAGE_NAME).write_text(LINTIAN_OVERRIDES, encoding="utf-8") + os.chmod(overrides_dir / PACKAGE_NAME, 0o644) man_dst = staging / "usr/share/man/man1" / f"{PACKAGE_NAME}.1" repo_man = root / "man" / f"{PACKAGE_NAME}.1" if repo_man.is_file(): install_file(repo_man, man_dst, 0o644) else: - man_dst.write_text(man_page(version), encoding="utf-8") - gzip_file(man_dst) + man_dst.write_text(man_page(version, epoch), encoding="utf-8") + gzip_file(man_dst, mtime=epoch) + # Reproducibility: pin every staged file/dir mtime to the release + # epoch. Applied last so it also covers the executable, desktop entry, + # icon, and gzip members copied/written above. + normalize_tree_mtime(staging, epoch) -def build_fpm_command(staging: Path, version: str, final_deb: Path) -> list[str]: - """Construct the FPM .deb command array (deterministic, no shell).""" + +def build_fpm_command( + staging: Path, version: str, final_deb: Path, changelog: Path | None = None +) -> list[str]: + """Construct the FPM .deb command array (deterministic, no shell). + + prerm/postrm are intentionally not packaged: they perform no work for + this package (user configuration is never touched), and Debian policy + forbids shipping empty maintainer scripts + (lintian maintainer-script-empty). + """ cmd = [ "fpm", "-s", @@ -242,7 +505,7 @@ def build_fpm_command(staging: Path, version: str, final_deb: Path) -> list[str] "--maintainer", MAINTAINER, "--description", - "Ecli — terminal DevOps editor with AI and Git integration", + DEB_DESCRIPTION, "--url", HOMEPAGE, "--license", @@ -254,15 +517,13 @@ def build_fpm_command(staging: Path, version: str, final_deb: Path) -> list[str] "--deb-compression", "xz", ] + if changelog is not None: + cmd += ["--deb-changelog", str(changelog)] for dep in DEB_DEPENDS: cmd += ["--depends", dep] cmd += [ "--after-install", "packaging/linux/fpm-common/postinst", - "--before-remove", - "packaging/linux/fpm-common/prerm", - "--after-remove", - "packaging/linux/fpm-common/postrm", "--package", str(final_deb), "-C", @@ -272,6 +533,69 @@ def build_fpm_command(staging: Path, version: str, final_deb: Path) -> list[str] return cmd +def build_deb_atomic( + root: Path, + staging: Path, + version: str, + releases_dir: Path, + final_deb: Path, + final_sha: Path, + epoch: int, +) -> None: + """Build, gate, and atomically promote the ``.deb`` and its sidecar. + + Everything up to and including the checksum is produced in a temporary + directory on the SAME filesystem as ``releases_dir`` (so the final + ``os.replace`` promotions are atomic). ``final_deb``/``final_sha`` are + never written to, touched, or partially created until every mandatory + gate -- ``dpkg-deb --info``, ``dpkg-deb --contents``, + ``verify_runtime.py``, and Lintian -- has passed. Any failure raises + out of the ``with`` block, and ``TemporaryDirectory`` removes every + scratch file; a previously promoted artifact at ``final_deb`` is never + touched by a failed run. + """ + releases_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=str(releases_dir)) as tmp: + tmp_path = Path(tmp) + candidate_deb = tmp_path / final_deb.name + changelog = tmp_path / "debian-changelog" + changelog.write_text(debian_changelog(version, epoch), encoding="utf-8") + + print("==> Building candidate .deb with FPM (temporary location)") + subprocess.run( + build_fpm_command(staging, version, candidate_deb, changelog), + cwd=root, + check=True, + env=deterministic_env(epoch), + ) + + print("==> Normalizing control fields (License/Vendor are not policy fields)") + strip_control_fields(candidate_deb, ("License", "Vendor"), epoch) + + print("==> Mandatory pre-promotion gates") + subprocess.run(["dpkg-deb", "--info", str(candidate_deb)], cwd=root, check=True) + subprocess.run( + ["dpkg-deb", "--contents", str(candidate_deb)], cwd=root, check=True + ) + subprocess.run( + [sys.executable, "scripts/verify_runtime.py", str(candidate_deb)], + cwd=root, + check=True, + ) + run_lintian_gate(candidate_deb, root) + + print("==> All gates passed: generating checksum and promoting atomically") + candidate_sha = tmp_path / f"{final_deb.name}.sha256" + candidate_sha.write_text(sha256_sidecar_text(candidate_deb), encoding="utf-8") + + # Same filesystem as final_deb/final_sha (both created with + # dir=str(releases_dir)), so these renames are atomic: there is no + # window where a partial or unverified file is visible at the + # final path. + os.replace(candidate_deb, final_deb) + os.replace(candidate_sha, final_sha) + + def main(argv: list[str] | None = None) -> int: """Build the Debian package and verify it; return the exit code.""" parser = argparse.ArgumentParser( @@ -282,69 +606,102 @@ def main(argv: list[str] | None = None) -> int: root = Path(__file__).resolve().parent.parent version = read_version(root) + epoch = release_epoch() print("==> Checking production runtime imports") subprocess.run( [python_bin(), "scripts/check_runtime_imports.py"], cwd=root, check=True ) - if not (require_tool("pyinstaller") and require_tool("fpm")): + if not ( + require_tool("pyinstaller") + and require_tool("fpm") + and require_tool("dpkg-deb") + and require_tool("lintian") + ): return EXIT_ERROR arch = filename_arch() releases_dir = root / "releases" / version final_deb = releases_dir / f"{PACKAGE_NAME}_{version}_linux_{arch}.deb" + final_sha = releases_dir / f"{final_deb.name}.sha256" staging = root / "build" / "deb_staging" - print(f"==> Version: {version}") + print(f"==> Version: {version} SOURCE_DATE_EPOCH: {epoch}") shutil.rmtree(root / "build", ignore_errors=True) shutil.rmtree(root / "dist", ignore_errors=True) - final_deb.unlink(missing_ok=True) - (releases_dir / f"{final_deb.name}.sha256").unlink(missing_ok=True) print("==> Building executable with PyInstaller") - run_pyinstaller(root) + run_pyinstaller(root, epoch) executable = find_executable(root) if executable is None: print("PyInstaller output not found", file=sys.stderr) return EXIT_ERROR print("==> Preparing staging (FHS)") - stage_payload(root, staging, executable, version) + stage_payload(root, staging, executable, version, epoch) releases_dir.mkdir(parents=True, exist_ok=True) (releases_dir / ".linux.env").write_text( f"LINUX_ARCH := {arch}\n", encoding="utf-8" ) - print("==> Building .deb with FPM") - subprocess.run(build_fpm_command(staging, version, final_deb), cwd=root, check=True) - - print("==> Verify") - if shutil.which("dpkg-deb"): - subprocess.run(["dpkg-deb", "--info", str(final_deb)], cwd=root, check=False) - subprocess.run( - ["dpkg-deb", "--contents", str(final_deb)], cwd=root, check=False + try: + build_deb_atomic( + root, staging, version, releases_dir, final_deb, final_sha, epoch + ) + except (subprocess.CalledProcessError, GateError) as exc: + print(f"ERROR: pre-promotion gate failed: {exc}", file=sys.stderr) + print( + f"No new artifact was promoted; {final_deb} is unchanged.", + file=sys.stderr, ) - subprocess.run( - [sys.executable, "scripts/verify_runtime.py", str(final_deb)], - cwd=root, - check=True, - ) - - print("==> Generating SHA-256 checksum") - write_sha256(releases_dir, final_deb) - - print("==> Recording F4 linter provisioning evidence") - f4_rc = run_or_record_f4_linter_provisioning_for_artifacts( - root, - artifact_ids_from_env(("deb",), root=root), - ) - if f4_rc != EXIT_OK: return EXIT_ERROR print(f"DONE: {final_deb}") + record_f4_evidence_non_gating(root, final_deb) return EXIT_OK +def record_f4_evidence_non_gating(root: Path, final_deb: Path) -> None: + """Record legacy F4 linter provisioning compatibility evidence. + + LEGACY, non-gating compatibility evidence only -- callers must invoke + this strictly AFTER ``final_deb`` and its checksum sidecar have + already been atomically promoted. It records facts about the BUILD + HOST's linter environment for the repository-wide canonical-21 + -artifact release contract (docs/release/artifact-contract.md); it is + NOT proof that the ``.deb`` installs or bundles the F4 linter + payload -- it never does, that is the sole responsibility of the + standalone stage-1 installer (``scripts/install_ecli_linters.py``). + + This function never raises and never returns a value a caller could + branch on: neither a non-zero return code nor any exception from the + underlying hook may ever change the build's exit code or touch the + already-promoted artifact. Every non-success outcome is caught here + and reported as a visible, non-fatal warning only. + """ + print("==> Recording F4 linter provisioning evidence (legacy, non-gating)") + try: + f4_rc = run_or_record_f4_linter_provisioning_for_artifacts( + root, + artifact_ids_from_env(("deb",), root=root), + ) + except Exception as exc: # noqa: BLE001 - must never affect the build outcome + print( + "WARNING: F4 linter provisioning evidence recording raised " + f"{exc!r}; the already-promoted .deb at {final_deb} is " + "unaffected. See docs/release/artifact-contract.md.", + file=sys.stderr, + ) + return + if f4_rc != EXIT_OK: + print( + "WARNING: F4 linter provisioning evidence recording failed " + f"(exit {f4_rc}); the already-promoted .deb at {final_deb} is " + "unaffected. See docs/release/artifact-contract.md.", + file=sys.stderr, + ) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/scripts/install_ecli_linters.py b/scripts/install_ecli_linters.py new file mode 100755 index 0000000..f358427 --- /dev/null +++ b/scripts/install_ecli_linters.py @@ -0,0 +1,2124 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: scripts/install_ecli_linters.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Interactive ECLI F4 linter toolchain installer for Debian 13 (amd64). + +Stage 1 of the two-stage ECLI Debian installation: + + sudo python3 scripts/install_ecli_linters.py + sudo apt install ./releases//ecli__linux_x86_64.deb + +This script provisions the complete 19-tool F4 linter toolchain into the +operating system (APT packages) and into the managed ECLI payload tree: + + /opt/ecli/payload/bin executable entry points (PATH surface) + /opt/ecli/payload/packages versioned tool distributions + /opt/ecli/payload/state managed installation state + /opt/ecli/payload/cache verified download cache / staging + +and configures ``/etc/profile.d/ecli_payload.sh`` so login shells see +``/opt/ecli/payload/bin``. The ECLI ``.deb`` (stage 2) never bundles, +downloads, or installs linters; ECLI discovers them through ``PATH``. + +Hard requirements enforced before any work: effective UID 0, Linux, +Debian, Debian major version 13, ``amd64`` architecture. + +Standalone tools are installed exclusively from the committed production +lock ``packaging/debian/ecli-linter-lock.json`` (pinned version, HTTPS +asset URL, exact SHA-256, expected archive member). No ``releases/latest`` +queries, no dynamic asset selection, no Zig ``master``. markdownlint-cli2 +is installed with ``npm ci --omit=dev`` from the committed +``packaging/debian/markdownlint-cli2/package-lock.json``. + +Downloads use the Python standard library only (HTTPS-only including +redirects, bounded size, bounded retries, ``.part`` staging, exact SHA-256 +verification, atomic rename). Archive extraction rejects absolute paths, +traversal, device/FIFO members, setuid/setgid bits, and link escapes, and +requires exactly the locked archive member. Because the standard library +handles every locked archive type natively, no external acquisition +binaries (curl/jq/tar/gzip/unzip/xz) are required; only ``ca-certificates`` +is installed for TLS trust when a standalone download is selected. + +This file is standalone: it must never import ECLI application modules. + +Exit codes: + +* ``0`` every selected tool installed and verified +* ``1`` at least one selected tool failed to install or verify +* ``2`` invalid selection or usage +* ``3`` unsupported platform or missing root privileges +* ``4`` missing or invalid lock file / npm lock inputs +""" + +from __future__ import annotations + +import argparse +import contextlib +import fcntl +import gzip +import hashlib +import json +import os +import re +import shlex +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +# -------------------------------------------------------------------------- +# Canonical layout and platform contract +# -------------------------------------------------------------------------- + +PAYLOAD_ROOT = Path("/opt/ecli/payload") +PAYLOAD_BIN = PAYLOAD_ROOT / "bin" +PAYLOAD_PACKAGES = PAYLOAD_ROOT / "packages" +PAYLOAD_STATE = PAYLOAD_ROOT / "state" +PAYLOAD_CACHE = PAYLOAD_ROOT / "cache" +STATE_FILE = PAYLOAD_STATE / "installed-tools.json" +PROFILE_SCRIPT = Path("/etc/profile.d/ecli_payload.sh") +LOG_FILE = Path("/var/log/ecli/linter-installer.log") +# Standard root-writable tmpfs lock directory, present on every systemd or +# sysvinit Debian system regardless of whether /opt/ecli/payload has been +# created yet -- avoids a chicken-and-egg dependency on the payload tree +# this same lock is meant to protect. +INSTALLER_LOCK_FILE = Path("/run/lock/ecli-linter-installer.lock") + +REQUIRED_OS_ID = "debian" +REQUIRED_DEBIAN_MAJOR = 13 +REQUIRED_DPKG_ARCH = "amd64" + +DIR_MODE = 0o755 +EXEC_MODE = 0o755 +FILE_MODE = 0o644 + +# Deterministic, idempotent PATH drop-in. The payload directory is +# PREPENDED so lock-pinned managed tools deterministically win over stale +# or unrelated same-named executables in /usr/local/bin, user-local +# directories, or other earlier PATH entries. The case guard prevents +# duplicate PATH entries on repeated logins and repeated installer runs. +PROFILE_CONTENT = """\ +# Managed by ECLI: scripts/install_ecli_linters.py. Do not edit. +# Prepends the ECLI linter payload to PATH exactly once so lock-pinned +# managed tools take precedence over same-named host executables. +if [ -d /opt/ecli/payload/bin ]; then + case ":$PATH:" in + *":/opt/ecli/payload/bin:"*) ;; + *) PATH="/opt/ecli/payload/bin:$PATH" ;; + esac + export PATH +fi +""" + +# Internal execution environment: version verification must work in this +# process, without requiring a new login shell. Payload first: managed +# tools must shadow same-named host executables deterministically, and +# probes never resolve from /usr/local or user-local directories. +PROBE_PATH = f"{PAYLOAD_BIN}:/usr/sbin:/usr/bin:/sbin:/bin" +APPROVED_PATH_PREFIXES = ( + f"{PAYLOAD_BIN}/", + "/usr/sbin/", + "/usr/bin/", + "/sbin/", + "/bin/", +) + +DOWNLOAD_CONNECT_TIMEOUT = 30.0 +DOWNLOAD_TOTAL_TIMEOUT = 900.0 +DOWNLOAD_RETRIES = 3 +DOWNLOAD_CHUNK = 1 << 20 +EXTRACT_TOTAL_SIZE_LIMIT = 1 << 30 # 1 GiB uncompressed hard cap +EXTRACT_MEMBER_LIMIT = 100_000 +APT_TIMEOUT = 1800.0 +NPM_TIMEOUT = 900.0 +PROBE_TIMEOUT = 120.0 + +EXIT_OK = 0 +EXIT_INSTALL_FAILED = 1 +EXIT_USAGE = 2 +EXIT_PLATFORM = 3 +EXIT_LOCK = 4 + +SHA256_HEX_LENGTH = 64 +MAX_INTERACTIVE_ATTEMPTS = 3 + +LOCK_REQUIRED_FIELDS = ( + "tool_id", + "version", + "source_url", + "asset_url", + "sha256", + "archive_type", + "expected_member", + "executable_name", + "version_command", + "install_directory", + "architecture", + "license", +) +LOCK_ARCHIVE_TYPES = ("binary", "gz", "tar.gz", "tar.xz", "zip", "npm-lock") + +# Base acquisition packages, installed only when at least one standalone +# (payload/npm) tool is selected. The stdlib downloader/extractor needs no +# curl/jq/tar/gzip/unzip/xz-utils; TLS trust anchors are required. +BASE_ACQUISITION_PACKAGES = ("ca-certificates",) + + +class InstallerError(Exception): + """Deliberate installer failure with an operator-facing message.""" + + +class SelectionError(InstallerError): + """Menu selection could not be parsed.""" + + +class LockError(InstallerError): + """Lock file missing, unreadable, or violating the schema.""" + + +class PlatformError(InstallerError): + """Host does not satisfy the supported-platform contract.""" + + +class DownloadError(InstallerError): + """Verified download could not be completed.""" + + +class ExtractionError(InstallerError): + """Archive failed safety validation or expected-member checks.""" + + +# -------------------------------------------------------------------------- +# The 19-tool menu (fixed order, English) +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ToolSpec: + """One fixed menu entry of the 19-tool F4 toolchain.""" + + number: int + menu_name: str + display_name: str + kind: str # "apt" | "payload" | "npm" + executable_name: str + version_command: tuple[str, ...] + lock_id: str | None = None + apt_packages: tuple[str, ...] = () + runtime_deps: tuple[str, ...] = () + requires_java: bool = False + # Retried when the primary version_command fails. Debian 13's + # checkstyle (8.36.1, picocli CLI) rejects the contract's ``-version`` + # form but answers ``--version``. + version_fallback: tuple[str, ...] = () + + +TOOLS: tuple[ToolSpec, ...] = ( + ToolSpec(1, "Ruff", "Ruff", "payload", "ruff", ("ruff", "--version"), "ruff"), + ToolSpec(2, "Biome", "Biome", "payload", "biome", ("biome", "--version"), "biome"), + ToolSpec( + 3, + "markdownlint-cli2", + "markdownlint-cli2", + "npm", + "markdownlint-cli2", + ("markdownlint-cli2", "--version"), + "markdownlint-cli2", + runtime_deps=("nodejs", "npm"), + ), + ToolSpec( + 4, + "yamllint", + "yamllint", + "apt", + "yamllint", + ("yamllint", "--version"), + apt_packages=("yamllint",), + ), + ToolSpec( + 5, + "shellcheck", + "ShellCheck", + "apt", + "shellcheck", + ("shellcheck", "--version"), + apt_packages=("shellcheck",), + ), + ToolSpec(6, "Zig", "Zig", "payload", "zig", ("zig", "version"), "zig"), + ToolSpec( + 7, + "Hadolint", + "Hadolint", + "payload", + "hadolint", + ("hadolint", "--version"), + "hadolint", + ), + ToolSpec(8, "Taplo", "Taplo", "payload", "taplo", ("taplo", "--version"), "taplo"), + ToolSpec( + 9, + "actionlint", + "actionlint", + "payload", + "actionlint", + ("actionlint", "-version"), + "actionlint", + ), + ToolSpec( + 10, + "clang-tidy", + "clang-tidy", + "apt", + "clang-tidy", + ("clang-tidy", "--version"), + apt_packages=("clang-tidy",), + ), + ToolSpec( + 11, + "cppcheck", + "Cppcheck", + "apt", + "cppcheck", + ("cppcheck", "--version"), + apt_packages=("cppcheck",), + ), + ToolSpec( + 12, + "clang-format", + "clang-format", + "apt", + "clang-format", + ("clang-format", "--version"), + apt_packages=("clang-format",), + ), + ToolSpec( + 13, + "Checkstyle", + "Checkstyle", + "apt", + "checkstyle", + ("checkstyle", "-version"), + apt_packages=("checkstyle",), + version_fallback=("checkstyle", "--version"), + ), + ToolSpec( + 14, + "PMD", + "PMD", + "payload", + "pmd", + ("pmd", "--version"), + "pmd", + runtime_deps=("default-jre-headless",), + requires_java=True, + ), + ToolSpec( + 15, + "SpotBugs", + "SpotBugs", + "payload", + "spotbugs", + ("spotbugs", "-version"), + "spotbugs", + runtime_deps=("default-jre-headless",), + requires_java=True, + ), + ToolSpec( + 16, + "cargo-clippy", + "cargo-clippy", + "apt", + "cargo", + ("cargo", "clippy", "--version"), + apt_packages=("cargo", "rust-clippy"), + ), + ToolSpec( + 17, + "golangci-lint", + "golangci-lint", + "payload", + "golangci-lint", + ("golangci-lint", "--version"), + "golangci-lint", + runtime_deps=("golang-go",), + ), + ToolSpec( + 18, + "SQLFluff", + "SQLFluff", + "apt", + "sqlfluff", + ("sqlfluff", "--version"), + apt_packages=("sqlfluff",), + ), + ToolSpec( + 19, + "TFLint", + "TFLint", + "payload", + "tflint", + ("tflint", "--version"), + "tflint", + ), +) + +TOOLS_BY_NUMBER: dict[int, ToolSpec] = {tool.number: tool for tool in TOOLS} +ALL_TOOL_NUMBERS: frozenset[int] = frozenset(TOOLS_BY_NUMBER) + + +# -------------------------------------------------------------------------- +# Logging +# -------------------------------------------------------------------------- + + +class InstallerLog: + """Root-owned timestamped file log that also echoes to stdout.""" + + def __init__(self, path: Path | None, echo: bool = True) -> None: + """Open (append) the log file, creating its directory as needed.""" + self.path = path + self.echo = echo + self._handle = None + if path is not None: + path.parent.mkdir(parents=True, exist_ok=True) + os.chmod(path.parent, DIR_MODE) + self._handle = path.open("a", encoding="utf-8") + os.chmod(path, 0o640) + + def log(self, message: str, *, echo: bool | None = None) -> None: + """Write one timestamped line to the log, echoing per configuration.""" + timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + if self._handle is not None: + self._handle.write(f"{timestamp} {message}\n") + self._handle.flush() + if echo if echo is not None else self.echo: + print(message, flush=True) + + def detail(self, message: str) -> None: + """Write to the log file only (no stdout echo).""" + self.log(message, echo=False) + + def close(self) -> None: + """Close the underlying file handle.""" + if self._handle is not None: + self._handle.close() + self._handle = None + + +# -------------------------------------------------------------------------- +# Shared secure command runner +# -------------------------------------------------------------------------- + + +def command_environment(extra: dict[str, str] | None = None) -> dict[str, str]: + """Minimal controlled subprocess environment (never inherits secrets).""" + env = { + "PATH": PROBE_PATH, + "HOME": "/root", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "DEBIAN_FRONTEND": "noninteractive", + } + if extra: + env.update(extra) + return env + + +def run_command( + argv: list[str] | tuple[str, ...], + log: InstallerLog, + *, + timeout: float, + env: dict[str, str] | None = None, + cwd: Path | None = None, + check: bool = True, + echo: bool = True, +) -> subprocess.CompletedProcess[str]: + """Run one argv-array command with logging, timeout, and rc checking. + + Never uses ``shell=True`` and never interpolates user-controlled text + into a shell. Output is recorded in the installer log; failures raise + :class:`InstallerError` with clear diagnostics. + """ + if not argv or not all(isinstance(item, str) for item in argv): + raise InstallerError(f"invalid command argv: {argv!r}") + printable = shlex.join(argv) + log.log(f" -> Executing: {printable}", echo=echo) + try: + result = subprocess.run( + list(argv), + capture_output=True, + text=True, + timeout=timeout, + env=env if env is not None else command_environment(), + cwd=str(cwd) if cwd is not None else None, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise InstallerError( + f"command timed out after {timeout:.0f}s: {printable}" + ) from exc + except FileNotFoundError as exc: + raise InstallerError(f"executable not found: {argv[0]}") from exc + if result.stdout: + log.detail(f" [stdout] {result.stdout.strip()[-8000:]}") + if result.stderr: + log.detail(f" [stderr] {result.stderr.strip()[-8000:]}") + if check and result.returncode != 0: + stderr_tail = (result.stderr or result.stdout or "").strip()[-2000:] + raise InstallerError( + f"command failed (exit {result.returncode}): {printable}\n{stderr_tail}" + ) + return result + + +# -------------------------------------------------------------------------- +# Platform validation +# -------------------------------------------------------------------------- + + +def parse_os_release(text: str) -> dict[str, str]: + """Parse ``/etc/os-release`` key=value content.""" + values: dict[str, str] = {} + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, raw = line.partition("=") + values[key.strip()] = raw.strip().strip('"').strip("'") + return values + + +def platform_errors( + *, + euid: int, + sys_platform: str, + os_release: dict[str, str], + dpkg_arch: str | None, +) -> list[str]: + """Return every supported-platform violation (empty when supported).""" + errors: list[str] = [] + if euid != 0: + errors.append("root privileges are required (effective UID 0); run with sudo") + if not sys_platform.startswith("linux"): + errors.append(f"unsupported platform {sys_platform!r}; Linux is required") + if os_release.get("ID") != REQUIRED_OS_ID: + errors.append( + f"unsupported distribution {os_release.get('ID')!r}; Debian is required" + ) + version_id = os_release.get("VERSION_ID", "") + major = version_id.split(".", 1)[0] + if major != str(REQUIRED_DEBIAN_MAJOR): + errors.append( + f"unsupported Debian version {version_id!r}; " + f"Debian {REQUIRED_DEBIAN_MAJOR} (trixie) is required" + ) + if dpkg_arch != REQUIRED_DPKG_ARCH: + errors.append( + f"unsupported architecture {dpkg_arch!r}; {REQUIRED_DPKG_ARCH} is required" + ) + return errors + + +def detect_dpkg_architecture() -> str | None: + """Return ``dpkg --print-architecture`` output, or None.""" + dpkg = shutil.which("dpkg") + if dpkg is None: + return None + try: + result = subprocess.run( + [dpkg, "--print-architecture"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def validate_platform() -> dict[str, str]: + """Enforce the platform contract; return os-release data on success.""" + os_release_path = Path("/etc/os-release") + os_release: dict[str, str] = {} + if os_release_path.is_file(): + os_release = parse_os_release( + os_release_path.read_text(encoding="utf-8", errors="replace") + ) + errors = platform_errors( + euid=os.geteuid(), + sys_platform=sys.platform, + os_release=os_release, + dpkg_arch=detect_dpkg_architecture(), + ) + if errors: + for error in errors: + print(f"[ERROR] {error}", file=sys.stderr) + raise PlatformError( + "this installer supports only Debian " + f"{REQUIRED_DEBIAN_MAJOR} (trixie) on amd64, run as root" + ) + return os_release + + +# -------------------------------------------------------------------------- +# Menu and selection parsing +# -------------------------------------------------------------------------- + + +def render_menu() -> str: + """Render the fixed 19-entry English menu.""" + lines = [ + "=" * 45, + " ECLI LINTER INSTALLER", + "=" * 45, + "[ A ] - Install All Linters", + "", + ] + for tool in TOOLS: + lines.append(f"{tool.number}.".ljust(4) + tool.menu_name) + lines.append("") + lines.append( + "Enter 'A' to install all, or a comma-separated list of numbers (e.g., 1,4,5):" + ) + return "\n".join(lines) + + +def parse_selection(raw: str) -> frozenset[int]: + """Parse a menu selection; raise :class:`SelectionError` when malformed. + + Accepts ``A``/``a`` for all tools, single numbers, comma-separated + numbers, whitespace around values, and duplicate values (deduplicated). + Every malformed token is rejected explicitly, never silently ignored. + """ + text = raw.strip() + if not text: + raise SelectionError("empty selection; enter 'A' or numbers 1-19") + if text.lower() == "a": + return ALL_TOOL_NUMBERS + selected: set[int] = set() + for raw_token in text.split(","): + token = raw_token.strip() + if not token: + raise SelectionError( + f"invalid selection {raw!r}: empty entry between commas" + ) + if not token.isdigit(): + raise SelectionError( + f"invalid selection token {token!r}: expected 'A' or a " + "number between 1 and 19" + ) + number = int(token) + if number not in ALL_TOOL_NUMBERS: + raise SelectionError( + f"invalid selection token {token!r}: tool numbers run from 1 to 19" + ) + selected.add(number) + return frozenset(selected) + + +def prompt_selection(input_fn: Any = input) -> frozenset[int]: + """Interactively prompt for a selection with clear rejection messages.""" + print(render_menu()) + for attempt in range(1, MAX_INTERACTIVE_ATTEMPTS + 1): + try: + raw = input_fn(">> ") + except EOFError as exc: + raise SelectionError("no selection provided (end of input)") from exc + try: + return parse_selection(raw) + except SelectionError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + if attempt < MAX_INTERACTIVE_ATTEMPTS: + print("Please try again.", file=sys.stderr) + raise SelectionError( + f"no valid selection after {MAX_INTERACTIVE_ATTEMPTS} attempts" + ) + + +# -------------------------------------------------------------------------- +# Lock file loading and validation +# -------------------------------------------------------------------------- + + +def default_lock_path() -> Path: + """Locate the committed lock file relative to this script.""" + script_dir = Path(__file__).resolve().parent + candidates = ( + script_dir.parent / "packaging" / "debian" / "ecli-linter-lock.json", + script_dir / "ecli-linter-lock.json", + ) + for candidate in candidates: + if candidate.is_file(): + return candidate + return candidates[0] + + +def default_npm_lock_dir() -> Path: + """Locate the committed markdownlint-cli2 npm lock directory.""" + script_dir = Path(__file__).resolve().parent + candidates = ( + script_dir.parent / "packaging" / "debian" / "markdownlint-cli2", + script_dir / "markdownlint-cli2", + ) + for candidate in candidates: + if candidate.is_dir(): + return candidate + return candidates[0] + + +# Every character allowed in a single trusted path segment: no "/", no +# NUL, no shell metacharacters (`$ ` \" \` ; | & < > ( ) { } [ ] * ? ~ ! # +# whitespace), no control characters. This charset is deliberately small +# enough that any string built from it is always safe to embed inside a +# double-quoted POSIX shell string with no further escaping. +_SAFE_SEGMENT_RE = re.compile(r"[A-Za-z0-9._-]+") + + +def is_safe_basename(name: str) -> bool: + r"""True when ``name`` is a plain, single-segment, shell-safe basename. + + Rejects empty strings, ``.``/``..``, any ``/`` or ``\\``, NUL bytes, + and any character outside the trusted segment charset. + """ + if not isinstance(name, str) or not name: + return False + if "/" in name or "\\" in name or "\x00" in name: + return False + if name in (".", ".."): + return False + return bool(_SAFE_SEGMENT_RE.fullmatch(name)) + + +def is_safe_relative_member(member: str) -> bool: + """True when ``member`` is a safe ``/``-separated relative path. + + Every individual path segment must independently satisfy + :func:`is_safe_basename`; this rejects traversal (``..``), absolute + paths, empty segments (``//``), NUL bytes, backslashes, and shell + metacharacters anywhere in the path -- while still allowing the + legitimate multi-segment archive members this installer extracts + (for example ``node_modules/.bin/markdownlint-cli2``). + """ + if not isinstance(member, str) or not member: + return False + if "\x00" in member or "\\" in member: + return False + if member.startswith("/") or member.startswith("~"): + return False + return all(is_safe_basename(part) for part in member.split("/")) + + +def is_strictly_within_payload(candidate: str) -> bool: + """True when ``candidate`` resolves strictly below :data:`PAYLOAD_ROOT`. + + Uses ``Path.resolve()`` (lexically normalizing ``..``/``.`` and + resolving symlinks for whatever prefix already exists on disk, + without requiring the target to exist yet) so a value like + ``/opt/ecli/payload-evil`` -- a string-prefix match but not an actual + descendant -- or ``/opt/ecli/payload/../../etc`` is correctly + rejected. Equal to the payload root itself is also rejected: entries + must live *below* it, never install directly onto it. + """ + if not isinstance(candidate, str) or not candidate: + return False + path = Path(candidate) + if not path.is_absolute(): + return False + try: + resolved = path.resolve() + root_resolved = PAYLOAD_ROOT.resolve() + except (OSError, RuntimeError): + return False + if resolved == root_resolved: + return False + try: + resolved.relative_to(root_resolved) + except ValueError: + return False + return True + + +def has_mutable_reference(url: str) -> bool: + """True when ``url`` points at a mutable/unpinned upstream reference. + + Catches GitHub ``/releases/latest/...`` download links and any + Zig ``master`` development-channel reference; production installation + must always use a specific pinned version. + """ + lowered = url.lower() + return "/latest" in lowered or "master" in lowered + + +def validate_lock_entry(tool_id: str, entry: Any) -> list[str]: + """Return schema violations for one lock entry.""" + errors: list[str] = [] + if not isinstance(entry, dict): + return [f"{tool_id}: lock entry must be an object"] + for fieldname in LOCK_REQUIRED_FIELDS: + if fieldname not in entry: + errors.append(f"{tool_id}: missing required field {fieldname!r}") + if errors: + return errors + if entry["tool_id"] != tool_id: + errors.append(f"{tool_id}: tool_id mismatch {entry['tool_id']!r}") + for url_field in ("source_url", "asset_url"): + url = entry[url_field] + if not isinstance(url, str) or not url.startswith("https://"): + errors.append(f"{tool_id}: {url_field} must be an https:// URL") + elif has_mutable_reference(url): + errors.append( + f"{tool_id}: {url_field} references a mutable/unpinned " + f"target ('latest' or 'master'): {url!r}" + ) + sha256 = entry["sha256"] + if ( + not isinstance(sha256, str) + or len(sha256) != SHA256_HEX_LENGTH + or not re.fullmatch(r"[0-9a-f]{64}", sha256) + ): + errors.append(f"{tool_id}: sha256 must be 64 lowercase hex characters") + if entry["archive_type"] not in LOCK_ARCHIVE_TYPES: + errors.append( + f"{tool_id}: archive_type {entry['archive_type']!r} not in " + f"{LOCK_ARCHIVE_TYPES}" + ) + if entry["architecture"] != REQUIRED_DPKG_ARCH: + errors.append(f"{tool_id}: architecture must be {REQUIRED_DPKG_ARCH!r}") + member = entry["expected_member"] + if not is_safe_relative_member(member): + errors.append( + f"{tool_id}: expected_member must be a safe relative path built " + f"only from [A-Za-z0-9._-] segments, no '..', no NUL, no shell " + f"metacharacters: {member!r}" + ) + executable_name = entry["executable_name"] + if not is_safe_basename(executable_name): + errors.append( + f"{tool_id}: executable_name must be a plain shell-safe " + f"basename (no '/', no '..', no shell metacharacters): " + f"{executable_name!r}" + ) + if "master" in str(entry["version"]).lower(): + errors.append(f"{tool_id}: development 'master' versions are forbidden") + version_command = entry["version_command"] + if ( + not isinstance(version_command, list) + or not version_command + or not all(isinstance(item, str) for item in version_command) + ): + errors.append(f"{tool_id}: version_command must be a list of strings") + install_directory = entry["install_directory"] + if not is_strictly_within_payload(install_directory): + errors.append( + f"{tool_id}: install_directory must resolve strictly below " + f"{PAYLOAD_ROOT} (not equal to it, no traversal, no " + f"string-prefix-only lookalikes): {install_directory!r}" + ) + max_bytes = entry.get("max_download_bytes") + if not isinstance(max_bytes, int) or max_bytes <= 0: + errors.append(f"{tool_id}: max_download_bytes must be a positive integer") + return errors + + +def load_lock(path: Path) -> dict[str, dict[str, Any]]: + """Load and validate the production lock; raise :class:`LockError`.""" + if not path.is_file(): + raise LockError(f"lock file not found: {path}") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise LockError(f"lock file unreadable: {path}: {exc}") from exc + tools = data.get("tools") + if not isinstance(tools, dict): + raise LockError(f"lock file {path} has no 'tools' object") + required_ids = {tool.lock_id for tool in TOOLS if tool.kind in ("payload", "npm")} + errors: list[str] = [] + missing = sorted(required_ids - set(tools)) + if missing: + errors.append(f"missing lock entries: {', '.join(missing)}") + for tool_id in sorted(required_ids & set(tools)): + errors.extend(validate_lock_entry(tool_id, tools[tool_id])) + if errors: + raise LockError( + f"lock file {path} failed validation:\n " + "\n ".join(errors) + ) + return tools + + +def validate_npm_lock_dir(path: Path, locked_version: str) -> None: + """Require committed package.json + package-lock.json pinning the lock.""" + package_json = path / "package.json" + package_lock = path / "package-lock.json" + for required in (package_json, package_lock): + if not required.is_file(): + raise LockError(f"required npm lock input not found: {required}") + try: + manifest = json.loads(package_json.read_text(encoding="utf-8")) + lockfile = json.loads(package_lock.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise LockError(f"invalid npm lock JSON under {path}: {exc}") from exc + pinned = manifest.get("dependencies", {}).get("markdownlint-cli2") + if pinned != locked_version: + raise LockError( + f"package.json pins markdownlint-cli2 {pinned!r}, lock file " + f"requires {locked_version!r}" + ) + locked_pkg = lockfile.get("packages", {}).get("node_modules/markdownlint-cli2", {}) + if locked_pkg.get("version") != locked_version: + raise LockError( + f"package-lock.json does not lock markdownlint-cli2 {locked_version!r}" + ) + + +# -------------------------------------------------------------------------- +# APT stage +# -------------------------------------------------------------------------- + + +def resolve_apt_packages(selected: frozenset[int]) -> tuple[str, ...]: + """Resolve the complete APT package set for one selection.""" + packages: set[str] = set() + standalone_selected = False + for number in selected: + tool = TOOLS_BY_NUMBER[number] + packages.update(tool.apt_packages) + packages.update(tool.runtime_deps) + if tool.kind in ("payload", "npm"): + standalone_selected = True + if standalone_selected: + packages.update(BASE_ACQUISITION_PACKAGES) + return tuple(sorted(packages)) + + +def apt_install(packages: tuple[str, ...], log: InstallerLog) -> None: + """Install the resolved package set in one APT transaction.""" + if not packages: + log.log(" No APT packages required for this selection.") + return + log.log(f" APT packages: {', '.join(packages)}") + run_command(["apt-get", "update"], log, timeout=APT_TIMEOUT) + run_command( + ["apt-get", "install", "--yes", "--no-install-recommends", *packages], + log, + timeout=APT_TIMEOUT, + ) + + +# -------------------------------------------------------------------------- +# Payload directories and permissions +# -------------------------------------------------------------------------- + + +def ensure_directory(path: Path) -> None: + """Create ``path`` (and parents) with 0755 root:root, by type.""" + path.mkdir(parents=True, exist_ok=True) + current = path + while True: + os.chmod(current, DIR_MODE) + if os.geteuid() == 0: + os.chown(current, 0, 0) + if current in (PAYLOAD_ROOT.parent, current.parent): + break + if not str(current).startswith(str(PAYLOAD_ROOT.parent)): + break + current = current.parent + + +def ensure_payload_layout() -> None: + """Create the canonical payload directory tree.""" + for directory in (PAYLOAD_BIN, PAYLOAD_PACKAGES, PAYLOAD_STATE, PAYLOAD_CACHE): + ensure_directory(directory) + + +def set_mode_by_type(path: Path, *, executable: bool) -> None: + """Apply the by-type permission policy to one regular file.""" + os.chmod(path, EXEC_MODE if executable else FILE_MODE) + if os.geteuid() == 0: + os.chown(path, 0, 0) + + +def normalize_tree_ownership(root: Path) -> None: + """Apply the by-type ownership/permission policy to a whole tree. + + Required after ``npm ci``: npm running as root preserves the uid/gid + recorded in upstream tarball headers, leaving node_modules files owned + by arbitrary users. Everything staged into the payload must be + root:root with 0755 directories and 0644/0755 files; symlinks are + re-owned without following their targets. + """ + as_root = os.geteuid() == 0 + for dirpath, dirnames, filenames in os.walk(root): + base = Path(dirpath) + os.chmod(base, DIR_MODE) + if as_root: + os.chown(base, 0, 0) + for name in dirnames: + path = base / name + if path.is_symlink() and as_root: + os.lchown(path, 0, 0) + for name in filenames: + path = base / name + if path.is_symlink(): + if as_root: + os.lchown(path, 0, 0) + continue + mode = stat.S_IMODE(path.stat().st_mode) + set_mode_by_type(path, executable=bool(mode & 0o111)) + + +# -------------------------------------------------------------------------- +# Managed installation state +# -------------------------------------------------------------------------- + + +def read_state(path: Path | None = None) -> dict[str, dict[str, Any]]: + """Read managed state; tolerate absent or corrupt state files.""" + path = path if path is not None else STATE_FILE + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def write_state(state: dict[str, dict[str, Any]], path: Path | None = None) -> None: + """Atomically persist managed state (temp file + rename).""" + path = path if path is not None else STATE_FILE + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temp_name = tempfile.mkstemp( + prefix=".installed-tools.", suffix=".tmp", dir=str(path.parent) + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(state, handle, indent=2, sort_keys=True) + handle.write("\n") + os.chmod(temp_name, FILE_MODE) + os.replace(temp_name, path) + except BaseException: + with contextlib.suppress(FileNotFoundError): + os.unlink(temp_name) + raise + + +def sha256_of_file(path: Path) -> str: + """Return the SHA-256 hex digest of a file.""" + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(DOWNLOAD_CHUNK), b""): + digest.update(chunk) + return digest.hexdigest() + + +# -------------------------------------------------------------------------- +# Verified HTTPS download +# -------------------------------------------------------------------------- + + +class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler): + """Redirect handler that refuses any redirect to a non-HTTPS URL.""" + + def redirect_request( + self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str + ) -> Any: + if urllib.parse.urlsplit(newurl).scheme != "https": + raise DownloadError( + f"refusing insecure redirect to non-HTTPS URL: {newurl}" + ) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def download_verified( + url: str, + destination: Path, + expected_sha256: str, + max_bytes: int, + log: InstallerLog, + *, + retries: int = DOWNLOAD_RETRIES, +) -> Path: + """Download ``url`` to ``destination`` with full verification. + + HTTPS-only (including redirects), bounded size, bounded retries, + ``.part`` staging, exact SHA-256 verification, atomic rename, and + partial-file cleanup on every failure path. + """ + if urllib.parse.urlsplit(url).scheme != "https": + raise DownloadError(f"refusing non-HTTPS download URL: {url}") + if destination.is_file() and sha256_of_file(destination) == expected_sha256: + log.log(f" Reusing verified cached download: {destination.name}") + return destination + destination.parent.mkdir(parents=True, exist_ok=True) + part = destination.with_name(destination.name + ".part") + opener = urllib.request.build_opener(_HttpsOnlyRedirectHandler()) + last_error: Exception | None = None + for attempt in range(1, retries + 1): + digest = hashlib.sha256() + received = 0 + try: + log.log(f" Downloading ({attempt}/{retries}): {url}") + request = urllib.request.Request( # noqa: S310 - scheme enforced + url, headers={"User-Agent": "ecli-linter-installer/0.2.4"} + ) + started = datetime.now(UTC) + with ( + opener.open(request, timeout=DOWNLOAD_CONNECT_TIMEOUT) as response, + part.open("wb") as sink, + ): + status = getattr(response, "status", 200) + if status != 200: + raise DownloadError(f"HTTP error {status} for {url}") + while True: + elapsed = (datetime.now(UTC) - started).total_seconds() + if elapsed > DOWNLOAD_TOTAL_TIMEOUT: + raise DownloadError( + f"transfer exceeded {DOWNLOAD_TOTAL_TIMEOUT:.0f}s: {url}" + ) + chunk = response.read(DOWNLOAD_CHUNK) + if not chunk: + break + received += len(chunk) + if received > max_bytes: + raise DownloadError( + f"download exceeded the maximum expected size " + f"({max_bytes} bytes): {url}" + ) + digest.update(chunk) + sink.write(chunk) + actual = digest.hexdigest() + if actual != expected_sha256: + part.unlink(missing_ok=True) + raise DownloadError( + f"SHA-256 mismatch for {url}\n" + f" expected: {expected_sha256}\n" + f" actual: {actual}" + ) + log.log(f" Verified SHA-256 ({received} bytes): {expected_sha256}") + os.replace(part, destination) + set_mode_by_type(destination, executable=False) + return destination + except DownloadError as exc: + part.unlink(missing_ok=True) + if "SHA-256 mismatch" in str(exc) or "maximum expected" in str(exc): + raise + last_error = exc + log.log(f" [WARN] download attempt {attempt} failed: {exc}") + except (urllib.error.URLError, OSError) as exc: + part.unlink(missing_ok=True) + last_error = exc + log.log(f" [WARN] download attempt {attempt} failed: {exc}") + raise DownloadError(f"download failed after {retries} attempts: {last_error}") + + +# -------------------------------------------------------------------------- +# Safe archive extraction +# -------------------------------------------------------------------------- + + +def _validated_target(destination: Path, member_name: str) -> Path: + """Validate one member path and return its extraction target.""" + if not member_name or member_name.startswith("/") or "\\" in member_name: + raise ExtractionError(f"unsafe archive member path: {member_name!r}") + parts = Path(member_name).parts + if any(part in ("..", "") for part in parts) or Path(member_name).is_absolute(): + raise ExtractionError(f"archive member escapes destination: {member_name!r}") + target = destination / member_name + resolved_destination = destination.resolve() + if not target.resolve().is_relative_to(resolved_destination): + raise ExtractionError(f"archive member escapes destination: {member_name!r}") + return target + + +def _validate_link( + destination: Path, member_name: str, link_target: str, *, hardlink: bool +) -> None: + """Reject symlinks/hardlinks whose targets escape the destination.""" + kind = "hardlink" if hardlink else "symlink" + if not link_target: + raise ExtractionError(f"{kind} member {member_name!r} has empty target") + if hardlink or not os.path.isabs(link_target): + base = os.path.dirname(member_name) if not hardlink else "" + joined = os.path.normpath(os.path.join(base, link_target)) + if joined.startswith("..") or os.path.isabs(joined): + raise ExtractionError( + f"{kind} member {member_name!r} escapes destination " + f"(target {link_target!r})" + ) + resolved = (destination / joined).resolve() + if not resolved.is_relative_to(destination.resolve()): + raise ExtractionError( + f"{kind} member {member_name!r} escapes destination " + f"(target {link_target!r})" + ) + else: + raise ExtractionError( + f"{kind} member {member_name!r} uses absolute target {link_target!r}" + ) + + +def safe_extract_tar(archive: Path, destination: Path) -> None: + """Safely extract a tar archive with full member validation.""" + destination.mkdir(parents=True, exist_ok=True) + total = 0 + count = 0 + seen: set[str] = set() + with tarfile.open(archive) as handle: + for member in handle: + count += 1 + if count > EXTRACT_MEMBER_LIMIT: + raise ExtractionError(f"archive has too many members: {archive}") + normalized = os.path.normpath(member.name) + if normalized in seen: + raise ExtractionError( + f"archive member {member.name!r} is duplicated; a repeated " + "path could silently overwrite an already-validated entry" + ) + seen.add(normalized) + target = _validated_target(destination, member.name) + if member.mode & (stat.S_ISUID | stat.S_ISGID): + raise ExtractionError( + f"archive member {member.name!r} carries setuid/setgid bits" + ) + if member.isdev() or member.ischr() or member.isblk() or member.isfifo(): + raise ExtractionError( + f"archive member {member.name!r} is a device or FIFO" + ) + if member.issym(): + _validate_link( + destination, member.name, member.linkname, hardlink=False + ) + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink() or target.exists(): + target.unlink() + os.symlink(member.linkname, target) + continue + if member.islnk(): + _validate_link(destination, member.name, member.linkname, hardlink=True) + source = destination / os.path.normpath(member.linkname) + if not source.is_file(): + raise ExtractionError( + f"hardlink member {member.name!r} references " + f"unextracted target {member.linkname!r}" + ) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + continue + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + os.chmod(target, DIR_MODE) + continue + if not member.isfile(): + raise ExtractionError( + f"archive member {member.name!r} has unsupported type" + ) + total += member.size + if total > EXTRACT_TOTAL_SIZE_LIMIT: + raise ExtractionError(f"archive expands beyond limit: {archive}") + source_obj = handle.extractfile(member) + if source_obj is None: + raise ExtractionError(f"cannot read archive member {member.name!r}") + target.parent.mkdir(parents=True, exist_ok=True) + with source_obj, target.open("wb") as sink: + shutil.copyfileobj(source_obj, sink, DOWNLOAD_CHUNK) + set_mode_by_type(target, executable=bool(member.mode & 0o111)) + + +def safe_extract_zip(archive: Path, destination: Path) -> None: + """Safely extract a zip archive with full member validation.""" + destination.mkdir(parents=True, exist_ok=True) + total = 0 + seen: set[str] = set() + with zipfile.ZipFile(archive) as handle: + infos = handle.infolist() + if len(infos) > EXTRACT_MEMBER_LIMIT: + raise ExtractionError(f"archive has too many members: {archive}") + for info in infos: + normalized = os.path.normpath(info.filename) + if normalized in seen: + raise ExtractionError( + f"archive member {info.filename!r} is duplicated; a " + "repeated path could silently overwrite an " + "already-validated entry" + ) + seen.add(normalized) + mode = info.external_attr >> 16 + target = _validated_target(destination, info.filename) + if mode & (stat.S_ISUID | stat.S_ISGID): + raise ExtractionError( + f"archive member {info.filename!r} carries setuid/setgid bits" + ) + if stat.S_ISBLK(mode) or stat.S_ISCHR(mode) or stat.S_ISFIFO(mode): + raise ExtractionError( + f"archive member {info.filename!r} is a device or FIFO" + ) + if stat.S_ISLNK(mode): + link_target = handle.read(info).decode("utf-8", "replace") + _validate_link(destination, info.filename, link_target, hardlink=False) + target.parent.mkdir(parents=True, exist_ok=True) + os.symlink(link_target, target) + continue + if info.is_dir(): + target.mkdir(parents=True, exist_ok=True) + os.chmod(target, DIR_MODE) + continue + total += info.file_size + if total > EXTRACT_TOTAL_SIZE_LIMIT: + raise ExtractionError(f"archive expands beyond limit: {archive}") + target.parent.mkdir(parents=True, exist_ok=True) + with handle.open(info) as source_obj, target.open("wb") as sink: + shutil.copyfileobj(source_obj, sink, DOWNLOAD_CHUNK) + set_mode_by_type(target, executable=bool(mode & 0o111)) + + +def extract_gz_single(archive: Path, destination_file: Path) -> None: + """Decompress a single-file ``.gz`` payload with a size cap.""" + destination_file.parent.mkdir(parents=True, exist_ok=True) + total = 0 + with gzip.open(archive, "rb") as source_obj, destination_file.open("wb") as sink: + while True: + chunk = source_obj.read(DOWNLOAD_CHUNK) + if not chunk: + break + total += len(chunk) + if total > EXTRACT_TOTAL_SIZE_LIMIT: + raise ExtractionError(f"gz payload expands beyond limit: {archive}") + sink.write(chunk) + + +_ELF_MAGIC = b"\x7fELF" +# ELF e_machine values (little-endian, offset 18-19 of the ELF header). +_EM_X86_64 = 0x3E +_ARCH_TO_EM_MACHINE = {"amd64": _EM_X86_64} + + +def verify_elf_architecture(path: Path, required_arch: str) -> None: + """Reject a staged executable whose ELF architecture doesn't match. + + Non-ELF files (POSIX shell launcher scripts such as PMD/SpotBugs's + ``bin/`` wrappers, which start with ``#!``) are not covered by this + check: their correctness is enforced by the mandatory post-install + version probe instead. Truncated or corrupt ELF headers are rejected + outright rather than silently skipped. + """ + expected_machine = _ARCH_TO_EM_MACHINE.get(required_arch) + if expected_machine is None: + return + with path.open("rb") as handle: + header = handle.read(20) + if header[:4] != _ELF_MAGIC: + return + if len(header) < 20: + raise ExtractionError(f"{path}: truncated ELF header") + e_machine = int.from_bytes(header[18:20], byteorder="little") + if e_machine != expected_machine: + raise ExtractionError( + f"{path}: ELF e_machine {e_machine:#06x} does not match the " + f"required architecture {required_arch!r} " + f"(expected {expected_machine:#06x})" + ) + + +def stage_archive(entry: dict[str, Any], archive: Path, staging: Path) -> Path: + """Extract one verified archive into ``staging`` and locate the member. + + Requires exactly the locked ``expected_member`` (no wildcards, no + ambiguity). Returns the absolute staged path of the expected member. + """ + archive_type = entry["archive_type"] + expected_member = entry["expected_member"] + if archive_type == "binary": + staged = staging / expected_member + staging.mkdir(parents=True, exist_ok=True) + shutil.copyfile(archive, staged) + elif archive_type == "gz": + staged = staging / expected_member + extract_gz_single(archive, staged) + elif archive_type in ("tar.gz", "tar.xz"): + safe_extract_tar(archive, staging) + staged = staging / expected_member + elif archive_type == "zip": + safe_extract_zip(archive, staging) + staged = staging / expected_member + else: + raise ExtractionError(f"unsupported archive_type {archive_type!r}") + if not staged.is_file(): + raise ExtractionError( + f"expected archive member {expected_member!r} not found in {archive.name}" + ) + verify_elf_architecture(staged, entry["architecture"]) + set_mode_by_type(staged, executable=True) + return staged + + +# -------------------------------------------------------------------------- +# Managed install / upgrade primitives +# -------------------------------------------------------------------------- + + +def guard_unmanaged_target( + target: Path, tool_id: str, state: dict[str, dict[str, Any]] +) -> None: + """Refuse to overwrite files this installer does not manage.""" + if (target.exists() or target.is_symlink()) and tool_id not in state: + raise InstallerError( + f"refusing to overwrite unmanaged file {target}; remove it " + "manually if it should be managed by this installer" + ) + + +def atomic_replace_file(staged: Path, target: Path) -> None: + """Atomically move a staged regular file onto its final path.""" + temp = target.with_name(f".{target.name}.new-{os.getpid()}") + shutil.move(str(staged), str(temp)) + set_mode_by_type(temp, executable=True) + os.replace(temp, target) + + +def atomic_replace_symlink(link_target: str, link_path: Path) -> None: + """Atomically (re)create a deterministic executable symlink.""" + temp = link_path.with_name(f".{link_path.name}.new-{os.getpid()}") + if temp.is_symlink() or temp.exists(): + temp.unlink() + os.symlink(link_target, temp) + os.replace(temp, link_path) + + +# Characters that would be dangerous if interpolated, unescaped, inside a +# double-quoted POSIX shell string: double-quote (ends the string early), +# backtick and dollar-sign (command/parameter substitution), backslash +# (escape reinterpretation), and any control character including NUL/CR/LF. +_WRAPPER_UNSAFE_CHARS = frozenset('"`$\\') + + +def wrapper_script_content(target: Path) -> str: + """Deterministic exec-wrapper for versioned tree-tool entry points. + + A plain symlink is not safe for every tree tool: PMD's ``bin/pmd`` + resolves its distribution home from an unresolved ``$0``, so invoking + it through a symlink in ``payload/bin`` fails (`can't cd to + ../packages/pmd//bin/..`). An exec wrapper keeps ``$0`` at + the real script for every tool. + + ``target`` is never interpolated unescaped: every path component that + reaches this function has already been validated against + :func:`is_safe_basename` / :func:`is_strictly_within_payload` by the + lock loader, and the final concrete string is independently + re-checked here immediately before it is embedded in generated shell + text, so a validation gap anywhere upstream can never turn into shell + injection in the wrapper. + """ + if not target.is_absolute(): + raise InstallerError(f"wrapper target must be an absolute path: {target}") + target_str = str(target) + for character in target_str: + if character in _WRAPPER_UNSAFE_CHARS or ord(character) < 0x20: + raise InstallerError( + "wrapper target cannot be represented safely in a " + f"double-quoted shell string (unsafe character {character!r}): " + f"{target_str!r}" + ) + return ( + "#!/bin/sh\n" + "# Managed by ECLI: scripts/install_ecli_linters.py. Do not edit.\n" + f'exec "{target_str}" "$@"\n' + ) + + +def atomic_replace_wrapper(content: str, link_path: Path) -> None: + """Atomically (re)write a deterministic executable wrapper script.""" + temp = link_path.with_name(f".{link_path.name}.new-{os.getpid()}") + temp.write_text(content, encoding="utf-8") + set_mode_by_type(temp, executable=True) + os.replace(temp, link_path) + + +def atomic_replace_tree(staged_root: Path, final_root: Path) -> None: + """Atomically swap a staged directory tree into its final location. + + The previous tree is retained until the new one is in place, then + removed. Requires ``staged_root`` on the same filesystem. + """ + final_root.parent.mkdir(parents=True, exist_ok=True) + os.chmod(final_root.parent, DIR_MODE) + backup = final_root.with_name(f".{final_root.name}.old-{os.getpid()}") + if final_root.exists(): + os.rename(final_root, backup) + try: + os.rename(staged_root, final_root) + except OSError: + if backup.exists(): + os.rename(backup, final_root) + raise + if backup.exists(): + shutil.rmtree(backup, ignore_errors=True) + + +def probe_staged_executable( + executable: Path, version_args: tuple[str, ...], log: InstallerLog +) -> str: + """Run a staged executable's version probe before promoting it.""" + result = run_command( + [str(executable), *version_args], + log, + timeout=PROBE_TIMEOUT, + echo=False, + ) + output = (result.stdout or "") + (result.stderr or "") + if not output.strip(): + raise InstallerError( + f"staged executable produced no version output: {executable}" + ) + return output.strip() + + +@dataclass +class ToolResult: + """Final per-tool outcome for the report.""" + + tool: ToolSpec + status: str # "OK" | "SKIPPED" | "FAILED" + detail: str = "" + version: str = "" + path: str = "" + stage: str = "" + + +class PayloadInstaller: + """Installs the lock-pinned standalone tools into the payload tree.""" + + def __init__( + self, + lock: dict[str, dict[str, Any]], + state: dict[str, dict[str, Any]], + log: InstallerLog, + npm_lock_dir: Path, + ) -> None: + """Bind the lock, managed state, log, and npm lock inputs.""" + self.lock = lock + self.state = state + self.log = log + self.npm_lock_dir = npm_lock_dir + self._staging_dirs: list[Path] = [] + + def cleanup(self) -> None: + """Remove temporary staging directories.""" + for staging in self._staging_dirs: + shutil.rmtree(staging, ignore_errors=True) + self._staging_dirs = [] + + def _new_staging(self) -> Path: + staging = Path(tempfile.mkdtemp(prefix="stage-", dir=str(PAYLOAD_CACHE))) + os.chmod(staging, 0o700) + self._staging_dirs.append(staging) + return staging + + # -- idempotency ------------------------------------------------------ + + def _installed_and_current(self, tool: ToolSpec) -> bool: + """True when the exact locked version is installed and verified.""" + entry = self.lock[tool.lock_id or ""] + record = self.state.get(tool.lock_id or "") + if not record or record.get("version") != entry["version"]: + return False + executable = PAYLOAD_BIN / entry["executable_name"] + if not executable.exists(): + return False + installed_sha = record.get("installed_sha256") + if installed_sha and executable.is_file() and not executable.is_symlink(): + if sha256_of_file(executable) != installed_sha: + self.log.log( + f" Managed binary {executable} does not match recorded " + "checksum; reinstalling." + ) + return False + try: + output = probe_staged_executable( + executable, tuple(entry["version_command"][1:]), self.log + ) + except InstallerError: + self.log.log( + f" Managed executable {executable} failed its version " + "probe; reinstalling." + ) + return False + if str(entry["version"]) not in output: + return False + self.log.log( + f" {tool.display_name} {entry['version']} already installed " + "and verified; skipping reinstall." + ) + return True + + # -- installers ------------------------------------------------------- + + def install(self, tool: ToolSpec) -> None: + """Install one payload/npm tool from the lock (idempotent).""" + if tool.kind == "npm": + self._install_npm(tool) + return + entry = self.lock[tool.lock_id or ""] + if self._installed_and_current(tool): + return + guard_unmanaged_target( + PAYLOAD_BIN / entry["executable_name"], + tool.lock_id or "", + self.state, + ) + archive_name = urllib.parse.urlsplit(entry["asset_url"]).path.rsplit("/", 1)[-1] + archive = download_verified( + entry["asset_url"], + PAYLOAD_CACHE / f"{entry['tool_id']}-{entry['version']}-{archive_name}", + entry["sha256"], + int(entry["max_download_bytes"]), + self.log, + ) + staging = self._new_staging() + staged_member = stage_archive(entry, archive, staging) + install_directory = Path(entry["install_directory"]) + if install_directory == PAYLOAD_BIN: + self._promote_single_binary(tool, entry, staged_member) + else: + self._promote_tree(tool, entry, staged_member, install_directory) + + def _promote_single_binary( + self, tool: ToolSpec, entry: dict[str, Any], staged_member: Path + ) -> None: + """Verify then atomically promote a single-file executable.""" + probe_staged_executable( + staged_member, tuple(entry["version_command"][1:]), self.log + ) + target = PAYLOAD_BIN / entry["executable_name"] + installed_sha = sha256_of_file(staged_member) + atomic_replace_file(staged_member, target) + self._record(tool, entry, target, installed_sha256=installed_sha) + + def _promote_tree( + self, + tool: ToolSpec, + entry: dict[str, Any], + staged_member: Path, + install_directory: Path, + ) -> None: + """Verify then atomically promote a versioned distribution tree.""" + member_relative = Path(entry["expected_member"]) + staged_root = staged_member.parents[len(member_relative.parts) - 2] + probe_staged_executable( + staged_member, tuple(entry["version_command"][1:]), self.log + ) + # Versioned trees live under packages//: that + # namespace is owned by this installer, so a stale tree there (for + # example after a lost state file) is replaced atomically rather + # than treated as an unmanaged foreign file. + atomic_replace_tree(staged_root, install_directory) + inner = install_directory / Path(*member_relative.parts[1:]) + link_path = PAYLOAD_BIN / entry["executable_name"] + atomic_replace_wrapper(wrapper_script_content(inner), link_path) + self._record(tool, entry, link_path) + + def _install_npm(self, tool: ToolSpec) -> None: + """Install markdownlint-cli2 from the committed npm lock.""" + entry = self.lock[tool.lock_id or ""] + if self._installed_and_current(tool): + return + validate_npm_lock_dir(self.npm_lock_dir, str(entry["version"])) + guard_unmanaged_target( + PAYLOAD_BIN / entry["executable_name"], + tool.lock_id or "", + self.state, + ) + final_root = PAYLOAD_PACKAGES / "nodejs" + if final_root.exists() and (tool.lock_id or "") not in self.state: + raise InstallerError( + f"refusing to overwrite unmanaged directory {final_root}" + ) + staging = self._new_staging() + build_root = staging / "nodejs" + build_root.mkdir(parents=True) + for name in ("package.json", "package-lock.json"): + shutil.copyfile(self.npm_lock_dir / name, build_root / name) + set_mode_by_type(build_root / name, executable=False) + npm_cache = PAYLOAD_CACHE / "npm-cache" + npm_cache.mkdir(parents=True, exist_ok=True) + run_command( + [ + "npm", + "ci", + "--omit=dev", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--loglevel=error", + ], + self.log, + timeout=NPM_TIMEOUT, + cwd=build_root, + env=command_environment({"npm_config_cache": str(npm_cache)}), + ) + normalize_tree_ownership(build_root) + staged_shim = build_root / entry["expected_member"] + if not (staged_shim.is_file() or staged_shim.is_symlink()): + raise InstallerError( + f"npm ci did not produce expected member {entry['expected_member']!r}" + ) + probe_staged_executable( + staged_shim, tuple(entry["version_command"][1:]), self.log + ) + atomic_replace_tree(build_root, final_root) + link_path = PAYLOAD_BIN / entry["executable_name"] + link_target = os.path.relpath( + final_root / entry["expected_member"], PAYLOAD_BIN + ) + atomic_replace_symlink(link_target, link_path) + self._record(tool, entry, link_path) + + def _record( + self, + tool: ToolSpec, + entry: dict[str, Any], + executable_path: Path, + *, + installed_sha256: str | None = None, + ) -> None: + """Persist one successful managed installation atomically.""" + record: dict[str, Any] = { + "tool_id": entry["tool_id"], + "version": str(entry["version"]), + "asset_url": entry["asset_url"], + "asset_sha256": entry["sha256"], + "executable_path": str(executable_path), + "install_directory": entry["install_directory"], + "installed_at": datetime.now(UTC).isoformat(), + } + if installed_sha256: + record["installed_sha256"] = installed_sha256 + self.state[entry["tool_id"]] = record + write_state(self.state) + self.log.log( + f" Installed {tool.display_name} {entry['version']} -> {executable_path}" + ) + + +# -------------------------------------------------------------------------- +# PATH configuration +# -------------------------------------------------------------------------- + + +def install_profile_script(log: InstallerLog) -> None: + """Write the deterministic, idempotent PATH drop-in atomically.""" + if PROFILE_SCRIPT.is_file(): + current = PROFILE_SCRIPT.read_text(encoding="utf-8", errors="replace") + if current == PROFILE_CONTENT: + log.log(f" {PROFILE_SCRIPT} already up to date.") + return + PROFILE_SCRIPT.parent.mkdir(parents=True, exist_ok=True) + descriptor, temp_name = tempfile.mkstemp( + prefix=".ecli_payload.", suffix=".tmp", dir=str(PROFILE_SCRIPT.parent) + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(PROFILE_CONTENT) + os.chmod(temp_name, FILE_MODE) + if os.geteuid() == 0: + os.chown(temp_name, 0, 0) + os.replace(temp_name, PROFILE_SCRIPT) + log.log(f" Wrote {PROFILE_SCRIPT} (mode 0644, world-readable).") + + +# -------------------------------------------------------------------------- +# Verification and reporting +# -------------------------------------------------------------------------- + + +def probe_tool( + tool: ToolSpec, + lock: dict[str, dict[str, Any]], + log: InstallerLog, +) -> ToolResult: + """Run one tool's version probe against the final environment.""" + executable = shutil.which(tool.executable_name, path=PROBE_PATH) + if executable is None: + return ToolResult( + tool, + "FAILED", + detail=f"executable {tool.executable_name!r} not found on PATH", + stage="verify", + ) + real = os.path.realpath(executable) + if not any(executable.startswith(prefix) for prefix in APPROVED_PATH_PREFIXES): + return ToolResult( + tool, + "FAILED", + detail=f"executable resolved outside approved paths: {executable}", + stage="verify", + ) + if tool.requires_java: + try: + run_command(["java", "-version"], log, timeout=PROBE_TIMEOUT, echo=False) + except InstallerError as exc: + return ToolResult( + tool, + "FAILED", + detail=f"java runtime probe failed: {exc}", + stage="verify", + ) + try: + result = run_command( + [executable, *tool.version_command[1:]], + log, + timeout=PROBE_TIMEOUT, + echo=False, + ) + except InstallerError as exc: + if not tool.version_fallback: + return ToolResult(tool, "FAILED", detail=one_line(str(exc)), stage="verify") + log.detail( + f" primary version probe failed for {tool.executable_name}; " + f"retrying with fallback {tool.version_fallback[1:]}" + ) + try: + result = run_command( + [executable, *tool.version_fallback[1:]], + log, + timeout=PROBE_TIMEOUT, + echo=False, + ) + except InstallerError as fallback_exc: + return ToolResult( + tool, "FAILED", detail=one_line(str(fallback_exc)), stage="verify" + ) + # Several tools (java -version style) print version data to stderr. + output = ((result.stdout or "") + " " + (result.stderr or "")).strip() + if not output: + return ToolResult( + tool, + "FAILED", + detail="version probe produced no output", + stage="verify", + ) + first_line = next( + (line.strip() for line in output.splitlines() if line.strip()), "" + ) + if tool.kind in ("payload", "npm"): + locked_version = str(lock[tool.lock_id or ""]["version"]) + if locked_version not in output: + return ToolResult( + tool, + "FAILED", + detail=( + f"expected locked version {locked_version} in probe " + f"output {first_line!r}" + ), + stage="verify", + ) + version = locked_version + else: + version = first_line[:60] + log.detail(f" probe {tool.executable_name}: {first_line} ({real})") + return ToolResult(tool, "OK", version=version, path=executable) + + +def one_line(text: str, limit: int = 240) -> str: + """Collapse whitespace for single-line report output (log keeps full text).""" + collapsed = " ".join(text.split()) + return collapsed[:limit] + ("..." if len(collapsed) > limit else "") + + +def format_report_line(result: ToolResult) -> str: + """Format one aligned final-report line.""" + tag = f"[{result.status}]".ljust(10) + name = result.tool.display_name.ljust(20) + if result.status == "OK": + return f"{tag}{name}version {result.version} path {result.path}" + if result.status == "SKIPPED": + return f"{tag}{name}not selected" + stage = f" [stage: {result.stage}]" if result.stage else "" + return f"{tag}{name}reason {result.detail}{stage}" + + +def print_final_report( + results: dict[int, ToolResult], + selected: frozenset[int], + log: InstallerLog, +) -> int: + """Print the per-tool report and the honest final summary.""" + log.log("") + log.log("=" * 72) + log.log("FINAL REPORT") + log.log("=" * 72) + for tool in TOOLS: + log.log(format_report_line(results[tool.number])) + log.log("=" * 72) + ok = sum(1 for number in selected if results[number].status == "OK") + failed = sorted( + results[number].tool.display_name + for number in selected + if results[number].status != "OK" + ) + total = len(selected) + if failed: + log.log( + f"ECLI linter installation FAILED: {ok}/{total} selected tools " + f"verified; failed: {', '.join(failed)}." + ) + log.log( + "Note: APT packages already installed by this run remain " + "installed; no APT rollback is performed." + ) + return EXIT_INSTALL_FAILED + if selected == ALL_TOOL_NUMBERS: + log.log( + "ECLI linter installation completed successfully: 19/19 tools verified." + ) + else: + log.log( + "ECLI linter installation completed successfully: " + f"{total}/{total} selected tools verified." + ) + log.log("") + log.log( + "Open a new login shell (or 'source /etc/profile.d/ecli_payload.sh') " + "so PATH includes /opt/ecli/payload/bin." + ) + log.log("You can now install ECLI itself, for example:") + log.log(" sudo apt install ./releases/0.2.4/ecli_0.2.4_linux_x86_64.deb") + return EXIT_OK + + +# -------------------------------------------------------------------------- +# Main flow +# -------------------------------------------------------------------------- + + +class InstallerLockHeldError(InstallerError): + """Another installer instance already holds the payload lock.""" + + +@contextlib.contextmanager +def installer_process_lock(lock_path: Path = INSTALLER_LOCK_FILE): + """Exclusive, non-blocking process lock guarding all payload mutation. + + Two concurrently running root instances of this installer must never + mutate ``/opt/ecli/payload`` at the same time -- interleaved atomic + promotions, state-file writes, or APT transactions could otherwise + corrupt the managed tree or the state file. Uses ``flock(2)`` on a + fixed path under ``/run/lock`` (always present, root-writable, and + independent of whether the payload tree itself exists yet). Fails + immediately with a clear message rather than blocking indefinitely if + another instance already holds it; the lock is released automatically + on process exit even if the process is killed (kernel-held, not a + stale on-disk marker that could be forgotten). + """ + lock_path.parent.mkdir(parents=True, exist_ok=True) + handle = open(lock_path, "w") # noqa: SIM115 - held for the whole block + try: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + raise InstallerLockHeldError( + f"another instance of this installer is already running " + f"(lock held: {lock_path}); wait for it to finish and " + "retry" + ) from exc + handle.write(f"{os.getpid()}\n") + handle.flush() + yield + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + handle.close() + + +def build_arg_parser() -> argparse.ArgumentParser: + """Build the CLI parser.""" + parser = argparse.ArgumentParser( + prog="install_ecli_linters.py", + description=( + "Interactive ECLI F4 linter toolchain installer for Debian 13 " + "(amd64). Stage 1 of the two-stage ECLI installation; run it " + "before installing the ECLI .deb." + ), + ) + parser.add_argument( + "--select", + metavar="SELECTION", + help=( + "non-interactive selection: 'A' for all tools or a " + "comma-separated list of menu numbers (e.g. '1,4,5')" + ), + ) + parser.add_argument( + "--lock-file", + type=Path, + default=None, + help="override the production lock file location", + ) + parser.add_argument( + "--npm-lock-dir", + type=Path, + default=None, + help=( + "override the committed markdownlint-cli2 " + "package.json/package-lock.json directory" + ), + ) + return parser + + +def run_installation( + selected: frozenset[int], + lock: dict[str, dict[str, Any]], + npm_lock_dir: Path, + log: InstallerLog, +) -> int: + """Execute the full installation flow for one validated selection.""" + results: dict[int, ToolResult] = { + tool.number: ToolResult(tool, "SKIPPED") for tool in TOOLS + } + selected_tools = [TOOLS_BY_NUMBER[number] for number in sorted(selected)] + log.log( + "Selected tools: " + ", ".join(tool.display_name for tool in selected_tools) + ) + + log.log("") + log.log("[STEP 1/5] Resolving APT dependencies...") + apt_packages = resolve_apt_packages(selected) + log.log("") + log.log("[STEP 2/5] Installing APT packages (single transaction)...") + try: + apt_install(apt_packages, log) + except InstallerError as exc: + log.log(f"[ERROR] APT stage failed: {exc}") + for tool in selected_tools: + results[tool.number] = ToolResult( + tool, + "FAILED", + detail="APT stage failed; tool not installed", + stage="apt-install", + ) + return print_final_report(results, selected, log) + + log.log("") + log.log("[STEP 3/5] Preparing payload directories under /opt/ecli/payload...") + ensure_payload_layout() + + log.log("") + log.log("[STEP 4/5] Installing standalone payload linters from the lock...") + state = read_state() + installer = PayloadInstaller(lock, state, log, npm_lock_dir) + install_failures: dict[int, str] = {} + try: + for tool in selected_tools: + if tool.kind not in ("payload", "npm"): + continue + log.log(f"--- {tool.display_name} ---") + try: + installer.install(tool) + except InstallerError as exc: + install_failures[tool.number] = str(exc) + log.log(f"[ERROR] {tool.display_name} failed during install: {exc}") + finally: + installer.cleanup() + + log.log("") + log.log("[STEP 5/5] Configuring global PATH...") + install_profile_script(log) + + log.log("") + log.log("Verifying selected tools (version probes)...") + for tool in selected_tools: + if tool.number in install_failures: + results[tool.number] = ToolResult( + tool, + "FAILED", + detail=install_failures[tool.number], + stage="install", + ) + continue + results[tool.number] = probe_tool(tool, lock, log) + return print_final_report(results, selected, log) + + +def main(argv: list[str] | None = None) -> int: + """Entry point; returns the process exit code.""" + args = build_arg_parser().parse_args(argv) + try: + validate_platform() + except PlatformError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return EXIT_PLATFORM + + log = InstallerLog(LOG_FILE) + try: + os_release = parse_os_release( + Path("/etc/os-release").read_text(encoding="utf-8", errors="replace") + ) + log.detail("==== ECLI linter installer session start ====") + log.detail( + f"Debian: {os_release.get('PRETTY_NAME', 'unknown')} " + f"arch: {detect_dpkg_architecture()}" + ) + + lock_path = args.lock_file or default_lock_path() + npm_lock_dir = args.npm_lock_dir or default_npm_lock_dir() + try: + lock = load_lock(lock_path) + except LockError as exc: + log.log(f"[ERROR] {exc}") + return EXIT_LOCK + log.detail(f"Lock file: {lock_path}") + + try: + if args.select is not None: + selected = parse_selection(args.select) + else: + selected = prompt_selection() + except SelectionError as exc: + log.log(f"[ERROR] {exc}") + return EXIT_USAGE + + if any(TOOLS_BY_NUMBER[number].kind == "npm" for number in selected): + try: + validate_npm_lock_dir( + npm_lock_dir, + str(lock["markdownlint-cli2"]["version"]), + ) + except LockError as exc: + log.log(f"[ERROR] {exc}") + return EXIT_LOCK + + with installer_process_lock(): + return run_installation(selected, lock, npm_lock_dir, log) + except InstallerError as exc: + log.log(f"[ERROR] installation aborted: {exc}") + return EXIT_INSTALL_FAILED + finally: + log.detail("==== ECLI linter installer session end ====") + log.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/packaging_common.py b/scripts/packaging_common.py index 6c294ce..59cc502 100644 --- a/scripts/packaging_common.py +++ b/scripts/packaging_common.py @@ -56,9 +56,9 @@ def install_file(src: Path, dst: Path, mode: int) -> None: dst.chmod(mode) -def gzip_file(path: Path, level: int = 9) -> Path: +def gzip_file(path: Path, level: int = 9, mtime: int = 0) -> Path: gz = path.with_name(path.name + ".gz") - gz.write_bytes(gzip.compress(path.read_bytes(), compresslevel=level, mtime=0)) + gz.write_bytes(gzip.compress(path.read_bytes(), compresslevel=level, mtime=mtime)) path.unlink() return gz diff --git a/scripts/verify_f4_toolchain_smoke.py b/scripts/verify_f4_toolchain_smoke.py new file mode 100644 index 0000000..d2a1408 --- /dev/null +++ b/scripts/verify_f4_toolchain_smoke.py @@ -0,0 +1,563 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: scripts/verify_f4_toolchain_smoke.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Real diagnostic smoke coverage for the complete 19-tool F4 toolchain. + +For every tool this harness creates a known-bad fixture, runs the real +linter against it, and asserts the documented diagnostic contract: + +* exact command (argv array, never a shell, never a pipe); +* resolved executable path (payload-first PATH); +* observed version; +* fixture path; +* real exit code taken directly from ``subprocess.run``; +* a normalized expected diagnostic marker in stdout+stderr. + +Exit codes are asserted per tool: several linters (clang-tidy with +warnings, SpotBugs textui) document exit code 0 with findings on the +output streams; most others document a specific non-zero code +(PMD ``4`` for violations, cargo ``101`` on denied warnings, TFLint ``2`` +for issues found, Checkstyle "number of errors"). The harness prints one +PASS/FAIL record per tool and returns 0 only when all 19 pass. + +Standalone: stdlib only, no ECLI imports. Intended for the Debian 13 +clean-room validation after ``install_ecli_linters.py --select A``. +""" + +from __future__ import annotations + +import argparse +import base64 +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + + +PAYLOAD_BIN = "/opt/ecli/payload/bin" +DEFAULT_TIMEOUT = 180.0 +BUILD_TIMEOUT = 600.0 # cargo-clippy / golangci-lint compile first + +# Precompiled fixture for SpotBugs (bytecode analyzer): javac --release 11, +# source EqualsNoHashCode.java — a class overriding equals() without +# hashCode(), triggering the always-on HE_EQUALS_NO_HASHCODE detector. +EQUALS_NO_HASHCODE_CLASS_B64 = ( + "yv66vgAAADcAFQoAAgADBwAEDAAFAAYBABBqYXZhL2xhbmcvT2JqZWN0AQAGPGluaXQ+" + "AQADKClWCQAIAAkHAAoMAAsADAEAEEVxdWFsc05vSGFzaENvZGUBAAV2YWx1ZQEAAUkB" + "AAQoSSlWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEABmVxdWFscwEAFShMamF2YS9s" + "YW5nL09iamVjdDspWgEADVN0YWNrTWFwVGFibGUBAApTb3VyY2VGaWxlAQAVRXF1YWxz" + "Tm9IYXNoQ29kZS5qYXZhACEACAACAAAAAQASAAsADAAAAAIAAQAFAA0AAQAOAAAAKgAC" + "AAIAAAAKKrcAASobtQAHsQAAAAEADwAAAA4AAwAAAAQABAAFAAkABgABABAAEQABAA4A" + "AAA+AAIAAgAAABsrwQAImQAVK8AACLQAByq0AAegAAcEpwAEA6wAAAACAA8AAAAGAAEA" + "AAAKABIAAAAFAAIZQAEAAQATAAAAAgAU" +) + + +@dataclass +class SmokeCase: + """One tool's real-diagnostic fixture contract.""" + + tool_id: str + display_name: str + executable: str + version_command: tuple[str, ...] + build: Callable[[Path], tuple[list[str], Path]] + expected_rc: tuple[int, ...] | None # None => documented "any non-zero" + rc_note: str + marker: str + timeout: float = DEFAULT_TIMEOUT + env_extra: Callable[[Path], dict[str, str]] | None = None + # Subdirectory of the workdir to run in (golangci-lint must run + # inside the Go module). + workdir_subpath: str | None = None + + +def _write(path: Path, content: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +def _ruff(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "ruff" / "bad.py", "import os,sys\nx=1\n") + return ["ruff", "check", "--no-cache", str(fixture)], fixture + + +def _biome(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "biome" / "bad.js", "var a = 1;\na = a;\n") + return ["biome", "lint", str(fixture)], fixture + + +def _markdownlint(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "mdl" / "bad.md", "# Heading\n\n\n\ntext\n") + return ["markdownlint-cli2", str(fixture)], fixture + + +def _yamllint(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "yaml" / "bad.yaml", "a:\n b: 1\n c: 2\n") + return ["yamllint", str(fixture)], fixture + + +def _shellcheck(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "sh" / "bad.sh", "#!/bin/sh\necho $UNQUOTED\n") + return ["shellcheck", str(fixture)], fixture + + +def _zig(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "zig" / "bad.zig", "const x = ;\n") + return ["zig", "ast-check", str(fixture)], fixture + + +def _hadolint(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "docker" / "Dockerfile", "FROM debian\nRUN cd /tmp\n") + return ["hadolint", str(fixture)], fixture + + +def _taplo(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "toml" / "bad.toml", "a =\n") + return ["taplo", "lint", str(fixture)], fixture + + +def _actionlint(work: Path) -> tuple[list[str], Path]: + fixture = _write( + work / "gha" / "workflow.yml", + "on: push\njobs:\n test:\n steps:\n - run: echo hi\n", + ) + return ["actionlint", str(fixture)], fixture + + +def _clang_tidy(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "ctidy" / "bad.c", "int main(void) { int x; return x; }\n") + return ["clang-tidy", "--quiet", str(fixture), "--"], fixture + + +def _cppcheck(work: Path) -> tuple[list[str], Path]: + fixture = _write( + work / "cppcheck" / "bad.c", + "int f(void) { int a[3]; return a[5]; }\n", + ) + return ["cppcheck", "--error-exitcode=1", str(fixture)], fixture + + +def _clang_format(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "cfmt" / "ugly.c", "int main( ){return 0 ;}\n") + return ["clang-format", "--dry-run", "--Werror", str(fixture)], fixture + + +def _checkstyle(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "checkstyle" / "Bad.java", "public class Bad { int x; }\n") + return ["checkstyle", "-c", "/sun_checks.xml", str(fixture)], fixture + + +def _pmd(work: Path) -> tuple[list[str], Path]: + fixture = _write( + work / "pmd" / "Bad2.java", + "public class Bad2 {\n" + " public int f() {\n" + " int unused = 1;\n" + " return 2;\n" + " }\n" + "}\n", + ) + return [ + "pmd", + "check", + "--no-progress", + "-d", + str(fixture), + "-R", + "rulesets/java/quickstart.xml", + "-f", + "text", + ], fixture + + +def _spotbugs(work: Path) -> tuple[list[str], Path]: + classes = work / "spotbugs" / "classes" + classes.mkdir(parents=True, exist_ok=True) + fixture = classes / "EqualsNoHashCode.class" + fixture.write_bytes(base64.b64decode(EQUALS_NO_HASHCODE_CLASS_B64)) + return ["spotbugs", "-textui", "-low", str(classes)], fixture + + +def _cargo_clippy(work: Path) -> tuple[list[str], Path]: + project = work / "clippy_proj" + _write( + project / "Cargo.toml", + '[package]\nname = "smoke"\nversion = "0.1.0"\nedition = "2021"\n', + ) + fixture = _write( + project / "src" / "main.rs", + 'fn main() {\n let x = true;\n if x == true { println!("y"); }\n}\n', + ) + return [ + "cargo", + "clippy", + "--offline", + "--quiet", + "--manifest-path", + str(project / "Cargo.toml"), + "--", + "-D", + "warnings", + ], fixture + + +def _golangci_lint(work: Path) -> tuple[list[str], Path]: + project = work / "golint_proj" + _write(project / "go.mod", "module smoke\n\ngo 1.24\n") + fixture = _write( + project / "main.go", + "package main\n\n" + 'import "fmt"\n\n' + "func main() {\n" + '\tfmt.Printf("%d\\n", "not-a-number")\n' + "}\n", + ) + return ["golangci-lint", "run", "./..."], fixture + + +def _sqlfluff(work: Path) -> tuple[list[str], Path]: + fixture = _write(work / "sql" / "bad.sql", "SELECT a from b\n") + return ["sqlfluff", "lint", "--dialect", "ansi", str(fixture)], fixture + + +def _tflint(work: Path) -> tuple[list[str], Path]: + project = work / "tf" + fixture = _write(project / "main.tf", 'variable "unused" { default = 1 }\n') + return ["tflint", f"--chdir={project}"], fixture + + +def _go_env(work: Path) -> dict[str, str]: + return { + "GOCACHE": str(work / ".gocache"), + "GOPATH": str(work / ".gopath"), + "GOLANGCI_LINT_CACHE": str(work / ".golangci-cache"), + } + + +def _cargo_env(work: Path) -> dict[str, str]: + return {"CARGO_HOME": str(work / ".cargo")} + + +SMOKE_CASES: tuple[SmokeCase, ...] = ( + SmokeCase( + "ruff", + "Ruff", + "ruff", + ("ruff", "--version"), + _ruff, + (1,), + "documented: 1 when violations are found", + "E401", + ), + SmokeCase( + "biome", + "Biome", + "biome", + ("biome", "--version"), + _biome, + (1,), + "documented: 1 when lint diagnostics are emitted", + "noSelfAssign", + ), + SmokeCase( + "markdownlint-cli2", + "markdownlint-cli2", + "markdownlint-cli2", + ("markdownlint-cli2", "--version"), + _markdownlint, + (1,), + "documented: 1 when errors are found (direct subprocess, no pipe)", + "MD012", + ), + SmokeCase( + "yamllint", + "yamllint", + "yamllint", + ("yamllint", "--version"), + _yamllint, + (1,), + "documented: 1 when errors are found", + "syntax", + ), + SmokeCase( + "shellcheck", + "ShellCheck", + "shellcheck", + ("shellcheck", "--version"), + _shellcheck, + (1,), + "documented: 1 when issues are found", + "SC2086", + ), + SmokeCase( + "zig", + "Zig", + "zig", + ("zig", "version"), + _zig, + (1,), + "documented: 1 on AST errors", + "error", + ), + SmokeCase( + "hadolint", + "Hadolint", + "hadolint", + ("hadolint", "--version"), + _hadolint, + (1,), + "documented: 1 when rule violations are found", + "DL3003", + ), + SmokeCase( + "taplo", + "Taplo", + "taplo", + ("taplo", "--version"), + _taplo, + (1,), + "documented: 1 on lint/parse errors", + "error", + ), + SmokeCase( + "actionlint", + "actionlint", + "actionlint", + ("actionlint", "-version"), + _actionlint, + (1,), + "documented: 1 when problems are found", + "runs-on", + ), + SmokeCase( + "clang-tidy", + "clang-tidy", + "clang-tidy", + ("clang-tidy", "--version"), + _clang_tidy, + (0,), + "documented: 0 when only warnings are emitted (no compile error)", + "warning:", + ), + SmokeCase( + "cppcheck", + "Cppcheck", + "cppcheck", + ("cppcheck", "--version"), + _cppcheck, + (1,), + "documented: --error-exitcode=1 forces 1 when errors are found", + "arrayIndexOutOfBounds", + ), + SmokeCase( + "clang-format", + "clang-format", + "clang-format", + ("clang-format", "--version"), + _clang_format, + (1,), + "documented: --dry-run --Werror exits 1 on formatting violations", + "clang-format-violations", + ), + SmokeCase( + "checkstyle", + "Checkstyle", + "checkstyle", + ("checkstyle", "--version"), + _checkstyle, + None, + "documented: exit code is the audit error count (non-zero here)", + "[ERROR]", + ), + SmokeCase( + "pmd", + "PMD", + "pmd", + ("pmd", "--version"), + _pmd, + (4,), + "documented: 4 when violations are found", + "NoPackage", + ), + SmokeCase( + "spotbugs", + "SpotBugs", + "spotbugs", + ("spotbugs", "-version"), + _spotbugs, + (0,), + "documented: textui exits 0; findings are reported on stdout", + "hashCode", + ), + SmokeCase( + "cargo-clippy", + "cargo-clippy", + "cargo", + ("cargo", "clippy", "--version"), + _cargo_clippy, + (101,), + "documented: cargo exits 101 when -D warnings denies a lint", + "bool_comparison", + timeout=BUILD_TIMEOUT, + env_extra=_cargo_env, + ), + SmokeCase( + "golangci-lint", + "golangci-lint", + "golangci-lint", + ("golangci-lint", "--version"), + _golangci_lint, + (1,), + "documented: 1 when issues are found", + "printf", + timeout=BUILD_TIMEOUT, + env_extra=_go_env, + workdir_subpath="golint_proj", + ), + SmokeCase( + "sqlfluff", + "SQLFluff", + "sqlfluff", + ("sqlfluff", "--version"), + _sqlfluff, + (1,), + "documented: 1 when lint violations are found", + "CP01", + ), + SmokeCase( + "tflint", + "TFLint", + "tflint", + ("tflint", "--version"), + _tflint, + (2,), + "documented: 2 when issues are found", + "terraform_unused_declarations", + ), +) + + +def smoke_environment(work: Path, extra: dict[str, str] | None) -> dict[str, str]: + """Deterministic execution environment (payload-first PATH).""" + env = { + "PATH": f"{PAYLOAD_BIN}:/usr/local/sbin:/usr/local/bin:" + "/usr/sbin:/usr/bin:/sbin:/bin", + "HOME": str(work), + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + } + if extra: + env.update(extra) + return env + + +def run_case(case: SmokeCase, work: Path) -> bool: + """Run one smoke case and print its full evidence record.""" + env = smoke_environment(work, case.env_extra(work) if case.env_extra else None) + resolved = shutil.which(case.executable, path=env["PATH"]) + if resolved is None: + print(f"[FAIL] {case.display_name}: executable not found on PATH") + return False + version_probe = subprocess.run( + [resolved, *case.version_command[1:]], + capture_output=True, + text=True, + timeout=120, + env=env, + check=False, + ) + version_line = next( + ( + line.strip() + for line in ( + (version_probe.stdout or "") + "\n" + (version_probe.stderr or "") + ).splitlines() + if line.strip() and any(ch.isdigit() for ch in line) + ), + "unknown", + ) + argv, fixture = case.build(work) + run_cwd = work / case.workdir_subpath if case.workdir_subpath else work + try: + result = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=case.timeout, + env=env, + cwd=str(run_cwd), + check=False, + ) + except subprocess.TimeoutExpired: + print(f"[FAIL] {case.display_name}: timed out after {case.timeout:.0f}s") + return False + output = (result.stdout or "") + (result.stderr or "") + rc_ok = ( + result.returncode != 0 + if case.expected_rc is None + else result.returncode in case.expected_rc + ) + marker_ok = case.marker in output + status = "PASS" if (rc_ok and marker_ok) else "FAIL" + expected_rc_text = "non-zero" if case.expected_rc is None else str(case.expected_rc) + print(f"[{status}] {case.display_name}") + print(f" command: {' '.join(argv)}") + print(f" path: {resolved}") + print(f" version: {version_line}") + print(f" fixture: {fixture}") + print( + f" exit: {result.returncode} " + f"(expected {expected_rc_text}; {case.rc_note})" + ) + print( + f" expected diagnostic marker: {case.marker!r} " + f"({'found' if marker_ok else 'NOT FOUND'})" + ) + if status == "FAIL": + tail = output.strip()[-1200:] + print(f" output tail:\n{tail}") + return status == "PASS" + + +def main(argv: list[str] | None = None) -> int: + """Run all 19 smoke cases; return 0 only when every one passes.""" + parser = argparse.ArgumentParser( + prog="verify_f4_toolchain_smoke.py", + description=( + "Run real diagnostic fixtures against the complete 19-tool " + "F4 toolchain (Debian 13 clean-room validation)." + ), + ) + parser.add_argument( + "--workdir", + type=Path, + default=Path("/tmp/ecli-f4-smoke"), + help="fixture/working directory (created if absent)", + ) + args = parser.parse_args(argv) + work = args.workdir + work.mkdir(parents=True, exist_ok=True) + passed = 0 + for case in SMOKE_CASES: + if run_case(case, work): + passed += 1 + total = len(SMOKE_CASES) + print(f"F4 diagnostics smoke: {passed}/{total} tools verified") + return 0 if passed == total else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ecli/__main__.py b/src/ecli/__main__.py index e5c4e59..da94586 100755 --- a/src/ecli/__main__.py +++ b/src/ecli/__main__.py @@ -93,6 +93,7 @@ def _print_help() -> None: "Options:\n" " -h, --help Show this help and exit.\n" " --version Show version and exit.\n" + " --f4-check Verify F4 linter toolchain detection and exit.\n" " --services Print ServiceRegistry service status.\n" " --doctor Run read-only SystemDoctor diagnostics.\n" " --plan-preview Print a draft CommandPlan preview.\n" @@ -113,6 +114,13 @@ def _print_version() -> None: if sys.argv[1:] == ["--version"]: _print_version() sys.exit(0) + if sys.argv[1:] == ["--f4-check"]: + # Headless F4 toolchain verification (no curses): proves inside the + # installed ECLI runtime that the provider layer initializes and + # all 19 tools resolve from approved managed/system paths. + from ecli.extensions.linters.core.toolchain_check import f4_check_main + + sys.exit(f4_check_main()) from ecli.cli import is_service_cli, run_service_cli diff --git a/src/ecli/extensions/linters/core/toolchain_check.py b/src/ecli/extensions/linters/core/toolchain_check.py new file mode 100644 index 0000000..407a69b --- /dev/null +++ b/src/ecli/extensions/linters/core/toolchain_check.py @@ -0,0 +1,285 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: src/ecli/extensions/linters/core/toolchain_check.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Non-fatal F4 toolchain availability check. + +Reports which first-class F4 linter executables (catalog tiers ``core`` and +``recommended`` -- the 19-tool toolchain provisioned by +``scripts/install_ecli_linters.py``) are missing from ``PATH``. The check is +purely informational: it never raises for a missing tool, never mutates +editor state, and providers keep degrading per-file via +``missing_executable_result`` exactly as before. Its purpose is a clear +startup/F4 diagnostics log line listing missing executables instead of the +user discovering them one crash-free failure at a time. +""" + +from __future__ import annotations + +import logging +import shutil +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from ecli.extensions.linters.core.registry import LinterDefinition + + +TOOLCHAIN_TIERS: tuple[str, ...] = ("core", "recommended") + +# Delivery contract for the 19-tool toolchain on Debian 13: exactly 11 +# ECLI-managed executables (installed under /opt/ecli/payload/bin by +# scripts/install_ecli_linters.py from the committed lock) and exactly 8 +# Debian-packaged executables (installed by APT under /usr/bin). +# ``ecli --f4-check`` fails when these counts drift. +MANAGED_EXECUTABLES: frozenset[str] = frozenset( + { + "ruff", + "biome", + "markdownlint-cli2", + "zig", + "hadolint", + "taplo", + "actionlint", + "pmd", + "spotbugs", + "golangci-lint", + "tflint", + } +) +SYSTEM_EXECUTABLES: frozenset[str] = frozenset( + { + "yamllint", + "shellcheck", + "clang-tidy", + "cppcheck", + "clang-format", + "checkstyle", + "cargo", + "sqlfluff", + } +) +MANAGED_TOOL_COUNT = 11 +SYSTEM_TOOL_COUNT = 8 +TOOLCHAIN_TOTAL = 19 + +MANAGED_PATH_PREFIX = "/opt/ecli/payload/bin/" +SYSTEM_PATH_PREFIXES: tuple[str, ...] = ( + "/usr/sbin/", + "/usr/bin/", + "/sbin/", + "/bin/", +) + + +def toolchain_definitions() -> tuple["LinterDefinition", ...]: + """Return the first-class 19-tool toolchain catalog entries.""" + from ecli.extensions.linters import iter_linters + + return tuple( + definition + for definition in iter_linters() + if definition.tier in TOOLCHAIN_TIERS + ) + + +def missing_toolchain_executables( + path: str | None = None, +) -> tuple["LinterDefinition", ...]: + """Return toolchain entries whose executable is absent from ``PATH``. + + Args: + path: Optional explicit search path for ``shutil.which`` (used by + tests); ``None`` searches the process environment ``PATH``. + """ + missing: list[LinterDefinition] = [] + seen: set[str] = set() + for definition in toolchain_definitions(): + executable = definition.executable + if executable in seen: + continue + seen.add(executable) + if shutil.which(executable, path=path) is None: + missing.append(definition) + return tuple(missing) + + +def log_toolchain_availability( + logger: logging.Logger, path: str | None = None +) -> None: + """Log a clear, non-fatal report of missing F4 toolchain executables. + + Never raises: any unexpected error is logged and swallowed so a + diagnostics convenience can never break editor startup. + """ + try: + missing = missing_toolchain_executables(path=path) + if not missing: + logger.info("F4 toolchain check: all linter executables found.") + return + listing = ", ".join( + f"{definition.display_name} ({definition.executable})" + for definition in missing + ) + logger.warning( + "F4 toolchain check: %d linter executable(s) missing from " + "PATH: %s. Affected linters stay disabled per file; install " + "them with: sudo python3 scripts/install_ecli_linters.py " + "(Debian 13).", + len(missing), + listing, + ) + except Exception: # noqa: BLE001 - diagnostics must never crash startup + logger.exception("F4 toolchain check failed (non-fatal)") + + +def _register_f4_providers() -> int: + """Instantiate the F4 provider layer exactly as LinterBridge does. + + Returns the number of registered providers. Imports are deliberately + lazy: this only runs for ``ecli --f4-check``. + """ + from ecli.extensions.linters.actionlint.provider import ( + ActionlintDiagnosticProvider, + ) + from ecli.extensions.linters.biome.provider import BiomeDiagnosticProvider + from ecli.extensions.linters.cargo_clippy.provider import ( + CargoClippyDiagnosticProvider, + ) + from ecli.extensions.linters.clang_tidy.provider import ( + ClangTidyDiagnosticProvider, + ) + from ecli.extensions.linters.core.service import DiagnosticsService + from ecli.extensions.linters.cppcheck.provider import ( + CppcheckDiagnosticProvider, + ) + from ecli.extensions.linters.hadolint.provider import ( + HadolintDiagnosticProvider, + ) + from ecli.extensions.linters.java_checkstyle.provider import ( + JavaCheckstyleDiagnosticProvider, + ) + from ecli.extensions.linters.java_pmd.provider import ( + JavaPmdDiagnosticProvider, + ) + from ecli.extensions.linters.markdownlint.provider import ( + MarkdownlintDiagnosticProvider, + ) + from ecli.extensions.linters.ruff.provider import RuffDiagnosticProvider + from ecli.extensions.linters.shellcheck.provider import ( + ShellCheckDiagnosticProvider, + ) + from ecli.extensions.linters.taplo.provider import TaploDiagnosticProvider + from ecli.extensions.linters.yamllint.provider import ( + YamllintDiagnosticProvider, + ) + from ecli.extensions.linters.zig.provider import ZigDiagnosticProvider + + service = DiagnosticsService() + for provider in ( + RuffDiagnosticProvider(enabled=True), + BiomeDiagnosticProvider(), + ZigDiagnosticProvider(), + ClangTidyDiagnosticProvider(), + CppcheckDiagnosticProvider(), + JavaCheckstyleDiagnosticProvider(), + JavaPmdDiagnosticProvider(), + ShellCheckDiagnosticProvider(), + MarkdownlintDiagnosticProvider(), + YamllintDiagnosticProvider(), + ActionlintDiagnosticProvider(), + HadolintDiagnosticProvider(), + TaploDiagnosticProvider(), + CargoClippyDiagnosticProvider(), + ): + service.register_provider(provider) + return len(service.provider_states()) + + +def f4_check_main(path: str | None = None) -> int: + """Headless F4 toolchain verification for ``ecli --f4-check``. + + This command reports three DIFFERENT, non-interchangeable counts. + They are never conflated: + + * PROVISIONED/VERIFIED EXECUTABLE (up to 19): a toolchain executable + that resolves on ``PATH`` from its approved managed or system + location. Provisioning is done by the stage-1 installer + (``scripts/install_ecli_linters.py``) or by APT; this command only + verifies the result. + * REGISTERED DIAGNOSTIC PROVIDER (14, fixed): the number of + ``DiagnosticProvider`` classes the F4 runtime layer actually wires + into ``DiagnosticsService`` (mirrors ``LinterBridge.__init__`` + exactly). Five provisioned/verified tools currently have no + registered provider (SpotBugs, golangci-lint, SQLFluff, TFLint, + clang-format) -- they are installed and on PATH, but F4 does not yet + run diagnostics through them. This is not a defect this command + reports on; it is the accurate current state. + + Never describe all 19 provisioned executables as "19 active F4 + providers" -- only 14 providers are registered. + + Resolves every toolchain executable through ``PATH`` and enforces the + Debian delivery contract: 11 ECLI-managed tools under + ``/opt/ecli/payload/bin``, 8 Debian tools under system directories, 19 + total, and no resolution from unapproved locations such as + ``/usr/local`` or user-local directories. Returns 0 only when every + requirement holds. + """ + provider_count = _register_f4_providers() + print(f"F4 registered diagnostic providers: {provider_count} (fixed; see docstring)") + managed = system = 0 + failures: list[str] = [] + for definition in toolchain_definitions(): + executable = definition.executable + resolved = shutil.which(executable, path=path) + if resolved is None: + failures.append(f"{definition.display_name}: executable not found") + print(f"[MISSING] {definition.display_name:<20} {executable}") + continue + if executable in MANAGED_EXECUTABLES: + expected = "managed" + location_ok = resolved.startswith(MANAGED_PATH_PREFIX) + else: + expected = "system" + location_ok = any( + resolved.startswith(prefix) for prefix in SYSTEM_PATH_PREFIXES + ) + if not location_ok: + failures.append( + f"{definition.display_name}: resolved outside the approved " + f"{expected} location: {resolved}" + ) + print(f"[DISALLOWED] {definition.display_name:<20} {resolved}") + continue + if expected == "managed": + managed += 1 + else: + system += 1 + print(f"[OK] {expected:<8}{definition.display_name:<20} -> {resolved}") + detected = managed + system + print( + f"F4 toolchain: {detected}/{TOOLCHAIN_TOTAL} executables " + f"provisioned and verified ({managed} managed, {system} system) " + f"-- distinct from the {provider_count} registered diagnostic " + "providers above" + ) + if managed != MANAGED_TOOL_COUNT: + failures.append( + f"managed tool count {managed} != required {MANAGED_TOOL_COUNT}" + ) + if system != SYSTEM_TOOL_COUNT: + failures.append( + f"Debian tool count {system} != required {SYSTEM_TOOL_COUNT}" + ) + for failure in failures: + print(f"[FAIL] {failure}") + return 0 if not failures else 1 diff --git a/src/ecli/integrations/LinterBridge.py b/src/ecli/integrations/LinterBridge.py index a29f09e..a51c82a 100755 --- a/src/ecli/integrations/LinterBridge.py +++ b/src/ecli/integrations/LinterBridge.py @@ -45,6 +45,7 @@ from ecli.extensions.linters.clang_tidy.provider import ClangTidyDiagnosticProvider from ecli.extensions.linters.core.models import DiagnosticsSnapshot from ecli.extensions.linters.core.service import DiagnosticsService, initial_snapshot +from ecli.extensions.linters.core.toolchain_check import log_toolchain_availability from ecli.extensions.linters.cppcheck.provider import CppcheckDiagnosticProvider from ecli.extensions.linters.hadolint.provider import HadolintDiagnosticProvider from ecli.extensions.linters.java_checkstyle.provider import ( @@ -126,6 +127,10 @@ def __init__(self, editor: "Ecli") -> None: logging.error("Found 'lint_devops' but failed to import it: %s", e) self.HAS_DEVOPS_LINTERS = False + # Startup F4 toolchain availability report: lists missing linter + # executables in the log instead of crashing (non-fatal by contract). + log_toolchain_availability(logger) + def request_diagnostics_refresh(self, scope: str = "buffer") -> bool: """Schedule a bounded background diagnostics refresh.""" file_path = self._current_file_path() diff --git a/tests/extensions/linters/test_toolchain_check.py b/tests/extensions/linters/test_toolchain_check.py new file mode 100644 index 0000000..ab81a81 --- /dev/null +++ b/tests/extensions/linters/test_toolchain_check.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: tests/extensions/linters/test_toolchain_check.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Tests for the F4 toolchain availability check and ``--f4-check``.""" + +from __future__ import annotations + +import logging + +import pytest + +from ecli.extensions.linters.core import toolchain_check as tc + + +def test_toolchain_is_exactly_nineteen_tools(): + definitions = tc.toolchain_definitions() + assert len(definitions) == tc.TOOLCHAIN_TOTAL == 19 + executables = {definition.executable for definition in definitions} + assert executables == tc.MANAGED_EXECUTABLES | tc.SYSTEM_EXECUTABLES + + +def test_managed_and_system_split_contract(): + assert len(tc.MANAGED_EXECUTABLES) == tc.MANAGED_TOOL_COUNT == 11 + assert len(tc.SYSTEM_EXECUTABLES) == tc.SYSTEM_TOOL_COUNT == 8 + assert not tc.MANAGED_EXECUTABLES & tc.SYSTEM_EXECUTABLES + + +def test_missing_toolchain_executables_lists_all_on_empty_path(): + missing = tc.missing_toolchain_executables(path="/nonexistent") + assert len(missing) == 19 + + +def test_provider_count_is_fixed_at_fourteen_not_nineteen(): + """Regression guard: the registered-provider count must never silently + drift to equal the 19-tool toolchain count without a deliberate change + (i.e. implementing the five missing providers). + """ + assert tc._register_f4_providers() == 14 + assert 14 != tc.TOOLCHAIN_TOTAL + + +def test_no_narrative_surface_claims_nineteen_active_providers(): + """Static guard against reintroducing the "19 providers" conflation + the codebase must never claim: 19 tools are provisioned/verified, only + 14 have a registered diagnostic provider. + """ + import pathlib + + repo_root = pathlib.Path(__file__).resolve().parents[3] + forbidden = ("19 providers", "19 active provider", "19 active F4 provider") + surfaces = ( + repo_root / "src/ecli/extensions/linters/core/toolchain_check.py", + repo_root / "src/ecli/__main__.py", + repo_root / "docs/install/debian.md", + repo_root / "scripts/build_and_package_deb.py", + repo_root / "scripts/install_ecli_linters.py", + ) + for surface in surfaces: + text = surface.read_text(encoding="utf-8") + for phrase in forbidden: + assert phrase not in text, f"{surface}: forbidden phrase {phrase!r}" + + +def test_log_toolchain_availability_never_raises(caplog): + logger = logging.getLogger("toolchain-check-test") + with caplog.at_level(logging.INFO, logger=logger.name): + tc.log_toolchain_availability(logger, path="/nonexistent") + assert any("missing" in record.message for record in caplog.records) + + +@pytest.fixture +def fake_toolchain(tmp_path, monkeypatch): + """Build a fake 11-managed / 8-system toolchain layout on disk.""" + managed_dir = tmp_path / "payload-bin" + system_dir = tmp_path / "usr-bin" + managed_dir.mkdir() + system_dir.mkdir() + for name in tc.MANAGED_EXECUTABLES: + path = managed_dir / name + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + for name in tc.SYSTEM_EXECUTABLES: + path = system_dir / name + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + monkeypatch.setattr(tc, "MANAGED_PATH_PREFIX", f"{managed_dir}/") + monkeypatch.setattr(tc, "SYSTEM_PATH_PREFIXES", (f"{system_dir}/",)) + monkeypatch.setattr(tc, "_register_f4_providers", lambda: 14) + return managed_dir, system_dir + + +def test_f4_check_passes_for_contract_layout(fake_toolchain, capsys): + managed_dir, system_dir = fake_toolchain + rc = tc.f4_check_main(path=f"{managed_dir}:{system_dir}") + out = capsys.readouterr().out + assert rc == 0 + assert "F4 toolchain: 19/19 executables provisioned and verified" in out + assert "(11 managed, 8 system)" in out + assert "F4 registered diagnostic providers: 14" in out + + +def test_f4_check_fails_when_tool_resolves_from_unapproved_dir( + fake_toolchain, tmp_path, capsys +): + managed_dir, system_dir = fake_toolchain + rogue_dir = tmp_path / "usr-local-bin" + rogue_dir.mkdir() + (managed_dir / "ruff").rename(rogue_dir / "ruff") + rc = tc.f4_check_main(path=f"{rogue_dir}:{managed_dir}:{system_dir}") + out = capsys.readouterr().out + assert rc == 1 + assert "[DISALLOWED]" in out + assert "managed tool count 10 != required 11" in out + + +def test_f4_check_fails_on_missing_tool(fake_toolchain, capsys): + managed_dir, system_dir = fake_toolchain + (system_dir / "sqlfluff").unlink() + rc = tc.f4_check_main(path=f"{managed_dir}:{system_dir}") + out = capsys.readouterr().out + assert rc == 1 + assert "[MISSING]" in out + assert "Debian tool count 7 != required 8" in out + + +def test_f4_provider_layer_registers_fourteen_providers(): + assert tc._register_f4_providers() == 14 diff --git a/tests/packaging/test_build_and_package_deb_script.py b/tests/packaging/test_build_and_package_deb_script.py index ed8ed0c..0c3cf93 100644 --- a/tests/packaging/test_build_and_package_deb_script.py +++ b/tests/packaging/test_build_and_package_deb_script.py @@ -62,3 +62,149 @@ def test_man_page_and_desktop(deb: ModuleType) -> None: def test_find_executable_missing(deb: ModuleType, tmp_path: Path) -> None: assert deb.find_executable(tmp_path) is None + + +# --------------------------------------------------------------------------- +# Legacy F4 evidence hook: must be strictly post-promotion and non-gating. +# +# record_f4_evidence_non_gating() is called from main() only AFTER +# build_deb_atomic() has already atomically promoted final_deb/final_sha +# (see main()'s source: the call sits directly after `print(f"DONE: +# {final_deb}")`, which itself only executes once build_deb_atomic() +# returned without raising). These tests cover the function's own +# contract in isolation: neither a non-zero return nor a raised exception +# from the underlying hook may ever propagate, and the promoted artifact +# and its sidecar must be left byte-for-byte unchanged either way. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def promoted_artifact(tmp_path: Path) -> tuple[Path, Path, bytes, bytes]: + """A fake already-promoted .deb + sidecar, as record_f4_evidence_non_gating + would find them: written and stable before the hook ever runs. + """ + final_deb = tmp_path / "ecli_0.2.4_linux_x86_64.deb" + final_sha = tmp_path / "ecli_0.2.4_linux_x86_64.deb.sha256" + deb_bytes = b"promoted-deb-bytes-do-not-touch" + sha_bytes = b"0" * 64 + b" ecli_0.2.4_linux_x86_64.deb\n" + final_deb.write_bytes(deb_bytes) + final_sha.write_bytes(sha_bytes) + return final_deb, final_sha, deb_bytes, sha_bytes + + +def test_f4_hook_nonzero_return_is_fully_non_gating( + deb: ModuleType, + tmp_path: Path, + promoted_artifact: tuple[Path, Path, bytes, bytes], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + final_deb, final_sha, deb_bytes, sha_bytes = promoted_artifact + monkeypatch.setattr( + deb, "run_or_record_f4_linter_provisioning_for_artifacts", lambda *a, **k: 1 + ) + + deb.record_f4_evidence_non_gating(tmp_path, final_deb) # must not raise + + assert final_deb.read_bytes() == deb_bytes, "promoted artifact must be unchanged" + assert final_sha.read_bytes() == sha_bytes, "sidecar must be unchanged" + stderr = capsys.readouterr().err + assert "WARNING" in stderr + assert "F4 linter provisioning" in stderr + + +def test_f4_hook_exception_is_fully_non_gating( + deb: ModuleType, + tmp_path: Path, + promoted_artifact: tuple[Path, Path, bytes, bytes], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + final_deb, final_sha, deb_bytes, sha_bytes = promoted_artifact + + def _raise(*_args: object, **_kwargs: object) -> int: + raise RuntimeError("boom: simulated hook crash") + + monkeypatch.setattr( + deb, "run_or_record_f4_linter_provisioning_for_artifacts", _raise + ) + + deb.record_f4_evidence_non_gating(tmp_path, final_deb) # must not raise + + assert final_deb.read_bytes() == deb_bytes, "promoted artifact must be unchanged" + assert final_sha.read_bytes() == sha_bytes, "sidecar must be unchanged" + stderr = capsys.readouterr().err + assert "WARNING" in stderr + assert "boom: simulated hook crash" in stderr + + +def test_f4_hook_success_prints_no_warning( + deb: ModuleType, + tmp_path: Path, + promoted_artifact: tuple[Path, Path, bytes, bytes], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + final_deb, _final_sha, _deb_bytes, _sha_bytes = promoted_artifact + monkeypatch.setattr( + deb, "run_or_record_f4_linter_provisioning_for_artifacts", lambda *a, **k: 0 + ) + + deb.record_f4_evidence_non_gating(tmp_path, final_deb) + + assert "WARNING" not in capsys.readouterr().err + + +def test_main_calls_hook_only_after_done_and_unconditionally_returns_ok( + deb: ModuleType, +) -> None: + """Structural guard: main() must call the hook strictly after promotion + (after the "DONE:" print) and must return EXIT_OK unconditionally + afterward, with nothing branching on the hook's outcome. + """ + import ast + import inspect + + source = inspect.getsource(deb.main) + tree = ast.parse(source) + (main_func,) = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)] + body = main_func.body + hook_index = next( + i + for i, node in enumerate(body) + if isinstance(node, ast.Expr) + and isinstance(node.value, ast.Call) + and getattr(node.value.func, "id", "") == "record_f4_evidence_non_gating" + ) + done_print_index = next( + i + for i, node in enumerate(body) + if isinstance(node, ast.Expr) + and isinstance(node.value, ast.Call) + and getattr(node.value.func, "id", "") == "print" + and any( + isinstance(arg, ast.JoinedStr) + and any( + isinstance(v, ast.Constant) and "DONE:" in str(v.value) + for v in arg.values + if isinstance(v, ast.Constant) + ) + for arg in node.value.args + ) + ) + assert done_print_index < hook_index, ( + "the hook must be called after the DONE: promotion message" + ) + # Nothing after the hook call may be an `if`/branch on its result -- + # main() must fall straight through to an unconditional `return EXIT_OK`. + remainder = body[hook_index + 1 :] + assert len(remainder) == 1 + assert isinstance(remainder[0], ast.Return) + assert getattr(remainder[0].value, "id", "") == "EXIT_OK" + + +def test_hook_is_documented_as_legacy_non_gating_evidence(deb: ModuleType) -> None: + doc = deb.record_f4_evidence_non_gating.__doc__ or "" + assert "LEGACY" in doc or "legacy" in doc + assert "non-gating" in doc + assert "NOT proof" in doc or "not proof" in doc.lower() diff --git a/tests/packaging/test_debian_two_stage_install_contract.py b/tests/packaging/test_debian_two_stage_install_contract.py new file mode 100644 index 0000000..afe44f7 --- /dev/null +++ b/tests/packaging/test_debian_two_stage_install_contract.py @@ -0,0 +1,342 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: tests/packaging/test_debian_two_stage_install_contract.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Contract tests for the two-stage Debian installation model. + +Stage 1: ``scripts/install_ecli_linters.py`` provisions the 19-tool F4 +linter toolchain. Stage 2: the ECLI ``.deb`` installs ECLI itself. The +``.deb`` never deletes user data, never provisions linters, and Snap +stays outside the canonical 21-artifact release contract. +""" + +from __future__ import annotations + +import json +import stat +from pathlib import Path + +import pytest +from conftest import CANONICAL_ARTIFACTS + + +REPO_ROOT = Path(__file__).resolve().parents[2] +FPM_COMMON = REPO_ROOT / "packaging" / "linux" / "fpm-common" +LOCK_PATH = REPO_ROOT / "packaging" / "debian" / "ecli-linter-lock.json" +NPM_LOCK_DIR = REPO_ROOT / "packaging" / "debian" / "markdownlint-cli2" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +class TestMaintainerScriptsPreserveUserData: + @pytest.mark.parametrize("name", ["postinst", "prerm", "postrm"]) + def test_scripts_are_posix_sh_and_executable(self, name): + script = FPM_COMMON / name + text = _read(script) + assert text.startswith("#!/bin/sh\n"), f"{name} must use POSIX /bin/sh" + mode = stat.S_IMODE(script.stat().st_mode) + assert mode & 0o755 == 0o755, f"{name} must be mode 0755" + + @pytest.mark.parametrize("name", ["postinst", "prerm", "postrm"]) + def test_scripts_never_touch_user_configuration(self, name): + text = _read(FPM_COMMON / name) + assert "rm -rf" not in text, f"{name} must not recursively delete" + assert "/home/*" not in text, f"{name} must not iterate /home/*" + assert "for homedir" not in text, f"{name} must not enumerate homes" + + @pytest.mark.parametrize("name", ["postinst", "prerm", "postrm"]) + def test_scripts_are_offline_and_apt_free(self, name): + text = _read(FPM_COMMON / name) + for forbidden in ("apt-get", "apt install", "curl ", "wget ", "npm "): + assert forbidden not in text, f"{name} must not invoke {forbidden!r}" + + def test_purge_preserves_root_and_home_configuration(self): + text = _read(FPM_COMMON / "postrm") + assert "preserves" in text or "never" in text.lower() + assert ".config/ecli" not in text.replace("~/.config/ecli", "").replace( + "/root/.config/ecli", "" + ), "postrm must not carry deletable user-config paths" + + def test_postinst_payload_check_cannot_fail_install(self): + text = _read(FPM_COMMON / "postinst") + assert "/opt/ecli/payload/bin" in text + assert text.rstrip().endswith("exit 0") + assert "exit 1" not in text + + def test_postinst_never_references_a_repo_relative_script_path(self): + """Postinst runs on a machine that may only ever have installed the + .deb -- it must never point at a repository-relative script path + that only exists inside a source checkout, since that path will + not exist on such a machine. + """ + text = _read(FPM_COMMON / "postinst") + assert "scripts/install_ecli_linters.py" not in text + assert "sudo python3 scripts/" not in text + + +class TestInstallerDistributionContract: + """docs/install/debian.md must document how to obtain the standalone + installer bundle -- copying only the .py file is not sufficient, its + lock inputs live alongside it -- and must not assume a Python 3 + interpreter is already present on a minimal Debian 13 install. + """ + + def test_documents_the_four_file_bundle_layout(self): + text = _read(REPO_ROOT / "docs" / "install" / "debian.md") + for required in ( + "install_ecli_linters.py", + "ecli-linter-lock.json", + "markdownlint-cli2/", + "package.json", + "package-lock.json", + ): + assert required in text, required + + def test_documents_python3_prerequisite_for_minimal_debian(self): + text = _read(REPO_ROOT / "docs" / "install" / "debian.md") + assert "apt-get install -y python3" in text + + def test_documents_a_no_clone_bootstrap_path(self): + """Users without a repository checkout must have a documented way + to fetch just the four bundle files. + """ + text = _read(REPO_ROOT / "docs" / "install" / "debian.md") + assert "curl" in text + assert "raw.githubusercontent.com" in text + + +class TestMinimalDebOnlyInstallScript: + """The minimal .deb-only proof must run BEFORE the full two-stage + integration test and must not run the linter installer, preinstall + Python, or preinstall any F4 toolchain package. + """ + + SCRIPT = REPO_ROOT / "packaging" / "debian" / "verify_deb_minimal_install.sh" + + def test_script_exists_and_is_executable_posix_sh(self): + text = _read(self.SCRIPT) + assert text.startswith("#!/bin/sh\n") + mode = stat.S_IMODE(self.SCRIPT.stat().st_mode) + assert mode & 0o755 == 0o755 + + def test_script_never_runs_the_linter_installer(self): + # The header comment documents (in prose) that this script does + # NOT run the installer -- that mention is fine. What must never + # appear is an actual invocation of it. + text = _read(self.SCRIPT) + assert "python3 scripts/install_ecli_linters.py" not in text + assert "python3 install_ecli_linters.py" not in text + + def test_script_never_preinstalls_python_or_toolchain_packages(self): + text = _read(self.SCRIPT) + assert "apt-get install -y python3" not in text + assert "apt-get install -y -qq python3" not in text + # apt-get install is invoked exactly once (against the .deb + # argument, asserted separately below); it never names a linter + # package, so no apt-get call can be preinstalling the toolchain. + assert text.count("apt-get install") == 1 + + def test_script_installs_only_the_deb_argument(self): + text = _read(self.SCRIPT) + # apt-get install is invoked exactly once, against the $DEB argv, + # never against a package name list. + assert 'apt-get install -y "$DEB"' in text + assert text.count("apt-get install") == 1 + + def test_script_verifies_version_and_ldd_resolution(self): + text = _read(self.SCRIPT) + assert "ecli --version" in text + assert "ldd " in text + assert "not found" in text + + +class TestTwoStageSeparation: + def test_installer_exists_and_is_standalone(self): + import ast + + installer = REPO_ROOT / "scripts" / "install_ecli_linters.py" + text = _read(installer) + assert "import ecli" not in text and "from ecli" not in text + tree = ast.parse(text) + docstrings = set() + for node in ast.walk(tree): + if isinstance( + node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + ): + body = getattr(node, "body", []) + if ( + body + and isinstance(body[0], ast.Expr) + and isinstance(body[0].value, ast.Constant) + ): + docstrings.add(id(body[0].value)) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + assert all(keyword.arg != "shell" for keyword in node.keywords) + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in docstrings + ): + assert "releases/latest" not in node.value, ( + "installer code must never query releases/latest" + ) + + def test_deb_build_never_bundles_linter_payload(self): + import ast + + text = _read(REPO_ROOT / "scripts" / "build_and_package_deb.py") + assert "/opt/ecli/payload" not in text, ( + "the .deb must not stage or modify the linter payload" + ) + # The package description may *mention* the stage-1 installer, but + # the build must never execute it or pass it to any command. + tree = ast.parse(text) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + for arg in ast.walk(node): + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + assert "install_ecli_linters" not in arg.value, ( + "the .deb build must not invoke the linter installer" + ) + + def test_lock_is_committed_and_pinned(self): + lock = json.loads(_read(LOCK_PATH)) + tools = lock["tools"] + assert len(tools) == 11 + for tool_id, entry in tools.items(): + assert entry["asset_url"].startswith("https://"), tool_id + assert "latest" not in entry["asset_url"], tool_id + assert "master" not in str(entry["version"]).lower(), tool_id + assert len(entry["sha256"]) == 64, tool_id + assert entry["architecture"] == "amd64", tool_id + + def test_npm_lock_inputs_are_committed(self): + manifest = json.loads(_read(NPM_LOCK_DIR / "package.json")) + lockfile = json.loads(_read(NPM_LOCK_DIR / "package-lock.json")) + pinned = manifest["dependencies"]["markdownlint-cli2"] + assert ( + pinned == lockfile["packages"]["node_modules/markdownlint-cli2"]["version"] + ) + assert not pinned.startswith(("^", "~", ">", "<", "*")), ( + "markdownlint-cli2 must be pinned exactly" + ) + + +class TestDebPackagingHygiene: + def test_deb_ships_only_postinst_never_empty_maintainer_scripts(self): + """Empty prerm/postrm must not be packaged (Debian policy). + + The files stay in packaging/linux/fpm-common for the RPM pipeline, + but the .deb build must not attach them. + """ + text = _read(REPO_ROOT / "scripts" / "build_and_package_deb.py") + assert "--after-install" in text + assert "--before-remove" not in text + assert "--after-remove" not in text + + def test_deb_build_carries_lintian_hygiene(self): + text = _read(REPO_ROOT / "scripts" / "build_and_package_deb.py") + assert '"libc6",' in text, "libc.so.6 is NEEDED; libc6 must be a dependency" + assert "debian_copyright" in text + assert "debian_changelog" in text + assert "strip_control_fields" in text + assert "hardening-no-pie" in text + assert "SOURCE_DATE_EPOCH" in text + synopsis = text.split('DEB_DESCRIPTION = (\n "', 1)[1].split("\\n", 1)[0] + assert not synopsis.lower().startswith("ecli"), ( + "Debian synopsis must not start with the package name" + ) + + def test_lintian_override_is_context_free_for_cross_version_portability( + self, repo_root + ): + """The override file syntax for a tag's context is not portable + across the two lintian releases this pipeline runs (bullseye + 2.104 build gate vs. trixie 2.122 downstream validation): one + wants a bracket-free context, the other wants brackets, and each + rejects the other's format as a "mismatched-override" no-op. A + context-free override (unambiguous here since the package ships + exactly one ELF binary) works identically on both. + """ + from conftest import load_script_module + + module = load_script_module( + repo_root, "scripts/build_and_package_deb.py", "deb_lintian_contract" + ) + override_lines = [ + line + for line in module.LINTIAN_OVERRIDES.splitlines() + if line and not line.startswith("#") + ] + assert override_lines == ["ecli: hardening-no-pie"] + + def test_deb_description_distinguishes_provisioned_from_registered(self): + """The package description must never claim 19 active F4 providers; + only 14 diagnostic providers are registered against 19 provisioned + tool executables. + """ + text = _read(REPO_ROOT / "scripts" / "build_and_package_deb.py") + assert "19 provisioned" in text + assert "14 registered provider" in text + assert "19 providers" not in text + assert "19 active provider" not in text + + def test_smoke_harness_covers_all_nineteen_tools(self): + import ast + import importlib.util + import sys as _sys + + path = REPO_ROOT / "scripts" / "verify_f4_toolchain_smoke.py" + text = _read(path) + assert "import ecli" not in text and "from ecli" not in text + tree = ast.parse(text) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + assert all(keyword.arg != "shell" for keyword in node.keywords) + spec = importlib.util.spec_from_file_location("f4_smoke_contract", path) + module = importlib.util.module_from_spec(spec) + _sys.modules["f4_smoke_contract"] = module + spec.loader.exec_module(module) + cases = module.SMOKE_CASES + assert len(cases) == 19 + assert len({case.tool_id for case in cases}) == 19 + for case in cases: + assert case.marker, case.tool_id + assert case.rc_note, case.tool_id + + +class TestSnapStaysOutsideReleaseContract: + def test_canonical_contract_is_exactly_twenty_one_artifacts(self): + assert len(CANONICAL_ARTIFACTS) == 21 + assert not any( + ".snap" in artifact.artifact_token for artifact in CANONICAL_ARTIFACTS + ) + + def test_no_snapcraft_manifest_in_repository(self): + assert not (REPO_ROOT / "snapcraft.yaml").exists() + + def test_makefile_snap_targets_are_hard_disabled(self): + makefile = _read(REPO_ROOT / "Makefile") + assert "Snap targets are disabled" in makefile + assert "snapcraft\n" not in makefile, ( + "no live snapcraft invocation may remain in the Makefile" + ) + for target in ( + "package-snap", + "package-snap-assert", + "show-snap-artifacts", + "release-snap", + ): + assert target in makefile, f"refusal stub for {target} missing" diff --git a/tests/packaging/test_install_ecli_linters_script.py b/tests/packaging/test_install_ecli_linters_script.py new file mode 100644 index 0000000..e78a628 --- /dev/null +++ b/tests/packaging/test_install_ecli_linters_script.py @@ -0,0 +1,1501 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Project: Ecli +# File: tests/packaging/test_install_ecli_linters_script.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the GNU General Public License version 2 only. +# See the LICENSE file in the project root for full license text. + +"""Behavior tests for the standalone Debian 13 linter installer. + +Covers menu parsing, invalid input, root/Debian-13/amd64 validation, APT +dependency resolution, secure command arrays, lock-file validation, +HTTPS-only downloads, checksum mismatch, interrupted downloads, safe +extraction (traversal / symlink escape / exact member), idempotent +reinstall, managed upgrade, corrupt binaries, PATH script idempotency, +state-file atomicity, version-probe handling (including stderr output), +partial failure, custom selections, the full 19-tool result, and the +no-false-success guarantee. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import io +import os +import stat +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALLER_PATH = REPO_ROOT / "scripts" / "install_ecli_linters.py" +_MODULE_NAME = "install_ecli_linters_under_test" + + +def _load_installer(): + if _MODULE_NAME in sys.modules: + return sys.modules[_MODULE_NAME] + spec = importlib.util.spec_from_file_location(_MODULE_NAME, INSTALLER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[_MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +MOD = _load_installer() + +ALL_NUMBERS = frozenset(range(1, 20)) + + +class LogStub: + """Log double capturing every line without touching /var/log.""" + + def __init__(self) -> None: + """Start with an empty capture buffer.""" + self.lines: list[str] = [] + + def log(self, message: str, *, echo: bool | None = None) -> None: + self.lines.append(message) + + def detail(self, message: str) -> None: + self.lines.append(message) + + def close(self) -> None: + pass + + @property + def text(self) -> str: + return "\n".join(self.lines) + + +@pytest.fixture +def payload_env(tmp_path, monkeypatch): + """Redirect every payload path constant into an isolated tmp tree.""" + root = tmp_path / "opt-ecli-payload" + mapping = { + "PAYLOAD_ROOT": root, + "PAYLOAD_BIN": root / "bin", + "PAYLOAD_PACKAGES": root / "packages", + "PAYLOAD_STATE": root / "state", + "PAYLOAD_CACHE": root / "cache", + "STATE_FILE": root / "state" / "installed-tools.json", + "PROFILE_SCRIPT": tmp_path / "profile.d" / "ecli_payload.sh", + } + for name, value in mapping.items(): + monkeypatch.setattr(MOD, name, value) + for name in ("PAYLOAD_BIN", "PAYLOAD_PACKAGES", "PAYLOAD_STATE", "PAYLOAD_CACHE"): + mapping[name].mkdir(parents=True, exist_ok=True) + return mapping + + +def _fake_tool_script(version_line: str, *, to_stderr: bool = False) -> bytes: + stream = "1>&2" if to_stderr else "" + return f'#!/bin/sh\necho "{version_line}" {stream}\n'.encode() + + +def _make_tar_gz(path: Path, members: dict[str, bytes], *, mode: int = 0o755): + with tarfile.open(path, "w:gz") as handle: + for name, payload in members.items(): + info = tarfile.TarInfo(name) + info.size = len(payload) + info.mode = mode + handle.addfile(info, io.BytesIO(payload)) + + +def _ruff_lock_entry(archive: Path, script: bytes) -> dict: + return { + "tool_id": "ruff", + "version": "0.15.21", + "source_url": "https://example.invalid/ruff", + "asset_url": "https://example.invalid/ruff.tar.gz", + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "archive_type": "tar.gz", + "expected_member": "ruff-x86_64-unknown-linux-gnu/ruff", + "executable_name": "ruff", + "version_command": ["ruff", "--version"], + "install_directory": str(MOD.PAYLOAD_BIN), + "architecture": "amd64", + "license": "MIT", + "max_download_bytes": max(archive.stat().st_size, 1), + } + + +# --------------------------------------------------------------------------- +# Menu parsing and invalid input +# --------------------------------------------------------------------------- + + +class TestSelectionParsing: + def test_a_selects_all_nineteen(self): + assert MOD.parse_selection("A") == ALL_NUMBERS + assert MOD.parse_selection("a") == ALL_NUMBERS + assert MOD.parse_selection(" a ") == ALL_NUMBERS + + def test_single_number(self): + assert MOD.parse_selection("7") == frozenset({7}) + + def test_comma_separated_with_whitespace(self): + assert MOD.parse_selection(" 1 , 4 ,5 ") == frozenset({1, 4, 5}) + + def test_duplicates_deduplicated(self): + assert MOD.parse_selection("2,2,2") == frozenset({2}) + + @pytest.mark.parametrize( + "raw", + ["", " ", "0", "20", "-1", "1,,2", "1;2", "one", "1 2", "a,1", "A,3", "1.5"], + ) + def test_malformed_input_rejected_not_ignored(self, raw): + with pytest.raises(MOD.SelectionError): + MOD.parse_selection(raw) + + def test_menu_lists_all_nineteen_tools_in_english(self): + menu = MOD.render_menu() + assert "[ A ] - Install All Linters" in menu + for tool in MOD.TOOLS: + assert f"{tool.number}." in menu + assert tool.menu_name in menu + assert len(MOD.TOOLS) == 19 + + def test_interactive_prompt_reprompts_then_accepts(self, capsys): + answers = iter(["bogus", "1,19"]) + selected = MOD.prompt_selection(input_fn=lambda _: next(answers)) + assert selected == frozenset({1, 19}) + assert "invalid selection" in capsys.readouterr().err + + def test_interactive_prompt_gives_up_after_bounded_attempts(self, capsys): + with pytest.raises(MOD.SelectionError): + MOD.prompt_selection(input_fn=lambda _: "nope") + + +# --------------------------------------------------------------------------- +# Root, Debian 13, and amd64 requirements +# --------------------------------------------------------------------------- + + +class TestPlatformValidation: + GOOD = {"ID": "debian", "VERSION_ID": "13"} + + def test_supported_platform_has_no_errors(self): + assert ( + MOD.platform_errors( + euid=0, + sys_platform="linux", + os_release=self.GOOD, + dpkg_arch="amd64", + ) + == [] + ) + + def test_root_required(self): + errors = MOD.platform_errors( + euid=1000, sys_platform="linux", os_release=self.GOOD, dpkg_arch="amd64" + ) + assert any("root" in error for error in errors) + + def test_debian_required(self): + errors = MOD.platform_errors( + euid=0, + sys_platform="linux", + os_release={"ID": "ubuntu", "VERSION_ID": "24.04"}, + dpkg_arch="amd64", + ) + assert any("Debian is required" in error for error in errors) + + @pytest.mark.parametrize("version_id", ["12", "14", "13.0.0-weird", ""]) + def test_debian_major_thirteen_required(self, version_id): + os_release = {"ID": "debian", "VERSION_ID": version_id} + errors = MOD.platform_errors( + euid=0, sys_platform="linux", os_release=os_release, dpkg_arch="amd64" + ) + if version_id.startswith("13"): + assert errors == [] + else: + assert any("Debian 13" in error for error in errors) + + @pytest.mark.parametrize("arch", ["arm64", "i386", None]) + def test_amd64_required(self, arch): + errors = MOD.platform_errors( + euid=0, sys_platform="linux", os_release=self.GOOD, dpkg_arch=arch + ) + assert any("amd64" in error for error in errors) + + def test_linux_required(self): + errors = MOD.platform_errors( + euid=0, sys_platform="freebsd14", os_release=self.GOOD, dpkg_arch="amd64" + ) + assert any("Linux is required" in error for error in errors) + + @pytest.mark.skipif(os.geteuid() == 0, reason="running as root") + def test_main_exits_nonzero_on_unsupported_invocation(self): + assert MOD.main(["--select", "A"]) == MOD.EXIT_PLATFORM + + +# --------------------------------------------------------------------------- +# APT dependency resolution (single transaction) +# --------------------------------------------------------------------------- + + +class TestAptResolution: + def test_apt_only_tool_needs_no_acquisition_packages(self): + assert MOD.resolve_apt_packages(frozenset({4})) == ("yamllint",) + + def test_standalone_tool_adds_base_acquisition_packages(self): + assert MOD.resolve_apt_packages(frozenset({1})) == ("ca-certificates",) + + def test_java_tools_require_headless_jre(self): + packages = MOD.resolve_apt_packages(frozenset({14, 15})) + assert "default-jre-headless" in packages + + def test_markdownlint_requires_nodejs_and_npm(self): + packages = MOD.resolve_apt_packages(frozenset({3})) + assert {"nodejs", "npm"} <= set(packages) + + def test_golangci_lint_requires_go_toolchain(self): + assert "golang-go" in MOD.resolve_apt_packages(frozenset({17})) + + def test_cargo_clippy_maps_to_cargo_and_rust_clippy(self): + packages = MOD.resolve_apt_packages(frozenset({16})) + assert {"cargo", "rust-clippy"} <= set(packages) + + def test_full_selection_resolves_complete_sorted_set(self): + packages = MOD.resolve_apt_packages(ALL_NUMBERS) + assert packages == tuple(sorted(packages)) + assert { + "yamllint", + "shellcheck", + "clang-tidy", + "cppcheck", + "clang-format", + "checkstyle", + "cargo", + "rust-clippy", + "sqlfluff", + "nodejs", + "npm", + "default-jre-headless", + "golang-go", + "ca-certificates", + } == set(packages) + + def test_apt_install_is_one_update_plus_one_install(self, monkeypatch): + calls: list[list[str]] = [] + monkeypatch.setattr( + MOD, + "run_command", + lambda argv, log, **kwargs: calls.append(list(argv)), + ) + log = LogStub() + MOD.apt_install(("pkg-a", "pkg-b"), log) + assert calls == [ + ["apt-get", "update"], + [ + "apt-get", + "install", + "--yes", + "--no-install-recommends", + "pkg-a", + "pkg-b", + ], + ] + + +# --------------------------------------------------------------------------- +# Secure command execution +# --------------------------------------------------------------------------- + + +class TestCommandRunner: + def test_installer_source_never_uses_shell_true(self): + import ast + + tree = ast.parse(INSTALLER_PATH.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + for keyword in node.keywords: + assert keyword.arg != "shell", ( + f"shell= keyword found at line {node.lineno}" + ) + + def test_installer_never_imports_ecli_modules(self): + source = INSTALLER_PATH.read_text(encoding="utf-8") + assert "import ecli" not in source + assert "from ecli" not in source + + def test_run_command_rejects_non_string_argv(self): + with pytest.raises(MOD.InstallerError): + MOD.run_command(["echo", 42], LogStub(), timeout=5) # type: ignore[list-item] + + def test_run_command_reports_failure_diagnostics(self): + with pytest.raises(MOD.InstallerError) as excinfo: + MOD.run_command(["/bin/sh", "-c", "exit 3"], LogStub(), timeout=5) + assert "exit 3" in str(excinfo.value) + + def test_run_command_reports_missing_executable(self): + with pytest.raises(MOD.InstallerError) as excinfo: + MOD.run_command(["/does/not/exist-xyz"], LogStub(), timeout=5) + assert "not found" in str(excinfo.value) + + def test_command_environment_never_leaks_caller_env(self, monkeypatch): + monkeypatch.setenv("SECRET_TOKEN", "hunter2") + env = MOD.command_environment() + assert "SECRET_TOKEN" not in env + + +# --------------------------------------------------------------------------- +# Lock-file validation +# --------------------------------------------------------------------------- + + +class TestLockValidation: + def test_committed_lock_passes_and_covers_all_standalone_tools(self): + lock = MOD.load_lock(REPO_ROOT / "packaging/debian/ecli-linter-lock.json") + expected_ids = { + tool.lock_id for tool in MOD.TOOLS if tool.kind in ("payload", "npm") + } + assert set(lock) == expected_ids + assert len(lock) == 11 + + def test_committed_lock_uses_https_only_and_real_checksums(self): + lock = MOD.load_lock(REPO_ROOT / "packaging/debian/ecli-linter-lock.json") + for tool_id, entry in lock.items(): + assert entry["asset_url"].startswith("https://"), tool_id + assert entry["source_url"].startswith("https://"), tool_id + assert len(entry["sha256"]) == 64, tool_id + assert "latest" not in entry["asset_url"], tool_id + assert "master" not in str(entry["version"]).lower(), tool_id + + def test_missing_lock_file_raises(self, tmp_path): + with pytest.raises(MOD.LockError): + MOD.load_lock(tmp_path / "absent.json") + + def test_lock_entry_rejects_http_url(self): + entry = {field: "x" for field in MOD.LOCK_REQUIRED_FIELDS} + entry.update( + tool_id="ruff", + source_url="http://example.invalid", + asset_url="https://example.invalid/a", + sha256="0" * 64, + archive_type="tar.gz", + expected_member="dir/ruff", + version_command=["ruff", "--version"], + install_directory="/opt/ecli/payload/bin", + architecture="amd64", + version="1.0.0", + max_download_bytes=10, + ) + errors = MOD.validate_lock_entry("ruff", entry) + assert any("https" in error for error in errors) + + def test_lock_entry_rejects_bad_sha256_and_master_version(self): + entry = { + "tool_id": "zig", + "version": "master", + "source_url": "https://example.invalid", + "asset_url": "https://example.invalid/zig.tar.xz", + "sha256": "NOT-HEX", + "archive_type": "tar.xz", + "expected_member": "zig/zig", + "executable_name": "zig", + "version_command": ["zig", "version"], + "install_directory": "/opt/ecli/payload/packages/zig/master", + "architecture": "amd64", + "license": "MIT", + "max_download_bytes": 10, + } + errors = MOD.validate_lock_entry("zig", entry) + assert any("sha256" in error for error in errors) + assert any("master" in error for error in errors) + + def test_lock_entry_rejects_traversal_member_and_foreign_arch(self): + entry = { + "tool_id": "ruff", + "version": "1.0.0", + "source_url": "https://example.invalid", + "asset_url": "https://example.invalid/a.tar.gz", + "sha256": "0" * 64, + "archive_type": "tar.gz", + "expected_member": "../escape", + "executable_name": "ruff", + "version_command": ["ruff", "--version"], + "install_directory": "/opt/ecli/payload/bin", + "architecture": "arm64", + "license": "MIT", + "max_download_bytes": 10, + } + errors = MOD.validate_lock_entry("ruff", entry) + assert any("relative path" in error for error in errors) + assert any("amd64" in error for error in errors) + + def test_npm_lock_dir_validation(self, tmp_path): + MOD.validate_npm_lock_dir( + REPO_ROOT / "packaging/debian/markdownlint-cli2", "0.22.1" + ) + with pytest.raises(MOD.LockError): + MOD.validate_npm_lock_dir( + REPO_ROOT / "packaging/debian/markdownlint-cli2", "9.9.9" + ) + with pytest.raises(MOD.LockError): + MOD.validate_npm_lock_dir(tmp_path, "0.22.1") + + # -- install_directory boundary hardening ------------------------------ + + _BASE_ENTRY = { + "tool_id": "ruff", + "version": "1.0.0", + "source_url": "https://example.invalid/a", + "asset_url": "https://example.invalid/a.tar.gz", + "sha256": "0" * 64, + "archive_type": "tar.gz", + "expected_member": "dir/ruff", + "executable_name": "ruff", + "version_command": ["ruff", "--version"], + "install_directory": "/opt/ecli/payload/bin", + "architecture": "amd64", + "license": "MIT", + "max_download_bytes": 10, + } + + @pytest.mark.parametrize( + "install_directory", + [ + "/opt/ecli/payload", # the root itself, not strictly below it + "/opt/ecli/payload-evil", # string-prefix lookalike, not a child + "/opt/ecli/payload/../../etc", # lexical traversal escapes it + "opt/ecli/payload/bin", # not absolute + "", + ], + ) + def test_lock_entry_rejects_unsafe_install_directory(self, install_directory): + entry = dict(self._BASE_ENTRY, install_directory=install_directory) + errors = MOD.validate_lock_entry("ruff", entry) + assert any("install_directory" in error for error in errors), errors + + def test_lock_entry_accepts_genuine_payload_subdirectory(self): + entry = dict( + self._BASE_ENTRY, + install_directory="/opt/ecli/payload/packages/zig/0.16.0", + ) + assert MOD.validate_lock_entry("ruff", entry) == [] + + # -- executable_name / expected_member basename hardening -------------- + + @pytest.mark.parametrize( + "executable_name", + ["ruff/evil", "..", ".", "ruff;rm -rf /", "ruff$(whoami)", "ruf f", ""], + ) + def test_lock_entry_rejects_unsafe_executable_name(self, executable_name): + entry = dict(self._BASE_ENTRY, executable_name=executable_name) + errors = MOD.validate_lock_entry("ruff", entry) + assert any("executable_name" in error for error in errors), errors + + @pytest.mark.parametrize( + "expected_member", + [ + "../escape", + "dir/../../escape", + "/absolute/escape", + "dir/\x00null", + "dir/ru`ff`", + "dir/$(whoami)", + "dir//double-slash-empty-segment", + "~root/escape", + ], + ) + def test_lock_entry_rejects_unsafe_expected_member(self, expected_member): + entry = dict(self._BASE_ENTRY, expected_member=expected_member) + errors = MOD.validate_lock_entry("ruff", entry) + assert any("expected_member" in error for error in errors), errors + + def test_lock_entry_accepts_legitimate_multi_segment_member(self): + entry = dict( + self._BASE_ENTRY, expected_member="node_modules/.bin/markdownlint-cli2" + ) + assert MOD.validate_lock_entry("ruff", entry) == [] + + # -- mutable URL reference hardening ------------------------------- + + @pytest.mark.parametrize( + "url", + [ + "https://github.com/x/y/releases/latest/download/z.tar.gz", + "https://ziglang.org/builds/zig-x86_64-linux-master.tar.xz", + ], + ) + def test_lock_entry_rejects_mutable_reference_urls(self, url): + entry = dict(self._BASE_ENTRY, asset_url=url) + errors = MOD.validate_lock_entry("ruff", entry) + assert any("mutable" in error for error in errors), errors + + # -- standalone helper-function unit coverage --------------------------- + + @pytest.mark.parametrize( + "name,expected", + [ + ("ruff", True), + ("markdownlint-cli2", True), + (".bin", True), + ("a.b_c-9", True), + ("", False), + (".", False), + ("..", False), + ("a/b", False), + ("a\\b", False), + ("a\x00b", False), + ("a;b", False), + ("a b", False), + ("$(a)", False), + ], + ) + def test_is_safe_basename(self, name, expected): + assert MOD.is_safe_basename(name) is expected + + @pytest.mark.parametrize( + "member,expected", + [ + ("ruff", True), + ("dir/ruff", True), + ("node_modules/.bin/markdownlint-cli2", True), + ("../escape", False), + ("dir/../escape", False), + ("/abs", False), + ("~x", False), + ("dir//x", False), + ("dir/\x00", False), + ("dir\\x", False), + ], + ) + def test_is_safe_relative_member(self, member, expected): + assert MOD.is_safe_relative_member(member) is expected + + def test_has_mutable_reference(self): + assert MOD.has_mutable_reference("https://x/releases/latest/y") + assert MOD.has_mutable_reference("https://x/MASTER/y") + assert not MOD.has_mutable_reference( + "https://github.com/a/b/releases/download/v1.0/c" + ) + + +# --------------------------------------------------------------------------- +# Downloads: HTTPS-only, checksum mismatch, interruption +# --------------------------------------------------------------------------- + + +class _FakeResponse: + def __init__(self, payload: bytes, *, fail_after: int | None = None) -> None: + """Serve ``payload``, optionally failing after N reads.""" + self._buffer = io.BytesIO(payload) + self._fail_after = fail_after + self._reads = 0 + self.status = 200 + + def read(self, size: int) -> bytes: + self._reads += 1 + if self._fail_after is not None and self._reads > self._fail_after: + raise OSError("connection interrupted") + return self._buffer.read(size) + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +class _FakeOpener: + def __init__(self, responses) -> None: + """Queue canned responses for successive open() calls.""" + self._responses = list(responses) + self.requests = 0 + + def open(self, request, timeout=None): + self.requests += 1 + return self._responses.pop(0) + + +class TestDownloads: + def test_non_https_url_refused(self, payload_env): + with pytest.raises(MOD.DownloadError): + MOD.download_verified( + "http://example.invalid/tool", + payload_env["PAYLOAD_CACHE"] / "tool", + "0" * 64, + 10, + LogStub(), + ) + + def test_redirect_handler_refuses_downgrade(self): + handler = MOD._HttpsOnlyRedirectHandler() + with pytest.raises(MOD.DownloadError): + handler.redirect_request( + None, None, 302, "Found", {}, "http://example.invalid/x" + ) + + def test_checksum_mismatch_fails_and_cleans_partials( + self, payload_env, monkeypatch + ): + payload = b"tool-bytes" + opener = _FakeOpener([_FakeResponse(payload)]) + monkeypatch.setattr( + MOD.urllib.request, "build_opener", lambda *handlers: opener + ) + destination = payload_env["PAYLOAD_CACHE"] / "tool.bin" + with pytest.raises(MOD.DownloadError) as excinfo: + MOD.download_verified( + "https://example.invalid/tool.bin", + destination, + "f" * 64, + len(payload), + LogStub(), + ) + assert "SHA-256 mismatch" in str(excinfo.value) + assert not destination.exists() + assert not destination.with_name("tool.bin.part").exists() + assert opener.requests == 1, "checksum mismatch must not be retried" + + def test_oversize_download_aborted(self, payload_env, monkeypatch): + payload = b"x" * 64 + opener = _FakeOpener([_FakeResponse(payload)]) + monkeypatch.setattr( + MOD.urllib.request, "build_opener", lambda *handlers: opener + ) + destination = payload_env["PAYLOAD_CACHE"] / "big.bin" + with pytest.raises(MOD.DownloadError) as excinfo: + MOD.download_verified( + "https://example.invalid/big.bin", + destination, + hashlib.sha256(payload).hexdigest(), + 8, + LogStub(), + ) + assert "maximum expected size" in str(excinfo.value) + assert not destination.with_name("big.bin.part").exists() + + def test_interrupted_download_retries_then_fails_clean( + self, payload_env, monkeypatch + ): + payload = b"y" * (2 << 20) + opener = _FakeOpener([_FakeResponse(payload, fail_after=1) for _ in range(3)]) + monkeypatch.setattr( + MOD.urllib.request, "build_opener", lambda *handlers: opener + ) + destination = payload_env["PAYLOAD_CACHE"] / "flaky.bin" + with pytest.raises(MOD.DownloadError): + MOD.download_verified( + "https://example.invalid/flaky.bin", + destination, + hashlib.sha256(payload).hexdigest(), + len(payload), + LogStub(), + ) + assert opener.requests == 3 + assert not destination.exists() + assert not destination.with_name("flaky.bin.part").exists() + + def test_successful_download_verifies_and_renames_atomically( + self, payload_env, monkeypatch + ): + payload = b"verified-bytes" + opener = _FakeOpener([_FakeResponse(payload)]) + monkeypatch.setattr( + MOD.urllib.request, "build_opener", lambda *handlers: opener + ) + destination = payload_env["PAYLOAD_CACHE"] / "good.bin" + result = MOD.download_verified( + "https://example.invalid/good.bin", + destination, + hashlib.sha256(payload).hexdigest(), + len(payload), + LogStub(), + ) + assert result == destination + assert destination.read_bytes() == payload + assert not destination.with_name("good.bin.part").exists() + + def test_verified_cache_is_reused_without_network(self, payload_env): + payload = b"cached" + destination = payload_env["PAYLOAD_CACHE"] / "cached.bin" + destination.write_bytes(payload) + result = MOD.download_verified( + "https://example.invalid/cached.bin", + destination, + hashlib.sha256(payload).hexdigest(), + len(payload), + LogStub(), + ) + assert result == destination + + +# --------------------------------------------------------------------------- +# Safe extraction +# --------------------------------------------------------------------------- + + +class TestSafeExtraction: + def test_plain_members_extract_with_by_type_modes(self, tmp_path): + archive = tmp_path / "ok.tar.gz" + _make_tar_gz( + archive, + {"dir/tool": b"#!/bin/sh\n", "dir/README": b"docs"}, + mode=0o755, + ) + destination = tmp_path / "out" + MOD.safe_extract_tar(archive, destination) + tool_mode = stat.S_IMODE((destination / "dir/tool").stat().st_mode) + assert tool_mode == 0o755 + + @pytest.mark.parametrize("name", ["../evil", "/abs/evil", "a/../../evil"]) + def test_traversal_members_rejected(self, tmp_path, name): + archive = tmp_path / "bad.tar.gz" + _make_tar_gz(archive, {name: b"x"}) + with pytest.raises(MOD.ExtractionError): + MOD.safe_extract_tar(archive, tmp_path / "out") + + def test_symlink_escape_rejected(self, tmp_path): + archive = tmp_path / "link.tar.gz" + with tarfile.open(archive, "w:gz") as handle: + info = tarfile.TarInfo("escape") + info.type = tarfile.SYMTYPE + info.linkname = "../../etc/passwd" + handle.addfile(info) + with pytest.raises(MOD.ExtractionError): + MOD.safe_extract_tar(archive, tmp_path / "out") + + def test_absolute_symlink_rejected(self, tmp_path): + archive = tmp_path / "abslink.tar.gz" + with tarfile.open(archive, "w:gz") as handle: + info = tarfile.TarInfo("abs") + info.type = tarfile.SYMTYPE + info.linkname = "/etc/passwd" + handle.addfile(info) + with pytest.raises(MOD.ExtractionError): + MOD.safe_extract_tar(archive, tmp_path / "out") + + def test_hardlink_escape_rejected(self, tmp_path): + archive = tmp_path / "hard.tar.gz" + with tarfile.open(archive, "w:gz") as handle: + info = tarfile.TarInfo("hard") + info.type = tarfile.LNKTYPE + info.linkname = "../outside" + handle.addfile(info) + with pytest.raises(MOD.ExtractionError): + MOD.safe_extract_tar(archive, tmp_path / "out") + + def test_device_and_fifo_members_rejected(self, tmp_path): + archive = tmp_path / "dev.tar.gz" + with tarfile.open(archive, "w:gz") as handle: + info = tarfile.TarInfo("null") + info.type = tarfile.CHRTYPE + handle.addfile(info) + with pytest.raises(MOD.ExtractionError): + MOD.safe_extract_tar(archive, tmp_path / "out") + archive2 = tmp_path / "fifo.tar.gz" + with tarfile.open(archive2, "w:gz") as handle: + info = tarfile.TarInfo("pipe") + info.type = tarfile.FIFOTYPE + handle.addfile(info) + with pytest.raises(MOD.ExtractionError): + MOD.safe_extract_tar(archive2, tmp_path / "out") + + def test_setuid_members_rejected(self, tmp_path): + archive = tmp_path / "suid.tar.gz" + _make_tar_gz(archive, {"tool": b"x"}, mode=0o4755) + with pytest.raises(MOD.ExtractionError): + MOD.safe_extract_tar(archive, tmp_path / "out") + + def test_zip_traversal_rejected(self, tmp_path): + archive = tmp_path / "bad.zip" + with zipfile.ZipFile(archive, "w") as handle: + handle.writestr("../evil", b"x") + with pytest.raises(MOD.ExtractionError): + MOD.safe_extract_zip(archive, tmp_path / "out") + + def test_tar_duplicate_member_rejected(self, tmp_path): + """A repeated path could silently overwrite an already-validated + entry with attacker-controlled content; reject outright. + """ + archive = tmp_path / "dup.tar.gz" + with tarfile.open(archive, "w:gz") as handle: + for payload in (b"benign", b"malicious-overwrite"): + info = tarfile.TarInfo("dir/tool") + info.size = len(payload) + handle.addfile(info, io.BytesIO(payload)) + with pytest.raises(MOD.ExtractionError, match="duplicated"): + MOD.safe_extract_tar(archive, tmp_path / "out") + + def test_zip_duplicate_member_rejected(self, tmp_path): + archive = tmp_path / "dup.zip" + with zipfile.ZipFile(archive, "w") as handle: + handle.writestr("dir/tool", b"benign") + handle.writestr("dir/tool", b"malicious-overwrite") + with pytest.raises(MOD.ExtractionError, match="duplicated"): + MOD.safe_extract_zip(archive, tmp_path / "out") + + def test_tar_duplicate_via_normpath_equivalence_rejected(self, tmp_path): + """Different literal strings that normalize to the same path are + just as ambiguous as an exact repeat. + """ + archive = tmp_path / "dup2.tar.gz" + with tarfile.open(archive, "w:gz") as handle: + for name, payload in ( + ("dir/tool", b"first"), + ("dir/./tool", b"second-ambiguous"), + ): + info = tarfile.TarInfo(name) + info.size = len(payload) + handle.addfile(info, io.BytesIO(payload)) + with pytest.raises(MOD.ExtractionError, match="duplicated"): + MOD.safe_extract_tar(archive, tmp_path / "out") + + def test_stage_archive_requires_exact_expected_member(self, tmp_path): + archive = tmp_path / "ruff.tar.gz" + _make_tar_gz(archive, {"unexpected/other": b"x"}) + entry = { + "archive_type": "tar.gz", + "expected_member": "ruff-x86_64-unknown-linux-gnu/ruff", + } + with pytest.raises(MOD.ExtractionError) as excinfo: + MOD.stage_archive(entry, archive, tmp_path / "stage") + assert "expected archive member" in str(excinfo.value) + + def test_stage_archive_gz_single_member(self, tmp_path): + import gzip as gzip_mod + + archive = tmp_path / "taplo.gz" + archive.write_bytes(gzip_mod.compress(b"#!/bin/sh\necho taplo\n")) + entry = { + "archive_type": "gz", + "expected_member": "taplo", + "architecture": "amd64", + } + staged = MOD.stage_archive(entry, archive, tmp_path / "stage") + assert staged.name == "taplo" + assert stat.S_IMODE(staged.stat().st_mode) == 0o755 + + +# --------------------------------------------------------------------------- +# ELF architecture verification +# --------------------------------------------------------------------------- + + +def _fake_elf_header(e_machine: int) -> bytes: + """Minimal 20-byte prefix with a real ELF magic and a given e_machine.""" + header = bytearray(20) + header[0:4] = b"\x7fELF" + header[18:20] = e_machine.to_bytes(2, byteorder="little") + return bytes(header) + + +class TestElfArchitectureVerification: + def test_accepts_matching_x86_64_elf(self, tmp_path): + path = tmp_path / "tool" + path.write_bytes(_fake_elf_header(MOD._EM_X86_64)) + MOD.verify_elf_architecture(path, "amd64") # does not raise + + def test_rejects_foreign_architecture_elf(self, tmp_path): + path = tmp_path / "tool" + aarch64_machine = 0xB7 + path.write_bytes(_fake_elf_header(aarch64_machine)) + with pytest.raises(MOD.ExtractionError, match="e_machine"): + MOD.verify_elf_architecture(path, "amd64") + + def test_rejects_truncated_elf_header(self, tmp_path): + path = tmp_path / "tool" + path.write_bytes(b"\x7fELF\x01\x02") + with pytest.raises(MOD.ExtractionError, match="truncated"): + MOD.verify_elf_architecture(path, "amd64") + + def test_skips_non_elf_shell_script(self, tmp_path): + path = tmp_path / "tool" + path.write_bytes(b"#!/bin/sh\nexec java -jar app.jar\n") + MOD.verify_elf_architecture(path, "amd64") # does not raise + + def test_stage_archive_rejects_wrong_architecture_binary(self, tmp_path): + archive = tmp_path / "tool.tar.gz" + _make_tar_gz(archive, {"dir/tool": _fake_elf_header(0xB7)}) + entry = { + "archive_type": "tar.gz", + "expected_member": "dir/tool", + "architecture": "amd64", + } + with pytest.raises(MOD.ExtractionError, match="e_machine"): + MOD.stage_archive(entry, archive, tmp_path / "stage") + + +# --------------------------------------------------------------------------- +# Wrapper generation safety +# --------------------------------------------------------------------------- + + +class TestWrapperSafety: + def test_wrapper_embeds_absolute_target_verbatim(self): + target = Path("/opt/ecli/payload/packages/pmd/7.26.0/bin/pmd") + content = MOD.wrapper_script_content(target) + assert content.startswith("#!/bin/sh\n") + assert f'exec "{target}" "$@"' in content + + def test_wrapper_rejects_relative_target(self): + with pytest.raises(MOD.InstallerError, match="absolute"): + MOD.wrapper_script_content(Path("relative/pmd")) + + @pytest.mark.parametrize("unsafe_char", ['"', "`", "$", "\\"]) + def test_wrapper_rejects_shell_unsafe_characters(self, unsafe_char): + target = Path(f"/opt/ecli/payload/{unsafe_char}evil/pmd") + with pytest.raises(MOD.InstallerError, match="unsafe character"): + MOD.wrapper_script_content(target) + + def test_wrapper_rejects_control_characters(self): + target = Path("/opt/ecli/payload/evil\nrm -rf /\npmd") + with pytest.raises(MOD.InstallerError, match="unsafe character"): + MOD.wrapper_script_content(target) + + +# --------------------------------------------------------------------------- +# Process-level installer lock +# --------------------------------------------------------------------------- + + +class TestInstallerProcessLock: + def test_second_concurrent_instance_is_rejected(self, tmp_path): + lock_path = tmp_path / "installer.lock" + with MOD.installer_process_lock(lock_path): + with pytest.raises(MOD.InstallerLockHeldError): + with MOD.installer_process_lock(lock_path): + pass # pragma: no cover - must never be entered + + def test_lock_is_released_after_the_context_exits(self, tmp_path): + lock_path = tmp_path / "installer.lock" + with MOD.installer_process_lock(lock_path): + pass + with MOD.installer_process_lock(lock_path): + pass # a fresh acquisition after release must succeed + + def test_lock_is_released_even_when_the_body_raises(self, tmp_path): + lock_path = tmp_path / "installer.lock" + with pytest.raises(RuntimeError, match="boom"): + with MOD.installer_process_lock(lock_path): + raise RuntimeError("boom") + with MOD.installer_process_lock(lock_path): + pass # still released despite the exception + + +# --------------------------------------------------------------------------- +# Idempotency, managed upgrade, corrupt binaries +# --------------------------------------------------------------------------- + + +@pytest.fixture +def managed_ruff(payload_env, tmp_path, monkeypatch): + """A PayloadInstaller with a fake downloadable ruff 0.15.21 archive.""" + script = _fake_tool_script("ruff 0.15.21") + archive = tmp_path / "fixture-ruff.tar.gz" + _make_tar_gz(archive, {"ruff-x86_64-unknown-linux-gnu/ruff": script}) + entry = _ruff_lock_entry(archive, script) + monkeypatch.setattr( + MOD, + "download_verified", + lambda url, dest, sha, max_bytes, log, **kwargs: archive, + ) + log = LogStub() + state: dict = {} + installer = MOD.PayloadInstaller( + {"ruff": entry}, state, log, REPO_ROOT / "packaging/debian/markdownlint-cli2" + ) + ruff_tool = MOD.TOOLS_BY_NUMBER[1] + return installer, ruff_tool, entry, log + + +class TestIdempotencyAndUpgrade: + def test_fresh_install_promotes_binary_and_records_state(self, managed_ruff): + installer, tool, entry, _log = managed_ruff + installer.install(tool) + target = MOD.PAYLOAD_BIN / "ruff" + assert target.is_file() + assert stat.S_IMODE(target.stat().st_mode) == 0o755 + state = MOD.read_state() + assert state["ruff"]["version"] == "0.15.21" + assert state["ruff"]["installed_sha256"] == MOD.sha256_of_file(target) + + def test_reinstall_skips_verified_current_version(self, managed_ruff): + installer, tool, entry, log = managed_ruff + installer.install(tool) + first_mtime = (MOD.PAYLOAD_BIN / "ruff").stat().st_mtime_ns + installer.install(tool) + assert (MOD.PAYLOAD_BIN / "ruff").stat().st_mtime_ns == first_mtime + assert "skipping reinstall" in log.text + + def test_corrupt_managed_binary_is_reinstalled(self, managed_ruff): + installer, tool, entry, log = managed_ruff + installer.install(tool) + target = MOD.PAYLOAD_BIN / "ruff" + target.write_bytes(b"corrupted") + installer.install(tool) + assert "does not match recorded checksum" in log.text + assert target.read_bytes() == _fake_tool_script("ruff 0.15.21") + + def test_outdated_managed_version_is_upgraded(self, managed_ruff): + installer, tool, entry, _log = managed_ruff + installer.install(tool) + installer.state["ruff"]["version"] = "0.15.20" + MOD.write_state(installer.state) + installer.install(tool) + assert MOD.read_state()["ruff"]["version"] == "0.15.21" + + def test_unmanaged_file_is_never_overwritten(self, managed_ruff): + installer, tool, entry, _log = managed_ruff + target = MOD.PAYLOAD_BIN / "ruff" + target.write_text("user-owned file", encoding="utf-8") + with pytest.raises(MOD.InstallerError) as excinfo: + installer.install(tool) + assert "unmanaged" in str(excinfo.value) + assert target.read_text(encoding="utf-8") == "user-owned file" + + def test_failed_staged_probe_retains_previous_installation( + self, managed_ruff, tmp_path, monkeypatch + ): + installer, tool, entry, _log = managed_ruff + installer.install(tool) + good = (MOD.PAYLOAD_BIN / "ruff").read_bytes() + broken_archive = tmp_path / "broken.tar.gz" + _make_tar_gz( + broken_archive, + {"ruff-x86_64-unknown-linux-gnu/ruff": b"#!/bin/sh\nexit 9\n"}, + ) + entry["version"] = "0.15.22" + entry["sha256"] = hashlib.sha256(broken_archive.read_bytes()).hexdigest() + monkeypatch.setattr( + MOD, + "download_verified", + lambda url, dest, sha, max_bytes, log, **kwargs: broken_archive, + ) + with pytest.raises(MOD.InstallerError): + installer.install(tool) + assert (MOD.PAYLOAD_BIN / "ruff").read_bytes() == good + + def test_tree_tool_install_creates_exec_wrapper_not_symlink( + self, payload_env, tmp_path, monkeypatch + ): + """PMD-style tools break behind symlinks; bin entry must be a wrapper.""" + import subprocess + + script = _fake_tool_script("PMD 7.26.0") + archive = tmp_path / "fixture-pmd.tar.gz" + _make_tar_gz(archive, {"pmd-bin-7.26.0/bin/pmd": script}) + entry = { + "tool_id": "pmd", + "version": "7.26.0", + "source_url": "https://example.invalid/pmd", + "asset_url": "https://example.invalid/pmd.tar.gz", + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "archive_type": "tar.gz", + "expected_member": "pmd-bin-7.26.0/bin/pmd", + "executable_name": "pmd", + "version_command": ["pmd", "--version"], + "install_directory": str(MOD.PAYLOAD_PACKAGES / "pmd" / "7.26.0"), + "architecture": "amd64", + "license": "BSD-2-Clause", + "max_download_bytes": max(archive.stat().st_size, 1), + } + monkeypatch.setattr( + MOD, + "download_verified", + lambda url, dest, sha, max_bytes, log, **kwargs: archive, + ) + installer = MOD.PayloadInstaller( + {"pmd": entry}, + {}, + LogStub(), + REPO_ROOT / "packaging/debian/markdownlint-cli2", + ) + installer.install(MOD.TOOLS_BY_NUMBER[14]) + inner = MOD.PAYLOAD_PACKAGES / "pmd" / "7.26.0" / "bin" / "pmd" + assert inner.is_file() + wrapper = MOD.PAYLOAD_BIN / "pmd" + assert wrapper.is_file() and not wrapper.is_symlink() + assert stat.S_IMODE(wrapper.stat().st_mode) == 0o755 + content = wrapper.read_text(encoding="utf-8") + assert content.startswith("#!/bin/sh\n") + assert f'exec "{inner}" "$@"' in content + probe = subprocess.run( + [str(wrapper), "--version"], capture_output=True, text=True, check=True + ) + assert "PMD 7.26.0" in probe.stdout + + def test_normalize_tree_ownership_applies_by_type_modes(self, tmp_path): + """Staging must reset npm-preserved upstream tarball modes/owners.""" + tree = tmp_path / "nodejs" + (tree / "node_modules" / "pkg").mkdir(parents=True) + loose_dir = tree / "node_modules" / "pkg" + os.chmod(loose_dir, 0o775) + data_file = loose_dir / "index.js" + data_file.write_text("x", encoding="utf-8") + os.chmod(data_file, 0o664) + exe_file = loose_dir / "cli.js" + exe_file.write_text("#!/usr/bin/env node\n", encoding="utf-8") + os.chmod(exe_file, 0o775) + link = tree / "node_modules" / "link.js" + link.symlink_to("pkg/index.js") + MOD.normalize_tree_ownership(tree) + assert stat.S_IMODE(loose_dir.stat().st_mode) == 0o755 + assert stat.S_IMODE(data_file.stat().st_mode) == 0o644 + assert stat.S_IMODE(exe_file.stat().st_mode) == 0o755 + assert link.is_symlink() + + +# --------------------------------------------------------------------------- +# PATH script idempotency +# --------------------------------------------------------------------------- + + +class TestProfileScript: + def test_profile_script_written_idempotently(self, payload_env): + log = LogStub() + MOD.install_profile_script(log) + first = MOD.PROFILE_SCRIPT.read_text(encoding="utf-8") + MOD.install_profile_script(log) + assert MOD.PROFILE_SCRIPT.read_text(encoding="utf-8") == first + assert "already up to date" in log.text + assert first.count("/opt/ecli/payload/bin") >= 2 + assert 'case ":$PATH:"' in first + # Deterministic precedence: the payload directory is PREPENDED so + # lock-pinned managed tools shadow same-named host executables. + assert 'PATH="/opt/ecli/payload/bin:$PATH"' in first + assert 'PATH="$PATH:' not in first + mode = stat.S_IMODE(MOD.PROFILE_SCRIPT.stat().st_mode) + assert mode == 0o644 + + def test_profile_prepend_shadows_stale_host_tool(self, payload_env, tmp_path): + import subprocess + + payload_like = tmp_path / "payload-bin" + payload_like.mkdir() + stale_dir = tmp_path / "usr-local-bin" + stale_dir.mkdir() + for directory, marker in ((payload_like, "managed"), (stale_dir, "stale")): + tool = directory / "ruff" + tool.write_bytes(_fake_tool_script(f"ruff {marker}")) + tool.chmod(0o755) + script = tmp_path / "ecli_payload.sh" + script.write_text( + MOD.PROFILE_CONTENT.replace("/opt/ecli/payload/bin", str(payload_like)), + encoding="utf-8", + ) + result = subprocess.run( + [ + "/bin/sh", + "-c", + f'PATH="{stale_dir}:/usr/bin:/bin"; . "{script}"; ruff --version', + ], + capture_output=True, + text=True, + check=True, + ) + assert "ruff managed" in result.stdout + + def test_profile_guard_prevents_duplicate_path_entries(self, payload_env, tmp_path): + import subprocess + + # The committed content guards on the canonical directory, which + # does not exist in the test sandbox; substitute an existing + # directory to exercise the duplicate-entry guard itself. + existing = tmp_path / "payload-bin" + existing.mkdir() + script = tmp_path / "ecli_payload.sh" + script.write_text( + MOD.PROFILE_CONTENT.replace("/opt/ecli/payload/bin", str(existing)), + encoding="utf-8", + ) + result = subprocess.run( + [ + "/bin/sh", + "-c", + f'PATH=/usr/bin; . "{script}"; . "{script}"; printf "%s" "$PATH"', + ], + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.count(str(existing)) == 1 + + +# --------------------------------------------------------------------------- +# State-file atomicity +# --------------------------------------------------------------------------- + + +class TestStateFile: + def test_state_written_atomically_without_temp_residue(self, payload_env): + MOD.write_state({"ruff": {"version": "0.15.21"}}) + state_dir = MOD.STATE_FILE.parent + leftovers = [ + path for path in state_dir.iterdir() if path.name != MOD.STATE_FILE.name + ] + assert leftovers == [] + assert MOD.read_state()["ruff"]["version"] == "0.15.21" + + def test_corrupt_state_file_tolerated(self, payload_env): + MOD.STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + MOD.STATE_FILE.write_text("{ not json", encoding="utf-8") + assert MOD.read_state() == {} + + +# --------------------------------------------------------------------------- +# Version probes (including stderr output) and final report +# --------------------------------------------------------------------------- + + +@pytest.fixture +def probe_env(tmp_path, monkeypatch): + """Sandbox PROBE_PATH with a writable approved tool directory.""" + tools_dir = tmp_path / "probe-bin" + tools_dir.mkdir() + monkeypatch.setattr(MOD, "PROBE_PATH", f"{tools_dir}:/usr/bin:/bin") + monkeypatch.setattr( + MOD, + "APPROVED_PATH_PREFIXES", + (f"{tools_dir}/", "/usr/bin/", "/bin/"), + ) + return tools_dir + + +def _write_probe_tool(directory: Path, name: str, body: bytes) -> Path: + path = directory / name + path.write_bytes(body) + path.chmod(0o755) + return path + + +class TestVersionProbes: + def test_probe_path_prepends_payload_and_avoids_usr_local(self): + assert MOD.PROBE_PATH.startswith(str(MOD.PAYLOAD_BIN)) + assert "/usr/local" not in MOD.PROBE_PATH + assert not any( + prefix.startswith("/usr/local") for prefix in MOD.APPROVED_PATH_PREFIXES + ) + + def test_probe_resolves_payload_tool_over_stale_shadow(self, tmp_path, monkeypatch): + """A same-named stale executable later in PATH must never win.""" + payload_like = tmp_path / "payload-bin" + stale_dir = tmp_path / "stale-bin" + payload_like.mkdir() + stale_dir.mkdir() + _write_probe_tool(payload_like, "ruff", _fake_tool_script("ruff 0.15.21")) + _write_probe_tool(stale_dir, "ruff", _fake_tool_script("ruff 0.0.1-stale")) + monkeypatch.setattr(MOD, "PROBE_PATH", f"{payload_like}:{stale_dir}") + monkeypatch.setattr( + MOD, "APPROVED_PATH_PREFIXES", (f"{payload_like}/", f"{stale_dir}/") + ) + lock = {"ruff": {"version": "0.15.21"}} + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[1], lock, LogStub()) + assert result.status == "OK" + assert result.path.startswith(str(payload_like)) + + def test_toolchain_split_is_eleven_managed_eight_debian(self): + managed = [tool for tool in MOD.TOOLS if tool.kind in ("payload", "npm")] + debian = [tool for tool in MOD.TOOLS if tool.kind == "apt"] + assert len(managed) == 11 + assert len(debian) == 8 + assert len(MOD.TOOLS) == 19 + + def test_probe_success_on_stdout(self, probe_env): + _write_probe_tool(probe_env, "yamllint", _fake_tool_script("yamllint 1.35.1")) + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[4], {}, LogStub()) + assert result.status == "OK" + assert "yamllint" in result.version + + def test_probe_success_when_version_only_on_stderr(self, probe_env): + _write_probe_tool( + probe_env, + "checkstyle", + _fake_tool_script("Checkstyle version: 10.17.0", to_stderr=True), + ) + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[13], {}, LogStub()) + assert result.status == "OK" + + def test_probe_fails_for_missing_executable(self, probe_env): + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[5], {}, LogStub()) + assert result.status == "FAILED" + assert "not found" in result.detail + + def test_probe_fails_on_nonzero_exit(self, probe_env): + _write_probe_tool(probe_env, "yamllint", b"#!/bin/sh\nexit 2\n") + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[4], {}, LogStub()) + assert result.status == "FAILED" + + def test_probe_fails_on_empty_output(self, probe_env): + _write_probe_tool(probe_env, "yamllint", b"#!/bin/sh\nexit 0\n") + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[4], {}, LogStub()) + assert result.status == "FAILED" + assert "no output" in result.detail + + def test_probe_requires_locked_version_for_managed_tools(self, probe_env): + _write_probe_tool(probe_env, "ruff", _fake_tool_script("ruff 0.15.20")) + lock = {"ruff": {"version": "0.15.21"}} + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[1], lock, LogStub()) + assert result.status == "FAILED" + assert "0.15.21" in result.detail + + def test_probe_accepts_locked_version_for_managed_tools(self, probe_env): + _write_probe_tool(probe_env, "ruff", _fake_tool_script("ruff 0.15.21")) + lock = {"ruff": {"version": "0.15.21"}} + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[1], lock, LogStub()) + assert result.status == "OK" + assert result.version == "0.15.21" + + def test_checkstyle_probe_falls_back_to_double_dash_version(self, probe_env): + """Debian 13 checkstyle (picocli CLI) rejects -version; --version works.""" + _write_probe_tool( + probe_env, + "checkstyle", + b'#!/bin/sh\ncase "$1" in\n' + b'--version) echo "Checkstyle version: 8.36.1" ;;\n' + b"*) echo \"Missing required parameter: ''\" 1>&2; exit 255 ;;\n" + b"esac\n", + ) + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[13], {}, LogStub()) + assert result.status == "OK" + assert "Checkstyle version" in result.version + + def test_fallback_failure_still_fails_with_one_line_detail(self, probe_env): + _write_probe_tool( + probe_env, + "checkstyle", + b'#!/bin/sh\necho "usage line one" 1>&2\necho "line two" 1>&2\nexit 255\n', + ) + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[13], {}, LogStub()) + assert result.status == "FAILED" + assert "\n" not in result.detail + + +class TestFinalReport: + @staticmethod + def _ok(tool): + return MOD.ToolResult(tool, "OK", version="1.0", path="/usr/bin/x") + + def test_full_nineteen_tool_success_message(self): + results = {tool.number: self._ok(tool) for tool in MOD.TOOLS} + log = LogStub() + rc = MOD.print_final_report(results, ALL_NUMBERS, log) + assert rc == MOD.EXIT_OK + assert ( + "ECLI linter installation completed successfully: " + "19/19 tools verified." in log.text + ) + + def test_custom_selection_success_message(self): + results = {tool.number: MOD.ToolResult(tool, "SKIPPED") for tool in MOD.TOOLS} + for number in (1, 4, 5): + results[number] = self._ok(MOD.TOOLS_BY_NUMBER[number]) + log = LogStub() + rc = MOD.print_final_report(results, frozenset({1, 4, 5}), log) + assert rc == MOD.EXIT_OK + assert "3/3 selected tools verified" in log.text + assert "not selected" in log.text + + def test_partial_failure_returns_nonzero_without_success_message(self): + results = {tool.number: self._ok(tool) for tool in MOD.TOOLS} + results[14] = MOD.ToolResult( + MOD.TOOLS_BY_NUMBER[14], + "FAILED", + detail="download failed", + stage="install", + ) + log = LogStub() + rc = MOD.print_final_report(results, ALL_NUMBERS, log) + assert rc == MOD.EXIT_INSTALL_FAILED + assert "completed successfully" not in log.text + assert "[FAILED]" in log.text + assert "PMD" in log.text + assert "no APT rollback" in log.text + + def test_report_line_layout(self): + ok_line = MOD.format_report_line(self._ok(MOD.TOOLS_BY_NUMBER[1])) + assert ok_line.startswith("[OK] Ruff") + skip_line = MOD.format_report_line( + MOD.ToolResult(MOD.TOOLS_BY_NUMBER[5], "SKIPPED") + ) + assert skip_line.startswith("[SKIPPED] ShellCheck") + assert skip_line.endswith("not selected") + + def test_every_required_probe_contract_is_declared(self): + expected = { + 1: ("ruff", "--version"), + 2: ("biome", "--version"), + 3: ("markdownlint-cli2", "--version"), + 4: ("yamllint", "--version"), + 5: ("shellcheck", "--version"), + 6: ("zig", "version"), + 7: ("hadolint", "--version"), + 8: ("taplo", "--version"), + 9: ("actionlint", "-version"), + 10: ("clang-tidy", "--version"), + 11: ("cppcheck", "--version"), + 12: ("clang-format", "--version"), + 13: ("checkstyle", "-version"), + 14: ("pmd", "--version"), + 15: ("spotbugs", "-version"), + 16: ("cargo", "clippy", "--version"), + 17: ("golangci-lint", "--version"), + 18: ("sqlfluff", "--version"), + 19: ("tflint", "--version"), + } + for number, command in expected.items(): + assert MOD.TOOLS_BY_NUMBER[number].version_command == command + + +# --------------------------------------------------------------------------- +# Partial failure of the orchestrated run +# --------------------------------------------------------------------------- + + +class TestRunInstallation: + def test_apt_failure_fails_all_selected_tools(self, payload_env, monkeypatch): + monkeypatch.setattr( + MOD, + "apt_install", + lambda packages, log: (_ for _ in ()).throw( + MOD.InstallerError("mirror unreachable") + ), + ) + log = LogStub() + rc = MOD.run_installation( + frozenset({4, 5}), + {}, + REPO_ROOT / "packaging/debian/markdownlint-cli2", + log, + ) + assert rc == MOD.EXIT_INSTALL_FAILED + assert "APT stage failed" in log.text + assert "completed successfully" not in log.text + + def test_single_tool_failure_does_not_block_others( + self, payload_env, probe_env, monkeypatch + ): + _write_probe_tool(probe_env, "yamllint", _fake_tool_script("yamllint 1.35.1")) + monkeypatch.setattr(MOD, "apt_install", lambda packages, log: None) + + def failing_install(self, tool): + raise MOD.InstallerError("checksum mismatch") + + monkeypatch.setattr(MOD.PayloadInstaller, "install", failing_install) + log = LogStub() + rc = MOD.run_installation( + frozenset({1, 4}), + {"ruff": {"version": "0.15.21"}}, + REPO_ROOT / "packaging/debian/markdownlint-cli2", + log, + ) + assert rc == MOD.EXIT_INSTALL_FAILED + assert "[FAILED] Ruff" in log.text + assert "[OK] yamllint" in log.text + assert "completed successfully" not in log.text From 3c97d8f610ad53808703ba3c08cce143fcd65caf Mon Sep 17 00:00:00 2001 From: SSobol77 Date: Sun, 12 Jul 2026 20:00:01 +0200 Subject: [PATCH 2/2] test(packaging): isolate missing executable probe --- .../packaging/test_install_ecli_linters_script.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/packaging/test_install_ecli_linters_script.py b/tests/packaging/test_install_ecli_linters_script.py index e78a628..fbe7560 100644 --- a/tests/packaging/test_install_ecli_linters_script.py +++ b/tests/packaging/test_install_ecli_linters_script.py @@ -1322,8 +1322,21 @@ def test_probe_success_when_version_only_on_stderr(self, probe_env): result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[13], {}, LogStub()) assert result.status == "OK" - def test_probe_fails_for_missing_executable(self, probe_env): + def test_probe_fails_for_missing_executable( + self, + probe_env, + monkeypatch: pytest.MonkeyPatch, + ): + """The missing-tool probe must not inherit host-installed executables.""" + monkeypatch.setattr(MOD, "PROBE_PATH", str(probe_env)) + monkeypatch.setattr( + MOD, + "APPROVED_PATH_PREFIXES", + (f"{probe_env}/",), + ) + result = MOD.probe_tool(MOD.TOOLS_BY_NUMBER[5], {}, LogStub()) + assert result.status == "FAILED" assert "not found" in result.detail