From 07916082e420100bb5ee3c6eeca5d149854f3855 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:04:44 -0400 Subject: [PATCH 1/4] docs: add agent instruction files Add repo-level and package-scoped agent guidance for Go practices, security-sensitive helpers, and test conventions. Add CLAUDE.md symlinks beside each AGENTS.md so tools that read either name get the same instructions. --- AGENTS.md | 95 ++++++++++++++++++++++++++++++ CLAUDE.md | 1 + daemon/AGENTS.md | 34 +++++++++++ daemon/CLAUDE.md | 1 + git/AGENTS.md | 31 ++++++++++ git/CLAUDE.md | 1 + safefileio/AGENTS.md | 27 +++++++++ safefileio/CLAUDE.md | 1 + selfupdate/AGENTS.md | 32 ++++++++++ selfupdate/CLAUDE.md | 1 + tools/testifyhelpercheck/AGENTS.md | 24 ++++++++ tools/testifyhelpercheck/CLAUDE.md | 1 + 12 files changed, 249 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 daemon/AGENTS.md create mode 120000 daemon/CLAUDE.md create mode 100644 git/AGENTS.md create mode 120000 git/CLAUDE.md create mode 100644 safefileio/AGENTS.md create mode 120000 safefileio/CLAUDE.md create mode 100644 selfupdate/AGENTS.md create mode 120000 selfupdate/CLAUDE.md create mode 100644 tools/testifyhelpercheck/AGENTS.md create mode 120000 tools/testifyhelpercheck/CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2d444fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# Agent Instructions + +## Project Overview + +`go.kenn.io/kit` is a Go module of reusable building blocks for Kenn CLI and +developer tools. Keep packages small, app-neutral, and usable by more than one +caller. Do not add product-specific config, database state, UI behavior, or +provider workflows to this repo unless the package already owns that concern. + +## How To Work Here + +- Start from the package intent, not from a calling app's current needs. The + reusable package should stay useful to future callers with similar mechanics. +- Prefer the repo's existing helpers and test fixtures over recreating local + variants in a caller. +- Follow the Go version and dependency choices in `go.mod`; do not pin guidance + here to a specific version unless the code requires that version for a reason. +- Keep Unix and Windows behavior explicit when permissions, ownership, sockets, + paths, or process behavior differ. +- When behavior changes, update the package-level `AGENTS.md` nearest the change + if it captures an invariant future agents need to preserve. + +## Development Commands + +Use the commands that match the repository's CI and lint configuration: + +```bash +go mod tidy +git diff --exit-code -- go.mod go.sum +go build ./... +go tool gotestsum --format pkgname-and-test-fails -- ./... +go vet ./... +golangci-lint run +``` + +When invoking `go test` directly, prefer shuffled execution so hidden +test-to-test coupling is easier to catch: + +```bash +go test ./... -shuffle=on +``` + +Do not pass `-count=1`; it is the default and disables useful cache behavior. +Use `-count=N` only when `N > 1`, such as when chasing a suspected flake. + +If a sandboxed tool runner cannot write the default Go cache, set `GOCACHE` to a +temporary directory for that command. + +## Go Conventions + +- Use standard library APIs first and add dependencies only when they pay for + themselves. +- Run `gofmt` and `goimports` on edited Go files. +- Keep public package APIs narrow and app-neutral. Callers should not need to + import unrelated kit packages to use one package correctly. +- Surface errors with enough context for callers to act on them. Do not turn + failures into success-shaped fallbacks. +- Use `context.Context` for subprocesses, network calls, update checks, probes, + and other work that can block. +- Avoid broad cleanup or mutation. Operate on exact paths, runtime records, and + repositories that the caller supplied or the test created. + +## Tests + +- Use `github.com/stretchr/testify` for new and changed tests. Prefer + `require` for setup, preconditions, and values used later; use `assert` for + independent checks where more failures help diagnosis. +- Do not add new `t.Fatal`, `t.Fatalf`, `t.Error`, `t.Errorf`, `t.Fail`, or + `t.FailNow` calls. Existing tests still contain some stdlib assertions; when + editing those checks, migrate the touched checks to testify if it keeps the + test readable. +- When a test repeats package-level testify calls, create local helpers such as + `assert := assert.New(t)` or `require := require.New(t)` and use the helper + methods for the repeated checks. +- Prefer table tests when they make input and expected behavior clearer. +- Use `t.TempDir()` for files created by tests unless the test specifically + needs a fixed OS temp path to exercise permissions or runtime-dir behavior. +- Tests must not depend on the user's git config, global credentials, real + repositories, home directory state, or live provider availability. + +## Linting + +`.golangci.yml` enables `errcheck`, `govet`, `importas`, `ineffassign`, +`modernize`, `staticcheck`, `testifylint`, and `unused`. Treat its findings as +the active style contract. In particular, follow the configured import aliases +instead of inventing new aliases for kit packages. + +## Git Workflow + +- Do not change branches unless the user explicitly asks. +- Do not amend commits unless the user explicitly asks. +- Never revert user changes. If existing edits touch the same files, read them + and work with them. +- Run the relevant build, test, vet, and lint commands before claiming a Go + change is complete. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/daemon/AGENTS.md b/daemon/AGENTS.md new file mode 100644 index 0000000..ec8fd45 --- /dev/null +++ b/daemon/AGENTS.md @@ -0,0 +1,34 @@ +# Daemon Package Instructions + +## Scope + +`daemon/` owns shared lifecycle pieces for local background daemons: endpoint +parsing, loopback or Unix-socket clients, runtime records, liveness probes, +listen locks, and caller-driven auto-start. Application server setup, auth, +databases, command parsing, and shutdown policy belong to the caller. + +## Invariants + +- Never infer live daemon state from a runtime record alone. Probe the endpoint + before claiming a process is reachable. +- Default network behavior stays local. Do not add public listen addresses or + broad bind behavior without an explicit caller option. +- Unix sockets and runtime records must live under private current-user + directories. +- Do not remove an existing path unless it is known to be the stale Unix socket + this package created. Refuse paths whose type or ownership does not match + that intent. +- Use listen locks to serialize startup and bind attempts. +- Keep platform-specific behavior in build-tagged files when ownership, sockets, + process checks, or permissions differ. +- Auto-start goes through the caller-provided `StartFunc`; this package must not + invent application launch commands. + +## Tests + +- Use local HTTP handlers, temporary dirs, and per-test runtime stores. +- Assert what a client observes: status code, response body, runtime record, or + returned error. +- Avoid timing assumptions. Prefer explicit locks, probes, and short contexts + over sleeps. +- Do not require elevated privileges or external daemons. diff --git a/daemon/CLAUDE.md b/daemon/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/daemon/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/git/AGENTS.md b/git/AGENTS.md new file mode 100644 index 0000000..1a153dd --- /dev/null +++ b/git/AGENTS.md @@ -0,0 +1,31 @@ +# Git Package Instructions + +## Scope + +The `git/` tree provides reusable helpers for automation that needs to inspect, +mutate, or isolate git repositories. Keep these packages about git mechanics, +not about one product's workflow. + +## Package Rules + +- Prefer `gitcmd.New()` for git subprocesses so automation stays insulated from + inherited repository state, global config, prompts, and credential leaks. +- Do not call `exec.Command("git", ...)` directly in package code unless the + direct call is the behavior being tested. +- Pass `context.Context` through git operations that can block. +- Return git failures with captured stderr. Do not hide git's message behind a + generic error. +- Use `gitlock` around mutations that can race with another process touching + the same repository. +- Keep remote and clone-path validation in `gitremote`; do not duplicate that + parsing in callers or sibling packages. +- Do not assume GitHub-only identity. Keep host, owner, and repository name + handling explicit. + +## Tests + +- Use `git/test` fixtures instead of the user's real repositories. +- Tests must not read or mutate global git config. Set needed identity/config + inside the fixture. +- Use temporary directories and clean them with test cleanup hooks. +- Prefer testify assertions for new or changed checks. diff --git a/git/CLAUDE.md b/git/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/git/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/safefileio/AGENTS.md b/safefileio/AGENTS.md new file mode 100644 index 0000000..d6b0366 --- /dev/null +++ b/safefileio/AGENTS.md @@ -0,0 +1,27 @@ +# Safe File I/O Instructions + +## Scope + +`safefileio/` provides hardened file and directory helpers for local runtime +state that must belong to the current user. It is intentionally small. Keep +callers responsible for their own file formats and higher-level policy. + +## Invariants + +- Treat ambiguous paths as unsafe. Empty paths, symlinks, and non-regular files + should fail before callers can write runtime state through them. +- Runtime directory validation should judge the directory entry the caller + supplied, not a symlink target reached after traversal. +- `EnsurePrivateDir` may repair a real directory's permissions when that is safe. + `ValidatePrivateDir` must report problems without repairing them. +- Keep permissions and ownership checks tied to current-user-only runtime state. + On Windows, that means SID semantics rather than username string comparisons. +- If ownership or file type cannot be established, return an error. + +## Tests + +- Use testify for new or changed assertions. +- Keep Unix and Windows coverage in build-tagged test files. +- Permission tests may use fixed paths under the OS temp directory when + `t.TempDir()` starts from permissions that hide the behavior under test. +- Clean up every path created outside `t.TempDir()`. diff --git a/safefileio/CLAUDE.md b/safefileio/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/safefileio/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/selfupdate/AGENTS.md b/selfupdate/AGENTS.md new file mode 100644 index 0000000..986733a --- /dev/null +++ b/selfupdate/AGENTS.md @@ -0,0 +1,32 @@ +# Self-Update Instructions + +## Scope + +`selfupdate/` checks GitHub releases, caches update metadata, verifies +downloaded assets, extracts archives, and installs a replacement binary. Treat +this package as security-sensitive because it moves bytes from the network to an +executable path. + +## Invariants + +- Never install an asset without a checksum. +- When signature verification is enabled, verify the checksum payload before + downloading or installing the archive. +- Keep checksum, signature, release metadata, and asset-name validation tied to + the update request. Do not let metadata from one asset authorize another. +- Archive extraction must not write outside the chosen destination or preserve + privilege-changing file modes. +- Install through a staging path and atomic replacement where the platform + allows it. Do not leave a half-written destination on ordinary failures. +- Keep cache-only update metadata from being installable; force a fresh check + before install. +- Do not add live GitHub calls to tests. Use fake HTTP handlers and explicit + responses. + +## Tests + +- Cover both success and refusal paths when changing verification, asset naming, + caching, archive extraction, or install behavior. +- Use small in-memory or temp-file fixtures. Do not download real releases. +- Preserve tests that prove the archive extractor cannot write outside the + destination directory. diff --git a/selfupdate/CLAUDE.md b/selfupdate/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/selfupdate/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/tools/testifyhelpercheck/AGENTS.md b/tools/testifyhelpercheck/AGENTS.md new file mode 100644 index 0000000..135519a --- /dev/null +++ b/tools/testifyhelpercheck/AGENTS.md @@ -0,0 +1,24 @@ +# Testify Helper Analyzer Instructions + +## Scope + +`tools/testifyhelpercheck/` contains the custom Go analyzer that reports tests +with repeated direct package-level testify calls. `cmd/testify-helper-check/` +only wraps this analyzer for CLI use. + +## Analyzer Rules + +- Keep the analyzer type-aware through `go/analysis`; do not replace it with + text matching. +- Keep each test body independent. Nested function literals should not make the + outer function look more repetitive than it is. +- The analyzer should report repetition only when a local testify helper would + make the test clearer; do not warn for one-off assertions. +- Existing diagnostic strings are asserted in testdata comments. If a message or + threshold changes, update the `// want` comments in the same change. + +## Tests + +- Use `analysistest` fixtures under `testdata/`. +- Keep fixture packages minimal so failures point at analyzer behavior. +- Add both positive and negative fixture cases for threshold or scoping changes. diff --git a/tools/testifyhelpercheck/CLAUDE.md b/tools/testifyhelpercheck/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/tools/testifyhelpercheck/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From af662caac26a20094f88a64871931e752dcc1668 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:11:45 -0400 Subject: [PATCH 2/4] docs: keep root agent guidance intent focused Remove the top-level command recipe and defer exact verification commands to CI, go.mod, and tool config. Keep lint guidance tied to the configured style contract instead of repeating concrete linter settings. --- AGENTS.md | 35 +++++------------------------------ 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2d444fe..d29a50d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,32 +19,9 @@ provider workflows to this repo unless the package already owns that concern. paths, or process behavior differ. - When behavior changes, update the package-level `AGENTS.md` nearest the change if it captures an invariant future agents need to preserve. - -## Development Commands - -Use the commands that match the repository's CI and lint configuration: - -```bash -go mod tidy -git diff --exit-code -- go.mod go.sum -go build ./... -go tool gotestsum --format pkgname-and-test-fails -- ./... -go vet ./... -golangci-lint run -``` - -When invoking `go test` directly, prefer shuffled execution so hidden -test-to-test coupling is easier to catch: - -```bash -go test ./... -shuffle=on -``` - -Do not pass `-count=1`; it is the default and disables useful cache behavior. -Use `-count=N` only when `N > 1`, such as when chasing a suspected flake. - -If a sandboxed tool runner cannot write the default Go cache, set `GOCACHE` to a -temporary directory for that command. +- Let CI, `go.mod`, and tool config files define the exact verification + commands. Do not duplicate command recipes here unless the command carries a + repo-specific intent that is not encoded elsewhere. ## Go Conventions @@ -80,10 +57,8 @@ temporary directory for that command. ## Linting -`.golangci.yml` enables `errcheck`, `govet`, `importas`, `ineffassign`, -`modernize`, `staticcheck`, `testifylint`, and `unused`. Treat its findings as -the active style contract. In particular, follow the configured import aliases -instead of inventing new aliases for kit packages. +Treat `.golangci.yml` as the active style contract. Follow its configured +linters, formatters, and import aliases instead of repeating those rules here. ## Git Workflow From de951f690cb49183c955e9f54a5bd3c14692927a Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:37:01 -0400 Subject: [PATCH 3/4] docs: avoid duplicating hook-enforced guidance Remove top-level formatter, lint, and verification-command prose that belongs in hook or tool configuration. Keep the root agent file focused on intent and package-level behavior. --- AGENTS.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d29a50d..24e66cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,6 @@ provider workflows to this repo unless the package already owns that concern. - Use standard library APIs first and add dependencies only when they pay for themselves. -- Run `gofmt` and `goimports` on edited Go files. - Keep public package APIs narrow and app-neutral. Callers should not need to import unrelated kit packages to use one package correctly. - Surface errors with enough context for callers to act on them. Do not turn @@ -55,16 +54,9 @@ provider workflows to this repo unless the package already owns that concern. - Tests must not depend on the user's git config, global credentials, real repositories, home directory state, or live provider availability. -## Linting - -Treat `.golangci.yml` as the active style contract. Follow its configured -linters, formatters, and import aliases instead of repeating those rules here. - ## Git Workflow - Do not change branches unless the user explicitly asks. - Do not amend commits unless the user explicitly asks. - Never revert user changes. If existing edits touch the same files, read them and work with them. -- Run the relevant build, test, vet, and lint commands before claiming a Go - change is complete. From 5713a690b4c22d97d886b92a8ba564e01e14b9b5 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:50:52 -0400 Subject: [PATCH 4/4] chore: add Go hook enforcement Add prek hooks for builtin hygiene, Go formatting, golangci-lint, testify helper enforcement, NilAway, and Go tests. Add NilAway as a Go tool dependency and clean up existing lint/test findings so the new hooks pass. --- daemon/endpoint_test.go | 31 +++++---- daemon/endpoint_unix_test.go | 32 +++++---- daemon/listen_unix_test.go | 103 +++++++++++++++++----------- daemon/manager_test.go | 33 +++++---- daemon/probe_test.go | 62 ++++++++++------- daemon/runtime_test.go | 69 +++++++++++-------- daemon/runtime_unix_test.go | 76 +++++++++++--------- daemon/start_test.go | 10 +-- go.mod | 7 +- go.sum | 8 +++ logging/logging.go | 5 +- prek.toml | 99 ++++++++++++++++++++++++++ safefileio/private_dir_unix_test.go | 52 ++++++++------ selfupdate/selfupdate.go | 5 +- selfupdate/selfupdate_test.go | 24 ++++--- 15 files changed, 410 insertions(+), 206 deletions(-) create mode 100644 prek.toml diff --git a/daemon/endpoint_test.go b/daemon/endpoint_test.go index d847d9c..9800ad4 100644 --- a/daemon/endpoint_test.go +++ b/daemon/endpoint_test.go @@ -16,15 +16,17 @@ import ( ) func TestParseEndpointDefaultTCP(t *testing.T) { + assert := assert.New(t) + ep, err := daemon.ParseEndpoint("", daemon.ParseEndpointOptions{ DefaultTCPAddress: "127.0.0.1:7373", TCPPolicy: daemon.RequireLoopback, }) require.NoError(t, err) - assert.Equal(t, daemon.NetworkTCP, ep.Network) - assert.Equal(t, "127.0.0.1:7373", ep.Address) - assert.Equal(t, "http://127.0.0.1:7373", ep.BaseURL()) - assert.Equal(t, 7373, ep.Port()) + assert.Equal(daemon.NetworkTCP, ep.Network) + assert.Equal("127.0.0.1:7373", ep.Address) + assert.Equal("http://127.0.0.1:7373", ep.BaseURL()) + assert.Equal(7373, ep.Port()) } func TestParseEndpointRejectsPublicTCPWhenPolicyRequiresNonPublic(t *testing.T) { @@ -53,12 +55,14 @@ func TestParseEndpointUnixDefault(t *testing.T) { } func TestUnixHTTPClientDialsSocket(t *testing.T) { + require := require.New(t) + socketDir, err := os.MkdirTemp("", "kitd") - require.NoError(t, err) + require.NoError(err) t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) socketPath := filepath.Join(socketDir, "daemon.sock") listener, err := net.Listen("unix", socketPath) - require.NoError(t, err) + require.NoError(err) t.Cleanup(func() { _ = listener.Close() _ = os.Remove(socketPath) @@ -72,7 +76,7 @@ func TestUnixHTTPClientDialsSocket(t *testing.T) { ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} resp, err := ep.HTTPClient(daemon.HTTPClientOptions{}).Get(ep.BaseURL()) - require.NoError(t, err) + require.NoError(err) defer func() { _ = resp.Body.Close() }() assert.Equal(t, http.StatusOK, resp.StatusCode) } @@ -88,20 +92,23 @@ func TestTCPHTTPClientBypassesEnvironmentProxy(t *testing.T) { } func TestDefaultSocketPathCreatesPrivateTempFallback(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + service := fmt.Sprintf("kitdtest%d", os.Getpid()) t.Setenv("TMPDIR", "/tmp") t.Setenv("XDG_RUNTIME_DIR", "") socketPath := daemon.DefaultSocketPath(service) - require.NotEmpty(t, socketPath) + require.NotEmpty(socketPath) t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(socketPath)) }) - assert.Equal(t, filepath.Join(filepath.Dir(socketPath), "daemon.sock"), socketPath) + assert.Equal(filepath.Join(filepath.Dir(socketPath), "daemon.sock"), socketPath) info, err := os.Stat(filepath.Dir(socketPath)) - require.NoError(t, err) - assert.True(t, info.IsDir()) + require.NoError(err) + assert.True(info.IsDir()) if runtime.GOOS != "windows" { - assert.Zero(t, info.Mode().Perm()&0o077) + assert.Zero(info.Mode().Perm() & 0o077) } } diff --git a/daemon/endpoint_unix_test.go b/daemon/endpoint_unix_test.go index 27026f0..6dc7ef2 100644 --- a/daemon/endpoint_unix_test.go +++ b/daemon/endpoint_unix_test.go @@ -14,21 +14,24 @@ import ( ) func TestDefaultSocketPathRepairsPublicTempFallback(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + tempDir := "/tmp" service := fmt.Sprintf("kitdpublic%d", os.Getpid()) t.Setenv("TMPDIR", tempDir) t.Setenv("XDG_RUNTIME_DIR", "") parent := filepath.Join(tempDir, fmt.Sprintf("%s-%d", service, os.Getuid())) t.Cleanup(func() { _ = os.RemoveAll(parent) }) - require.NoError(t, os.MkdirAll(parent, 0o700)) - require.NoError(t, os.Chmod(parent, 0o777)) + require.NoError(os.MkdirAll(parent, 0o700)) + require.NoError(os.Chmod(parent, 0o777)) socketPath := daemon.DefaultSocketPath(service) - require.NotEmpty(t, socketPath) - assert.Equal(t, filepath.Join(parent, "daemon.sock"), socketPath) + require.NotEmpty(socketPath) + assert.Equal(filepath.Join(parent, "daemon.sock"), socketPath) info, err := os.Stat(parent) - require.NoError(t, err) - assert.Zero(t, info.Mode().Perm()&0o077) + require.NoError(err) + assert.Zero(info.Mode().Perm() & 0o077) } func TestDefaultSocketPathRejectsSymlinkedTempFallback(t *testing.T) { @@ -49,20 +52,23 @@ func TestDefaultSocketPathRejectsSymlinkedTempFallback(t *testing.T) { } func TestDefaultSocketPathCreatesPrivateXDGRuntimeDir(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + xdg := filepath.Join("/tmp", fmt.Sprintf("kitdxdg%d", os.Getpid())) service := "svc" t.Cleanup(func() { _ = os.RemoveAll(xdg) }) - require.NoError(t, os.MkdirAll(xdg, 0o700)) - require.NoError(t, os.Chmod(xdg, 0o700)) + require.NoError(os.MkdirAll(xdg, 0o700)) + require.NoError(os.Chmod(xdg, 0o700)) t.Setenv("XDG_RUNTIME_DIR", xdg) socketPath := daemon.DefaultSocketPath(service) - require.NotEmpty(t, socketPath) + require.NotEmpty(socketPath) parent := filepath.Join(xdg, service) - assert.Equal(t, filepath.Join(parent, "daemon.sock"), socketPath) + assert.Equal(filepath.Join(parent, "daemon.sock"), socketPath) info, err := os.Stat(parent) - require.NoError(t, err) - assert.True(t, info.IsDir()) - assert.Zero(t, info.Mode().Perm()&0o077) + require.NoError(err) + assert.True(info.IsDir()) + assert.Zero(info.Mode().Perm() & 0o077) } diff --git a/daemon/listen_unix_test.go b/daemon/listen_unix_test.go index d05d8f6..ce9fd79 100644 --- a/daemon/listen_unix_test.go +++ b/daemon/listen_unix_test.go @@ -32,38 +32,47 @@ func TestListenUnixRemovesStaleSocketAndBinds(t *testing.T) { } func TestListenUnixRejectsNonSocketPath(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + socketPath := unixSocketPath(t) - require.NoError(t, os.WriteFile(socketPath, []byte("not a socket"), 0o600)) + require.NoError(os.WriteFile(socketPath, []byte("not a socket"), 0o600)) ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} listener, err := daemon.Listen(context.Background(), ep) - require.Error(t, err) - assert.Nil(t, listener) - assert.Contains(t, err.Error(), "refusing to remove non-socket path") + require.Error(err) + assert.Nil(listener) + assert.Contains(err.Error(), "refusing to remove non-socket path") body, readErr := os.ReadFile(socketPath) - require.NoError(t, readErr) - assert.Equal(t, "not a socket", string(body)) + require.NoError(readErr) + assert.Equal("not a socket", string(body)) } func TestListenUnixRejectsLiveSocket(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + socketPath := unixSocketPath(t) live, err := net.Listen(daemon.NetworkUnix, socketPath) - require.NoError(t, err) + require.NoError(err) t.Cleanup(func() { _ = live.Close() }) ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} listener, err := daemon.Listen(context.Background(), ep) - require.Error(t, err) - assert.Nil(t, listener) - assert.Contains(t, err.Error(), "daemon already listening") + require.Error(err) + assert.Nil(listener) + assert.Contains(err.Error(), "daemon already listening") conn, err := net.DialTimeout(daemon.NetworkUnix, socketPath, time.Second) - require.NoError(t, err) + require.NoError(err) _ = conn.Close() } func TestListenUnixSerializesConcurrentStaleSocketStartup(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + socketPath := staleUnixSocket(t) lockPath := filepath.Join(filepath.Dir(socketPath), "daemon.lock") ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} @@ -86,32 +95,35 @@ func TestListenUnixSerializesConcurrentStaleSocketStartup(t *testing.T) { for range starters { result := <-results if result.err == nil { - require.Nil(t, winner, "only one daemon start should bind the socket") + require.Nil(winner, "only one daemon start should bind the socket") winner = result.listener continue } errors = append(errors, result.err) } - require.NotNil(t, winner) + require.NotNil(winner) t.Cleanup(func() { _ = winner.Close() }) - require.Len(t, errors, starters-1) + require.Len(errors, starters-1) for _, err := range errors { - assert.True(t, + assert.True( strings.Contains(err.Error(), "daemon already listening") || strings.Contains(err.Error(), "bind: address already in use"), "unexpected listen error: %v", err) } conn, err := net.DialTimeout(daemon.NetworkUnix, socketPath, time.Second) - require.NoError(t, err) + require.NoError(err) _ = conn.Close() } func TestListenUnixProbesAfterAcquiringLock(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + socketPath := staleUnixSocket(t) lockPath := filepath.Join(filepath.Dir(socketPath), "daemon.lock") heldLock := flock.New(lockPath) - require.NoError(t, heldLock.Lock()) + require.NoError(heldLock.Lock()) locked := true t.Cleanup(func() { if locked { @@ -128,76 +140,85 @@ func TestListenUnixProbesAfterAcquiringLock(t *testing.T) { resultCh <- listenResult{listener: listener, err: err} }() - require.NoError(t, os.Remove(socketPath)) + require.NoError(os.Remove(socketPath)) live, err := net.Listen(daemon.NetworkUnix, socketPath) - require.NoError(t, err) + require.NoError(err) t.Cleanup(func() { _ = live.Close() }) - require.NoError(t, heldLock.Unlock()) + require.NoError(heldLock.Unlock()) locked = false result := <-resultCh - require.Error(t, result.err) - assert.Nil(t, result.listener) - assert.Contains(t, result.err.Error(), "daemon already listening") + require.Error(result.err) + assert.Nil(result.listener) + assert.Contains(result.err.Error(), "daemon already listening") conn, err := net.DialTimeout(daemon.NetworkUnix, socketPath, time.Second) - require.NoError(t, err) + require.NoError(err) _ = conn.Close() } func TestListenUnixRejectsUnsafeLockDirectory(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + socketPath := staleUnixSocket(t) base, err := os.MkdirTemp("/tmp", "kitd-lock") - require.NoError(t, err) + require.NoError(err) t.Cleanup(func() { _ = os.RemoveAll(base) }) target := filepath.Join(base, "target") link := filepath.Join(base, "link") - require.NoError(t, os.MkdirAll(target, 0o700)) - require.NoError(t, os.Symlink(target, link)) + require.NoError(os.MkdirAll(target, 0o700)) + require.NoError(os.Symlink(target, link)) ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} listener, err := daemon.Listen(context.Background(), ep, daemon.WithListenLockPath(filepath.Join(link, "daemon.lock"))) - require.Error(t, err) - assert.Nil(t, listener) - assert.Contains(t, err.Error(), "prepare daemon lock dir") - assert.Contains(t, err.Error(), "symlink") + require.Error(err) + assert.Nil(listener) + assert.Contains(err.Error(), "prepare daemon lock dir") + assert.Contains(err.Error(), "symlink") _, statErr := os.Lstat(socketPath) - require.NoError(t, statErr, "stale socket should not be touched when lock dir is unsafe") + require.NoError(statErr, "stale socket should not be touched when lock dir is unsafe") } func TestListenUnixRejectsRelativeLockPath(t *testing.T) { + assert := assert.New(t) + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: unixSocketPath(t)} listener, err := daemon.Listen(context.Background(), ep, daemon.WithListenLockPath("daemon.lock")) require.Error(t, err) - assert.Nil(t, listener) - assert.Contains(t, err.Error(), "daemon lock path") - assert.Contains(t, err.Error(), "must be absolute") + assert.Nil(listener) + assert.Contains(err.Error(), "daemon lock path") + assert.Contains(err.Error(), "must be absolute") } func TestListenUnixRejectsRelativeSocketPath(t *testing.T) { + assert := assert.New(t) + lockPath := filepath.Join(t.TempDir(), "daemon.lock") ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: "daemon.sock"} listener, err := daemon.Listen(context.Background(), ep, daemon.WithListenLockPath(lockPath)) require.Error(t, err) - assert.Nil(t, listener) - assert.Contains(t, err.Error(), "unix socket path") - assert.Contains(t, err.Error(), "must be absolute") + assert.Nil(listener) + assert.Contains(err.Error(), "unix socket path") + assert.Contains(err.Error(), "must be absolute") } func TestListenUnixRejectsSharedSocketDirectoryEvenWithStoreLock(t *testing.T) { + assert := assert.New(t) + socketPath := filepath.Join("/tmp", "kitd-shared-socket.sock") t.Cleanup(func() { _ = os.Remove(socketPath) }) ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} listener, err := daemon.Listen(context.Background(), ep, daemon.WithRuntimeStore(daemon.RuntimeStore{Dir: t.TempDir()})) require.Error(t, err) - assert.Nil(t, listener) - assert.Contains(t, err.Error(), "validate unix socket dir") + assert.Nil(listener) + assert.Contains(err.Error(), "validate unix socket dir") _, statErr := os.Lstat(socketPath) - assert.True(t, os.IsNotExist(statErr), "socket in shared dir should not be created: %v", statErr) + assert.True(os.IsNotExist(statErr), "socket in shared dir should not be created: %v", statErr) } type listenResult struct { diff --git a/daemon/manager_test.go b/daemon/manager_test.go index c9dbd06..cea11ab 100644 --- a/daemon/manager_test.go +++ b/daemon/manager_test.go @@ -17,6 +17,8 @@ import ( ) func TestManagerEnsureStartsAndPollsForCompatibleDaemon(t *testing.T) { + assert := assert.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprint(w, `{"ok":true,"service":"tool","version":"v2"}`) })) @@ -48,9 +50,9 @@ func TestManagerEnsureStartsAndPollsForCompatibleDaemon(t *testing.T) { rec, info, err := manager.Ensure(context.Background(), time.Second) require.NoError(t, err) - assert.True(t, started) - assert.Equal(t, "tool", rec.Service) - assert.Equal(t, "v2", info.Version) + assert.True(started) + assert.Equal("tool", rec.Service) + assert.Equal("v2", info.Version) } func TestManagerFindSkipsIncompatibleDaemon(t *testing.T) { @@ -83,6 +85,9 @@ func TestManagerFindSkipsIncompatibleDaemon(t *testing.T) { } func TestManagerFindScansPastIncompatibleDaemon(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + oldServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprint(w, `{"ok":true,"service":"tool","version":"old"}`) })) @@ -101,7 +106,7 @@ func TestManagerFindScansPastIncompatibleDaemon(t *testing.T) { Version: "old", StartedAt: time.Now().Add(-time.Minute), }) - require.NoError(t, err) + require.NoError(err) _, err = store.Write(daemon.RuntimeRecord{ PID: os.Getpid() + 1, Network: daemon.NetworkTCP, @@ -110,7 +115,7 @@ func TestManagerFindScansPastIncompatibleDaemon(t *testing.T) { Version: "new", StartedAt: time.Now(), }) - require.NoError(t, err) + require.NoError(err) manager := daemon.Manager{ Store: store, @@ -121,10 +126,10 @@ func TestManagerFindScansPastIncompatibleDaemon(t *testing.T) { } rec, info, ok, err := manager.Find(context.Background()) - require.NoError(t, err) - require.True(t, ok) - assert.Equal(t, listenerAddr(t, newServer), rec.Address) - assert.Equal(t, "new", info.Version) + require.NoError(err) + require.True(ok) + assert.Equal(listenerAddr(t, newServer), rec.Address) + assert.Equal("new", info.Version) } func TestManagerEnsureSerializesConcurrentStarts(t *testing.T) { @@ -168,12 +173,14 @@ func TestManagerEnsureSerializesConcurrentStarts(t *testing.T) { } func TestManagerEnsureAppliesTimeoutToStartLock(t *testing.T) { + require := require.New(t) + store := daemon.RuntimeStore{Dir: t.TempDir()} lockPath, err := store.LockPath() - require.NoError(t, err) - require.NoError(t, os.MkdirAll(store.Dir, 0o700)) + require.NoError(err) + require.NoError(os.MkdirAll(store.Dir, 0o700)) lock := flock.New(lockPath) - require.NoError(t, lock.Lock()) + require.NoError(lock.Lock()) defer func() { _ = lock.Unlock() }() manager := daemon.Manager{ @@ -186,6 +193,6 @@ func TestManagerEnsureAppliesTimeoutToStartLock(t *testing.T) { startedAt := time.Now() _, _, err = manager.Ensure(context.Background(), 50*time.Millisecond) - require.Error(t, err) + require.Error(err) assert.Less(t, time.Since(startedAt), 500*time.Millisecond) } diff --git a/daemon/probe_test.go b/daemon/probe_test.go index 49a5894..2b8cb5b 100644 --- a/daemon/probe_test.go +++ b/daemon/probe_test.go @@ -18,6 +18,9 @@ import ( ) func TestNewPingHandlerEmitsRequiredPingInfo(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + server := httptest.NewServer(daemon.NewPingHandler(daemon.PingHandlerOptions{ Service: "roborev", Version: "dev", @@ -26,16 +29,16 @@ func TestNewPingHandlerEmitsRequiredPingInfo(t *testing.T) { defer server.Close() resp, err := server.Client().Get(server.URL) - require.NoError(t, err) + require.NoError(err) defer func() { _ = resp.Body.Close() }() - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + assert.Equal(http.StatusOK, resp.StatusCode) + assert.Equal("application/json", resp.Header.Get("Content-Type")) var info daemon.PingInfo - require.NoError(t, json.NewDecoder(resp.Body).Decode(&info)) - assert.True(t, info.OK) - assert.Equal(t, "roborev", info.Service) - assert.Equal(t, "dev", info.Version) - assert.Equal(t, 123, info.PID) + require.NoError(json.NewDecoder(resp.Body).Decode(&info)) + assert.True(info.OK) + assert.Equal("roborev", info.Service) + assert.Equal("dev", info.Version) + assert.Equal(123, info.PID) } func TestNewPingHandlerRejectsNonGET(t *testing.T) { @@ -50,8 +53,10 @@ func TestNewPingHandlerRejectsNonGET(t *testing.T) { } func TestProbeHTTPRequiresOKTrue(t *testing.T) { + assert := assert.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, daemon.DefaultPingPath, r.URL.Path) + assert.Equal(daemon.DefaultPingPath, r.URL.Path) _, _ = fmt.Fprint(w, `{"ok":true,"service":"roborev","version":"dev","pid":123}`) })) defer server.Close() @@ -60,10 +65,10 @@ func TestProbeHTTPRequiresOKTrue(t *testing.T) { ExpectedService: "roborev", }) require.NoError(t, err) - assert.True(t, info.OK) - assert.Equal(t, "roborev", info.Service) - assert.Equal(t, "dev", info.Version) - assert.Equal(t, 123, info.PID) + assert.True(info.OK) + assert.Equal("roborev", info.Service) + assert.Equal("dev", info.Version) + assert.Equal(123, info.PID) } func TestProbeHTTPRejectsOKOmitted(t *testing.T) { @@ -104,6 +109,9 @@ func TestProbeHTTPAppliesTimeoutOption(t *testing.T) { } func TestDiscoverFindsResponsiveRuntime(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintf(w, `{"ok":true,"service":"kata","version":"v1","pid":%d}`, os.Getpid()) })) @@ -119,16 +127,16 @@ func TestDiscoverFindsResponsiveRuntime(t *testing.T) { Version: "v1", StartedAt: time.Now(), }) - require.NoError(t, err) + require.NoError(err) rec, info, ok, err := daemon.Discover(context.Background(), store, daemon.DiscoverOptions{ Probe: daemon.ProbeOptions{ExpectedService: "kata"}, RequirePIDAlive: true, }) - require.NoError(t, err) - require.True(t, ok) - assert.Equal(t, addr, rec.Address) - assert.Equal(t, "kata", info.Service) + require.NoError(err) + require.True(ok) + assert.Equal(addr, rec.Address) + assert.Equal("kata", info.Service) } func TestDiscoverRejectsPIDMismatchWhenRequiringLivePID(t *testing.T) { @@ -164,6 +172,8 @@ func TestDiscoverPropagatesContextCancellation(t *testing.T) { } func TestDiscoverSkipsPerProbeTimeouts(t *testing.T) { + require := require.New(t) + slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) @@ -181,7 +191,7 @@ func TestDiscoverSkipsPerProbeTimeouts(t *testing.T) { Service: "kata", StartedAt: time.Now().Add(-time.Minute), }) - require.NoError(t, err) + require.NoError(err) _, err = store.Write(daemon.RuntimeRecord{ PID: os.Getpid() + 1, Network: daemon.NetworkTCP, @@ -189,23 +199,25 @@ func TestDiscoverSkipsPerProbeTimeouts(t *testing.T) { Service: "kata", StartedAt: time.Now(), }) - require.NoError(t, err) + require.NoError(err) _, info, ok, err := daemon.Discover(context.Background(), store, daemon.DiscoverOptions{ Probe: daemon.ProbeOptions{ExpectedService: "kata", Timeout: 100 * time.Millisecond}, }) - require.NoError(t, err) - require.True(t, ok) + require.NoError(err) + require.True(ok) assert.Equal(t, "fast", info.Version) } func TestProbeDialsUnixEndpoint(t *testing.T) { + require := require.New(t) + socketDir, err := os.MkdirTemp("", "kitd") - require.NoError(t, err) + require.NoError(err) t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) socketPath := filepath.Join(socketDir, "daemon.sock") listener, err := net.Listen("unix", socketPath) - require.NoError(t, err) + require.NoError(err) t.Cleanup(func() { _ = listener.Close() _ = os.Remove(socketPath) @@ -221,7 +233,7 @@ func TestProbeDialsUnixEndpoint(t *testing.T) { Network: daemon.NetworkUnix, Address: socketPath, }, daemon.ProbeOptions{ExpectedService: "kata"}) - require.NoError(t, err) + require.NoError(err) } func listenerAddr(t *testing.T, server *httptest.Server) string { diff --git a/daemon/runtime_test.go b/daemon/runtime_test.go index 90b7d49..86714ac 100644 --- a/daemon/runtime_test.go +++ b/daemon/runtime_test.go @@ -13,6 +13,9 @@ import ( ) func TestRuntimeStoreWriteListAndRead(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + store := daemon.RuntimeStore{Dir: t.TempDir()} ep := daemon.Endpoint{Network: daemon.NetworkTCP, Address: "127.0.0.1:7474"} rec := daemon.NewRuntimeRecord("kata", "v1", ep) @@ -20,22 +23,25 @@ func TestRuntimeStoreWriteListAndRead(t *testing.T) { rec.Metadata = map[string]string{"db_path": "/tmp/kata.db"} path, err := store.Write(rec) - require.NoError(t, err) - assert.Equal(t, "daemon."+strconv.Itoa(os.Getpid())+".json", filepath.Base(path)) + require.NoError(err) + assert.Equal("daemon."+strconv.Itoa(os.Getpid())+".json", filepath.Base(path)) records, err := store.List() - require.NoError(t, err) - require.Len(t, records, 1) + require.NoError(err) + require.Len(records, 1) got := records[0] - assert.Equal(t, os.Getpid(), got.PID) - assert.Equal(t, "kata", got.Service) - assert.Equal(t, "v1", got.Version) - assert.Equal(t, ep, got.Endpoint()) - assert.Equal(t, path, got.SourcePath) - assert.Equal(t, "/tmp/kata.db", got.Metadata["db_path"]) + assert.Equal(os.Getpid(), got.PID) + assert.Equal("kata", got.Service) + assert.Equal("v1", got.Version) + assert.Equal(ep, got.Endpoint()) + assert.Equal(path, got.SourcePath) + assert.Equal("/tmp/kata.db", got.Metadata["db_path"]) } func TestRuntimeStoreCleanupDeadLeavesMismatchedFiles(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + store := daemon.RuntimeStore{Dir: t.TempDir()} deadPID := 999999 if daemon.ProcessAlive(deadPID) { @@ -48,60 +54,67 @@ func TestRuntimeStoreCleanupDeadLeavesMismatchedFiles(t *testing.T) { StartedAt: time.Now(), } _, err := store.Write(dead) - require.NoError(t, err) + require.NoError(err) mismatchPath, err := store.Path(deadPID + 1) - require.NoError(t, err) + require.NoError(err) err = os.WriteFile(mismatchPath, []byte(`{"pid":999999,"address":"127.0.0.1:7475"}`), 0o644) - require.NoError(t, err) + require.NoError(err) removed, err := store.CleanupDead() - require.NoError(t, err) - assert.Equal(t, 1, removed) + require.NoError(err) + assert.Equal(1, removed) deadPath, err := store.Path(deadPID) - require.NoError(t, err) + require.NoError(err) _, err = os.Stat(deadPath) - assert.True(t, os.IsNotExist(err), "dead runtime still exists or unexpected stat error: %v", err) + assert.True(os.IsNotExist(err), "dead runtime still exists or unexpected stat error: %v", err) _, err = os.Stat(mismatchPath) - require.NoError(t, err) + require.NoError(err) } func TestRuntimeStoreRejectsPrefixTraversal(t *testing.T) { + require := require.New(t) + store := daemon.RuntimeStore{Dir: t.TempDir(), Prefix: "../escape"} _, err := store.Path(123) - require.Error(t, err) + require.Error(err) _, err = store.Write(daemon.RuntimeRecord{ PID: 123, Network: daemon.NetworkTCP, Address: "127.0.0.1:7474", }) - require.Error(t, err) + require.Error(err) _, err = store.List() - require.Error(t, err) + require.Error(err) _, err = store.CleanupDead() - require.Error(t, err) + require.Error(err) } func TestRuntimeStoreRejectsRelativeDirBeforePreparing(t *testing.T) { + assert := assert.New(t) + store := daemon.RuntimeStore{Dir: "relative-runtime"} _, err := store.LockPath() require.Error(t, err) - assert.Contains(t, err.Error(), "must be absolute") + assert.Contains(err.Error(), "must be absolute") _, statErr := os.Stat("relative-runtime") - assert.True(t, os.IsNotExist(statErr), "relative runtime dir should not be created: %v", statErr) + assert.True(os.IsNotExist(statErr), "relative runtime dir should not be created: %v", statErr) } func TestRuntimeStoreListenLockPathIsSeparateFromStartLock(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + store := daemon.RuntimeStore{Dir: t.TempDir(), Prefix: "kata"} startLock, err := store.LockPath() - require.NoError(t, err) + require.NoError(err) listenLock, err := store.ListenLockPath() - require.NoError(t, err) + require.NoError(err) - assert.Equal(t, filepath.Join(store.Dir, "kata.lock"), startLock) - assert.Equal(t, filepath.Join(store.Dir, "kata.listen.lock"), listenLock) + assert.Equal(filepath.Join(store.Dir, "kata.lock"), startLock) + assert.Equal(filepath.Join(store.Dir, "kata.listen.lock"), listenLock) } diff --git a/daemon/runtime_unix_test.go b/daemon/runtime_unix_test.go index 9ddbaeb..dd9c301 100644 --- a/daemon/runtime_unix_test.go +++ b/daemon/runtime_unix_test.go @@ -14,11 +14,13 @@ import ( ) func TestRuntimeStoreRepairsPublicDir(t *testing.T) { + require := require.New(t) + dir := filepath.Join("/tmp", fmt.Sprintf("kit-runtime-public-%d", os.Getpid())) t.Cleanup(func() { _ = os.RemoveAll(dir) }) - require.NoError(t, os.RemoveAll(dir)) - require.NoError(t, os.MkdirAll(dir, 0o700)) - require.NoError(t, os.Chmod(dir, 0o777)) + require.NoError(os.RemoveAll(dir)) + require.NoError(os.MkdirAll(dir, 0o700)) + require.NoError(os.Chmod(dir, 0o777)) store := daemon.RuntimeStore{Dir: dir} rec := daemon.RuntimeRecord{ @@ -27,86 +29,94 @@ func TestRuntimeStoreRepairsPublicDir(t *testing.T) { Address: "127.0.0.1:7474", } _, err := store.Write(rec) - require.NoError(t, err) + require.NoError(err) _, err = store.List() - require.NoError(t, err) + require.NoError(err) _, err = store.CleanupDead() - require.NoError(t, err) + require.NoError(err) _, err = store.LockPath() - require.NoError(t, err) + require.NoError(err) _, err = store.Read(filepath.Join(dir, "daemon.1.json")) - require.Error(t, err) + require.Error(err) info, err := os.Stat(dir) - require.NoError(t, err) - require.Zero(t, info.Mode().Perm()&0o077) + require.NoError(err) + require.Zero(info.Mode().Perm() & 0o077) } func TestRuntimeStoreRepairsPrivateUnusableDir(t *testing.T) { + require := require.New(t) + dir := filepath.Join("/tmp", fmt.Sprintf("kit-runtime-unusable-%d", os.Getpid())) t.Cleanup(func() { _ = os.RemoveAll(dir) }) - require.NoError(t, os.RemoveAll(dir)) - require.NoError(t, os.MkdirAll(dir, 0o700)) - require.NoError(t, os.Chmod(dir, 0o500)) + require.NoError(os.RemoveAll(dir)) + require.NoError(os.MkdirAll(dir, 0o700)) + require.NoError(os.Chmod(dir, 0o500)) store := daemon.RuntimeStore{Dir: dir} _, err := store.LockPath() - require.NoError(t, err) + require.NoError(err) info, err := os.Stat(dir) - require.NoError(t, err) - require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + require.NoError(err) + require.Equal(os.FileMode(0o700), info.Mode().Perm()) } func TestRuntimeStoreRejectsSymlinkDir(t *testing.T) { + require := require.New(t) + base := filepath.Join("/tmp", fmt.Sprintf("kit-runtime-symlink-%d", os.Getpid())) target := base + "-target" t.Cleanup(func() { _ = os.Remove(base) _ = os.RemoveAll(target) }) - require.NoError(t, os.RemoveAll(base)) - require.NoError(t, os.RemoveAll(target)) - require.NoError(t, os.MkdirAll(target, 0o700)) - require.NoError(t, os.Symlink(target, base)) + require.NoError(os.RemoveAll(base)) + require.NoError(os.RemoveAll(target)) + require.NoError(os.MkdirAll(target, 0o700)) + require.NoError(os.Symlink(target, base)) store := daemon.RuntimeStore{Dir: base} _, err := store.LockPath() - require.Error(t, err) + require.Error(err) _, err = store.Write(daemon.RuntimeRecord{ PID: os.Getpid(), Network: daemon.NetworkTCP, Address: "127.0.0.1:7474", }) - require.Error(t, err) + require.Error(err) _, err = store.List() - require.Error(t, err) + require.Error(err) _, err = store.CleanupDead() - require.Error(t, err) + require.Error(err) } func TestRuntimeStoreRejectsSymlinkRecord(t *testing.T) { + require := require.New(t) + dir := filepath.Join("/tmp", fmt.Sprintf("kit-runtime-record-symlink-%d", os.Getpid())) target := filepath.Join(dir, "target.json") link := filepath.Join(dir, "daemon.1.json") t.Cleanup(func() { _ = os.RemoveAll(dir) }) - require.NoError(t, os.RemoveAll(dir)) - require.NoError(t, os.MkdirAll(dir, 0o700)) - require.NoError(t, os.WriteFile(target, []byte(`{"pid":1,"address":"127.0.0.1:7474"}`), 0o644)) - require.NoError(t, os.Symlink(target, link)) + require.NoError(os.RemoveAll(dir)) + require.NoError(os.MkdirAll(dir, 0o700)) + require.NoError(os.WriteFile(target, []byte(`{"pid":1,"address":"127.0.0.1:7474"}`), 0o644)) + require.NoError(os.Symlink(target, link)) store := daemon.RuntimeStore{Dir: dir} _, err := store.Read(link) - require.Error(t, err) + require.Error(err) } func TestRuntimeStoreRejectsNonRegularRecord(t *testing.T) { + require := require.New(t) + dir := filepath.Join("/tmp", fmt.Sprintf("kit-runtime-fifo-%d", os.Getpid())) record := filepath.Join(dir, "daemon.1.json") t.Cleanup(func() { _ = os.RemoveAll(dir) }) - require.NoError(t, os.RemoveAll(dir)) - require.NoError(t, os.MkdirAll(dir, 0o700)) - require.NoError(t, syscall.Mkfifo(record, 0o600)) + require.NoError(os.RemoveAll(dir)) + require.NoError(os.MkdirAll(dir, 0o700)) + require.NoError(syscall.Mkfifo(record, 0o600)) store := daemon.RuntimeStore{Dir: dir} _, err := store.Read(record) - require.Error(t, err) + require.Error(err) } diff --git a/daemon/start_test.go b/daemon/start_test.go index 0bbd6b2..70c6ea6 100644 --- a/daemon/start_test.go +++ b/daemon/start_test.go @@ -8,10 +8,12 @@ import ( ) func TestIsEphemeralExecutableDetectsTestBinaries(t *testing.T) { - assert.True(t, daemon.IsEphemeralExecutable("/tmp/tool.test")) - assert.True(t, daemon.IsEphemeralExecutable(`C:\Temp\tool.test.exe`)) - assert.True(t, daemon.IsEphemeralExecutable(`C:\Temp\tool.TEST.EXE`)) - assert.False(t, daemon.IsEphemeralExecutable("/usr/local/bin/tool")) + assert := assert.New(t) + + assert.True(daemon.IsEphemeralExecutable("/tmp/tool.test")) + assert.True(daemon.IsEphemeralExecutable(`C:\Temp\tool.test.exe`)) + assert.True(daemon.IsEphemeralExecutable(`C:\Temp\tool.TEST.EXE`)) + assert.False(daemon.IsEphemeralExecutable("/usr/local/bin/tool")) } func TestIsEphemeralExecutableDetectsGoBuildPaths(t *testing.T) { diff --git a/go.mod b/go.mod index 15fe7fd..9f51e6a 100644 --- a/go.mod +++ b/go.mod @@ -26,15 +26,20 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/nilaway v0.0.0-20260528182042-490362de4fb6 // indirect golang.org/x/text v0.17.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/gotestsum v1.13.0 // indirect ) -tool gotest.tools/gotestsum +tool ( + go.uber.org/nilaway/cmd/nilaway + gotest.tools/gotestsum +) diff --git a/go.sum b/go.sum index b5e5e8e..c74d58b 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -36,6 +38,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -50,6 +54,10 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/nilaway v0.0.0-20260528182042-490362de4fb6 h1:zPMeq2W25bus839tj1hJc6L1et7usbCgI6PrhdeRNy8= +go.uber.org/nilaway v0.0.0-20260528182042-490362de4fb6/go.mod h1:z2TyDbbirBXLiioI5nUG3MnE9hB8+4F6ha93I6C4cmg= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= diff --git a/logging/logging.go b/logging/logging.go index 56936c4..6a7c60b 100644 --- a/logging/logging.go +++ b/logging/logging.go @@ -11,6 +11,7 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -294,8 +295,8 @@ func (r *Result) Close() error { } var err error - for i := len(r.closers) - 1; i >= 0; i-- { - if closeErr := r.closers[i](); closeErr != nil { + for _, closer := range slices.Backward(r.closers) { + if closeErr := closer(); closeErr != nil { err = errors.Join(err, closeErr) } } diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..0c4012b --- /dev/null +++ b/prek.toml @@ -0,0 +1,99 @@ +default_install_hook_types = ["pre-commit", "pre-push"] +default_stages = ["pre-commit"] + +[[repos]] +repo = "builtin" + +[[repos.hooks]] +id = "check-case-conflict" + +[[repos.hooks]] +id = "check-merge-conflict" +args = ["--assume-in-merge"] + +[[repos.hooks]] +id = "detect-private-key" + +[[repos.hooks]] +id = "no-commit-to-branch" + +[[repos.hooks]] +id = "check-added-large-files" +args = ["--maxkb=1024"] + +[[repos.hooks]] +id = "trailing-whitespace" +args = ["--markdown-linebreak-ext=md"] + +[[repos.hooks]] +id = "end-of-file-fixer" + +[[repos.hooks]] +id = "check-json" + +[[repos.hooks]] +id = "check-toml" + +[[repos.hooks]] +id = "check-yaml" + +[[repos]] +repo = "local" + +[[repos.hooks]] +id = "gofmt" +name = "gofmt" +language = "system" +entry = "gofmt -w" +files = "\\.go$" +priority = 0 + +[[repos.hooks]] +id = "golangci-lint" +name = "golangci-lint" +language = "system" +entry = "golangci-lint run" +files = "^(go\\.mod|go\\.sum|.*\\.go)$" +pass_filenames = false +require_serial = true +priority = 10 + +[[repos.hooks]] +id = "testify-helper-check" +name = "testify helper check" +language = "system" +entry = "go run ./cmd/testify-helper-check ./..." +files = "^(go\\.mod|go\\.sum|.*_test\\.go|tools/testifyhelpercheck/.*\\.go|cmd/testify-helper-check/.*\\.go)$" +pass_filenames = false +require_serial = true +priority = 15 + +[[repos.hooks]] +id = "nilaway" +name = "nilaway" +language = "system" +entry = "go tool nilaway -include-pkgs=go.kenn.io/kit ./..." +stages = ["pre-push"] +files = "^(go\\.mod|go\\.sum|.*\\.go)$" +pass_filenames = false +require_serial = true +priority = 20 + +[[repos.hooks]] +id = "go-test" +name = "go test" +language = "system" +entry = "go tool gotestsum --format pkgname-and-test-fails -- ./..." +files = "^(go\\.mod|go\\.sum|.*\\.go)$" +pass_filenames = false +require_serial = true +priority = 25 + +[[repos]] +repo = "meta" + +[[repos.hooks]] +id = "check-hooks-apply" + +[[repos.hooks]] +id = "check-useless-excludes" diff --git a/safefileio/private_dir_unix_test.go b/safefileio/private_dir_unix_test.go index ff9760a..8566a56 100644 --- a/safefileio/private_dir_unix_test.go +++ b/safefileio/private_dir_unix_test.go @@ -14,31 +14,35 @@ import ( ) func TestEnsurePrivateDirRepairsPublicDir(t *testing.T) { + require := require.New(t) + dir := filepath.Join("/tmp", fmt.Sprintf("kit-safefileio-public-%d", os.Getpid())) t.Cleanup(func() { _ = os.RemoveAll(dir) }) - require.NoError(t, os.RemoveAll(dir)) - require.NoError(t, os.MkdirAll(dir, 0o700)) - require.NoError(t, os.Chmod(dir, 0o777)) + require.NoError(os.RemoveAll(dir)) + require.NoError(os.MkdirAll(dir, 0o700)) + require.NoError(os.Chmod(dir, 0o777)) - require.NoError(t, safefileio.EnsurePrivateDir(dir)) + require.NoError(safefileio.EnsurePrivateDir(dir)) info, err := os.Stat(dir) - require.NoError(t, err) - require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + require.NoError(err) + require.Equal(os.FileMode(0o700), info.Mode().Perm()) } func TestValidatePrivateDirRejectsWithoutRepairingPublicDir(t *testing.T) { + require := require.New(t) + dir := filepath.Join("/tmp", fmt.Sprintf("kit-safefileio-validate-public-%d", os.Getpid())) t.Cleanup(func() { _ = os.RemoveAll(dir) }) - require.NoError(t, os.RemoveAll(dir)) - require.NoError(t, os.MkdirAll(dir, 0o700)) - require.NoError(t, os.Chmod(dir, 0o777)) + require.NoError(os.RemoveAll(dir)) + require.NoError(os.MkdirAll(dir, 0o700)) + require.NoError(os.Chmod(dir, 0o777)) - require.Error(t, safefileio.ValidatePrivateDir(dir)) + require.Error(safefileio.ValidatePrivateDir(dir)) info, err := os.Stat(dir) - require.NoError(t, err) - require.Equal(t, os.FileMode(0o777), info.Mode().Perm()) + require.NoError(err) + require.Equal(os.FileMode(0o777), info.Mode().Perm()) } func TestValidatePrivateDirAcceptsPrivateDir(t *testing.T) { @@ -55,18 +59,20 @@ func TestEnsurePrivateDirRejectsEmptyPath(t *testing.T) { } func TestEnsurePrivateDirRejectsSymlink(t *testing.T) { + require := require.New(t) + base := filepath.Join("/tmp", fmt.Sprintf("kit-safefileio-symlink-%d", os.Getpid())) target := base + "-target" t.Cleanup(func() { _ = os.Remove(base) _ = os.RemoveAll(target) }) - require.NoError(t, os.RemoveAll(base)) - require.NoError(t, os.RemoveAll(target)) - require.NoError(t, os.MkdirAll(target, 0o700)) - require.NoError(t, os.Symlink(target, base)) + require.NoError(os.RemoveAll(base)) + require.NoError(os.RemoveAll(target)) + require.NoError(os.MkdirAll(target, 0o700)) + require.NoError(os.Symlink(target, base)) - require.Error(t, safefileio.EnsurePrivateDir(base)) + require.Error(safefileio.EnsurePrivateDir(base)) } func TestOpenCurrentUserFileRejectsEmptyPath(t *testing.T) { @@ -76,14 +82,16 @@ func TestOpenCurrentUserFileRejectsEmptyPath(t *testing.T) { } func TestOpenCurrentUserFileRejectsNonRegularFile(t *testing.T) { + require := require.New(t) + dir := filepath.Join("/tmp", fmt.Sprintf("kit-safefileio-fifo-%d", os.Getpid())) path := filepath.Join(dir, "record.json") t.Cleanup(func() { _ = os.RemoveAll(dir) }) - require.NoError(t, os.RemoveAll(dir)) - require.NoError(t, os.MkdirAll(dir, 0o700)) - require.NoError(t, syscall.Mkfifo(path, 0o600)) + require.NoError(os.RemoveAll(dir)) + require.NoError(os.MkdirAll(dir, 0o700)) + require.NoError(syscall.Mkfifo(path, 0o600)) file, err := safefileio.OpenCurrentUserFile(path) - require.Error(t, err) - require.Nil(t, file) + require.Error(err) + require.Nil(file) } diff --git a/selfupdate/selfupdate.go b/selfupdate/selfupdate.go index 3b65d8c..f93bcfe 100644 --- a/selfupdate/selfupdate.go +++ b/selfupdate/selfupdate.go @@ -32,6 +32,7 @@ const ( defaultHTTPTimeout = 30 * time.Second maxChecksumBytes = 1 << 20 maxSignatureBytes = 64 << 10 + legacyTarRegularType = byte(0) ) // Release represents the subset of a GitHub release response used by Client. @@ -459,7 +460,7 @@ func ExtractTarGz(archivePath, destDir string) error { if err := ensureNoSymlinkPath(absDestDir, target); err != nil { return err } - case tar.TypeReg, tar.TypeRegA: + case tar.TypeReg, legacyTarRegularType: outFile, err := createArchiveFile(absDestDir, target) if err != nil { return err @@ -1165,7 +1166,7 @@ func ensureNoSymlinkPath(absDestDir, target string) error { } current := absDestDir - for _, part := range strings.Split(rel, string(filepath.Separator)) { + for part := range strings.SplitSeq(rel, string(filepath.Separator)) { if part == "." || part == "" { continue } diff --git a/selfupdate/selfupdate_test.go b/selfupdate/selfupdate_test.go index 2fe357b..1e259fd 100644 --- a/selfupdate/selfupdate_test.go +++ b/selfupdate/selfupdate_test.go @@ -116,6 +116,9 @@ func TestCheckUsesReleaseBodyChecksumFallback(t *testing.T) { if err != nil { t.Fatalf("Check: %v", err) } + if info == nil { + t.Fatal("expected update info") + } if info.Checksum != testHashAAAA { t.Fatalf("checksum = %q", info.Checksum) } @@ -741,7 +744,7 @@ func TestExtractTarGzExtractsLegacyRegularFiles(t *testing.T) { tmpDir := t.TempDir() archivePath := filepath.Join(tmpDir, "legacy-regular.tar.gz") createTarGz(t, archivePath, []archiveEntry{ - {Name: "tool", Content: "legacy", Mode: 0o755, TypeFlag: tar.TypeRegA}, + {Name: "tool", Content: "legacy", Mode: 0o755, TypeFlag: legacyTarRegularType, TypeFlagSet: true}, }) extractDir := filepath.Join(tmpDir, "extract") if err := ExtractTarGz(archivePath, extractDir); err != nil { @@ -881,7 +884,7 @@ func TestInstallBinary(t *testing.T) { } }() - for i := 0; i < 1000; i++ { + for i := range 1000 { if err := InstallBinary(srcPath, dstPath); err != nil { close(stop) <-done @@ -1012,11 +1015,12 @@ func TestDefaultAssetNameAndFormatSize(t *testing.T) { } type archiveEntry struct { - Name string - Content string - Mode int64 - TypeFlag byte - LinkName string + Name string + Content string + Mode int64 + TypeFlag byte + TypeFlagSet bool + LinkName string } func createTarGz(t *testing.T, path string, entries []archiveEntry) { @@ -1037,7 +1041,7 @@ func createTarGz(t *testing.T, path string, entries []archiveEntry) { mode = 0o644 } typeFlag := entry.TypeFlag - if typeFlag == 0 { + if typeFlag == 0 && !entry.TypeFlagSet { typeFlag = tar.TypeReg } data := []byte(entry.Content) @@ -1048,13 +1052,13 @@ func createTarGz(t *testing.T, path string, entries []archiveEntry) { Typeflag: typeFlag, Linkname: entry.LinkName, } - if typeFlag != tar.TypeReg && typeFlag != tar.TypeRegA { + if typeFlag != tar.TypeReg && typeFlag != legacyTarRegularType { header.Size = 0 } if err := tw.WriteHeader(header); err != nil { t.Fatal(err) } - if typeFlag == tar.TypeReg || typeFlag == tar.TypeRegA { + if typeFlag == tar.TypeReg || typeFlag == legacyTarRegularType { if _, err := tw.Write(data); err != nil { t.Fatal(err) }