From 7eebbcd5e579d3fbbfdd5e4182fb2ef4441e7ff3 Mon Sep 17 00:00:00 2001 From: Adityaaa <144815351+addydude@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:48:59 +0530 Subject: [PATCH] feat(diagnose): complete M2 collectors and six rules --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 19 +- CONTRIBUTING.md | 5 + README.md | 116 ++++++++---- docs/ARCHITECTURE.md | 29 ++- docs/PLAN.md | 75 +++++--- docs/adr/0004-m2-collectors-and-rules.md | 51 ++++++ docs/rules/ENV001.md | 18 ++ docs/rules/ENV002.md | 17 ++ docs/rules/ENV003.md | 18 ++ docs/rules/ENV004.md | 17 ++ docs/rules/ENV005.md | 18 ++ docs/rules/ENV006.md | 18 ++ docs/schema/snapshot-v1.schema.json | 209 ++++++++++++++++++---- docs/testing/m1-m2-validation.md | 85 +++++++++ go.mod | 2 +- go.sum | 4 +- internal/cli/cli.go | 10 +- internal/cli/cli_test.go | 1 + internal/cli/diagnose.go | 34 +++- internal/cli/fixture.go | 46 +++++ internal/cli/m2_test.go | 89 +++++++++ internal/collector/collector.go | 32 +++- internal/collector/collector_test.go | 26 +++ internal/collector/docker.go | 55 ++++++ internal/collector/git.go | 54 ++++++ internal/collector/path.go | 6 + internal/collector/path_test.go | 24 +++ internal/collector/path_windows_test.go | 23 +++ internal/collector/ports.go | 38 ++++ internal/collector/redact.go | 46 ++++- internal/collector/redact_test.go | 18 ++ internal/collector/runtime.go | 149 +++++++++++++++ internal/collector/runtime_test.go | 152 ++++++++++++++++ internal/collector/snapshot.go | 37 ++-- internal/collector/tool_test.go | 71 ++++++++ internal/config/config.go | 15 +- internal/diagnostic/fixture_test.go | 5 +- internal/diagnostic/rules.go | 151 ++++++++++++++++ internal/diagnostic/rules_purity_test.go | 33 ++++ internal/report/document.go | 21 ++- internal/report/text.go | 42 +++++ internal/system/inspect.go | 146 +++++++++++++++ internal/system/inspect_test.go | 94 ++++++++++ internal/system/lsof.go | 34 ++++ internal/system/path_errors.go | 9 + internal/system/path_errors_unix.go | 12 ++ internal/system/path_errors_windows.go | 16 ++ internal/system/ports.go | 6 + internal/system/ports_darwin.go | 12 ++ internal/system/ports_linux.go | 73 ++++++++ internal/system/ports_windows.go | 75 ++++++++ tests/demo/snapshot.json | 20 +++ tests/golden/ENV001-darwin-edge.json | 31 ++++ tests/golden/ENV001-darwin-negative.json | 31 ++++ tests/golden/ENV001-darwin-positive.json | 51 ++++++ tests/golden/ENV001-linux-edge.json | 31 ++++ tests/golden/ENV001-linux-negative.json | 31 ++++ tests/golden/ENV001-linux-positive.json | 51 ++++++ tests/golden/ENV001-windows-edge.json | 31 ++++ tests/golden/ENV001-windows-negative.json | 31 ++++ tests/golden/ENV001-windows-positive.json | 51 ++++++ tests/golden/ENV002-darwin-edge.json | 37 ++++ tests/golden/ENV002-darwin-negative.json | 24 +++ tests/golden/ENV002-darwin-positive.json | 51 ++++++ tests/golden/ENV002-linux-edge.json | 37 ++++ tests/golden/ENV002-linux-negative.json | 24 +++ tests/golden/ENV002-linux-positive.json | 51 ++++++ tests/golden/ENV002-windows-edge.json | 37 ++++ tests/golden/ENV002-windows-negative.json | 24 +++ tests/golden/ENV002-windows-positive.json | 51 ++++++ tests/golden/ENV003-darwin-edge.json | 32 ++++ tests/golden/ENV003-darwin-negative.json | 26 +++ tests/golden/ENV003-darwin-positive.json | 45 +++++ tests/golden/ENV003-linux-edge.json | 32 ++++ tests/golden/ENV003-linux-negative.json | 26 +++ tests/golden/ENV003-linux-positive.json | 45 +++++ tests/golden/ENV003-windows-edge.json | 32 ++++ tests/golden/ENV003-windows-negative.json | 26 +++ tests/golden/ENV003-windows-positive.json | 45 +++++ tests/golden/ENV004-darwin-edge.json | 33 ++++ tests/golden/ENV004-darwin-negative.json | 32 ++++ tests/golden/ENV004-darwin-positive.json | 51 ++++++ tests/golden/ENV004-linux-edge.json | 33 ++++ tests/golden/ENV004-linux-negative.json | 32 ++++ tests/golden/ENV004-linux-positive.json | 51 ++++++ tests/golden/ENV004-windows-edge.json | 33 ++++ tests/golden/ENV004-windows-negative.json | 32 ++++ tests/golden/ENV004-windows-positive.json | 51 ++++++ tests/golden/ENV005-darwin-edge.json | 27 +++ tests/golden/ENV005-darwin-negative.json | 27 +++ tests/golden/ENV005-darwin-positive.json | 46 +++++ tests/golden/ENV005-linux-edge.json | 27 +++ tests/golden/ENV005-linux-negative.json | 27 +++ tests/golden/ENV005-linux-positive.json | 46 +++++ tests/golden/ENV005-windows-edge.json | 27 +++ tests/golden/ENV005-windows-negative.json | 27 +++ tests/golden/ENV005-windows-positive.json | 46 +++++ tests/golden/ENV006-darwin-edge.json | 21 +++ tests/golden/ENV006-darwin-negative.json | 21 +++ tests/golden/ENV006-darwin-positive.json | 41 +++++ tests/golden/ENV006-linux-edge.json | 21 +++ tests/golden/ENV006-linux-negative.json | 21 +++ tests/golden/ENV006-linux-positive.json | 41 +++++ tests/golden/ENV006-windows-edge.json | 21 +++ tests/golden/ENV006-windows-negative.json | 21 +++ tests/golden/ENV006-windows-positive.json | 41 +++++ tests/integration/cli_test.go | 115 ++++++++++++ 108 files changed, 4232 insertions(+), 145 deletions(-) create mode 100644 docs/adr/0004-m2-collectors-and-rules.md create mode 100644 docs/rules/ENV001.md create mode 100644 docs/rules/ENV002.md create mode 100644 docs/rules/ENV003.md create mode 100644 docs/rules/ENV004.md create mode 100644 docs/rules/ENV005.md create mode 100644 docs/rules/ENV006.md create mode 100644 docs/testing/m1-m2-validation.md create mode 100644 internal/cli/fixture.go create mode 100644 internal/cli/m2_test.go create mode 100644 internal/collector/docker.go create mode 100644 internal/collector/git.go create mode 100644 internal/collector/path_windows_test.go create mode 100644 internal/collector/ports.go create mode 100644 internal/collector/runtime.go create mode 100644 internal/collector/runtime_test.go create mode 100644 internal/collector/tool_test.go create mode 100644 internal/diagnostic/rules.go create mode 100644 internal/diagnostic/rules_purity_test.go create mode 100644 internal/system/inspect.go create mode 100644 internal/system/inspect_test.go create mode 100644 internal/system/lsof.go create mode 100644 internal/system/path_errors.go create mode 100644 internal/system/path_errors_unix.go create mode 100644 internal/system/path_errors_windows.go create mode 100644 internal/system/ports.go create mode 100644 internal/system/ports_darwin.go create mode 100644 internal/system/ports_linux.go create mode 100644 internal/system/ports_windows.go create mode 100644 tests/demo/snapshot.json create mode 100644 tests/golden/ENV001-darwin-edge.json create mode 100644 tests/golden/ENV001-darwin-negative.json create mode 100644 tests/golden/ENV001-darwin-positive.json create mode 100644 tests/golden/ENV001-linux-edge.json create mode 100644 tests/golden/ENV001-linux-negative.json create mode 100644 tests/golden/ENV001-linux-positive.json create mode 100644 tests/golden/ENV001-windows-edge.json create mode 100644 tests/golden/ENV001-windows-negative.json create mode 100644 tests/golden/ENV001-windows-positive.json create mode 100644 tests/golden/ENV002-darwin-edge.json create mode 100644 tests/golden/ENV002-darwin-negative.json create mode 100644 tests/golden/ENV002-darwin-positive.json create mode 100644 tests/golden/ENV002-linux-edge.json create mode 100644 tests/golden/ENV002-linux-negative.json create mode 100644 tests/golden/ENV002-linux-positive.json create mode 100644 tests/golden/ENV002-windows-edge.json create mode 100644 tests/golden/ENV002-windows-negative.json create mode 100644 tests/golden/ENV002-windows-positive.json create mode 100644 tests/golden/ENV003-darwin-edge.json create mode 100644 tests/golden/ENV003-darwin-negative.json create mode 100644 tests/golden/ENV003-darwin-positive.json create mode 100644 tests/golden/ENV003-linux-edge.json create mode 100644 tests/golden/ENV003-linux-negative.json create mode 100644 tests/golden/ENV003-linux-positive.json create mode 100644 tests/golden/ENV003-windows-edge.json create mode 100644 tests/golden/ENV003-windows-negative.json create mode 100644 tests/golden/ENV003-windows-positive.json create mode 100644 tests/golden/ENV004-darwin-edge.json create mode 100644 tests/golden/ENV004-darwin-negative.json create mode 100644 tests/golden/ENV004-darwin-positive.json create mode 100644 tests/golden/ENV004-linux-edge.json create mode 100644 tests/golden/ENV004-linux-negative.json create mode 100644 tests/golden/ENV004-linux-positive.json create mode 100644 tests/golden/ENV004-windows-edge.json create mode 100644 tests/golden/ENV004-windows-negative.json create mode 100644 tests/golden/ENV004-windows-positive.json create mode 100644 tests/golden/ENV005-darwin-edge.json create mode 100644 tests/golden/ENV005-darwin-negative.json create mode 100644 tests/golden/ENV005-darwin-positive.json create mode 100644 tests/golden/ENV005-linux-edge.json create mode 100644 tests/golden/ENV005-linux-negative.json create mode 100644 tests/golden/ENV005-linux-positive.json create mode 100644 tests/golden/ENV005-windows-edge.json create mode 100644 tests/golden/ENV005-windows-negative.json create mode 100644 tests/golden/ENV005-windows-positive.json create mode 100644 tests/golden/ENV006-darwin-edge.json create mode 100644 tests/golden/ENV006-darwin-negative.json create mode 100644 tests/golden/ENV006-darwin-positive.json create mode 100644 tests/golden/ENV006-linux-edge.json create mode 100644 tests/golden/ENV006-linux-negative.json create mode 100644 tests/golden/ENV006-linux-positive.json create mode 100644 tests/golden/ENV006-windows-edge.json create mode 100644 tests/golden/ENV006-windows-negative.json create mode 100644 tests/golden/ENV006-windows-positive.json create mode 100644 tests/integration/cli_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 993c652..14dd883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, 'feat/**', 'fix/**'] pull_request: + workflow_dispatch: permissions: contents: read @@ -42,7 +43,7 @@ jobs: run: go vet ./... - name: Test - run: go test -race -count=1 ./... + run: go test -race -count=1 -timeout=120s ./... - name: Build run: go build ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 72101ab..e137abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Added +- M2 runtime, requested TCP-port and Git collectors, plus the local Docker probe needed by ENV006. +- Rules ENV001–ENV006 with deterministic evidence, manual suggestions and rule documentation. +- 54 three-OS golden cases and built-CLI checks for JSON, findings and exit codes. +- `--port` and bounded offline `--fixture-dir` diagnosis, with a committed synthetic demo. +- Runtime, Git and Docker facts in the plain-text report. - The repository tracks CodeGraph's index ignore rules and documents it as an optional local tool for code navigation. Nothing in the build or in CI depends on it. ### Changed +- `golang.org/x/sys` updated to v0.48.0 to remove the vulnerable v0.30.0 dependency. +- Feature and fix branches trigger the three-OS CI matrix. Snapshot schema permits absent platform facts + after a failed collector; offline errors retain degraded notices and exit code 3. - The repository is published at https://github.com/Shellghostt/EnvDoctor and the CI workflow has executed. The first run on 2026-11-13 passed the Windows, Ubuntu and macOS matrix, the lint job and the vulnerability scan. @@ -25,11 +33,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Fixed +- M1 runner deadline now bounds a collector that ignores cancellation. +- Home redaction includes quoted PATH entries and paths embedded in collector/timing errors. +- Broken symlinks do not shadow usable runtimes; Python venvs stay distinct from their system symlink + targets; unrelated numeric directories do not become runtime versions. +- Per-user Windows Docker locations are recognized. Invalid Git config and missing macOS tools cause + degraded inspection instead of silent clean results. Git/Docker context overrides are removed. +- Port verification commands include the requested ports. Offline input rejects extra JSON documents. +- PATH permission/inspection failures are degraded rather than converted to missing-directory evidence; + malformed paths and non-directory ancestors still produce broken-entry warnings with raw text preserved. - Four writes whose error return was discarded now say so explicitly with `_, _ =`. ### Planned -- M2: runtime, port and Git collectors, plus the first diagnostic rules with golden fixtures. +- M3: rules ENV007–ENV012, full Docker/dependency inspection and the `explain` command. ## [0.1.0] - 2026-11-13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ffaa69f..6592cba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,11 @@ make test # unit tests make lint # formatting and static analysis ``` +`go test -count=1 -timeout=120s ./...` also builds the CLI and runs all 54 golden cases through it. +Each new rule needs positive, negative and edge snapshots for Windows, Linux and macOS. Expected +findings are reviewed evidence, not output regenerated from the implementation. See +[the validation record](docs/testing/m1-m2-validation.md) for Windows and Linux race commands. + ## Code navigation [CodeGraph](https://github.com/colbymchenry/codegraph) builds a local symbol index of this diff --git a/README.md b/README.md index 658b111..0d1084b 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ > A cross-platform CLI that detects, diagnoses and helps resolve development environment problems. -![status](https://img.shields.io/badge/status-M1_collectors-orange) +![status](https://img.shields.io/badge/status-M2_rules-orange) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) ![Go](https://img.shields.io/badge/go-1.26-00ADD8?logo=go&logoColor=white) -**Status: M1 (v0.1.0).** EnvDoctor collects platform, shell and PATH facts and prints them as text or JSON, -with a documented exit-code contract. **There are no diagnostic rules yet**, so it cannot tell you that -anything is wrong: `diagnose` lists facts and then reports zero findings. Nothing is modified on your machine. +**Status: M2 complete (unreleased).** EnvDoctor collects platform, shell, PATH, Python/Node runtime, +requested TCP-port, Git and local Docker facts. Six rules explain conflicts and suggest manual next steps. +Reports are plain text or versioned JSON. Diagnosis never applies fixes or executes interpreters found on PATH. ## Why this exists @@ -21,18 +21,19 @@ explains the cause instead of dumping raw output. | Capability | Milestone | Status | |---|---|---| -| Detect conflicting Python and Node versions | M2 | Not started | -| Identify broken PATH entries | M2 | Facts reported; no rule yet | -| Detect port conflicts | M2 | Not started | -| Diagnose Docker daemon problems | M3 | Not started | +| Detect conflicting Python aliases and Node candidates | M2 | **Done**, when metadata provides the required evidence | +| Identify broken PATH entries | M2 | **Done** (raw entries preserved after home redaction) | +| Detect requested TCP port conflicts | M2 | **Done**, owner PID/name where permitted | +| Detect unreachable local Docker engine | M2 | **Done** (ENV006) | +| Inspect Docker contexts and version skew | M3 | Not started | | Identify missing system dependencies | M3 | Not started | | Detect incompatible package managers | M3 | Not started | -| Check Git configuration | M2 | Not started | +| Collect effective Git identity and autocrlf | M2 | **Done**; Git diagnostic rules are M3 | | Generate diagnostic bundles | M4 | Not started | -| Human-readable reports | M4 | Basic text report (M1); polish is M4 | +| Human-readable reports | M2/M4 | **Done** plain text; interactive polish is M4 | | JSON export | M1 | **Done** | | Safe, opt-in automated remediation | M5 | Not started | -| Windows, Linux and macOS support | M6 | Collectors build for all three; CI matrix not yet run | +| Windows, Linux and macOS support | M2/M6 | Three-OS code and fixtures; signed release/clean-host verification is M6 | ## Architecture @@ -64,34 +65,38 @@ might not exist, and no fix may run without explicit confirmation. ## Quickstart -Requires Go 1.26+. +Requires Go 1.26+. From the repository root, in PowerShell: -```bash -make build -./bin/envdoctor diagnose +```powershell +go build -o bin/envdoctor.exe ./cmd/envdoctor +.\bin\envdoctor.exe diagnose +.\bin\envdoctor.exe diagnose --port 3000,8080 ``` +On Linux/macOS use `go build -o bin/envdoctor ./cmd/envdoctor` and `./bin/envdoctor diagnose`. +Port inspection runs only when you provide `--port`; a listening service is not automatically a conflict. + +Try the committed **synthetic** demo without inspecting your machine: + +```powershell +.\bin\envdoctor.exe diagnose --fixture-dir tests/demo +.\bin\envdoctor.exe diagnose --fixture-dir tests/demo --rule ENV005 ``` -EnvDoctor 0.1.0 - environment diagnosis -Generated 2026-11-13T01:41:49Z -Platform windows 11 (build 26300) amd64 -Elevated no -Shell powershell -PATH 26 entries, 2 missing, 0 duplicate +The demo intentionally fires all six rules and exits 2. Its port finding includes: -Missing PATH entries: - - C:\Windows\System32 - - ~\AppData\Local\Programs\Antigravity\bin +```text +[ERROR] Requested TCP port is already in use -Rules 0 registered in this build +Detected: + - TCP 3000: demo-node pid 123 -No findings. +Verify: + envdoctor diagnose --rule ENV005 --port 3000 ``` -That output is real, captured from `goreleaser build --snapshot` on Windows 11 while developing M1. The -leading space in the first entry is part of the value: it is a malformed PATH element, and that is exactly -what the collector is for. +This excerpt was produced by the CLI using `tests/demo/snapshot.json`; its process and paths are examples. +The [validation record](docs/testing/m1-m2-validation.md) also records a real Windows listener test. Machine-readable output, for an editor or CI: @@ -105,11 +110,45 @@ in the test suite; the snapshot inside it is described by with `~` before anything is written. Exit codes: `0` nothing at or above warning, `1` warnings, `2` at least one error, `3` the tool itself failed -(including a collector timeout), `4` invalid usage. Today only `0`, `3` and `4` are reachable, because zero -rules are registered. +(including a collector timeout), `4` invalid usage. Captured collector errors retain exit 3 during offline +diagnosis. A degraded report still contains the findings that could be evaluated. + +`--component` and `--rule` narrow findings. Collection still covers the full snapshot. `--port` accepts +comma-separated values and repeated flags. `--fixture-dir` reads one version-1 `snapshot.json` up to 8 MiB; +it rejects unknown fields, trailing documents and unsupported versions, and invokes no collectors. + +## Implemented rules + +| Rule | Evidence and behavior | +|---|---| +| [ENV001](docs/rules/ENV001.md) | Known differing versions for active Python command aliases | +| [ENV002](docs/rules/ENV002.md) | Multiple Node candidates or a missing selected target | +| [ENV003](docs/rules/ENV003.md) | Missing raw PATH entries | +| [ENV004](docs/rules/ENV004.md) | A selected environment shadowed by an earlier runtime | +| [ENV005](docs/rules/ENV005.md) | A requested TCP port held by a listening process | +| [ENV006](docs/rules/ENV006.md) | Installed Docker CLI with an unreachable default local engine | + +Runtime versions come from known manager paths, Windows file resources or venv metadata. Unknown versions +stay unknown; Unix system interpreters and opaque manager shims may not provide enough metadata. Expected +targets use `VIRTUAL_ENV`, `NVM_BIN` or `NVM_SYMLINK`. Git/Docker tools are found only in known installation +locations. Docker probes its default local socket rather than a selected remote context. + +Home paths are redacted, including quoted paths and error notices. JSON includes Git identity; review it +before sharing. Diagnostic bundles with broader redaction remain M4. + +## Testing + +```powershell +$env:GOTOOLCHAIN='go1.26.11' +go test -count=1 -timeout=120s ./... +go vet ./... +go build ./... +``` -`--component` and `--rule` narrow which rules are evaluated. With no rules, either flag can only produce an -empty result or an "unknown rule" error. +The suite includes 54 exact golden cases: six rules × three OS snapshots × positive/negative/edge cases. +It also builds the CLI and checks those cases through real subprocesses, validating JSON and exit codes. +CI runs the full race-enabled suite on Windows, Ubuntu and macOS. Local Linux race commands and measured +results are in [docs/testing/m1-m2-validation.md](docs/testing/m1-m2-validation.md). A local release build of all six targets: @@ -117,7 +156,7 @@ A local release build of all six targets: goreleaser build --snapshot --clean ``` -`goreleaser check` additionally wants a git remote, so it only passes once the repository is pushed. +`goreleaser check` and the local six-target snapshot build passed. A snapshot build does not publish a release. ## Repository layout @@ -126,14 +165,15 @@ goreleaser build --snapshot --clean | `cmd/envdoctor` | CLI entry point | | `internal/cli` | Command wiring, flags, exit codes | | `internal/config` | Resolved options, schema version, collector timeout | -| `internal/collector` | Collector interface, registry, parallel runner | -| `internal/collector/{windows,linux,darwin}` | Reserved for build-tagged collectors (empty in M1) | +| `internal/collector` | Parallel runner and platform/PATH/runtime/port/Git/local-Docker collectors | | `internal/diagnostic` | Rule engine, severity model, fixture harness | | `internal/report` | Terminal renderer, JSON renderer | | `internal/fix` | Remediation plans, dry-run by default (empty until M5) | | `internal/system` | Thin wrappers over OS facilities, the only place syscalls live | | `docs/schema` | Committed JSON schemas, enforced by tests | -| `tests/golden` | Snapshot-to-diagnosis fixtures, one per rule | +| `tests/golden` | 54 snapshot-to-diagnosis fixtures | +| `tests/integration` | Built CLI tests against all golden cases | +| `tests/demo` | Synthetic offline snapshot demonstrating all six rules | | `deploy` | Release and packaging notes | ## Roadmap @@ -141,7 +181,7 @@ goreleaser build --snapshot --clean | Milestone | Deliverable | Status | |---|---|---| | M1 | CLI foundation, collector interface, system information | **Done** (v0.1.0) | -| M2 | Cross-platform collectors: runtimes, PATH, ports, Git | Not started | +| M2 | Cross-platform collectors and rules ENV001–ENV006 | **Done (unreleased)** | | M3 | Rules ENV007–ENV012, Docker and dependency inspectors, `explain` | Not started | | M4 | Polished terminal output, Markdown export, diagnostic bundles | Not started | | M5 | Safe, confirmed automated remediation | Not started | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4e1f88..7d495ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,9 +1,9 @@ # EnvDoctor Architecture -**Status: partially implemented (M1).** The collector pipeline, platform, shell and PATH collection, and -the JSON contract exist. There are no diagnostic rules yet, so EnvDoctor reports no findings on any machine. -This document describes intended structure, not delivered behaviour. The authoritative specification is -[PLAN.md](PLAN.md), and section 0 there is the current truth. +**Status: M2 complete, unreleased.** Seven collectors feed six pure rules and a plain-text/JSON report. +Dependency inspection, broader Docker rules, bundles, automatic fixes and interactive output remain planned. +The authoritative specification is [PLAN.md](PLAN.md); [ADR 0004](adr/0004-m2-collectors-and-rules.md) +records the implemented evidence and inspection boundaries. ## Context @@ -39,6 +39,27 @@ flowchart TD See [PLAN.md](PLAN.md) section 6 for the responsibility of each box, the request and event flows, and the failure behaviour of each stage. +The live path runs platform, shell, PATH, runtime, requested-port, Git and local-Docker collectors in +parallel with a two-second deadline. Each returns a typed part; results merge in registration order. +Failed or late results contribute an error rather than partial facts. The runner's wait is bounded even +when a kernel filesystem operation ignores cancellation. + +Runtime discovery reads metadata without executing interpreters or shims. Windows uses TCP table and +query-only process APIs, Linux uses `/proc`, and macOS uses a known lsof installation. No port inspection +runs without `--port`. Git and Docker use known absolute CLI locations with fixed arguments, bounded +output and sanitized context overrides. Docker contacts only its default local engine. + +After home redaction, rules ENV001–ENV006 use only the snapshot. Rule/component filters affect evaluation, +not collection. Findings are validated, sorted by severity and rendered with evidence, possible cause, +manual next steps and verification commands. Failed collectors disable dependent rules and give exit 3. + +The offline path decodes a bounded versioned snapshot and skips collectors, then uses the same redaction, +engine and report. Captured errors retain degraded notices. Both paths write exactly one JSON document +under `--json`. Platform fields may be absent after a failed collector; no facts are invented to fill them. + +Home redaction is not an identity/secret scrubber: Git identity is part of the JSON snapshot. The future +bundle feature must define its own sharing policy. No current command applies a fix. + ## What is deliberately absent See [PLAN.md](PLAN.md) section 4 (non-goals). If a component is not on that diagram, it is not part of the diff --git a/docs/PLAN.md b/docs/PLAN.md index 195a3b2..4b54e9f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -12,7 +12,7 @@ depends_on: - No runtime dependencies (single static binary) - Docker (optional, only for testing rules that inspect Docker) created: 2026-11-13 -updated: 2026-11-13 +updated: 2026-11-14 --- # EnvDoctor — Implementation Plan @@ -29,29 +29,31 @@ project: EnvDoctor status: mvp version: 0.1.0 builds: true -functional: partial # collection and reporting work; no rules exist +functional: partial # M1/M2 collection and six rules work; M3-M6 remain implemented: - - cobra CLI with diagnose and version, --json, --component, --rule, --verbose + - cobra CLI with diagnose and version, --json, --component, --rule, --verbose, --port, --fixture-dir - collector interface, registry and parallel runner with a 2 s per-collector deadline - platform collector (OS, version, architecture, elevation) on windows, linux and darwin - shell collector (name and path where the shell exposes it) - PATH collector with existence, directory, position and duplicate flags - - redaction of the home directory before any export + - runtime metadata discovery for Python and Node, including selected environment targets + - requested TCP port ownership via OS facilities, Git config and local-default Docker probes + - pure rules ENV001-ENV006 with manual suggestions and actionable verification commands + - home-path redaction including quoted paths and embedded collector errors - text report and a versioned JSON document - documented exit codes 0/1/2/3/4 - JSON schemas for the snapshot and diagnose documents, enforced by a test - - golden-fixture harness (no fixtures yet, because there are no rules) + - 54 golden fixtures covering six rules, three OS snapshots and positive/negative/edge cases + - built CLI integration tests for the golden cases and JSON/exit-code contracts + - bounded offline diagnosis that preserves degraded collector errors - goreleaser configuration verified with a local snapshot build of six targets not_implemented: - - diagnostic rules (section 9 lists 18; zero are implemented) - - runtime collectors (python, node) - - port collector - - git collector - - docker collector + - diagnostic rules ENV007-ENV018 + - full Docker context/version inspector (M2 has only the local-default probe for ENV006) - dependency collector - - explanation engine beyond the finding renderer + - explain/rules commands and generated rule documentation - bubble tea output - diagnostic bundles - fixes and undo @@ -253,12 +255,17 @@ There is no database. The two persisted artefacts are the snapshot and the repor **Severity:** `info`, `warning`, `error`, `critical`. Exit code derives from the highest severity present. +The snapshot above illustrates planned model fields. In M2, runtime `expected`/`missing`, requested TCP +ports and Docker `installed` are also recorded. Docker `context` is `local-default`, not the user's chosen +context. The finding above illustrates a future automatic fix: M2 suggestions are manual, have no commands, +and do not probe TCP port 2375. See ADR 0004 and the committed schemas for the implemented contract. + --- ## 8. Command surface and exit codes ``` -envdoctor diagnose [--json] [--component ] [--rule ] [--verbose] +envdoctor diagnose [--json] [--component ] [--rule ] [--verbose] [--port ] [--fixture-dir ] envdoctor explain envdoctor bundle [--output ] [--include-logs] envdoctor fix [--rule ] [--dry-run] [--yes] @@ -266,7 +273,7 @@ envdoctor rules [--json] envdoctor version ``` -Implemented as of M1: `diagnose` (with all four flags) and `version`. The other commands are planned; see +Implemented as of M2: `diagnose` (with all six flags) and `version`. The other commands are planned; see section 0 for the authoritative list. | Exit code | Meaning | @@ -274,12 +281,18 @@ section 0 for the authoritative list. | 0 | No findings at or above `warning` | | 1 | Warnings only | | 2 | At least one `error` or `critical` | -| 3 | The tool itself failed (collector crash, invalid flag) | +| 3 | The tool itself failed, including degraded collectors or invalid offline snapshot data | | 4 | Invalid usage | `--json` writes exactly one JSON document to stdout and nothing else. All diagnostics and progress go to stderr. This is a contract: scripts depend on it. +`--port` accepts repeated flags and comma-separated ports 1–65535. Network inspection is skipped without +requested ports. `--fixture-dir` reads one version-1 `snapshot.json` up to 8 MiB, invokes no collectors, +and rejects unknown fields, trailing documents, unsupported versions and missing capture timestamps. +Captured collector errors still skip their dependent rules and make the run exit 3. Filters narrow findings, +not collection. JSON includes effective Git identity; review reports before sharing them. + --- ## 9. Rules @@ -307,6 +320,11 @@ Rule ids are stable and never reused. One markdown page per rule in `docs/rules/ | ENV017 | docker | warning | Docker context points at a remote host | | ENV018 | git | info | Repository-local config differs from global identity | +ENV001–ENV006 are implemented. ENV007–ENV018 remain planned. ENV001 requires known differing versions +for active Python aliases. ENV002 reports multiple Node candidates or a missing selected target. ENV004 +uses an explicitly selected environment; ENV005 only reports requested ports; ENV006 probes the default +local engine. Runtime versions are metadata-based and may be unknown. The per-rule pages record caveats. + **Rule contract.** A rule receives the snapshot and returns zero or more findings. It must: - only use fields its declared components provide; @@ -410,11 +428,21 @@ six targets. The CI workflow has a three-OS matrix and it has executed: the firs **Acceptance criteria** -- [ ] Node and Python conflicts detected on all three OSes using golden fixtures -- [ ] Broken PATH entries reported with the exact raw entry -- [ ] Port conflict names the holding process and pid where the OS permits -- [ ] Golden fixtures exist for all six rules and run in CI on every platform -- [ ] No rule calls `os.Getenv` or executes a binary directly (enforced by a lint test) +- [x] Node and Python conflicts detected on all three OSes using golden fixtures +- [x] Broken PATH entries reported with the exact raw entry +- [x] Port conflict names the holding process and pid where the OS permits +- [x] Golden fixtures exist for all six rules and run in CI on every platform +- [x] No rule calls `os.Getenv` or executes a binary directly (enforced by a lint test) + +Status: complete on `feat/m2-implementation`, unreleased. All 54 golden cases passed through both the +engine and a built CLI. Native Windows and Linux race suites passed. A real Windows listening socket was +identified by port, process name and PID. GoReleaser built Windows/Linux/macOS on amd64/arm64; the linter +and vulnerability scanner passed. The existing three-OS CI matrix executes the same full suite on feature +branches. The new M2 branch has not been pushed to GitHub during this session, so an actual updated macOS +run and native Windows race run remain CI validation. Cross-compilation is not evidence of macOS execution. + +Demo: build the CLI and run `diagnose --fixture-dir tests/demo` (synthetic, six findings, exit 2). +See [testing/m1-m2-validation.md](testing/m1-m2-validation.md) for commands, measurements and limitations. ### M3 — Rule engine and dependency rules @@ -489,7 +517,7 @@ six targets. The CI workflow has a three-OS matrix and it has executed: the firs | ENV-017 | Rule ENV005 + fixture | ENV-011 | M2 | | ENV-018 | Rule ENV006 + fixture | ENV-003 | M2 | | ENV-019 | Terminal renderer with severity grouping | ENV-006 | M2 | -| ENV-020 | Docker collector (cli, daemon, context) | ENV-003 | M3 | +| ENV-020 | Full Docker context/version inspector (local-default probe implemented for ENV006 in M2) | ENV-003 | M3 | | ENV-021 | Dependency collector (libs, build tools) | ENV-003 | M3 | | ENV-022 | Rules ENV007–ENV012 | ENV-020, ENV-021 | M3 | | ENV-023 | `explain` command and docs generation | ENV-013 | M3 | @@ -567,15 +595,16 @@ Explicitly out of scope: malware detection, host compromise assessment. 1. Should `diagnose` exit non-zero by default in CI, or require `--strict`? *Current: non-zero on error, so problems are not silently ignored.* -2. How long do fix logs live in `~/.envdoctor/`? *Current: 90 days, pruned on startup.* +2. How long do fix logs live in `~/.envdoctor/`? *Planned: 90 days, pruned on startup; fixes are M5.* 3. Is a rule registry format needed for team rule packs before v1.0? *Current: no, post-1.0.* --- ## 19. Anti-hallucination contract -**Facts:** the repository builds, prints its version, and does nothing else. There are no rules, no -collectors and no fixes. +**Facts:** M1 and M2 collection, six pure diagnostic rules, plain-text/JSON reporting and bounded offline +diagnosis are implemented and tested. Bundles, automatic fixes, rules ENV007–ENV018 and signed releases +remain future work. Runtime discovery does not execute PATH candidates and unknown metadata stays unknown. **Rules:** diff --git a/docs/adr/0004-m2-collectors-and-rules.md b/docs/adr/0004-m2-collectors-and-rules.md new file mode 100644 index 0000000..3016b76 --- /dev/null +++ b/docs/adr/0004-m2-collectors-and-rules.md @@ -0,0 +1,51 @@ +# ADR 0004 — M2 collection, rule evidence and offline diagnosis + +- Status: accepted for the M2 feature branch +- Date: 2026-11-14 +- Supersedes: ADR 0002 decision 5 only for quoted paths and embedded error paths + +## Decisions + +1. Runtime discovery reads absolute PATH entries, file/symlink metadata, known version-manager paths, + Windows PE version resources and `pyvenv.cfg`. It never executes an interpreter or shim discovered + on PATH. Unknown versions stay empty. A numeric directory alone is insufficient evidence of a version. + Python and python3 have independent active resolutions. Broken symlinks cannot shadow a usable later + candidate; venv invocation paths remain distinct from their shared system interpreter target. +2. Expected Python comes from `VIRTUAL_ENV`; expected Node comes from `NVM_BIN` or `NVM_SYMLINK`. + Manager labels cover observable pyenv/nvm/fnm/volta/venv paths. This is metadata-based discovery, + not execution of version-manager commands or proof that every shim selects a working runtime. +3. A port is a conflict only when explicitly requested with `--port`. Collectors read Linux `/proc`, + macOS lsof field output, or Windows `GetExtendedTcpTable` and query-only process handles. Ownership + may be absent when the OS denies access. Missing inspection tools cause degradation, not a clean result. +4. Git and Docker CLIs are invoked only from known absolute installation locations with fixed arguments, + bounded output and a collector deadline. PATH is not used to find them. Git reads effective identity + and autocrlf configuration; an absent key is distinct from an invalid config. Git/Docker environment + overrides are removed from child processes so the probe cannot select an injected repository or remote + Docker context. Git identity remains visible in JSON and should be reviewed before sharing reports. +5. ENV006 belongs to M2 even though the full Docker inspector backlog is M3. M2 therefore implements the + minimum local-default engine probe needed by that rule. It distinguishes a CLI not found in known + locations from a present CLI whose local engine is unreachable. Remote contexts, version-skew rules + and the broader Docker/dependency inspection remain M3. A failed/timed-out probe is degraded. +6. ENV001–ENV006 are pure snapshot functions with stable IDs, deterministic evidence and manual fix + suggestions. Every rule has positive, negative and edge fixtures for all three operating systems. + ENV005 verification includes the relevant requested ports. Import/AST checks prohibit rule I/O. +7. `--fixture-dir` loads one bounded, versioned `snapshot.json` and invokes no collectors. Unknown fields, + additional JSON documents, absent capture timestamps and unsupported versions are rejected. Captured + collector errors still yield notices and exit 3. Platform facts can be absent after a failed collector; + the snapshot schema now permits that documented degraded state. +8. Home redaction also covers quoted raw PATH entries and home paths embedded in error messages. Both + snapshot errors and timing notices are redacted before output. This is home-path redaction, not a + general secret/identity scrubber; the later bundle feature must establish its own export policy. + Unexpected PATH metadata failures (including access denial) degrade the collector; they are not + evidence that a directory is missing. OS errors for malformed paths, overly long paths or a + non-directory ancestor are broken-entry evidence and preserve the raw entry for ENV003. +9. The M1 runner also bounds its wait when a collector ignores cancellation. Late results use buffered + channels and are not merged. A blocked kernel filesystem call cannot be forcibly canceled, but cannot + hold up the CLI after the deadline. No diagnostic command changes the machine or applies a fix. + +## Consequences + +Six rules ship with an actionable plain-text report and a versioned JSON report. A healthy report exits 0, +warnings exit 1, errors exit 2, incomplete inspection exits 3, and invalid usage exits 4. Rule/component +filters still filter findings rather than collectors (ADR 0002 decision 4). The full test suite runs in +the three-OS CI matrix; local cross-compilation is separate from execution on an actual macOS host. diff --git a/docs/rules/ENV001.md b/docs/rules/ENV001.md new file mode 100644 index 0000000..29b6d6b --- /dev/null +++ b/docs/rules/ENV001.md @@ -0,0 +1,18 @@ +# ENV001: Conflicting active Python versions + +Component: python. Severity: error. + +Active Python command aliases resolve to different installed versions. + +Requires at least two distinct known versions on active `python`/`python3` resolutions. Extra inactive +Python installations alone do not fire this rule. Unknown versions and a failed runtime collector do not +provide conflict evidence. Versions come from manager paths, PE resources or `pyvenv.cfg`; system Unix +binaries and opaque shims may remain unknown because discovered interpreters are never executed. + +Verify with `envdoctor diagnose --rule ENV001`. Fixtures compare Python 3.11.1 and 3.12.1 across all +three OS snapshots. Venv/symlink behavior is also tested on Linux; native Windows and Linux suites passed +on 2026-11-14. Updated macOS execution remains in the CI matrix. + +Suggested fix: Activate the intended environment and align python and python3 on PATH. + +No command is executed by this rule. Evidence comes only from the snapshot. Unknown or failed collectors do not produce guessed findings. Positive, negative and edge fixtures cover Windows, Linux and macOS. diff --git a/docs/rules/ENV002.md b/docs/rules/ENV002.md new file mode 100644 index 0000000..1b5b4f8 --- /dev/null +++ b/docs/rules/ENV002.md @@ -0,0 +1,17 @@ +# ENV002: Conflicting Node installations or missing target + +Component: node. Severity: error. + +Multiple Node candidates or a selected manager target are inconsistent. + +Fires when the snapshot contains multiple distinct installed Node candidates, or an active/selected target +is missing. It does not require known versions and does not prove that every inactive install is harmful. +Expected targets use `NVM_BIN` or `NVM_SYMLINK`; fnm/volta locations can be identified from observable paths, +but opaque shim selection is not executed or guessed. A failed runtime collector disables the rule. + +Verify with `envdoctor diagnose --rule ENV002`. Three-OS fixtures include Node 20.1.0 and 22.1.0, plus +negative/degraded cases. Metadata discovery also tests an nvm 22.11.0 directory without executing its binary. + +Suggested fix: Select one Node installation and repair the selected version-manager target. + +No command is executed by this rule. Evidence comes only from the snapshot. Unknown or failed collectors do not produce guessed findings. Positive, negative and edge fixtures cover Windows, Linux and macOS. diff --git a/docs/rules/ENV003.md b/docs/rules/ENV003.md new file mode 100644 index 0000000..24dbd6b --- /dev/null +++ b/docs/rules/ENV003.md @@ -0,0 +1,18 @@ +# ENV003: Missing PATH entries + +Component: path. Severity: warning. + +PATH references directories that no longer exist. + +The evidence preserves each missing entry exactly as stored in PATH, including whitespace and quotes, +except that the current home path is replaced with `~`. Empty elements are omitted under the M1 contract. +Permission/inspection failures degrade the PATH collector rather than invent missing-directory evidence. +OS errors for malformed Windows paths, overly long paths and non-directory ancestors are reported as +broken entries. An existing regular file on PATH and shell startup settings remain outside this rule. + +Verify with `envdoctor diagnose --rule ENV003`. Positive, negative and failed-collector fixtures cover +all three OS snapshots; raw-entry, quoting, malformed-path and permission behavior have collector regressions. + +Suggested fix: Remove or correct these entries in your shell or system PATH settings. + +No command is executed by this rule. Evidence comes only from the snapshot. Unknown or failed collectors do not produce guessed findings. Positive, negative and edge fixtures cover Windows, Linux and macOS. diff --git a/docs/rules/ENV004.md b/docs/rules/ENV004.md new file mode 100644 index 0000000..cafd5f3 --- /dev/null +++ b/docs/rules/ENV004.md @@ -0,0 +1,17 @@ +# ENV004: Selected runtime is shadowed on PATH + +Component: path. Severity: warning. + +An earlier PATH candidate takes precedence over the selected environment. + +The snapshot must identify an existing selected runtime that is inactive, plus a different active runtime +of the same family. Selection comes from `VIRTUAL_ENV`, `NVM_BIN` or `NVM_SYMLINK`. Missing selected +targets are not shadowing evidence; ENV002 covers missing Node targets. Failed PATH/runtime inspection +disables this rule. Python venv invocation paths remain distinct even when they share a system symlink target. + +Verify with `envdoctor diagnose --rule ENV004`. Three-OS fixtures cover selected versus active targets; +Linux tests exercise the venv/system symlink case without executing the interpreter. + +Suggested fix: Activate the selected environment or move its bin directory before the conflicting entry. + +No command is executed by this rule. Evidence comes only from the snapshot. Unknown or failed collectors do not produce guessed findings. Positive, negative and edge fixtures cover Windows, Linux and macOS. diff --git a/docs/rules/ENV005.md b/docs/rules/ENV005.md new file mode 100644 index 0000000..7b45c00 --- /dev/null +++ b/docs/rules/ENV005.md @@ -0,0 +1,18 @@ +# ENV005: Requested TCP port is already in use + +Component: network. Severity: error. + +A listening process already holds a port requested for this application. + +Use `--port 3000` or `--port 3000,8080`. Network inspection is skipped when no ports are requested. +Only TCP listeners count; established connections alone do not. PID/name come from OS facilities where +permitted; the finding says when ownership is unavailable. It does not terminate a process or change a port. + +Verification preserves the relevant ports, for example `envdoctor diagnose --rule ENV005 --port 3000`. +Windows uses native TCP/process APIs, Linux reads `/proc`, and macOS parses lsof field output from a known +installation. A missing inspector degrades the report. Real owned-listener tests passed on Windows and +Linux on 2026-11-14; three-OS fixtures and the CI matrix cover the platform contract. + +Suggested fix: Choose another port or stop the identified process after confirming it is safe to stop. + +No command is executed by this rule. Evidence comes only from the snapshot. Unknown or failed collectors do not produce guessed findings. Positive, negative and edge fixtures cover Windows, Linux and macOS. diff --git a/docs/rules/ENV006.md b/docs/rules/ENV006.md new file mode 100644 index 0000000..aa9786e --- /dev/null +++ b/docs/rules/ENV006.md @@ -0,0 +1,18 @@ +# ENV006: Docker daemon unavailable + +Component: docker. Severity: error. + +The local default Docker engine is stopped, inaccessible, or uses another socket. + +Requires a Docker CLI found in a known installation location and a completed probe whose metadata reports +no reachable server. A CLI not found, malformed output or a timed-out/failed collector does not fire the rule. +The probe explicitly uses `/var/run/docker.sock` or the Windows `docker_engine` pipe and removes remote +context overrides. A rootless/custom socket or selected remote context is not checked by this M2 rule. + +Verify with `envdoctor diagnose --rule ENV006`. Presence/outage/invalid-output tests use Docker 29.9.2 +metadata; a live per-user Docker Desktop installation was detected on Windows on 2026-11-14. Full context +inspection and version-skew rules remain M3, as recorded in ADR 0004. + +Suggested fix: Start the local Docker engine and check permission to its default socket. + +No command is executed by this rule. Evidence comes only from the snapshot. Unknown or failed collectors do not produce guessed findings. Positive, negative and edge fixtures cover Windows, Linux and macOS. diff --git a/docs/schema/snapshot-v1.schema.json b/docs/schema/snapshot-v1.schema.json index 74730fc..9685d69 100644 --- a/docs/schema/snapshot-v1.schema.json +++ b/docs/schema/snapshot-v1.schema.json @@ -5,55 +5,96 @@ "description": "One redacted observation of a machine. Fields that a build does not collect are absent, not empty.", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "collectedAt", "platform"], + "required": [ + "schemaVersion", + "collectedAt" + ], "properties": { - "schemaVersion": { "const": 1 }, - "collectedAt": { "type": "string", "format": "date-time" }, + "schemaVersion": { + "const": 1 + }, + "collectedAt": { + "type": "string", + "format": "date-time" + }, "platform": { "type": "object", "additionalProperties": false, - "required": ["os", "version", "arch", "elevated"], + "required": [ + "os", + "version", + "arch", + "elevated" + ], "properties": { - "os": { "type": "string", "minLength": 1 }, + "os": { + "type": "string", + "minLength": 1 + }, "version": { "type": "string", "description": "Empty when the operating system does not expose a version." }, - "arch": { "type": "string", "minLength": 1 }, - "elevated": { "type": "boolean" } + "arch": { + "type": "string", + "minLength": 1 + }, + "elevated": { + "type": "boolean" + } } }, "shell": { "type": "object", "additionalProperties": false, - "required": ["name"], + "required": [ + "name" + ], "properties": { - "name": { "type": "string", "minLength": 1 }, - "path": { "type": "string" } + "name": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string" + } } }, "path": { "type": "object", "additionalProperties": false, - "required": ["entries"], + "required": [ + "entries" + ], "properties": { "entries": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["raw", "exists", "isDir", "position", "duplicate"], + "required": [ + "raw", + "exists", + "isDir", + "position", + "duplicate" + ], "properties": { "raw": { "type": "string", "description": "The entry exactly as it appeared, after home-directory redaction." }, - "exists": { "type": "boolean" }, + "exists": { + "type": "boolean" + }, "isDir": { "type": "boolean", "description": "True only when the entry exists and is a directory." }, - "position": { "type": "integer", "minimum": 0 }, + "position": { + "type": "integer", + "minimum": 0 + }, "duplicate": { "type": "boolean", "description": "True when an identical earlier entry exists. Case-insensitive on Windows." @@ -68,13 +109,35 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["name", "version", "path", "manager", "active"], + "required": [ + "name", + "version", + "path", + "manager", + "active" + ], "properties": { - "name": { "type": "string" }, - "version": { "type": "string" }, - "path": { "type": "string" }, - "manager": { "type": "string" }, - "active": { "type": "boolean" } + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "path": { + "type": "string" + }, + "manager": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "expected": { + "type": "boolean" + }, + "missing": { + "type": "boolean" + } } } }, @@ -83,35 +146,88 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["port", "protocol", "state"], + "required": [ + "port", + "protocol", + "state" + ], "properties": { - "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, - "protocol": { "type": "string", "enum": ["tcp", "udp"] }, - "state": { "type": "string" }, - "pid": { "type": "integer", "minimum": 0 }, - "process": { "type": "string" } + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "protocol": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + "state": { + "type": "string" + }, + "pid": { + "type": "integer", + "minimum": 0 + }, + "process": { + "type": "string" + } } } }, "docker": { "type": "object", "additionalProperties": false, - "required": ["cliVersion", "daemonReachable", "context"], + "required": [ + "cliVersion", + "daemonReachable", + "context" + ], "properties": { - "cliVersion": { "type": "string" }, - "daemonReachable": { "type": "boolean" }, - "context": { "type": "string" } + "cliVersion": { + "type": "string" + }, + "daemonReachable": { + "type": "boolean" + }, + "context": { + "type": "string" + }, + "installed": { + "type": "boolean" + } } }, "git": { "type": "object", "additionalProperties": false, - "required": ["version", "userName", "userEmail", "autocrlf"], + "required": [ + "version", + "userName", + "userEmail", + "autocrlf" + ], "properties": { - "version": { "type": "string" }, - "userName": { "type": ["string", "null"] }, - "userEmail": { "type": ["string", "null"] }, - "autocrlf": { "type": "string" } + "version": { + "type": "string" + }, + "userName": { + "type": [ + "string", + "null" + ] + }, + "userEmail": { + "type": [ + "string", + "null" + ] + }, + "autocrlf": { + "type": "string" + } } }, "errors": { @@ -119,12 +235,29 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["collector", "message"], + "required": [ + "collector", + "message" + ], "properties": { - "collector": { "type": "string", "minLength": 1 }, - "message": { "type": "string", "minLength": 1 } + "collector": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + } } } + }, + "requestedPorts": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } } } } diff --git a/docs/testing/m1-m2-validation.md b/docs/testing/m1-m2-validation.md new file mode 100644 index 0000000..df0990a --- /dev/null +++ b/docs/testing/m1-m2-validation.md @@ -0,0 +1,85 @@ +# EnvDoctor M1 and M2 validation — 2026-11-14 + +Branch: `feat/m2-implementation`. M2 is complete locally and unreleased. No main-branch changes, +pushes or release publication are part of this work. + +## Environment + +Windows host (AMD Ryzen 5 5600H), Go 1.26.11, Docker Desktop Linux containers with Go 1.26.11, +golangci-lint 2.15.0 and GoReleaser. Linux race tests use the cached `golang:1.26.11` image. +Docker is a test tool/optional inspected component; the CLI itself remains one static Go binary. + +## M1 regressions + +The original platform, shell, PATH, rendering, schema, flags and exit-code tests pass. Extra regressions +bound the runner when a collector ignores cancellation, permit absent platform facts in a degraded JSON +snapshot, and preserve home redaction in quoted entries and error/timing notices. PATH metadata access +failures now degrade collection rather than inventing evidence that a directory is missing. Malformed +Windows paths and paths traversing a regular file remain broken-entry evidence, with the raw entry preserved. + +## M2 acceptance + +| Requirement | Evidence | +|---|---| +| Python and Node conflict fixtures for all three OSes | 54 exact golden cases: six rules × Windows/Linux/macOS × positive/negative/edge | +| Raw broken PATH entries preserved | Collector quoting/raw-entry tests, ENV003 fixtures and home-redaction tests | +| Requested port identifies its owner where permitted | Real owned TCP listener tests on Windows and Linux; final Windows CLI smoke run identified `pwsh.exe` PID 7200 on TCP 26743 | +| All six rules covered by CLI and CI tests | Built binary exercised against all 54 fixtures, checking exact findings, schema and exit code; full suite wired into the three-OS CI matrix | +| Rules perform no direct OS/environment/process I/O | Import/AST purity test; deterministic snapshot-to-finding comparisons | + +Collector tests cover nonexecution of discovered PATH binaries, unrelated numeric-directory versions, +broken symlinks, venv/system symlink separation, missing versus invalid Git config, per-user Docker locations, +local Docker metadata states, sanitized Git/Docker context overrides, lsof parsing/failure distinction and +process deadlines. Offline tests reject trailing JSON and preserve captured collector errors as exit 3. + +The committed `tests/demo/snapshot.json` is synthetic. It produces all six findings and exits 2. +A focused ENV005 demo includes `--port 3000` in its verification command. It is not a captured host diagnosis. + +## Checks and measurements + +- Native `go test -count=1 -timeout=120s ./...`, `go vet ./...`, and `go build ./...`: passed. +- Linux `go test -race -count=1 -timeout=120s ./...`: passed, including real listener and built-CLI tests. +- golangci-lint 2.15.0: zero issues. `govulncheck ./...` on Go 1.26.11: no vulnerabilities found. +- `goreleaser check` and `goreleaser build --snapshot --clean`: passed for all six targets. +- Live Windows JSON diagnosis detected six registered rules, the owned TCP socket, and the reachable + per-user Docker Desktop CLI/engine. It did not change the listener or any environment configuration. +- Three sequential native diagnosis invocations took 208.78, 189.69 and 188.96 ms with a running Docker + engine. These are local warm observations, not a clean-host or all-platform M6 performance claim. +- Ten additional sequential live listener checks passed without degraded collectors (184.54–229.65 ms). + One earlier live attempt returned exit 3; the final thirteen warm runs completed without degradation. +- Snapshot binaries ranged from 3,276,962 to 3,693,056 bytes across the six targets. + +The dependency `golang.org/x/sys` moved from v0.30.0 to v0.48.0, removing the vulnerability found during +the audit. Source formatting and staged diffs are checked before the feature-branch commit. + +## Reproduce + +```powershell +$env:GOTOOLCHAIN='go1.26.11' +go test -count=1 -timeout=120s ./... +go vet ./... +go build ./... +goreleaser check +goreleaser build --snapshot --clean +``` + +When a native Windows C toolchain cannot run the race detector: + +```powershell +docker run --rm -v "${PWD}:/src:ro" -v m2-go-mod:/go/pkg/mod -v m2-go-build:/root/.cache/go-build -w /src -e GOTOOLCHAIN=go1.26.11 golang:1.26.11 go test -race -count=1 -timeout=120s ./... +``` + +CI runs that full suite on Windows, Ubuntu and macOS. Feature/fix branches now trigger it. The updated +branch has not been pushed during this session, so actual updated macOS execution and native Windows race +execution remain pending CI. Cross-compiling macOS binaries is not a substitute for executing them there. + +## Limits + +Runtime discovery is based on metadata. Unknown versions stay unknown; opaque shims, custom installation +locations and some Unix system binaries cannot be fully resolved without executing them. Expected targets +use `VIRTUAL_ENV`, `NVM_BIN` and `NVM_SYMLINK`. Ports are checked only when requested; owner information +can be unavailable due to OS permissions. Docker probes only the default local socket, not remote/rootless +or custom contexts. Git identity appears in JSON and should be reviewed before sharing. + +Rules ENV007–ENV018, dependency inspection, broader Docker rules, generated explanation docs, bundles, +automatic remediation, interactive output and signed releases remain later milestones. diff --git a/go.mod b/go.mod index 7ee3080..47b3336 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.26.11 require ( github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/spf13/cobra v1.10.2 - golang.org/x/sys v0.30.0 + golang.org/x/sys v0.48.0 ) require ( diff --git a/go.sum b/go.sum index a0f92aa..4830213 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,6 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index db70b88..be657bc 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -88,17 +88,21 @@ func (a *App) applyDefaults() { a.Collectors = DefaultCollectors() } if a.Engine == nil { - a.Engine = diagnostic.NewEngine() + a.Engine = diagnostic.DefaultEngine() } } -// DefaultCollectors returns the collectors implemented in this build. M1 has -// three; runtimes, ports, Git and Docker arrive in M2 and M3. +// DefaultCollectors returns the seven M2 collectors. Network inspection runs +// only for requested ports; Docker probes only the default local engine. func DefaultCollectors() []collector.Collector { return []collector.Collector{ collector.NewPlatformCollector(), collector.NewShellCollector(), collector.NewPathCollector(), + collector.NewRuntimeCollector(), + collector.PortCollector{}, + collector.GitCollector{}, + collector.DockerCollector{}, } } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ac0080b..52de10a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -56,6 +56,7 @@ func newApp(stdout, stderr *bytes.Buffer) cli.App { Now: func() time.Time { return fixedNow }, HomeDir: func() (string, error) { return fixedHome, nil }, Collectors: healthyCollectors(), + Engine: diagnostic.NewEngine(), // Keep the M1 renderer contract test independent of rules. } } diff --git a/internal/cli/diagnose.go b/internal/cli/diagnose.go index 7d6c13d..444a87e 100644 --- a/internal/cli/diagnose.go +++ b/internal/cli/diagnose.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "fmt" "github.com/spf13/cobra" @@ -55,20 +56,43 @@ func (a *App) newDiagnoseCommand() *cobra.Command { cmd.Flags().BoolVar(&opts.Verbose, "verbose", false, "write collector timings to stderr") cmd.Flags().StringVar(&opts.Component, "component", "", "only evaluate rules for this component") cmd.Flags().StringVar(&opts.Rule, "rule", "", "only evaluate this rule id (ENV001)") + cmd.Flags().IntSliceVar(&opts.Ports, "port", nil, "TCP ports your application needs (repeatable or comma separated)") + cmd.Flags().StringVar(&opts.FixtureDir, "fixture-dir", "", "read snapshot.json in this directory; do not inspect this machine") return cmd } func (a *App) diagnose(ctx context.Context, opts config.Options) (report.Document, error) { - runner := collector.Runner{ - Collectors: a.Collectors, - Timeout: config.CollectorTimeout, - Now: a.Now, + var snapshot collector.Snapshot + var timings []collector.Timing + if opts.FixtureDir != "" { + loaded, err := loadSnapshot(opts.FixtureDir) + if err != nil { + return report.Document{}, err + } + snapshot = loaded + if len(opts.Ports) > 0 { + snapshot.RequestedPorts = opts.Ports + } + } else { + collectors := append([]collector.Collector(nil), a.Collectors...) + for i, c := range collectors { + if _, ok := c.(collector.PortCollector); ok { + collectors[i] = collector.PortCollector{Requested: opts.Ports} + } + } + runner := collector.Runner{Collectors: collectors, Timeout: config.CollectorTimeout, Now: a.Now} + snapshot, timings = runner.Run(ctx) + snapshot.RequestedPorts = opts.Ports } - snapshot, timings := runner.Run(ctx) if home, err := a.HomeDir(); err == nil { snapshot.Redact(home) + for i := range timings { + if timings[i].Err != nil { + timings[i].Err = errors.New(collector.RedactText(timings[i].Err.Error(), home)) + } + } } findings, err := a.Engine.Evaluate(snapshot, diagnostic.Filter{ diff --git a/internal/cli/fixture.go b/internal/cli/fixture.go new file mode 100644 index 0000000..cf8ffd2 --- /dev/null +++ b/internal/cli/fixture.go @@ -0,0 +1,46 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/shellghostt/envdoctor/internal/collector" + "github.com/shellghostt/envdoctor/internal/config" +) + +const maxSnapshotBytes = 8 << 20 + +func loadSnapshot(dir string) (collector.Snapshot, error) { + file, err := os.Open(filepath.Join(dir, "snapshot.json")) + if err != nil { + return collector.Snapshot{}, err + } + defer func() { _ = file.Close() }() + data, err := io.ReadAll(io.LimitReader(file, maxSnapshotBytes+1)) + if err != nil { + return collector.Snapshot{}, err + } + if len(data) > maxSnapshotBytes { + return collector.Snapshot{}, fmt.Errorf("snapshot exceeds 8 MiB") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var snapshot collector.Snapshot + if err = decoder.Decode(&snapshot); err != nil { + return collector.Snapshot{}, err + } + if err = decoder.Decode(new(any)); err != io.EOF { + return collector.Snapshot{}, fmt.Errorf("snapshot must contain exactly one JSON document") + } + if snapshot.SchemaVersion != config.SchemaVersion { + return collector.Snapshot{}, fmt.Errorf("unsupported snapshot version") + } + if snapshot.CollectedAt.IsZero() { + return collector.Snapshot{}, fmt.Errorf("snapshot has no collectedAt timestamp") + } + return snapshot, nil +} diff --git a/internal/cli/m2_test.go b/internal/cli/m2_test.go new file mode 100644 index 0000000..23e3ba9 --- /dev/null +++ b/internal/cli/m2_test.go @@ -0,0 +1,89 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "github.com/shellghostt/envdoctor/internal/cli" + "github.com/shellghostt/envdoctor/internal/collector" + "github.com/shellghostt/envdoctor/internal/diagnostic" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDefaultRulesAndSchema(t *testing.T) { + var out, errs bytes.Buffer + app := newApp(&out, &errs) + app.Engine = nil + code := cli.Main([]string{"envdoctor", "diagnose", "--json"}, app) + if code != 1 { + t.Fatalf("exit=%d %s", code, errs.String()) + } + validateAgainstSchema(t, compileDiagnoseSchema(t), out.Bytes()) + if !strings.Contains(out.String(), "ENV003") { + t.Fatal("default rule did not run") + } +} +func TestFixtureCLIIsOfflineAndExitCodesMatch(t *testing.T) { + paths, err := filepath.Glob(filepath.Join("..", "..", "tests", "golden", "*.json")) + if err != nil { + t.Fatal(err) + } + for _, path := range paths { + t.Run(filepath.Base(path), func(t *testing.T) { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + f, err := diagnostic.LoadFixture(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + raw, _ := json.Marshal(f.Snapshot) + if err := os.WriteFile(filepath.Join(dir, "snapshot.json"), raw, 0600); err != nil { + t.Fatal(err) + } + var out, errs bytes.Buffer + app := newApp(&out, &errs) + app.Engine = nil + app.Collectors = []collector.Collector{staticCollector{err: os.ErrPermission}} + code := cli.Main([]string{"envdoctor", "diagnose", "--fixture-dir", dir, "--rule", f.RuleID, "--json"}, app) + wantCode := diagnostic.ExitCode(f.ExpectedFindings) + if len(f.Snapshot.Errors) > 0 { + wantCode = cli.ExitToolFailure + } + if code != wantCode { + t.Fatalf("exit=%d stderr=%s", code, errs.String()) + } + validateAgainstSchema(t, compileDiagnoseSchema(t), out.Bytes()) + }) + } +} + +func TestOfflineInputRejectsTrailingDocuments(t *testing.T) { + dir := t.TempDir() + raw, err := json.Marshal(collector.Snapshot{SchemaVersion: 1, CollectedAt: time.Now()}) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(dir, "snapshot.json"), append(raw, []byte(` {"schemaVersion":999}`)...), 0600); err != nil { + t.Fatal(err) + } + var out, errs bytes.Buffer + if code := cli.Main([]string{"envdoctor", "diagnose", "--fixture-dir", dir, "--json"}, newApp(&out, &errs)); code != cli.ExitToolFailure { + t.Fatalf("trailing document was accepted: exit=%d output=%s", code, out.String()) + } +} + +func TestJSONSchemaAllowsFailedPlatformCollector(t *testing.T) { + var out, errs bytes.Buffer + app := newApp(&out, &errs) + app.Collectors = []collector.Collector{staticCollector{name: "platform", err: os.ErrPermission}} + if code := cli.Main([]string{"envdoctor", "diagnose", "--json"}, app); code != cli.ExitToolFailure { + t.Fatalf("failed platform exit=%d", code) + } + validateAgainstSchema(t, compileDiagnoseSchema(t), out.Bytes()) +} diff --git a/internal/collector/collector.go b/internal/collector/collector.go index b6b6417..7003732 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -32,6 +32,10 @@ type Part struct { Platform *Platform Shell *Shell Path *PathInfo + Runtimes []Runtime + Ports []Port + Docker *Docker + Git *Git } // Timing records how long one collector took, whether it failed, and why. @@ -98,7 +102,21 @@ func (r Runner) Run(ctx context.Context) (Snapshot, []Timing) { collectCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - part, err := c.Collect(collectCtx) + // A slow filesystem call may not observe cancellation. Bound the + // runner's wait as well; the buffered result lets a late collector exit. + done := make(chan outcome, 1) + go func() { + part, err := c.Collect(collectCtx) + done <- outcome{part: part, err: err} + }() + var part Part + var err error + select { + case result := <-done: + part, err = result.part, result.err + case <-collectCtx.Done(): + err = collectCtx.Err() + } if err != nil && errors.Is(err, context.DeadlineExceeded) { err = fmt.Errorf("timeout after %s", timeout) } @@ -133,6 +151,18 @@ func (r Runner) Run(ctx context.Context) (Snapshot, []Timing) { } func mergePart(snapshot *Snapshot, part Part) { + if part.Runtimes != nil { + snapshot.Runtimes = part.Runtimes + } + if part.Ports != nil { + snapshot.Ports = part.Ports + } + if part.Docker != nil { + snapshot.Docker = part.Docker + } + if part.Git != nil { + snapshot.Git = part.Git + } if part.Platform != nil { snapshot.Platform = part.Platform } diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 9456d62..8914e45 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -16,6 +16,32 @@ type fakeCollector struct { delay time.Duration } +type stuckCollector struct{ release <-chan struct{} } + +func (stuckCollector) Name() string { return "stuck" } +func (c stuckCollector) Collect(context.Context) (collector.Part, error) { + <-c.release + return collector.Part{}, nil +} + +func TestRunnerEnforcesDeadlineWhenCollectorIgnoresCancellation(t *testing.T) { + release := make(chan struct{}) + defer close(release) + done := make(chan []collector.Timing, 1) + go func() { + _, timings := (collector.Runner{Collectors: []collector.Collector{stuckCollector{release}}, Timeout: 20 * time.Millisecond}).Run(context.Background()) + done <- timings + }() + select { + case timings := <-done: + if !collector.Degraded(timings) { + t.Fatal("timeout was not recorded") + } + case <-time.After(time.Second): + t.Fatal("collector bypassed the runner deadline") + } +} + func (f fakeCollector) Name() string { return f.name } func (f fakeCollector) Collect(ctx context.Context) (collector.Part, error) { diff --git a/internal/collector/docker.go b/internal/collector/docker.go new file mode 100644 index 0000000..01791ce --- /dev/null +++ b/internal/collector/docker.go @@ -0,0 +1,55 @@ +package collector + +import ( + "context" + "encoding/json" + "fmt" + "github.com/shellghostt/envdoctor/internal/system" + "runtime" +) + +type DockerCollector struct { + FindTool func(string) string + RunTool func(context.Context, string, ...string) ([]byte, error) +} + +func (DockerCollector) Name() string { return "docker" } +func (c DockerCollector) Collect(ctx context.Context) (Part, error) { + find, run := c.FindTool, c.RunTool + if find == nil { + find = system.InstalledTool + } + if run == nil { + run = system.RunTool + } + path := find("docker") + if path == "" { + return Part{Docker: &Docker{}}, nil + } + host := "unix:///var/run/docker.sock" + if runtime.GOOS == "windows" { + host = "npipe:////./pipe/docker_engine" + } + out, err := run(ctx, path, "--host", host, "version", "--format", "{{json .}}") + if ctx.Err() != nil { + return Part{}, ctx.Err() + } + var version struct { + Client *struct{ Version string } + Server *struct{ Version string } + } + if json.Unmarshal(out, &version) != nil || version.Client == nil { + if ctx.Err() != nil { + return Part{}, ctx.Err() + } + if err != nil { + return Part{}, err + } + return Part{}, fmt.Errorf("Docker version returned invalid metadata") + } + d := &Docker{Installed: true, Context: "local-default", DaemonReachable: err == nil && version.Server != nil} + if version.Client != nil { + d.CLIVersion = version.Client.Version + } + return Part{Docker: d}, nil +} diff --git a/internal/collector/git.go b/internal/collector/git.go new file mode 100644 index 0000000..f70dc55 --- /dev/null +++ b/internal/collector/git.go @@ -0,0 +1,54 @@ +package collector + +import ( + "context" + "github.com/shellghostt/envdoctor/internal/system" + "strings" +) + +type GitCollector struct { + FindTool func(string) string + RunTool func(context.Context, string, ...string) ([]byte, error) +} + +func (GitCollector) Name() string { return "git" } +func (c GitCollector) Collect(ctx context.Context) (Part, error) { + find, run := c.FindTool, c.RunTool + if find == nil { + find = system.InstalledTool + } + if run == nil { + run = system.RunTool + } + path := find("git") + if path == "" { + return Part{}, nil + } + version, err := run(ctx, path, "--version") + if err != nil { + return Part{}, err + } + g := &Git{Version: strings.TrimSpace(strings.TrimPrefix(string(version), "git version "))} + for _, key := range []string{"user.name", "user.email", "core.autocrlf"} { + out, err := run(ctx, path, "config", "--get", key) + if ctx.Err() != nil { + return Part{}, ctx.Err() + } + if err != nil { + if system.ExitedWith(err, 1) { + continue // Missing config values are different from malformed config. + } + return Part{}, err + } + value := strings.TrimSpace(string(out)) + switch key { + case "user.name": + g.UserName = &value + case "user.email": + g.UserEmail = &value + case "core.autocrlf": + g.Autocrlf = value + } + } + return Part{Git: g}, nil +} diff --git a/internal/collector/path.go b/internal/collector/path.go index 9af0f1a..b1e1957 100644 --- a/internal/collector/path.go +++ b/internal/collector/path.go @@ -2,9 +2,12 @@ package collector import ( "context" + "fmt" "os" "runtime" "strings" + + "github.com/shellghostt/envdoctor/internal/system" ) // PathCollector splits PATH and checks each entry with file metadata. @@ -50,6 +53,9 @@ func (c PathCollector) Collect(ctx context.Context) (Part, error) { target := unquote(raw) info, err := c.Stat(target) + if err != nil && !system.IsMissingPath(err) { + return Part{}, fmt.Errorf("inspect PATH entry %q: %w", raw, err) + } exists := err == nil isDir := exists && info.IsDir() diff --git a/internal/collector/path_test.go b/internal/collector/path_test.go index b05e603..a62e0d8 100644 --- a/internal/collector/path_test.go +++ b/internal/collector/path_test.go @@ -3,6 +3,7 @@ package collector_test import ( "context" "os" + "path/filepath" "testing" "time" @@ -135,3 +136,26 @@ func TestPathCollectorStopsOnCancelledContext(t *testing.T) { t.Fatal("cancelled context should return an error") } } + +func TestPathPermissionFailureIsNotMissingDirectoryEvidence(t *testing.T) { + c := collector.PathCollector{Getenv: func(string) string { return "/private/bin" }, Separator: ":", Stat: func(string) (os.FileInfo, error) { return nil, os.ErrPermission }} + if _, err := c.Collect(context.Background()); err == nil { + t.Fatal("access denied was converted into evidence that a PATH directory is missing") + } +} + +func TestPathThroughRegularFileIsReportedAsMissing(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, nil, 0600); err != nil { + t.Fatal(err) + } + raw := filepath.Join(file, "bin") + c := collector.PathCollector{Getenv: func(string) string { return raw }, Separator: string(os.PathListSeparator), Stat: os.Stat} + part, err := c.Collect(context.Background()) + if err != nil { + t.Fatalf("non-directory ancestor degraded collection: %v", err) + } + if part.Path == nil || len(part.Path.Entries) != 1 || part.Path.Entries[0].Raw != raw || part.Path.Entries[0].Exists { + t.Fatalf("invalid raw entry was not preserved as missing: %+v", part.Path) + } +} diff --git a/internal/collector/path_windows_test.go b/internal/collector/path_windows_test.go new file mode 100644 index 0000000..1edb401 --- /dev/null +++ b/internal/collector/path_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package collector_test + +import ( + "context" + "os" + "testing" + + "github.com/shellghostt/envdoctor/internal/collector" +) + +func TestMalformedWindowsPathIsReportedWithRawEntry(t *testing.T) { + raw := ` C:\Windows\System32` + c := collector.PathCollector{Getenv: func(string) string { return raw }, Separator: ";", Stat: os.Stat} + part, err := c.Collect(context.Background()) + if err != nil { + t.Fatalf("malformed path degraded collection: %v", err) + } + if part.Path == nil || len(part.Path.Entries) != 1 || part.Path.Entries[0].Raw != raw || part.Path.Entries[0].Exists { + t.Fatalf("malformed raw entry was not preserved as missing: %+v", part.Path) + } +} diff --git a/internal/collector/ports.go b/internal/collector/ports.go new file mode 100644 index 0000000..ea0d23e --- /dev/null +++ b/internal/collector/ports.go @@ -0,0 +1,38 @@ +package collector + +import ( + "context" + "github.com/shellghostt/envdoctor/internal/system" + "sort" +) + +type PortCollector struct{ Requested []int } + +func (PortCollector) Name() string { return "network" } +func (c PortCollector) Collect(ctx context.Context) (Part, error) { + if len(c.Requested) == 0 { + return Part{Ports: []Port{}}, nil + } + sockets, err := system.ListeningPorts(ctx) + if err != nil { + return Part{}, err + } + out := []Port{} + seen := map[[2]int]bool{} + for _, s := range sockets { + for _, wanted := range c.Requested { + key := [2]int{s.Port, s.PID} + if s.Port == wanted && !seen[key] { + seen[key] = true + out = append(out, Port{Port: s.Port, Protocol: "tcp", State: "listen", PID: s.PID, Process: s.Process}) + } + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Port == out[j].Port { + return out[i].PID < out[j].PID + } + return out[i].Port < out[j].Port + }) + return Part{Ports: out}, nil +} diff --git a/internal/collector/redact.go b/internal/collector/redact.go index 5cbe858..be1435e 100644 --- a/internal/collector/redact.go +++ b/internal/collector/redact.go @@ -20,16 +20,56 @@ func (s *Snapshot) Redact(home string) { } if s.Shell != nil { - s.Shell.Path = RedactHome(s.Shell.Path, home) + s.Shell.Path = RedactText(s.Shell.Path, home) } if s.Path != nil { for i := range s.Path.Entries { - s.Path.Entries[i].Raw = RedactHome(s.Path.Entries[i].Raw, home) + s.Path.Entries[i].Raw = RedactText(s.Path.Entries[i].Raw, home) } } for i := range s.Runtimes { - s.Runtimes[i].Path = RedactHome(s.Runtimes[i].Path, home) + s.Runtimes[i].Path = RedactText(s.Runtimes[i].Path, home) } + for i := range s.Errors { + s.Errors[i].Message = RedactText(s.Errors[i].Message, home) + } +} + +// RedactText also handles quoted paths and paths embedded in an OS error. +// Segment boundaries prevent replacing another user's home or a nested path. +func RedactText(value, home string) string { + home = strings.TrimRight(home, `\/`) + if home == "" { + return value + } + needle := normalizeSeparators(home) + var result strings.Builder + last := 0 + for at := 0; at+len(home) <= len(value); at++ { + if !strings.EqualFold(normalizeSeparators(value[at:at+len(home)]), needle) { + continue + } + end := at + len(home) + if at > 0 && isPathByte(value[at-1]) { + continue + } + if end < len(value) && value[end] != '/' && value[end] != '\\' && !strings.ContainsRune(" \t\r\n\"':,;)]}", rune(value[end])) { + continue + } + result.WriteString(value[last:at]) + result.WriteByte('~') + last = end + at = end - 1 + } + if last == 0 { + return value + } + result.WriteString(value[last:]) + return result.String() +} + +func isPathByte(b byte) bool { + return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || strings.ContainsRune("/_-.\\", rune(b)) } // RedactHome replaces a leading home directory with `~`. The comparison is diff --git a/internal/collector/redact_test.go b/internal/collector/redact_test.go index b655684..b95936e 100644 --- a/internal/collector/redact_test.go +++ b/internal/collector/redact_test.go @@ -84,3 +84,21 @@ func TestRedactRewritesSnapshot(t *testing.T) { t.Error("redaction changed existence") } } + +func TestRedactTextCoversQuotedPathsAndCollectorErrors(t *testing.T) { + for input, want := range map[string]string{ + `"C:\Users\ann\bin"`: `"~\bin"`, + `open C:\Users\ann\config: access denied`: `open ~\config: access denied`, + `open C:\Users\anna\config: access denied`: `open C:\Users\anna\config: access denied`, + `ü C:\Users\ann\bin`: `ü ~\bin`, + `/tmp/home/ann/bin`: `/tmp/home/ann/bin`, + } { + home := `C:\Users\ann` + if input[0] == '/' { + home = "/home/ann" + } + if got := collector.RedactText(input, home); got != want { + t.Errorf("RedactText(%q)=%q want=%q", input, got, want) + } + } +} diff --git a/internal/collector/runtime.go b/internal/collector/runtime.go new file mode 100644 index 0000000..2987b7d --- /dev/null +++ b/internal/collector/runtime.go @@ -0,0 +1,149 @@ +package collector + +import ( + "context" + "github.com/shellghostt/envdoctor/internal/system" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +// RuntimeCollector inspects PATH metadata and selected manager locations. It never +// executes a discovered interpreter, shim or script. Unknown versions stay empty. +type RuntimeCollector struct { + Getenv func(string) string + GOOS string +} + +func NewRuntimeCollector() RuntimeCollector { return RuntimeCollector{os.Getenv, runtime.GOOS} } +func (RuntimeCollector) Name() string { return "runtimes" } + +var pathVersion = regexp.MustCompile(`(?:^|[/\\])v?([0-9]+\.[0-9]+(?:\.[0-9]+)?)(?:[/\\]|$)`) +var numericVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+(?:\.[0-9]+)?$`) + +func (c RuntimeCollector) Collect(ctx context.Context) (Part, error) { + getenv := c.Getenv + if getenv == nil { + getenv = os.Getenv + } + goos := c.GOOS + if goos == "" { + goos = runtime.GOOS + } + aliases := []string{"python", "python3", "node"} + suffix := "" + sep := ":" + if goos == "windows" { + suffix = ".exe" + sep = ";" + } + out := []Runtime{} + seen := map[string]int{} + active := map[string]bool{} + expected := map[string]string{} + if v := getenv("VIRTUAL_ENV"); v != "" { + sub := "bin" + if goos == "windows" { + sub = "Scripts" + } + expected["python"] = filepath.Join(v, sub, "python"+suffix) + } + if v := getenv("NVM_BIN"); v != "" { + expected["node"] = filepath.Join(v, "node"+suffix) + } else if v := getenv("NVM_SYMLINK"); v != "" { + expected["node"] = filepath.Join(v, "node"+suffix) + } + add := func(path, name string, isActive, isExpected bool) { + resolved, err := filepath.EvalSymlinks(path) + missing := err != nil + if missing { + resolved = path + } + venvRoot := filepath.Dir(filepath.Dir(path)) + var venvData []byte + if name == "python" { + venvData, _ = system.ReadSmallFile(filepath.Join(venvRoot, "pyvenv.cfg")) + } + key := name + "|" + filepath.Clean(resolved) + // A venv may symlink to the system interpreter; its invocation directory + // still changes Python's environment. Deduplicate aliases within that venv. + if len(venvData) > 0 { + key += "|" + venvRoot + } + if goos == "windows" { + key = strings.ToLower(key) + } + if i, ok := seen[key]; ok { + out[i].Active = out[i].Active || isActive + out[i].Expected = out[i].Expected || isExpected + return + } + manager := "system" + lower := strings.ToLower(filepath.ToSlash(path) + "|" + filepath.ToSlash(resolved)) + for _, m := range []string{"pyenv", "nvm", "fnm", "volta"} { + if strings.Contains(lower, "."+m+"/") || strings.Contains(lower, "/"+m+"/") { + manager = m + break + } + } + if len(venvData) > 0 { + manager = "venv" + } + version := "" + if manager != "system" && manager != "venv" { + if matches := pathVersion.FindAllStringSubmatch(resolved, -1); len(matches) > 0 { + version = matches[len(matches)-1][1] + } + } + if version == "" && !missing { + version = system.FileVersion(resolved) + } + if version == "" && len(venvData) > 0 { + for _, line := range strings.Split(string(venvData), "\n") { + k, v, ok := strings.Cut(line, "=") + if ok && strings.TrimSpace(k) == "version" && numericVersion.MatchString(strings.TrimSpace(v)) { + version = strings.TrimSpace(v) + } + } + } + seen[key] = len(out) + out = append(out, Runtime{Name: name, Version: version, Path: path, Manager: manager, Active: isActive, Expected: isExpected, Missing: missing}) + } + for _, dir := range strings.Split(getenv("PATH"), sep) { + if err := ctx.Err(); err != nil { + return Part{}, err + } + dir = strings.Trim(dir, `"`) + if !filepath.IsAbs(dir) { + continue + } + for _, alias := range aliases { + p := filepath.Join(dir, alias+suffix) + info, err := os.Lstat(p) + if err != nil || info.IsDir() { + continue + } + resolvedInfo, statErr := os.Stat(p) + usable := statErr == nil && resolvedInfo.Mode().IsRegular() + if goos != "windows" && usable && resolvedInfo.Mode()&0111 == 0 { + continue + } + name := alias + if name == "python3" { + name = "python" + } + add(p, name, !active[alias] && usable, p == expected[name]) + if usable { + active[alias] = true + } + } + } + for _, name := range []string{"python", "node"} { + if p := expected[name]; p != "" { + add(p, name, false, true) + } + } + return Part{Runtimes: out}, nil +} diff --git a/internal/collector/runtime_test.go b/internal/collector/runtime_test.go new file mode 100644 index 0000000..c217edb --- /dev/null +++ b/internal/collector/runtime_test.go @@ -0,0 +1,152 @@ +package collector_test + +import ( + "context" + "github.com/shellghostt/envdoctor/internal/collector" + "net" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestRuntimeDiscoveryDoesNotExecutePATHBinaries(t *testing.T) { + root := t.TempDir() + bin := filepath.Join(root, "nvm", "v22.11.0", "bin") + if err := os.MkdirAll(bin, 0755); err != nil { + t.Fatal(err) + } + name := "node" + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(filepath.Join(bin, name), []byte("not an executable; inspection must not run this"), 0755); err != nil { + t.Fatal(err) + } + c := collector.RuntimeCollector{Getenv: func(key string) string { + if key == "PATH" { + return bin + } + return "" + }, GOOS: runtime.GOOS} + p, err := c.Collect(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(p.Runtimes) != 1 || p.Runtimes[0].Version != "22.11.0" || p.Runtimes[0].Manager != "nvm" || !p.Runtimes[0].Active { + t.Fatalf("%+v", p.Runtimes) + } +} +func TestPortCollectorFindsOwnedListener(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + port := listener.Addr().(*net.TCPAddr).Port + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + p, err := (collector.PortCollector{Requested: []int{port}}).Collect(ctx) + if err != nil { + t.Fatal(err) + } + found := false + for _, p := range p.Ports { + if p.Port == port && p.PID == os.Getpid() && p.Process != "" { + found = true + } + } + if !found { + t.Fatalf("own listener was not attributed: %+v", p.Ports) + } +} + +func TestRuntimeDoesNotInferVersionFromUnrelatedDirectory(t *testing.T) { + bin := filepath.Join(t.TempDir(), "1.2.3", "bin") + if err := os.MkdirAll(bin, 0755); err != nil { + t.Fatal(err) + } + name := "node" + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(filepath.Join(bin, name), []byte("metadata without a version"), 0755); err != nil { + t.Fatal(err) + } + c := collector.RuntimeCollector{Getenv: func(key string) string { + if key == "PATH" { + return bin + } + return "" + }} + p, err := c.Collect(context.Background()) + if err != nil || len(p.Runtimes) != 1 || p.Runtimes[0].Version != "" { + t.Fatalf("unrelated directory became a runtime version: %+v %v", p.Runtimes, err) + } +} + +func TestBrokenSymlinkDoesNotShadowUsableRuntime(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix executable PATH resolution; Windows symlink permissions differ") + } + root := t.TempDir() + first, second := filepath.Join(root, "first"), filepath.Join(root, "nvm", "v22.11.0", "bin") + for _, dir := range []string{first, second} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join(root, "missing"), filepath.Join(first, "node")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(second, "node"), []byte("metadata"), 0755); err != nil { + t.Fatal(err) + } + c := collector.RuntimeCollector{Getenv: func(key string) string { + if key == "PATH" { + return first + string(os.PathListSeparator) + second + } + return "" + }} + p, err := c.Collect(context.Background()) + if err != nil || len(p.Runtimes) != 2 || p.Runtimes[0].Active || !p.Runtimes[1].Active { + t.Fatalf("broken symlink shadowed a usable executable: %+v %v", p.Runtimes, err) + } +} + +func TestVirtualEnvironmentIsDistinctFromItsSystemSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix Python venv symlink semantics") + } + root := t.TempDir() + systemBin, venvBin := filepath.Join(root, "system", "bin"), filepath.Join(root, "venv", "bin") + for _, dir := range []string{systemBin, venvBin} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + python := filepath.Join(systemBin, "python") + if err := os.WriteFile(python, []byte("metadata"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(python, filepath.Join(venvBin, "python")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "venv", "pyvenv.cfg"), []byte("version = 3.12.7\n"), 0600); err != nil { + t.Fatal(err) + } + c := collector.RuntimeCollector{Getenv: func(key string) string { + switch key { + case "PATH": + return systemBin + string(os.PathListSeparator) + venvBin + case "VIRTUAL_ENV": + return filepath.Join(root, "venv") + } + return "" + }} + p, err := c.Collect(context.Background()) + if err != nil || len(p.Runtimes) != 2 || p.Runtimes[0].Expected || !p.Runtimes[1].Expected || p.Runtimes[1].Active || p.Runtimes[1].Manager != "venv" { + t.Fatalf("venv merged into its system interpreter: %+v %v", p.Runtimes, err) + } +} diff --git a/internal/collector/snapshot.go b/internal/collector/snapshot.go index f2f28af..3069e56 100644 --- a/internal/collector/snapshot.go +++ b/internal/collector/snapshot.go @@ -10,16 +10,17 @@ import ( // The JSON shape is documented in docs/schema/snapshot-v1.schema.json and is // versioned by SchemaVersion. Adding a field is a schema change. type Snapshot struct { - SchemaVersion int `json:"schemaVersion"` - CollectedAt time.Time `json:"collectedAt"` - Platform *Platform `json:"platform,omitempty"` - Shell *Shell `json:"shell,omitempty"` - Path *PathInfo `json:"path,omitempty"` - Runtimes []Runtime `json:"runtimes,omitempty"` - Ports []Port `json:"ports,omitempty"` - Docker *Docker `json:"docker,omitempty"` - Git *Git `json:"git,omitempty"` - Errors []CollectorError `json:"errors,omitempty"` + SchemaVersion int `json:"schemaVersion"` + CollectedAt time.Time `json:"collectedAt"` + Platform *Platform `json:"platform,omitempty"` + Shell *Shell `json:"shell,omitempty"` + Path *PathInfo `json:"path,omitempty"` + Runtimes []Runtime `json:"runtimes,omitempty"` + Ports []Port `json:"ports,omitempty"` + RequestedPorts []int `json:"requestedPorts,omitempty"` + Docker *Docker `json:"docker,omitempty"` + Git *Git `json:"git,omitempty"` + Errors []CollectorError `json:"errors,omitempty"` } // Platform describes the operating system the process runs on. @@ -53,11 +54,13 @@ type PathEntry struct { // Runtime is one interpreter or toolchain installation. Filled from M2. type Runtime struct { - Name string `json:"name"` - Version string `json:"version"` - Path string `json:"path"` - Manager string `json:"manager"` - Active bool `json:"active"` + Name string `json:"name"` + Version string `json:"version"` + Path string `json:"path"` + Manager string `json:"manager"` + Active bool `json:"active"` + Expected bool `json:"expected,omitempty"` + Missing bool `json:"missing,omitempty"` } // Port is one listening socket. Filled from M2. @@ -69,11 +72,13 @@ type Port struct { Process string `json:"process,omitempty"` } -// Docker is the Docker CLI and daemon state. Filled from M3. +// Docker records installed CLI/local-default engine state in M2. Full context +// and compatibility inspection remain M3. type Docker struct { CLIVersion string `json:"cliVersion"` DaemonReachable bool `json:"daemonReachable"` Context string `json:"context"` + Installed bool `json:"installed"` } // Git is the Git version and identity. Filled from M2. diff --git a/internal/collector/tool_test.go b/internal/collector/tool_test.go new file mode 100644 index 0000000..60f073a --- /dev/null +++ b/internal/collector/tool_test.go @@ -0,0 +1,71 @@ +package collector_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/shellghostt/envdoctor/internal/collector" +) + +type toolExit int + +func (e toolExit) Error() string { return fmt.Sprintf("exit %d", e) } +func (e toolExit) ExitCode() int { return int(e) } + +func TestDockerCollectionDistinguishesAbsenceOutageAndBadMetadata(t *testing.T) { + for _, tc := range []struct { + name, output string + installed, reachable, fail bool + err error + }{ + {name: "absent"}, + {name: "healthy", installed: true, reachable: true, output: `{"Client":{"Version":"29.9.2"},"Server":{"Version":"29.9.2"}}`}, + {name: "unreachable", installed: true, output: `{"Client":{"Version":"29.9.2"}}`, err: toolExit(1)}, + {name: "bad metadata", installed: true, fail: true, output: `{"Client":null}`}, + } { + t.Run(tc.name, func(t *testing.T) { + c := collector.DockerCollector{FindTool: func(string) string { + if tc.installed { + return "known-docker" + } + return "" + }, RunTool: func(_ context.Context, _ string, args ...string) ([]byte, error) { + if len(args) != 5 || args[0] != "--host" || !strings.Contains(args[1], "docker") { + t.Fatalf("probe did not select the fixed local socket: %v", args) + } + return []byte(tc.output), tc.err + }} + part, err := c.Collect(context.Background()) + if tc.fail { + if err == nil { + t.Fatal("invalid metadata accepted") + } + return + } + if err != nil || part.Docker == nil || part.Docker.Installed != tc.installed || part.Docker.DaemonReachable != tc.reachable { + t.Fatalf("Docker state: %+v %v", part.Docker, err) + } + }) + } +} + +func TestGitMissingIdentityIsDifferentFromInvalidConfiguration(t *testing.T) { + for _, code := range []toolExit{1, 128} { + c := collector.GitCollector{FindTool: func(string) string { return "known-git" }, RunTool: func(_ context.Context, _ string, args ...string) ([]byte, error) { + if args[0] == "--version" { + return []byte("git version 2.53.0"), nil + } + return nil, code + }} + part, err := c.Collect(context.Background()) + if code == 1 && (err != nil || part.Git == nil || part.Git.UserName != nil || part.Git.UserEmail != nil) { + t.Fatalf("missing optional identity degraded Git: %+v %v", part.Git, err) + } + if code == 128 && !errors.Is(err, code) { + t.Fatalf("invalid Git configuration was hidden: %v", err) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6e2cde1..fb47386 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,16 +30,23 @@ var ( // Options is the result of parsing the diagnose flags. type Options struct { - JSON bool - Verbose bool - Component string - Rule string + JSON bool + Verbose bool + Component string + Rule string + Ports []int + FixtureDir string } // Validate rejects values that cannot possibly select anything. It does not // check whether the rule exists: that needs the rule registry, which the // caller owns. func (o Options) Validate() error { + for _, p := range o.Ports { + if p < 1 || p > 65535 { + return fmt.Errorf("--port must be between 1 and 65535") + } + } if o.Rule != "" && !ruleIDPattern.MatchString(o.Rule) { return fmt.Errorf("--rule %q is not a rule id; expected the form ENV001", o.Rule) } diff --git a/internal/diagnostic/fixture_test.go b/internal/diagnostic/fixture_test.go index 172459c..7b0ac8d 100644 --- a/internal/diagnostic/fixture_test.go +++ b/internal/diagnostic/fixture_test.go @@ -58,8 +58,7 @@ func TestEvaluateFixtureRejectsUnknownRule(t *testing.T) { // TestGoldenFixtures is the harness required by PLAN ENV-007. Every file in // tests/golden is a fixture; evaluating it must reproduce the expected findings -// exactly. There are no fixtures while there are no rules, and a fixture for a -// rule that does not exist fails. +// exactly. A registered rule without a fixture or a fixture for a missing rule fails. func TestGoldenFixtures(t *testing.T) { dir := filepath.Join("..", "..", "tests", "golden") entries, err := os.ReadDir(dir) @@ -67,7 +66,7 @@ func TestGoldenFixtures(t *testing.T) { t.Fatalf("read golden dir: %v", err) } - engine := diagnostic.NewEngine() + engine := diagnostic.DefaultEngine() seen := map[string]bool{} for _, entry := range entries { diff --git a/internal/diagnostic/rules.go b/internal/diagnostic/rules.go new file mode 100644 index 0000000..97221cf --- /dev/null +++ b/internal/diagnostic/rules.go @@ -0,0 +1,151 @@ +package diagnostic + +import ( + "fmt" + "github.com/shellghostt/envdoctor/internal/collector" + "sort" + "strconv" + "strings" +) + +type builtinRule struct { + id, component string + run func(collector.Snapshot) []Finding +} + +func (r builtinRule) ID() string { return r.id } +func (r builtinRule) Components() []string { return []string{r.component} } +func (r builtinRule) Evaluate(s collector.Snapshot) []Finding { + for _, e := range s.Errors { + if e.Collector == r.component || (e.Collector == "runtimes" && (r.component == "python" || r.component == "node" || r.id == "ENV004")) { + return nil + } + } + return r.run(s) +} +func DefaultEngine() *Engine { + return NewEngine( + builtinRule{"ENV001", "python", pythonConflicts}, builtinRule{"ENV002", "node", nodeConflicts}, + builtinRule{"ENV003", "path", missingPath}, builtinRule{"ENV004", "path", shadowing}, + builtinRule{"ENV005", "network", portsInUse}, builtinRule{"ENV006", "docker", dockerUnavailable}, + ) +} +func finding(id, component, title string, severity Severity, evidence []string, cause, fix string) Finding { + sort.Strings(evidence) + return Finding{RuleID: id, Component: component, Title: title, Severity: severity, Detected: evidence, Cause: cause, Fix: &Fix{Summary: fix, Automatic: false, Reversible: true}, Verify: "envdoctor diagnose --rule " + id, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/" + id + ".md"} +} +func pythonConflicts(s collector.Snapshot) []Finding { + versions := map[string]bool{} + evidence := []string{} + for _, r := range s.Runtimes { + if r.Name == "python" && r.Active && !r.Missing && r.Version != "" { + versions[r.Version] = true + evidence = append(evidence, r.Path+" ("+r.Version+")") + } + } + if len(versions) < 2 { + return nil + } + return []Finding{finding("ENV001", "python", "Conflicting active Python versions", SeverityError, evidence, "Active Python command aliases resolve to different installed versions.", "Activate the intended environment and align python and python3 on PATH.")} +} +func nodeConflicts(s collector.Snapshot) []Finding { + paths := map[string]bool{} + evidence := []string{} + missing := false + for _, r := range s.Runtimes { + if r.Name != "node" { + continue + } + if r.Missing && (r.Active || r.Expected) { + missing = true + evidence = append(evidence, r.Path+" (missing target)") + } else if !r.Missing { + paths[r.Path] = true + evidence = append(evidence, r.Path) + } + } + if len(paths) < 2 && !missing { + return nil + } + return []Finding{finding("ENV002", "node", "Conflicting Node installations or missing target", SeverityError, evidence, "Multiple Node candidates or a selected manager target are inconsistent.", "Select one Node installation and repair the selected version-manager target.")} +} +func missingPath(s collector.Snapshot) []Finding { + if s.Path == nil { + return nil + } + evidence := []string{} + for _, e := range s.Path.Entries { + if !e.Exists { + evidence = append(evidence, e.Raw) + } + } + if len(evidence) == 0 { + return nil + } + return []Finding{finding("ENV003", "path", "Missing PATH entries", SeverityWarning, evidence, "PATH references directories that no longer exist.", "Remove or correct these entries in your shell or system PATH settings.")} +} +func shadowing(s collector.Snapshot) []Finding { + evidence := []string{} + for _, expected := range s.Runtimes { + if !expected.Expected || expected.Missing || expected.Active { + continue + } + for _, active := range s.Runtimes { + if active.Name == expected.Name && active.Active && !active.Missing && active.Path != expected.Path { + evidence = append(evidence, active.Path+" shadows expected "+expected.Path) + } + } + } + if len(evidence) == 0 { + return nil + } + return []Finding{finding("ENV004", "path", "Selected runtime is shadowed on PATH", SeverityWarning, evidence, "An earlier PATH candidate takes precedence over the selected environment.", "Activate the selected environment or move its bin directory before the conflicting entry.")} +} +func portsInUse(s collector.Snapshot) []Finding { + evidence := []string{} + ports := map[int]bool{} + for _, p := range s.Ports { + if p.State != "listen" || p.Protocol != "tcp" { + continue + } + requested := false + for _, port := range s.RequestedPorts { + if port == p.Port { + requested = true + } + } + if !requested { + continue + } + owner := "process unavailable" + if p.PID > 0 { + owner = fmt.Sprintf("pid %d", p.PID) + } + if p.Process != "" { + owner = p.Process + " " + owner + } + evidence = append(evidence, fmt.Sprintf("TCP %d: %s", p.Port, strings.TrimSpace(owner))) + ports[p.Port] = true + } + if len(evidence) == 0 { + return nil + } + result := finding("ENV005", "network", "Requested TCP port is already in use", SeverityError, evidence, "A listening process already holds a port requested for this application.", "Choose another port or stop the identified process after confirming it is safe to stop.") + ordered := make([]int, 0, len(ports)) + for port := range ports { + ordered = append(ordered, port) + } + sort.Ints(ordered) + values := make([]string, 0, len(ordered)) + for _, port := range ordered { + values = append(values, strconv.Itoa(port)) + } + result.Verify += " --port " + strings.Join(values, ",") + return []Finding{result} +} +func dockerUnavailable(s collector.Snapshot) []Finding { + if s.Docker == nil || !s.Docker.Installed || s.Docker.DaemonReachable { + return nil + } + return []Finding{finding("ENV006", "docker", "Docker daemon unavailable", SeverityError, []string{"Docker CLI installed (" + s.Docker.CLIVersion + ")", "Local default Docker daemon did not answer"}, "The local default Docker engine is stopped, inaccessible, or uses another socket.", "Start the local Docker engine and check permission to its default socket.")} +} diff --git a/internal/diagnostic/rules_purity_test.go b/internal/diagnostic/rules_purity_test.go new file mode 100644 index 0000000..fcf62f6 --- /dev/null +++ b/internal/diagnostic/rules_purity_test.go @@ -0,0 +1,33 @@ +package diagnostic_test + +import ( + "go/ast" + "go/parser" + "go/token" + "strconv" + "testing" +) + +func TestBuiltinRulesOnlyImportPureDependencies(t *testing.T) { + f, err := parser.ParseFile(token.NewFileSet(), "rules.go", nil, 0) + if err != nil { + t.Fatal(err) + } + allowed := map[string]bool{"fmt": true, "sort": true, "strconv": true, "strings": true, "github.com/shellghostt/envdoctor/internal/collector": true} + for _, i := range f.Imports { + path, _ := strconv.Unquote(i.Path.Value) + if !allowed[path] { + t.Errorf("rule imports an impure or unreviewed dependency: %s", path) + } + } + // The collector package exposes I/O constructors as well as snapshot types. + // Importing it does not authorize rules to invoke those constructors. + ast.Inspect(f, func(node ast.Node) bool { + if selector, ok := node.(*ast.SelectorExpr); ok { + if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == "collector" && selector.Sel.Name != "Snapshot" { + t.Errorf("rule refers to collector I/O or an unreviewed symbol: %s", selector.Sel.Name) + } + } + return true + }) +} diff --git a/internal/report/document.go b/internal/report/document.go index 2b5aa17..37c0984 100644 --- a/internal/report/document.go +++ b/internal/report/document.go @@ -78,6 +78,25 @@ func NewDocument(version string, generatedAt time.Time, snapshot collector.Snaps } collectors = append(collectors, entry) } + // Offline snapshots have errors but no live timing records. Preserve their + // degraded status and expose the same notices as a live diagnosis. + for _, failure := range snapshot.Errors { + matched := false + for i := range collectors { + if collectors[i].Name == failure.Collector { + collectors[i].Error = failure.Message + matched = true + break + } + } + if !matched { + collectors = append(collectors, Collector{Name: failure.Collector, Error: failure.Message}) + } + } + code := exitCode(timings, copied) + if len(snapshot.Errors) > 0 { + code = 3 + } return Document{ SchemaVersion: 1, @@ -86,7 +105,7 @@ func NewDocument(version string, generatedAt time.Time, snapshot collector.Snaps Snapshot: snapshot, Findings: copied, Summary: summary, - ExitCode: exitCode(timings, copied), + ExitCode: code, Rules: ruleCount, Collectors: collectors, } diff --git a/internal/report/text.go b/internal/report/text.go index 6576fce..0e43e5c 100644 --- a/internal/report/text.go +++ b/internal/report/text.go @@ -19,6 +19,7 @@ func WriteText(w io.Writer, document Document) error { writePlatform(&b, document.Snapshot) writePath(&b, document.Snapshot.Path) + writeTools(&b, document.Snapshot) fmt.Fprintf(&b, "Rules %d registered in this build\n\n", document.Rules) writeDegraded(&b, document) writeFindings(&b, document.Findings) @@ -29,6 +30,47 @@ func WriteText(w io.Writer, document Document) error { return nil } +func writeTools(b *strings.Builder, snapshot collector.Snapshot) { + for _, runtime := range snapshot.Runtimes { + version := runtime.Version + if version == "" { + version = "version unknown" + } + status := "candidate" + if runtime.Active { + status = "active" + } + if runtime.Expected { + status += ", selected" + } + if runtime.Missing { + status += ", missing target" + } + fmt.Fprintf(b, "Runtime %s %s (%s; %s) %s\n", runtime.Name, version, runtime.Manager, status, runtime.Path) + } + if snapshot.Git != nil { + autocrlf := snapshot.Git.Autocrlf + if autocrlf == "" { + autocrlf = "unset" + } + fmt.Fprintf(b, "Git %s; core.autocrlf=%s\n", snapshot.Git.Version, autocrlf) + } + if snapshot.Docker != nil { + if !snapshot.Docker.Installed { + b.WriteString("Docker CLI not found in known installation locations\n") + } else { + state := "unreachable" + if snapshot.Docker.DaemonReachable { + state = "reachable" + } + fmt.Fprintf(b, "Docker %s; %s engine %s\n", snapshot.Docker.CLIVersion, snapshot.Docker.Context, state) + } + } + if len(snapshot.Runtimes) > 0 || snapshot.Git != nil || snapshot.Docker != nil { + b.WriteString("\n") + } +} + func writePlatform(b *strings.Builder, snapshot collector.Snapshot) { if snapshot.Platform == nil { b.WriteString("Platform unknown (collector did not run)\n") diff --git a/internal/system/inspect.go b/internal/system/inspect.go new file mode 100644 index 0000000..f465cf7 --- /dev/null +++ b/internal/system/inspect.go @@ -0,0 +1,146 @@ +package system + +import ( + "bytes" + "context" + "debug/pe" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +func ReadSmallFile(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + b, err := io.ReadAll(io.LimitReader(f, 1<<20+1)) + if len(b) > 1<<20 { + return nil, errors.New("metadata file too large") + } + return b, err +} + +// FileVersion reads a PE fixed version resource without loading or executing it. +// Unix packages without a version in their installation path remain unknown. +func FileVersion(path string) string { + f, err := pe.Open(path) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + section := f.Section(".rsrc") + if section == nil || section.Size > 4<<20 { + return "" + } + data, err := section.Data() + if err != nil { + return "" + } + signature := []byte{0xbd, 0x04, 0xef, 0xfe} + offset := bytes.Index(data, signature) + if offset < 0 || offset+16 > len(data) { + return "" + } + ms := binary.LittleEndian.Uint32(data[offset+8:]) + ls := binary.LittleEndian.Uint32(data[offset+12:]) + return fmt.Sprintf("%d.%d.%d", ms>>16, ms&65535, ls>>16) +} + +// InstalledTool uses fixed installation paths, never the caller's PATH. All +// invocation arguments are fixed by collectors, with no shell interpolation. +func InstalledTool(name string) string { + var paths []string + if runtime.GOOS == "windows" { + root := os.Getenv("SystemRoot") + programs := os.Getenv("ProgramFiles") + switch name { + case "git": + paths = []string{filepath.Join(programs, "Git", "cmd", "git.exe")} + case "docker": + local := os.Getenv("LOCALAPPDATA") + paths = []string{ + filepath.Join(programs, "Docker", "Docker", "resources", "bin", "docker.exe"), + filepath.Join(local, "Programs", "DockerDesktop", "resources", "bin", "docker.exe"), + filepath.Join(local, "Programs", "Docker", "Docker", "resources", "bin", "docker.exe"), + } + case "netstat", "tasklist": + paths = []string{filepath.Join(root, "System32", name+".exe")} + } + } else { + switch name { + case "lsof": + paths = []string{"/usr/sbin/lsof", "/usr/bin/lsof"} + case "docker": + paths = []string{"/usr/bin/docker", "/usr/local/bin/docker", "/opt/homebrew/bin/docker", "/Applications/Docker.app/Contents/Resources/bin/docker"} + default: + paths = []string{"/usr/bin/" + name, "/usr/local/bin/" + name, "/opt/homebrew/bin/" + name} + } + } + for _, p := range paths { + if filepath.IsAbs(p) { + if s, err := os.Stat(p); err == nil && s.Mode().IsRegular() { + return p + } + } + } + return "" +} + +type limitedOutput struct { + bytes.Buffer + overflow bool +} + +func (b *limitedOutput) Write(p []byte) (int, error) { + n := len(p) + if b.Len()+n > 1<<20 { + b.overflow = true + return 0, errors.New("inspection output exceeds 1 MiB") + } + _, _ = b.Buffer.Write(p) + return n, nil +} +func RunTool(ctx context.Context, path string, args ...string) ([]byte, error) { + if !filepath.IsAbs(path) { + return nil, errors.New("tool is not installed in a known location") + } + cmd := exec.CommandContext(ctx, path, args...) + cmd.WaitDelay = 100 * time.Millisecond + // PATH is not used to resolve the executable. Disallow git work-tree overrides + // and injected git config while preserving platform essentials. + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + key = strings.ToUpper(key) + if strings.HasPrefix(key, "GIT_") || strings.HasPrefix(key, "DOCKER_") { + continue + } + cmd.Env = append(cmd.Env, entry) + } + cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0", "GIT_OPTIONAL_LOCKS=0") + var stdout limitedOutput + cmd.Stdout = &stdout + cmd.Stderr = io.Discard + err := cmd.Run() + if ctx.Err() != nil { + return nil, ctx.Err() + } + if stdout.overflow { + return nil, errors.New("inspection output exceeds 1 MiB") + } + return stdout.Bytes(), err +} + +// ExitedWith recognizes process exit statuses through wrapped errors. +func ExitedWith(err error, code int) bool { + var exit interface{ ExitCode() int } + return errors.As(err, &exit) && exit.ExitCode() == code +} diff --git a/internal/system/inspect_test.go b/internal/system/inspect_test.go new file mode 100644 index 0000000..14429f6 --- /dev/null +++ b/internal/system/inspect_test.go @@ -0,0 +1,94 @@ +package system + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + "time" +) + +type processExit int + +func (e processExit) Error() string { return fmt.Sprintf("exit %d", e) } +func (e processExit) ExitCode() int { return int(e) } + +func TestLSOFOutputAndFailureDistinction(t *testing.T) { + got, err := parseLSOF([]byte("p42\ncnode\nn[::1]:3000\np57\ncpython\nn127.0.0.1:8000\n"), nil) + want := []Socket{{Port: 3000, PID: 42, Process: "node"}, {Port: 8000, PID: 57, Process: "python"}} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("parsed sockets=%+v err=%v", got, err) + } + if got, err := parseLSOF(nil, processExit(1)); err != nil || len(got) != 0 { + t.Fatalf("normal empty lsof selection: %+v %v", got, err) + } + for _, failure := range []error{os.ErrNotExist, context.DeadlineExceeded, processExit(2)} { + if _, err := parseLSOF(nil, failure); !errors.Is(err, failure) { + t.Fatalf("inspection failure became a clean result: %v", err) + } + } +} + +func TestInspectionHelper(t *testing.T) { + if os.Getenv("ENVDOCTOR_INSPECTION_HELPER") != "1" { + return + } + if os.Getenv("ENVDOCTOR_HELPER_MODE") == "wait" { + time.Sleep(10 * time.Second) + return + } + _ = json.NewEncoder(os.Stdout).Encode(map[string]string{"git": os.Getenv("GIT_DIR"), "docker": os.Getenv("DOCKER_CONTEXT")}) + os.Exit(0) +} + +func TestRunToolSanitizesContextOverrides(t *testing.T) { + t.Setenv("ENVDOCTOR_INSPECTION_HELPER", "1") + t.Setenv("GIT_DIR", "injected-repository") + t.Setenv("DOCKER_CONTEXT", "remote-collector-must-not-contact") + out, err := RunTool(context.Background(), os.Args[0], "-test.run=^TestInspectionHelper$") + if err != nil { + t.Fatal(err) + } + var values map[string]string + if err = json.Unmarshal(out, &values); err != nil || values["git"] != "" || values["docker"] != "" { + t.Fatalf("context overrides reached child process: %s %v", out, err) + } +} + +func TestRunToolHonorsDeadline(t *testing.T) { + t.Setenv("ENVDOCTOR_INSPECTION_HELPER", "1") + t.Setenv("ENVDOCTOR_HELPER_MODE", "wait") + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + start := time.Now() + if _, err := RunTool(ctx, os.Args[0], "-test.run=^TestInspectionHelper$"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("deadline not reported: %v", err) + } + if time.Since(start) > 2*time.Second { + t.Fatal("tool process exceeded bounded shutdown") + } +} + +func TestInstalledDockerIncludesPerUserWindowsLocation(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows installation locations") + } + root := t.TempDir() + t.Setenv("LOCALAPPDATA", root) + t.Setenv("ProgramFiles", filepath.Join(root, "unused-programs")) + path := filepath.Join(root, "Programs", "DockerDesktop", "resources", "bin", "docker.exe") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("inspection does not execute this file"), 0600); err != nil { + t.Fatal(err) + } + if got := InstalledTool("docker"); got != path { + t.Fatalf("per-user Docker was missed: %q", got) + } +} diff --git a/internal/system/lsof.go b/internal/system/lsof.go new file mode 100644 index 0000000..5e187ab --- /dev/null +++ b/internal/system/lsof.go @@ -0,0 +1,34 @@ +package system + +import ( + "strconv" + "strings" +) + +// lsof exits 1 when its selection matches no sockets. Launch failures and +// other process errors mean inspection is degraded, not that the port is free. +func parseLSOF(out []byte, err error) ([]Socket, error) { + if err != nil && (len(out) != 0 || !ExitedWith(err, 1)) { + return nil, err + } + sockets := []Socket{} + pid, name := 0, "" + for _, line := range strings.Split(string(out), "\n") { + if len(line) < 2 { + continue + } + switch line[0] { + case 'p': + pid, _ = strconv.Atoi(line[1:]) + case 'c': + name = line[1:] + case 'n': + if at := strings.LastIndex(line, ":"); at >= 0 { + if port, err := strconv.Atoi(line[at+1:]); err == nil && port > 0 && port <= 65535 { + sockets = append(sockets, Socket{Port: port, PID: pid, Process: name}) + } + } + } + } + return sockets, nil +} diff --git a/internal/system/path_errors.go b/internal/system/path_errors.go new file mode 100644 index 0000000..eeed347 --- /dev/null +++ b/internal/system/path_errors.go @@ -0,0 +1,9 @@ +package system + +import "os" + +// IsMissingPath distinguishes absent or malformed paths from inspection failures. +// A permission or I/O error is not evidence that a PATH directory is missing. +func IsMissingPath(err error) bool { + return os.IsNotExist(err) || isMalformedPath(err) +} diff --git a/internal/system/path_errors_unix.go b/internal/system/path_errors_unix.go new file mode 100644 index 0000000..8fce1f1 --- /dev/null +++ b/internal/system/path_errors_unix.go @@ -0,0 +1,12 @@ +//go:build linux || darwin + +package system + +import ( + "errors" + "syscall" +) + +func isMalformedPath(err error) bool { + return errors.Is(err, syscall.ENOTDIR) || errors.Is(err, syscall.ENAMETOOLONG) +} diff --git a/internal/system/path_errors_windows.go b/internal/system/path_errors_windows.go new file mode 100644 index 0000000..b8c46e4 --- /dev/null +++ b/internal/system/path_errors_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package system + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +func isMalformedPath(err error) bool { + return errors.Is(err, windows.ERROR_INVALID_NAME) || + errors.Is(err, windows.ERROR_BAD_PATHNAME) || + errors.Is(err, windows.ERROR_DIRECTORY) || + errors.Is(err, windows.ERROR_FILENAME_EXCED_RANGE) +} diff --git a/internal/system/ports.go b/internal/system/ports.go new file mode 100644 index 0000000..4bb0040 --- /dev/null +++ b/internal/system/ports.go @@ -0,0 +1,6 @@ +package system + +type Socket struct { + Port, PID int + Process string +} diff --git a/internal/system/ports_darwin.go b/internal/system/ports_darwin.go new file mode 100644 index 0000000..f2449e3 --- /dev/null +++ b/internal/system/ports_darwin.go @@ -0,0 +1,12 @@ +//go:build darwin + +package system + +import ( + "context" +) + +func ListeningPorts(ctx context.Context) ([]Socket, error) { + out, err := RunTool(ctx, InstalledTool("lsof"), "-nP", "-iTCP", "-sTCP:LISTEN", "-Fpcn") + return parseLSOF(out, err) +} diff --git a/internal/system/ports_linux.go b/internal/system/ports_linux.go new file mode 100644 index 0000000..7bb70ed --- /dev/null +++ b/internal/system/ports_linux.go @@ -0,0 +1,73 @@ +//go:build linux + +package system + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +func ListeningPorts(ctx context.Context) ([]Socket, error) { + sockets := []Socket{} + inodes := map[string][]int{} + for _, path := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { + data, err := ReadSmallFile(path) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + for _, line := range strings.Split(string(data), "\n") { + f := strings.Fields(line) + if len(f) < 10 || f[3] != "0A" { + continue + } + _, hex, ok := strings.Cut(f[1], ":") + if !ok { + continue + } + port, err := strconv.ParseInt(hex, 16, 32) + if err != nil { + continue + } + inodes[f[9]] = append(inodes[f[9]], len(sockets)) + sockets = append(sockets, Socket{Port: int(port)}) + } + } + processes, _ := os.ReadDir("/proc") + for _, p := range processes { + if err := ctx.Err(); err != nil { + return nil, err + } + pid, err := strconv.Atoi(p.Name()) + if err != nil { + continue + } + fds, _ := os.ReadDir(filepath.Join("/proc", p.Name(), "fd")) + for _, fd := range fds { + if err := ctx.Err(); err != nil { + return nil, err + } + target, err := os.Readlink(filepath.Join("/proc", p.Name(), "fd", fd.Name())) + if err != nil { + continue + } + inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]") + if positions := inodes[inode]; len(positions) > 0 { + name, _ := ReadSmallFile(fmt.Sprintf("/proc/%d/comm", pid)) + for _, i := range positions { + if sockets[i].PID == 0 || pid < sockets[i].PID { + sockets[i].PID = pid + sockets[i].Process = strings.TrimSpace(string(name)) + } + } + } + } + } + return sockets, nil +} diff --git a/internal/system/ports_windows.go b/internal/system/ports_windows.go new file mode 100644 index 0000000..2a18e9b --- /dev/null +++ b/internal/system/ports_windows.go @@ -0,0 +1,75 @@ +//go:build windows + +package system + +import ( + "context" + "encoding/binary" + "fmt" + "golang.org/x/sys/windows" + "path/filepath" + "unsafe" +) + +// GetExtendedTcpTable reports numeric TCP states independent of OS language. +// Process names use a query-only handle; access-denied owners retain their PID. +func ListeningPorts(ctx context.Context) ([]Socket, error) { + proc := windows.NewLazySystemDLL("iphlpapi.dll").NewProc("GetExtendedTcpTable") + out := []Socket{} + for _, family := range []uintptr{2, 23} { + var size uint32 + code, _, _ := proc.Call(0, uintptr(unsafe.Pointer(&size)), 0, family, 3, 0) + if code != 0 && code != 122 { + return nil, fmt.Errorf("TCP table query failed (%d)", code) + } + var data []byte + for attempt := 0; attempt < 3; attempt++ { + if size < 4 || size > 16<<20 { + return nil, fmt.Errorf("invalid TCP table size") + } + data = make([]byte, size) + code, _, _ = proc.Call(uintptr(unsafe.Pointer(&data[0])), uintptr(unsafe.Pointer(&size)), 0, family, 3, 0) + if code != 122 { + break + } + } + if code != 0 { + return nil, fmt.Errorf("TCP table read failed (%d)", code) + } + count := int(binary.LittleEndian.Uint32(data)) + rowSize := 24 + if family == 23 { + rowSize = 56 + } + if count > (len(data)-4)/rowSize { + return nil, fmt.Errorf("truncated TCP table") + } + for i := 0; i < count; i++ { + if err := ctx.Err(); err != nil { + return nil, err + } + row := data[4+i*rowSize:] + stateOffset, portOffset, pidOffset := 0, 8, 20 + if family == 23 { + stateOffset, portOffset, pidOffset = 48, 20, 52 + } + if binary.LittleEndian.Uint32(row[stateOffset:]) != 2 { + continue + } + port := int(binary.BigEndian.Uint16(row[portOffset:])) + pid := binary.LittleEndian.Uint32(row[pidOffset:]) + name := "" + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err == nil { + buf := make([]uint16, 32768) + n := uint32(len(buf)) + if windows.QueryFullProcessImageName(handle, 0, &buf[0], &n) == nil { + name = filepath.Base(windows.UTF16ToString(buf[:n])) + } + _ = windows.CloseHandle(handle) + } + out = append(out, Socket{Port: port, PID: int(pid), Process: name}) + } + } + return out, nil +} diff --git a/tests/demo/snapshot.json b/tests/demo/snapshot.json new file mode 100644 index 0000000..4206390 --- /dev/null +++ b/tests/demo/snapshot.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "collectedAt": "2026-11-14T00:00:00Z", + "platform": {"os": "linux", "version": "synthetic demo", "arch": "amd64", "elevated": false}, + "shell": {"name": "bash"}, + "path": {"entries": [ + {"raw": "/usr/bin", "exists": true, "isDir": true, "position": 0, "duplicate": false}, + {"raw": " /missing/devtools", "exists": false, "isDir": false, "position": 1, "duplicate": false} + ]}, + "runtimes": [ + {"name": "python", "version": "3.12.7", "path": "/usr/bin/python", "manager": "system", "active": true}, + {"name": "python", "version": "3.13.1", "path": "~/.pyenv/versions/3.13.1/bin/python3", "manager": "pyenv", "active": true}, + {"name": "node", "version": "20.18.0", "path": "/usr/bin/node", "manager": "system", "active": true}, + {"name": "node", "version": "22.11.0", "path": "~/.nvm/versions/node/v22.11.0/bin/node", "manager": "nvm", "active": false, "expected": true} + ], + "requestedPorts": [3000], + "ports": [{"port": 3000, "protocol": "tcp", "state": "listen", "pid": 123, "process": "demo-node"}], + "docker": {"cliVersion": "29.9.2", "daemonReachable": false, "context": "local-default", "installed": true}, + "git": {"version": "2.53.0", "userName": null, "userEmail": null, "autocrlf": "input"} +} diff --git a/tests/golden/ENV001-darwin-edge.json b/tests/golden/ENV001-darwin-edge.json new file mode 100644 index 0000000..f7234fb --- /dev/null +++ b/tests/golden/ENV001-darwin-edge.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV001", + "description": "darwin edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "python", + "path": "/tools/a", + "version": "3.11.1", + "active": true, + "manager": "system" + }, + { + "name": "python", + "path": "/tools/b", + "version": "", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV001-darwin-negative.json b/tests/golden/ENV001-darwin-negative.json new file mode 100644 index 0000000..5a24b7a --- /dev/null +++ b/tests/golden/ENV001-darwin-negative.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV001", + "description": "darwin negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "python", + "path": "/tools/a", + "version": "3.11.1", + "active": true, + "manager": "system" + }, + { + "name": "python", + "path": "/tools/b", + "version": "3.11.1", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV001-darwin-positive.json b/tests/golden/ENV001-darwin-positive.json new file mode 100644 index 0000000..354afa8 --- /dev/null +++ b/tests/golden/ENV001-darwin-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV001", + "description": "darwin positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "python", + "path": "/tools/a", + "version": "3.11.1", + "active": true, + "manager": "system" + }, + { + "name": "python", + "path": "/tools/b", + "version": "3.12.1", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV001", + "severity": "error", + "component": "python", + "title": "Conflicting active Python versions", + "detected": [ + "/tools/a (3.11.1)", + "/tools/b (3.12.1)" + ], + "cause": "Active Python command aliases resolve to different installed versions.", + "fix": { + "summary": "Activate the intended environment and align python and python3 on PATH.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV001", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV001.md" + } + ] +} diff --git a/tests/golden/ENV001-linux-edge.json b/tests/golden/ENV001-linux-edge.json new file mode 100644 index 0000000..df6b31c --- /dev/null +++ b/tests/golden/ENV001-linux-edge.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV001", + "description": "linux edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "python", + "path": "/tools/a", + "version": "3.11.1", + "active": true, + "manager": "system" + }, + { + "name": "python", + "path": "/tools/b", + "version": "", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV001-linux-negative.json b/tests/golden/ENV001-linux-negative.json new file mode 100644 index 0000000..0c69fb0 --- /dev/null +++ b/tests/golden/ENV001-linux-negative.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV001", + "description": "linux negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "python", + "path": "/tools/a", + "version": "3.11.1", + "active": true, + "manager": "system" + }, + { + "name": "python", + "path": "/tools/b", + "version": "3.11.1", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV001-linux-positive.json b/tests/golden/ENV001-linux-positive.json new file mode 100644 index 0000000..573e96f --- /dev/null +++ b/tests/golden/ENV001-linux-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV001", + "description": "linux positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "python", + "path": "/tools/a", + "version": "3.11.1", + "active": true, + "manager": "system" + }, + { + "name": "python", + "path": "/tools/b", + "version": "3.12.1", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV001", + "severity": "error", + "component": "python", + "title": "Conflicting active Python versions", + "detected": [ + "/tools/a (3.11.1)", + "/tools/b (3.12.1)" + ], + "cause": "Active Python command aliases resolve to different installed versions.", + "fix": { + "summary": "Activate the intended environment and align python and python3 on PATH.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV001", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV001.md" + } + ] +} diff --git a/tests/golden/ENV001-windows-edge.json b/tests/golden/ENV001-windows-edge.json new file mode 100644 index 0000000..b97a475 --- /dev/null +++ b/tests/golden/ENV001-windows-edge.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV001", + "description": "windows edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "python", + "path": "C:\\tools\\a", + "version": "3.11.1", + "active": true, + "manager": "system" + }, + { + "name": "python", + "path": "C:\\tools\\b", + "version": "", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV001-windows-negative.json b/tests/golden/ENV001-windows-negative.json new file mode 100644 index 0000000..9d41a16 --- /dev/null +++ b/tests/golden/ENV001-windows-negative.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV001", + "description": "windows negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "python", + "path": "C:\\tools\\a", + "version": "3.11.1", + "active": true, + "manager": "system" + }, + { + "name": "python", + "path": "C:\\tools\\b", + "version": "3.11.1", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV001-windows-positive.json b/tests/golden/ENV001-windows-positive.json new file mode 100644 index 0000000..675350a --- /dev/null +++ b/tests/golden/ENV001-windows-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV001", + "description": "windows positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "python", + "path": "C:\\tools\\a", + "version": "3.11.1", + "active": true, + "manager": "system" + }, + { + "name": "python", + "path": "C:\\tools\\b", + "version": "3.12.1", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV001", + "severity": "error", + "component": "python", + "title": "Conflicting active Python versions", + "detected": [ + "C:\\tools\\a (3.11.1)", + "C:\\tools\\b (3.12.1)" + ], + "cause": "Active Python command aliases resolve to different installed versions.", + "fix": { + "summary": "Activate the intended environment and align python and python3 on PATH.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV001", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV001.md" + } + ] +} diff --git a/tests/golden/ENV002-darwin-edge.json b/tests/golden/ENV002-darwin-edge.json new file mode 100644 index 0000000..94319a0 --- /dev/null +++ b/tests/golden/ENV002-darwin-edge.json @@ -0,0 +1,37 @@ +{ + "ruleId": "ENV002", + "description": "darwin edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system" + } + ], + "errors": [ + { + "collector": "runtimes", + "message": "timeout" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV002-darwin-negative.json b/tests/golden/ENV002-darwin-negative.json new file mode 100644 index 0000000..ff64dbe --- /dev/null +++ b/tests/golden/ENV002-darwin-negative.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV002", + "description": "darwin negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV002-darwin-positive.json b/tests/golden/ENV002-darwin-positive.json new file mode 100644 index 0000000..b7fa15c --- /dev/null +++ b/tests/golden/ENV002-darwin-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV002", + "description": "darwin positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV002", + "severity": "error", + "component": "node", + "title": "Conflicting Node installations or missing target", + "detected": [ + "/tools/a", + "/tools/b" + ], + "cause": "Multiple Node candidates or a selected manager target are inconsistent.", + "fix": { + "summary": "Select one Node installation and repair the selected version-manager target.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV002", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV002.md" + } + ] +} diff --git a/tests/golden/ENV002-linux-edge.json b/tests/golden/ENV002-linux-edge.json new file mode 100644 index 0000000..5c7c3d8 --- /dev/null +++ b/tests/golden/ENV002-linux-edge.json @@ -0,0 +1,37 @@ +{ + "ruleId": "ENV002", + "description": "linux edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system" + } + ], + "errors": [ + { + "collector": "runtimes", + "message": "timeout" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV002-linux-negative.json b/tests/golden/ENV002-linux-negative.json new file mode 100644 index 0000000..c143eb5 --- /dev/null +++ b/tests/golden/ENV002-linux-negative.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV002", + "description": "linux negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV002-linux-positive.json b/tests/golden/ENV002-linux-positive.json new file mode 100644 index 0000000..35778c4 --- /dev/null +++ b/tests/golden/ENV002-linux-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV002", + "description": "linux positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV002", + "severity": "error", + "component": "node", + "title": "Conflicting Node installations or missing target", + "detected": [ + "/tools/a", + "/tools/b" + ], + "cause": "Multiple Node candidates or a selected manager target are inconsistent.", + "fix": { + "summary": "Select one Node installation and repair the selected version-manager target.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV002", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV002.md" + } + ] +} diff --git a/tests/golden/ENV002-windows-edge.json b/tests/golden/ENV002-windows-edge.json new file mode 100644 index 0000000..23fc835 --- /dev/null +++ b/tests/golden/ENV002-windows-edge.json @@ -0,0 +1,37 @@ +{ + "ruleId": "ENV002", + "description": "windows edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "C:\\tools\\a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "C:\\tools\\b", + "version": "22.1.0", + "active": false, + "manager": "system" + } + ], + "errors": [ + { + "collector": "runtimes", + "message": "timeout" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV002-windows-negative.json b/tests/golden/ENV002-windows-negative.json new file mode 100644 index 0000000..ebaa37f --- /dev/null +++ b/tests/golden/ENV002-windows-negative.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV002", + "description": "windows negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "C:\\tools\\a", + "version": "20.1.0", + "active": true, + "manager": "system" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV002-windows-positive.json b/tests/golden/ENV002-windows-positive.json new file mode 100644 index 0000000..3304fab --- /dev/null +++ b/tests/golden/ENV002-windows-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV002", + "description": "windows positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "C:\\tools\\a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "C:\\tools\\b", + "version": "22.1.0", + "active": false, + "manager": "system" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV002", + "severity": "error", + "component": "node", + "title": "Conflicting Node installations or missing target", + "detected": [ + "C:\\tools\\a", + "C:\\tools\\b" + ], + "cause": "Multiple Node candidates or a selected manager target are inconsistent.", + "fix": { + "summary": "Select one Node installation and repair the selected version-manager target.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV002", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV002.md" + } + ] +} diff --git a/tests/golden/ENV003-darwin-edge.json b/tests/golden/ENV003-darwin-edge.json new file mode 100644 index 0000000..66e9741 --- /dev/null +++ b/tests/golden/ENV003-darwin-edge.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV003", + "description": "darwin edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "path": { + "entries": [ + { + "raw": "\"/tools/a\"", + "exists": false, + "isDir": false, + "position": 0, + "duplicate": false + } + ] + }, + "errors": [ + { + "collector": "path", + "message": "timeout" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV003-darwin-negative.json b/tests/golden/ENV003-darwin-negative.json new file mode 100644 index 0000000..2a587d2 --- /dev/null +++ b/tests/golden/ENV003-darwin-negative.json @@ -0,0 +1,26 @@ +{ + "ruleId": "ENV003", + "description": "darwin negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "path": { + "entries": [ + { + "raw": "\"/tools/a\"", + "exists": true, + "isDir": true, + "position": 0, + "duplicate": false + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV003-darwin-positive.json b/tests/golden/ENV003-darwin-positive.json new file mode 100644 index 0000000..01f1885 --- /dev/null +++ b/tests/golden/ENV003-darwin-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV003", + "description": "darwin positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "path": { + "entries": [ + { + "raw": "\"/tools/a\"", + "exists": false, + "isDir": false, + "position": 0, + "duplicate": false + } + ] + } + }, + "expectedFindings": [ + { + "ruleId": "ENV003", + "severity": "warning", + "component": "path", + "title": "Missing PATH entries", + "detected": [ + "\"/tools/a\"" + ], + "cause": "PATH references directories that no longer exist.", + "fix": { + "summary": "Remove or correct these entries in your shell or system PATH settings.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV003", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV003.md" + } + ] +} diff --git a/tests/golden/ENV003-linux-edge.json b/tests/golden/ENV003-linux-edge.json new file mode 100644 index 0000000..8bacc8b --- /dev/null +++ b/tests/golden/ENV003-linux-edge.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV003", + "description": "linux edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "path": { + "entries": [ + { + "raw": "\"/tools/a\"", + "exists": false, + "isDir": false, + "position": 0, + "duplicate": false + } + ] + }, + "errors": [ + { + "collector": "path", + "message": "timeout" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV003-linux-negative.json b/tests/golden/ENV003-linux-negative.json new file mode 100644 index 0000000..c843814 --- /dev/null +++ b/tests/golden/ENV003-linux-negative.json @@ -0,0 +1,26 @@ +{ + "ruleId": "ENV003", + "description": "linux negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "path": { + "entries": [ + { + "raw": "\"/tools/a\"", + "exists": true, + "isDir": true, + "position": 0, + "duplicate": false + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV003-linux-positive.json b/tests/golden/ENV003-linux-positive.json new file mode 100644 index 0000000..8e224b3 --- /dev/null +++ b/tests/golden/ENV003-linux-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV003", + "description": "linux positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "path": { + "entries": [ + { + "raw": "\"/tools/a\"", + "exists": false, + "isDir": false, + "position": 0, + "duplicate": false + } + ] + } + }, + "expectedFindings": [ + { + "ruleId": "ENV003", + "severity": "warning", + "component": "path", + "title": "Missing PATH entries", + "detected": [ + "\"/tools/a\"" + ], + "cause": "PATH references directories that no longer exist.", + "fix": { + "summary": "Remove or correct these entries in your shell or system PATH settings.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV003", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV003.md" + } + ] +} diff --git a/tests/golden/ENV003-windows-edge.json b/tests/golden/ENV003-windows-edge.json new file mode 100644 index 0000000..448869e --- /dev/null +++ b/tests/golden/ENV003-windows-edge.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV003", + "description": "windows edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "path": { + "entries": [ + { + "raw": "\"C:\\tools\\a\"", + "exists": false, + "isDir": false, + "position": 0, + "duplicate": false + } + ] + }, + "errors": [ + { + "collector": "path", + "message": "timeout" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV003-windows-negative.json b/tests/golden/ENV003-windows-negative.json new file mode 100644 index 0000000..c753bd2 --- /dev/null +++ b/tests/golden/ENV003-windows-negative.json @@ -0,0 +1,26 @@ +{ + "ruleId": "ENV003", + "description": "windows negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "path": { + "entries": [ + { + "raw": "\"C:\\tools\\a\"", + "exists": true, + "isDir": true, + "position": 0, + "duplicate": false + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV003-windows-positive.json b/tests/golden/ENV003-windows-positive.json new file mode 100644 index 0000000..bf295c6 --- /dev/null +++ b/tests/golden/ENV003-windows-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV003", + "description": "windows positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "path": { + "entries": [ + { + "raw": "\"C:\\tools\\a\"", + "exists": false, + "isDir": false, + "position": 0, + "duplicate": false + } + ] + } + }, + "expectedFindings": [ + { + "ruleId": "ENV003", + "severity": "warning", + "component": "path", + "title": "Missing PATH entries", + "detected": [ + "\"C:\\tools\\a\"" + ], + "cause": "PATH references directories that no longer exist.", + "fix": { + "summary": "Remove or correct these entries in your shell or system PATH settings.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV003", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV003.md" + } + ] +} diff --git a/tests/golden/ENV004-darwin-edge.json b/tests/golden/ENV004-darwin-edge.json new file mode 100644 index 0000000..7a57d91 --- /dev/null +++ b/tests/golden/ENV004-darwin-edge.json @@ -0,0 +1,33 @@ +{ + "ruleId": "ENV004", + "description": "darwin edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system", + "expected": true, + "missing": true + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV004-darwin-negative.json b/tests/golden/ENV004-darwin-negative.json new file mode 100644 index 0000000..6f48daf --- /dev/null +++ b/tests/golden/ENV004-darwin-negative.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV004", + "description": "darwin negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system", + "expected": false + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV004-darwin-positive.json b/tests/golden/ENV004-darwin-positive.json new file mode 100644 index 0000000..cded9ae --- /dev/null +++ b/tests/golden/ENV004-darwin-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV004", + "description": "darwin positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system", + "expected": true + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV004", + "severity": "warning", + "component": "path", + "title": "Selected runtime is shadowed on PATH", + "detected": [ + "/tools/a shadows expected /tools/b" + ], + "cause": "An earlier PATH candidate takes precedence over the selected environment.", + "fix": { + "summary": "Activate the selected environment or move its bin directory before the conflicting entry.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV004", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV004.md" + } + ] +} diff --git a/tests/golden/ENV004-linux-edge.json b/tests/golden/ENV004-linux-edge.json new file mode 100644 index 0000000..ba7e7d9 --- /dev/null +++ b/tests/golden/ENV004-linux-edge.json @@ -0,0 +1,33 @@ +{ + "ruleId": "ENV004", + "description": "linux edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system", + "expected": true, + "missing": true + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV004-linux-negative.json b/tests/golden/ENV004-linux-negative.json new file mode 100644 index 0000000..19ae74f --- /dev/null +++ b/tests/golden/ENV004-linux-negative.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV004", + "description": "linux negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system", + "expected": false + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV004-linux-positive.json b/tests/golden/ENV004-linux-positive.json new file mode 100644 index 0000000..c6db02e --- /dev/null +++ b/tests/golden/ENV004-linux-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV004", + "description": "linux positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "/tools/a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "/tools/b", + "version": "22.1.0", + "active": false, + "manager": "system", + "expected": true + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV004", + "severity": "warning", + "component": "path", + "title": "Selected runtime is shadowed on PATH", + "detected": [ + "/tools/a shadows expected /tools/b" + ], + "cause": "An earlier PATH candidate takes precedence over the selected environment.", + "fix": { + "summary": "Activate the selected environment or move its bin directory before the conflicting entry.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV004", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV004.md" + } + ] +} diff --git a/tests/golden/ENV004-windows-edge.json b/tests/golden/ENV004-windows-edge.json new file mode 100644 index 0000000..2385019 --- /dev/null +++ b/tests/golden/ENV004-windows-edge.json @@ -0,0 +1,33 @@ +{ + "ruleId": "ENV004", + "description": "windows edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "C:\\tools\\a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "C:\\tools\\b", + "version": "22.1.0", + "active": false, + "manager": "system", + "expected": true, + "missing": true + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV004-windows-negative.json b/tests/golden/ENV004-windows-negative.json new file mode 100644 index 0000000..ab0f0ea --- /dev/null +++ b/tests/golden/ENV004-windows-negative.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV004", + "description": "windows negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "C:\\tools\\a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "C:\\tools\\b", + "version": "22.1.0", + "active": false, + "manager": "system", + "expected": false + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV004-windows-positive.json b/tests/golden/ENV004-windows-positive.json new file mode 100644 index 0000000..0e327c5 --- /dev/null +++ b/tests/golden/ENV004-windows-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV004", + "description": "windows positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "runtimes": [ + { + "name": "node", + "path": "C:\\tools\\a", + "version": "20.1.0", + "active": true, + "manager": "system" + }, + { + "name": "node", + "path": "C:\\tools\\b", + "version": "22.1.0", + "active": false, + "manager": "system", + "expected": true + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV004", + "severity": "warning", + "component": "path", + "title": "Selected runtime is shadowed on PATH", + "detected": [ + "C:\\tools\\a shadows expected C:\\tools\\b" + ], + "cause": "An earlier PATH candidate takes precedence over the selected environment.", + "fix": { + "summary": "Activate the selected environment or move its bin directory before the conflicting entry.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV004", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV004.md" + } + ] +} diff --git a/tests/golden/ENV005-darwin-edge.json b/tests/golden/ENV005-darwin-edge.json new file mode 100644 index 0000000..7168f9b --- /dev/null +++ b/tests/golden/ENV005-darwin-edge.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV005", + "description": "darwin edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "requestedPorts": [ + 3000 + ], + "ports": [ + { + "port": 3000, + "protocol": "tcp", + "state": "established", + "pid": 123, + "process": "node" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV005-darwin-negative.json b/tests/golden/ENV005-darwin-negative.json new file mode 100644 index 0000000..42dd9bb --- /dev/null +++ b/tests/golden/ENV005-darwin-negative.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV005", + "description": "darwin negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "requestedPorts": [ + 8000 + ], + "ports": [ + { + "port": 3000, + "protocol": "tcp", + "state": "listen", + "pid": 123, + "process": "node" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV005-darwin-positive.json b/tests/golden/ENV005-darwin-positive.json new file mode 100644 index 0000000..bc8f9d3 --- /dev/null +++ b/tests/golden/ENV005-darwin-positive.json @@ -0,0 +1,46 @@ +{ + "ruleId": "ENV005", + "description": "darwin positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "requestedPorts": [ + 3000 + ], + "ports": [ + { + "port": 3000, + "protocol": "tcp", + "state": "listen", + "pid": 123, + "process": "node" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV005", + "severity": "error", + "component": "network", + "title": "Requested TCP port is already in use", + "detected": [ + "TCP 3000: node pid 123" + ], + "cause": "A listening process already holds a port requested for this application.", + "fix": { + "summary": "Choose another port or stop the identified process after confirming it is safe to stop.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV005 --port 3000", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV005.md" + } + ] +} diff --git a/tests/golden/ENV005-linux-edge.json b/tests/golden/ENV005-linux-edge.json new file mode 100644 index 0000000..76373c1 --- /dev/null +++ b/tests/golden/ENV005-linux-edge.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV005", + "description": "linux edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "requestedPorts": [ + 3000 + ], + "ports": [ + { + "port": 3000, + "protocol": "tcp", + "state": "established", + "pid": 123, + "process": "node" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV005-linux-negative.json b/tests/golden/ENV005-linux-negative.json new file mode 100644 index 0000000..592f05e --- /dev/null +++ b/tests/golden/ENV005-linux-negative.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV005", + "description": "linux negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "requestedPorts": [ + 8000 + ], + "ports": [ + { + "port": 3000, + "protocol": "tcp", + "state": "listen", + "pid": 123, + "process": "node" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV005-linux-positive.json b/tests/golden/ENV005-linux-positive.json new file mode 100644 index 0000000..0773dcb --- /dev/null +++ b/tests/golden/ENV005-linux-positive.json @@ -0,0 +1,46 @@ +{ + "ruleId": "ENV005", + "description": "linux positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "requestedPorts": [ + 3000 + ], + "ports": [ + { + "port": 3000, + "protocol": "tcp", + "state": "listen", + "pid": 123, + "process": "node" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV005", + "severity": "error", + "component": "network", + "title": "Requested TCP port is already in use", + "detected": [ + "TCP 3000: node pid 123" + ], + "cause": "A listening process already holds a port requested for this application.", + "fix": { + "summary": "Choose another port or stop the identified process after confirming it is safe to stop.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV005 --port 3000", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV005.md" + } + ] +} diff --git a/tests/golden/ENV005-windows-edge.json b/tests/golden/ENV005-windows-edge.json new file mode 100644 index 0000000..b0f6d82 --- /dev/null +++ b/tests/golden/ENV005-windows-edge.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV005", + "description": "windows edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "requestedPorts": [ + 3000 + ], + "ports": [ + { + "port": 3000, + "protocol": "tcp", + "state": "established", + "pid": 123, + "process": "node" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV005-windows-negative.json b/tests/golden/ENV005-windows-negative.json new file mode 100644 index 0000000..a522a24 --- /dev/null +++ b/tests/golden/ENV005-windows-negative.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV005", + "description": "windows negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "requestedPorts": [ + 8000 + ], + "ports": [ + { + "port": 3000, + "protocol": "tcp", + "state": "listen", + "pid": 123, + "process": "node" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV005-windows-positive.json b/tests/golden/ENV005-windows-positive.json new file mode 100644 index 0000000..5462078 --- /dev/null +++ b/tests/golden/ENV005-windows-positive.json @@ -0,0 +1,46 @@ +{ + "ruleId": "ENV005", + "description": "windows positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "requestedPorts": [ + 3000 + ], + "ports": [ + { + "port": 3000, + "protocol": "tcp", + "state": "listen", + "pid": 123, + "process": "node" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV005", + "severity": "error", + "component": "network", + "title": "Requested TCP port is already in use", + "detected": [ + "TCP 3000: node pid 123" + ], + "cause": "A listening process already holds a port requested for this application.", + "fix": { + "summary": "Choose another port or stop the identified process after confirming it is safe to stop.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV005 --port 3000", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV005.md" + } + ] +} diff --git a/tests/golden/ENV006-darwin-edge.json b/tests/golden/ENV006-darwin-edge.json new file mode 100644 index 0000000..28a6e79 --- /dev/null +++ b/tests/golden/ENV006-darwin-edge.json @@ -0,0 +1,21 @@ +{ + "ruleId": "ENV006", + "description": "darwin edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": false, + "cliVersion": "29.0.0", + "daemonReachable": false, + "context": "local-default" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-darwin-negative.json b/tests/golden/ENV006-darwin-negative.json new file mode 100644 index 0000000..2709b03 --- /dev/null +++ b/tests/golden/ENV006-darwin-negative.json @@ -0,0 +1,21 @@ +{ + "ruleId": "ENV006", + "description": "darwin negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "29.0.0", + "daemonReachable": true, + "context": "local-default" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-darwin-positive.json b/tests/golden/ENV006-darwin-positive.json new file mode 100644 index 0000000..22dd4a6 --- /dev/null +++ b/tests/golden/ENV006-darwin-positive.json @@ -0,0 +1,41 @@ +{ + "ruleId": "ENV006", + "description": "darwin positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "29.0.0", + "daemonReachable": false, + "context": "local-default" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV006", + "severity": "error", + "component": "docker", + "title": "Docker daemon unavailable", + "detected": [ + "Docker CLI installed (29.0.0)", + "Local default Docker daemon did not answer" + ], + "cause": "The local default Docker engine is stopped, inaccessible, or uses another socket.", + "fix": { + "summary": "Start the local Docker engine and check permission to its default socket.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV006", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV006.md" + } + ] +} diff --git a/tests/golden/ENV006-linux-edge.json b/tests/golden/ENV006-linux-edge.json new file mode 100644 index 0000000..1249e9f --- /dev/null +++ b/tests/golden/ENV006-linux-edge.json @@ -0,0 +1,21 @@ +{ + "ruleId": "ENV006", + "description": "linux edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": false, + "cliVersion": "29.0.0", + "daemonReachable": false, + "context": "local-default" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-linux-negative.json b/tests/golden/ENV006-linux-negative.json new file mode 100644 index 0000000..822f8af --- /dev/null +++ b/tests/golden/ENV006-linux-negative.json @@ -0,0 +1,21 @@ +{ + "ruleId": "ENV006", + "description": "linux negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "29.0.0", + "daemonReachable": true, + "context": "local-default" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-linux-positive.json b/tests/golden/ENV006-linux-positive.json new file mode 100644 index 0000000..9946b84 --- /dev/null +++ b/tests/golden/ENV006-linux-positive.json @@ -0,0 +1,41 @@ +{ + "ruleId": "ENV006", + "description": "linux positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "29.0.0", + "daemonReachable": false, + "context": "local-default" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV006", + "severity": "error", + "component": "docker", + "title": "Docker daemon unavailable", + "detected": [ + "Docker CLI installed (29.0.0)", + "Local default Docker daemon did not answer" + ], + "cause": "The local default Docker engine is stopped, inaccessible, or uses another socket.", + "fix": { + "summary": "Start the local Docker engine and check permission to its default socket.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV006", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV006.md" + } + ] +} diff --git a/tests/golden/ENV006-windows-edge.json b/tests/golden/ENV006-windows-edge.json new file mode 100644 index 0000000..89a1bc3 --- /dev/null +++ b/tests/golden/ENV006-windows-edge.json @@ -0,0 +1,21 @@ +{ + "ruleId": "ENV006", + "description": "windows edge", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": false, + "cliVersion": "29.0.0", + "daemonReachable": false, + "context": "local-default" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-windows-negative.json b/tests/golden/ENV006-windows-negative.json new file mode 100644 index 0000000..62ac1fc --- /dev/null +++ b/tests/golden/ENV006-windows-negative.json @@ -0,0 +1,21 @@ +{ + "ruleId": "ENV006", + "description": "windows negative", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "29.0.0", + "daemonReachable": true, + "context": "local-default" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-windows-positive.json b/tests/golden/ENV006-windows-positive.json new file mode 100644 index 0000000..8351521 --- /dev/null +++ b/tests/golden/ENV006-windows-positive.json @@ -0,0 +1,41 @@ +{ + "ruleId": "ENV006", + "description": "windows positive", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "29.0.0", + "daemonReachable": false, + "context": "local-default" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV006", + "severity": "error", + "component": "docker", + "title": "Docker daemon unavailable", + "detected": [ + "Docker CLI installed (29.0.0)", + "Local default Docker daemon did not answer" + ], + "cause": "The local default Docker engine is stopped, inaccessible, or uses another socket.", + "fix": { + "summary": "Start the local Docker engine and check permission to its default socket.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV006", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV006.md" + } + ] +} diff --git a/tests/integration/cli_test.go b/tests/integration/cli_test.go new file mode 100644 index 0000000..4c95256 --- /dev/null +++ b/tests/integration/cli_test.go @@ -0,0 +1,115 @@ +package integration + +import ( + "bytes" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/shellghostt/envdoctor/internal/diagnostic" + "github.com/shellghostt/envdoctor/internal/report" +) + +func TestBuiltCLIWithAllGoldenFixtures(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "envdoctor") + if runtime.GOOS == "windows" { + binary += ".exe" + } + build := exec.Command("go", "build", "-o", binary, "./cmd/envdoctor") + build.Dir = root + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build CLI: %v\n%s", err, out) + } + compiler := jsonschema.NewCompiler() + for _, name := range []string{"snapshot", "diagnose"} { + path := filepath.Join(root, "docs", "schema", name+"-v1.schema.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err = compiler.AddResource("https://github.com/shellghostt/envdoctor/schema/"+name+"-v1.schema.json", bytes.NewReader(data)); err != nil { + t.Fatal(err) + } + } + schema, err := compiler.Compile("https://github.com/shellghostt/envdoctor/schema/diagnose-v1.schema.json") + if err != nil { + t.Fatal(err) + } + fixtures, err := filepath.Glob(filepath.Join(root, "tests", "golden", "*.json")) + if err != nil || len(fixtures) != 54 { + t.Fatalf("expected 54 golden cases: %d %v", len(fixtures), err) + } + for _, path := range fixtures { + t.Run(filepath.Base(path), func(t *testing.T) { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + fixture, err := diagnostic.LoadFixture(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + snapshot, err := json.Marshal(fixture.Snapshot) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(dir, "snapshot.json"), snapshot, 0600); err != nil { + t.Fatal(err) + } + cmd := exec.Command(binary, "diagnose", "--fixture-dir", dir, "--rule", fixture.RuleID, "--json") + var out, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &stderr + err = cmd.Run() + code := 0 + if err != nil { + if exit, ok := err.(*exec.ExitError); ok { + code = exit.ExitCode() + } else { + t.Fatal(err) + } + } + wantCode := diagnostic.ExitCode(fixture.ExpectedFindings) + if len(fixture.Snapshot.Errors) > 0 { + wantCode = 3 + } + if code != wantCode || stderr.Len() != 0 { + t.Fatalf("exit=%d want=%d stderr=%s", code, wantCode, stderr.String()) + } + var document report.Document + decoder := json.NewDecoder(bytes.NewReader(out.Bytes())) + if err = decoder.Decode(&document); err != nil { + t.Fatal(err) + } + if err = decoder.Decode(new(any)); err != io.EOF { + t.Fatal("CLI wrote more than one JSON document") + } + if len(document.Findings) == 0 { + document.Findings = nil + } + if len(fixture.ExpectedFindings) == 0 { + fixture.ExpectedFindings = nil + } + if document.ExitCode != code || document.Rules != 6 || !reflect.DeepEqual(document.Findings, fixture.ExpectedFindings) { + t.Fatalf("unexpected CLI report: %s", out.String()) + } + var decoded any + if err = json.Unmarshal(out.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if err = schema.Validate(decoded); err != nil { + t.Fatalf("report schema: %v", err) + } + }) + } +}