diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14dd883..d7bbcc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: - name: Vet run: go vet ./... + - name: Check generated rule documentation + run: go run ./cmd/rulegen --check + - name: Test run: go test -race -count=1 -timeout=120s ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index e137abf..7e5b83c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Added +- M3 rules ENV007–ENV012: known Docker API incompatibility, explicitly required dependency absence, + package-manager/lockfile disagreements, effective Git identity/explicit autocrlf policy, + canonical virtualenv/interpreter mismatch and opt-in proxy refusal/no-route evidence. +- Local Docker context/API metadata, strict version-1 `envdoctor.requirements.json`, supported + tool/OpenSSL metadata checks and explicit `--package-manager` runner evidence. +- Redacted proxy configuration with bounded `--probe-network` DNS/TCP checks, offline by default. +- Offline `explain`, `rules --json`, a shared rule registry, generated pages for all twelve rules, + `make docs`/`make check-docs` and CI documentation drift enforcement. +- 118 additional golden cases, preserving the original 54 for 172 total; collector, schema, purity, + CLI, cancellation and socket lifecycle regressions. +- Accepted [ADR 0005](docs/adr/0005-m3-evidence-and-rule-registry.md), + [M3 acceptance requirements](docs/phases/M3.md), [validation evidence](docs/testing/m3-validation.md) + and [development-tool audit](docs/DEVELOPMENT-TOOLS.md). M3 is complete on the feature branch; + [CI 43723839978](https://github.com/Shellghostt/EnvDoctor/actions/runs/43723839978) passed native + Windows/Ubuntu/macOS race, tests and builds, plus lint and the vulnerability scan. + - 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. @@ -19,6 +35,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Changed +- Optional M3 snapshot evidence extends schema v1 compatibly; project and rule-registry JSON have + separate version-1 schemas. Explicit typed partial collector evidence retains degraded notices + and disables dependent rules. +- `make build` disables CGO only for static builds and uses `.exe` on Windows; race tests retain CGO. +- Home/credential-URL redaction covers new evidence, paths within URLs, and collector/CLI errors. - `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. @@ -33,6 +54,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Fixed +- Bounded subprocess output no longer exposes `bytes.Buffer.ReadFrom`, which could bypass the + writer's limit for stdout/stderr. +- Protected WindowsApps/MSIX paths and application aliases remain unresolved metadata; new + unknown-resolution snapshots do not cause speculative M2 runtime conflicts. +- Git autocrlf boolean aliases use portable typed config queries; `input` is handled directly. - 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 @@ -46,7 +72,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Planned -- M3: rules ENV007–ENV012, full Docker/dependency inspection and the `explain` command. +- M4 reports and bundles; later rules ENV013–ENV018, confirmed fixes and signed releases. ## [0.1.0] - 2026-11-13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6592cba..d139b3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,10 @@ Thanks for taking the time. This document is deliberately short and specific. 2. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) - how the pieces fit together. 3. [docs/adr/](docs/adr/) - decisions that have already been argued about. Do not relitigate them without a new ADR. +4. [docs/phases/M3.md](docs/phases/M3.md) and [ADR 0005](docs/adr/0005-m3-evidence-and-rule-registry.md) - + implemented M3 evidence contracts and verified three-OS CI gate. +5. [docs/DEVELOPMENT-TOOLS.md](docs/DEVELOPMENT-TOOLS.md) - CodeGraph freshness, static-analysis coverage + and profiling appropriate to this repository's runtime. ## Ground rules @@ -27,10 +31,31 @@ 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. +Pin `GOTOOLCHAIN=go1.26.11` for the recorded gates. `go test -count=1 -timeout=120s ./...` also builds +the CLI and runs all 172 golden cases through it, including the unchanged 54 M2 cases. 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. +[M3 validation](docs/testing/m3-validation.md) for native Windows and Linux race commands and limits. +Windows race tests need a 64-bit MinGW-w64-compatible GCC configured in Go's CC/CXX; ordinary static +builds use `make build`, which disables CGO only for that target. + +## Rule metadata and documentation + +`internal/diagnostic/registry.go` owns each builtin rule's metadata and pure evaluator. Findings, +`explain`, `rules --json` and generated `docs/rules/ENV*.md` pages share that registry. +Update the registry, add reviewed fixtures, then run: + +```bash +make docs +make check-docs +make test +make race +make lint +``` + +Do not edit generated rule pages directly. Tests and CI reject drift. Optional evidence additions must +preserve snapshot-v1 compatibility; project and registry contracts have their own committed schemas. +Unknown or failed evidence cannot become a speculative finding, and no metadata command runs collectors. ## Code navigation diff --git a/Makefile b/Makefile index 4d24dfa..f188be1 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,17 @@ -BINARY := envdoctor -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +ifeq ($(OS),Windows_NT) +EXE_EXT := .exe +endif +BINARY := envdoctor$(EXE_EXT) +VERSION ?= $(shell git describe --tags --always --dirty) LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: bootstrap build test race lint fmt vet run docker clean +.PHONY: bootstrap build test race lint fmt vet docs check-docs run docker clean bootstrap: go mod download go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0 +build: export CGO_ENABLED := 0 build: go build -trimpath -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/envdoctor @@ -27,6 +31,12 @@ fmt: vet: go vet ./... +docs: + go run ./cmd/rulegen + +check-docs: + go run ./cmd/rulegen --check + run: build ./bin/$(BINARY) diff --git a/README.md b/README.md index 0d1084b..9af01b7 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,16 @@ > A cross-platform CLI that detects, diagnoses and helps resolve development environment problems. -![status](https://img.shields.io/badge/status-M2_rules-orange) +![status](https://img.shields.io/badge/status-M3_complete-green) [![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: 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. +**Status: M3 complete on `feat/m3-implementation`; Windows, Ubuntu and macOS CI passed.** +Nine collectors feed twelve rules covering platform, shell, PATH, Python/Node runtime, requested TCP ports, +effective Git config, local Docker/API metadata, explicit project dependencies and proxy configuration. +Reports are plain text or versioned JSON; `explain` and `rules --json` work offline. +Diagnosis never applies fixes or executes interpreters found on PATH. The +[M3 validation record](docs/testing/m3-validation.md) records commands, live checks and limits. ## Why this exists @@ -25,10 +28,13 @@ explains the cause instead of dumping raw output. | 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 | -| Collect effective Git identity and autocrlf | M2 | **Done**; Git diagnostic rules are M3 | +| Inspect Docker contexts and API compatibility | M3 | **Implemented**, default local probe and known API bounds | +| Identify missing system dependencies | M3 | **Implemented**, supported requirements explicitly declared by a project | +| Detect incompatible package managers | M3 | **Implemented**, explicit manager evidence and observed lockfiles | +| Inspect effective Git identity and explicit autocrlf policy | M2/M3 | **Implemented**, config origins and ENV010 | +| Detect virtualenv/interpreter mismatch | M3 | **Implemented**, known canonical environment metadata | +| Inspect proxy configuration and optional reachability | M3 | **Implemented**, redacted and offline by default | +| Explain rules and export the rule registry | M3 | **Implemented**, no collectors invoked | | Generate diagnostic bundles | M4 | Not started | | Human-readable reports | M2/M4 | **Done** plain text; interactive polish is M4 | | JSON export | M1 | **Done** | @@ -39,20 +45,13 @@ explains the cause instead of dumping raw output. ```mermaid flowchart TD - CLI[CLI entry point] --> COL[Environment collectors] - COL --> SYS[System inspector] - COL --> RUNTIME[Runtime inspector] - COL --> DEP[Dependency inspector] - COL --> NET[Network inspector] - SYS --> ENGINE[Diagnostic engine] - RUNTIME --> ENGINE - DEP --> ENGINE - NET --> ENGINE - ENGINE --> RULES[Rule engine] - RULES --> FIX[Fix recommendations] - RULES --> REPORT[Terminal report] - RULES --> JSON[JSON export] - FIX -->|explicit confirmation| APPLY[Safe auto-fix] + CLI[diagnose] --> COL[Nine bounded collectors] + COL --> SNAP[Typed snapshot and redaction] + SNAP --> ENGINE[Pure diagnostic engine] + REG[Rule registry] --> ENGINE + ENGINE --> REPORT[Plain text or JSON with manual advice] + META[explain and rules] --> REG + REG --> PAGES[Generated rule pages] ``` Full reasoning, data model, API surface and failure behaviour are in [docs/PLAN.md](docs/PLAN.md). @@ -65,15 +64,17 @@ might not exist, and no fix may run without explicit confirmation. ## Quickstart -Requires Go 1.26+. From the repository root, in PowerShell: +Requires Go 1.26+ and GNU Make for the canonical targets. From the repository root, in PowerShell: ```powershell -go build -o bin/envdoctor.exe ./cmd/envdoctor +$env:GOTOOLCHAIN='go1.26.11' +make build .\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`. +On Linux/macOS use `GOTOOLCHAIN=go1.26.11 make build` and `./bin/envdoctor diagnose`. +The build target disables CGO and chooses the platform's executable suffix. 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: @@ -83,7 +84,7 @@ Try the committed **synthetic** demo without inspecting your machine: .\bin\envdoctor.exe diagnose --fixture-dir tests/demo --rule ENV005 ``` -The demo intentionally fires all six rules and exits 2. Its port finding includes: +The M2 demo intentionally fires the original six rules and exits 2. Its port finding includes: ```text [ERROR] Requested TCP port is already in use @@ -96,7 +97,7 @@ Verify: ``` 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. +The [M3 validation record](docs/testing/m3-validation.md) also records a real Windows listener test. Machine-readable output, for an editor or CI: @@ -107,7 +108,7 @@ Machine-readable output, for an editor or CI: The JSON document is validated against [docs/schema/diagnose-v1.schema.json](docs/schema/diagnose-v1.schema.json) in the test suite; the snapshot inside it is described by [docs/schema/snapshot-v1.schema.json](docs/schema/snapshot-v1.schema.json). Home directories are replaced -with `~` before anything is written. +with `~`, and credential-bearing URLs are redacted 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. Captured collector errors retain exit 3 during offline @@ -117,6 +118,51 @@ diagnosis. A degraded report still contains the findings that could be evaluated 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. +## Project and optional network evidence + +Declare supported requirements in `envdoctor.requirements.json` in the current working directory: + +```json +{ + "schemaVersion": 1, + "requirements": [ + {"kind": "tool", "name": "cmake"}, + {"kind": "library", "name": "openssl", "path": "vendor/openssl"} + ], + "gitAutocrlf": "input" +} +``` + +The [project schema](docs/schema/project-v1.schema.json) documents this bounded, versioned marker. +Supported tools are `cmake`, `make`, `gcc` and `pkg-config`; EnvDoctor checks executable metadata on +absolute PATH entries. OpenSSL checks headers and development libraries at the declared prefix; +Windows support for that library check is amd64 only. Denied or unsupported evidence stays unknown. +No project scripts, interpreters or package-manager commands are run. + +`package.json.packageManager` supplies an exact npm/pnpm/yarn/bun version. If a runner is explicitly +selected outside that declaration, supply it with `--package-manager`: + +```powershell +.\bin\envdoctor.exe diagnose --rule ENV009 --package-manager npm@10.0.0 +.\bin\envdoctor.exe explain ENV006 +.\bin\envdoctor.exe rules --json +``` + +ENV009 compares supported observed lockfiles with explicit manager evidence; installed npm alone is +inconclusive. `explain` and `rules` read the builtin registry without inspecting the host. +Registry JSON follows [rules-v1.schema.json](docs/schema/rules-v1.schema.json). + +Proxy configuration is collected as redacted host/port metadata with reachability untested. To opt in: + +```powershell +.\bin\envdoctor.exe diagnose --rule ENV012 --probe-network +``` + +This permits at most three distinct configured proxy endpoints, one DNS lookup and one TCP dial per +endpoint, with a one-second total deadline. No credentials, application bytes or external HTTP requests +are sent. DNS failure, permission denial, timeout and cancellation remain unknown/degraded. +`--probe-network` and `--package-manager` are rejected with `--fixture-dir`. + ## Implemented rules | Rule | Evidence and behavior | @@ -127,28 +173,44 @@ it rejects unknown fields, trailing documents and unsupported versions, and invo | [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 | +| [ENV007](docs/rules/ENV007.md) | Known incompatible Docker API bounds or explicit API override | +| [ENV008](docs/rules/ENV008.md) | Supported, explicitly required build tool or OpenSSL files missing at the inspected location | +| [ENV009](docs/rules/ENV009.md) | Declared/selected package manager and observed lockfiles disagree | +| [ENV010](docs/rules/ENV010.md) | Missing checked Git identity or conflict with explicit autocrlf policy | +| [ENV011](docs/rules/ENV011.md) | Known selected Python invocation outside the active canonical virtualenv | +| [ENV012](docs/rules/ENV012.md) | Configured proxy refuses an opt-in TCP connection or has no route | 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. +locations. Docker records selected context metadata but probes its default local pipe/socket. +Different Docker product versions alone do not establish API incompatibility. WindowsApps aliases, +opaque shims and protected package paths remain unresolved metadata. -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. +Home paths and credential-bearing URLs are redacted, including 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 ./... +make test +make race +make lint +make check-docs +make build +govulncheck ./... ``` -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). +The suite includes 172 exact golden cases, preserving all 54 original M2 snapshots and adding 118 cases. +It builds the CLI and checks those cases through real subprocesses, validating JSON and exit codes. +Native Windows and Linux race suites passed locally. The native Windows, Ubuntu and macOS race/test/build +matrix, lint and vulnerability scan passed in [M3 CI](https://github.com/Shellghostt/EnvDoctor/actions/runs/43723839978). +Both macOS architectures also cross-build. Commands, allocation measurements, +socket lifecycle evidence and limits are in [docs/testing/m3-validation.md](docs/testing/m3-validation.md). + +`make docs` regenerates all twelve rule pages from the same registry as findings, `explain` and `rules`. +`make check-docs` and CI detect documentation drift. Do not edit generated pages directly. A local release build of all six targets: @@ -163,26 +225,31 @@ goreleaser build --snapshot --clean | Path | Purpose | |---|---| | `cmd/envdoctor` | CLI entry point | +| `cmd/rulegen` | Rule-page generation and drift check | | `internal/cli` | Command wiring, flags, exit codes | | `internal/config` | Resolved options, schema version, collector timeout | -| `internal/collector` | Parallel runner and platform/PATH/runtime/port/Git/local-Docker collectors | -| `internal/diagnostic` | Rule engine, severity model, fixture harness | +| `internal/collector` | Parallel runner and nine collectors, including project and proxy evidence | +| `internal/diagnostic` | Rule registry, pure 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` | 54 snapshot-to-diagnosis fixtures | +| `tests/golden` | 172 snapshot-to-diagnosis fixtures | | `tests/integration` | Built CLI tests against all golden cases | -| `tests/demo` | Synthetic offline snapshot demonstrating all six rules | +| `tests/demo` | Synthetic offline snapshot demonstrating the original six M2 rules | | `deploy` | Release and packaging notes | ## Roadmap +M3 is **complete on the feature branch, including three-OS CI**. Read the +[M3 acceptance gates](docs/phases/M3.md), [accepted ADR 0005](docs/adr/0005-m3-evidence-and-rule-registry.md) +and [validation record](docs/testing/m3-validation.md) for its contracts and evidence. + | Milestone | Deliverable | Status | |---|---|---| | M1 | CLI foundation, collector interface, system information | **Done** (v0.1.0) | -| M2 | Cross-platform collectors and rules ENV001–ENV006 | **Done (unreleased)** | -| M3 | Rules ENV007–ENV012, Docker and dependency inspectors, `explain` | Not started | +| M2 | Cross-platform collectors and rules ENV001–ENV006 | **Done (merged)** | +| M3 | Rules ENV007–ENV012, Docker/dependency/proxy evidence, offline registry commands | **Done (feature branch, CI passed)** | | M4 | Polished terminal output, Markdown export, diagnostic bundles | Not started | | M5 | Safe, confirmed automated remediation | Not started | | M6 | Windows, Linux, macOS packaging and signed releases | Not started | diff --git a/cmd/rulegen/main.go b/cmd/rulegen/main.go new file mode 100644 index 0000000..244f2fb --- /dev/null +++ b/cmd/rulegen/main.go @@ -0,0 +1,44 @@ +// Command rulegen writes/checks rule pages from the same registry as the CLI. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/shellghostt/envdoctor/internal/diagnostic" +) + +func main() { + check := flag.Bool("check", false, "check committed pages without writing") + flag.Parse() + if flag.NArg() != 0 { + fmt.Fprintln(os.Stderr, "rulegen accepts only --check") + os.Exit(1) + } + if !*check { + if err := os.MkdirAll(filepath.Join("docs", "rules"), 0755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + failed := false + for _, rule := range diagnostic.BuiltinMetadata() { + path := filepath.Join("docs", "rules", rule.RuleID+".md") + expected := diagnostic.RuleMarkdown(rule) + if *check { + actual, err := os.ReadFile(path) + if err != nil || string(actual) != expected { + fmt.Fprintf(os.Stderr, "%s differs; run make docs\n", path) + failed = true + } + } else if err := os.WriteFile(path, []byte(expected), 0644); err != nil { + fmt.Fprintln(os.Stderr, err) + failed = true + } + } + if failed { + os.Exit(1) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7d495ba..2a76f08 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,9 +1,10 @@ # EnvDoctor Architecture -**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. +**Status: M3 complete on the feature branch; three-OS CI passed.** Nine collectors feed +twelve pure rules and a plain-text/JSON report. Offline metadata commands and generated pages share the +same rule registry. Bundles, automatic fixes and interactive output remain planned. +The authoritative specification is [PLAN.md](PLAN.md); [ADR 0005](adr/0005-m3-evidence-and-rule-registry.md) +records the M3 extensions to the M2 evidence and inspection boundaries. ## Context @@ -14,24 +15,21 @@ a Docker daemon that is not running, or a missing system library. Developers los existing answers are scattered forum posts. EnvDoctor collects the evidence, applies explicit rules, and explains the cause instead of dumping raw output. -## Intended shape +## Implemented flow ```mermaid flowchart TD - CLI[CLI entry point] --> COL[Environment collectors] - COL --> SYS[System inspector] - COL --> RUNTIME[Runtime inspector] - COL --> DEP[Dependency inspector] - COL --> NET[Network inspector] - SYS --> ENGINE[Diagnostic engine] - RUNTIME --> ENGINE - DEP --> ENGINE - NET --> ENGINE - ENGINE --> RULES[Rule engine] - RULES --> FIX[Fix recommendations] - RULES --> REPORT[Terminal report] - RULES --> JSON[JSON export] - FIX -->|explicit confirmation| APPLY[Safe auto-fix] + CLI[diagnose] --> COL[Nine bounded collectors] + COL --> SYS[OS and metadata wrappers] + SYS --> SNAP[Typed snapshot] + SNAP --> REDACT[Home and credential URL redaction] + REDACT --> ENGINE[Pure rule evaluation] + REG[Immutable builtin registry] --> ENGINE + ENGINE --> REPORT[Plain text or JSON with manual advice] + FIXTURE[Offline versioned snapshot] --> REDACT + META[explain and rules] --> REG + GEN[cmd/rulegen] --> REG + REG --> DOCS[Rule metadata and generated pages] ``` ## Component responsibilities @@ -39,17 +37,32 @@ 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. +The live path runs platform, shell, PATH, runtime, requested-port, Git, local-Docker, dependency and proxy +collectors in parallel with a two-second deadline. Each returns a typed part; results merge in registration order. +An ordinary error or late result contributes an error rather than facts. Explicit typed partial evidence +can be retained with `Part.Errors`; notices and timings stay degraded and dependent rules are skipped. +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. +output and sanitized context overrides. Docker contacts only its default local engine; selected context +metadata and API/host overrides remain evidence, not connection targets. API compatibility uses advertised +bounds and explicit overrides rather than product-version comparisons. -After home redaction, rules ENV001–ENV006 use only the snapshot. Rule/component filters affect evaluation, +The dependency collector strictly decodes a bounded `envdoctor.requirements.json` and reads optional +`package.json`/lockfile metadata in the working directory. Tool and OpenSSL checks use declared requirements +and file metadata. Explicit `--package-manager` supplies runner evidence; no project scripts are run. +Git records effective identity/config origins, and line-ending advice requires explicit project policy. +Python resolution compares canonical invocation environments, preserving venv/system symlink distinctions. +Unknown shims and protected WindowsApps paths stay inconclusive. + +Proxy collection is offline by default and exports only redacted configuration. `--probe-network` permits +at most three distinct configured endpoints, one DNS lookup/one TCP dial each, and a one-second total +deadline. It sends no application bytes and closes each connection. Invalid configuration, denied/DNS/ +timeout/cancelled probes degrade collection; refusal/no-route is the conclusive unreachable evidence. + +After redaction, rules ENV001–ENV012 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. @@ -57,10 +70,24 @@ The offline path decodes a bounded versioned snapshot and skips collectors, then 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. +`explain`, `rules --json` and `cmd/rulegen` use defensive copies of registry metadata without running +collectors. One registry owns finding text, prerequisites, caveats and verification commands. +The generator's check mode is enforced by tests and CI. Optional M3 snapshot fields preserve schema v1; +project and registry JSON have separate version-1 schemas. + +Redaction covers home paths and credential-bearing URLs, including errors. Git identity remains part of +the JSON snapshot under the M2 sharing contract. 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 design. + +## M3 evidence + +Read [phase requirements](phases/M3.md), [ADR 0005](adr/0005-m3-evidence-and-rule-registry.md) and +[M3 validation](testing/m3-validation.md) for implemented contracts, tests and limits. Native Windows +and Linux race suites passed locally; native Windows/Ubuntu/macOS tests and race suites also passed in +[M3 CI](https://github.com/Shellghostt/EnvDoctor/actions/runs/43723839978). +[Development tools](DEVELOPMENT-TOOLS.md) records navigation and runtime-profiling coverage. diff --git a/docs/DEVELOPMENT-TOOLS.md b/docs/DEVELOPMENT-TOOLS.md new file mode 100644 index 0000000..e8db8f6 --- /dev/null +++ b/docs/DEVELOPMENT-TOOLS.md @@ -0,0 +1,58 @@ +# Development tools and memory validation + +Initial audit: 2026-11-14; M3 verification refresh: 2026-11-15. +These are development aids, not product features or substitutes for tests. +Read [PLAN.md](PLAN.md), the [M3 requirements](phases/M3.md) and accepted [ADRs](adr/) first. + +## CodeGraph + +CodeGraph 1.6.2 is installed. The existing index was refreshed from merged M2 during the initial audit +and from final M3 source on 2026-11-15: 75 files, 819 nodes and 1,804 edges; status reported up to date. +Check freshness when switching branches or pulling changes: + +```bash +codegraph status +codegraph sync +``` + +For indexed code, use the CodeGraph MCP exploration tool with this repository's absolute +`projectPath` and the symbol/flow you need. Read its returned source and callers before edits; +use direct source/config searches for areas the graph does not capture. Unresolved references remain, +so the graph does not prove complete dependency coverage, correctness or test coverage. +Builds and CI do not require the local index. Generated databases/transient files stay ignored through +`.codegraph/.gitignore`; do not commit them. Initialize a new index only when intentionally enabling it. + +## MemGuard and runtime profiling + +MemGuard is JavaScript/TypeScript static resource analysis. It does not analyze Go/Python, inspect a +running process, or prove there are no leaks. Pin its version, check the reported file count and +diagnostics, and inspect resource findings rather than suppressing them to get a green result. + +Use Go allocation/heap profiles and process RSS around repeated diagnosis, cancellation and fixture runs. Check that subprocesses, sockets and goroutines finish after deadlines. This is a Go CLI, so MemGuard and Java HPROF analysis do not cover its current code. + +For runtime changes, reproduce the suspected workload, record a baseline, repeat the same lifecycle, +then compare live/retained memory after cleanup. Capture allocation rate, heap/RSS, goroutines or +active handles as applicable, and latency/drop metrics. Go benchmarks can collect `-benchmem` and +`-memprofile` for a specific package/workload; inspect profiles with `go tool pprof`. Node supports +heap snapshots/inspector; Python supports `tracemalloc`. Choose the native tool for the process. +Record versions, duration, inputs, commands and limitations in a validation report. +Profile only when the change or suspected fault justifies it; a documentation edit needs link/diff checks. + +[M3 validation](testing/m3-validation.md) records the actual proxy lifecycle/heap benchmark, retained +profile, goroutine counts and local diagnosis timings. Those are bounded Go workload measurements; +MemGuard/MAT were not treated as Go runtime gates or evidence of universal leak freedom. + +## Memory Analyzer + +The locally installed analyzer is Eclipse MAT 1.17.0, configured with Java 21 and a 4 GiB analyzer heap. +It reads Java/HPROF heap dumps. This repository has no Java runtime workload; MAT is therefore not an +appropriate gate for it. MAT's own heap setting is not this project's memory budget. +Heap dumps/profiles can contain secrets and user data; keep them local/ignored and use synthetic inputs. + +## Handoff between editors or assistants + +State the branch and commit, read the phase/ADRs, refresh the graph after source changes, and provide +the exact failing command, output and minimal synthetic fixture. Keep implementation on a feature +branch. A skipped integration test must be reported as skipped, with its missing dependency; it is not +a passing check. Preserve local Docker volumes and use disposable databases for migration tests. + diff --git a/docs/PLAN.md b/docs/PLAN.md index 4b54e9f..66c4d97 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-14 +updated: 2026-11-15 --- # EnvDoctor — Implementation Plan @@ -24,36 +24,47 @@ updated: 2026-11-14 ## 0. Machine context +M2 is complete and merged into `main`. M3 is **complete on `feat/m3-implementation`, including +[three-OS CI](https://github.com/Shellghostt/EnvDoctor/actions/runs/43723839978)**. +[ADR 0005](adr/0005-m3-evidence-and-rule-registry.md) +was accepted and committed before implementation. The [M3 requirements](phases/M3.md) define the evidence +and acceptance contract; [M3 validation](testing/m3-validation.md) records the final commands and limits. +The [development-tool audit](DEVELOPMENT-TOOLS.md) records actual coverage and profiling limits. + ```yaml project: EnvDoctor status: mvp version: 0.1.0 builds: true -functional: partial # M1/M2 collection and six rules work; M3-M6 remain +functional: partial # M1-M3 collection and twelve rules work; M4-M6 remain implemented: - - cobra CLI with diagnose and version, --json, --component, --rule, --verbose, --port, --fixture-dir + - cobra CLI with diagnose, version, offline explain and rules --json + - diagnose flags --json, --component, --rule, --verbose, --port, --fixture-dir, --probe-network, --package-manager - 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 - 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 + - requested TCP port ownership via OS facilities, effective Git config/origins and local-default Docker probes + - locally inspected Docker context metadata, advertised API bounds and explicit override evidence + - explicit project requirements for supported build tools and OpenSSL development files + - package-manager declaration/runner/lockfile metadata and canonical Python environment evidence + - redacted proxy configuration and bounded opt-in DNS/TCP reachability + - pure rules ENV001-ENV012 with manual suggestions and actionable verification commands + - one builtin rule registry for findings, explain, rules JSON and generated documentation + - home-path and credential-URL redaction including 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 - - 54 golden fixtures covering six rules, three OS snapshots and positive/negative/edge cases + - compatible snapshot/report schema v1 additions and separate project/registry v1 schemas + - 172 golden fixtures including the unchanged 54 M2 cases and 118 additional cases - built CLI integration tests for the golden cases and JSON/exit-code contracts - - bounded offline diagnosis that preserves degraded collector errors + - bounded offline diagnosis that preserves degraded collector errors and rejects live-evidence flags + - reproducible rule-page generation and a CI documentation drift check - goreleaser configuration verified with a local snapshot build of six targets not_implemented: - - diagnostic rules ENV007-ENV018 - - full Docker context/version inspector (M2 has only the local-default probe for ENV006) - - dependency collector - - explain/rules commands and generated rule documentation + - diagnostic rules ENV013-ENV018 - bubble tea output - diagnostic bundles - fixes and undo @@ -65,6 +76,7 @@ source_of_truth: architecture: docs/ARCHITECTURE.md decisions: docs/adr/ rule_fixtures: tests/golden/ + rule_metadata: internal/diagnostic/registry.go status: README.md#roadmap forbidden_claims: @@ -202,8 +214,13 @@ flowchart TD `envdoctor diagnose` → detect platform → run enabled collectors (parallel, each with a timeout) → assemble `Snapshot` → run rules that declare compatible components → sort findings by severity → render. -A collector that times out or errors produces a **degraded** snapshot entry, not a crash. A missing -component disables the rules that need it and prints one line explaining why. +A collector that times out or errors produces a **degraded** snapshot entry, not a crash. Ordinary errors +discard that collector's facts; explicitly typed partial evidence can be retained with `Part.Errors`. +Failed prerequisites disable dependent rules and print a notice. M3 runs nine collectors with a two-second +deadline. Proxy DNS/TCP work requires `--probe-network` and has a one-second total deadline within it. + +The builtin registry owns the twelve pure evaluators and their public metadata. Offline `explain`, +`rules --json` and generated rule pages use the same registry without running collectors. --- @@ -255,17 +272,18 @@ 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. +The examples above include planned model/fix fields; committed schemas define the implemented contract. +M3 adds optional Docker context/API/state, dependency/package-manager, effective Git-origin, canonical +Python-environment and proxy evidence to snapshot v1. Selected Docker context metadata is distinct from +the `local-default` probe target. Actual findings contain manual suggestions and do not apply fixes or +probe TCP port 2375. See ADRs 0004/0005 and the committed schemas for field and compatibility details. --- ## 8. Command surface and exit codes ``` -envdoctor diagnose [--json] [--component ] [--rule ] [--verbose] [--port ] [--fixture-dir ] +envdoctor diagnose [--json] [--component ] [--rule ] [--verbose] [--port ] [--fixture-dir ] [--probe-network] [--package-manager [@]] envdoctor explain envdoctor bundle [--output ] [--include-logs] envdoctor fix [--rule ] [--dry-run] [--yes] @@ -273,8 +291,9 @@ envdoctor rules [--json] envdoctor version ``` -Implemented as of M2: `diagnose` (with all six flags) and `version`. The other commands are planned; see -section 0 for the authoritative list. +Implemented as of M3: `diagnose`, `version`, offline `explain` and `rules`/`rules --json`. +`bundle` and `fix` remain planned; see section 0 for the authoritative list. Unknown rule IDs and invalid +metadata arguments exit 4; output failures exit 3. | Exit code | Meaning | |---|---| @@ -287,12 +306,24 @@ section 0 for the authoritative list. `--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, +`--port` accepts repeated flags and comma-separated ports 1–65535. TCP listener inspection is skipped +without requested ports. Proxy configuration is collected without network work; `--probe-network` permits +at most three configured endpoints, one DNS lookup/one TCP dial each and a one-second total deadline. +`--package-manager [@]` explicitly selects npm/pnpm/yarn/bun runner evidence. +Both new live-evidence flags are incompatible with `--fixture-dir`. + +`--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. +`envdoctor.requirements.json` in the working directory supplies strictly decoded version-1 project +requirements (at most 1 MiB and 32 entries) and optional explicit `gitAutocrlf` policy. Initial checks cover +`cmake`, `make`, `gcc`, `pkg-config` on absolute PATH and OpenSSL development files at a declared prefix. +`package.json.packageManager` needs an exact numeric version; installed npm alone never selects a runner. +See [project-v1.schema.json](schema/project-v1.schema.json), [rules-v1.schema.json](schema/rules-v1.schema.json) +and ADR 0005 for precise evidence and unsupported cases. + --- ## 9. Rules @@ -305,14 +336,14 @@ Rule ids are stable and never reused. One markdown page per rule in `docs/rules/ | ENV002 | node | error | Multiple Node installations, or version manager shim pointing at a missing version | | ENV003 | path | warning | PATH entry that does not exist | | ENV004 | path | warning | PATH shadowing: a different binary earlier in PATH than the expected one | -| ENV005 | network | error | Port already in use by another process | +| ENV005 | network | error | Requested TCP port already in use by a listening process | | ENV006 | docker | error | Docker CLI present but daemon unreachable | -| ENV007 | docker | warning | Docker CLI and daemon version skew beyond a documented range | -| ENV008 | deps | error | Missing system library or build tool required by a detected project | -| ENV009 | node | warning | Lockfile/package-manager mismatch (e.g. `pnpm-lock.yaml` with npm in use) | -| ENV010 | git | warning | Missing `user.name`/`user.email`, or `core.autocrlf` inconsistent with the platform | +| ENV007 | docker | warning | Known client/daemon API incompatibility under a documented compatibility policy | +| ENV008 | deps | error | Missing supported tool or OpenSSL files explicitly required at an inspected project location | +| ENV009 | node | warning | Observed lockfiles disagree with a declared/selected package manager, or lockfile families conflict | +| ENV010 | git | warning | Missing effective identity, or `core.autocrlf` conflicting with explicit project policy | | ENV011 | python | warning | Active virtualenv does not match the interpreter on PATH | -| ENV012 | network | info | Proxy environment variables set but not reachable | +| ENV012 | network | info | Configured proxy unreachable during an explicit opt-in bounded probe | | ENV013 | system | warning | Low disk space in the working drive | | ENV014 | deps | warning | Global tool installed but not on PATH | | ENV015 | node | info | Deprecated or end-of-life runtime version | @@ -320,10 +351,14 @@ 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 +ENV001–ENV012 are implemented; ENV013–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. +local engine. Runtime versions and unresolved shims may be unknown. ENV007 requires known API bounds or +override evidence; different product versions alone are inconclusive. ENV008 uses explicit supported +project requirements, ENV009 explicit manager/lockfile evidence, ENV010 checked Git identity or explicit +line-ending policy, ENV011 known canonical invocation environments and ENV012 an opt-in conclusive TCP +result. The generated per-rule pages record prerequisites, verification and platform limits. **Rule contract.** A rule receives the snapshot and returns zero or more findings. It must: @@ -434,27 +469,43 @@ six targets. The CI workflow has a three-OS matrix and it has executed: the firs - [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 +Status: complete and merged into `main`. 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. +and vulnerability scanner passed. Main CI +[43554155740](https://github.com/shellghostt/envdoctor/actions/runs/43554155740) passed for the merged baseline. +Cross-compilation is not evidence of native execution. These are merged M2 results; +M3 evidence is recorded below. 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 -**Deliverable:** rules ENV007–ENV012, Docker and dependency inspectors, `explain` command. +**Deliverable:** rules ENV007–ENV012, Docker and dependency inspectors, `explain` and `rules --json`. + +Status: **Complete on `feat/m3-implementation`, including native Windows/Ubuntu/macOS CI.** +Follow [M3 requirements](phases/M3.md), [ADR 0005](adr/0005-m3-evidence-and-rule-registry.md) and +[M3 validation](testing/m3-validation.md) for evidence, tests and limits. **Acceptance criteria** -- [ ] Docker daemon down is distinguished from Docker not installed -- [ ] Missing system library reported only when a project requiring it is detected -- [ ] Package manager mismatch detects lockfile vs runner -- [ ] `envdoctor explain ENV006` prints the full rule documentation -- [ ] Rule pages exist for every rule and are generated from the same source as `rules --json` +- [x] Docker daemon down is distinguished from Docker not installed +- [x] Missing system library reported only when a project requiring it is detected +- [x] Package manager mismatch detects lockfile vs runner +- [x] `envdoctor explain ENV006` prints the full rule documentation +- [x] Rule pages exist for every rule and are generated from the same source as `rules --json` +- [x] Normal diagnosis and fixtures initiate no remote probes; proxy reachability requires explicit opt-in +- [x] Unknown versions, ambiguous runners, denied/missing evidence and redaction have three-OS fixtures +- [x] `make test`, native Windows/Linux race, `make lint`, build, docs drift and vulnerability checks pass +- [x] The final M3 feature branch passes the Windows/Ubuntu/macOS CI matrix + +All 172 fixtures pass through the engine and built CLI. Nine collectors complete real Windows inspection +without degradation; local proxy tests send zero application bytes and release resources. Both macOS +architectures cross-compile. [CI 43723839978](https://github.com/Shellghostt/EnvDoctor/actions/runs/43723839978) +passed native Windows/Ubuntu/macOS race, tests and builds, plus lint and vulnerability scanning. +The validation record names the +tests and records local measurements without claiming M6 clean-host/release coverage. ### M4 — Reports and diagnostic bundles @@ -519,8 +570,9 @@ See [testing/m1-m2-validation.md](testing/m1-m2-validation.md) for commands, mea | ENV-019 | Terminal renderer with severity grouping | ENV-006 | M2 | | 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 | +| ENV-023A | Opt-in bounded proxy evidence collector and offline-default regression checks | ENV-005 | M3 | +| ENV-022 | Rules ENV007–ENV012 | ENV-020, ENV-021, ENV-023A | M3 | +| ENV-023 | Rule registry, `explain`, `rules --json` and docs generation | ENV-022 | M3 | | ENV-024 | Bubble Tea renderer with plain fallback | ENV-019 | M4 | | ENV-025 | Bundle writer with redaction tests | ENV-005 | M4 | | ENV-026 | `diagnose --from-bundle` | ENV-025 | M4 | @@ -537,10 +589,10 @@ See [testing/m1-m2-validation.md](testing/m1-m2-validation.md) for commands, mea merged without both. - **CLI integration tests** run the built binary against a fixture directory (`--fixture-dir`), asserting stdout, stderr and exit code. -- **Redaction tests** attempt to leak a fake token through every collector and assert it never appears in a - bundle. +- **Redaction tests** attempt to leak synthetic credentials through snapshots, findings and notices. + Bundle sharing/redaction remains a separate M4 acceptance gate. - **Cross-platform:** the same suite runs on `windows-latest`, `ubuntu-latest`, `macos-latest`. A rule that - only passes on one OS is marked `platform: [windows]` and tested only there. + has a platform exclusion records it in registry caveats and explicit inconclusive fixtures. **Performance targets:** full diagnose < 3 s warm / < 8 s cold; binary < 25 MB; memory < 100 MB; every collector < 2 s or it is disabled with a degraded notice. @@ -551,9 +603,9 @@ collector < 2 s or it is disabled with a degraded notice. No telemetry, by design. Diagnostics only: -- `--verbose` prints collector timings and the snapshot path. -- `envdoctor diagnose --debug-snapshot ` writes the raw snapshot for bug reports. -- Bundles include tool version, OS build and collector timings. +- `--verbose` prints collector timings; errors are redacted. +- Planned `envdoctor diagnose --debug-snapshot ` will write a snapshot for bug reports. +- Planned bundles will include tool version, OS build and collector timings. There is no `envdoctor.io` endpoint. There is no analytics. A PR that adds phone-home is rejected. @@ -602,9 +654,11 @@ Explicitly out of scope: malware detection, host compromise assessment. ## 19. Anti-hallucination contract -**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. +**Facts:** M1–M3 collection, twelve pure diagnostic rules, plain-text/JSON reporting, offline metadata +commands and bounded offline diagnosis are implemented and tested. Native Windows/Linux local race +suites and native Windows/Ubuntu/macOS M3 CI pass. Bundles, automatic fixes, rules ENV013–ENV018 +and signed releases remain future work. Runtime discovery does not execute PATH candidates and unknown +metadata stays unknown. Optional proxy reachability requires explicit opt-in. **Rules:** diff --git a/docs/adr/0005-m3-evidence-and-rule-registry.md b/docs/adr/0005-m3-evidence-and-rule-registry.md new file mode 100644 index 0000000..37a019b --- /dev/null +++ b/docs/adr/0005-m3-evidence-and-rule-registry.md @@ -0,0 +1,89 @@ +# ADR 0005 — M3 evidence, bounded network opt-in and rule metadata + +- Status: accepted for the M3 feature branch +- Date: 2026-11-15 +- Supersedes: ADR 0004 decisions 4–6 and 8 only for the extensions below; + ADR 0002's six-rule registry limit. Existing rule IDs, offline defaults and exit codes remain. + +## Decisions + +1. Docker still probes the **default local** Unix socket or Windows engine pipe, with a trusted absolute + CLI, fixed arguments and a sanitized environment. Selected context metadata is read locally from the + standard home-directory Docker configuration and the context's SHA-256 metadata directory. Context + and host overrides are recorded as data; they never select the probe endpoint. Alternate Docker + configuration directories are unsupported. A TCP/SSH context cannot cause a remote connection. +2. ENV007 uses API numbers, not Docker product versions. The daemon advertises its maximum `ApiVersion` + and, when available, `MinAPIVersion`. The client advertises its API version and optional minimum. + A client maximum below the server minimum, or an explicitly known client minimum above the server + maximum, proves disjoint ranges. An explicit `DOCKER_API_VERSION` outside the known daemon range + also proves incompatibility. A newer client maximum alone does not: Docker supports negotiation. + Unknown/malformed bounds remain inconclusive. A successful negotiated API inside the daemon's + range is compatible. Sources: Docker Engine API version matrix and API negotiation documentation + (), and the CLI's `docker version` JSON fields. +3. Local Docker states distinguish CLI absent, available, unavailable, permission denied, unsupported + API and unknown metadata. Only a completed, recognized local connection failure supports ENV006; + denial, unknown subprocess failures and malformed metadata degrade inspection. Older M2 snapshots + without the new status retain their established ENV006 interpretation. +4. The initial dependency catalog is deliberately explicit. The project marker + `envdoctor.requirements.json` is bounded to 1 MiB, strictly decoded, versioned at 1, and declares + `requirements` with `kind`, `name` and optional `path`. Supported tools are `cmake`, `make`, `gcc` + and `pkg-config`: inspect executable file/symlink metadata on absolute PATH entries, never run them. + A complete, successful search can establish that the required executable is absent from PATH; + denied metadata or relative PATH ambiguity is unknown. Supported library `openssl` requires an + explicit installation prefix (`path`, relative to the project or absolute). Inspect `include/openssl/ssl.h` + plus both SSL and crypto development libraries: Linux `.so`/`.a` in `lib` or `lib64`, macOS `.dylib`/`.a` + in `lib`, Windows `.lib` in `lib` or `lib/VC/x64/MD` (amd64 only). Missing files establish absence + **at that declared prefix**, not absence everywhere on the machine. Unsupported names/layouts/ + architectures are unknown. Presence is metadata evidence, not proof a compiler can link the SDK. + Source files, language extensions and undeclared transitive dependencies do not imply requirements. +5. In the working directory, bounded `package.json` metadata supplies an exact `packageManager` + declaration (npm/pnpm/yarn/bun with an exact numeric version; optional integrity suffix). Observed + lockfiles are `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock` + and `bun.lockb`. `--package-manager [@]` explicitly selects a runner; an installed + npm executable alone does not. ENV009 distinguishes conflicting lockfile families, declaration/ + runner disagreement, version disagreement when both versions are known, and selected manager/ + lockfile disagreement. Missing/unsupported declarations and unknown versions are inconclusive. +6. Git records effective identity, whether it was checked, and the config origin from fixed + `git config --null --show-origin --get` invocations. Missing keys remain distinct from failures. + The requirements file may explicitly declare `gitAutocrlf` (`true`, `false`, `input`). Only a + conflict with that policy supports autocrlf advice. The OS alone does not, and an `eol` attribute + overriding autocrlf is not itself a conflict. Git identity remains visible by the M2 contract; + origin paths are redacted. Invalid config is degraded. +7. ENV011 requires an absolute `VIRTUAL_ENV`, readable `pyvenv.cfg`, a known canonical environment + directory and a selected interpreter with known file resolution. Compare invocation environment + metadata, preserving the distinction between a venv symlink and its shared system interpreter. + Normalize separators, Windows case and resolved environment-directory symlinks. Known shims and + unresolved metadata remain inconclusive; never execute an interpreter or shim. +8. `--probe-network` is the only proxy reachability opt-in and is incompatible with `--fixture-dir`. + Without it, collect HTTP_PROXY, HTTPS_PROXY and ALL_PROXY (uppercase takes precedence over lowercase) + as redacted endpoint data, with reachability `untested`. Accept HTTP/HTTPS/SOCKS5/SOCKS5H URLs with + a host and valid port; default ports are 80/443/1080. Strip userinfo, path, query and fragment. + NO_PROXY is represented only by whether it is configured. With opt-in, probe at most three distinct + configured host/port pairs, with a one-second total deadline within the collector's two-second + deadline. Each endpoint gets at most one DNS lookup and one TCP dial to one resolved address. + No credentials, HTTP requests, TLS handshakes, external targets or scanning. Close each connection. + Refusal/no-route is unreachable evidence. DNS failures, denial, timeout, cancellation and invalid + URLs are unknown/degraded, not proof that the proxy is down. Duplicate endpoints share a result. +9. Optional snapshot fields extend schema v1 without making old fields newly required. Fixtures remain + strict, bounded data and never invoke collectors. A collector can return typed, certain partial + evidence with explicit `Part.Errors`; that evidence is retained with degraded notices/timings, and + dependent rules are skipped. An ordinary returned error still discards the part, as in M2. + Redact credential-bearing URLs and home paths in evidence, errors and CLI notices even when home + discovery fails. New fields do not authorize disclosing arbitrary project/environment contents. +10. One immutable builtin registry owns each rule's ID, primary component, collector prerequisites, + severity, title, evidence description, explanation, manual steps, verification, limitations and + documentation link, along with its pure evaluator. Evaluation, `explain`, `rules --json` and + generated `docs/rules/ENV*.md` use that registry. Registry JSON has `schemaVersion: 1` and an + ordered `rules` array, documented by its own schema. Metadata commands run no collectors. + Unknown IDs/invalid arguments exit 4; output failures exit 3. `go run ./cmd/rulegen` generates + pages and `go run ./cmd/rulegen --check` detects drift, also enforced by CI/tests. + +## Consequences + +ENV007–ENV012 join the existing six rules without changing their ordering, filtering, fixture or +exit-code contracts. Unsupported evidence limits coverage rather than generating speculative findings. +The dependency manifest is project evidence, not executable configuration or an automatic repair plan. +M4 reporting/bundles, ENV013–ENV018, fixes and packaging remain later scope. Cross-platform fixtures, +real read-only inspection, cancellation/socket tests, registry/schema checks and canonical Go gates +must pass before readiness documentation can claim completion. Local cross-compilation is not native +three-OS CI execution. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8213863..ff673ea 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -5,8 +5,10 @@ Each file records one decision that was expensive to make and would be expensive | ADR | Title | Status | |---|---|---| | [0001](0001-initial-decisions.md) | Initial technology and scope decisions | accepted | -| [0002](0002-m1-implementation-decisions.md) | M1 implementation decisions | accepted, partly superseded by 0003 | +| [0002](0002-m1-implementation-decisions.md) | M1 implementation decisions | accepted, partly superseded by 0003 and 0005 | | [0003](0003-go-126-floor-and-ci-contract.md) | Go 1.26 floor and CI contract | accepted | +| [0004](0004-m2-collectors-and-rules.md) | M2 collectors and rules | accepted, scoped extensions in 0005 | +| [0005](0005-m3-evidence-and-rule-registry.md) | M3 evidence, bounded network opt-in and rule metadata | accepted for the M3 feature branch | New ADRs are numbered sequentially and never edited after acceptance. A changed mind gets a new ADR that supersedes the old one. diff --git a/docs/phases/M3.md b/docs/phases/M3.md new file mode 100644 index 0000000..fb64f40 --- /dev/null +++ b/docs/phases/M3.md @@ -0,0 +1,92 @@ +# M3 requirements: evidence-based rules and inspectors + +Status: **Complete on `feat/m3-implementation`, including native Windows/Ubuntu/macOS CI.** +Prepared 2026-11-14 from merged M2 commit +`c7e2c84c730edca814a44580cdfc9abaf630b457`. +The [plan](../PLAN.md) defines scope; this is its M3 acceptance contract. +[ADR 0005](../adr/0005-m3-evidence-and-rule-registry.md), accepted and committed as `3e22674` before +implementation, records the scoped extensions to ADR 0004. Final evidence is in +[M3 validation](../testing/m3-validation.md). + +## Scope and boundaries + +Implement ENV007–ENV012, expanded Docker/dependency evidence, `explain` and `rules --json`. +M2 already has the local-default Docker probe for ENV006, seven collectors and six pure rules. +Interactive reporting/bundles remain M4; automatic remediation remains M5; packaging remains M6. +No collector or rule edits PATH, profiles, Git config, Docker state or installed packages. + +## Evidence required for each rule + +| Rule | Required evidence and decision | +|---|---| +| ENV007 | Known client/server API compatibility ranges or an explicit API override that the daemon cannot support. Different product version strings alone are not a failure. Record the supported API policy and source. | +| ENV008 | A detected project with an explicit, supported requirement for a named tool/library, plus a reliable platform presence check. A language extension alone does not imply every compiler/library is required. | +| ENV009 | Declared package manager/version or explicitly selected runner and observed lockfiles. A lockfile plus installed npm does not establish that npm is the runner. Conflicting lockfiles need a distinct explanation. | +| ENV010 | Effective Git identity and its config origin; autocrlf advice requires an explicit project policy/attributes conflict. OS choice alone does not prove the setting is wrong. | +| ENV011 | Active environment metadata and the resolved interpreter path, normalized for platform case/symlinks. Unknown shim resolution is inconclusive. Never execute an interpreter discovered on PATH. | +| ENV012 | Proxy configuration and an explicitly requested, bounded reachability result. Without opt-in, collect redacted configuration only and mark reachability untested. | + +For each rule, publish positive/negative/edge fixtures, evidence prerequisites, platform limitations, +manual advice and verification commands. An absent/failed/denied probe stays unknown or degraded; +do not convert it into a missing dependency or unreachable-service diagnosis. +Keep declared component dependencies and deterministic snapshot-only rule evaluation. + +## Inspector and network contract + +- Inspect Docker context metadata locally. A selected TCP/SSH context must never silently trigger a remote + connection. ENV017's remote-context diagnostic remains a later rule, not an extra M3 feature. +- Preserve the default local probe and distinguish CLI absent, local daemon unavailable, permission denied, + unsupported API and unknown version. Use trusted absolute CLI locations, fixed arguments and deadlines. +- Define the initial dependency catalog before coding: project markers, precise requirements, platform + probe and unsupported cases. Bound manifest reads; parse data without executing project scripts. +- ENV012 uses the explicit `--probe-network` option defined by ADR 0005. + Permit only the configured proxy host/port with a finite deadline, bounded count and cancellation. + Use no proxy credentials, external target request or general connectivity scan. Invalid proxy URLs are + invalid configuration, not proof the proxy is down. DNS is network work and requires the same opt-in. +- Fixtures, `explain` and `rules` perform no probes. Tests must prove a normal diagnosis initiates no remote + connections even when Docker/proxy environment variables select remote endpoints. +- Redact home paths and credential-bearing URLs from snapshots, notices and errors. Preserve M2's + documented Git identity disclosure; bundles with broader redaction remain M4. + +## Registry, CLI and compatibility + +One registry owns rule ID, component prerequisites, severity, title, evidence description, explanation, +manual steps, caveats and documentation links. Both `rules --json` and generated pages derive from it; +generation must be reproducible and CI must detect drift. `explain ENV006` works offline for existing +rules as well as new ones. Invalid IDs return a documented error, never empty success. + +Preserve M2 report ordering, severity/exit-code mapping, `--component`, `--rule`, fixture behavior and +plain non-TTY output. Add snapshot evidence compatibly and validate real/fixture JSON against committed +schemas. Breaking field changes require an ADR, versioning and fixture migration guidance. + +## Recorded decisions + +ADR 0005 defines the network option/deadline/limits, Docker API criteria, initial dependency catalog, +project marker, package-manager evidence and registry JSON contract. It explicitly supersedes the affected +M2 restrictions while preserving offline defaults and defines missing/unsupported/partial evidence. +Do not amend accepted ADRs or ship unsupported claims as rule findings. + +## Acceptance and evidence + +- [x] ENV007–ENV012 have positive/negative/edge fixtures on every applicable OS; exclusions are documented. +- [x] All existing 54 M2 golden cases and pure-rule I/O checks still pass. +- [x] Docker absent/down/denied/remote-context and compatible/incompatible/unknown API cases are distinct. +- [x] Dependencies require concrete project evidence; mismatch and venv cases avoid guessing. +- [x] Normal/fixture/metadata commands open no remote sockets; opt-in probes time out and cancel safely. +- [x] Credential/error redaction, schema compatibility and stable CLI exit/output tests pass. +- [x] `explain`, `rules --json` and rule pages agree, with a reproducible documentation generation check. +- [x] `make test`, native Windows/Linux race, `make lint`, build and vulnerability checks pass. +- [x] The final feature branch passes the Windows/Ubuntu/macOS CI matrix. + +The [M3 validation record](../testing/m3-validation.md) names each acceptance test and records 172 golden +cases, canonical local gates, real read-only Windows checks and bounded socket/allocation evidence. +Both macOS architectures cross-compile. [CI 43723839978](https://github.com/Shellghostt/EnvDoctor/actions/runs/43723839978) +passed native Windows/Ubuntu/macOS race, tests and builds, plus lint and vulnerability scanning. +The [M1/M2 record](../testing/m1-m2-validation.md) preserves historical results separately. + +## Handoff + +ENV-020–ENV-023A and all M3 acceptance gates are verified on the feature branch. The recorded CI run +verifies M3 source at `fbf79dd`, including native macOS execution. Follow +[development tools](../DEVELOPMENT-TOOLS.md) and provide the branch, fixture/reproducer, exact command +and redacted output for debugging in another editor. diff --git a/docs/rules/ENV001.md b/docs/rules/ENV001.md index 29b6d6b..97c40a1 100644 --- a/docs/rules/ENV001.md +++ b/docs/rules/ENV001.md @@ -1,18 +1,27 @@ # ENV001: Conflicting active Python versions + + Component: python. Severity: error. +Collector prerequisites: runtimes. + 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. +Evidence required: At least two active Python aliases with known, differing metadata versions. + +Manual steps: + +- Activate the intended environment and align python and python3 on PATH. + +Verify with `envdoctor diagnose --rule ENV001`. + +Limitations: -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. +- Unknown metadata versions do not establish a conflict. Discovery never executes PATH interpreters. +- Python and python3 resolve independently; a venv invocation remains distinct from its system symlink target. +- Opaque shims and WindowsApps aliases/protected package paths remain unknown. New snapshots with unknown resolution do not support a runtime conflict. -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. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. -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. +Documentation: . diff --git a/docs/rules/ENV002.md b/docs/rules/ENV002.md index 1b5b4f8..59ff1cb 100644 --- a/docs/rules/ENV002.md +++ b/docs/rules/ENV002.md @@ -1,17 +1,27 @@ # ENV002: Conflicting Node installations or missing target + + Component: node. Severity: error. +Collector prerequisites: runtimes. + 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. +Evidence required: Multiple observed Node candidates, or a missing explicitly selected manager target. + +Manual steps: + +- Select one Node installation and repair the selected version-manager target. + +Verify with `envdoctor diagnose --rule ENV002`. + +Limitations: -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. +- Multiple candidates are metadata evidence, not proof that an inactive installation is broken. +- nvm/fnm/volta labels come from paths; their commands are never executed. +- Opaque shims and WindowsApps aliases/protected package paths remain unknown. New snapshots with unknown resolution do not support a runtime conflict. -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. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. -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. +Documentation: . diff --git a/docs/rules/ENV003.md b/docs/rules/ENV003.md index 24dbd6b..c35fb6e 100644 --- a/docs/rules/ENV003.md +++ b/docs/rules/ENV003.md @@ -1,18 +1,26 @@ # ENV003: Missing PATH entries + + Component: path. Severity: warning. +Collector prerequisites: path. + 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. +Evidence required: A PATH entry whose metadata confirms absence or an unusable path ancestor. + +Manual steps: + +- Remove or correct these entries in your shell or system PATH settings. + +Verify with `envdoctor diagnose --rule ENV003`. + +Limitations: -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. +- The original entry is retained after home redaction. +- Permission errors degrade collection rather than proving an entry is missing. -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. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. -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. +Documentation: . diff --git a/docs/rules/ENV004.md b/docs/rules/ENV004.md index cafd5f3..d06748f 100644 --- a/docs/rules/ENV004.md +++ b/docs/rules/ENV004.md @@ -1,17 +1,27 @@ # ENV004: Selected runtime is shadowed on PATH + + Component: path. Severity: warning. +Collector prerequisites: path, runtimes. + 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. +Evidence required: A present selected Python/Node target and a different active invocation path. + +Manual steps: + +- Activate the selected environment or move its bin directory before the conflicting entry. + +Verify with `envdoctor diagnose --rule ENV004`. + +Limitations: -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. +- Selection comes from VIRTUAL_ENV, NVM_BIN or NVM_SYMLINK, not a guessed preference. +- No PATH entries or shell profiles are changed. +- Opaque shims and WindowsApps aliases/protected package paths remain unknown. New snapshots with unknown resolution do not support a runtime conflict. -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. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. -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. +Documentation: . diff --git a/docs/rules/ENV005.md b/docs/rules/ENV005.md index 7b45c00..7380fa2 100644 --- a/docs/rules/ENV005.md +++ b/docs/rules/ENV005.md @@ -1,18 +1,26 @@ # ENV005: Requested TCP port is already in use + + Component: network. Severity: error. +Collector prerequisites: network. + 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. +Evidence required: An observed TCP listener on a port explicitly requested with --port. + +Manual steps: + +- Choose another port or stop the identified process after confirming it is safe to stop. + +Verify with `envdoctor diagnose --rule ENV005`. + +Limitations: -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. +- PID/process names can be unavailable when OS permissions deny ownership metadata. +- No port inspection runs without --port. Verification appends the affected ports. -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. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. -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. +Documentation: . diff --git a/docs/rules/ENV006.md b/docs/rules/ENV006.md index aa9786e..614a8bb 100644 --- a/docs/rules/ENV006.md +++ b/docs/rules/ENV006.md @@ -1,18 +1,27 @@ # ENV006: Docker daemon unavailable + + Component: docker. Severity: error. +Collector prerequisites: docker. + 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. +Evidence required: A trusted Docker CLI and a recognized failure to connect to the default local engine. + +Manual steps: + +- Start the local Docker engine and check permission to its default socket. + +Verify with `envdoctor diagnose --rule ENV006`. + +Limitations: -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. +- The endpoint is /var/run/docker.sock or the Windows docker_engine pipe. Selected TCP/SSH contexts are metadata only. +- CLI absence, denied inspection, malformed output and unknown failures do not prove an outage. +- M2 snapshots without probeStatus preserve their documented unavailable-daemon interpretation. -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. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. -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. +Documentation: . diff --git a/docs/rules/ENV007.md b/docs/rules/ENV007.md new file mode 100644 index 0000000..33e1b76 --- /dev/null +++ b/docs/rules/ENV007.md @@ -0,0 +1,27 @@ +# ENV007: Docker client and daemon API incompatibility + + + +Component: docker. Severity: warning. + +Collector prerequisites: docker. + +The advertised Docker API ranges or explicit API override cannot overlap the daemon's supported range. + +Evidence required: Valid advertised API bounds proving disjoint ranges, or an explicit API override outside the daemon range. + +Manual steps: + +- Remove an unsupported Docker API override or choose a client supported by the local daemon. + +Verify with `envdoctor diagnose --rule ENV007`. + +Limitations: + +- Different Docker product versions alone do not establish incompatibility; newer clients can negotiate down. +- Missing/malformed API bounds remain unknown. API policy and sources are recorded in ADR 0005. +- Selected remote contexts are never contacted; ENV017 remains later scope. + +No command is executed by this rule. Evidence comes only from the snapshot. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. + +Documentation: . diff --git a/docs/rules/ENV008.md b/docs/rules/ENV008.md new file mode 100644 index 0000000..e41f4a6 --- /dev/null +++ b/docs/rules/ENV008.md @@ -0,0 +1,28 @@ +# ENV008: Required project dependency is missing + + + +Component: deps. Severity: error. + +Collector prerequisites: deps. + +A dependency explicitly required by this project is absent from the inspected PATH or declared installation prefix. + +Evidence required: A supported requirement in envdoctor.requirements.json and conclusive executable/development-file absence. + +Manual steps: + +- Provide the declared tool on PATH or the required development files at the project's declared library prefix. + +Verify with `envdoctor diagnose --rule ENV008`. + +Limitations: + +- Catalog: cmake, make, gcc and pkg-config on absolute PATH; OpenSSL development files at an explicit prefix. +- OpenSSL checks ssl.h plus SSL/crypto .so/.a (Linux), .dylib/.a (macOS), or .lib (Windows amd64). +- Unsupported layouts, Windows arm64 OpenSSL, relative PATH ambiguity and denied metadata are inconclusive. +- No scripts, package managers or PATH-discovered executables are run. Presence does not prove successful compilation/linking. + +No command is executed by this rule. Evidence comes only from the snapshot. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. + +Documentation: . diff --git a/docs/rules/ENV009.md b/docs/rules/ENV009.md new file mode 100644 index 0000000..564fa30 --- /dev/null +++ b/docs/rules/ENV009.md @@ -0,0 +1,28 @@ +# ENV009: Package manager and lockfile mismatch + + + +Component: node. Severity: warning. + +Collector prerequisites: deps. + +The project's declared package manager, selected runner or lockfile families disagree. + +Evidence required: package.json with observed supported lockfiles and a declared/explicit manager disagreement, or conflicting lockfile families. + +Manual steps: + +- Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy. + +Verify with `envdoctor diagnose --rule ENV009`. + +Limitations: + +- packageManager declarations need an exact numeric npm/pnpm/yarn/bun version; --package-manager explicitly selects a runner. +- Installed npm alone never establishes selection. Unknown versions do not establish a version mismatch. +- npm-shrinkwrap.json and package-lock.json are one family; bun.lock and bun.lockb are one family. +- Conflicting families are identified separately from runner/declaration/version disagreement. Lockfile contents are not executed. + +No command is executed by this rule. Evidence comes only from the snapshot. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. + +Documentation: . diff --git a/docs/rules/ENV010.md b/docs/rules/ENV010.md new file mode 100644 index 0000000..d4e11d6 --- /dev/null +++ b/docs/rules/ENV010.md @@ -0,0 +1,28 @@ +# ENV010: Git identity or project line-ending policy needs attention + + + +Component: git. Severity: warning. + +Collector prerequisites: git, deps. + +Effective Git identity is incomplete or core.autocrlf disagrees with the project's explicit policy. + +Evidence required: Completed effective Git identity inspection, or a config-origin-backed conflict with explicit gitAutocrlf project policy. + +Manual steps: + +- Set the intended Git identity or align core.autocrlf with the project's documented policy after reviewing the reported config origins. + +Verify with `envdoctor diagnose --rule ENV010`. + +Limitations: + +- Git user.name/user.email remain visible in JSON by the M2 sharing contract; origin home paths are redacted. +- An absent core.autocrlf defaults to false. OS choice alone does not prove a conflict. +- eol attributes override autocrlf and do not by themselves prove a conflict. Policy is explicit gitAutocrlf in the requirements file. +- Invalid/denied config and unchecked older snapshots remain inconclusive. No Git configuration is changed. + +No command is executed by this rule. Evidence comes only from the snapshot. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. + +Documentation: . diff --git a/docs/rules/ENV011.md b/docs/rules/ENV011.md new file mode 100644 index 0000000..1fa897b --- /dev/null +++ b/docs/rules/ENV011.md @@ -0,0 +1,28 @@ +# ENV011: Active virtual environment differs from selected Python + + + +Component: python. Severity: warning. + +Collector prerequisites: runtimes. + +The active environment metadata and the selected Python invocation refer to different environments. + +Evidence required: Readable VIRTUAL_ENV pyvenv.cfg/canonical directory and a known selected Python invocation outside that environment. + +Manual steps: + +- Reactivate the intended virtual environment and check both Python aliases in the current shell. + +Verify with `envdoctor diagnose --rule ENV011`. + +Limitations: + +- Compare canonical environment directories with Windows case and separator normalization. +- A venv symlink to a system interpreter still belongs to its invocation environment. +- Unknown shims, unreadable environment metadata and unresolved interpreters are inconclusive. No interpreter is executed. +- Opaque shims and WindowsApps aliases/protected package paths remain unknown. New snapshots with unknown resolution do not support a runtime conflict. + +No command is executed by this rule. Evidence comes only from the snapshot. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. + +Documentation: . diff --git a/docs/rules/ENV012.md b/docs/rules/ENV012.md new file mode 100644 index 0000000..e532670 --- /dev/null +++ b/docs/rules/ENV012.md @@ -0,0 +1,29 @@ +# ENV012: Configured proxy did not accept a connection + + + +Component: network. Severity: info. + +Collector prerequisites: proxy. + +The configured proxy endpoint refused the bounded TCP connection or had no reachable network route. + +Evidence required: An explicitly requested probe of a configured proxy endpoint returned connection refusal or no-route evidence. + +Manual steps: + +- Check the configured proxy host/port and local network or VPN, then repeat the opt-in probe. + +Verify with `envdoctor diagnose --rule ENV012 --probe-network`. + +Limitations: + +- Default diagnosis records only redacted configuration and leaves reachability untested. +- --probe-network allows at most three configured endpoints, one DNS lookup/one TCP dial each, and a one-second total deadline. +- No credentials, application bytes, external HTTP requests or TLS handshakes are sent; connections are closed. +- DNS failure, denial, timeout, cancellation and invalid URLs remain unknown/degraded. NO_PROXY is recorded only as configured/unconfigured. +- Use ASCII/punycode hostnames or unscoped IPv6 literals. Zone-qualified IPv6 endpoints and unsupported URL schemes are degraded configuration. + +No command is executed by this rule. Evidence comes only from the snapshot. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above. + +Documentation: . diff --git a/docs/schema/project-v1.schema.json b/docs/schema/project-v1.schema.json new file mode 100644 index 0000000..57875da --- /dev/null +++ b/docs/schema/project-v1.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/shellghostt/envdoctor/schema/project-v1.schema.json", + "title": "EnvDoctor explicit project requirements v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "requirements": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "name" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "tool", + "library" + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "path": { + "type": "string", + "maxLength": 4096 + } + } + }, + "maxItems": 32 + }, + "gitAutocrlf": { + "type": "string", + "enum": [ + "true", + "false", + "input" + ] + } + } +} diff --git a/docs/schema/rules-v1.schema.json b/docs/schema/rules-v1.schema.json new file mode 100644 index 0000000..7a3c4c1 --- /dev/null +++ b/docs/schema/rules-v1.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/shellghostt/envdoctor/schema/rules-v1.schema.json", + "title": "EnvDoctor builtin rule registry v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "rules" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "ruleId", + "component", + "prerequisites", + "severity", + "title", + "evidence", + "explanation", + "manualSteps", + "verify", + "caveats", + "docs" + ], + "properties": { + "ruleId": { + "type": "string", + "pattern": "^ENV[0-9]{3}$" + }, + "component": { + "type": "string", + "minLength": 1 + }, + "prerequisites": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warning", + "error", + "critical" + ] + }, + "title": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "type": "string", + "minLength": 1 + }, + "explanation": { + "type": "string", + "minLength": 1 + }, + "manualSteps": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "verify": { + "type": "string", + "minLength": 1 + }, + "caveats": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "docs": { + "type": "string", + "format": "uri" + } + } + } + } + } +} diff --git a/docs/schema/snapshot-v1.schema.json b/docs/schema/snapshot-v1.schema.json index 9685d69..45d983a 100644 --- a/docs/schema/snapshot-v1.schema.json +++ b/docs/schema/snapshot-v1.schema.json @@ -137,6 +137,19 @@ }, "missing": { "type": "boolean" + }, + "resolvedPath": { + "type": "string" + }, + "resolution": { + "type": "string", + "enum": [ + "known", + "unknown" + ] + }, + "environmentRoot": { + "type": "string" } } } @@ -197,6 +210,41 @@ }, "installed": { "type": "boolean" + }, + "clientApi": { + "type": "string" + }, + "clientMinApi": { + "type": "string" + }, + "clientDefaultApi": { + "type": "string" + }, + "serverApi": { + "type": "string" + }, + "serverMinApi": { + "type": "string" + }, + "apiOverride": { + "type": "string" + }, + "selectedContext": { + "type": "string" + }, + "selectedHost": { + "type": "string" + }, + "probeStatus": { + "type": "string", + "enum": [ + "absent", + "available", + "unavailable", + "denied", + "unsupported", + "unknown" + ] } } }, @@ -227,6 +275,18 @@ }, "autocrlf": { "type": "string" + }, + "identityChecked": { + "type": "boolean" + }, + "userNameOrigin": { + "type": "string" + }, + "userEmailOrigin": { + "type": "string" + }, + "autocrlfOrigin": { + "type": "string" } } }, @@ -258,6 +318,174 @@ "minimum": 1, "maximum": 65535 } + }, + "dependencies": { + "type": "object", + "additionalProperties": false, + "required": [ + "projectRoot" + ], + "properties": { + "projectRoot": { + "type": "string" + }, + "requirementsFile": { + "type": "string" + }, + "gitAutocrlf": { + "type": "string", + "enum": [ + "true", + "false", + "input" + ] + }, + "requirements": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "name", + "status" + ], + "properties": { + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "present", + "missing", + "unknown" + ] + } + } + } + }, + "package": { + "type": "object", + "additionalProperties": false, + "required": [ + "manifest" + ], + "properties": { + "manifest": { + "type": "string" + }, + "declared": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "runner": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "lockfiles": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "pythonEnvironment": { + "type": "object", + "additionalProperties": false, + "required": [ + "root", + "metadataKnown" + ], + "properties": { + "root": { + "type": "string" + }, + "canonicalRoot": { + "type": "string" + }, + "metadataKnown": { + "type": "boolean" + } + } + }, + "proxies": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "variable", + "url", + "probe" + ], + "properties": { + "variable": { + "type": "string", + "enum": [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY" + ] + }, + "url": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "probe": { + "type": "string", + "enum": [ + "untested", + "reachable", + "unreachable", + "unknown", + "invalid" + ] + } + } + }, + "maxItems": 3 + }, + "noProxyConfigured": { + "type": "boolean" } } } diff --git a/docs/testing/m3-validation.md b/docs/testing/m3-validation.md new file mode 100644 index 0000000..a08a3c7 --- /dev/null +++ b/docs/testing/m3-validation.md @@ -0,0 +1,159 @@ +# EnvDoctor M3 validation — 2026-11-15 + +M3 is complete on `feat/m3-implementation`, based on merged M2 +`c7e2c84`. [ADR 0005](../adr/0005-m3-evidence-and-rule-registry.md) was accepted and committed +as `3e22674` before implementation. Implementation commit: `d185e5b`. +This record covers the final M3 source, not a release or merge. +The feature branch was pushed on 2026-11-15. [CI 43723839978](https://github.com/Shellghostt/EnvDoctor/actions/runs/43723839978) +passed for `fbf79dd`: native Windows, Ubuntu and macOS race/test/build/documentation checks, lint and +the vulnerability scan. The run completed at 14:21:34 UTC with all five jobs successful. + +## Environment and gates + +Windows 11 amd64 on an AMD Ryzen 5 5600H, Go 1.26.11, GNU Make 4.4.1, golangci-lint 2.15.0, +and govulncheck 1.9.0. The vulnerability database reported an update of 2026-11-11. +The host's default Go is newer; every local Go gate pins `GOTOOLCHAIN=go1.26.11`. +Native race tests use the existing MSYS2 UCRT64 GCC 16.2 toolchain targeting +`x86_64-w64-mingw32`. Go's CC/CXX point to its absolute GCC/G++ paths beneath +`%LOCALAPPDATA%/Programs/MSYS2-Go-x64/msys64/ucrt64/bin`. + +| Check | Final result | +|---|---| +| `make docs` | Passed; all twelve generated page hashes unchanged on regeneration | +| `make check-docs` | Passed; registry and committed/generated pages agree | +| `make test` | Passed, including 172 exact fixtures and built-CLI schema/exit checks | +| Native Windows `make race` | Passed, all packages | +| `make lint` | Passed: gofmt, vet and golangci-lint; zero issues | +| `make build` | Passed; Windows amd64 binary, `CGO_ENABLED=0`, 4,379,136 bytes | +| Linux `go test -race -count=1 -timeout=120s ./...` | Passed on the final source in `golang:1.26.11`; built-CLI integration package took 10.491 s | +| Linux static build | Passed; build metadata confirms linux/amd64 and `CGO_ENABLED=0` | +| macOS static cross-builds | Passed for amd64 and arm64; 4,140,384 and 3,929,250 bytes, respectively | +| `govulncheck ./...` | Passed on final source: no vulnerabilities found | +| `git diff --check` | Passed | +| CodeGraph refresh/status | Current: 75 files, 819 nodes, 1,804 edges; local index is ignored | +| M3 three-OS GitHub CI | Passed for `fbf79dd`: native Windows/Ubuntu/macOS race, tests, builds and docs checks, plus lint/security; [run 43723839978](https://github.com/Shellghostt/EnvDoctor/actions/runs/43723839978) | + +`make build` disables CGO only for that target, preserving the static binary contract without disabling +the race detector for `make race`. Cross-compilation confirms compilation; the CI matrix supplies +native macOS execution evidence. +The original 54 M2 fixture JSON files are unchanged; 118 additional cases bring the total to 172. + +## Acceptance evidence + +| Requirement | Tests and evidence | +|---|---| +| ENV007–ENV012 positive/negative/edge cases on applicable OSes | `TestRegistryPagesAndFixturesCoverEveryBuiltin`, all `tests/golden/ENV007`–`ENV012` cases and `TestBuiltCLIWithAllGoldenFixtures`; Windows arm64 OpenSSL is explicitly inconclusive | +| Preserve all 54 M2 golden cases and pure rule evaluation | Existing fixtures, `TestBuiltinRulesOnlyImportPureDependencies`, `TestM3RuleEvaluationIsDeterministicAndDoesNotMutateSnapshot`, `TestUnknownResolutionDoesNotCreateM2Findings` | +| Distinct Docker absent/down/denied/remote/API states | `TestDockerLocalProbeKeepsRemoteContextAsMetadata`, `TestDockerHostOverrideAndInspectionStates`; expanded ENV006 fixtures; ENV007 compatible, incompatible, override, malformed and remote-context cases | +| Explicit, reliable dependency evidence | `TestDependenciesRequireExplicitManifestAndReliableAbsence`, `TestOpenSSLDeclaredPrefixNeedsHeaderAndBothLibraries`, `TestProjectParsingIsBoundedStrictAndNeverRunsScripts`, ENV008 missing/present/denied/no-declaration cases | +| Package-manager and virtualenv decisions avoid guessing | ENV009 declaration/runner/version/conflicting-family cases; ENV011 case normalization/shared-system-target/unknown-environment cases; `TestUnrecognizedPythonTextShimIsInconclusive`, `TestNativeExecutableDoesNotTreatTextShimsAsResolvedInterpreters` | +| Effective Git identity, origins and explicit line-ending policy | `TestGitEffectiveValuesIncludeConfigOrigins`, `TestGitBooleanAliasesUsePortableTypedConfigQuery`, ENV010 fixtures; live Git 2.53.0.windows.2 inspection | +| Offline normal/fixture/metadata commands | `TestNormalDiagnosisDoesNotConnectToRemoteOverrides`, `TestMetadataCommandsAreOfflineAndMatchRegistryAndPages`, `TestProxyCollectionIsOfflineRedactedAndBounded`, existing fixture collector-bypass tests | +| Bounded opt-in networking and resource cleanup | `TestProxyFailuresStayUnknownAndCancellationStopsFurtherDials`, `TestProxyDNSIsBoundedByCancellation`, `TestProxyProbeSendsNoApplicationBytesAndClosesConnection`, `TestRepeatedProxyLifecycleReleasesResources`; end-to-end loopback smoke below | +| Redaction, schema compatibility and stable output/exit codes | `TestNewEvidenceAndNoticesAreRedactedEvenWithoutHomeDiscovery`, `TestRedactionCoversHomePathsInsideURLsAndCredentialURLPaths`, `TestProxyValidationAndEnvironmentPrecedence`, `TestMetadataWriteErrorsUseToolFailureExit`, built-CLI schema and fixture checks | +| Registry/explain/JSON/page consistency and immutability | `TestMetadataCommandsAreOfflineAndMatchRegistryAndPages`, `TestMetadataCopiesCannotMutateRulesOrDocumentation`, page hash comparison and `make check-docs` | +| Degraded partial evidence cannot trigger dependent rules | `TestFailedCollectorPrerequisitesDisableEveryM3Rule`, partial-result collector/CLI cases and fixture error/exit-code checks | + +The built CLI validates the version-1 snapshot/report contract for all fixtures. Metadata tests validate +the new version-1 registry schema, and project tests validate the version-1 requirements schema and strict +collector parsing. Findings remain ordered, snapshot-only and manual; no diagnosis applies a fix. + +## Read-only Windows smoke + +Ten consecutive full diagnoses completed without degraded collectors, with nine collectors and twelve +registered rules. Durations in milliseconds were 390.93, 186.28, 196.89, 186.22, 191.68, 189.57, +194.06, 192.39, 204.12 and 193.47. Exit 1 reflected existing PATH warnings. These are local observations, +not clean-host or three-platform M6 performance results. Machine PATH was not changed. + +Docker Desktop returned CLI 29.9.2, client/server API 1.56 and server minimum API 1.40. +The selected context was `desktop-linux`; the default local probe was available. +An owned TCP listener on port 46852 was identified as `pwsh.exe`, PID 25232, producing exit 2 for ENV005. +That disposable listener was stopped. + +An end-to-end `--probe-network` test pointed HTTP_PROXY, HTTPS_PROXY and ALL_PROXY at one disposable +loopback listener. Exactly one connection was accepted and zero application bytes arrived. Credential +text was redacted and the CLI exited 0. The listener was closed and process environment restored. + +A disposable project declared a required `cmake` and an OpenSSL prefix beneath that project. With PATH +temporarily set to an absolute empty directory in the smoke process, ENV008 returned exit 2 and one finding +with both missing requirements. A `package.json` declaring `pnpm@9.0.0`, a `pnpm-lock.yaml`, and an explicitly +selected `npm@10.0.0` runner produced ENV009 and exit 1. A harmless install hook that would write a sentinel +never executed. The process PATH was restored, and the four known smoke files and two empty directories +were removed without recursive cleanup. + +## Lifecycle and allocation evidence + +The bounded Windows loopback lifecycle workload ran 100 proxy cycles in 31.77 ms. Goroutines changed +from 3 to 2; retained heap after GC changed from 377,944 to 340,936 bytes. A 1,000-cycle benchmark reported +320,839 ns/op, 2,408 B/op and 39 allocs/op. M3 rule evaluation reported 2,582 ns/op, 1,945 B/op and +16 allocs/op. A sampled retained heap profile contained runtime/testing allocations and no sampled retained +socket allocations. Profiles and compiled test binaries stay local beneath ignored `bin/`. + +These results cover repeated creation/closure in this workload. They do not establish universal absence +of leaks or a process RSS budget. Native macOS allocation benchmarks/profiles and M6 clean-host budgets +were not recorded. MemGuard and Eclipse MAT do not analyze this Go runtime and were not used as gates. + +## Corrections found during verification + +- The inherited subprocess output buffer embedded `bytes.Buffer`, exposing its `ReadFrom` method and + allowing `io.Copy` to bypass the bounded `Write`. A named private buffer closes that bypass; stdout + and stderr limit regressions pass. +- Protected WindowsApps/MSIX paths and application aliases remain unknown metadata rather than being + opened as ordinary interpreters or described as missing. M2 rules ignore explicitly unknown resolution + in new snapshots while preserving the original snapshots' behavior. +- Git autocrlf inspection uses portable raw queries and the longstanding `--type=bool` query for boolean + aliases. Literal `input` is handled directly; inspection does not depend on newer `bool-or-str` support. + +## Reproduce + +Run native gates with an amd64 MinGW-w64-compatible compiler configured for Go race tests: + +```powershell +$env:GOTOOLCHAIN='go1.26.11' +make docs +make check-docs +make test +make race +make lint +make build +govulncheck ./... +git diff --check +``` + +The final Linux run used a cached Go image and a read-only repository mount, with no persistent volumes: + +```powershell +docker run --rm --name envdoctor-m3-race ` + --mount 'type=bind,source=D:\Dev Repo\EnvDoctor,target=/src,readonly' ` + -w /src -e GOTOOLCHAIN=local -e CGO_ENABLED=1 golang:1.26.11 ` + sh -c 'go test -race -count=1 -timeout=120s ./... && CGO_ENABLED=0 go build -trimpath -o /tmp/envdoctor ./cmd/envdoctor && go version -m /tmp/envdoctor' +``` + +The container removed itself on completion. Existing Docker workloads and data were preserved. +For optional local profiling: + +```powershell +$env:GOTOOLCHAIN='go1.26.11' +go test -run '^$' -bench '^BenchmarkProxyLifecycle$' -benchtime=1000x -benchmem -memprofile bin/m3-socket-mem.pprof -o bin/m3-system-profile.test.exe ./internal/system +go tool pprof -top -inuse_space bin/m3-socket-mem.pprof +go test -run '^$' -bench '^BenchmarkM3RuleEvaluation$' -benchtime=1000x -benchmem ./internal/diagnostic +``` + +## Evidence limits + +The initial catalog checks `cmake`, `make`, `gcc` and `pkg-config` on absolute PATH entries, and OpenSSL +development files at a declared prefix. Relative PATH entries, denied metadata, unsupported libraries +and unsupported Windows architectures remain unknown. Presence does not prove an SDK can link. +ENV009 requires observed supported lockfiles and explicit manager evidence, or conflicting lockfile +families. Installed npm alone and unknown manager versions cannot prove a mismatch. + +Docker probes its default local pipe/socket, regardless of selected context or host overrides. Custom +Docker config directories and remote/rootless endpoints are not probed. API incompatibility requires +known bounds; differing Docker product versions alone do not establish it. Python discovery never runs +interpreters or shims. Effective Git identity remains visible in JSON under the M2 sharing contract. + +Proxy reachability is off by default. Opt-in allows at most three distinct configured endpoints, one DNS +lookup and one TCP dial per endpoint, with a one-second total deadline. Refusal/no-route is evidence; +DNS failures, denial, timeout and cancellation are unknown/degraded. A successful TCP connection does not +prove proxy authentication or forwarding works. M4 bundles, ENV013–ENV018, automated fixes and releases +remain later work. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index be657bc..072bfd5 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -57,7 +57,8 @@ func Main(args []string, app App) int { root.SetArgs(args[1:]) if err := root.Execute(); err != nil { - _, _ = fmt.Fprintf(app.Stderr, "error: %v\n", err) + home, _ := app.HomeDir() + _, _ = fmt.Fprintf(app.Stderr, "error: %s\n", collector.RedactText(err.Error(), home)) var failure toolFailure if errors.As(err, &failure) { return ExitToolFailure @@ -92,8 +93,8 @@ func (a *App) applyDefaults() { } } -// DefaultCollectors returns the seven M2 collectors. Network inspection runs -// only for requested ports; Docker probes only the default local engine. +// DefaultCollectors preserves the M2 collectors and adds project/proxy evidence. +// Proxy collection performs no network work without an explicit diagnose flag. func DefaultCollectors() []collector.Collector { return []collector.Collector{ collector.NewPlatformCollector(), @@ -103,6 +104,8 @@ func DefaultCollectors() []collector.Collector { collector.PortCollector{}, collector.GitCollector{}, collector.DockerCollector{}, + collector.DependencyCollector{}, + collector.ProxyCollector{}, } } @@ -121,6 +124,8 @@ func (a *App) newRoot() *cobra.Command { root.AddCommand(a.newDiagnoseCommand()) root.AddCommand(a.newVersionCommand()) + root.AddCommand(a.newExplainCommand()) + root.AddCommand(a.newRulesCommand()) return root } diff --git a/internal/cli/diagnose.go b/internal/cli/diagnose.go index 444a87e..65bb575 100644 --- a/internal/cli/diagnose.go +++ b/internal/cli/diagnose.go @@ -58,6 +58,8 @@ func (a *App) newDiagnoseCommand() *cobra.Command { 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") + cmd.Flags().BoolVar(&opts.ProbeNetwork, "probe-network", false, "probe configured proxy endpoints with a one-second total deadline") + cmd.Flags().StringVar(&opts.PackageManager, "package-manager", "", "explicit project runner: npm, pnpm, yarn or bun, optionally @major.minor.patch") return cmd } @@ -77,8 +79,16 @@ func (a *App) diagnose(ctx context.Context, opts config.Options) (report.Documen } 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} + switch value := c.(type) { + case collector.PortCollector: + value.Requested = opts.Ports + collectors[i] = value + case collector.ProxyCollector: + value.ProbeNetwork = opts.ProbeNetwork + collectors[i] = value + case collector.DependencyCollector: + value.PackageManager = opts.PackageManager + collectors[i] = value } } runner := collector.Runner{Collectors: collectors, Timeout: config.CollectorTimeout, Now: a.Now} @@ -86,12 +96,11 @@ func (a *App) diagnose(ctx context.Context, opts config.Options) (report.Documen snapshot.RequestedPorts = opts.Ports } - 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)) - } + home, _ := a.HomeDir() + snapshot.Redact(home) + for i := range timings { + if timings[i].Err != nil { + timings[i].Err = errors.New(collector.RedactText(timings[i].Err.Error(), home)) } } diff --git a/internal/cli/m3_test.go b/internal/cli/m3_test.go new file mode 100644 index 0000000..fa8b89a --- /dev/null +++ b/internal/cli/m3_test.go @@ -0,0 +1,201 @@ +package cli_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/shellghostt/envdoctor/internal/cli" + "github.com/shellghostt/envdoctor/internal/collector" + "github.com/shellghostt/envdoctor/internal/diagnostic" + "github.com/shellghostt/envdoctor/internal/report" +) + +type neverCollect struct{} + +func (neverCollect) Name() string { return "forbidden" } +func (neverCollect) Collect(context.Context) (collector.Part, error) { + panic("offline command invoked a collector") +} + +func TestMetadataCommandsAreOfflineAndMatchRegistryAndPages(t *testing.T) { + var out, errs bytes.Buffer + app := newApp(&out, &errs) + app.Collectors = []collector.Collector{neverCollect{}} + if code := cli.Main([]string{"envdoctor", "rules", "--json"}, app); code != 0 || errs.Len() != 0 { + t.Fatalf("rules exit=%d stderr=%s", code, errs.String()) + } + var registry diagnostic.RegistryDocument + decoder := json.NewDecoder(bytes.NewReader(out.Bytes())) + if err := decoder.Decode(®istry); err != nil { + t.Fatal(err) + } + if decoder.Decode(new(any)) != io.EOF { + t.Fatal("rules emitted multiple JSON documents") + } + if registry.SchemaVersion != 1 || !reflect.DeepEqual(registry.Rules, diagnostic.BuiltinMetadata()) { + t.Fatal("rules JSON diverges from the registry") + } + data, err := os.ReadFile(filepath.Join("..", "..", "docs", "schema", "rules-v1.schema.json")) + if err != nil { + t.Fatal(err) + } + compiler := jsonschema.NewCompiler() + id := "https://github.com/shellghostt/envdoctor/schema/rules-v1.schema.json" + if err := compiler.AddResource(id, bytes.NewReader(data)); err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile(id) + if err != nil { + t.Fatal(err) + } + validateAgainstSchema(t, schema, out.Bytes()) + for _, rule := range registry.Rules { + out.Reset() + errs.Reset() + if code := cli.Main([]string{"envdoctor", "explain", rule.RuleID}, app); code != 0 || errs.Len() != 0 { + t.Fatalf("explain %s exit=%d stderr=%s", rule.RuleID, code, errs.String()) + } + page, err := os.ReadFile(filepath.Join("..", "..", "docs", "rules", rule.RuleID+".md")) + if err != nil { + t.Fatal(err) + } + if out.String() != string(page) || out.String() != diagnostic.RuleMarkdown(rule) || strings.Contains(out.String(), "\x1b") { + t.Fatalf("explain/page mismatch for %s", rule.RuleID) + } + } + for _, args := range [][]string{{"explain", "ENV013"}, {"explain"}, {"explain", "ENV006", "ENV007"}, {"rules", "extra"}, {"rules", "--probe-network"}, {"diagnose", "--fixture-dir", "unused", "--probe-network"}, {"diagnose", "--fixture-dir", "unused", "--package-manager", "npm"}, {"diagnose", "--package-manager", "npm@^10"}} { + out.Reset() + errs.Reset() + if code := cli.Main(append([]string{"envdoctor"}, args...), app); code != cli.ExitUsage || out.Len() != 0 { + t.Fatalf("invalid metadata/option usage: %v code=%d out=%s err=%s", args, code, out.String(), errs.String()) + } + } +} + +type failedWriter struct{} + +func (failedWriter) Write([]byte) (int, error) { return 0, errors.New("output failed") } + +func TestMetadataWriteErrorsUseToolFailureExit(t *testing.T) { + for _, args := range [][]string{{"rules"}, {"rules", "--json"}, {"explain", "ENV006"}} { + var errs bytes.Buffer + app := newApp(nil, &errs) + app.Stdout = failedWriter{} + app.Collectors = []collector.Collector{neverCollect{}} + if code := cli.Main(append([]string{"envdoctor"}, args...), app); code != cli.ExitToolFailure { + t.Fatalf("write failure: %v exit=%d", args, code) + } + } +} + +type partialProxy struct{ part collector.Part } + +func (partialProxy) Name() string { return "proxy" } +func (p partialProxy) Collect(context.Context) (collector.Part, error) { return p.part, nil } + +func TestNewEvidenceAndNoticesAreRedactedEvenWithoutHomeDiscovery(t *testing.T) { + for _, home := range []string{`C:\Users\fixture-user`, "/home/fixture-user", "/Users/fixture-user"} { + var out, errs bytes.Buffer + app := newApp(&out, &errs) + app.Engine = nil + app.HomeDir = func() (string, error) { return home, nil } + app.Collectors = []collector.Collector{partialProxy{part: collector.Part{ + Proxies: []collector.Proxy{{Variable: "HTTPS_PROXY", URL: "https://user:FAKE_SECRET@proxy.invalid:443/private?token=FAKE_QUERY", Host: "proxy.invalid", Port: 443, Probe: "unknown"}}, + Dependencies: &collector.Dependencies{ProjectRoot: home + "/project", RequirementsFile: home + "/project/envdoctor.requirements.json"}, + Git: &collector.Git{Version: "2.45.0", UserNameOrigin: "file:" + home + "/.gitconfig"}, + Errors: []collector.CollectorError{{Collector: "proxy", Message: "denied at " + home + "/config https://user:FAKE_SECRET@proxy.invalid/path?token=FAKE_QUERY"}}, + }}} + code := cli.Main([]string{"envdoctor", "diagnose", "--json", "--verbose"}, app) + if code != cli.ExitToolFailure { + t.Fatalf("partial failure exit=%d", code) + } + validateAgainstSchema(t, compileDiagnoseSchema(t), out.Bytes()) + joined := out.String() + errs.String() + for _, value := range []string{"FAKE_SECRET", "FAKE_QUERY", strings.ReplaceAll(home, `\`, `\\`), home} { + if strings.Contains(joined, value) { + t.Fatalf("new evidence leaked %s: %s", value, joined) + } + } + var document report.Document + if err := json.Unmarshal(out.Bytes(), &document); err != nil { + t.Fatal(err) + } + if len(document.Findings) != 0 || len(document.Snapshot.Proxies) != 1 || document.Snapshot.Dependencies == nil || len(document.Collectors) != 1 || document.Collectors[0].Error == "" { + t.Fatalf("partial evidence/degradation not retained: %+v", document) + } + } + var out, errs bytes.Buffer + app := newApp(&out, &errs) + app.HomeDir = func() (string, error) { return "", os.ErrPermission } + app.Collectors = []collector.Collector{staticCollector{name: "proxy", err: errors.New("failed https://user:FAKE_SECRET@host/?token=FAKE_QUERY")}} + if code := cli.Main([]string{"envdoctor", "diagnose", "--json", "--verbose"}, app); code != 3 { + t.Fatalf("failure exit=%d", code) + } + if strings.Contains(out.String()+errs.String(), "FAKE_") { + t.Fatal("failed home discovery disabled credential redaction") + } + code := cli.Main([]string{"envdoctor", "explain", "https://user:FAKE_SECRET@host/?token=FAKE_QUERY"}, app) + if code != 4 || strings.Contains(errs.String(), "FAKE_") { + t.Fatal("usage error leaked URL credentials") + } +} + +func TestNormalDiagnosisDoesNotConnectToRemoteOverrides(t *testing.T) { + listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + endpoint := "http://" + listener.Addr().String() + t.Setenv("HTTP_PROXY", endpoint) + t.Setenv("HTTPS_PROXY", endpoint) + t.Setenv("ALL_PROXY", endpoint) + t.Setenv("http_proxy", endpoint) + t.Setenv("https_proxy", endpoint) + t.Setenv("all_proxy", endpoint) + t.Setenv("DOCKER_HOST", "tcp://"+listener.Addr().String()) + t.Setenv("DOCKER_CONTEXT", "") + t.Setenv("DOCKER_CONFIG", "") + var out, errs bytes.Buffer + app := newApp(&out, &errs) + app.Collectors = nil + app.Engine = nil + start := time.Now() + code := cli.Main([]string{"envdoctor", "diagnose", "--json"}, app) + if code == 4 || out.Len() == 0 || time.Since(start) > 4*time.Second { + t.Fatalf("normal diagnosis: exit=%d elapsed=%s err=%s", code, time.Since(start), errs.String()) + } + var document report.Document + if err := json.Unmarshal(out.Bytes(), &document); err != nil { + t.Fatal(err) + } + for _, p := range document.Snapshot.Proxies { + if p.Probe != "untested" { + t.Fatalf("default diagnosis probed proxy: %+v", p) + } + } + if err := listener.SetDeadline(time.Now().Add(50 * time.Millisecond)); err != nil { + t.Fatal(err) + } + conn, err := listener.Accept() + if err == nil { + _ = conn.Close() + t.Fatal("normal diagnosis connected to a remote override") + } + var timeout net.Error + if !errors.As(err, &timeout) || !timeout.Timeout() { + t.Fatalf("listener failure: %v", err) + } + validateAgainstSchema(t, compileDiagnoseSchema(t), out.Bytes()) +} diff --git a/internal/cli/rules.go b/internal/cli/rules.go new file mode 100644 index 0000000..2bdcb6a --- /dev/null +++ b/internal/cli/rules.go @@ -0,0 +1,52 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/shellghostt/envdoctor/internal/diagnostic" + "github.com/spf13/cobra" +) + +func (a *App) newExplainCommand() *cobra.Command { + return &cobra.Command{ + Use: "explain ", Short: "Print a rule's full documentation without inspecting this machine", Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + metadata, ok := diagnostic.LookupRule(args[0]) + if !ok { + return fmt.Errorf("rule %s is not registered in this build", args[0]) + } + if _, err := io.WriteString(a.Stdout, diagnostic.RuleMarkdown(metadata)); err != nil { + return toolFailure{err} + } + return nil + }, + } +} + +func (a *App) newRulesCommand() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "rules", Short: "List builtin rule metadata without inspecting this machine", Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + metadata := diagnostic.BuiltinMetadata() + if asJSON { + encoder := json.NewEncoder(a.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(diagnostic.RegistryDocument{SchemaVersion: 1, Rules: metadata}); err != nil { + return toolFailure{err} + } + return nil + } + for _, rule := range metadata { + if _, err := fmt.Fprintf(a.Stdout, "%s [%s] %s: %s\n", rule.RuleID, rule.Severity, rule.Component, rule.Title); err != nil { + return toolFailure{err} + } + } + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "write one versioned registry JSON document") + return cmd +} diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 7003732..1095a8b 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -29,13 +29,20 @@ type Collector interface { // The type is a struct rather than an interface so that adding a collector is // a compile-time change here, reviewed once. type Part struct { - Platform *Platform - Shell *Shell - Path *PathInfo - Runtimes []Runtime - Ports []Port - Docker *Docker - Git *Git + Platform *Platform + Shell *Shell + Path *PathInfo + Runtimes []Runtime + Ports []Port + Docker *Docker + Git *Git + Dependencies *Dependencies + PythonEnvironment *PythonEnvironment + Proxies []Proxy + NoProxyConfigured bool + // Errors retain certain typed evidence while disabling dependent rules. + // An ordinary returned error still discards the entire part. + Errors []CollectorError } // Timing records how long one collector took, whether it failed, and why. @@ -146,11 +153,28 @@ func (r Runner) Run(ctx context.Context) (Snapshot, []Timing) { continue } mergePart(&snapshot, result.part) + for _, failure := range result.part.Errors { + if failure.Collector == name { + timings[len(timings)-1].Err = errors.New(failure.Message) + break + } + } } return snapshot, timings } func mergePart(snapshot *Snapshot, part Part) { + snapshot.Errors = append(snapshot.Errors, part.Errors...) + if part.Dependencies != nil { + snapshot.Dependencies = part.Dependencies + } + if part.PythonEnvironment != nil { + snapshot.PythonEnvironment = part.PythonEnvironment + } + if part.Proxies != nil { + snapshot.Proxies = part.Proxies + snapshot.NoProxyConfigured = part.NoProxyConfigured + } if part.Runtimes != nil { snapshot.Runtimes = part.Runtimes } diff --git a/internal/collector/dependencies.go b/internal/collector/dependencies.go new file mode 100644 index 0000000..63e1af1 --- /dev/null +++ b/internal/collector/dependencies.go @@ -0,0 +1,268 @@ +package collector + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/shellghostt/envdoctor/internal/config" + "github.com/shellghostt/envdoctor/internal/system" +) + +const RequirementsFile = "envdoctor.requirements.json" + +type projectRequirement struct { + Kind string `json:"kind"` + Name string `json:"name"` + Path string `json:"path,omitempty"` +} +type projectRequirements struct { + SchemaVersion int `json:"schemaVersion"` + Requirements []projectRequirement `json:"requirements,omitempty"` + GitAutocrlf string `json:"gitAutocrlf,omitempty"` +} + +type DependencyCollector struct { + PackageManager string + WorkingDir func() (string, error) + Getenv func(string) string + ReadFile func(string) ([]byte, error) + Stat func(string) (os.FileInfo, error) + GOOS, Arch string +} + +func (DependencyCollector) Name() string { return "deps" } + +func (c DependencyCollector) Collect(ctx context.Context) (Part, error) { + if err := ctx.Err(); err != nil { + return Part{}, err + } + if c.WorkingDir == nil { + c.WorkingDir = system.WorkingDir + } + if c.Getenv == nil { + c.Getenv = system.Getenv + } + if c.ReadFile == nil { + c.ReadFile = system.ReadSmallFile + } + if c.Stat == nil { + c.Stat = system.Stat + } + if c.GOOS == "" { + c.GOOS = runtime.GOOS + } + if c.Arch == "" { + c.Arch = runtime.GOARCH + } + root, err := c.WorkingDir() + if err != nil { + return Part{}, err + } + d := &Dependencies{ProjectRoot: root} + part := Part{Dependencies: d} + data, found, err := c.readOptional(filepath.Join(root, RequirementsFile)) + if err != nil { + return Part{}, fmt.Errorf("read project requirements: %w", err) + } + if found { + var manifest projectRequirements + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&manifest) != nil || decoder.Decode(new(any)) != io.EOF || manifest.SchemaVersion != 1 || len(manifest.Requirements) > 32 { + return Part{}, fmt.Errorf("invalid project requirements: expected one version-1 document with at most 32 requirements") + } + if manifest.GitAutocrlf != "" && manifest.GitAutocrlf != "true" && manifest.GitAutocrlf != "false" && manifest.GitAutocrlf != "input" { + return Part{}, fmt.Errorf("invalid project gitAutocrlf policy") + } + d.RequirementsFile, d.GitAutocrlf = filepath.Join(root, RequirementsFile), manifest.GitAutocrlf + seen := map[string]bool{} + for _, req := range manifest.Requirements { + if (req.Kind != "tool" && req.Kind != "library") || req.Name == "" || len(req.Name) > 64 || len(req.Path) > 4096 { + return Part{}, fmt.Errorf("invalid project requirement kind, name or path") + } + if err := ctx.Err(); err != nil { + return Part{}, err + } + key := req.Kind + "|" + req.Name + "|" + req.Path + if seen[key] { + continue + } + seen[key] = true + dependency, inspectErr := c.inspectRequirement(ctx, root, req) + d.Requirements = append(d.Requirements, dependency) + if inspectErr != nil { + part.Errors = append(part.Errors, CollectorError{Collector: "deps", Message: "dependency metadata is unknown (inspection failed or was denied)"}) + } + } + } + data, found, err = c.readOptional(filepath.Join(root, "package.json")) + if err != nil { + return Part{}, fmt.Errorf("read package manifest: %w", err) + } + if found { + var manifest struct { + PackageManager string `json:"packageManager"` + } + if json.Unmarshal(data, &manifest) != nil { + return Part{}, fmt.Errorf("invalid package.json metadata") + } + p := &PackageEvidence{Manifest: filepath.Join(root, "package.json")} + name, version, parseErr := config.ParsePackageManager(manifest.PackageManager) + if parseErr == nil && version != "" { + p.Declared = &PackageManager{Name: name, Version: version} + } + if c.PackageManager != "" { + name, version, parseErr = config.ParsePackageManager(c.PackageManager) + if parseErr != nil { + return Part{}, parseErr + } + p.Runner = &PackageManager{Name: name, Version: version} + } + for _, filename := range []string{"package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"} { + if err := ctx.Err(); err != nil { + return Part{}, err + } + info, statErr := c.Stat(filepath.Join(root, filename)) + if statErr == nil && info.Mode().IsRegular() { + p.Lockfiles = append(p.Lockfiles, filename) + } else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return Part{}, fmt.Errorf("inspect project lockfile: %w", statErr) + } + } + d.Package = p + } + return part, nil +} + +func (c DependencyCollector) readOptional(path string) ([]byte, bool, error) { + data, err := c.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if len(data) > 1<<20 { + return nil, false, fmt.Errorf("project metadata exceeds 1 MiB") + } + return data, true, nil +} + +func (c DependencyCollector) inspectRequirement(ctx context.Context, root string, req projectRequirement) (Dependency, error) { + d := Dependency{Kind: req.Kind, Name: req.Name, Status: "unknown"} + if req.Kind == "tool" && req.Path == "" { + switch req.Name { + case "cmake", "make", "gcc", "pkg-config": + default: + return d, nil + } + names := []string{req.Name} + sep := ":" + if c.GOOS == "windows" { + sep = ";" + names = []string{req.Name + ".exe"} + if req.Name == "make" { + names = append(names, "mingw32-make.exe") + } + } + ambiguous := false + var inspectionErr error + for _, dir := range strings.Split(c.Getenv("PATH"), sep) { + if err := ctx.Err(); err != nil { + return d, err + } + dir = strings.Trim(dir, `"`) + if dir == "" { + ambiguous = true // Empty PATH components may select the current directory. + continue + } + if !filepath.IsAbs(dir) { + ambiguous = true + continue + } + for _, name := range names { + path := filepath.Join(dir, name) + info, err := c.Stat(path) + if err == nil && info.Mode().IsRegular() && (c.GOOS == "windows" || info.Mode()&0111 != 0) { + d.Path, d.Status = path, "present" + return d, nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + inspectionErr = err + } + } + } + if inspectionErr != nil { + return d, inspectionErr + } + if !ambiguous { + d.Status = "missing" + } + return d, nil + } + if req.Kind != "library" || req.Name != "openssl" || req.Path == "" || c.GOOS == "windows" && c.Arch != "amd64" { + return d, nil + } + prefix := req.Path + if !filepath.IsAbs(prefix) { + prefix = filepath.Join(root, prefix) + } + d.Path = filepath.Clean(prefix) + header, err := c.Stat(filepath.Join(prefix, "include", "openssl", "ssl.h")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return d, err + } + if errors.Is(err, os.ErrNotExist) || err == nil && !header.Mode().IsRegular() { + d.Status = "missing" + return d, nil + } + dirs, extensions := []string{"lib"}, []string{".so", ".a"} + if c.GOOS == "linux" { + dirs = append(dirs, "lib64") + } + if c.GOOS == "darwin" { + extensions = []string{".dylib", ".a"} + } + if c.GOOS == "windows" { + dirs = append(dirs, filepath.Join("lib", "VC", "x64", "MD")) + extensions = []string{".lib"} + } + var inspectionErr error + for _, dir := range dirs { + complete := true + for _, name := range []string{"libssl", "libcrypto"} { + present := false + for _, extension := range extensions { + if err := ctx.Err(); err != nil { + return d, err + } + info, statErr := c.Stat(filepath.Join(prefix, dir, name+extension)) + if statErr == nil && info.Mode().IsRegular() { + present = true + break + } + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + inspectionErr = statErr + } + } + complete = complete && present + } + if complete { + d.Status = "present" + return d, nil + } + } + if inspectionErr != nil { + return d, inspectionErr + } + d.Status = "missing" + return d, nil +} diff --git a/internal/collector/docker.go b/internal/collector/docker.go index 01791ce..16b0fd3 100644 --- a/internal/collector/docker.go +++ b/internal/collector/docker.go @@ -2,15 +2,24 @@ package collector import ( "context" + "crypto/sha256" "encoding/json" + "errors" "fmt" "github.com/shellghostt/envdoctor/internal/system" + "os" + "path/filepath" "runtime" + "strings" ) type DockerCollector struct { FindTool func(string) string RunTool func(context.Context, string, ...string) ([]byte, error) + Getenv func(string) string + HomeDir func() (string, error) + ReadFile func(string) ([]byte, error) + GOOS string } func (DockerCollector) Name() string { return "docker" } @@ -24,10 +33,23 @@ func (c DockerCollector) Collect(ctx context.Context) (Part, error) { } path := find("docker") if path == "" { - return Part{Docker: &Docker{}}, nil + return Part{Docker: &Docker{ProbeStatus: "absent"}}, nil + } + getenv := c.Getenv + if getenv == nil { + getenv = system.Getenv + } + goos := c.GOOS + if goos == "" { + goos = runtime.GOOS + } + d := &Docker{Installed: true, Context: "local-default", ProbeStatus: "unknown", APIOverride: getenv("DOCKER_API_VERSION")} + part := Part{Docker: d} + if err := c.contextMetadata(d, getenv); err != nil { + part.Errors = append(part.Errors, CollectorError{Collector: "docker", Message: "selected Docker context metadata could not be inspected"}) } host := "unix:///var/run/docker.sock" - if runtime.GOOS == "windows" { + if goos == "windows" { host = "npipe:////./pipe/docker_engine" } out, err := run(ctx, path, "--host", host, "version", "--format", "{{json .}}") @@ -35,21 +57,101 @@ func (c DockerCollector) Collect(ctx context.Context) (Part, error) { return Part{}, ctx.Err() } var version struct { - Client *struct{ Version string } - Server *struct{ Version string } + Client *struct{ Version, ApiVersion, MinAPIVersion, DefaultAPIVersion string } + Server *struct{ Version, ApiVersion, MinAPIVersion string } } if json.Unmarshal(out, &version) != nil || version.Client == nil { if ctx.Err() != nil { return Part{}, ctx.Err() } - if err != nil { - return Part{}, err + part.Errors = append(part.Errors, CollectorError{Collector: "docker", Message: "Docker version returned invalid or unavailable metadata"}) + return part, nil + } + d.CLIVersion = version.Client.Version + d.ClientAPI, d.ClientMinAPI, d.ClientDefaultAPI = version.Client.ApiVersion, version.Client.MinAPIVersion, version.Client.DefaultAPIVersion + if version.Server != nil { + d.ServerAPI, d.ServerMinAPI = version.Server.ApiVersion, version.Server.MinAPIVersion + } + if err == nil && version.Server != nil { + d.DaemonReachable, d.ProbeStatus = true, "available" + } else { + var toolError *system.ToolError + detail := "" + if errors.As(err, &toolError) { + detail = strings.ToLower(toolError.Detail) + } + switch { + case strings.Contains(detail, "permission denied"), strings.Contains(detail, "access is denied"), strings.Contains(detail, "access denied"): + d.ProbeStatus = "denied" + case strings.Contains(detail, "minimum supported api"), strings.Contains(detail, "client version") && (strings.Contains(detail, "too old") || strings.Contains(detail, "too new")): + d.ProbeStatus = "unsupported" + case strings.Contains(detail, "cannot connect to the docker daemon"), strings.Contains(detail, "is the docker daemon running"), strings.Contains(detail, "connection refused"), strings.Contains(detail, "no such file or directory"), strings.Contains(detail, "the system cannot find the file specified"): + d.ProbeStatus = "unavailable" } - return Part{}, fmt.Errorf("Docker version returned invalid metadata") + if d.ProbeStatus != "unavailable" { + part.Errors = append(part.Errors, CollectorError{Collector: "docker", Message: "local Docker inspection is " + d.ProbeStatus + "; daemon availability or API bounds are inconclusive"}) + } + } + return part, nil +} + +// Context selection is data only. Never pass this endpoint or an environment +// context name to the CLI; the actual probe above always uses the local default. +func (c DockerCollector) contextMetadata(d *Docker, getenv func(string) string) error { + read, homeDir := c.ReadFile, c.HomeDir + if read == nil { + read = system.ReadSmallFile + } + if homeDir == nil { + homeDir = system.HomeDir + } + if getenv("DOCKER_CONFIG") != "" { + return fmt.Errorf("alternate Docker configuration directory is unsupported") + } + if name := getenv("DOCKER_CONTEXT"); name != "" { + d.SelectedContext = name + } else if host := getenv("DOCKER_HOST"); host != "" { + d.SelectedContext, d.SelectedHost = "environment", host + return nil + } + home, err := homeDir() + if err != nil { + return err + } + if d.SelectedContext == "" { + data, readErr := read(filepath.Join(home, ".docker", "config.json")) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return readErr + } + if readErr == nil { + var config struct { + CurrentContext string `json:"currentContext"` + } + if len(data) > 1<<20 || json.Unmarshal(data, &config) != nil { + return fmt.Errorf("invalid Docker context configuration") + } + d.SelectedContext = config.CurrentContext + } + } + if d.SelectedContext == "" || d.SelectedContext == "default" { + d.SelectedContext = "default" + return nil + } + if len(d.SelectedContext) > 256 { + return fmt.Errorf("Docker context name too long") + } + digest := fmt.Sprintf("%x", sha256.Sum256([]byte(d.SelectedContext))) + data, err := read(filepath.Join(home, ".docker", "contexts", "meta", digest, "meta.json")) + if err != nil { + return err + } + var metadata struct { + Name string + Endpoints struct{ Docker struct{ Host string } } } - d := &Docker{Installed: true, Context: "local-default", DaemonReachable: err == nil && version.Server != nil} - if version.Client != nil { - d.CLIVersion = version.Client.Version + if len(data) > 1<<20 || json.Unmarshal(data, &metadata) != nil || metadata.Name != d.SelectedContext { + return fmt.Errorf("invalid Docker context metadata") } - return Part{Docker: d}, nil + d.SelectedHost = metadata.Endpoints.Docker.Host + return nil } diff --git a/internal/collector/git.go b/internal/collector/git.go index f70dc55..5e69a16 100644 --- a/internal/collector/git.go +++ b/internal/collector/git.go @@ -2,6 +2,7 @@ package collector import ( "context" + "fmt" "github.com/shellghostt/envdoctor/internal/system" "strings" ) @@ -28,9 +29,10 @@ func (c GitCollector) Collect(ctx context.Context) (Part, error) { if err != nil { return Part{}, err } - g := &Git{Version: strings.TrimSpace(strings.TrimPrefix(string(version), "git version "))} + g := &Git{Version: strings.TrimSpace(strings.TrimPrefix(string(version), "git version ")), IdentityChecked: true} for _, key := range []string{"user.name", "user.email", "core.autocrlf"} { - out, err := run(ctx, path, "config", "--get", key) + args := []string{"config", "--null", "--show-origin", "--get", key} + out, err := run(ctx, path, args...) if ctx.Err() != nil { return Part{}, ctx.Err() } @@ -40,14 +42,40 @@ func (c GitCollector) Collect(ctx context.Context) (Part, error) { } return Part{}, err } - value := strings.TrimSpace(string(out)) + fields := strings.Split(string(out), "\x00") + if len(fields) != 3 || fields[0] == "" || fields[2] != "" { + return Part{}, fmt.Errorf("Git config returned invalid origin metadata") + } + origin, value := fields[0], strings.TrimSpace(fields[1]) + // Older Git versions lack bool-or-str. Read the input mode literally, + // otherwise use the longstanding bool type to canonicalize aliases. + if key == "core.autocrlf" && value != "input" { + out, err = run(ctx, path, "config", "--type=bool", "--null", "--show-origin", "--get", "core.autocrlf") + if ctx.Err() != nil { + return Part{}, ctx.Err() + } + if err != nil { + return Part{}, err + } + fields = strings.Split(string(out), "\x00") + if len(fields) != 3 || fields[0] == "" || fields[2] != "" { + return Part{}, fmt.Errorf("Git config returned invalid boolean origin metadata") + } + origin, value = fields[0], strings.TrimSpace(fields[1]) + } switch key { case "user.name": g.UserName = &value + g.UserNameOrigin = origin case "user.email": g.UserEmail = &value + g.UserEmailOrigin = origin case "core.autocrlf": + if value != "true" && value != "false" && value != "input" { + return Part{}, fmt.Errorf("Git core.autocrlf is unsupported or invalid") + } g.Autocrlf = value + g.AutocrlfOrigin = origin } } return Part{Git: g}, nil diff --git a/internal/collector/m3_test.go b/internal/collector/m3_test.go new file mode 100644 index 0000000..ca59978 --- /dev/null +++ b/internal/collector/m3_test.go @@ -0,0 +1,413 @@ +package collector_test + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/shellghostt/envdoctor/internal/collector" + "github.com/shellghostt/envdoctor/internal/system" +) + +func putFile(t *testing.T, path, data string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0755); err != nil { + t.Fatal(err) + } +} + +func TestProxyCollectionIsOfflineRedactedAndBounded(t *testing.T) { + values := map[string]string{"HTTP_PROXY": "http://user:secret@PROXY.invalid:8080/private?token=secret", "HTTPS_PROXY": "http://other:secret@proxy.invalid:8080", "ALL_PROXY": "socks5://proxy.invalid:1080", "NO_PROXY": "private.internal"} + getenv := func(k string) string { return values[k] } + calls := 0 + c := collector.ProxyCollector{Getenv: getenv, Probe: func(_ context.Context, host string, port int) (string, error) { + calls++ + if host != "proxy.invalid" || port != 8080 && port != 1080 { + t.Fatalf("probe contacted an unexpected target: %s:%d", host, port) + } + return "reachable", nil + }} + part, err := c.Collect(context.Background()) + if err != nil || calls != 0 || len(part.Proxies) != 3 || !part.NoProxyConfigured { + t.Fatalf("default proxy collection: %+v calls=%d err=%v", part, calls, err) + } + raw, _ := json.Marshal(part.Proxies) + for _, forbidden := range []string{"secret", "user", "private", "token"} { + if strings.Contains(string(raw), forbidden) { + t.Fatalf("proxy credentials/path leaked: %s", raw) + } + } + for _, p := range part.Proxies { + if p.Probe != "untested" { + t.Fatal("offline collection invented probe evidence") + } + } + c.ProbeNetwork = true + part, err = c.Collect(context.Background()) + if err != nil || calls != 2 || len(part.Errors) != 0 { + t.Fatalf("duplicate endpoints were not bounded: calls=%d part=%+v err=%v", calls, part, err) + } + for _, p := range part.Proxies { + if p.Probe != "reachable" { + t.Fatalf("probe evidence: %+v", p) + } + } +} + +func TestProxyFailuresStayUnknownAndCancellationStopsFurtherDials(t *testing.T) { + for _, failure := range []error{os.ErrPermission, context.DeadlineExceeded, errors.New("DNS failed with https://user:FAKE_TOKEN@host/path")} { + c := collector.ProxyCollector{ProbeNetwork: true, Getenv: func(k string) string { + if k == "HTTPS_PROXY" { + return "https://proxy.invalid" + } + return "" + }, Probe: func(context.Context, string, int) (string, error) { return "unreachable", failure }} + part, err := c.Collect(context.Background()) + if err != nil || len(part.Errors) != 1 || part.Proxies[0].Probe != "unknown" || strings.Contains(part.Errors[0].Message, "FAKE_TOKEN") { + t.Fatalf("failed probe became conclusive: %+v %v", part, err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + calls := 0 + c := collector.ProxyCollector{ProbeNetwork: true, Getenv: func(k string) string { + if strings.HasSuffix(k, "PROXY") { + return "http://" + strings.ToLower(k) + ".invalid:8080" + } + return "" + }, Probe: func(ctx context.Context, _ string, _ int) (string, error) { + calls++ + <-ctx.Done() + return "unknown", ctx.Err() + }} + start := time.Now() + _, _ = c.Collect(ctx) + if calls != 1 || time.Since(start) > time.Second { + t.Fatalf("cancellation did not stop probes: calls=%d elapsed=%s", calls, time.Since(start)) + } + ctx, cancel = context.WithCancel(context.Background()) + cancel() + if _, err := c.Collect(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled collector: %v", err) + } +} + +func TestProxyValidationAndEnvironmentPrecedence(t *testing.T) { + for _, value := range []string{"proxy.invalid", "http://", "http://proxy.invalid:0", "http://proxy.invalid:65536", "http://proxy.invalid:", "ftp://user:FAKE_SECRET@proxy.invalid", "http://proxy.invalid:%zz", "http://::1", "http://[fe80::1%25zone]"} { + calls := 0 + c := collector.ProxyCollector{ProbeNetwork: true, Getenv: func(k string) string { + if k == "HTTP_PROXY" { + return value + } + return "" + }, Probe: func(context.Context, string, int) (string, error) { calls++; return "reachable", nil }} + part, err := c.Collect(context.Background()) + if err != nil || calls != 0 || len(part.Errors) != 1 || part.Proxies[0].Probe != "invalid" || part.Proxies[0].URL != "[invalid]" { + t.Fatalf("invalid URL probed: %q %+v %v", value, part, err) + } + } + c := collector.ProxyCollector{Getenv: func(k string) string { + switch k { + case "HTTP_PROXY": + return "http://upper.invalid" + case "http_proxy": + return "http://lower.invalid" + case "https_proxy": + return "https://[::1]" + } + return "" + }} + part, err := c.Collect(context.Background()) + if err != nil || part.Proxies[0].Host != "upper.invalid" || part.Proxies[1].URL != "https://[::1]:443" { + t.Fatalf("proxy precedence/default ports: %+v %v", part, err) + } +} + +func TestDockerLocalProbeKeepsRemoteContextAsMetadata(t *testing.T) { + for _, goos := range []string{"windows", "linux", "darwin"} { + t.Run(goos, func(t *testing.T) { + root := t.TempDir() + putFile(t, filepath.Join(root, ".docker", "config.json"), `{"currentContext":"remote","auths":{"ignored":{"auth":"FAKE_TOKEN"}}}`) + digest := fmt.Sprintf("%x", sha256.Sum256([]byte("remote"))) + putFile(t, filepath.Join(root, ".docker", "contexts", "meta", digest, "meta.json"), `{"Name":"remote","Endpoints":{"docker":{"Host":"tcp://user:FAKE_TOKEN@remote.invalid:2376"}}}`) + calls := 0 + c := collector.DockerCollector{GOOS: goos, Getenv: func(k string) string { + if k == "DOCKER_API_VERSION" { + return "1.53" + } + return "" + }, HomeDir: func() (string, error) { return root, nil }, FindTool: func(string) string { return "trusted-docker" }, RunTool: func(_ context.Context, _ string, args ...string) ([]byte, error) { + calls++ + host := "unix:///var/run/docker.sock" + if goos == "windows" { + host = "npipe:////./pipe/docker_engine" + } + if !reflect.DeepEqual(args, []string{"--host", host, "version", "--format", "{{json .}}"}) { + t.Fatalf("context selected a remote probe: %v", args) + } + return []byte(`{"Client":{"Version":"30.0.0","ApiVersion":"1.48","DefaultAPIVersion":"1.53"},"Server":{"Version":"26.0.0","ApiVersion":"1.48","MinAPIVersion":"1.44"}}`), nil + }} + part, err := c.Collect(context.Background()) + if err != nil || len(part.Errors) > 0 || calls != 1 || part.Docker.SelectedContext != "remote" || part.Docker.ServerMinAPI != "1.44" || part.Docker.APIOverride != "1.53" { + t.Fatalf("Docker evidence: %+v %v", part, err) + } + snapshot := collector.Snapshot{Docker: part.Docker} + snapshot.Redact(root) + if strings.Contains(snapshot.Docker.SelectedHost, "FAKE_TOKEN") { + t.Fatal("Docker endpoint credentials leaked") + } + }) + } +} + +func TestDockerHostOverrideAndInspectionStates(t *testing.T) { + for _, tc := range []struct { + detail, status string + degraded bool + }{ + {"Cannot connect to the Docker daemon. Is the docker daemon running?", "unavailable", false}, + {"permission denied connecting to local engine", "denied", true}, + {"client version 1.20 is too old. Minimum supported API version is 1.44", "unsupported", true}, + {"unexpected internal error https://user:FAKE_TOKEN@host/path", "unknown", true}, + } { + t.Run(tc.status, func(t *testing.T) { + c := collector.DockerCollector{Getenv: func(k string) string { + if k == "DOCKER_HOST" { + return "tcp://remote.invalid:2375" + } + return "" + }, FindTool: func(string) string { return "trusted" }, RunTool: func(_ context.Context, _ string, args ...string) ([]byte, error) { + if strings.Contains(strings.Join(args, " "), "remote.invalid") { + t.Fatal("host override reached probe") + } + return []byte(`{"Client":{"Version":"26.0.0","ApiVersion":"1.45"}}`), &system.ToolError{Err: toolExit(1), Detail: tc.detail} + }} + part, err := c.Collect(context.Background()) + if err != nil || part.Docker.ProbeStatus != tc.status || (len(part.Errors) > 0) != tc.degraded { + t.Fatalf("Docker state: %+v %v", part, err) + } + for _, e := range part.Errors { + if strings.Contains(e.Message, "FAKE_TOKEN") { + t.Fatal("raw subprocess stderr was published") + } + } + }) + } +} + +func TestGitEffectiveValuesIncludeConfigOrigins(t *testing.T) { + c := collector.GitCollector{FindTool: func(string) string { return "trusted-git" }, RunTool: func(_ context.Context, _ string, args ...string) ([]byte, error) { + if args[0] == "--version" { + return []byte("git version 2.45.0\n"), nil + } + if !strings.Contains(strings.Join(args, " "), "--null --show-origin --get") { + t.Fatalf("unbounded/unattributed Git invocation: %v", args) + } + key := args[len(args)-1] + value := map[string]string{"user.name": "Developer", "user.email": "dev@example.invalid", "core.autocrlf": "input"}[key] + return []byte("file:/home/developer/.gitconfig\x00" + value + "\x00"), nil + }} + part, err := c.Collect(context.Background()) + if err != nil || !part.Git.IdentityChecked || *part.Git.UserName != "Developer" || part.Git.UserEmailOrigin != "file:/home/developer/.gitconfig" || part.Git.Autocrlf != "input" { + t.Fatalf("Git metadata: %+v %v", part, err) + } +} + +func TestGitBooleanAliasesUsePortableTypedConfigQuery(t *testing.T) { + c := collector.GitCollector{FindTool: func(string) string { return "trusted-git" }, RunTool: func(_ context.Context, _ string, args ...string) ([]byte, error) { + if args[0] == "--version" { + return []byte("git version 2.39.0"), nil + } + if strings.Contains(strings.Join(args, " "), "bool-or-str") { + return nil, toolExit(128) + } + key := args[len(args)-1] + if key != "core.autocrlf" { + return nil, toolExit(1) + } + value := "yes" + if strings.Contains(strings.Join(args, " "), "--type=bool") { + value = "true" + } + return []byte("file:/fixture/config\x00" + value + "\x00"), nil + }} + part, err := c.Collect(context.Background()) + if err != nil || part.Git.Autocrlf != "true" || part.Git.AutocrlfOrigin != "file:/fixture/config" { + t.Fatalf("portable Git boolean inspection: %+v %v", part, err) + } +} + +func TestDependenciesRequireExplicitManifestAndReliableAbsence(t *testing.T) { + root := t.TempDir() + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(bin, 0755); err != nil { + t.Fatal(err) + } + getenv := func(k string) string { + if k == "PATH" { + return bin + } + return "" + } + c := collector.DependencyCollector{WorkingDir: func() (string, error) { return root, nil }, Getenv: getenv} + putFile(t, filepath.Join(root, "main.c"), "int main() { return 0; }") + part, err := c.Collect(context.Background()) + if err != nil || len(part.Dependencies.Requirements) != 0 { + t.Fatalf("language file manufactured dependencies: %+v %v", part, err) + } + putFile(t, filepath.Join(root, collector.RequirementsFile), `{"schemaVersion":1,"requirements":[{"kind":"tool","name":"cmake"}]}`) + part, err = c.Collect(context.Background()) + if err != nil || part.Dependencies.Requirements[0].Status != "missing" { + t.Fatalf("explicit absence: %+v %v", part, err) + } + name := "cmake" + if runtime.GOOS == "windows" { + name += ".exe" + } + putFile(t, filepath.Join(bin, name), "this file must never execute") + part, err = c.Collect(context.Background()) + if err != nil || part.Dependencies.Requirements[0].Status != "present" { + t.Fatalf("metadata presence: %+v %v", part, err) + } + c.Stat = func(string) (os.FileInfo, error) { return nil, os.ErrPermission } + part, err = c.Collect(context.Background()) + if err != nil || len(part.Errors) == 0 || part.Dependencies.Requirements[0].Status != "unknown" { + t.Fatalf("denial became absence: %+v %v", part, err) + } + c.Stat = nil + sep := ":" + if runtime.GOOS == "windows" { + sep = ";" + } + c.Getenv = func(k string) string { + if k == "PATH" { + return filepath.Join(root, "absent-bin") + sep + } + return "" + } + part, err = c.Collect(context.Background()) + if err != nil || part.Dependencies.Requirements[0].Status != "unknown" { + t.Fatalf("empty PATH component became absence: %+v %v", part, err) + } + c.Getenv = func(k string) string { + if k == "PATH" { + return "." + } + return "" + } + part, err = c.Collect(context.Background()) + if err != nil || part.Dependencies.Requirements[0].Status != "unknown" { + t.Fatalf("relative PATH became absence: %+v %v", part, err) + } +} + +func TestOpenSSLDeclaredPrefixNeedsHeaderAndBothLibraries(t *testing.T) { + for _, goos := range []string{"windows", "linux", "darwin"} { + t.Run(goos, func(t *testing.T) { + root := t.TempDir() + prefix := filepath.Join(root, "openssl") + putFile(t, filepath.Join(root, collector.RequirementsFile), `{"schemaVersion":1,"requirements":[{"kind":"library","name":"openssl","path":"openssl"}]}`) + c := collector.DependencyCollector{GOOS: goos, Arch: "amd64", WorkingDir: func() (string, error) { return root, nil }, Getenv: func(string) string { return "" }} + part, err := c.Collect(context.Background()) + if err != nil || part.Dependencies.Requirements[0].Status != "missing" { + t.Fatalf("absent declared header: %+v %v", part, err) + } + putFile(t, filepath.Join(prefix, "include", "openssl", "ssl.h"), "header") + ext := ".so" + if goos == "windows" { + ext = ".lib" + } + if goos == "darwin" { + ext = ".dylib" + } + putFile(t, filepath.Join(prefix, "lib", "libssl"+ext), "library") + part, err = c.Collect(context.Background()) + if err != nil || part.Dependencies.Requirements[0].Status != "missing" { + t.Fatalf("missing crypto was ignored: %+v %v", part, err) + } + putFile(t, filepath.Join(prefix, "lib", "libcrypto"+ext), "library") + part, err = c.Collect(context.Background()) + if err != nil || part.Dependencies.Requirements[0].Status != "present" { + t.Fatalf("complete development prefix: %+v %v", part, err) + } + c.Stat = func(string) (os.FileInfo, error) { return nil, os.ErrPermission } + part, err = c.Collect(context.Background()) + if err != nil || len(part.Errors) == 0 || part.Dependencies.Requirements[0].Status != "unknown" { + t.Fatalf("denied prefix: %+v %v", part, err) + } + }) + } +} + +func TestProjectParsingIsBoundedStrictAndNeverRunsScripts(t *testing.T) { + root := t.TempDir() + c := collector.DependencyCollector{WorkingDir: func() (string, error) { return root, nil }, Getenv: func(string) string { return "" }} + for _, body := range []string{`{"schemaVersion":2}`, `{"schemaVersion":1,"surprise":true}`, `{"schemaVersion":1} {}`, `{"schemaVersion":1,"requirements":[{"kind":"tool","name":"gcc","execute":"bad"}]}`, `{"schemaVersion":1,"gitAutocrlf":"maybe"}`, strings.Repeat(" ", 1<<20+1)} { + putFile(t, filepath.Join(root, collector.RequirementsFile), body) + if _, err := c.Collect(context.Background()); err == nil { + t.Fatalf("invalid/bounded project metadata accepted: %.80s", body) + } + } + if err := os.Remove(filepath.Join(root, collector.RequirementsFile)); err != nil { + t.Fatal(err) + } + putFile(t, filepath.Join(root, "package.json"), `{"packageManager":"pnpm@9.0.0+sha512.integrity","scripts":{"install":"must never execute"}}`) + putFile(t, filepath.Join(root, "pnpm-lock.yaml"), "data only") + c.PackageManager = "npm@10.0.0" + part, err := c.Collect(context.Background()) + if err != nil || part.Dependencies.Package.Declared.Version != "9.0.0" || part.Dependencies.Package.Runner.Name != "npm" || len(part.Dependencies.Package.Lockfiles) != 1 { + t.Fatalf("package evidence: %+v %v", part, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := c.Collect(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("project collection ignored cancellation: %v", err) + } +} + +func TestUnrecognizedPythonTextShimIsInconclusive(t *testing.T) { + root := t.TempDir() + bin := filepath.Join(root, "bin") + env := filepath.Join(root, "venv") + name := "python" + sep := ":" + if runtime.GOOS == "windows" { + name += ".exe" + sep = ";" + } + putFile(t, filepath.Join(bin, name), "#!/bin/sh\necho never-run\n") + putFile(t, filepath.Join(env, "pyvenv.cfg"), "version = 3.12.0\n") + c := collector.RuntimeCollector{GOOS: runtime.GOOS, Getenv: func(k string) string { + switch k { + case "PATH": + return bin + sep + case "VIRTUAL_ENV": + return env + } + return "" + }} + part, err := c.Collect(context.Background()) + if err != nil { + t.Fatal(err) + } + if part.PythonEnvironment == nil || !part.PythonEnvironment.MetadataKnown { + t.Fatal("explicit readable environment was not recorded") + } + for _, r := range part.Runtimes { + if r.Active && r.Resolution != "unknown" { + t.Fatalf("unknown shim became an interpreter: %+v", r) + } + } +} diff --git a/internal/collector/proxy.go b/internal/collector/proxy.go new file mode 100644 index 0000000..67af256 --- /dev/null +++ b/internal/collector/proxy.go @@ -0,0 +1,125 @@ +package collector + +import ( + "context" + "fmt" + "net" + "net/url" + "strconv" + "strings" + "time" + + "github.com/shellghostt/envdoctor/internal/system" +) + +const ProxyProbeTimeout = time.Second + +type ProxyCollector struct { + ProbeNetwork bool + Getenv func(string) string + Probe func(context.Context, string, int) (string, error) +} + +func (ProxyCollector) Name() string { return "proxy" } + +func (c ProxyCollector) Collect(ctx context.Context) (Part, error) { + getenv, probe := c.Getenv, c.Probe + if getenv == nil { + getenv = system.Getenv + } + if probe == nil { + probe = system.ProbeProxy + } + part := Part{Proxies: []Proxy{}, NoProxyConfigured: getenv("NO_PROXY") != "" || getenv("no_proxy") != ""} + probeCtx, cancel := context.WithTimeout(ctx, ProxyProbeTimeout) + defer cancel() + type result struct { + status string + err error + } + results := map[string]result{} + for _, variable := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"} { + if err := ctx.Err(); err != nil { + return Part{}, err + } + value := getenv(variable) + if value == "" { + value = getenv(strings.ToLower(variable)) + } + if value == "" { + continue + } + p, err := proxyEndpoint(variable, value) + if err != nil { + part.Proxies = append(part.Proxies, Proxy{Variable: variable, URL: "[invalid]", Probe: "invalid"}) + part.Errors = append(part.Errors, CollectorError{Collector: "proxy", Message: variable + " has an invalid or unsupported proxy URL"}) + continue + } + if c.ProbeNetwork { + key := net.JoinHostPort(p.Host, strconv.Itoa(p.Port)) + r, ok := results[key] + if !ok { + if probeCtx.Err() != nil { + r = result{"unknown", probeCtx.Err()} + } else { + r.status, r.err = probe(probeCtx, p.Host, p.Port) + if probeCtx.Err() != nil { + r = result{"unknown", probeCtx.Err()} + } + if r.status != "reachable" && r.status != "unreachable" && r.status != "unknown" { + r = result{"unknown", fmt.Errorf("proxy probe returned unsupported evidence")} + } + } + results[key] = r + } + p.Probe = r.status + if r.err != nil || r.status == "unknown" { + p.Probe = "unknown" + part.Errors = append(part.Errors, CollectorError{Collector: "proxy", Message: variable + " reachability is unknown (probe failed, timed out or was canceled)"}) + } + } + part.Proxies = append(part.Proxies, p) + } + return part, nil +} + +func proxyEndpoint(variable, value string) (Proxy, error) { + if len(value) > 8192 { + return Proxy{}, fmt.Errorf("proxy URL too long") + } + u, err := url.Parse(value) + if err != nil || u.Hostname() == "" || u.Opaque != "" { + return Proxy{}, fmt.Errorf("invalid proxy URL") + } + host := strings.ToLower(u.Hostname()) + if strings.Contains(host, ":") && net.ParseIP(host) == nil { + return Proxy{}, fmt.Errorf("invalid or unsupported proxy host") + } + if !strings.Contains(host, ":") { + for _, ch := range host { + if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyz0123456789-._", ch) { + return Proxy{}, fmt.Errorf("unsupported proxy hostname") + } + } + } + port := 0 + switch strings.ToLower(u.Scheme) { + case "http": + port = 80 + case "https": + port = 443 + case "socks5", "socks5h": + port = 1080 + default: + return Proxy{}, fmt.Errorf("unsupported proxy scheme") + } + if u.Port() != "" { + port, err = strconv.Atoi(u.Port()) + if err != nil || port < 1 || port > 65535 { + return Proxy{}, fmt.Errorf("invalid proxy port") + } + } else if strings.HasSuffix(u.Host, ":") { + return Proxy{}, fmt.Errorf("empty proxy port") + } + return Proxy{Variable: variable, URL: strings.ToLower(u.Scheme) + "://" + net.JoinHostPort(host, strconv.Itoa(port)), Host: host, Port: port, Probe: "untested"}, nil +} diff --git a/internal/collector/redact.go b/internal/collector/redact.go index be1435e..42ed1ab 100644 --- a/internal/collector/redact.go +++ b/internal/collector/redact.go @@ -1,6 +1,9 @@ package collector import ( + "net/url" + "reflect" + "regexp" "strings" ) @@ -9,35 +12,108 @@ import ( // is meant to be shareable in a bug report. // // The replacement is `~`, which stays actionable: the reader still sees which -// directory is involved. Nothing else is rewritten. +// directory is involved. Credential-bearing URLs are also scrubbed. func (s *Snapshot) Redact(home string) { - if s == nil || home == "" { + if s == nil { return } - home = strings.TrimRight(home, `\/`) - if home == "" { - return - } - - if s.Shell != nil { - s.Shell.Path = RedactText(s.Shell.Path, home) - } - if s.Path != nil { - for i := range s.Path.Entries { - s.Path.Entries[i].Raw = RedactText(s.Path.Entries[i].Raw, home) + // Preserve documented identity visibility while scrubbing URLs in those values. + var name, email string + if s.Git != nil { + if s.Git.UserName != nil { + name = *s.Git.UserName + } + if s.Git.UserEmail != nil { + email = *s.Git.UserEmail } } - for i := range s.Runtimes { - s.Runtimes[i].Path = RedactText(s.Runtimes[i].Path, home) + redactValue(reflect.ValueOf(s), home) + if s.Git != nil { + if s.Git.UserName != nil { + *s.Git.UserName = RedactText(name, "") + } + if s.Git.UserEmail != nil { + *s.Git.UserEmail = RedactText(email, "") + } } - for i := range s.Errors { - s.Errors[i].Message = RedactText(s.Errors[i].Message, home) +} + +// Walking the typed snapshot prevents a newly added string field from silently +// bypassing redaction. Unexported time.Time internals are never changed. +func redactValue(value reflect.Value, home string) { + switch value.Kind() { + case reflect.Pointer: + if !value.IsNil() { + redactValue(value.Elem(), home) + } + case reflect.Struct: + for i := 0; i < value.NumField(); i++ { + if value.Field(i).CanSet() { + redactValue(value.Field(i), home) + } + } + case reflect.Slice: + for i := 0; i < value.Len(); i++ { + redactValue(value.Index(i), home) + } + case reflect.String: + if value.CanSet() { + value.SetString(RedactText(value.String(), home)) + } } } +var credentialURL = regexp.MustCompile(`(?i)[a-z][a-z0-9+.-]*://[^\s"'<>]+`) + +// filepath.Join collapses URI double slashes in malformed path inputs. Scrub +// those credentials too, without treating a Windows drive letter as a scheme. +var collapsedURL = regexp.MustCompile(`(?i)[a-z][a-z0-9+.-]+:/[^/\s"'<>][^\s"'<>]*`) + +func redactURLs(value, home string) string { + value = collapsedURL.ReplaceAllStringFunc(value, func(raw string) string { + scheme, rest, _ := strings.Cut(raw, ":/") + u, err := url.Parse(scheme + "://" + rest) + if err != nil { + return "[redacted URL]" + } + if u.User == nil && u.RawQuery == "" && u.Fragment == "" { + return raw + } + return u.Scheme + "://" + u.Host + }) + value = credentialURL.ReplaceAllStringFunc(value, func(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "[redacted URL]" + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + u.User, u.RawQuery, u.Fragment = nil, "", "" + u.Path, u.RawPath, u.ForceQuery = "", "", false + } else { + path := u.Path + if strings.HasPrefix(path, "/") && len(home) >= 2 && home[1] == ':' { + path = path[1:] + } + redacted := RedactHome(path, home) + if redacted == path { + return raw + } + u.Path, u.RawPath = "/"+redacted, "" + } + return u.String() + }) + return strings.Map(func(ch rune) rune { + if ch < 32 && ch != '\n' && ch != '\t' || ch == 127 { + return '\uFFFD' + } + return ch + }, value) +} + // 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 { + value = redactURLs(value, home) home = strings.TrimRight(home, `\/`) if home == "" { return value diff --git a/internal/collector/redact_m3_test.go b/internal/collector/redact_m3_test.go new file mode 100644 index 0000000..f74357f --- /dev/null +++ b/internal/collector/redact_m3_test.go @@ -0,0 +1,23 @@ +package collector_test + +import ( + "strings" + "testing" + + "github.com/shellghostt/envdoctor/internal/collector" +) + +func TestRedactionCoversHomePathsInsideURLsAndCredentialURLPaths(t *testing.T) { + for _, tc := range []struct{ value, home string }{ + {"unix:///home/fixture-user/.docker/engine.sock", "/home/fixture-user"}, + {"file:///C:/Users/fixture-user/config", `C:\Users\fixture-user`}, + {"error https://user:FAKE_SECRET@proxy.invalid/FAKE_PATH?token=FAKE_QUERY", ""}, + {"ssh://user:FAKE_SECRET@remote.invalid/home/fixture-user/socket", "/home/fixture-user"}, + {"open https:/user:FAKE_SECRET@remote.invalid/FAKE_PATH?token=FAKE_QUERY", ""}, + } { + redacted := collector.RedactText(tc.value, tc.home) + if strings.Contains(redacted, "fixture-user") || strings.Contains(redacted, "FAKE_") || strings.Contains(redacted, "user:") { + t.Fatalf("URL redaction leaked input: %s", redacted) + } + } +} diff --git a/internal/collector/runtime.go b/internal/collector/runtime.go index 2987b7d..89111cc 100644 --- a/internal/collector/runtime.go +++ b/internal/collector/runtime.go @@ -2,6 +2,7 @@ package collector import ( "context" + "errors" "github.com/shellghostt/envdoctor/internal/system" "os" "path/filepath" @@ -42,8 +43,18 @@ func (c RuntimeCollector) Collect(ctx context.Context) (Part, error) { out := []Runtime{} seen := map[string]int{} active := map[string]bool{} + var inspectionErr error expected := map[string]string{} + var environment *PythonEnvironment if v := getenv("VIRTUAL_ENV"); v != "" { + environment = &PythonEnvironment{Root: v} + if filepath.IsAbs(v) { + data, readErr := system.ReadSmallFile(filepath.Join(v, "pyvenv.cfg")) + canonical, resolveErr := system.ResolvePath(v) + if readErr == nil && resolveErr == nil && knownVenvMetadata(data) { + environment.CanonicalRoot, environment.MetadataKnown = canonical, true + } + } sub := "bin" if goos == "windows" { sub = "Scripts" @@ -56,21 +67,37 @@ func (c RuntimeCollector) Collect(ctx context.Context) (Part, error) { 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 { + packaged := packagedRuntimePath(path, goos) + resolved := path + var err error + if !packaged { + resolved, err = filepath.EvalSymlinks(path) + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + inspectionErr = err + } + missing := errors.Is(err, os.ErrNotExist) + if err != nil { resolved = path } venvRoot := filepath.Dir(filepath.Dir(path)) var venvData []byte - if name == "python" { - venvData, _ = system.ReadSmallFile(filepath.Join(venvRoot, "pyvenv.cfg")) + if name == "python" && !packaged { + var readErr error + venvData, readErr = system.ReadSmallFile(filepath.Join(venvRoot, "pyvenv.cfg")) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + inspectionErr = readErr + } } 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 + canonicalRoot, rootErr := system.ResolvePath(venvRoot) + if rootErr != nil { + canonicalRoot = venvRoot + } + key += "|" + canonicalRoot } if goos == "windows" { key = strings.ToLower(key) @@ -97,7 +124,7 @@ func (c RuntimeCollector) Collect(ctx context.Context) (Part, error) { version = matches[len(matches)-1][1] } } - if version == "" && !missing { + if version == "" && !missing && !packaged { version = system.FileVersion(resolved) } if version == "" && len(venvData) > 0 { @@ -108,8 +135,28 @@ func (c RuntimeCollector) Collect(ctx context.Context) (Part, error) { } } } + resolution := "known" + if missing || err != nil || packaged || strings.Contains(lower, "/shims/") { + resolution = "unknown" + } + if resolution == "known" && name == "python" { + native, nativeErr := system.NativeExecutable(resolved, goos) + if !native { + resolution = "unknown" + } + if nativeErr != nil { + inspectionErr = nativeErr + } + } + environmentRoot := "" + if knownVenvMetadata(venvData) { + environmentRoot, _ = system.ResolvePath(venvRoot) + if environmentRoot == "" { + resolution = "unknown" + } + } seen[key] = len(out) - out = append(out, Runtime{Name: name, Version: version, Path: path, Manager: manager, Active: isActive, Expected: isExpected, Missing: missing}) + out = append(out, Runtime{Name: name, Version: version, Path: path, Manager: manager, Active: isActive, Expected: isExpected, Missing: missing, ResolvedPath: resolved, Resolution: resolution, EnvironmentRoot: environmentRoot}) } for _, dir := range strings.Split(getenv("PATH"), sep) { if err := ctx.Err(); err != nil { @@ -122,10 +169,17 @@ func (c RuntimeCollector) Collect(ctx context.Context) (Part, error) { for _, alias := range aliases { p := filepath.Join(dir, alias+suffix) info, err := os.Lstat(p) + packaged := packagedRuntimePath(p, goos) + if err != nil && !errors.Is(err, os.ErrNotExist) && !packaged { + inspectionErr = err + } if err != nil || info.IsDir() { continue } resolvedInfo, statErr := os.Stat(p) + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) && !packaged { + inspectionErr = statErr + } usable := statErr == nil && resolvedInfo.Mode().IsRegular() if goos != "windows" && usable && resolvedInfo.Mode()&0111 == 0 { continue @@ -145,5 +199,25 @@ func (c RuntimeCollector) Collect(ctx context.Context) (Part, error) { add(p, name, false, true) } } - return Part{Runtimes: out}, nil + part := Part{Runtimes: out, PythonEnvironment: environment} + if inspectionErr != nil { + part.Errors = []CollectorError{{Collector: "runtimes", Message: "runtime metadata inspection failed or was denied"}} + } + return part, nil +} + +// App execution aliases and protected MSIX files cannot establish interpreter +// resolution through ordinary filesystem APIs. Preserve their unknown state. +func packagedRuntimePath(path, goos string) bool { + return goos == "windows" && strings.Contains(strings.ToLower(filepath.ToSlash(path)), "/windowsapps/") +} + +func knownVenvMetadata(data []byte) bool { + for _, line := range strings.Split(string(data), "\n") { + key, value, ok := strings.Cut(line, "=") + if ok && (strings.TrimSpace(key) == "home" && strings.TrimSpace(value) != "" || strings.TrimSpace(key) == "version" && numericVersion.MatchString(strings.TrimSpace(value))) { + return true + } + } + return false } diff --git a/internal/collector/snapshot.go b/internal/collector/snapshot.go index 3069e56..505b3ff 100644 --- a/internal/collector/snapshot.go +++ b/internal/collector/snapshot.go @@ -10,17 +10,21 @@ 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"` - RequestedPorts []int `json:"requestedPorts,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"` + Dependencies *Dependencies `json:"dependencies,omitempty"` + PythonEnvironment *PythonEnvironment `json:"pythonEnvironment,omitempty"` + Proxies []Proxy `json:"proxies,omitempty"` + NoProxyConfigured bool `json:"noProxyConfigured,omitempty"` + Errors []CollectorError `json:"errors,omitempty"` } // Platform describes the operating system the process runs on. @@ -54,13 +58,16 @@ 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"` - Expected bool `json:"expected,omitempty"` - Missing bool `json:"missing,omitempty"` + 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"` + ResolvedPath string `json:"resolvedPath,omitempty"` + Resolution string `json:"resolution,omitempty"` + EnvironmentRoot string `json:"environmentRoot,omitempty"` } // Port is one listening socket. Filled from M2. @@ -72,21 +79,76 @@ type Port struct { Process string `json:"process,omitempty"` } -// Docker records installed CLI/local-default engine state in M2. Full context -// and compatibility inspection remain M3. +// Docker keeps the M2 local-default fields and optional M3 API/context evidence. type Docker struct { - CLIVersion string `json:"cliVersion"` - DaemonReachable bool `json:"daemonReachable"` - Context string `json:"context"` - Installed bool `json:"installed"` + CLIVersion string `json:"cliVersion"` + DaemonReachable bool `json:"daemonReachable"` + Context string `json:"context"` + Installed bool `json:"installed"` + ProbeStatus string `json:"probeStatus,omitempty"` + ClientAPI string `json:"clientApi,omitempty"` + ClientMinAPI string `json:"clientMinApi,omitempty"` + ClientDefaultAPI string `json:"clientDefaultApi,omitempty"` + ServerAPI string `json:"serverApi,omitempty"` + ServerMinAPI string `json:"serverMinApi,omitempty"` + APIOverride string `json:"apiOverride,omitempty"` + SelectedContext string `json:"selectedContext,omitempty"` + SelectedHost string `json:"selectedHost,omitempty"` } // Git is the Git version and identity. Filled from M2. type Git struct { - Version string `json:"version"` - UserName *string `json:"userName"` - UserEmail *string `json:"userEmail"` - Autocrlf string `json:"autocrlf"` + Version string `json:"version"` + UserName *string `json:"userName"` + UserEmail *string `json:"userEmail"` + Autocrlf string `json:"autocrlf"` + IdentityChecked bool `json:"identityChecked,omitempty"` + UserNameOrigin string `json:"userNameOrigin,omitempty"` + UserEmailOrigin string `json:"userEmailOrigin,omitempty"` + AutocrlfOrigin string `json:"autocrlfOrigin,omitempty"` +} + +// Dependencies contains only supported, explicitly declared project evidence. +type Dependencies struct { + ProjectRoot string `json:"projectRoot"` + RequirementsFile string `json:"requirementsFile,omitempty"` + Requirements []Dependency `json:"requirements,omitempty"` + Package *PackageEvidence `json:"package,omitempty"` + GitAutocrlf string `json:"gitAutocrlf,omitempty"` +} + +type Dependency struct { + Kind string `json:"kind"` + Name string `json:"name"` + Path string `json:"path,omitempty"` + Status string `json:"status"` // present, missing or unknown +} + +type PackageManager struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +type PackageEvidence struct { + Manifest string `json:"manifest"` + Declared *PackageManager `json:"declared,omitempty"` + Runner *PackageManager `json:"runner,omitempty"` + Lockfiles []string `json:"lockfiles,omitempty"` +} + +type PythonEnvironment struct { + Root string `json:"root"` + CanonicalRoot string `json:"canonicalRoot,omitempty"` + MetadataKnown bool `json:"metadataKnown"` +} + +// Proxy never contains userinfo, a request path or a query. Probe defaults to untested. +type Proxy struct { + Variable string `json:"variable"` + URL string `json:"url"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Probe string `json:"probe"` } // CollectorError marks a collector that failed or timed out. Its component's diff --git a/internal/collector/tool_test.go b/internal/collector/tool_test.go index 60f073a..2803ac8 100644 --- a/internal/collector/tool_test.go +++ b/internal/collector/tool_test.go @@ -40,8 +40,8 @@ func TestDockerCollectionDistinguishesAbsenceOutageAndBadMetadata(t *testing.T) }} part, err := c.Collect(context.Background()) if tc.fail { - if err == nil { - t.Fatal("invalid metadata accepted") + if err == nil && len(part.Errors) == 0 { + t.Fatal("invalid metadata accepted without degradation") } return } diff --git a/internal/config/config.go b/internal/config/config.go index fb47386..3a10fb4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,7 @@ // Package config holds the resolved options for one EnvDoctor run. // -// There is no configuration file and no environment variable that changes -// behaviour. Options come from command-line flags only, which keeps a shared -// team rule set reviewable: it is the flags in a script, not a hidden file. +// Options come from command-line flags only. Project manifests and environment +// variables supply evidence; they never enable network probes or apply fixes. package config import ( @@ -30,18 +29,31 @@ var ( // Options is the result of parsing the diagnose flags. type Options struct { - JSON bool - Verbose bool - Component string - Rule string - Ports []int - FixtureDir string + JSON bool + Verbose bool + Component string + Rule string + Ports []int + FixtureDir string + ProbeNetwork bool + PackageManager 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 { + if o.ProbeNetwork && o.FixtureDir != "" { + return fmt.Errorf("--probe-network cannot be used with --fixture-dir") + } + if o.PackageManager != "" { + if _, _, err := ParsePackageManager(o.PackageManager); err != nil { + return fmt.Errorf("--package-manager: %w", err) + } + if o.FixtureDir != "" { + return fmt.Errorf("--package-manager cannot be used with --fixture-dir; runner evidence belongs in the snapshot") + } + } for _, p := range o.Ports { if p < 1 || p > 65535 { return fmt.Errorf("--port must be between 1 and 65535") diff --git a/internal/config/manager.go b/internal/config/manager.go new file mode 100644 index 0000000..551b554 --- /dev/null +++ b/internal/config/manager.go @@ -0,0 +1,36 @@ +package config + +import ( + "fmt" + "strings" +) + +// ParsePackageManager accepts an explicit supported runner and optional exact +// numeric version. package.json declarations additionally require the version. +func ParsePackageManager(value string) (string, string, error) { + name, version, hasVersion := strings.Cut(value, "@") + switch name { + case "npm", "pnpm", "yarn", "bun": + default: + return "", "", fmt.Errorf("expected npm, pnpm, yarn or bun, optionally @major.minor.patch") + } + if !hasVersion { + return name, "", nil + } + version, _, _ = strings.Cut(version, "+") + parts := strings.Split(version, ".") + if len(parts) != 3 { + return "", "", fmt.Errorf("version must be an exact major.minor.patch") + } + for _, p := range parts { + if p == "" || len(p) > 9 || len(p) > 1 && p[0] == '0' { + return "", "", fmt.Errorf("version must be an exact major.minor.patch") + } + for _, ch := range p { + if ch < '0' || ch > '9' { + return "", "", fmt.Errorf("version must be an exact major.minor.patch") + } + } + } + return name, version, nil +} diff --git a/internal/diagnostic/documentation.go b/internal/diagnostic/documentation.go new file mode 100644 index 0000000..731291b --- /dev/null +++ b/internal/diagnostic/documentation.go @@ -0,0 +1,27 @@ +package diagnostic + +import ( + "fmt" + "strings" +) + +// RuleMarkdown is the full offline explanation and the reproducible rule page. +func RuleMarkdown(rule RuleMetadata) string { + var text strings.Builder + fmt.Fprintf(&text, "# %s: %s\n\n", rule.RuleID, rule.Title) + text.WriteString("\n\n") + fmt.Fprintf(&text, "Component: %s. Severity: %s.\n\n", rule.Component, rule.Severity) + fmt.Fprintf(&text, "Collector prerequisites: %s.\n\n", strings.Join(rule.Prerequisites, ", ")) + fmt.Fprintf(&text, "%s\n\nEvidence required: %s\n\n", rule.Explanation, rule.Evidence) + text.WriteString("Manual steps:\n\n") + for _, step := range rule.ManualSteps { + fmt.Fprintf(&text, "- %s\n", step) + } + fmt.Fprintf(&text, "\nVerify with `%s`.\n\nLimitations:\n\n", rule.Verify) + for _, caveat := range rule.Caveats { + fmt.Fprintf(&text, "- %s\n", caveat) + } + text.WriteString("\nNo command is executed by this rule. Evidence comes only from the snapshot. Failed prerequisite collectors disable the rule. Positive, negative and edge fixtures cover Windows, Linux and macOS; platform exclusions are listed above.\n\n") + fmt.Fprintf(&text, "Documentation: <%s>.\n", rule.Docs) + return text.String() +} diff --git a/internal/diagnostic/m3_test.go b/internal/diagnostic/m3_test.go new file mode 100644 index 0000000..5679a10 --- /dev/null +++ b/internal/diagnostic/m3_test.go @@ -0,0 +1,162 @@ +package diagnostic_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/shellghostt/envdoctor/internal/collector" + "github.com/shellghostt/envdoctor/internal/diagnostic" +) + +func TestRegistryPagesAndFixturesCoverEveryBuiltin(t *testing.T) { + seen := map[string]bool{} + metadata := diagnostic.BuiltinMetadata() + if len(metadata) != 12 { + t.Fatalf("registry size=%d", len(metadata)) + } + for _, rule := range metadata { + if seen[rule.RuleID] || rule.Title == "" || rule.Evidence == "" || len(rule.ManualSteps) == 0 || len(rule.Caveats) == 0 || len(rule.Prerequisites) == 0 { + t.Fatalf("incomplete/duplicate rule: %+v", rule) + } + seen[rule.RuleID] = true + page, err := os.ReadFile(filepath.Join("..", "..", "docs", "rules", rule.RuleID+".md")) + if err != nil { + t.Fatal(err) + } + if string(page) != diagnostic.RuleMarkdown(rule) { + t.Fatalf("generated page drift: %s; run make docs", rule.RuleID) + } + for _, goos := range []string{"windows", "linux", "darwin"} { + for _, kind := range []string{"positive", "negative", "edge"} { + path := filepath.Join("..", "..", "tests", "golden", fmt.Sprintf("%s-%s-%s.json", rule.RuleID, goos, kind)) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing rule/OS/case fixture: %v", err) + } + fixture, err := diagnostic.LoadFixture(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + if fixture.RuleID != rule.RuleID || fixture.Snapshot.Platform == nil || fixture.Snapshot.Platform.OS != goos { + t.Fatalf("fixture evidence is for a different rule/OS: %s", path) + } + } + } + } + if !reflect.DeepEqual(diagnostic.DefaultEngine().Rules(), func() []string { + var ids []string + for _, m := range metadata { + ids = append(ids, m.RuleID) + } + return ids + }()) { + t.Fatal("engine and metadata registry disagree") + } +} + +func TestMetadataCopiesCannotMutateRulesOrDocumentation(t *testing.T) { + before, _ := diagnostic.LookupRule("ENV007") + metadata := diagnostic.BuiltinMetadata() + metadata[6].Title = "corrupted" + metadata[6].Prerequisites[0] = "corrupted" + metadata[6].ManualSteps[0] = "corrupted" + metadata[6].Caveats[0] = "corrupted" + lookup, _ := diagnostic.LookupRule("ENV007") + lookup.Prerequisites[0] = "also corrupted" + after, _ := diagnostic.LookupRule("ENV007") + if !reflect.DeepEqual(before, after) { + t.Fatal("public metadata mutated the builtin registry") + } +} + +func TestFailedCollectorPrerequisitesDisableEveryM3Rule(t *testing.T) { + for _, rule := range diagnostic.BuiltinMetadata()[6:] { + data, err := os.ReadFile(filepath.Join("..", "..", "tests", "golden", rule.RuleID+"-linux-positive.json")) + if err != nil { + t.Fatal(err) + } + fixture, err := diagnostic.LoadFixture(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + for _, name := range rule.Prerequisites { + fixture.Snapshot.Errors = []collector.CollectorError{{Collector: name, Message: "permission denied"}} + got, err := diagnostic.EvaluateFixture(diagnostic.DefaultEngine(), fixture) + if err != nil || len(got) != 0 { + t.Fatalf("%s used failed %s evidence: %+v %v", rule.RuleID, name, got, err) + } + } + } +} + +func TestUnknownResolutionDoesNotCreateM2Findings(t *testing.T) { + for _, goos := range []string{"windows", "linux", "darwin"} { + cases := map[string][]collector.Runtime{ + "ENV001": {{Name: "python", Version: "3.11.0", Path: "opaque/python", Active: true, Resolution: "unknown"}, {Name: "python", Version: "3.12.0", Path: "known/python", Active: true, Resolution: "known"}}, + "ENV002": {{Name: "node", Path: "opaque/node", Resolution: "unknown"}, {Name: "node", Path: "known/node", Active: true, Resolution: "known"}}, + "ENV004": {{Name: "python", Path: "opaque/python", Expected: true, Resolution: "unknown"}, {Name: "python", Path: "known/python", Active: true, Resolution: "known"}}, + } + for id, runtimes := range cases { + snapshot := collector.Snapshot{Platform: &collector.Platform{OS: goos, Arch: "amd64"}, Runtimes: runtimes} + got, err := diagnostic.DefaultEngine().Evaluate(snapshot, diagnostic.Filter{RuleID: id}) + if err != nil || len(got) != 0 { + t.Fatalf("%s %s guessed from opaque resolution: %+v %v", goos, id, got, err) + } + } + } +} + +func BenchmarkM3RuleEvaluation(b *testing.B) { + data, err := os.ReadFile(filepath.Join("..", "..", "tests", "golden", "ENV009-linux-positive.json")) + if err != nil { + b.Fatal(err) + } + fixture, err := diagnostic.LoadFixture(bytes.NewReader(data)) + if err != nil { + b.Fatal(err) + } + engine := diagnostic.DefaultEngine() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + findings, err := engine.Evaluate(fixture.Snapshot, diagnostic.Filter{}) + if err != nil || len(findings) != 1 { + b.Fatalf("findings=%+v err=%v", findings, err) + } + } +} + +func TestM3RuleEvaluationIsDeterministicAndDoesNotMutateSnapshot(t *testing.T) { + paths, err := filepath.Glob(filepath.Join("..", "..", "tests", "golden", "ENV0*.json")) + if err != nil { + t.Fatal(err) + } + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + fixture, err := diagnostic.LoadFixture(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + before, _ := json.Marshal(fixture.Snapshot) + first, err := diagnostic.EvaluateFixture(diagnostic.DefaultEngine(), fixture) + if err != nil { + t.Fatal(err) + } + second, err := diagnostic.EvaluateFixture(diagnostic.DefaultEngine(), fixture) + if err != nil { + t.Fatal(err) + } + after, _ := json.Marshal(fixture.Snapshot) + if !bytes.Equal(before, after) || !reflect.DeepEqual(first, second) { + t.Fatalf("rule changed its snapshot or varied across runs: %s", path) + } + } +} diff --git a/internal/diagnostic/registry.go b/internal/diagnostic/registry.go new file mode 100644 index 0000000..662cd43 --- /dev/null +++ b/internal/diagnostic/registry.go @@ -0,0 +1,62 @@ +package diagnostic + +// RuleMetadata is the versioned public contract shared by metadata commands, +// findings and generated rule pages. Returned slices are defensive copies. +type RuleMetadata struct { + RuleID string `json:"ruleId"` + Component string `json:"component"` + Prerequisites []string `json:"prerequisites"` + Severity Severity `json:"severity"` + Title string `json:"title"` + Evidence string `json:"evidence"` + Explanation string `json:"explanation"` + ManualSteps []string `json:"manualSteps"` + Verify string `json:"verify"` + Caveats []string `json:"caveats"` + Docs string `json:"docs"` +} +type RegistryDocument struct { + SchemaVersion int `json:"schemaVersion"` + Rules []RuleMetadata `json:"rules"` +} + +var builtinRules []builtinRule + +func init() { + builtinRules = []builtinRule{ + {metadata: RuleMetadata{RuleID: "ENV001", Component: "python", Prerequisites: []string{"runtimes"}, Severity: "error", Title: "Conflicting active Python versions", Evidence: "At least two active Python aliases with known, differing metadata versions.", Explanation: "Active Python command aliases resolve to different installed versions.", ManualSteps: []string{"Activate the intended environment and align python and python3 on PATH."}, Verify: "envdoctor diagnose --rule ENV001", Caveats: []string{"Unknown metadata versions do not establish a conflict. Discovery never executes PATH interpreters.", "Python and python3 resolve independently; a venv invocation remains distinct from its system symlink target.", "Opaque shims and WindowsApps aliases/protected package paths remain unknown. New snapshots with unknown resolution do not support a runtime conflict."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV001.md"}, run: pythonConflicts}, + {metadata: RuleMetadata{RuleID: "ENV002", Component: "node", Prerequisites: []string{"runtimes"}, Severity: "error", Title: "Conflicting Node installations or missing target", Evidence: "Multiple observed Node candidates, or a missing explicitly selected manager target.", Explanation: "Multiple Node candidates or a selected manager target are inconsistent.", ManualSteps: []string{"Select one Node installation and repair the selected version-manager target."}, Verify: "envdoctor diagnose --rule ENV002", Caveats: []string{"Multiple candidates are metadata evidence, not proof that an inactive installation is broken.", "nvm/fnm/volta labels come from paths; their commands are never executed.", "Opaque shims and WindowsApps aliases/protected package paths remain unknown. New snapshots with unknown resolution do not support a runtime conflict."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV002.md"}, run: nodeConflicts}, + {metadata: RuleMetadata{RuleID: "ENV003", Component: "path", Prerequisites: []string{"path"}, Severity: "warning", Title: "Missing PATH entries", Evidence: "A PATH entry whose metadata confirms absence or an unusable path ancestor.", Explanation: "PATH references directories that no longer exist.", ManualSteps: []string{"Remove or correct these entries in your shell or system PATH settings."}, Verify: "envdoctor diagnose --rule ENV003", Caveats: []string{"The original entry is retained after home redaction.", "Permission errors degrade collection rather than proving an entry is missing."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV003.md"}, run: missingPath}, + {metadata: RuleMetadata{RuleID: "ENV004", Component: "path", Prerequisites: []string{"path", "runtimes"}, Severity: "warning", Title: "Selected runtime is shadowed on PATH", Evidence: "A present selected Python/Node target and a different active invocation path.", Explanation: "An earlier PATH candidate takes precedence over the selected environment.", ManualSteps: []string{"Activate the selected environment or move its bin directory before the conflicting entry."}, Verify: "envdoctor diagnose --rule ENV004", Caveats: []string{"Selection comes from VIRTUAL_ENV, NVM_BIN or NVM_SYMLINK, not a guessed preference.", "No PATH entries or shell profiles are changed.", "Opaque shims and WindowsApps aliases/protected package paths remain unknown. New snapshots with unknown resolution do not support a runtime conflict."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV004.md"}, run: shadowing}, + {metadata: RuleMetadata{RuleID: "ENV005", Component: "network", Prerequisites: []string{"network"}, Severity: "error", Title: "Requested TCP port is already in use", Evidence: "An observed TCP listener on a port explicitly requested with --port.", Explanation: "A listening process already holds a port requested for this application.", ManualSteps: []string{"Choose another port or stop the identified process after confirming it is safe to stop."}, Verify: "envdoctor diagnose --rule ENV005", Caveats: []string{"PID/process names can be unavailable when OS permissions deny ownership metadata.", "No port inspection runs without --port. Verification appends the affected ports."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV005.md"}, run: portsInUse}, + {metadata: RuleMetadata{RuleID: "ENV006", Component: "docker", Prerequisites: []string{"docker"}, Severity: "error", Title: "Docker daemon unavailable", Evidence: "A trusted Docker CLI and a recognized failure to connect to the default local engine.", Explanation: "The local default Docker engine is stopped, inaccessible, or uses another socket.", ManualSteps: []string{"Start the local Docker engine and check permission to its default socket."}, Verify: "envdoctor diagnose --rule ENV006", Caveats: []string{"The endpoint is /var/run/docker.sock or the Windows docker_engine pipe. Selected TCP/SSH contexts are metadata only.", "CLI absence, denied inspection, malformed output and unknown failures do not prove an outage.", "M2 snapshots without probeStatus preserve their documented unavailable-daemon interpretation."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV006.md"}, run: dockerUnavailable}, + {metadata: RuleMetadata{RuleID: "ENV007", Component: "docker", Prerequisites: []string{"docker"}, Severity: "warning", Title: "Docker client and daemon API incompatibility", Evidence: "Valid advertised API bounds proving disjoint ranges, or an explicit API override outside the daemon range.", Explanation: "The advertised Docker API ranges or explicit API override cannot overlap the daemon's supported range.", ManualSteps: []string{"Remove an unsupported Docker API override or choose a client supported by the local daemon."}, Verify: "envdoctor diagnose --rule ENV007", Caveats: []string{"Different Docker product versions alone do not establish incompatibility; newer clients can negotiate down.", "Missing/malformed API bounds remain unknown. API policy and sources are recorded in ADR 0005.", "Selected remote contexts are never contacted; ENV017 remains later scope."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV007.md"}, run: dockerAPIMismatch}, + {metadata: RuleMetadata{RuleID: "ENV008", Component: "deps", Prerequisites: []string{"deps"}, Severity: "error", Title: "Required project dependency is missing", Evidence: "A supported requirement in envdoctor.requirements.json and conclusive executable/development-file absence.", Explanation: "A dependency explicitly required by this project is absent from the inspected PATH or declared installation prefix.", ManualSteps: []string{"Provide the declared tool on PATH or the required development files at the project's declared library prefix."}, Verify: "envdoctor diagnose --rule ENV008", Caveats: []string{"Catalog: cmake, make, gcc and pkg-config on absolute PATH; OpenSSL development files at an explicit prefix.", "OpenSSL checks ssl.h plus SSL/crypto .so/.a (Linux), .dylib/.a (macOS), or .lib (Windows amd64).", "Unsupported layouts, Windows arm64 OpenSSL, relative PATH ambiguity and denied metadata are inconclusive.", "No scripts, package managers or PATH-discovered executables are run. Presence does not prove successful compilation/linking."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV008.md"}, run: missingDependencies}, + {metadata: RuleMetadata{RuleID: "ENV009", Component: "node", Prerequisites: []string{"deps"}, Severity: "warning", Title: "Package manager and lockfile mismatch", Evidence: "package.json with observed supported lockfiles and a declared/explicit manager disagreement, or conflicting lockfile families.", Explanation: "The project's declared package manager, selected runner or lockfile families disagree.", ManualSteps: []string{"Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy."}, Verify: "envdoctor diagnose --rule ENV009", Caveats: []string{"packageManager declarations need an exact numeric npm/pnpm/yarn/bun version; --package-manager explicitly selects a runner.", "Installed npm alone never establishes selection. Unknown versions do not establish a version mismatch.", "npm-shrinkwrap.json and package-lock.json are one family; bun.lock and bun.lockb are one family.", "Conflicting families are identified separately from runner/declaration/version disagreement. Lockfile contents are not executed."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md"}, run: packageManagerMismatch}, + {metadata: RuleMetadata{RuleID: "ENV010", Component: "git", Prerequisites: []string{"git", "deps"}, Severity: "warning", Title: "Git identity or project line-ending policy needs attention", Evidence: "Completed effective Git identity inspection, or a config-origin-backed conflict with explicit gitAutocrlf project policy.", Explanation: "Effective Git identity is incomplete or core.autocrlf disagrees with the project's explicit policy.", ManualSteps: []string{"Set the intended Git identity or align core.autocrlf with the project's documented policy after reviewing the reported config origins."}, Verify: "envdoctor diagnose --rule ENV010", Caveats: []string{"Git user.name/user.email remain visible in JSON by the M2 sharing contract; origin home paths are redacted.", "An absent core.autocrlf defaults to false. OS choice alone does not prove a conflict.", "eol attributes override autocrlf and do not by themselves prove a conflict. Policy is explicit gitAutocrlf in the requirements file.", "Invalid/denied config and unchecked older snapshots remain inconclusive. No Git configuration is changed."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV010.md"}, run: gitConfiguration}, + {metadata: RuleMetadata{RuleID: "ENV011", Component: "python", Prerequisites: []string{"runtimes"}, Severity: "warning", Title: "Active virtual environment differs from selected Python", Evidence: "Readable VIRTUAL_ENV pyvenv.cfg/canonical directory and a known selected Python invocation outside that environment.", Explanation: "The active environment metadata and the selected Python invocation refer to different environments.", ManualSteps: []string{"Reactivate the intended virtual environment and check both Python aliases in the current shell."}, Verify: "envdoctor diagnose --rule ENV011", Caveats: []string{"Compare canonical environment directories with Windows case and separator normalization.", "A venv symlink to a system interpreter still belongs to its invocation environment.", "Unknown shims, unreadable environment metadata and unresolved interpreters are inconclusive. No interpreter is executed.", "Opaque shims and WindowsApps aliases/protected package paths remain unknown. New snapshots with unknown resolution do not support a runtime conflict."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV011.md"}, run: virtualEnvironmentMismatch}, + {metadata: RuleMetadata{RuleID: "ENV012", Component: "network", Prerequisites: []string{"proxy"}, Severity: "info", Title: "Configured proxy did not accept a connection", Evidence: "An explicitly requested probe of a configured proxy endpoint returned connection refusal or no-route evidence.", Explanation: "The configured proxy endpoint refused the bounded TCP connection or had no reachable network route.", ManualSteps: []string{"Check the configured proxy host/port and local network or VPN, then repeat the opt-in probe."}, Verify: "envdoctor diagnose --rule ENV012 --probe-network", Caveats: []string{"Default diagnosis records only redacted configuration and leaves reachability untested.", "--probe-network allows at most three configured endpoints, one DNS lookup/one TCP dial each, and a one-second total deadline.", "No credentials, application bytes, external HTTP requests or TLS handshakes are sent; connections are closed.", "DNS failure, denial, timeout, cancellation and invalid URLs remain unknown/degraded. NO_PROXY is recorded only as configured/unconfigured.", "Use ASCII/punycode hostnames or unscoped IPv6 literals. Zone-qualified IPv6 endpoints and unsupported URL schemes are degraded configuration."}, Docs: "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV012.md"}, run: proxyUnreachable}, + } +} + +func BuiltinMetadata() []RuleMetadata { + result := make([]RuleMetadata, 0, len(builtinRules)) + for _, rule := range builtinRules { + result = append(result, copyMetadata(rule.metadata)) + } + return result +} +func LookupRule(id string) (RuleMetadata, bool) { + for _, rule := range builtinRules { + if rule.ID() == id { + return copyMetadata(rule.metadata), true + } + } + return RuleMetadata{}, false +} +func copyMetadata(m RuleMetadata) RuleMetadata { + m.Prerequisites = append([]string(nil), m.Prerequisites...) + m.ManualSteps = append([]string(nil), m.ManualSteps...) + m.Caveats = append([]string(nil), m.Caveats...) + return m +} diff --git a/internal/diagnostic/rules.go b/internal/diagnostic/rules.go index 97221cf..110d175 100644 --- a/internal/diagnostic/rules.go +++ b/internal/diagnostic/rules.go @@ -9,36 +9,39 @@ import ( ) type builtinRule struct { - id, component string - run func(collector.Snapshot) []Finding + metadata RuleMetadata + 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) ID() string { return r.metadata.RuleID } +func (r builtinRule) Components() []string { return []string{r.metadata.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 + for _, prerequisite := range r.metadata.Prerequisites { + if e.Collector == prerequisite { + 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}, - ) + rules := make([]Rule, 0, len(builtinRules)) + for _, rule := range builtinRules { + rules = append(rules, rule) + } + return NewEngine(rules...) } -func finding(id, component, title string, severity Severity, evidence []string, cause, fix string) Finding { +func finding(id string, evidence []string) Finding { + metadata, _ := LookupRule(id) 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"} + return Finding{RuleID: id, Component: metadata.Component, Title: metadata.Title, Severity: metadata.Severity, Detected: evidence, Cause: metadata.Explanation, Fix: &Fix{Summary: metadata.ManualSteps[0], Automatic: false, Reversible: true}, Verify: metadata.Verify, Docs: metadata.Docs} } 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 != "" { + if r.Name == "python" && r.Active && !r.Missing && r.Version != "" && r.Resolution != "unknown" { versions[r.Version] = true evidence = append(evidence, r.Path+" ("+r.Version+")") } @@ -46,7 +49,7 @@ func pythonConflicts(s collector.Snapshot) []Finding { 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.")} + return []Finding{finding("ENV001", evidence)} } func nodeConflicts(s collector.Snapshot) []Finding { paths := map[string]bool{} @@ -56,6 +59,9 @@ func nodeConflicts(s collector.Snapshot) []Finding { if r.Name != "node" { continue } + if r.Resolution == "unknown" { + continue + } if r.Missing && (r.Active || r.Expected) { missing = true evidence = append(evidence, r.Path+" (missing target)") @@ -67,7 +73,7 @@ func nodeConflicts(s collector.Snapshot) []Finding { 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.")} + return []Finding{finding("ENV002", evidence)} } func missingPath(s collector.Snapshot) []Finding { if s.Path == nil { @@ -82,16 +88,16 @@ func missingPath(s collector.Snapshot) []Finding { 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.")} + return []Finding{finding("ENV003", evidence)} } func shadowing(s collector.Snapshot) []Finding { evidence := []string{} for _, expected := range s.Runtimes { - if !expected.Expected || expected.Missing || expected.Active { + if !expected.Expected || expected.Missing || expected.Active || expected.Resolution == "unknown" { continue } for _, active := range s.Runtimes { - if active.Name == expected.Name && active.Active && !active.Missing && active.Path != expected.Path { + if active.Name == expected.Name && active.Active && !active.Missing && active.Path != expected.Path && active.Resolution != "unknown" { evidence = append(evidence, active.Path+" shadows expected "+expected.Path) } } @@ -99,7 +105,7 @@ func shadowing(s collector.Snapshot) []Finding { 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.")} + return []Finding{finding("ENV004", evidence)} } func portsInUse(s collector.Snapshot) []Finding { evidence := []string{} @@ -130,7 +136,7 @@ func portsInUse(s collector.Snapshot) []Finding { 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.") + result := finding("ENV005", evidence) ordered := make([]int, 0, len(ports)) for port := range ports { ordered = append(ordered, port) @@ -147,5 +153,8 @@ 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.")} + if s.Docker.ProbeStatus != "" && s.Docker.ProbeStatus != "unavailable" { + return nil + } + return []Finding{finding("ENV006", []string{"Docker CLI installed (" + s.Docker.CLIVersion + ")", "Local default Docker daemon did not answer"})} } diff --git a/internal/diagnostic/rules_m3.go b/internal/diagnostic/rules_m3.go new file mode 100644 index 0000000..24dd904 --- /dev/null +++ b/internal/diagnostic/rules_m3.go @@ -0,0 +1,256 @@ +package diagnostic + +import ( + "path" + "strconv" + "strings" + + "github.com/shellghostt/envdoctor/internal/collector" +) + +func dockerAPIMismatch(s collector.Snapshot) []Finding { + d := s.Docker + if d == nil || !d.Installed { + return nil + } + serverMax, maxKnown := apiNumber(d.ServerAPI) + serverMin, minKnown := apiNumber(d.ServerMinAPI) + if !maxKnown || !minKnown || serverMin > serverMax { + return nil + } + clientMaxText := d.ClientDefaultAPI + if clientMaxText == "" { + clientMaxText = d.ClientAPI + } + clientMax, clientKnown := apiNumber(clientMaxText) + clientMin, clientMinKnown := apiNumber(d.ClientMinAPI) + if clientMinKnown && clientKnown && clientMin > clientMax { + return nil + } + incompatible := clientKnown && clientMax < serverMin || clientMinKnown && clientMin > serverMax + evidence := []string{"Server API range: " + d.ServerMinAPI + " to " + d.ServerAPI} + if clientKnown { + evidence = append(evidence, "Client API maximum: "+clientMaxText) + } + if clientMinKnown { + evidence = append(evidence, "Client API minimum: "+d.ClientMinAPI) + } + if d.APIOverride != "" { + override, known := apiNumber(d.APIOverride) + if !known { + return nil + } + if override < serverMin || override > serverMax { + incompatible = true + evidence = append(evidence, "Explicit API override: "+d.APIOverride) + } + } + if !incompatible { + return nil + } + return []Finding{finding("ENV007", evidence)} +} + +func apiNumber(value string) (int, bool) { + major, minor, ok := strings.Cut(value, ".") + if !ok || major != "1" || minor == "" || len(minor) > 6 { + return 0, false + } + for _, ch := range minor { + if ch < '0' || ch > '9' { + return 0, false + } + } + n, err := strconv.Atoi(minor) + return n, err == nil +} + +func missingDependencies(s collector.Snapshot) []Finding { + d := s.Dependencies + if d == nil || d.ProjectRoot == "" || d.RequirementsFile == "" { + return nil + } + evidence := []string{} + for _, req := range d.Requirements { + if req.Status != "missing" { + continue + } + if req.Kind == "tool" { + switch req.Name { + case "cmake", "make", "gcc", "pkg-config": + evidence = append(evidence, "Required tool "+req.Name+": no usable executable on the inspected PATH") + } + } else if req.Kind == "library" && req.Name == "openssl" && req.Path != "" && s.Platform != nil && (s.Platform.OS == "linux" || s.Platform.OS == "darwin" || s.Platform.OS == "windows" && s.Platform.Arch == "amd64") { + evidence = append(evidence, "Required library openssl: development files missing at "+req.Path) + } + } + if len(evidence) == 0 { + return nil + } + return []Finding{finding("ENV008", evidence)} +} + +func packageManagerMismatch(s collector.Snapshot) []Finding { + if s.Dependencies == nil || s.Dependencies.Package == nil { + return nil + } + p := s.Dependencies.Package + if p.Manifest == "" { + return nil + } + families := map[string]bool{} + evidence := []string{} + for _, lock := range p.Lockfiles { + family := lockfileFamily(lock) + if family != "" { + families[family] = true + evidence = append(evidence, "Observed lockfile: "+lock) + } + } + if len(families) == 0 { + return nil + } + mismatch := len(families) > 1 + if mismatch { + evidence = append(evidence, "Conflicting package-manager lockfile families") + } + declared, runner := p.Declared, p.Runner + if declared != nil && (!supportedManager(declared.Name) || !exactVersion(declared.Version)) { + declared = nil + } + if runner != nil && !supportedManager(runner.Name) { + runner = nil + } + selected := declared + if declared != nil { + evidence = append(evidence, "Declared package manager: "+declared.Name+"@"+declared.Version) + } + if runner != nil { + selected = runner + text := runner.Name + if exactVersion(runner.Version) { + text += "@" + runner.Version + } + evidence = append(evidence, "Explicit runner: "+text) + } + if declared != nil && runner != nil && (declared.Name != runner.Name || exactVersion(runner.Version) && declared.Version != runner.Version) { + mismatch = true + } + if selected != nil { + for family := range families { + if family != selected.Name { + mismatch = true + } + } + } + if !mismatch { + return nil + } + return []Finding{finding("ENV009", evidence)} +} + +func supportedManager(name string) bool { + return name == "npm" || name == "pnpm" || name == "yarn" || name == "bun" +} +func exactVersion(version string) bool { + parts := strings.Split(version, ".") + if len(parts) != 3 { + return false + } + for _, p := range parts { + if p == "" || len(p) > 9 || len(p) > 1 && p[0] == '0' { + return false + } + for _, ch := range p { + if ch < '0' || ch > '9' { + return false + } + } + } + return true +} +func lockfileFamily(lock string) string { + switch lock { + case "package-lock.json", "npm-shrinkwrap.json": + return "npm" + case "pnpm-lock.yaml": + return "pnpm" + case "yarn.lock": + return "yarn" + case "bun.lock", "bun.lockb": + return "bun" + } + return "" +} + +func gitConfiguration(s collector.Snapshot) []Finding { + g := s.Git + if g == nil || !g.IdentityChecked || g.Version == "" { + return nil + } + evidence := []string{} + if g.UserName == nil || strings.TrimSpace(*g.UserName) == "" { + evidence = append(evidence, "user.name is unset after effective config inspection") + } + if g.UserEmail == nil || strings.TrimSpace(*g.UserEmail) == "" { + evidence = append(evidence, "user.email is unset after effective config inspection") + } + if d := s.Dependencies; d != nil && d.RequirementsFile != "" && (d.GitAutocrlf == "true" || d.GitAutocrlf == "false" || d.GitAutocrlf == "input") { + actual, origin := g.Autocrlf, g.AutocrlfOrigin + if actual == "" { + actual, origin = "false", "default" + } + if origin != "" && (actual == "true" || actual == "false" || actual == "input") && actual != d.GitAutocrlf { + evidence = append(evidence, "core.autocrlf="+actual+" ("+origin+") conflicts with declared policy "+d.GitAutocrlf+" ("+d.RequirementsFile+")") + } + } + if len(evidence) == 0 { + return nil + } + return []Finding{finding("ENV010", evidence)} +} + +func virtualEnvironmentMismatch(s collector.Snapshot) []Finding { + e := s.PythonEnvironment + if e == nil || !e.MetadataKnown || e.CanonicalRoot == "" || s.Platform == nil || (s.Platform.OS != "windows" && s.Platform.OS != "linux" && s.Platform.OS != "darwin") { + return nil + } + windows := s.Platform != nil && s.Platform.OS == "windows" + expected := normalizeEnvironmentPath(e.CanonicalRoot, windows) + evidence := []string{} + for _, r := range s.Runtimes { + if r.Name != "python" || !r.Active || r.Missing || r.Resolution != "known" || r.ResolvedPath == "" { + continue + } + if r.EnvironmentRoot != "" && normalizeEnvironmentPath(r.EnvironmentRoot, windows) == expected { + continue + } + evidence = append(evidence, "Selected Python invocation: "+r.Path) + } + if len(evidence) == 0 { + return nil + } + evidence = append(evidence, "Active virtual environment: "+e.CanonicalRoot) + return []Finding{finding("ENV011", evidence)} +} + +func normalizeEnvironmentPath(value string, windows bool) string { + value = path.Clean(strings.ReplaceAll(value, `\`, "/")) + if windows { + value = strings.ToLower(value) + } + return value +} + +func proxyUnreachable(s collector.Snapshot) []Finding { + evidence := []string{} + for _, p := range s.Proxies { + if p.Probe == "unreachable" && p.Host != "" && p.Port >= 1 && p.Port <= 65535 && (p.Variable == "HTTP_PROXY" || p.Variable == "HTTPS_PROXY" || p.Variable == "ALL_PROXY") { + evidence = append(evidence, p.Variable+" "+p.URL+": connection refused or no route") + } + } + if len(evidence) == 0 { + return nil + } + return []Finding{finding("ENV012", evidence)} +} diff --git a/internal/diagnostic/rules_purity_test.go b/internal/diagnostic/rules_purity_test.go index fcf62f6..4bd693b 100644 --- a/internal/diagnostic/rules_purity_test.go +++ b/internal/diagnostic/rules_purity_test.go @@ -4,16 +4,31 @@ import ( "go/ast" "go/parser" "go/token" + "path/filepath" "strconv" "testing" ) func TestBuiltinRulesOnlyImportPureDependencies(t *testing.T) { - f, err := parser.ParseFile(token.NewFileSet(), "rules.go", nil, 0) + files, err := filepath.Glob("*.go") 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 _, file := range files { + if filepath.Ext(file) != ".go" || file == "fixture.go" || len(file) >= 8 && file[len(file)-8:] == "_test.go" { + continue + } + t.Run(file, func(t *testing.T) { checkPureFile(t, file) }) + } +} + +func checkPureFile(t *testing.T, file string) { + t.Helper() + f, err := parser.ParseFile(token.NewFileSet(), file, nil, 0) + if err != nil { + t.Fatal(err) + } + allowed := map[string]bool{"fmt": true, "sort": true, "strconv": true, "strings": true, "path": true, "regexp": true, "github.com/shellghostt/envdoctor/internal/collector": true} for _, i := range f.Imports { path, _ := strconv.Unquote(i.Path.Value) if !allowed[path] { diff --git a/internal/report/text.go b/internal/report/text.go index 0e43e5c..ea32e6c 100644 --- a/internal/report/text.go +++ b/internal/report/text.go @@ -46,6 +46,9 @@ func writeTools(b *strings.Builder, snapshot collector.Snapshot) { if runtime.Missing { status += ", missing target" } + if runtime.Resolution == "unknown" { + status += ", resolution unknown" + } fmt.Fprintf(b, "Runtime %s %s (%s; %s) %s\n", runtime.Name, version, runtime.Manager, status, runtime.Path) } if snapshot.Git != nil { @@ -63,9 +66,23 @@ func writeTools(b *strings.Builder, snapshot collector.Snapshot) { if snapshot.Docker.DaemonReachable { state = "reachable" } + if snapshot.Docker.ProbeStatus != "" { + state = snapshot.Docker.ProbeStatus + } fmt.Fprintf(b, "Docker %s; %s engine %s\n", snapshot.Docker.CLIVersion, snapshot.Docker.Context, state) + if snapshot.Docker.SelectedContext != "" { + fmt.Fprintf(b, "Context %s; %s (metadata only)\n", snapshot.Docker.SelectedContext, snapshot.Docker.SelectedHost) + } } } + if snapshot.Dependencies != nil { + for _, dependency := range snapshot.Dependencies.Requirements { + fmt.Fprintf(b, "Dependency %s (%s): %s %s\n", dependency.Name, dependency.Kind, dependency.Status, dependency.Path) + } + } + for _, proxy := range snapshot.Proxies { + fmt.Fprintf(b, "Proxy %s %s; reachability %s\n", proxy.Variable, proxy.URL, proxy.Probe) + } if len(snapshot.Runtimes) > 0 || snapshot.Git != nil || snapshot.Docker != nil { b.WriteString("\n") } diff --git a/internal/system/executable.go b/internal/system/executable.go new file mode 100644 index 0000000..289ef77 --- /dev/null +++ b/internal/system/executable.go @@ -0,0 +1,48 @@ +package system + +import ( + "encoding/binary" + "errors" + "io" + "os" +) + +// NativeExecutable recognizes native file headers without executing/loading the +// file. An unknown text shim cannot supply a conclusive interpreter resolution. +func NativeExecutable(path, goos string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer func() { _ = f.Close() }() + var header [4]byte + if _, err = io.ReadFull(f, header[:]); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return false, nil + } + return false, err + } + switch goos { + case "windows": + if header[0] != 'M' || header[1] != 'Z' { + return false, nil + } + if _, err := f.ReadAt(header[:], 0x3c); err != nil { + return false, nil + } + offset := binary.LittleEndian.Uint32(header[:]) + if offset > 4<<20 { + return false, nil + } + if _, err := f.ReadAt(header[:], int64(offset)); err != nil { + return false, nil + } + return string(header[:]) == "PE\x00\x00", nil + case "linux": + return string(header[:]) == "\x7fELF", nil + case "darwin": + magic := binary.BigEndian.Uint32(header[:]) + return magic == 0xfeedface || magic == 0xfeedfacf || magic == 0xcefaedfe || magic == 0xcffaedfe || magic == 0xcafebabe || magic == 0xbebafeca || magic == 0xcafebabf || magic == 0xbfbafeca, nil + } + return false, nil +} diff --git a/internal/system/inspect.go b/internal/system/inspect.go index f465cf7..fdc5acc 100644 --- a/internal/system/inspect.go +++ b/internal/system/inspect.go @@ -96,19 +96,23 @@ func InstalledTool(name string) string { } type limitedOutput struct { - bytes.Buffer + // Do not embed bytes.Buffer: its promoted ReadFrom lets io.Copy bypass Write. + buffer bytes.Buffer overflow bool } func (b *limitedOutput) Write(p []byte) (int, error) { n := len(p) - if b.Len()+n > 1<<20 { + if b.buffer.Len()+n > 1<<20 { b.overflow = true return 0, errors.New("inspection output exceeds 1 MiB") } - _, _ = b.Buffer.Write(p) + _, _ = b.buffer.Write(p) return n, nil } + +func (b *limitedOutput) Bytes() []byte { return b.buffer.Bytes() } +func (b *limitedOutput) String() string { return b.buffer.String() } 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") @@ -120,25 +124,41 @@ func RunTool(ctx context.Context, path string, args ...string) ([]byte, error) { for _, entry := range os.Environ() { key, _, _ := strings.Cut(entry, "=") key = strings.ToUpper(key) - if strings.HasPrefix(key, "GIT_") || strings.HasPrefix(key, "DOCKER_") { + if strings.HasPrefix(key, "GIT_") || strings.HasPrefix(key, "DOCKER_") || + strings.HasPrefix(key, "LD_") || strings.HasPrefix(key, "DYLD_") || + key == "HTTP_PROXY" || key == "HTTPS_PROXY" || key == "ALL_PROXY" || key == "NO_PROXY" { continue } cmd.Env = append(cmd.Env, entry) } cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0", "GIT_OPTIONAL_LOCKS=0") var stdout limitedOutput + var stderr limitedOutput cmd.Stdout = &stdout - cmd.Stderr = io.Discard + cmd.Stderr = &stderr err := cmd.Run() if ctx.Err() != nil { return nil, ctx.Err() } - if stdout.overflow { + if stdout.overflow || stderr.overflow { return nil, errors.New("inspection output exceeds 1 MiB") } - return stdout.Bytes(), err + if err != nil { + return stdout.Bytes(), &ToolError{Err: err, Detail: stderr.String()} + } + return stdout.Bytes(), nil } +// ToolError keeps bounded stderr for internal classification. Its public error +// deliberately does not echo subprocess output; callers publish safe summaries. +type ToolError struct { + Err error + Detail string +} + +func (e *ToolError) Error() string { return e.Err.Error() } +func (e *ToolError) Unwrap() error { return e.Err } + // ExitedWith recognizes process exit statuses through wrapped errors. func ExitedWith(err error, code int) bool { var exit interface{ ExitCode() int } diff --git a/internal/system/inspect_test.go b/internal/system/inspect_test.go index 14429f6..480dc2b 100644 --- a/internal/system/inspect_test.go +++ b/internal/system/inspect_test.go @@ -42,7 +42,11 @@ func TestInspectionHelper(t *testing.T) { time.Sleep(10 * time.Second) return } - _ = json.NewEncoder(os.Stdout).Encode(map[string]string{"git": os.Getenv("GIT_DIR"), "docker": os.Getenv("DOCKER_CONTEXT")}) + if os.Getenv("ENVDOCTOR_HELPER_MODE") == "overflow" { + _, _ = fmt.Fprint(os.Stderr, string(make([]byte, 1<<20+1))) + os.Exit(1) + } + _ = json.NewEncoder(os.Stdout).Encode(map[string]string{"git": os.Getenv("GIT_DIR"), "docker": os.Getenv("DOCKER_CONTEXT"), "proxy": os.Getenv("HTTPS_PROXY"), "loader": os.Getenv("LD_PRELOAD")}) os.Exit(0) } @@ -50,16 +54,31 @@ 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") + t.Setenv("HTTPS_PROXY", "http://user:FAKE_SECRET@remote.invalid:8080") + t.Setenv("LD_PRELOAD", "injected-loader") 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"] != "" { + if err = json.Unmarshal(out, &values); err != nil || values["git"] != "" || values["docker"] != "" || values["proxy"] != "" || values["loader"] != "" { t.Fatalf("context overrides reached child process: %s %v", out, err) } } +func TestRunToolBoundsStderrAndRejectsRelativeExecutables(t *testing.T) { + t.Setenv("ENVDOCTOR_INSPECTION_HELPER", "1") + t.Setenv("ENVDOCTOR_HELPER_MODE", "overflow") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := RunTool(ctx, os.Args[0], "-test.run=^TestInspectionHelper$"); err == nil || err.Error() != "inspection output exceeds 1 MiB" { + t.Fatalf("stderr overflow not bounded: %v", err) + } + if _, err := RunTool(context.Background(), "git", "--version"); err == nil { + t.Fatal("relative executable could use untrusted PATH") + } +} + func TestRunToolHonorsDeadline(t *testing.T) { t.Setenv("ENVDOCTOR_INSPECTION_HELPER", "1") t.Setenv("ENVDOCTOR_HELPER_MODE", "wait") diff --git a/internal/system/proxy.go b/internal/system/proxy.go new file mode 100644 index 0000000..31f2cb5 --- /dev/null +++ b/internal/system/proxy.go @@ -0,0 +1,42 @@ +package system + +import ( + "context" + "errors" + "net" + "strconv" +) + +// ProbeProxy makes one TCP connection to one configured endpoint. It sends no +// application data. The caller supplies the opt-in and an overall deadline. +func ProbeProxy(ctx context.Context, host string, port int) (string, error) { + ip := net.ParseIP(host) + if ip == nil { + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return "unknown", errors.New("proxy DNS lookup failed or timed out") + } + if len(addresses) == 0 { + return "unknown", errors.New("proxy DNS lookup returned no addresses") + } + ip = addresses[0].IP + } + if err := ctx.Err(); err != nil { + return "unknown", err + } + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), strconv.Itoa(port))) + if err != nil { + if ctx.Err() != nil { + return "unknown", ctx.Err() + } + if proxyUnreachableError(err) { + return "unreachable", nil + } + return "unknown", errors.New("proxy connection failed without conclusive reachability evidence") + } + if err := conn.Close(); err != nil { + return "unknown", errors.New("proxy connection could not be closed cleanly") + } + return "reachable", nil +} diff --git a/internal/system/proxy_errors_unix.go b/internal/system/proxy_errors_unix.go new file mode 100644 index 0000000..4034513 --- /dev/null +++ b/internal/system/proxy_errors_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package system + +import ( + "errors" + "syscall" +) + +func proxyUnreachableError(err error) bool { + return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTUNREACH) +} diff --git a/internal/system/proxy_errors_windows.go b/internal/system/proxy_errors_windows.go new file mode 100644 index 0000000..5bd1aec --- /dev/null +++ b/internal/system/proxy_errors_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package system + +import ( + "errors" + "golang.org/x/sys/windows" +) + +func proxyUnreachableError(err error) bool { + return errors.Is(err, windows.WSAECONNREFUSED) || errors.Is(err, windows.WSAENETUNREACH) || errors.Is(err, windows.WSAEHOSTUNREACH) +} diff --git a/internal/system/proxy_test.go b/internal/system/proxy_test.go new file mode 100644 index 0000000..d92cfb0 --- /dev/null +++ b/internal/system/proxy_test.go @@ -0,0 +1,174 @@ +package system + +import ( + "context" + "errors" + "io" + "net" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestProxyProbeSendsNoApplicationBytesAndClosesConnection(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + result := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + result <- err + return + } + defer func() { _ = conn.Close() }() + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + var data [1]byte + n, err := conn.Read(data[:]) + if n != 0 || !errors.Is(err, io.EOF) { + result <- errors.New("proxy probe sent bytes or failed to close its connection") + return + } + result <- nil + }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + status, err := ProbeProxy(ctx, "127.0.0.1", listener.Addr().(*net.TCPAddr).Port) + if err != nil || status != "reachable" { + t.Fatalf("probe=%s err=%v", status, err) + } + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("connection did not terminate") + } +} + +func TestProxyRefusalIsConclusiveButCancellationIsUnknown(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err = listener.Close(); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + status, err := ProbeProxy(ctx, "127.0.0.1", port) + if err != nil || status != "unreachable" { + t.Fatalf("refusal became ambiguous: %s %v", status, err) + } + ctx, cancel = context.WithCancel(context.Background()) + cancel() + status, err = ProbeProxy(ctx, "127.0.0.1", port) + if status != "unknown" || !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation became outage evidence: %s %v", status, err) + } +} + +func TestProxyDNSIsBoundedByCancellation(t *testing.T) { + old := net.DefaultResolver + defer func() { net.DefaultResolver = old }() + net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { <-ctx.Done(); return nil, ctx.Err() }} + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + start := time.Now() + status, err := ProbeProxy(ctx, "synthetic-proxy.invalid", 8888) + if status != "unknown" || err == nil || time.Since(start) > time.Second { + t.Fatalf("DNS escaped deadline: %s %v %s", status, err, time.Since(start)) + } +} + +func TestRepeatedProxyLifecycleReleasesResources(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + finished := make(chan struct{}) + go func() { + defer close(finished) + for { + conn, err := listener.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() + defer func() { _ = listener.Close(); <-finished }() + runtime.GC() + beforeG := runtime.NumGoroutine() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + start := time.Now() + for i := 0; i < 100; i++ { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + status, err := ProbeProxy(ctx, "127.0.0.1", listener.Addr().(*net.TCPAddr).Port) + cancel() + if err != nil || status != "reachable" { + t.Fatalf("cycle %d: %s %v", i, status, err) + } + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } + <-finished + runtime.GC() + runtime.ReadMemStats(&after) + afterG := runtime.NumGoroutine() + if afterG > beforeG+4 { + t.Fatalf("goroutines retained: before=%d after=%d", beforeG, afterG) + } + t.Logf("100 cycles in %s; goroutines %d -> %d; retained heap %d -> %d bytes", time.Since(start), beforeG, afterG, before.HeapAlloc, after.HeapAlloc) +} + +func BenchmarkProxyLifecycle(b *testing.B) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + finished := make(chan struct{}) + go func() { + defer close(finished) + for { + conn, err := listener.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() + b.Cleanup(func() { _ = listener.Close(); <-finished }) + port := listener.Addr().(*net.TCPAddr).Port + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + status, err := ProbeProxy(ctx, "127.0.0.1", port) + cancel() + if err != nil || status != "reachable" { + b.Fatalf("probe: %s %v", status, err) + } + } +} + +func TestNativeExecutableDoesNotTreatTextShimsAsResolvedInterpreters(t *testing.T) { + file := filepath.Join(t.TempDir(), "python") + if err := os.WriteFile(file, []byte("#!/bin/sh\nexit 99\n"), 0755); err != nil { + t.Fatal(err) + } + if known, err := NativeExecutable(file, runtime.GOOS); err != nil || known { + t.Fatalf("text shim classified as native: %v %v", known, err) + } + if known, err := NativeExecutable(os.Args[0], runtime.GOOS); err != nil || !known { + t.Fatalf("native file header not recognized: %v %v", known, err) + } +} diff --git a/internal/system/system.go b/internal/system/system.go index 212415d..bd24e90 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -28,6 +28,9 @@ func Stat(path string) (os.FileInfo, error) { return os.Stat(path) } +func WorkingDir() (string, error) { return os.Getwd() } +func ResolvePath(path string) (string, error) { return filepath.EvalSymlinks(path) } + // PlatformInfo returns a human-readable operating system version and whether // the current process is elevated. It never fails: if the version cannot be // determined the string is empty, because a missing version is a cosmetic gap diff --git a/tests/golden/ENV006-darwin-denied.json b/tests/golden/ENV006-darwin-denied.json new file mode 100644 index 0000000..da129f8 --- /dev/null +++ b/tests/golden/ENV006-darwin-denied.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV006", + "description": "Synthetic darwin denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": false, + "context": "local-default", + "probeStatus": "denied", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + }, + "errors": [ + { + "collector": "docker", + "message": "Local Docker inspection denied" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-darwin-unknown.json b/tests/golden/ENV006-darwin-unknown.json new file mode 100644 index 0000000..3cf005f --- /dev/null +++ b/tests/golden/ENV006-darwin-unknown.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV006", + "description": "Synthetic darwin unknown; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": false, + "context": "local-default", + "probeStatus": "unknown", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + }, + "errors": [ + { + "collector": "docker", + "message": "Local Docker inspection unknown" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-darwin-unsupported.json b/tests/golden/ENV006-darwin-unsupported.json new file mode 100644 index 0000000..7948205 --- /dev/null +++ b/tests/golden/ENV006-darwin-unsupported.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV006", + "description": "Synthetic darwin unsupported; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": false, + "context": "local-default", + "probeStatus": "unsupported", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + }, + "errors": [ + { + "collector": "docker", + "message": "Local Docker inspection unsupported" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-linux-denied.json b/tests/golden/ENV006-linux-denied.json new file mode 100644 index 0000000..bb67d16 --- /dev/null +++ b/tests/golden/ENV006-linux-denied.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV006", + "description": "Synthetic linux denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": false, + "context": "local-default", + "probeStatus": "denied", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + }, + "errors": [ + { + "collector": "docker", + "message": "Local Docker inspection denied" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-linux-unknown.json b/tests/golden/ENV006-linux-unknown.json new file mode 100644 index 0000000..674b951 --- /dev/null +++ b/tests/golden/ENV006-linux-unknown.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV006", + "description": "Synthetic linux unknown; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": false, + "context": "local-default", + "probeStatus": "unknown", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + }, + "errors": [ + { + "collector": "docker", + "message": "Local Docker inspection unknown" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-linux-unsupported.json b/tests/golden/ENV006-linux-unsupported.json new file mode 100644 index 0000000..78dc599 --- /dev/null +++ b/tests/golden/ENV006-linux-unsupported.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV006", + "description": "Synthetic linux unsupported; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": false, + "context": "local-default", + "probeStatus": "unsupported", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + }, + "errors": [ + { + "collector": "docker", + "message": "Local Docker inspection unsupported" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-windows-denied.json b/tests/golden/ENV006-windows-denied.json new file mode 100644 index 0000000..ef163b1 --- /dev/null +++ b/tests/golden/ENV006-windows-denied.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV006", + "description": "Synthetic windows denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": false, + "context": "local-default", + "probeStatus": "denied", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + }, + "errors": [ + { + "collector": "docker", + "message": "Local Docker inspection denied" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-windows-unknown.json b/tests/golden/ENV006-windows-unknown.json new file mode 100644 index 0000000..5e86194 --- /dev/null +++ b/tests/golden/ENV006-windows-unknown.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV006", + "description": "Synthetic windows unknown; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": false, + "context": "local-default", + "probeStatus": "unknown", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + }, + "errors": [ + { + "collector": "docker", + "message": "Local Docker inspection unknown" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV006-windows-unsupported.json b/tests/golden/ENV006-windows-unsupported.json new file mode 100644 index 0000000..2dcd0f6 --- /dev/null +++ b/tests/golden/ENV006-windows-unsupported.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV006", + "description": "Synthetic windows unsupported; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": false, + "context": "local-default", + "probeStatus": "unsupported", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + }, + "errors": [ + { + "collector": "docker", + "message": "Local Docker inspection unsupported" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-darwin-edge.json b/tests/golden/ENV007-darwin-edge.json new file mode 100644 index 0000000..e149089 --- /dev/null +++ b/tests/golden/ENV007-darwin-edge.json @@ -0,0 +1,25 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic darwin edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-darwin-malformed-api.json b/tests/golden/ENV007-darwin-malformed-api.json new file mode 100644 index 0000000..4e7d816 --- /dev/null +++ b/tests/golden/ENV007-darwin-malformed-api.json @@ -0,0 +1,25 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic darwin malformed-api; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "bogus", + "serverApi": "1.48", + "serverMinApi": "1.invalid" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-darwin-negative.json b/tests/golden/ENV007-darwin-negative.json new file mode 100644 index 0000000..e2e62c2 --- /dev/null +++ b/tests/golden/ENV007-darwin-negative.json @@ -0,0 +1,26 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic darwin negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "30.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.48", + "serverApi": "1.48", + "serverMinApi": "1.44", + "clientDefaultApi": "1.53" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-darwin-override.json b/tests/golden/ENV007-darwin-override.json new file mode 100644 index 0000000..9ecbf48 --- /dev/null +++ b/tests/golden/ENV007-darwin-override.json @@ -0,0 +1,47 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic darwin override; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.48", + "serverApi": "1.48", + "serverMinApi": "1.44", + "apiOverride": "1.53" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV007", + "severity": "warning", + "component": "docker", + "title": "Docker client and daemon API incompatibility", + "detected": [ + "Client API maximum: 1.48", + "Explicit API override: 1.53", + "Server API range: 1.44 to 1.48" + ], + "cause": "The advertised Docker API ranges or explicit API override cannot overlap the daemon's supported range.", + "fix": { + "summary": "Remove an unsupported Docker API override or choose a client supported by the local daemon.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV007", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV007.md" + } + ] +} diff --git a/tests/golden/ENV007-darwin-positive.json b/tests/golden/ENV007-darwin-positive.json new file mode 100644 index 0000000..9843eb8 --- /dev/null +++ b/tests/golden/ENV007-darwin-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic darwin positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV007", + "severity": "warning", + "component": "docker", + "title": "Docker client and daemon API incompatibility", + "detected": [ + "Client API maximum: 1.40", + "Server API range: 1.44 to 1.48" + ], + "cause": "The advertised Docker API ranges or explicit API override cannot overlap the daemon's supported range.", + "fix": { + "summary": "Remove an unsupported Docker API override or choose a client supported by the local daemon.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV007", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV007.md" + } + ] +} diff --git a/tests/golden/ENV007-darwin-remote-context.json b/tests/golden/ENV007-darwin-remote-context.json new file mode 100644 index 0000000..4e4ac4e --- /dev/null +++ b/tests/golden/ENV007-darwin-remote-context.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic darwin remote-context; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.48", + "serverApi": "1.48", + "serverMinApi": "1.44", + "selectedContext": "remote", + "selectedHost": "ssh://remote.example" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-linux-edge.json b/tests/golden/ENV007-linux-edge.json new file mode 100644 index 0000000..6fdcb01 --- /dev/null +++ b/tests/golden/ENV007-linux-edge.json @@ -0,0 +1,25 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic linux edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-linux-malformed-api.json b/tests/golden/ENV007-linux-malformed-api.json new file mode 100644 index 0000000..d034ad4 --- /dev/null +++ b/tests/golden/ENV007-linux-malformed-api.json @@ -0,0 +1,25 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic linux malformed-api; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "bogus", + "serverApi": "1.48", + "serverMinApi": "1.invalid" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-linux-negative.json b/tests/golden/ENV007-linux-negative.json new file mode 100644 index 0000000..86d3f39 --- /dev/null +++ b/tests/golden/ENV007-linux-negative.json @@ -0,0 +1,26 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic linux negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "30.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.48", + "serverApi": "1.48", + "serverMinApi": "1.44", + "clientDefaultApi": "1.53" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-linux-override.json b/tests/golden/ENV007-linux-override.json new file mode 100644 index 0000000..c0b19de --- /dev/null +++ b/tests/golden/ENV007-linux-override.json @@ -0,0 +1,47 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic linux override; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.48", + "serverApi": "1.48", + "serverMinApi": "1.44", + "apiOverride": "1.53" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV007", + "severity": "warning", + "component": "docker", + "title": "Docker client and daemon API incompatibility", + "detected": [ + "Client API maximum: 1.48", + "Explicit API override: 1.53", + "Server API range: 1.44 to 1.48" + ], + "cause": "The advertised Docker API ranges or explicit API override cannot overlap the daemon's supported range.", + "fix": { + "summary": "Remove an unsupported Docker API override or choose a client supported by the local daemon.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV007", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV007.md" + } + ] +} diff --git a/tests/golden/ENV007-linux-positive.json b/tests/golden/ENV007-linux-positive.json new file mode 100644 index 0000000..e6a63a7 --- /dev/null +++ b/tests/golden/ENV007-linux-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic linux positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV007", + "severity": "warning", + "component": "docker", + "title": "Docker client and daemon API incompatibility", + "detected": [ + "Client API maximum: 1.40", + "Server API range: 1.44 to 1.48" + ], + "cause": "The advertised Docker API ranges or explicit API override cannot overlap the daemon's supported range.", + "fix": { + "summary": "Remove an unsupported Docker API override or choose a client supported by the local daemon.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV007", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV007.md" + } + ] +} diff --git a/tests/golden/ENV007-linux-remote-context.json b/tests/golden/ENV007-linux-remote-context.json new file mode 100644 index 0000000..1b68f4b --- /dev/null +++ b/tests/golden/ENV007-linux-remote-context.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic linux remote-context; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.48", + "serverApi": "1.48", + "serverMinApi": "1.44", + "selectedContext": "remote", + "selectedHost": "ssh://remote.example" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-windows-edge.json b/tests/golden/ENV007-windows-edge.json new file mode 100644 index 0000000..2c6ddc4 --- /dev/null +++ b/tests/golden/ENV007-windows-edge.json @@ -0,0 +1,25 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic windows edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-windows-malformed-api.json b/tests/golden/ENV007-windows-malformed-api.json new file mode 100644 index 0000000..32a8a01 --- /dev/null +++ b/tests/golden/ENV007-windows-malformed-api.json @@ -0,0 +1,25 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic windows malformed-api; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "bogus", + "serverApi": "1.48", + "serverMinApi": "1.invalid" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-windows-negative.json b/tests/golden/ENV007-windows-negative.json new file mode 100644 index 0000000..8349df1 --- /dev/null +++ b/tests/golden/ENV007-windows-negative.json @@ -0,0 +1,26 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic windows negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "30.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.48", + "serverApi": "1.48", + "serverMinApi": "1.44", + "clientDefaultApi": "1.53" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV007-windows-override.json b/tests/golden/ENV007-windows-override.json new file mode 100644 index 0000000..f598759 --- /dev/null +++ b/tests/golden/ENV007-windows-override.json @@ -0,0 +1,47 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic windows override; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.48", + "serverApi": "1.48", + "serverMinApi": "1.44", + "apiOverride": "1.53" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV007", + "severity": "warning", + "component": "docker", + "title": "Docker client and daemon API incompatibility", + "detected": [ + "Client API maximum: 1.48", + "Explicit API override: 1.53", + "Server API range: 1.44 to 1.48" + ], + "cause": "The advertised Docker API ranges or explicit API override cannot overlap the daemon's supported range.", + "fix": { + "summary": "Remove an unsupported Docker API override or choose a client supported by the local daemon.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV007", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV007.md" + } + ] +} diff --git a/tests/golden/ENV007-windows-positive.json b/tests/golden/ENV007-windows-positive.json new file mode 100644 index 0000000..2203272 --- /dev/null +++ b/tests/golden/ENV007-windows-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic windows positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.40", + "serverApi": "1.48", + "serverMinApi": "1.44" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV007", + "severity": "warning", + "component": "docker", + "title": "Docker client and daemon API incompatibility", + "detected": [ + "Client API maximum: 1.40", + "Server API range: 1.44 to 1.48" + ], + "cause": "The advertised Docker API ranges or explicit API override cannot overlap the daemon's supported range.", + "fix": { + "summary": "Remove an unsupported Docker API override or choose a client supported by the local daemon.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV007", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV007.md" + } + ] +} diff --git a/tests/golden/ENV007-windows-remote-context.json b/tests/golden/ENV007-windows-remote-context.json new file mode 100644 index 0000000..5291732 --- /dev/null +++ b/tests/golden/ENV007-windows-remote-context.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV007", + "description": "Synthetic windows remote-context; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "docker": { + "installed": true, + "cliVersion": "26.0.0", + "daemonReachable": true, + "context": "local-default", + "probeStatus": "available", + "clientApi": "1.48", + "serverApi": "1.48", + "serverMinApi": "1.44", + "selectedContext": "remote", + "selectedHost": "ssh://remote.example" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-darwin-denied.json b/tests/golden/ENV008-darwin-denied.json new file mode 100644 index 0000000..bbea12f --- /dev/null +++ b/tests/golden/ENV008-darwin-denied.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic darwin denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "missing" + } + ] + }, + "errors": [ + { + "collector": "deps", + "message": "Permission denied while reading dependency metadata" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-darwin-edge.json b/tests/golden/ENV008-darwin-edge.json new file mode 100644 index 0000000..751fcee --- /dev/null +++ b/tests/golden/ENV008-darwin-edge.json @@ -0,0 +1,26 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic darwin edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "unknown" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-darwin-library-missing.json b/tests/golden/ENV008-darwin-library-missing.json new file mode 100644 index 0000000..e7f4e72 --- /dev/null +++ b/tests/golden/ENV008-darwin-library-missing.json @@ -0,0 +1,46 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic darwin library-missing; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "library", + "name": "openssl", + "path": "/workspace/app/vendor/openssl", + "status": "missing" + } + ] + } + }, + "expectedFindings": [ + { + "ruleId": "ENV008", + "severity": "error", + "component": "deps", + "title": "Required project dependency is missing", + "detected": [ + "Required library openssl: development files missing at /workspace/app/vendor/openssl" + ], + "cause": "A dependency explicitly required by this project is absent from the inspected PATH or declared installation prefix.", + "fix": { + "summary": "Provide the declared tool on PATH or the required development files at the project's declared library prefix.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV008", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV008.md" + } + ] +} diff --git a/tests/golden/ENV008-darwin-library-present.json b/tests/golden/ENV008-darwin-library-present.json new file mode 100644 index 0000000..41b7e1a --- /dev/null +++ b/tests/golden/ENV008-darwin-library-present.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic darwin library-present; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "library", + "name": "openssl", + "path": "/workspace/app/vendor/openssl", + "status": "present" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-darwin-negative.json b/tests/golden/ENV008-darwin-negative.json new file mode 100644 index 0000000..c9c0231 --- /dev/null +++ b/tests/golden/ENV008-darwin-negative.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic darwin negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "present", + "path": "/workspace/app/cmake" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-darwin-no-declaration.json b/tests/golden/ENV008-darwin-no-declaration.json new file mode 100644 index 0000000..f83aec0 --- /dev/null +++ b/tests/golden/ENV008-darwin-no-declaration.json @@ -0,0 +1,25 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic darwin no-declaration; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirements": [ + { + "kind": "tool", + "name": "gcc", + "status": "missing" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-darwin-positive.json b/tests/golden/ENV008-darwin-positive.json new file mode 100644 index 0000000..5fb809b --- /dev/null +++ b/tests/golden/ENV008-darwin-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic darwin positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "missing" + } + ] + } + }, + "expectedFindings": [ + { + "ruleId": "ENV008", + "severity": "error", + "component": "deps", + "title": "Required project dependency is missing", + "detected": [ + "Required tool cmake: no usable executable on the inspected PATH" + ], + "cause": "A dependency explicitly required by this project is absent from the inspected PATH or declared installation prefix.", + "fix": { + "summary": "Provide the declared tool on PATH or the required development files at the project's declared library prefix.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV008", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV008.md" + } + ] +} diff --git a/tests/golden/ENV008-linux-denied.json b/tests/golden/ENV008-linux-denied.json new file mode 100644 index 0000000..c981120 --- /dev/null +++ b/tests/golden/ENV008-linux-denied.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic linux denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "missing" + } + ] + }, + "errors": [ + { + "collector": "deps", + "message": "Permission denied while reading dependency metadata" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-linux-edge.json b/tests/golden/ENV008-linux-edge.json new file mode 100644 index 0000000..a2e17b3 --- /dev/null +++ b/tests/golden/ENV008-linux-edge.json @@ -0,0 +1,26 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic linux edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "unknown" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-linux-library-missing.json b/tests/golden/ENV008-linux-library-missing.json new file mode 100644 index 0000000..c09cadb --- /dev/null +++ b/tests/golden/ENV008-linux-library-missing.json @@ -0,0 +1,46 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic linux library-missing; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "library", + "name": "openssl", + "path": "/workspace/app/vendor/openssl", + "status": "missing" + } + ] + } + }, + "expectedFindings": [ + { + "ruleId": "ENV008", + "severity": "error", + "component": "deps", + "title": "Required project dependency is missing", + "detected": [ + "Required library openssl: development files missing at /workspace/app/vendor/openssl" + ], + "cause": "A dependency explicitly required by this project is absent from the inspected PATH or declared installation prefix.", + "fix": { + "summary": "Provide the declared tool on PATH or the required development files at the project's declared library prefix.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV008", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV008.md" + } + ] +} diff --git a/tests/golden/ENV008-linux-library-present.json b/tests/golden/ENV008-linux-library-present.json new file mode 100644 index 0000000..8464436 --- /dev/null +++ b/tests/golden/ENV008-linux-library-present.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic linux library-present; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "library", + "name": "openssl", + "path": "/workspace/app/vendor/openssl", + "status": "present" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-linux-negative.json b/tests/golden/ENV008-linux-negative.json new file mode 100644 index 0000000..32f4fb0 --- /dev/null +++ b/tests/golden/ENV008-linux-negative.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic linux negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "present", + "path": "/workspace/app/cmake" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-linux-no-declaration.json b/tests/golden/ENV008-linux-no-declaration.json new file mode 100644 index 0000000..5f27357 --- /dev/null +++ b/tests/golden/ENV008-linux-no-declaration.json @@ -0,0 +1,25 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic linux no-declaration; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirements": [ + { + "kind": "tool", + "name": "gcc", + "status": "missing" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-linux-positive.json b/tests/golden/ENV008-linux-positive.json new file mode 100644 index 0000000..ec89bb3 --- /dev/null +++ b/tests/golden/ENV008-linux-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic linux positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "missing" + } + ] + } + }, + "expectedFindings": [ + { + "ruleId": "ENV008", + "severity": "error", + "component": "deps", + "title": "Required project dependency is missing", + "detected": [ + "Required tool cmake: no usable executable on the inspected PATH" + ], + "cause": "A dependency explicitly required by this project is absent from the inspected PATH or declared installation prefix.", + "fix": { + "summary": "Provide the declared tool on PATH or the required development files at the project's declared library prefix.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV008", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV008.md" + } + ] +} diff --git a/tests/golden/ENV008-windows-denied.json b/tests/golden/ENV008-windows-denied.json new file mode 100644 index 0000000..1758e16 --- /dev/null +++ b/tests/golden/ENV008-windows-denied.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic windows denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "requirementsFile": "C:\\projects\\app\\envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "missing" + } + ] + }, + "errors": [ + { + "collector": "deps", + "message": "Permission denied while reading dependency metadata" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-windows-edge.json b/tests/golden/ENV008-windows-edge.json new file mode 100644 index 0000000..14760b9 --- /dev/null +++ b/tests/golden/ENV008-windows-edge.json @@ -0,0 +1,26 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic windows edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "requirementsFile": "C:\\projects\\app\\envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "unknown" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-windows-library-missing.json b/tests/golden/ENV008-windows-library-missing.json new file mode 100644 index 0000000..a2f14bb --- /dev/null +++ b/tests/golden/ENV008-windows-library-missing.json @@ -0,0 +1,46 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic windows library-missing; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "requirementsFile": "C:\\projects\\app\\envdoctor.requirements.json", + "requirements": [ + { + "kind": "library", + "name": "openssl", + "path": "C:\\projects\\app\\vendor\\openssl", + "status": "missing" + } + ] + } + }, + "expectedFindings": [ + { + "ruleId": "ENV008", + "severity": "error", + "component": "deps", + "title": "Required project dependency is missing", + "detected": [ + "Required library openssl: development files missing at C:\\projects\\app\\vendor\\openssl" + ], + "cause": "A dependency explicitly required by this project is absent from the inspected PATH or declared installation prefix.", + "fix": { + "summary": "Provide the declared tool on PATH or the required development files at the project's declared library prefix.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV008", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV008.md" + } + ] +} diff --git a/tests/golden/ENV008-windows-library-present.json b/tests/golden/ENV008-windows-library-present.json new file mode 100644 index 0000000..a27f96f --- /dev/null +++ b/tests/golden/ENV008-windows-library-present.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic windows library-present; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "requirementsFile": "C:\\projects\\app\\envdoctor.requirements.json", + "requirements": [ + { + "kind": "library", + "name": "openssl", + "path": "C:\\projects\\app\\vendor\\openssl", + "status": "present" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-windows-negative.json b/tests/golden/ENV008-windows-negative.json new file mode 100644 index 0000000..19642a0 --- /dev/null +++ b/tests/golden/ENV008-windows-negative.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic windows negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "requirementsFile": "C:\\projects\\app\\envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "present", + "path": "C:\\projects\\app\\cmake.exe" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-windows-no-declaration.json b/tests/golden/ENV008-windows-no-declaration.json new file mode 100644 index 0000000..383b0ed --- /dev/null +++ b/tests/golden/ENV008-windows-no-declaration.json @@ -0,0 +1,25 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic windows no-declaration; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "requirements": [ + { + "kind": "tool", + "name": "gcc", + "status": "missing" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV008-windows-positive.json b/tests/golden/ENV008-windows-positive.json new file mode 100644 index 0000000..4d6f965 --- /dev/null +++ b/tests/golden/ENV008-windows-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic windows positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "requirementsFile": "C:\\projects\\app\\envdoctor.requirements.json", + "requirements": [ + { + "kind": "tool", + "name": "cmake", + "status": "missing" + } + ] + } + }, + "expectedFindings": [ + { + "ruleId": "ENV008", + "severity": "error", + "component": "deps", + "title": "Required project dependency is missing", + "detected": [ + "Required tool cmake: no usable executable on the inspected PATH" + ], + "cause": "A dependency explicitly required by this project is absent from the inspected PATH or declared installation prefix.", + "fix": { + "summary": "Provide the declared tool on PATH or the required development files at the project's declared library prefix.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV008", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV008.md" + } + ] +} diff --git a/tests/golden/ENV008-windows-unsupported-arm64.json b/tests/golden/ENV008-windows-unsupported-arm64.json new file mode 100644 index 0000000..41dd214 --- /dev/null +++ b/tests/golden/ENV008-windows-unsupported-arm64.json @@ -0,0 +1,27 @@ +{ + "ruleId": "ENV008", + "description": "Synthetic windows unsupported-arm64; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "arm64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "requirementsFile": "C:\\projects\\app\\envdoctor.requirements.json", + "requirements": [ + { + "kind": "library", + "name": "openssl", + "path": "C:\\projects\\app\\vendor\\openssl", + "status": "unknown" + } + ] + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-darwin-conflicting-lockfiles.json b/tests/golden/ENV009-darwin-conflicting-lockfiles.json new file mode 100644 index 0000000..0dd6bfd --- /dev/null +++ b/tests/golden/ENV009-darwin-conflicting-lockfiles.json @@ -0,0 +1,46 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic darwin conflicting-lockfiles; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "lockfiles": [ + "package-lock.json", + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [ + { + "ruleId": "ENV009", + "severity": "warning", + "component": "node", + "title": "Package manager and lockfile mismatch", + "detected": [ + "Conflicting package-manager lockfile families", + "Observed lockfile: package-lock.json", + "Observed lockfile: pnpm-lock.yaml" + ], + "cause": "The project's declared package manager, selected runner or lockfile families disagree.", + "fix": { + "summary": "Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV009", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md" + } + ] +} diff --git a/tests/golden/ENV009-darwin-edge.json b/tests/golden/ENV009-darwin-edge.json new file mode 100644 index 0000000..b180993 --- /dev/null +++ b/tests/golden/ENV009-darwin-edge.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic darwin edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-darwin-negative.json b/tests/golden/ENV009-darwin-negative.json new file mode 100644 index 0000000..615b0bb --- /dev/null +++ b/tests/golden/ENV009-darwin-negative.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic darwin negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "pnpm", + "version": "9.0.0" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-darwin-positive.json b/tests/golden/ENV009-darwin-positive.json new file mode 100644 index 0000000..bbfbbcc --- /dev/null +++ b/tests/golden/ENV009-darwin-positive.json @@ -0,0 +1,53 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic darwin positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "npm", + "version": "10.0.0" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [ + { + "ruleId": "ENV009", + "severity": "warning", + "component": "node", + "title": "Package manager and lockfile mismatch", + "detected": [ + "Declared package manager: pnpm@9.0.0", + "Explicit runner: npm@10.0.0", + "Observed lockfile: pnpm-lock.yaml" + ], + "cause": "The project's declared package manager, selected runner or lockfile families disagree.", + "fix": { + "summary": "Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV009", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md" + } + ] +} diff --git a/tests/golden/ENV009-darwin-same-family.json b/tests/golden/ENV009-darwin-same-family.json new file mode 100644 index 0000000..2a109f7 --- /dev/null +++ b/tests/golden/ENV009-darwin-same-family.json @@ -0,0 +1,33 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic darwin same-family; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "npm", + "version": "10.0.0" + }, + "runner": { + "name": "npm", + "version": "10.0.0" + }, + "lockfiles": [ + "package-lock.json", + "npm-shrinkwrap.json" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-darwin-unknown-runner-version.json b/tests/golden/ENV009-darwin-unknown-runner-version.json new file mode 100644 index 0000000..bd39408 --- /dev/null +++ b/tests/golden/ENV009-darwin-unknown-runner-version.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic darwin unknown-runner-version; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "pnpm" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-darwin-version-mismatch.json b/tests/golden/ENV009-darwin-version-mismatch.json new file mode 100644 index 0000000..b5c208c --- /dev/null +++ b/tests/golden/ENV009-darwin-version-mismatch.json @@ -0,0 +1,53 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic darwin version-mismatch; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "pnpm", + "version": "10.0.0" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [ + { + "ruleId": "ENV009", + "severity": "warning", + "component": "node", + "title": "Package manager and lockfile mismatch", + "detected": [ + "Declared package manager: pnpm@9.0.0", + "Explicit runner: pnpm@10.0.0", + "Observed lockfile: pnpm-lock.yaml" + ], + "cause": "The project's declared package manager, selected runner or lockfile families disagree.", + "fix": { + "summary": "Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV009", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md" + } + ] +} diff --git a/tests/golden/ENV009-linux-conflicting-lockfiles.json b/tests/golden/ENV009-linux-conflicting-lockfiles.json new file mode 100644 index 0000000..16bf33e --- /dev/null +++ b/tests/golden/ENV009-linux-conflicting-lockfiles.json @@ -0,0 +1,46 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic linux conflicting-lockfiles; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "lockfiles": [ + "package-lock.json", + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [ + { + "ruleId": "ENV009", + "severity": "warning", + "component": "node", + "title": "Package manager and lockfile mismatch", + "detected": [ + "Conflicting package-manager lockfile families", + "Observed lockfile: package-lock.json", + "Observed lockfile: pnpm-lock.yaml" + ], + "cause": "The project's declared package manager, selected runner or lockfile families disagree.", + "fix": { + "summary": "Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV009", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md" + } + ] +} diff --git a/tests/golden/ENV009-linux-edge.json b/tests/golden/ENV009-linux-edge.json new file mode 100644 index 0000000..c9e0ce8 --- /dev/null +++ b/tests/golden/ENV009-linux-edge.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic linux edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-linux-negative.json b/tests/golden/ENV009-linux-negative.json new file mode 100644 index 0000000..7a8aa67 --- /dev/null +++ b/tests/golden/ENV009-linux-negative.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic linux negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "pnpm", + "version": "9.0.0" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-linux-positive.json b/tests/golden/ENV009-linux-positive.json new file mode 100644 index 0000000..e63c67f --- /dev/null +++ b/tests/golden/ENV009-linux-positive.json @@ -0,0 +1,53 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic linux positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "npm", + "version": "10.0.0" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [ + { + "ruleId": "ENV009", + "severity": "warning", + "component": "node", + "title": "Package manager and lockfile mismatch", + "detected": [ + "Declared package manager: pnpm@9.0.0", + "Explicit runner: npm@10.0.0", + "Observed lockfile: pnpm-lock.yaml" + ], + "cause": "The project's declared package manager, selected runner or lockfile families disagree.", + "fix": { + "summary": "Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV009", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md" + } + ] +} diff --git a/tests/golden/ENV009-linux-same-family.json b/tests/golden/ENV009-linux-same-family.json new file mode 100644 index 0000000..683a113 --- /dev/null +++ b/tests/golden/ENV009-linux-same-family.json @@ -0,0 +1,33 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic linux same-family; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "npm", + "version": "10.0.0" + }, + "runner": { + "name": "npm", + "version": "10.0.0" + }, + "lockfiles": [ + "package-lock.json", + "npm-shrinkwrap.json" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-linux-unknown-runner-version.json b/tests/golden/ENV009-linux-unknown-runner-version.json new file mode 100644 index 0000000..6e52c64 --- /dev/null +++ b/tests/golden/ENV009-linux-unknown-runner-version.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic linux unknown-runner-version; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "pnpm" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-linux-version-mismatch.json b/tests/golden/ENV009-linux-version-mismatch.json new file mode 100644 index 0000000..c786e03 --- /dev/null +++ b/tests/golden/ENV009-linux-version-mismatch.json @@ -0,0 +1,53 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic linux version-mismatch; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "/workspace/app", + "package": { + "manifest": "/workspace/app/package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "pnpm", + "version": "10.0.0" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [ + { + "ruleId": "ENV009", + "severity": "warning", + "component": "node", + "title": "Package manager and lockfile mismatch", + "detected": [ + "Declared package manager: pnpm@9.0.0", + "Explicit runner: pnpm@10.0.0", + "Observed lockfile: pnpm-lock.yaml" + ], + "cause": "The project's declared package manager, selected runner or lockfile families disagree.", + "fix": { + "summary": "Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV009", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md" + } + ] +} diff --git a/tests/golden/ENV009-windows-conflicting-lockfiles.json b/tests/golden/ENV009-windows-conflicting-lockfiles.json new file mode 100644 index 0000000..97ee542 --- /dev/null +++ b/tests/golden/ENV009-windows-conflicting-lockfiles.json @@ -0,0 +1,46 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic windows conflicting-lockfiles; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "package": { + "manifest": "C:\\projects\\app\\package.json", + "lockfiles": [ + "package-lock.json", + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [ + { + "ruleId": "ENV009", + "severity": "warning", + "component": "node", + "title": "Package manager and lockfile mismatch", + "detected": [ + "Conflicting package-manager lockfile families", + "Observed lockfile: package-lock.json", + "Observed lockfile: pnpm-lock.yaml" + ], + "cause": "The project's declared package manager, selected runner or lockfile families disagree.", + "fix": { + "summary": "Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV009", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md" + } + ] +} diff --git a/tests/golden/ENV009-windows-edge.json b/tests/golden/ENV009-windows-edge.json new file mode 100644 index 0000000..e592bf4 --- /dev/null +++ b/tests/golden/ENV009-windows-edge.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic windows edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "package": { + "manifest": "C:\\projects\\app\\package.json", + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-windows-negative.json b/tests/golden/ENV009-windows-negative.json new file mode 100644 index 0000000..5532c67 --- /dev/null +++ b/tests/golden/ENV009-windows-negative.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic windows negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "package": { + "manifest": "C:\\projects\\app\\package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "pnpm", + "version": "9.0.0" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-windows-positive.json b/tests/golden/ENV009-windows-positive.json new file mode 100644 index 0000000..bd95551 --- /dev/null +++ b/tests/golden/ENV009-windows-positive.json @@ -0,0 +1,53 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic windows positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "package": { + "manifest": "C:\\projects\\app\\package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "npm", + "version": "10.0.0" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [ + { + "ruleId": "ENV009", + "severity": "warning", + "component": "node", + "title": "Package manager and lockfile mismatch", + "detected": [ + "Declared package manager: pnpm@9.0.0", + "Explicit runner: npm@10.0.0", + "Observed lockfile: pnpm-lock.yaml" + ], + "cause": "The project's declared package manager, selected runner or lockfile families disagree.", + "fix": { + "summary": "Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV009", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md" + } + ] +} diff --git a/tests/golden/ENV009-windows-same-family.json b/tests/golden/ENV009-windows-same-family.json new file mode 100644 index 0000000..e96cf1a --- /dev/null +++ b/tests/golden/ENV009-windows-same-family.json @@ -0,0 +1,33 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic windows same-family; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "package": { + "manifest": "C:\\projects\\app\\package.json", + "declared": { + "name": "npm", + "version": "10.0.0" + }, + "runner": { + "name": "npm", + "version": "10.0.0" + }, + "lockfiles": [ + "package-lock.json", + "npm-shrinkwrap.json" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-windows-unknown-runner-version.json b/tests/golden/ENV009-windows-unknown-runner-version.json new file mode 100644 index 0000000..0fc2165 --- /dev/null +++ b/tests/golden/ENV009-windows-unknown-runner-version.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic windows unknown-runner-version; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "package": { + "manifest": "C:\\projects\\app\\package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "pnpm" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV009-windows-version-mismatch.json b/tests/golden/ENV009-windows-version-mismatch.json new file mode 100644 index 0000000..58317d6 --- /dev/null +++ b/tests/golden/ENV009-windows-version-mismatch.json @@ -0,0 +1,53 @@ +{ + "ruleId": "ENV009", + "description": "Synthetic windows version-mismatch; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "package": { + "manifest": "C:\\projects\\app\\package.json", + "declared": { + "name": "pnpm", + "version": "9.0.0" + }, + "runner": { + "name": "pnpm", + "version": "10.0.0" + }, + "lockfiles": [ + "pnpm-lock.yaml" + ] + } + } + }, + "expectedFindings": [ + { + "ruleId": "ENV009", + "severity": "warning", + "component": "node", + "title": "Package manager and lockfile mismatch", + "detected": [ + "Declared package manager: pnpm@9.0.0", + "Explicit runner: pnpm@10.0.0", + "Observed lockfile: pnpm-lock.yaml" + ], + "cause": "The project's declared package manager, selected runner or lockfile families disagree.", + "fix": { + "summary": "Choose the intended package manager and version, then reconcile the declaration and lockfiles after reviewing project policy.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV009", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV009.md" + } + ] +} diff --git a/tests/golden/ENV010-darwin-denied.json b/tests/golden/ENV010-darwin-denied.json new file mode 100644 index 0000000..c82c133 --- /dev/null +++ b/tests/golden/ENV010-darwin-denied.json @@ -0,0 +1,28 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic darwin denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": null, + "userEmail": null, + "autocrlf": "", + "identityChecked": true + }, + "errors": [ + { + "collector": "git", + "message": "Git config inspection denied" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV010-darwin-edge.json b/tests/golden/ENV010-darwin-edge.json new file mode 100644 index 0000000..25dd98d --- /dev/null +++ b/tests/golden/ENV010-darwin-edge.json @@ -0,0 +1,22 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic darwin edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": null, + "userEmail": null, + "autocrlf": "", + "identityChecked": false + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV010-darwin-explicit-policy.json b/tests/golden/ENV010-darwin-explicit-policy.json new file mode 100644 index 0000000..4d901e5 --- /dev/null +++ b/tests/golden/ENV010-darwin-explicit-policy.json @@ -0,0 +1,47 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic darwin explicit-policy; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": "Developer", + "userEmail": "developer@example.invalid", + "autocrlf": "true", + "identityChecked": true, + "autocrlfOrigin": "file:/workspace/app/.git/config" + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "gitAutocrlf": "input" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV010", + "severity": "warning", + "component": "git", + "title": "Git identity or project line-ending policy needs attention", + "detected": [ + "core.autocrlf=true (file:/workspace/app/.git/config) conflicts with declared policy input (/workspace/app/envdoctor.requirements.json)" + ], + "cause": "Effective Git identity is incomplete or core.autocrlf disagrees with the project's explicit policy.", + "fix": { + "summary": "Set the intended Git identity or align core.autocrlf with the project's documented policy after reviewing the reported config origins.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV010", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV010.md" + } + ] +} diff --git a/tests/golden/ENV010-darwin-negative.json b/tests/golden/ENV010-darwin-negative.json new file mode 100644 index 0000000..8bfb6d7 --- /dev/null +++ b/tests/golden/ENV010-darwin-negative.json @@ -0,0 +1,28 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic darwin negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": "Developer", + "userEmail": "developer@example.invalid", + "autocrlf": "true", + "identityChecked": true, + "autocrlfOrigin": "file:/workspace/app/.git/config", + "userNameOrigin": "file:/workspace/app/.git/config", + "userEmailOrigin": "file:/workspace/app/.git/config" + }, + "dependencies": { + "projectRoot": "/workspace/app" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV010-darwin-positive.json b/tests/golden/ENV010-darwin-positive.json new file mode 100644 index 0000000..eec0c2d --- /dev/null +++ b/tests/golden/ENV010-darwin-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic darwin positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": null, + "userEmail": null, + "autocrlf": "", + "identityChecked": true + }, + "dependencies": { + "projectRoot": "/workspace/app" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV010", + "severity": "warning", + "component": "git", + "title": "Git identity or project line-ending policy needs attention", + "detected": [ + "user.email is unset after effective config inspection", + "user.name is unset after effective config inspection" + ], + "cause": "Effective Git identity is incomplete or core.autocrlf disagrees with the project's explicit policy.", + "fix": { + "summary": "Set the intended Git identity or align core.autocrlf with the project's documented policy after reviewing the reported config origins.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV010", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV010.md" + } + ] +} diff --git a/tests/golden/ENV010-linux-denied.json b/tests/golden/ENV010-linux-denied.json new file mode 100644 index 0000000..5b99a18 --- /dev/null +++ b/tests/golden/ENV010-linux-denied.json @@ -0,0 +1,28 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic linux denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": null, + "userEmail": null, + "autocrlf": "", + "identityChecked": true + }, + "errors": [ + { + "collector": "git", + "message": "Git config inspection denied" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV010-linux-edge.json b/tests/golden/ENV010-linux-edge.json new file mode 100644 index 0000000..fc2b3c5 --- /dev/null +++ b/tests/golden/ENV010-linux-edge.json @@ -0,0 +1,22 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic linux edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": null, + "userEmail": null, + "autocrlf": "", + "identityChecked": false + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV010-linux-explicit-policy.json b/tests/golden/ENV010-linux-explicit-policy.json new file mode 100644 index 0000000..7c8fd9a --- /dev/null +++ b/tests/golden/ENV010-linux-explicit-policy.json @@ -0,0 +1,47 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic linux explicit-policy; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": "Developer", + "userEmail": "developer@example.invalid", + "autocrlf": "true", + "identityChecked": true, + "autocrlfOrigin": "file:/workspace/app/.git/config" + }, + "dependencies": { + "projectRoot": "/workspace/app", + "requirementsFile": "/workspace/app/envdoctor.requirements.json", + "gitAutocrlf": "input" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV010", + "severity": "warning", + "component": "git", + "title": "Git identity or project line-ending policy needs attention", + "detected": [ + "core.autocrlf=true (file:/workspace/app/.git/config) conflicts with declared policy input (/workspace/app/envdoctor.requirements.json)" + ], + "cause": "Effective Git identity is incomplete or core.autocrlf disagrees with the project's explicit policy.", + "fix": { + "summary": "Set the intended Git identity or align core.autocrlf with the project's documented policy after reviewing the reported config origins.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV010", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV010.md" + } + ] +} diff --git a/tests/golden/ENV010-linux-negative.json b/tests/golden/ENV010-linux-negative.json new file mode 100644 index 0000000..6ac7651 --- /dev/null +++ b/tests/golden/ENV010-linux-negative.json @@ -0,0 +1,28 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic linux negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": "Developer", + "userEmail": "developer@example.invalid", + "autocrlf": "true", + "identityChecked": true, + "autocrlfOrigin": "file:/workspace/app/.git/config", + "userNameOrigin": "file:/workspace/app/.git/config", + "userEmailOrigin": "file:/workspace/app/.git/config" + }, + "dependencies": { + "projectRoot": "/workspace/app" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV010-linux-positive.json b/tests/golden/ENV010-linux-positive.json new file mode 100644 index 0000000..92abc42 --- /dev/null +++ b/tests/golden/ENV010-linux-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic linux positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": null, + "userEmail": null, + "autocrlf": "", + "identityChecked": true + }, + "dependencies": { + "projectRoot": "/workspace/app" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV010", + "severity": "warning", + "component": "git", + "title": "Git identity or project line-ending policy needs attention", + "detected": [ + "user.email is unset after effective config inspection", + "user.name is unset after effective config inspection" + ], + "cause": "Effective Git identity is incomplete or core.autocrlf disagrees with the project's explicit policy.", + "fix": { + "summary": "Set the intended Git identity or align core.autocrlf with the project's documented policy after reviewing the reported config origins.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV010", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV010.md" + } + ] +} diff --git a/tests/golden/ENV010-windows-denied.json b/tests/golden/ENV010-windows-denied.json new file mode 100644 index 0000000..a72f03c --- /dev/null +++ b/tests/golden/ENV010-windows-denied.json @@ -0,0 +1,28 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic windows denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": null, + "userEmail": null, + "autocrlf": "", + "identityChecked": true + }, + "errors": [ + { + "collector": "git", + "message": "Git config inspection denied" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV010-windows-edge.json b/tests/golden/ENV010-windows-edge.json new file mode 100644 index 0000000..0cf9688 --- /dev/null +++ b/tests/golden/ENV010-windows-edge.json @@ -0,0 +1,22 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic windows edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": null, + "userEmail": null, + "autocrlf": "", + "identityChecked": false + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV010-windows-explicit-policy.json b/tests/golden/ENV010-windows-explicit-policy.json new file mode 100644 index 0000000..1eeb25e --- /dev/null +++ b/tests/golden/ENV010-windows-explicit-policy.json @@ -0,0 +1,47 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic windows explicit-policy; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": "Developer", + "userEmail": "developer@example.invalid", + "autocrlf": "true", + "identityChecked": true, + "autocrlfOrigin": "file:C:\\projects\\app/.git/config" + }, + "dependencies": { + "projectRoot": "C:\\projects\\app", + "requirementsFile": "C:\\projects\\app\\envdoctor.requirements.json", + "gitAutocrlf": "input" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV010", + "severity": "warning", + "component": "git", + "title": "Git identity or project line-ending policy needs attention", + "detected": [ + "core.autocrlf=true (file:C:\\projects\\app/.git/config) conflicts with declared policy input (C:\\projects\\app\\envdoctor.requirements.json)" + ], + "cause": "Effective Git identity is incomplete or core.autocrlf disagrees with the project's explicit policy.", + "fix": { + "summary": "Set the intended Git identity or align core.autocrlf with the project's documented policy after reviewing the reported config origins.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV010", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV010.md" + } + ] +} diff --git a/tests/golden/ENV010-windows-negative.json b/tests/golden/ENV010-windows-negative.json new file mode 100644 index 0000000..2143e8e --- /dev/null +++ b/tests/golden/ENV010-windows-negative.json @@ -0,0 +1,28 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic windows negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": "Developer", + "userEmail": "developer@example.invalid", + "autocrlf": "true", + "identityChecked": true, + "autocrlfOrigin": "file:C:\\projects\\app/.git/config", + "userNameOrigin": "file:C:\\projects\\app/.git/config", + "userEmailOrigin": "file:C:\\projects\\app/.git/config" + }, + "dependencies": { + "projectRoot": "C:\\projects\\app" + } + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV010-windows-positive.json b/tests/golden/ENV010-windows-positive.json new file mode 100644 index 0000000..5b049d9 --- /dev/null +++ b/tests/golden/ENV010-windows-positive.json @@ -0,0 +1,45 @@ +{ + "ruleId": "ENV010", + "description": "Synthetic windows positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "git": { + "version": "2.45.0", + "userName": null, + "userEmail": null, + "autocrlf": "", + "identityChecked": true + }, + "dependencies": { + "projectRoot": "C:\\projects\\app" + } + }, + "expectedFindings": [ + { + "ruleId": "ENV010", + "severity": "warning", + "component": "git", + "title": "Git identity or project line-ending policy needs attention", + "detected": [ + "user.email is unset after effective config inspection", + "user.name is unset after effective config inspection" + ], + "cause": "Effective Git identity is incomplete or core.autocrlf disagrees with the project's explicit policy.", + "fix": { + "summary": "Set the intended Git identity or align core.autocrlf with the project's documented policy after reviewing the reported config origins.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV010", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV010.md" + } + ] +} diff --git a/tests/golden/ENV011-darwin-case-normalization.json b/tests/golden/ENV011-darwin-case-normalization.json new file mode 100644 index 0000000..61126b7 --- /dev/null +++ b/tests/golden/ENV011-darwin-case-normalization.json @@ -0,0 +1,52 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic darwin case-normalization; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/WORKSPACE/APP/.VENV/bin/python", + "manager": "venv", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known", + "environmentRoot": "/WORKSPACE/APP/.VENV" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV011", + "severity": "warning", + "component": "python", + "title": "Active virtual environment differs from selected Python", + "detected": [ + "Active virtual environment: /workspace/app/.venv", + "Selected Python invocation: /WORKSPACE/APP/.VENV/bin/python" + ], + "cause": "The active environment metadata and the selected Python invocation refer to different environments.", + "fix": { + "summary": "Reactivate the intended virtual environment and check both Python aliases in the current shell.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV011", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV011.md" + } + ] +} diff --git a/tests/golden/ENV011-darwin-edge.json b/tests/golden/ENV011-darwin-edge.json new file mode 100644 index 0000000..d28e508 --- /dev/null +++ b/tests/golden/ENV011-darwin-edge.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic darwin edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/workspace/app/.pyenv/shims/python", + "manager": "pyenv", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "unknown" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-darwin-negative.json b/tests/golden/ENV011-darwin-negative.json new file mode 100644 index 0000000..6a764db --- /dev/null +++ b/tests/golden/ENV011-darwin-negative.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic darwin negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/workspace/app/.venv/bin/python", + "manager": "venv", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known", + "environmentRoot": "/workspace/app/.venv" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-darwin-positive.json b/tests/golden/ENV011-darwin-positive.json new file mode 100644 index 0000000..65711aa --- /dev/null +++ b/tests/golden/ENV011-darwin-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic darwin positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/usr/bin/python3", + "manager": "system", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV011", + "severity": "warning", + "component": "python", + "title": "Active virtual environment differs from selected Python", + "detected": [ + "Active virtual environment: /workspace/app/.venv", + "Selected Python invocation: /usr/bin/python3" + ], + "cause": "The active environment metadata and the selected Python invocation refer to different environments.", + "fix": { + "summary": "Reactivate the intended virtual environment and check both Python aliases in the current shell.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV011", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV011.md" + } + ] +} diff --git a/tests/golden/ENV011-darwin-shared-system-target.json b/tests/golden/ENV011-darwin-shared-system-target.json new file mode 100644 index 0000000..e272548 --- /dev/null +++ b/tests/golden/ENV011-darwin-shared-system-target.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic darwin shared-system-target; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv/alias", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/workspace/app/.venv/bin/python", + "manager": "venv", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known", + "environmentRoot": "/workspace/app/.venv" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-darwin-unknown-environment.json b/tests/golden/ENV011-darwin-unknown-environment.json new file mode 100644 index 0000000..6549cbb --- /dev/null +++ b/tests/golden/ENV011-darwin-unknown-environment.json @@ -0,0 +1,30 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic darwin unknown-environment; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "metadataKnown": false + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/usr/bin/python3", + "manager": "system", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-linux-case-normalization.json b/tests/golden/ENV011-linux-case-normalization.json new file mode 100644 index 0000000..37cefb0 --- /dev/null +++ b/tests/golden/ENV011-linux-case-normalization.json @@ -0,0 +1,52 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic linux case-normalization; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/WORKSPACE/APP/.VENV/bin/python", + "manager": "venv", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known", + "environmentRoot": "/WORKSPACE/APP/.VENV" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV011", + "severity": "warning", + "component": "python", + "title": "Active virtual environment differs from selected Python", + "detected": [ + "Active virtual environment: /workspace/app/.venv", + "Selected Python invocation: /WORKSPACE/APP/.VENV/bin/python" + ], + "cause": "The active environment metadata and the selected Python invocation refer to different environments.", + "fix": { + "summary": "Reactivate the intended virtual environment and check both Python aliases in the current shell.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV011", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV011.md" + } + ] +} diff --git a/tests/golden/ENV011-linux-edge.json b/tests/golden/ENV011-linux-edge.json new file mode 100644 index 0000000..8c92fdf --- /dev/null +++ b/tests/golden/ENV011-linux-edge.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic linux edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/workspace/app/.pyenv/shims/python", + "manager": "pyenv", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "unknown" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-linux-negative.json b/tests/golden/ENV011-linux-negative.json new file mode 100644 index 0000000..fcbb89e --- /dev/null +++ b/tests/golden/ENV011-linux-negative.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic linux negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/workspace/app/.venv/bin/python", + "manager": "venv", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known", + "environmentRoot": "/workspace/app/.venv" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-linux-positive.json b/tests/golden/ENV011-linux-positive.json new file mode 100644 index 0000000..5d5a9a5 --- /dev/null +++ b/tests/golden/ENV011-linux-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic linux positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/usr/bin/python3", + "manager": "system", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV011", + "severity": "warning", + "component": "python", + "title": "Active virtual environment differs from selected Python", + "detected": [ + "Active virtual environment: /workspace/app/.venv", + "Selected Python invocation: /usr/bin/python3" + ], + "cause": "The active environment metadata and the selected Python invocation refer to different environments.", + "fix": { + "summary": "Reactivate the intended virtual environment and check both Python aliases in the current shell.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV011", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV011.md" + } + ] +} diff --git a/tests/golden/ENV011-linux-shared-system-target.json b/tests/golden/ENV011-linux-shared-system-target.json new file mode 100644 index 0000000..3f48855 --- /dev/null +++ b/tests/golden/ENV011-linux-shared-system-target.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic linux shared-system-target; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv/alias", + "canonicalRoot": "/workspace/app/.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/workspace/app/.venv/bin/python", + "manager": "venv", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known", + "environmentRoot": "/workspace/app/.venv" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-linux-unknown-environment.json b/tests/golden/ENV011-linux-unknown-environment.json new file mode 100644 index 0000000..deac5d8 --- /dev/null +++ b/tests/golden/ENV011-linux-unknown-environment.json @@ -0,0 +1,30 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic linux unknown-environment; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "/workspace/app/.venv", + "metadataKnown": false + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "/usr/bin/python3", + "manager": "system", + "active": true, + "resolvedPath": "/usr/bin/python3", + "resolution": "known" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-windows-case-normalization.json b/tests/golden/ENV011-windows-case-normalization.json new file mode 100644 index 0000000..4bc8488 --- /dev/null +++ b/tests/golden/ENV011-windows-case-normalization.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic windows case-normalization; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "C:\\projects\\app\\.venv", + "canonicalRoot": "C:\\projects\\app\\.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "C:\\PROJECTS\\APP\\.VENV\\Scripts\\python.exe", + "manager": "venv", + "active": true, + "resolvedPath": "C:\\Python312\\python.exe", + "resolution": "known", + "environmentRoot": "C:/PROJECTS/APP/.VENV" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-windows-edge.json b/tests/golden/ENV011-windows-edge.json new file mode 100644 index 0000000..405ae34 --- /dev/null +++ b/tests/golden/ENV011-windows-edge.json @@ -0,0 +1,31 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic windows edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "C:\\projects\\app\\.venv", + "canonicalRoot": "C:\\projects\\app\\.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "C:\\projects\\app\\.pyenv\\shims\\python.exe", + "manager": "pyenv", + "active": true, + "resolvedPath": "C:\\Python312\\python.exe", + "resolution": "unknown" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-windows-negative.json b/tests/golden/ENV011-windows-negative.json new file mode 100644 index 0000000..bdec271 --- /dev/null +++ b/tests/golden/ENV011-windows-negative.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic windows negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "C:\\projects\\app\\.venv", + "canonicalRoot": "C:\\projects\\app\\.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "C:\\projects\\app\\.venv\\Scripts\\python.exe", + "manager": "venv", + "active": true, + "resolvedPath": "C:\\Python312\\python.exe", + "resolution": "known", + "environmentRoot": "C:\\projects\\app\\.venv" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-windows-positive.json b/tests/golden/ENV011-windows-positive.json new file mode 100644 index 0000000..d6681a7 --- /dev/null +++ b/tests/golden/ENV011-windows-positive.json @@ -0,0 +1,51 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic windows positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "C:\\projects\\app\\.venv", + "canonicalRoot": "C:\\projects\\app\\.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "C:\\Python312\\python.exe", + "manager": "system", + "active": true, + "resolvedPath": "C:\\Python312\\python.exe", + "resolution": "known" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV011", + "severity": "warning", + "component": "python", + "title": "Active virtual environment differs from selected Python", + "detected": [ + "Active virtual environment: C:\\projects\\app\\.venv", + "Selected Python invocation: C:\\Python312\\python.exe" + ], + "cause": "The active environment metadata and the selected Python invocation refer to different environments.", + "fix": { + "summary": "Reactivate the intended virtual environment and check both Python aliases in the current shell.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV011", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV011.md" + } + ] +} diff --git a/tests/golden/ENV011-windows-shared-system-target.json b/tests/golden/ENV011-windows-shared-system-target.json new file mode 100644 index 0000000..5cee8d7 --- /dev/null +++ b/tests/golden/ENV011-windows-shared-system-target.json @@ -0,0 +1,32 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic windows shared-system-target; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "C:\\projects\\app\\.venv/alias", + "canonicalRoot": "C:\\projects\\app\\.venv", + "metadataKnown": true + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "C:\\projects\\app\\.venv\\Scripts\\python.exe", + "manager": "venv", + "active": true, + "resolvedPath": "C:\\Python312\\python.exe", + "resolution": "known", + "environmentRoot": "C:\\projects\\app\\.venv" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV011-windows-unknown-environment.json b/tests/golden/ENV011-windows-unknown-environment.json new file mode 100644 index 0000000..9d81b4f --- /dev/null +++ b/tests/golden/ENV011-windows-unknown-environment.json @@ -0,0 +1,30 @@ +{ + "ruleId": "ENV011", + "description": "Synthetic windows unknown-environment; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "pythonEnvironment": { + "root": "C:\\projects\\app\\.venv", + "metadataKnown": false + }, + "runtimes": [ + { + "name": "python", + "version": "3.12.0", + "path": "C:\\Python312\\python.exe", + "manager": "system", + "active": true, + "resolvedPath": "C:\\Python312\\python.exe", + "resolution": "known" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-darwin-denied.json b/tests/golden/ENV012-darwin-denied.json new file mode 100644 index 0000000..c68537f --- /dev/null +++ b/tests/golden/ENV012-darwin-denied.json @@ -0,0 +1,30 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic darwin denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unknown" + } + ], + "errors": [ + { + "collector": "proxy", + "message": "Proxy inspection denied" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-darwin-dns-failure.json b/tests/golden/ENV012-darwin-dns-failure.json new file mode 100644 index 0000000..d90a6b1 --- /dev/null +++ b/tests/golden/ENV012-darwin-dns-failure.json @@ -0,0 +1,30 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic darwin dns-failure; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unknown" + } + ], + "errors": [ + { + "collector": "proxy", + "message": "Proxy DNS lookup failed or timed out" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-darwin-edge.json b/tests/golden/ENV012-darwin-edge.json new file mode 100644 index 0000000..7ce8e66 --- /dev/null +++ b/tests/golden/ENV012-darwin-edge.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic darwin edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unknown" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-darwin-negative.json b/tests/golden/ENV012-darwin-negative.json new file mode 100644 index 0000000..b744eb4 --- /dev/null +++ b/tests/golden/ENV012-darwin-negative.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic darwin negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "untested" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-darwin-positive.json b/tests/golden/ENV012-darwin-positive.json new file mode 100644 index 0000000..49667e8 --- /dev/null +++ b/tests/golden/ENV012-darwin-positive.json @@ -0,0 +1,43 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic darwin positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "darwin", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unreachable" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV012", + "severity": "info", + "component": "network", + "title": "Configured proxy did not accept a connection", + "detected": [ + "HTTPS_PROXY http://127.0.0.1:8888: connection refused or no route" + ], + "cause": "The configured proxy endpoint refused the bounded TCP connection or had no reachable network route.", + "fix": { + "summary": "Check the configured proxy host/port and local network or VPN, then repeat the opt-in probe.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV012 --probe-network", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV012.md" + } + ] +} diff --git a/tests/golden/ENV012-linux-denied.json b/tests/golden/ENV012-linux-denied.json new file mode 100644 index 0000000..14616d9 --- /dev/null +++ b/tests/golden/ENV012-linux-denied.json @@ -0,0 +1,30 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic linux denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unknown" + } + ], + "errors": [ + { + "collector": "proxy", + "message": "Proxy inspection denied" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-linux-dns-failure.json b/tests/golden/ENV012-linux-dns-failure.json new file mode 100644 index 0000000..ca45a48 --- /dev/null +++ b/tests/golden/ENV012-linux-dns-failure.json @@ -0,0 +1,30 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic linux dns-failure; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unknown" + } + ], + "errors": [ + { + "collector": "proxy", + "message": "Proxy DNS lookup failed or timed out" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-linux-edge.json b/tests/golden/ENV012-linux-edge.json new file mode 100644 index 0000000..5a2bad8 --- /dev/null +++ b/tests/golden/ENV012-linux-edge.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic linux edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unknown" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-linux-negative.json b/tests/golden/ENV012-linux-negative.json new file mode 100644 index 0000000..14bd695 --- /dev/null +++ b/tests/golden/ENV012-linux-negative.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic linux negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "untested" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-linux-positive.json b/tests/golden/ENV012-linux-positive.json new file mode 100644 index 0000000..351137c --- /dev/null +++ b/tests/golden/ENV012-linux-positive.json @@ -0,0 +1,43 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic linux positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "linux", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unreachable" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV012", + "severity": "info", + "component": "network", + "title": "Configured proxy did not accept a connection", + "detected": [ + "HTTPS_PROXY http://127.0.0.1:8888: connection refused or no route" + ], + "cause": "The configured proxy endpoint refused the bounded TCP connection or had no reachable network route.", + "fix": { + "summary": "Check the configured proxy host/port and local network or VPN, then repeat the opt-in probe.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV012 --probe-network", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV012.md" + } + ] +} diff --git a/tests/golden/ENV012-windows-denied.json b/tests/golden/ENV012-windows-denied.json new file mode 100644 index 0000000..6bd7101 --- /dev/null +++ b/tests/golden/ENV012-windows-denied.json @@ -0,0 +1,30 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic windows denied; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unknown" + } + ], + "errors": [ + { + "collector": "proxy", + "message": "Proxy inspection denied" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-windows-dns-failure.json b/tests/golden/ENV012-windows-dns-failure.json new file mode 100644 index 0000000..3d54a9a --- /dev/null +++ b/tests/golden/ENV012-windows-dns-failure.json @@ -0,0 +1,30 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic windows dns-failure; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unknown" + } + ], + "errors": [ + { + "collector": "proxy", + "message": "Proxy DNS lookup failed or timed out" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-windows-edge.json b/tests/golden/ENV012-windows-edge.json new file mode 100644 index 0000000..dcecb7b --- /dev/null +++ b/tests/golden/ENV012-windows-edge.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic windows edge; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unknown" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-windows-negative.json b/tests/golden/ENV012-windows-negative.json new file mode 100644 index 0000000..b0eb89a --- /dev/null +++ b/tests/golden/ENV012-windows-negative.json @@ -0,0 +1,24 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic windows negative; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "untested" + } + ] + }, + "expectedFindings": [] +} diff --git a/tests/golden/ENV012-windows-positive.json b/tests/golden/ENV012-windows-positive.json new file mode 100644 index 0000000..4dca42a --- /dev/null +++ b/tests/golden/ENV012-windows-positive.json @@ -0,0 +1,43 @@ +{ + "ruleId": "ENV012", + "description": "Synthetic windows positive; authored evidence expectations, no live capture", + "snapshot": { + "schemaVersion": 1, + "collectedAt": "2026-11-13T10:00:00Z", + "platform": { + "os": "windows", + "version": "test", + "arch": "amd64", + "elevated": false + }, + "proxies": [ + { + "variable": "HTTPS_PROXY", + "url": "http://127.0.0.1:8888", + "host": "127.0.0.1", + "port": 8888, + "probe": "unreachable" + } + ] + }, + "expectedFindings": [ + { + "ruleId": "ENV012", + "severity": "info", + "component": "network", + "title": "Configured proxy did not accept a connection", + "detected": [ + "HTTPS_PROXY http://127.0.0.1:8888: connection refused or no route" + ], + "cause": "The configured proxy endpoint refused the bounded TCP connection or had no reachable network route.", + "fix": { + "summary": "Check the configured proxy host/port and local network or VPN, then repeat the opt-in probe.", + "automatic": false, + "requiresElevation": false, + "reversible": true + }, + "verify": "envdoctor diagnose --rule ENV012 --probe-network", + "docs": "https://github.com/shellghostt/envdoctor/blob/main/docs/rules/ENV012.md" + } + ] +} diff --git a/tests/golden/README.md b/tests/golden/README.md index 1bb81c7..fb73226 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -1,7 +1,18 @@ # Golden files -One JSON fixture per rule, named after the rule id: `ENV004.json`. A fixture contains a captured -collector snapshot and the exact expected diagnosis. Rules are tested against these files rather than -against live machine state. +Fixtures are named by rule, OS and case, for example `ENV007-windows-positive.json`. Each JSON file +contains a synthetic collector snapshot and exact expected findings. Pure rules are tested against +these files rather than live machine state; integration tests also run every case through the built CLI, +checking findings, JSON schemas and exit codes. + +There are 172 cases: all 54 original M2 fixtures remain unchanged, with 118 additional cases covering +ENV007–ENV012 and expanded ENV006 failure states. Every rule has positive, negative and edge cases +for Windows, Linux and macOS. Additional cases exercise API overrides/malformed bounds, explicit +dependency absence, unsupported Windows arm64 OpenSSL, conflicting lockfile families, unknown runner +versions, explicit Git policy, canonical venv paths and degraded proxy probes. + +Expected findings are reviewed evidence. Do not regenerate expectations from production rule output to +make a failing test pass. A missing rule or a rule without fixtures fails the harness. Fixtures remain +bounded, versioned data; fixture diagnosis rejects live-evidence options and invokes no collectors. Real machine output is never committed here without redaction (usernames, hostnames, tokens). diff --git a/tests/integration/cli_test.go b/tests/integration/cli_test.go index 4c95256..841c338 100644 --- a/tests/integration/cli_test.go +++ b/tests/integration/cli_test.go @@ -46,8 +46,8 @@ func TestBuiltCLIWithAllGoldenFixtures(t *testing.T) { 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) + if err != nil || len(fixtures) < 108 { + t.Fatalf("expected the 54 M2 cases plus at least 54 M3 cases: %d %v", len(fixtures), err) } for _, path := range fixtures { t.Run(filepath.Base(path), func(t *testing.T) { @@ -100,7 +100,7 @@ func TestBuiltCLIWithAllGoldenFixtures(t *testing.T) { if len(fixture.ExpectedFindings) == 0 { fixture.ExpectedFindings = nil } - if document.ExitCode != code || document.Rules != 6 || !reflect.DeepEqual(document.Findings, fixture.ExpectedFindings) { + if document.ExitCode != code || document.Rules != 12 || !reflect.DeepEqual(document.Findings, fixture.ExpectedFindings) { t.Fatalf("unexpected CLI report: %s", out.String()) } var decoded any