diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..cdf0f35 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# +# CodeRabbit configuration for nock. +# Reviews are scoped to the git-flow integration branches: pull requests INTO +# `develop` (feature -> develop) and INTO `main` (develop -> main). On those PRs a +# review can be (re)triggered any time by commenting `@coderabbitai review`. +language: en-US +early_access: false + +reviews: + # CHILL keeps the signal high — fewer, higher-confidence findings — matching how + # this repo reviews (correctness + security over nitpicks). + profile: chill + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + + auto_review: + enabled: true + drafts: false + # Only PRs targeting these branches are reviewed: feature -> develop, and the + # develop -> main release PR. PRs to other bases are left alone. + base_branches: + - develop + - main + +chat: + auto_reply: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a551c22..9784a27 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] # gate both hops: feature -> develop and develop -> main schedule: - cron: "27 3 * * 1" # weekly, Monday 03:27 UTC diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..1327636 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,38 @@ +name: gitleaks + +# Secret scanning — mechanically enforces nock's hard rule that API keys never +# land in commits, logs, or history. Gitleaks is the de-facto OSS secret scanner +# and is free for public repositories. +# +# Scoped to both git-flow merge hops: PRs into develop (feature -> develop) and +# into main (develop -> main), plus a weekly sweep that catches a secret pattern +# disclosed after it was committed. Report-only for now: this check is intentionally +# NOT a required status check yet — let it run and annotate while false positives +# are tuned, then promote it to required in branch protection. + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + - cron: "13 6 * * 1" # weekly, Monday 06:13 UTC + workflow_dispatch: + +permissions: + contents: read + pull-requests: write # post the findings summary on the PR + security-events: write # upload SARIF to the Security tab + +jobs: + scan: + name: scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # full history so PR-range and push diffs are scanned + - name: gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbd1203..c2a432c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,10 +13,22 @@ Thanks for your interest in nock. This guide gets you productive fast. ```bash make build # compile to bin/nock make test # unit tests with the race detector -make check # full local gate: fmt, vet, lint, test — run before pushing +make check # quick local gate: fmt, vet, lint, test (host OS only) +make verify # full CI mirror: cross-platform build + lint, coverage, govulncheck make run ARGS="search web directories" ``` +`make check` is the fast pre-commit gate but only exercises your host OS. Before +pushing anything that touches platform-specific code (build-tagged files, +filesystem permissions, `os/exec`), run **`make verify`** — it cross-builds every +release target (`linux`, `darwin`, `windows`; `amd64`/`arm64`), cross-lints each +target OS (so `*_windows.go` and the like are checked, not just the host's), and +runs coverage plus `govulncheck`, catching build-tag and compile breakage the +host-only gate misses. It cannot run another OS's *test suite* (CI does that), but +it catches the compile/lint failures that otherwise only surface on the runner. +The script is [`scripts/dev-check.sh`](scripts/dev-check.sh); `FAST=1` skips the +cross-platform matrix. + The scaffold is stdlib-only and builds offline. Heavy dependencies (bubbletea, the MCP SDK, the Anthropic SDK, the YAML parser) are introduced milestone by milestone — see `ROADMAP.md`. @@ -44,6 +56,29 @@ milestone — see `ROADMAP.md`. - Keep PRs focused; add tests for behavior changes. - Fill in the PR template checklist. CI must be green. +### Branching (git-flow) + +Feature work branches off `develop`, never `main`: `feature -> develop`, and +`develop -> main` only at release. Open PRs against `develop`; the +`develop -> main` PR is the release cut. + +### Automated review (CodeRabbit) + +**Why, on a solo project:** nock is single-maintainer, so there is no second +human reviewer to catch what the author misses. nock also *launches commands* — +it has to be safe for public use — so a bug or a permissions gap is a security +issue, not just a defect. CodeRabbit is the cheap, always-on "second pair of +eyes" that fills that gap: it already caught a real secrets-at-rest hole (history +files created before a fix kept loose permissions). It is a reviewer, not a gate +— it never blocks a merge and the maintainer decides every change. + +PRs into `develop` and `main` are reviewed automatically by +[CodeRabbit](https://coderabbit.ai), configured in [`.coderabbit.yaml`](.coderabbit.yaml) +(CHILL profile, scoped to those two base branches). To (re)trigger a pass after +pushing fixes, comment `@coderabbitai review` on the PR. Treat its findings as +review input: verify each against the code, fix the valid ones, and say why +you're skipping the rest. + ## Licensing of contributions By contributing, you agree your contributions are licensed under the project's diff --git a/Makefile b/Makefile index 0ad7afc..3fa4e37 100644 --- a/Makefile +++ b/Makefile @@ -82,6 +82,11 @@ tidy: .PHONY: check check: fmt vet lint test +## verify: full CI mirror incl. cross-platform build/lint (scripts/dev-check.sh) +.PHONY: verify +verify: + ./scripts/dev-check.sh + ## clean: remove build artifacts .PHONY: clean clean: diff --git a/README.md b/README.md index 46a63c6..14004e5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,15 @@ rebuilt from scratch: engine-first, agent-native, vendor-neutral. See > ⚠️ For **authorized** security testing only. You are responsible for having > permission to run what you launch. See [`SECURITY.md`](SECURITY.md). +## The problem + +Mid-engagement, you burn seconds you don't have recalling the exact flags — or +your cheatsheets rot in a scratch file you `grep` by hand and re-type. When recall +is slower than just remembering it, the tool has failed. + +nock makes recall instant: fuzzy-search the command, set your target once, review +the fully-resolved line, fire. Same knowledge, two keystrokes instead of twenty. + ## Why nock - **Single static binary** (Go) — instant startup, trivial distribution, no @@ -60,6 +69,20 @@ rebuilt from scratch: engine-first, agent-native, vendor-neutral. See - **Graceful degradation** — fuzzy search needs no AI and no network. AI ranking layers on only when configured. +## At a glance + +| | raw notes | arsenal | nock | +| --------------------------------- | :-------: | :-----: | :--: | +| Set a variable once, fill it everywhere | 🔴 | 🟢 | 🟢 | +| Single static binary, no runtime drift | — | 🔴 | 🟢 | +| Works offline / air-gapped | 🟢 | 🟢 | 🟢 | +| Optional AI ranking | 🔴 | 🔴 | 🟢 | +| MCP / agent-native (query from Claude, Cursor…) | 🔴 | 🔴 | 🟢 | + +[arsenal](https://github.com/Orange-Cyberdefense/arsenal) pioneered fast command +recall; nock keeps the idea and modernizes the architecture — engine-first, +agent-native, single Go binary. + ## Status Early. **Milestone 0 (scaffold)** is in place: the engine, fuzzy search, the variable @@ -91,6 +114,23 @@ export NOCK_CHEATSHEETS=/path/to/your/cheatsheets ./bin/nock search smb ``` +## What it feels like + +The interactive TUI (Milestone 2) is the search → fill → fire loop end to end: + +``` +› nmap serv # fuzzy-search as you type + [recon] nmap-service-scan nmap -sV -sC -oA scans/ + + = 10.0.0.5 # fill each variable once + +✓ nmap -sV -sC -oA scans/10.0.0.5 10.0.0.5 # review the resolved line, then fire +``` + +Nothing runs until you confirm, and what's shown is exactly what's emitted — no +hidden expansion ([show-before-fire](SECURITY.md)). _An asciinema recording lands +with the TUI._ + ## Modes | Command | Mode | Status | @@ -138,6 +178,8 @@ Versioned, in-repo docs live in [`docs/`](docs/): - [CLI reference](docs/cli.md) — every mode and flag. - [Cheatsheet schema](docs/schema.md) — the `pkg/format` authoring contract. - [MCP setup](docs/mcp.md) — exposing nock to AI agents (Milestone 3). +- [Contributing](CONTRIBUTING.md) — dev loop, git-flow branching, and the + CodeRabbit review workflow (`@coderabbitai review` on PRs to `develop`/`main`). Narrative and community content (tutorials, FAQ, operator workflows) lives in the [Wiki](https://github.com/jessn-dev/nock/wiki). diff --git a/ROADMAP.md b/ROADMAP.md index f4486f3..18cd01e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -124,9 +124,12 @@ type Provider interface { to the operator before any execution/prefill. What is displayed must equal what runs — no hidden expansion, no auto-execution. This is the primary mitigation for the malicious-cheatsheet injection vector and must be designed in now, not retrofitted. -- [ ] tmux pane / prefill-into-shell output (prefill into the shell line, operator hits Enter — - never auto-run on nock's behalf) -- [ ] command history +- [x] tmux pane / prefill-into-shell output (prefill into the shell line, operator hits Enter — + never auto-run on nock's behalf) — `--fire=stdout|tmux` + per-command `ctrl+t` override; + tmux uses `send-keys -l` (no Enter), gated on `$TMUX` so it never fires where it can't work +- [x] command history — recall with `ctrl+r`; stores template + var bindings (never the + flattened resolved string), owner-only on every OS (0600 / Windows owner DACL), + `NOCK_HISTORY=off` disables - [ ] Ship: GitHub Releases, Homebrew tap, `go install`, AUR - [ ] **Launch-day: enable GitHub Discussions** (community + traction, feeds the funding story — sponsors/grants want a visible community). Categories: Announcements, diff --git a/SECURITY.md b/SECURITY.md index 457c640..2bfd2b9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,3 +30,20 @@ Out of scope: nock reads AI provider API keys from environment variables and never writes them to disk, logs, or cheatsheet files. Report any deviation as a vulnerability. + +## Automated security testing + +Every pull request into `develop` and `main` runs a free, industry-standard +security stack in CI — the same baseline enterprises use at the free tier: + +| Check | Tool | What it catches | +|---|---|---| +| SAST (Go) | [gosec](https://github.com/securego/gosec) via golangci-lint | injection, weak crypto, unsafe file perms | +| SAST (deep) | [CodeQL](https://codeql.github.com) (`security-and-quality`) | data-flow vulnerabilities | +| Dependency CVEs | [govulncheck](https://pkg.go.dev/golang.org/x/vuln) + Dependabot | known vulns in pinned deps | +| Secrets | [gitleaks](https://github.com/gitleaks/gitleaks) | API keys/tokens committed to history | +| Supply chain | [OpenSSF Scorecard](https://github.com/ossf/scorecard) | project security posture | + +gosec and govulncheck gate every PR; CodeQL and gitleaks cover both merge hops +(`feature -> develop` and `develop -> main`). The binary is built `CGO_ENABLED=0` +with `-trimpath`; releases ship checksums and an SBOM. diff --git a/docs/cli.md b/docs/cli.md index f0f9d32..1c0644a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -18,9 +18,39 @@ nock version print build metadata [available] | Variable | Default | Meaning | |---|---|---| | `NOCK_CHEATSHEETS` | `examples/cheatsheets` | Directory scanned for `.json`/`.yaml`/`.yml` cheatsheets. | +| `NOCK_HISTORY` | `/nock/history.jsonl` | History file path. Set to an explicit path to relocate it, or `off` to disable history entirely. | Load errors are non-fatal: a bad file is reported on stderr and the rest still load. +`` is `os.UserConfigDir()` — `~/.config` (Linux), `~/Library/Application +Support` (macOS), `%AppData%` (Windows). + +## `nock` (interactive TUI) + +The default mode: fuzzy-search, fill `` placeholders, review the resolved +command, then fire it. + +``` +--fire=stdout|tmux where a confirmed command is delivered (default: stdout) +``` + +- **stdout** (default, every platform): the command is printed once after the UI + tears down, for the shell to capture — nock never runs it. +- **tmux**: the command is *prefilled* into the current tmux pane via + `tmux send-keys -l` with **no** trailing Enter, so you still fire it yourself. + Only offered inside a tmux session (`$TMUX` set); unavailable on Windows. + +Keys: `ctrl+t` on the confirm screen overrides the target to tmux for one command; +`ctrl+r` opens history. + +### History + +Fired commands are recalled with `ctrl+r`. nock stores each command's **template +and variable bindings separately** — never the flattened resolved string — so a +recall re-resolves through the engine and values stay redactable. The history file +is created **owner-only on every OS**: POSIX `0600` on Linux/macOS, an owner-only +ACL on Windows. Set `NOCK_HISTORY=off` to disable persistence. + ## `nock search ` Fuzzy-search loaded commands and print matches, best first. Offline, instant, no diff --git a/go.mod b/go.mod index d5f2880..5cd5f66 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/goccy/go-yaml v1.19.2 + golang.org/x/sys v0.38.0 ) require ( @@ -36,6 +37,5 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/internal/fire/fire.go b/internal/fire/fire.go new file mode 100644 index 0000000..5726658 --- /dev/null +++ b/internal/fire/fire.go @@ -0,0 +1,90 @@ +// Package fire delivers a confirmed, fully resolved command out of nock to the +// operator's chosen target. It is the concrete edge of show-before-fire (ADR 009/ +// 015): nothing here resolves or expands anything — the string passed in is +// exactly what the confirm screen displayed. +// +// nock never executes the command itself: +// - stdout prints it for the operator's shell to capture (the default; ADR 015). +// - tmux prefills it into the current pane's command line via `send-keys -l` +// with NO trailing newline, so the operator still presses Enter to run it. +// +// Both are explicit, operator-driven acts. Displayed == emitted; no auto-run. +package fire + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" +) + +// Target is where a confirmed command is delivered. +type Target string + +const ( + // Stdout prints the command to standard output (default). + Stdout Target = "stdout" + // Tmux prefills the command into the current tmux pane without running it. + Tmux Target = "tmux" +) + +// ErrUnknownTarget is returned by Parse for an unrecognised target name. +var ErrUnknownTarget = errors.New("fire: unknown target") + +// Parse converts a flag value to a Target. Empty string defaults to Stdout. +func Parse(s string) (Target, error) { + switch Target(s) { + case "", Stdout: + return Stdout, nil + case Tmux: + return Tmux, nil + default: + return "", fmt.Errorf("%w %q (want stdout|tmux)", ErrUnknownTarget, s) + } +} + +// Available reports whether the target can be used in the current environment. +// Tmux requires running inside a tmux session ($TMUX set); Stdout is always +// available. The TUI uses this to avoid offering a tmux fire that would fail. +func (t Target) Available() bool { + switch t { + case Tmux: + return os.Getenv("TMUX") != "" + default: + return true + } +} + +// Emit delivers cmd to the target. Stdout writes to the package's Stdout writer +// (a trailing newline, so a shell reads a complete line). Tmux shells out to +// `tmux send-keys -l` — literal, no Enter — leaving cmd on the pane's prompt for +// the operator to fire. +func Emit(t Target, cmd string) error { + switch t { + case Tmux: + if !t.Available() { + return errors.New("fire: not inside a tmux session ($TMUX unset)") + } + // -l sends the argument literally; the absence of a following Enter is + // what makes this a prefill, not an auto-run. + c := exec.Command("tmux", "send-keys", "-l", cmd) + if out, err := c.CombinedOutput(); err != nil { + return fmt.Errorf("fire: tmux send-keys: %w: %s", err, out) + } + return nil + case "", Stdout: + if _, err := fmt.Fprintln(Out, cmd); err != nil { + return fmt.Errorf("fire: stdout: %w", err) + } + return nil + default: + // An unknown target must fail loudly, not silently fall back to stdout — + // that would deliver a command somewhere the operator did not choose. + return fmt.Errorf("%w %q (want stdout|tmux)", ErrUnknownTarget, t) + } +} + +// Out is the writer the Stdout target prints to. It is a package variable so +// tests can capture emitted commands without a real terminal. +var Out io.Writer = os.Stdout diff --git a/internal/fire/fire_test.go b/internal/fire/fire_test.go new file mode 100644 index 0000000..32720a9 --- /dev/null +++ b/internal/fire/fire_test.go @@ -0,0 +1,103 @@ +package fire + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + in string + want Target + wantErr bool + }{ + {"", Stdout, false}, + {"stdout", Stdout, false}, + {"tmux", Tmux, false}, + {"clipboard", "", true}, + {"STDOUT", "", true}, // case-sensitive on purpose + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got, err := Parse(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("Parse(%q) err = nil, want error", tt.in) + } + if !errors.Is(err, ErrUnknownTarget) { + t.Fatalf("Parse(%q) err = %v, want ErrUnknownTarget", tt.in, err) + } + return + } + if err != nil { + t.Fatalf("Parse(%q) unexpected err: %v", tt.in, err) + } + if got != tt.want { + t.Fatalf("Parse(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestStdoutAlwaysAvailable(t *testing.T) { + if !Stdout.Available() { + t.Fatal("stdout target must always be available") + } +} + +func TestTmuxAvailabilityFollowsEnv(t *testing.T) { + t.Setenv("TMUX", "") + if Tmux.Available() { + t.Fatal("tmux must be unavailable when $TMUX is empty") + } + t.Setenv("TMUX", "/tmp/tmux-1000/default,1234,0") + if !Tmux.Available() { + t.Fatal("tmux must be available when $TMUX is set") + } +} + +func TestEmitStdoutWritesLine(t *testing.T) { + var buf bytes.Buffer + old := Out + Out = &buf + defer func() { Out = old }() + + if err := Emit(Stdout, "nmap -sV 10.0.0.5"); err != nil { + t.Fatalf("Emit: %v", err) + } + got := buf.String() + if !strings.HasSuffix(got, "\n") { + t.Fatal("stdout emit must end with a newline so a shell reads a full line") + } + if strings.TrimSpace(got) != "nmap -sV 10.0.0.5" { + t.Fatalf("emitted %q, want the command verbatim", got) + } +} + +func TestEmitTmuxWithoutSessionErrors(t *testing.T) { + t.Setenv("TMUX", "") + if err := Emit(Tmux, "id"); err == nil { + t.Fatal("emitting to tmux outside a session must error, not silently drop") + } +} + +func TestEmitUnknownTargetErrors(t *testing.T) { + // A bad target must fail loudly, never silently fall back to stdout. + var buf bytes.Buffer + old := Out + Out = &buf + defer func() { Out = old }() + + err := Emit(Target("clipboard"), "id") + if err == nil { + t.Fatal("unknown target must error") + } + if !errors.Is(err, ErrUnknownTarget) { + t.Fatalf("err = %v, want ErrUnknownTarget", err) + } + if buf.Len() != 0 { + t.Fatalf("unknown target must not write to stdout, got %q", buf.String()) + } +} diff --git a/internal/history/history.go b/internal/history/history.go new file mode 100644 index 0000000..ddf91a3 --- /dev/null +++ b/internal/history/history.go @@ -0,0 +1,148 @@ +// Package history records fired commands so an operator can recall and re-fire +// them mid-engagement. It is a frontend concern, not engine logic: the engine +// still owns search and resolution. History only persists what was fired. +// +// Security — secrets at rest: a resolved command can carry passwords, tokens, or +// target addresses. History stores the command *template* and the variable +// *bindings* separately, never a single flattened resolved string. That keeps a +// value redactable and means recall re-resolves through the engine rather than +// trusting a baked-in line. The file is created owner-only on every platform — +// POSIX 0600 on Unix, an owner-restricted DACL on Windows (see openAppend, which +// has per-OS implementations) — and NOCK_HISTORY=off disables persistence. +package history + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Entry is one fired command. The template and the variable bindings are kept +// apart on purpose (see the package doc): the resolved string is never stored. +type Entry struct { + Time time.Time `json:"time"` + Sheet string `json:"sheet,omitempty"` + ID string `json:"id"` + Template string `json:"template"` + Vars map[string]string `json:"vars,omitempty"` +} + +// Store appends and reads history entries at a single file path. A Store with an +// empty path is a no-op sink (Append discards, Recent returns nothing); this is +// how NOCK_HISTORY=off and "no config dir" degrade gracefully. +type Store struct { + path string +} + +// New returns a Store writing to path. An empty path makes the Store a no-op. +func New(path string) *Store { return &Store{path: path} } + +// Path reports the file the Store writes to ("" when disabled). +func (s *Store) Path() string { return s.path } + +// DefaultPath resolves the history file location. It honours NOCK_HISTORY: +// "off" (case-insensitive) disables history and returns ""; any other value is +// used verbatim as the path. Otherwise it is /nock/history.jsonl. +func DefaultPath() (string, error) { + if v, ok := os.LookupEnv("NOCK_HISTORY"); ok { + if isOff(v) { + return "", nil + } + return v, nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("history: locate config dir: %w", err) + } + return filepath.Join(dir, "nock", "history.jsonl"), nil +} + +func isOff(v string) bool { + for _, off := range []string{"off", "0", "false", "none"} { + if strings.EqualFold(v, off) { + return true + } + } + return false +} + +// Append writes one entry as a JSON line. It is a no-op for a disabled Store. The +// directory is created 0700 and the file is created owner-only (openAppend has a +// per-OS implementation) so secrets in variable bindings are not readable by +// other users on a shared host. +func (s *Store) Append(e Entry) error { + if s.path == "" { + return nil + } + if e.Time.IsZero() { + e.Time = time.Now() + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("history: create dir: %w", err) + } + f, err := openAppend(s.path) + if err != nil { + return err + } + line, err := json.Marshal(e) + if err != nil { + _ = f.Close() + return fmt.Errorf("history: marshal: %w", err) + } + if _, err := fmt.Fprintf(f, "%s\n", line); err != nil { + _ = f.Close() + return fmt.Errorf("history: write: %w", err) + } + // Close error is reported: it can signal the record did not reach disk. + if err := f.Close(); err != nil { + return fmt.Errorf("history: close: %w", err) + } + return nil +} + +// Recent returns up to n most-recent entries, newest first. A missing file (or a +// disabled Store) yields an empty slice, not an error. Malformed lines are +// skipped so one bad record never breaks recall. +func (s *Store) Recent(n int) ([]Entry, error) { + if s.path == "" || n <= 0 { + return nil, nil + } + f, err := os.Open(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("history: open: %w", err) + } + defer func() { _ = f.Close() }() // read-only: a close error cannot lose data + + var all []Entry + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + var e Entry + if err := json.Unmarshal(line, &e); err != nil { + continue // skip a corrupt record rather than fail the whole recall + } + all = append(all, e) + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("history: read: %w", err) + } + + // Newest first, capped at n. + out := make([]Entry, 0, n) + for i := len(all) - 1; i >= 0 && len(out) < n; i-- { + out = append(out, all[i]) + } + return out, nil +} diff --git a/internal/history/history_test.go b/internal/history/history_test.go new file mode 100644 index 0000000..d1d62eb --- /dev/null +++ b/internal/history/history_test.go @@ -0,0 +1,151 @@ +package history + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestAppendRecentRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.jsonl") + s := New(path) + + for i, id := range []string{"first", "second", "third"} { + err := s.Append(Entry{ + Time: time.Unix(int64(i+1), 0), + ID: id, + Template: "echo ", + Vars: map[string]string{"x": id}, + }) + if err != nil { + t.Fatalf("Append %q: %v", id, err) + } + } + + got, err := s.Recent(10) + if err != nil { + t.Fatalf("Recent: %v", err) + } + if len(got) != 3 { + t.Fatalf("got %d entries, want 3", len(got)) + } + // Newest first. + if got[0].ID != "third" || got[2].ID != "first" { + t.Fatalf("order = %s..%s, want third..first (newest first)", got[0].ID, got[2].ID) + } + // Bindings survive the round trip and are stored, not the resolved string. + if got[0].Vars["x"] != "third" || got[0].Template != "echo " { + t.Fatalf("entry not preserved: %+v", got[0]) + } +} + +func TestRecentCapsAtN(t *testing.T) { + path := filepath.Join(t.TempDir(), "h.jsonl") + s := New(path) + for i := 0; i < 5; i++ { + if err := s.Append(Entry{ID: "c", Template: "x"}); err != nil { + t.Fatal(err) + } + } + got, err := s.Recent(2) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("Recent(2) returned %d, want 2", len(got)) + } +} + +func TestDisabledStoreIsNoOp(t *testing.T) { + s := New("") // empty path = disabled + if err := s.Append(Entry{ID: "x"}); err != nil { + t.Fatalf("Append on disabled store must be a no-op, got %v", err) + } + got, err := s.Recent(10) + if err != nil || got != nil { + t.Fatalf("Recent on disabled store = (%v, %v), want (nil, nil)", got, err) + } +} + +func TestRecentMissingFileIsEmpty(t *testing.T) { + s := New(filepath.Join(t.TempDir(), "does-not-exist.jsonl")) + got, err := s.Recent(10) + if err != nil { + t.Fatalf("missing file must not error, got %v", err) + } + if len(got) != 0 { + t.Fatalf("missing file should yield no entries, got %d", len(got)) + } +} + +func TestRecentSkipsCorruptLine(t *testing.T) { + path := filepath.Join(t.TempDir(), "h.jsonl") + good := `{"id":"ok","template":"x"}` + // A garbage line between two good ones must be skipped, not fatal. + content := good + "\n" + "{not json}\n" + good + "\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + got, err := New(path).Recent(10) + if err != nil { + t.Fatalf("corrupt line must not fail recall, got %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d valid entries, want 2 (corrupt line skipped)", len(got)) + } +} + +func TestDefaultPathHonoursEnv(t *testing.T) { + t.Setenv("NOCK_HISTORY", "off") + p, err := DefaultPath() + if err != nil { + t.Fatal(err) + } + if p != "" { + t.Fatalf("NOCK_HISTORY=off must disable history (empty path), got %q", p) + } + + t.Setenv("NOCK_HISTORY", "/custom/h.jsonl") + p, err = DefaultPath() + if err != nil { + t.Fatal(err) + } + if p != "/custom/h.jsonl" { + t.Fatalf("explicit NOCK_HISTORY path not honoured, got %q", p) + } +} + +func TestDefaultPathOffIsCaseInsensitive(t *testing.T) { + for _, v := range []string{"off", "OFF", "Off", "oFf", "None", "FALSE"} { + t.Setenv("NOCK_HISTORY", v) + p, err := DefaultPath() + if err != nil { + t.Fatalf("%q: %v", v, err) + } + if p != "" { + t.Fatalf("NOCK_HISTORY=%q must disable history, got %q", v, p) + } + } +} + +// TestFileIsOwnerOnly checks the secrets-at-rest guarantee on Unix, where mode +// bits are enforced. Windows uses an ACL instead (covered by manual review and +// the openAppend implementation); os.FileMode bits are not meaningful there. +func TestFileIsOwnerOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX mode bits not meaningful on Windows (owner-only DACL used instead)") + } + path := filepath.Join(t.TempDir(), "h.jsonl") + if err := New(path).Append(Entry{ID: "x", Template: "y"}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("history file mode = %o, want 0600 (secrets at rest)", perm) + } +} diff --git a/internal/history/open_unix.go b/internal/history/open_unix.go new file mode 100644 index 0000000..a315a77 --- /dev/null +++ b/internal/history/open_unix.go @@ -0,0 +1,27 @@ +//go:build !windows + +package history + +import ( + "fmt" + "os" +) + +// openAppend opens the history file for appending, creating it 0600 if absent. +// The kernel enforces owner-only access, so secrets in variable bindings are not +// readable by other users on the host. An *existing* file is re-tightened to 0600 +// as well, so a file created or migrated with looser permissions cannot keep them +// while secrets are appended. +func openAppend(path string) (*os.File, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) //nolint:gosec // G304: history path is operator-supplied by design + if err != nil { + return nil, fmt.Errorf("history: open: %w", err) + } + // O_CREATE only sets the mode on creation; enforce owner-only on every open so + // a pre-existing, looser file is hardened before it receives secrets. + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + return nil, fmt.Errorf("history: chmod: %w", err) + } + return f, nil +} diff --git a/internal/history/open_windows.go b/internal/history/open_windows.go new file mode 100644 index 0000000..49faab4 --- /dev/null +++ b/internal/history/open_windows.go @@ -0,0 +1,112 @@ +//go:build windows + +package history + +import ( + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// openAppend opens the history file for appending, enforcing an owner-only +// security descriptor — the Windows equivalent of Unix 0600. The descriptor's +// DACL is protected (no inheritance from the parent directory) and grants full +// file access to the current user alone, so secrets in variable bindings are not +// readable by other principals on a shared host. +// +// The descriptor is set two ways so both new and existing files are covered: +// - SecurityAttributes applies it atomically at creation time. +// - SetSecurityInfo reapplies it on every open, so a file created or migrated +// with a looser ACL is re-tightened before it receives secrets (CreateFile's +// OPEN_ALWAYS otherwise reuses the existing ACL untouched). +// +// FILE_SHARE_READ lets a concurrent reader (e.g. Recent) open the file while an +// append is in flight; access is still governed by the owner-only DACL, not the +// share mode. +func openAppend(path string) (*os.File, error) { + sd, err := ownerOnlyDescriptor() + if err != nil { + return nil, err + } + // Whether the file already exists decides how it is secured. A new file gets its + // owner-only DACL atomically from SecurityAttributes at creation — privilege-free + // and guaranteed. A pre-existing file is additionally re-tightened, but only + // best-effort: rewriting an existing file's ACL can be denied in some + // environments, and a file nock created is already owner-only, so a failed + // re-tighten must never block an append. + preexisting := false + if _, statErr := os.Stat(path); statErr == nil { + preexisting = true + } + + sa := &windows.SecurityAttributes{SecurityDescriptor: sd} + sa.Length = uint32(unsafe.Sizeof(*sa)) + + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, fmt.Errorf("history: path: %w", err) + } + h, err := windows.CreateFile( + p, + windows.FILE_APPEND_DATA|windows.WRITE_DAC, + windows.FILE_SHARE_READ, + sa, + windows.OPEN_ALWAYS, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return nil, fmt.Errorf("history: create: %w", err) + } + + if preexisting { + // Defense-in-depth for a file that existed before this open (e.g. created + // outside nock with a looser ACL). Ignore a denial: nock-created files are + // already owner-only from their SecurityAttributes at creation time. + _ = enforceOwnerOnly(h, sd) + } + return os.NewFile(uintptr(h), path), nil +} + +// enforceOwnerOnly reapplies the protected, owner-only DACL from sd onto an +// already-open handle, correcting a pre-existing file's permissions rather than +// inheriting them. Only the DACL is set: the creating user is already the owner, +// and rewriting the owner at runtime needs SeRestorePrivilege (denied to ordinary +// processes, e.g. CI runners). The DACL is what actually gates access. Callers +// invoke this best-effort — new files are already secured at creation, so a +// failure here does not compromise a file nock itself wrote. +func enforceOwnerOnly(h windows.Handle, sd *windows.SECURITY_DESCRIPTOR) error { + dacl, _, err := sd.DACL() + if err != nil { + return fmt.Errorf("history: read dacl: %w", err) + } + info := windows.SECURITY_INFORMATION( + windows.DACL_SECURITY_INFORMATION | + windows.PROTECTED_DACL_SECURITY_INFORMATION, + ) + if err := windows.SetSecurityInfo(h, windows.SE_FILE_OBJECT, info, nil, nil, dacl, nil); err != nil { + return fmt.Errorf("history: enforce owner-only acl: %w", err) + } + return nil +} + +// ownerOnlyDescriptor builds a security descriptor whose owner is the current +// user and whose protected DACL contains a single ACE granting that user full +// file access — no other principal, no inherited ACEs. +func ownerOnlyDescriptor() (*windows.SECURITY_DESCRIPTOR, error) { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, fmt.Errorf("history: token user: %w", err) + } + sid := user.User.Sid.String() + // SDDL: owner+group = current user; D:P = protected DACL (blocks inheritance); + // one ACE (A) granting FILE_ALL_ACCESS (FA) to the user SID. No other ACEs. + sddl := fmt.Sprintf("O:%[1]sG:%[1]sD:P(A;;FA;;;%[1]s)", sid) + sd, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return nil, fmt.Errorf("history: build security descriptor: %w", err) + } + return sd, nil +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 8fa6e82..e07aa41 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -21,9 +21,14 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/jessn-dev/nock/internal/engine" + "github.com/jessn-dev/nock/internal/fire" + "github.com/jessn-dev/nock/internal/history" "github.com/jessn-dev/nock/pkg/format" ) +// maxHistory bounds how many recent entries the recall screen loads and draws. +const maxHistory = 20 + // maxVisible bounds how many search results are drawn at once; the list scrolls // around the cursor beyond that. const maxVisible = 12 @@ -35,24 +40,57 @@ const ( stageSearch stage = iota // typing a query, navigating results stageVars // prompting for the selected command's missing variables stageConfirm // showing the fully resolved command before firing + stageHistory // browsing recently fired commands to recall one ) +// Options configures the TUI's side-effecting edges. Both are optional: a nil +// History disables persistence and an empty DefaultTarget falls back to stdout, +// so the zero Options keeps the original print-to-stdout behaviour. +type Options struct { + // History records fired commands for later recall. Nil means no persistence. + History *history.Store + // DefaultTarget is where Enter on the confirm screen delivers the command. + DefaultTarget fire.Target +} + // Run starts the interactive TUI against the given engine. It blocks until the -// operator quits. If a command was confirmed, its fully resolved form is printed -// to stdout after the UI tears down, so a shell can capture it. -func Run(ctx context.Context, e *engine.Engine) error { +// operator quits. If a command was confirmed, it is delivered to the chosen fire +// target after the UI tears down (stdout by default) and recorded to history. +func Run(ctx context.Context, e *engine.Engine, opts Options) error { m := newModel(e) + if opts.History != nil { + m.hist = opts.History + } + if opts.DefaultTarget != "" { + m.defaultTarget = opts.DefaultTarget + } + // Validate the fire target before entering the alt-screen. A default that + // cannot work here (e.g. --fire=tmux outside tmux) must fail loudly now, not + // after the operator has confirmed a command and the UI has torn down. + if !m.defaultTarget.Available() { + return fmt.Errorf("tui: fire target %q unavailable (not inside a tmux session?)", + m.defaultTarget) + } + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx)) final, err := p.Run() if err != nil { return fmt.Errorf("tui: %w", err) } - if fm, ok := final.(model); ok && fm.emit != "" { - // Printed to the normal screen after the alt-screen UI is gone: this is the - // command the operator confirmed, ready to run. nock does not run it. - if _, werr := fmt.Fprintln(os.Stdout, fm.emit); werr != nil { - return fmt.Errorf("tui: emit command: %w", werr) - } + fm, ok := final.(model) + if !ok || fm.emit == "" { + return nil + } + + // Record before delivering: history is the operator's own audit trail and must + // not be lost if delivery to an external target (tmux) errors. + if werr := fm.hist.Append(fm.firedEntry()); werr != nil { + fmt.Fprintf(os.Stderr, "nock: warning recording history: %v\n", werr) + } + // Delivered after the alt-screen UI is gone. nock does not run the command; + // stdout prints it, tmux prefills it — the operator fires it. + if werr := fire.Emit(fm.target, fm.emit); werr != nil { + return fmt.Errorf("tui: fire command: %w", werr) } return nil } @@ -68,14 +106,25 @@ type model struct { cursor int // index into results offset int // first visible result, for scrolling - selected format.Command // the command being filled / confirmed - missing []string // its still-unbound variables, in prompt order - varIdx int // which missing variable is being prompted + selected format.Command // the command being filled / confirmed + selectedSheet string // its source cheatsheet, for the history record + missing []string // its still-unbound variables, in prompt order + varIdx int // which missing variable is being prompted resolved string // fully resolved command shown on the confirm screen emit string // set when the operator confirms; printed after teardown status string // transient hint / error line + // Fire + history wiring. target is where the confirmed command is delivered; + // it starts at defaultTarget and the confirm screen can override it per-command. + hist *history.Store + defaultTarget fire.Target + target fire.Target + + // History recall (stageHistory): entries loaded newest-first, browsed by cursor. + histEntries []history.Entry + histCursor int + width, height int } @@ -88,11 +137,35 @@ func newModel(e *engine.Engine) model { v := textinput.New() v.Prompt = "› " - m := model{engine: e, query: q, varIn: v} + m := model{ + engine: e, + query: q, + varIn: v, + hist: history.New(""), // no-op sink until Run wires a real store + defaultTarget: fire.Stdout, + } m.results = e.Search("") // empty query lists everything in corpus order return m } +// firedEntry captures the just-confirmed command as a history record: the +// template and the variable bindings it used, never the flattened resolved +// string (see internal/history secrets-at-rest note). +func (m model) firedEntry() history.Entry { + binds := make(map[string]string, len(m.selected.Vars())) + for _, name := range m.selected.Vars() { + if v, ok := m.engine.Vars().Get(name); ok { + binds[name] = v + } + } + return history.Entry{ + Sheet: m.selectedSheet, + ID: m.selected.ID, + Template: m.selected.Command, + Vars: binds, + } +} + // Init satisfies tea.Model; it starts the text-input cursor blink. func (m model) Init() tea.Cmd { return textinput.Blink } @@ -111,6 +184,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateVars(msg) case stageConfirm: return m.updateConfirm(msg) + case stageHistory: + return m.updateHistory(msg) } } return m, nil @@ -120,6 +195,8 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit + case "ctrl+r": + return m.openHistory() case "up", "ctrl+k": m.moveCursor(-1) return m, nil @@ -131,6 +208,7 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } m.selected = m.results[m.cursor].Command + m.selectedSheet = m.results[m.cursor].Sheet return m.beginFill() } // Any other key edits the query; re-search and reset the cursor. @@ -175,13 +253,90 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.backToSearch(), nil case "enter": // Show-before-fire: only here, on an explicit keypress against the displayed - // command, does anything leave nock — and only to stdout, never executed. + // command, does anything leave nock — delivered to the default target, + // never executed by nock. m.emit = m.resolved + m.target = m.defaultTarget + return m, tea.Quit + case "ctrl+t": + // Per-command override to tmux prefill. Refused (with a hint) when not in a + // tmux session, so the operator never fires into a target that would error. + if !fire.Tmux.Available() { + m.status = "not inside a tmux session ($TMUX unset)" + return m, nil + } + m.emit = m.resolved + m.target = fire.Tmux return m, tea.Quit } return m, nil } +// openHistory loads recent entries and switches to the recall screen. A load +// error is surfaced on the search screen rather than dropping the operator into +// an empty list with no explanation. +func (m model) openHistory() (tea.Model, tea.Cmd) { + entries, err := m.hist.Recent(maxHistory) + if err != nil { + m.status = "history: " + err.Error() + return m, nil + } + m.histEntries = entries + m.histCursor = 0 + m.status = "" + m.stage = stageHistory + return m, nil +} + +// updateHistory drives the recall screen: navigate entries, then Enter to reload +// a past command back into the fill flow (re-binding its saved variables through +// the engine so the operator can edit them before firing — nothing auto-fires). +func (m model) updateHistory(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + return m.backToSearch(), nil + case "up", "ctrl+k": + if m.histCursor > 0 { + m.histCursor-- + } + return m, nil + case "down", "ctrl+j": + if m.histCursor < len(m.histEntries)-1 { + m.histCursor++ + } + return m, nil + case "enter": + if len(m.histEntries) == 0 { + return m, nil + } + return m.recallEntry(m.histEntries[m.histCursor]) + } + return m, nil +} + +// recallEntry seeds the engine's var store with a past command's saved bindings +// and re-enters the fill flow. Pre-bound variables are skipped by beginFill's +// Missing() check, so the operator lands straight on confirm for an unchanged +// recall — still shown before firing. +func (m model) recallEntry(e history.Entry) (tea.Model, tea.Cmd) { + for name, val := range e.Vars { + m.engine.Vars().Set(name, val) + } + // Rehydrate the live command if it still exists, so the confirm screen keeps + // its risk / requires-auth badges; overlay the stored template in case the + // command's text has since changed. Fall back to a bare command if it's gone. + if cmd, ok := m.engine.Get(e.ID); ok { + cmd.Command = e.Template + m.selected = cmd + } else { + m.selected = format.Command{ID: e.ID, Command: e.Template} + } + m.selectedSheet = e.Sheet + return m.beginFill() +} + // beginFill computes the selected command's missing variables and either prompts // for them or, if none are missing, jumps straight to the confirm screen. func (m model) beginFill() (tea.Model, tea.Cmd) { @@ -264,6 +419,8 @@ func (m model) View() string { return m.viewVars() case stageConfirm: return m.viewConfirm() + case stageHistory: + return m.viewHistory() default: return m.viewSearch() } @@ -294,7 +451,7 @@ func (m model) viewSearch() string { if m.status != "" { b.WriteString("\n" + statusStyle.Render(m.status) + "\n") } - b.WriteString("\n" + helpStyle.Render("type to filter · ↑/↓ move · enter select · esc quit")) + b.WriteString("\n" + helpStyle.Render("type to filter · ↑/↓ move · enter select · ctrl+r history · esc quit")) return b.String() } @@ -325,9 +482,45 @@ func (m model) viewConfirm() string { } b.WriteString("\n\n") b.WriteString(" " + resolvedStyle.Render(m.resolved) + "\n\n") - b.WriteString(dimStyle.Render(" nock will print this command to stdout. It does not run it —\n")) + dest := "print to stdout" + if m.defaultTarget == fire.Tmux { + dest = "prefill into this tmux pane" + } + fmt.Fprintf(&b, "%s\n", dimStyle.Render(" nock will "+dest+". It does not run it —")) b.WriteString(dimStyle.Render(" you do. Review it before you fire.") + "\n") - b.WriteString("\n" + helpStyle.Render("enter fire (print) · esc back · ctrl+c quit")) + + help := "enter fire · esc back · ctrl+c quit" + if fire.Tmux.Available() { + // Offer the per-command tmux override only where it can actually work. + help = "enter fire · ctrl+t fire→tmux · esc back · ctrl+c quit" + } + if m.status != "" { + b.WriteString("\n" + statusStyle.Render(" "+m.status) + "\n") + } + b.WriteString("\n" + helpStyle.Render(help)) + return b.String() +} + +// viewHistory renders the recall screen: recently fired commands, newest first, +// each shown as its resolved-looking template so the operator recognises it. +func (m model) viewHistory() string { + var b strings.Builder + b.WriteString(titleStyle.Render("nock") + dimStyle.Render(" — history") + "\n\n") + if len(m.histEntries) == 0 { + b.WriteString(dimStyle.Render(" no history yet") + "\n") + b.WriteString("\n" + helpStyle.Render("esc back · ctrl+c quit")) + return b.String() + } + for i, e := range m.histEntries { + prefix := " " + if i == m.histCursor { + prefix = cursorStyle.Render("› ") + } + when := e.Time.Local().Format("01-02 15:04") + b.WriteString(prefix + dimStyle.Render(when) + " " + selectedStyle.Render(e.ID) + + " " + dimStyle.Render(e.Template) + "\n") + } + b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter recall · esc back · ctrl+c quit")) return b.String() } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 5ef9a81..714635c 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,12 +1,15 @@ package tui import ( + "path/filepath" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/jessn-dev/nock/internal/engine" + "github.com/jessn-dev/nock/internal/fire" + "github.com/jessn-dev/nock/internal/history" "github.com/jessn-dev/nock/internal/vars" "github.com/jessn-dev/nock/pkg/format" ) @@ -30,6 +33,10 @@ func key(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyUp} case "down": return tea.KeyMsg{Type: tea.KeyDown} + case "ctrl+r": + return tea.KeyMsg{Type: tea.KeyCtrlR} + case "ctrl+t": + return tea.KeyMsg{Type: tea.KeyCtrlT} default: return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } @@ -148,3 +155,115 @@ func TestConfirmViewShowsResolvedCommand(t *testing.T) { t.Fatal("confirm view must display the resolved command (show-before-fire)") } } + +// modelWithHistory wires a real on-disk history store into a fresh model, the way +// Run does, so recall and recording can be exercised headlessly. +func modelWithHistory(t *testing.T) (model, *history.Store) { + t.Helper() + path := filepath.Join(t.TempDir(), "history.jsonl") + store := history.New(path) + m := newModel(testEngine()) + m.hist = store + return m, store +} + +func TestConfirmSetsDefaultTarget(t *testing.T) { + m := newModel(testEngine()) + if m.defaultTarget != fire.Stdout { + t.Fatalf("defaultTarget = %q, want stdout", m.defaultTarget) + } + m = selectByID(t, m, "novar") + m, _ = send(t, m, "enter") // -> confirm + final, _ := send(t, m, "enter") + if final.target != fire.Stdout { + t.Fatalf("fired target = %q, want stdout (the default)", final.target) + } +} + +func TestFiredEntryRecordsTemplateAndVars(t *testing.T) { + m, _ := modelWithHistory(t) + m = selectByID(t, m, "needs-var") + m, _ = send(t, m, "enter") + m = typeRunes(t, m, "10.0.0.5") + m, _ = send(t, m, "enter") // -> confirm + final, _ := send(t, m, "enter") + + e := final.firedEntry() + if e.ID != "needs-var" || e.Template != "echo " { + t.Fatalf("entry id/template = %q/%q, want needs-var/echo ", e.ID, e.Template) + } + // The binding is stored, never the flattened resolved string. + if e.Vars["host"] != "10.0.0.5" { + t.Fatalf("entry vars = %v, want host=10.0.0.5", e.Vars) + } +} + +func TestHistoryRecallReFillsFromSavedVars(t *testing.T) { + m, store := modelWithHistory(t) + if err := store.Append(history.Entry{ + ID: "needs-var", + Template: "echo ", + Vars: map[string]string{"host": "192.168.1.1"}, + }); err != nil { + t.Fatal(err) + } + + // ctrl+r opens recall; enter recalls the single entry. + m, _ = send(t, m, "ctrl+r") + if m.stage != stageHistory { + t.Fatalf("stage = %d, want stageHistory after ctrl+r", m.stage) + } + if len(m.histEntries) != 1 { + t.Fatalf("loaded %d history entries, want 1", len(m.histEntries)) + } + m, _ = send(t, m, "enter") + // Saved var is pre-bound, so recall lands straight on confirm — still shown. + if m.stage != stageConfirm { + t.Fatalf("stage = %d, want stageConfirm (pre-bound recall)", m.stage) + } + if m.resolved != "echo 192.168.1.1" { + t.Fatalf("resolved = %q, want echo 192.168.1.1", m.resolved) + } +} + +func TestHistoryRecallPreservesRiskMetadata(t *testing.T) { + m, store := modelWithHistory(t) + // needs-var carries RiskLow in testEngine; recall must rehydrate it, not drop + // it to an unspecified bare command. + if err := store.Append(history.Entry{ + ID: "needs-var", + Template: "echo ", + Vars: map[string]string{"host": "1.1.1.1"}, + }); err != nil { + t.Fatal(err) + } + m, _ = send(t, m, "ctrl+r") + m, _ = send(t, m, "enter") + if m.selected.Risk != format.RiskLow { + t.Fatalf("recalled risk = %q, want low (metadata must survive recall)", m.selected.Risk) + } + // And the stored template still overlays the live command text. + if m.selected.Command != "echo " { + t.Fatalf("recalled template = %q, want echo ", m.selected.Command) + } +} + +func TestEmptyHistoryShowsHint(t *testing.T) { + m, _ := modelWithHistory(t) + m, _ = send(t, m, "ctrl+r") + if m.stage != stageHistory { + t.Fatalf("stage = %d, want stageHistory", m.stage) + } + if !strings.Contains(m.View(), "no history") { + t.Fatal("empty history must show a hint, not a blank screen") + } +} + +func TestEscFromHistoryReturnsToSearch(t *testing.T) { + m, _ := modelWithHistory(t) + m, _ = send(t, m, "ctrl+r") + m, _ = send(t, m, "esc") + if m.stage != stageSearch { + t.Fatalf("stage = %d, want stageSearch after esc from history", m.stage) + } +} diff --git a/scripts/dev-check.sh b/scripts/dev-check.sh new file mode 100755 index 0000000..751df8a --- /dev/null +++ b/scripts/dev-check.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# dev-check.sh — run nock's full local gate, mirroring CI as closely as a dev +# machine can. Beyond `make check` (which only exercises your host OS), this +# cross-builds and cross-lints the other release targets, so platform-specific +# breakage — e.g. a build-tagged Windows file that no longer compiles — is caught +# before you push, not on the CI runner. +# +# What it cannot do locally: run the test suite on other operating systems (that +# needs their runners). Cross-build + cross-vet + cross-lint catch compile and +# static issues; genuinely OS-specific runtime behaviour is still verified by CI. +# +# Usage: +# scripts/dev-check.sh # full gate +# FAST=1 scripts/dev-check.sh # skip the cross-platform build/lint matrix +# +# Exit non-zero on the first failing step. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +GO=${GO:-go} + +# --- pretty output ----------------------------------------------------------- +if [ -t 1 ]; then + BLUE=$'\033[36m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; RED=$'\033[31m'; RESET=$'\033[0m' +else + BLUE=''; GREEN=''; YELLOW=''; RED=''; RESET='' +fi + +step() { printf '\n%s==> %s%s\n' "$BLUE" "$1" "$RESET"; } +ok() { printf '%s ok%s\n' "$GREEN" "$RESET"; } +warn() { printf '%s skip: %s%s\n' "$YELLOW" "$1" "$RESET"; } +fail() { printf '%s FAIL: %s%s\n' "$RED" "$1" "$RESET"; exit 1; } + +have() { command -v "$1" >/dev/null 2>&1; } + +# Cross-platform targets matching the release matrix. +PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64" + +# --- 1. modules -------------------------------------------------------------- +step "go mod tidy + verify (working tree must stay clean)" +cp go.mod go.mod.bak; cp go.sum go.sum.bak +$GO mod tidy +if ! diff -q go.mod go.mod.bak >/dev/null || ! diff -q go.sum go.sum.bak >/dev/null; then + mv go.mod.bak go.mod; mv go.sum.bak go.sum + fail "go.mod/go.sum not tidy — run 'go mod tidy' and commit the result" +fi +rm -f go.mod.bak go.sum.bak +$GO mod verify >/dev/null +ok + +# --- 2. formatting (non-mutating check) -------------------------------------- +step "gofmt (check only)" +unformatted=$(gofmt -l . 2>/dev/null || true) +if [ -n "$unformatted" ]; then + printf '%s\n' "$unformatted" + fail "files need gofmt — run 'make fmt'" +fi +ok + +# --- 3. vet (host + cross-OS) ------------------------------------------------ +step "go vet (host)" +$GO vet ./... +ok +if [ "${FAST:-0}" != "1" ]; then + for p in $PLATFORMS; do + os=${p%/*}; arch=${p#*/} + step "go vet ($os/$arch)" + GOOS=$os GOARCH=$arch $GO vet ./... && ok + done +fi + +# --- 4. lint (host + every target OS's build-tagged files) ------------------- +# golangci-lint is mandatory here: a "full gate" that silently skips linting would +# green-light lint-broken changes locally. Fail if it is missing. +if ! have golangci-lint; then + fail "golangci-lint not installed — required for 'make verify' (https://golangci-lint.run)" +fi +step "golangci-lint (host)" +golangci-lint run ./... +ok +if [ "${FAST:-0}" != "1" ]; then + # Lint each target OS so OS-tagged files (e.g. *_windows.go) are covered, not + # just the host's. GOARCH rarely changes which files compile, so one run per + # unique GOOS is enough. + host_os=$($GO env GOOS) + # shellcheck disable=SC2086 # word-splitting PLATFORMS is intentional + for os in $(printf '%s\n' $PLATFORMS | cut -d/ -f1 | sort -u); do + [ "$os" = "$host_os" ] && continue + step "golangci-lint (GOOS=$os)" + GOOS=$os golangci-lint run ./... && ok + done +fi + +# --- 5. cross-build matrix --------------------------------------------------- +if [ "${FAST:-0}" != "1" ]; then + for p in $PLATFORMS; do + os=${p%/*}; arch=${p#*/} + step "build ($os/$arch, CGO_ENABLED=0)" + CGO_ENABLED=0 GOOS=$os GOARCH=$arch $GO build ./... && ok + done +fi + +# --- 6. tests (race + coverage) ---------------------------------------------- +step "go test (race + coverage)" +$GO test -race -covermode=atomic -coverprofile=coverage.txt ./... +$GO tool cover -func=coverage.txt | tail -1 +ok + +# --- 7. vulnerabilities ------------------------------------------------------ +step "govulncheck" +if have govulncheck; then + govulncheck ./... && ok +else + $GO run golang.org/x/vuln/cmd/govulncheck@latest ./... && ok +fi + +# --- 8. secrets (optional) --------------------------------------------------- +step "gitleaks (optional)" +if have gitleaks; then + gitleaks detect --no-banner --redact && ok +else + warn "gitleaks not installed — CI still scans on the PR" +fi + +printf '\n%sAll checks passed.%s\n' "$GREEN" "$RESET"