From 0f7aa8bc3bb963d014c2a4573c281f19d6c8ef54 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 05:34:47 +0000 Subject: [PATCH 1/2] security: harden notifications, ref handling, signals, and release pipeline Pre-release security pass before public beta. Changes are grouped into four self-contained hardening areas, each with new tests: - internal/notify: replace ad-hoc PowerShell quoting with -EncodedCommand (UTF-16LE base64), add `--` separator for notify-send, sanitize control characters and cap field length so a malicious branch name or backupRef can't smuggle escapes into a toast or terminal. - internal/git: tighten branch-name handling for backup refs to an allowlist ([A-Za-z0-9._-]) with hash fallback, and replace the prefix-only IsBackupRef with a full-pattern match so that user-supplied refs containing git revision syntax (@{N}, ^, ~, :) cannot reach `git reset --hard` via `rescue restore`. - internal/cli + cmd/git-real: thread context.Context through Run, runStart, runChallenge, and sleepUntil; main wires signal.NotifyContext for SIGINT and SIGTERM so Ctrl-C during the grace period exits cleanly without applying a penalty. - Makefile + release workflow: build with -trimpath, -buildvcs, embedded version/commit/date, and a pinned SOURCE_DATE_EPOCH for reproducible binaries; sign SHA256SUMS via cosign keyless OIDC and ship the .sig/.pem alongside the archives. Adds `git real --version` and verification instructions in the README. https://claude.ai/code/session_01BQTaxBPYMX7YFsicvwNdUG --- .github/workflows/release.yml | 57 +++++- Makefile | 13 +- README.md | 29 +++ cmd/git-real/e2e_test.go | 67 ++++++- cmd/git-real/main.go | 30 +++- cmd/git-real/main_test.go | 30 +++- internal/cli/app.go | 89 ++++++--- internal/cli/app_test.go | 317 +++++++++++++++++++++++++++------ internal/git/git.go | 59 +++++- internal/git/git_test.go | 156 +++++++++++++++- internal/notify/notify.go | 62 ++++++- internal/notify/notify_test.go | 138 +++++++++++++- 12 files changed, 933 insertions(+), 114 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c90c71e..19d9b3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + id-token: write # required for cosign keyless signing via OIDC jobs: build-release: @@ -38,6 +39,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-go@v5 with: @@ -46,19 +49,39 @@ jobs: - run: go mod download - name: Build archive + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + BINARY_NAME: ${{ matrix.binary_name }} + ARCHIVE_EXT: ${{ matrix.archive_ext }} run: | set -euo pipefail - artifact_name="git-real_${{ matrix.goos }}_${{ matrix.goarch }}" - archive_name="${artifact_name}.${{ matrix.archive_ext }}" + artifact_name="git-real_${GOOS}_${GOARCH}" + archive_name="${artifact_name}.${ARCHIVE_EXT}" mkdir -p "dist/${artifact_name}" - GOOS="${{ matrix.goos }}" GOARCH="${{ matrix.goarch }}" go build -o "dist/${artifact_name}/${{ matrix.binary_name }}" ./cmd/git-real + version="$(git describe --tags --always --dirty)" + commit="$(git rev-parse HEAD)" + date="$(git log -1 --format=%cI)" + + # Pin the build timestamp to the tag commit's author date so that + # rebuilding from the same source produces a byte-identical binary. + export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" - if [ "${{ matrix.archive_ext }}" = "zip" ]; then - zip -j "dist/${archive_name}" "dist/${artifact_name}/${{ matrix.binary_name }}" + go build \ + -trimpath \ + -buildvcs=true \ + -ldflags="-s -w -X main.version=${version} -X main.commit=${commit} -X main.date=${date}" \ + -o "dist/${artifact_name}/${BINARY_NAME}" \ + ./cmd/git-real + + if [ "${ARCHIVE_EXT}" = "zip" ]; then + (cd dist && zip -j "${archive_name}" "${artifact_name}/${BINARY_NAME}") else - tar -C dist -czf "dist/${archive_name}" "${artifact_name}" + tar -C dist --sort=name --owner=0 --group=0 --numeric-owner \ + --mtime="@${SOURCE_DATE_EPOCH}" \ + -czf "dist/${archive_name}" "${artifact_name}" fi - uses: actions/upload-artifact@v4 @@ -81,8 +104,26 @@ jobs: - name: Generate checksums run: | set -euo pipefail - sha256sum dist/* > dist/SHA256SUMS + (cd dist && sha256sum git-real_*.tar.gz git-real_*.zip > SHA256SUMS) + + - uses: sigstore/cosign-installer@v3 + + - name: Sign checksums with cosign (keyless OIDC) + env: + COSIGN_EXPERIMENTAL: "1" + run: | + set -euo pipefail + cosign sign-blob \ + --yes \ + --output-signature dist/SHA256SUMS.sig \ + --output-certificate dist/SHA256SUMS.pem \ + dist/SHA256SUMS - uses: softprops/action-gh-release@v2 with: - files: dist/* + files: | + dist/git-real_*.tar.gz + dist/git-real_*.zip + dist/SHA256SUMS + dist/SHA256SUMS.sig + dist/SHA256SUMS.pem diff --git a/Makefile b/Makefile index db95d9b..9d7283c 100644 --- a/Makefile +++ b/Makefile @@ -8,10 +8,21 @@ export GOMODCACHE ?= $(CACHE_DIR)/gomod export GOPATH ?= $(CACHE_DIR)/gopath export XDG_CACHE_HOME ?= $(CACHE_DIR)/xdg +# Reproducible-build inputs. Override on the command line for releases: +# make build VERSION=v1.2.3 COMMIT=$(git rev-parse HEAD) DATE=$(git log -1 --format=%cI) +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo none) +DATE ?= $(shell git log -1 --format=%cI 2>/dev/null || echo unknown) + +LDFLAGS := -s -w \ + -X main.version=$(VERSION) \ + -X main.commit=$(COMMIT) \ + -X main.date=$(DATE) + .PHONY: build fmt fmt-check lint typecheck deadcode test coverage check build: - $(GO) build -o git-real ./cmd/git-real + $(GO) build -trimpath -buildvcs=true -ldflags='$(LDFLAGS)' -o git-real ./cmd/git-real fmt: $(GO) fmt ./... diff --git a/README.md b/README.md index e168dbf..376d812 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ GitReal is intentionally conservative: - Before any reset, GitReal stores the current `HEAD` under `refs/gitreal/backups/...`. - `git real rescue restore ` also backs up the current `HEAD` before restoring a backup. - Dirty worktree changes are stashed and then restored when possible. +- `Ctrl-C` (SIGINT or SIGTERM) is honored at any point during a challenge: if you cancel + before the deadline, no penalty is applied. If you want real enforcement for the current repository: @@ -170,6 +172,33 @@ Release archives are published as: - `git-real_linux_arm64.tar.gz` - `git-real_windows_amd64.zip` - `SHA256SUMS` +- `SHA256SUMS.sig` (cosign keyless signature) +- `SHA256SUMS.pem` (cosign certificate) + +### Verifying a release + +Releases are built with `-trimpath` and a pinned `SOURCE_DATE_EPOCH` so the +binaries are reproducible from the tagged commit. The `SHA256SUMS` file is +signed with [cosign](https://github.com/sigstore/cosign) keyless mode, tied to +the GitHub Actions OIDC identity for this repository. + +```bash +cosign verify-blob \ + --certificate SHA256SUMS.pem \ + --signature SHA256SUMS.sig \ + --certificate-identity-regexp '^https://github.com/watany-dev/gitreal/' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + SHA256SUMS + +sha256sum --check SHA256SUMS +``` + +You can confirm a downloaded binary matches the published source revision with: + +```bash +git real --version +# git-real v0.x.y (, built ) +``` ## More Detail diff --git a/cmd/git-real/e2e_test.go b/cmd/git-real/e2e_test.go index f59846b..b19c72f 100644 --- a/cmd/git-real/e2e_test.go +++ b/cmd/git-real/e2e_test.go @@ -2,11 +2,13 @@ package main import ( "bytes" + "context" "os" "os/exec" "path/filepath" "strings" "testing" + "time" ) func TestMainEndToEnd(t *testing.T) { @@ -19,6 +21,7 @@ func TestMainEndToEnd(t *testing.T) { runGit(t, repoDir, "checkout", "-b", "main") runGit(t, repoDir, "config", "user.name", "GitReal Test") runGit(t, repoDir, "config", "user.email", "test@example.com") + runGit(t, repoDir, "config", "commit.gpgsign", "false") writeFile(t, filepath.Join(repoDir, "file.txt"), "base\n") runGit(t, repoDir, "add", "file.txt") @@ -109,10 +112,72 @@ func runMain(t *testing.T, dir string, args ...string) (string, string, int) { stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) - exitCode := Main(args, stdout, stderr) + exitCode := Main(context.Background(), args, stdout, stderr) return stdout.String(), stderr.String(), exitCode } +func TestMainOnceCancelledByContext(t *testing.T) { + t.Parallel() + + workDir := t.TempDir() + originDir := filepath.Join(workDir, "origin.git") + repoDir := filepath.Join(workDir, "repo") + + runGit(t, workDir, "init", "--bare", originDir) + runGit(t, workDir, "clone", originDir, repoDir) + runGit(t, repoDir, "checkout", "-b", "main") + runGit(t, repoDir, "config", "user.name", "GitReal Test") + runGit(t, repoDir, "config", "user.email", "test@example.com") + runGit(t, repoDir, "config", "commit.gpgsign", "false") + + writeFile(t, filepath.Join(repoDir, "file.txt"), "base\n") + runGit(t, repoDir, "add", "file.txt") + runGit(t, repoDir, "commit", "-m", "base") + runGit(t, repoDir, "push", "-u", "origin", "HEAD") + + previousDir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatalf("Chdir() error = %v", err) + } + defer func() { _ = os.Chdir(previousDir) }() + + if exit := Main(context.Background(), []string{"init"}, new(bytes.Buffer), new(bytes.Buffer)); exit != 0 { + t.Fatalf("init exit = %d, want 0", exit) + } + if exit := Main(context.Background(), []string{"arm"}, new(bytes.Buffer), new(bytes.Buffer)); exit != 0 { + t.Fatalf("arm exit = %d, want 0", exit) + } + + writeFile(t, filepath.Join(repoDir, "file.txt"), "base\nlocal\n") + runGit(t, repoDir, "commit", "-am", "local") + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + exitCode := Main(ctx, []string{"once", "--grace-seconds=30"}, stdout, stderr) + if exitCode != 0 { + t.Fatalf("once after cancel exit = %d, stderr = %q", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "interrupted") { + t.Fatalf("stdout = %q, want interrupt notice", stdout.String()) + } + if ahead := strings.TrimSpace(runGitOutput(t, repoDir, "rev-list", "--count", "@{u}..HEAD")); ahead != "1" { + t.Fatalf("ahead after cancel = %q, want 1 (no penalty)", ahead) + } + out := runGitOutput(t, repoDir, "for-each-ref", "refs/gitreal/backups/") + if strings.TrimSpace(out) != "" { + t.Fatalf("backup refs created on cancel: %q", out) + } +} + func runGit(t *testing.T, dir string, args ...string) { t.Helper() diff --git a/cmd/git-real/main.go b/cmd/git-real/main.go index 072931b..8ffb255 100644 --- a/cmd/git-real/main.go +++ b/cmd/git-real/main.go @@ -1,16 +1,40 @@ package main import ( + "context" "io" "os" + "os/signal" + "syscall" "github.com/watany-dev/gitreal/internal/cli" ) -func Main(args []string, stdout, stderr io.Writer) int { - return cli.Run(args, stdout, stderr) +// Build-time variables populated via -ldflags by the Makefile and the release +// workflow. They are surfaced via `git real --version` so users can verify that +// a binary corresponds to a specific source revision and SHA256SUMS entry. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func Main(ctx context.Context, args []string, stdout, stderr io.Writer) int { + for _, arg := range args { + if arg == "--version" || arg == "-V" { + printVersion(stdout) + return 0 + } + } + return cli.Run(ctx, args, stdout, stderr) +} + +func printVersion(w io.Writer) { + _, _ = io.WriteString(w, "git-real "+version+" ("+commit+", built "+date+")\n") } func main() { - os.Exit(Main(os.Args[1:], os.Stdout, os.Stderr)) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + os.Exit(Main(ctx, os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/cmd/git-real/main_test.go b/cmd/git-real/main_test.go index 4c6cc18..7bd372e 100644 --- a/cmd/git-real/main_test.go +++ b/cmd/git-real/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "strings" "testing" ) @@ -12,7 +13,7 @@ func TestMain(t *testing.T) { stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) - exitCode := Main([]string{"help"}, stdout, stderr) + exitCode := Main(context.Background(), []string{"help"}, stdout, stderr) if exitCode != 0 { t.Fatalf("Main() exitCode = %d, want 0", exitCode) } @@ -25,3 +26,30 @@ func TestMain(t *testing.T) { t.Fatalf("stderr = %q, want empty", got) } } + +func TestMainVersion(t *testing.T) { + t.Parallel() + + previousVersion, previousCommit, previousDate := version, commit, date + t.Cleanup(func() { + version, commit, date = previousVersion, previousCommit, previousDate + }) + version, commit, date = "v1.2.3", "abc1234", "2026-05-01T00:00:00Z" + + for _, flag := range []string{"--version", "-V"} { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + if got := Main(context.Background(), []string{flag}, stdout, stderr); got != 0 { + t.Fatalf("Main(%s) exit code = %d, want 0", flag, got) + } + out := stdout.String() + for _, want := range []string{"git-real", "v1.2.3", "abc1234", "2026-05-01T00:00:00Z"} { + if !strings.Contains(out, want) { + t.Fatalf("Main(%s) stdout = %q, want substring %q", flag, out, want) + } + } + if stderr.Len() != 0 { + t.Fatalf("Main(%s) stderr = %q, want empty", flag, stderr.String()) + } + } +} diff --git a/internal/cli/app.go b/internal/cli/app.go index 760bf49..e6688aa 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1,6 +1,8 @@ package cli import ( + "context" + "errors" "flag" "fmt" "io" @@ -14,6 +16,10 @@ import ( "github.com/watany-dev/gitreal/internal/notify" ) +// errInterrupted signals that the caller cancelled the run via SIGINT/SIGTERM. +// It is treated as a graceful shutdown, not a failure. +var errInterrupted = errors.New("interrupted") + type repository interface { Root() string SetConfigBool(key string, value bool) error @@ -34,7 +40,7 @@ type repository interface { type app struct { discoverRepo func(path string) (repository, error) now func() time.Time - sleep func(time.Duration) + sleep func(ctx context.Context, d time.Duration) error sendNotification func(title, message string) error rng *rand.Rand stdout io.Writer @@ -42,8 +48,8 @@ type app struct { startIterations int } -func Run(args []string, stdout, stderr io.Writer) int { - return newApp(stdout, stderr).run(args) +func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { + return newApp(stdout, stderr).run(ctx, args) } func newApp(stdout, stderr io.Writer) *app { @@ -52,7 +58,7 @@ func newApp(stdout, stderr io.Writer) *app { return ggit.Discover(path) }, now: time.Now, - sleep: time.Sleep, + sleep: sleepWithContext, sendNotification: notify.Send, rng: rand.New(rand.NewSource(time.Now().UnixNano())), stdout: stdout, @@ -60,7 +66,24 @@ func newApp(stdout, stderr io.Writer) *app { } } -func (a *app) run(args []string) int { +// sleepWithContext blocks for the requested duration but unblocks immediately +// when the context is cancelled (e.g. SIGINT). It returns context.Canceled or +// context.DeadlineExceeded if the context fires first; otherwise nil. +func sleepWithContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + return ctx.Err() + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (a *app) run(ctx context.Context, args []string) int { if len(args) == 0 { printHelp(a.stdout) return 0 @@ -75,9 +98,9 @@ func (a *app) run(args []string) int { case "status": return a.commandStatus() case "once": - return a.commandOnce(args[1:]) + return a.commandOnce(ctx, args[1:]) case "start": - return a.commandStart(args[1:]) + return a.commandStart(ctx, args[1:]) case "arm": return a.commandArm() case "disarm": @@ -146,7 +169,7 @@ func (a *app) commandStatus() int { return 0 } -func (a *app) commandOnce(args []string) int { +func (a *app) commandOnce(ctx context.Context, args []string) int { repo, err := a.discoverRepo(".") if err != nil { return a.fail(err) @@ -161,14 +184,18 @@ func (a *app) commandOnce(args []string) int { return a.fail(err) } - if err := a.runChallenge(repo, graceSeconds, repo.ConfigBool("gitreal.armed", false)); err != nil { + if err := a.runChallenge(ctx, repo, graceSeconds, repo.ConfigBool("gitreal.armed", false)); err != nil { + if errors.Is(err, errInterrupted) { + fmt.Fprintln(a.stdout, "interrupted; no penalty applied") + return 0 + } return a.fail(err) } return 0 } -func (a *app) commandStart(args []string) int { +func (a *app) commandStart(ctx context.Context, args []string) int { repo, err := a.discoverRepo(".") if err != nil { return a.fail(err) @@ -183,20 +210,32 @@ func (a *app) commandStart(args []string) int { return a.fail(err) } - return a.runStart(repo, graceSeconds, a.startIterations) + return a.runStart(ctx, repo, graceSeconds, a.startIterations) } -func (a *app) runStart(repo repository, graceSeconds int, iterations int) int { +func (a *app) runStart(ctx context.Context, repo repository, graceSeconds int, iterations int) int { base := a.now() fmt.Fprintf(a.stdout, "GitReal started for %s\n", repo.Root()) completed := 0 for iterations <= 0 || completed < iterations { + if ctx.Err() != nil { + fmt.Fprintln(a.stdout, "interrupted; stopping scheduler") + return 0 + } + next := nextRandomSlot(base, a.rng) fmt.Fprintf(a.stdout, "next challenge: %s\n", next.Format(time.RFC3339)) - a.sleepUntil(next) + if err := a.sleepUntil(ctx, next); err != nil { + fmt.Fprintln(a.stdout, "interrupted; stopping scheduler") + return 0 + } - if err := a.runChallenge(repo, graceSeconds, repo.ConfigBool("gitreal.armed", false)); err != nil { + if err := a.runChallenge(ctx, repo, graceSeconds, repo.ConfigBool("gitreal.armed", false)); err != nil { + if errors.Is(err, errInterrupted) { + fmt.Fprintln(a.stdout, "interrupted; stopping scheduler") + return 0 + } fmt.Fprintf(a.stderr, "git-real: %v\n", err) } @@ -281,7 +320,7 @@ func (a *app) commandRescue(args []string) int { backupRef := args[1] if !ggit.IsBackupRef(backupRef) { - fmt.Fprintln(a.stderr, "git-real rescue restore: ref must start with refs/gitreal/backups/") + fmt.Fprintln(a.stderr, "git-real rescue restore: ref must be a GitReal backup ref produced by 'git real rescue list'") return 2 } @@ -324,7 +363,7 @@ func (a *app) restoreBackupRef(repo repository, backupRef string) int { return 0 } -func (a *app) runChallenge(repo repository, graceSeconds int, armed bool) error { +func (a *app) runChallenge(ctx context.Context, repo repository, graceSeconds int, armed bool) error { branch, err := repo.CurrentBranch() if err != nil { return err @@ -359,7 +398,11 @@ func (a *app) runChallenge(repo repository, graceSeconds int, armed bool) error fmt.Fprintf(a.stdout, "deadline: %s\n", deadline.Format(time.RFC3339)) a.notify("GitReal", fmt.Sprintf("%s has %d unpushed commit(s). Push before %s.", branch, ahead, deadline.Format("15:04:05"))) - a.sleepUntil(deadline) + if err := a.sleepUntil(ctx, deadline); err != nil { + // User cancelled during the grace period. The user has not yet missed + // the deadline from their perspective, so do not apply the penalty. + return errInterrupted + } if err := repo.FetchQuiet(); err != nil { a.notify("GitReal", "fetch failed; punishment skipped for safety.") @@ -384,6 +427,11 @@ func (a *app) runChallenge(repo repository, graceSeconds int, armed bool) error return nil } + if ctx.Err() != nil { + fmt.Fprintln(a.stdout, "interrupted before reset; punishment skipped") + return errInterrupted + } + backupRef, err := repo.BackupHead(branch, a.now()) if err != nil { return err @@ -417,11 +465,12 @@ func (a *app) notify(title, message string) { } } -func (a *app) sleepUntil(target time.Time) { +func (a *app) sleepUntil(ctx context.Context, target time.Time) error { duration := target.Sub(a.now()) - if duration > 0 { - a.sleep(duration) + if duration <= 0 { + return ctx.Err() } + return a.sleep(ctx, duration) } func resolveGraceSeconds(args []string, repo repository, stderr io.Writer) (int, error) { diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index c96b4f6..8c2894e 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "errors" "math/rand" "strings" @@ -130,7 +131,8 @@ func (f *fakeRepo) BackupHead(branch string, now time.Time) (string, error) { if f.backupRef != "" { return f.backupRef, nil } - return "refs/gitreal/backups/" + branch + "/" + now.UTC().Format("20060102T150405Z"), nil + timestamp := now.UTC().Format("20060102T150405Z") + "-000000000" + return "refs/gitreal/backups/" + branch + "/" + timestamp, nil } func (f *fakeRepo) StashDirtyWorktree(message string) (bool, error) { @@ -158,15 +160,34 @@ func (f *fakeRepo) RescueRefs() ([]string, error) { } type fakeClock struct { - current time.Time + current time.Time + cancelAt time.Duration // cumulative slept time at which to fire the cancel + slept time.Duration + cancelFn context.CancelFunc + sleepHook func(d time.Duration) // optional pre-sleep hook for tests } func (f *fakeClock) now() time.Time { return f.current } -func (f *fakeClock) sleep(duration time.Duration) { - f.current = f.current.Add(duration) +func (f *fakeClock) sleep(ctx context.Context, d time.Duration) error { + if f.sleepHook != nil { + f.sleepHook(d) + } + if f.cancelFn != nil && f.cancelAt > 0 { + next := f.slept + d + if next >= f.cancelAt { + // Advance the clock only up to the cancellation point and fire it. + f.current = f.current.Add(f.cancelAt - f.slept) + f.slept = f.cancelAt + f.cancelFn() + return ctx.Err() + } + } + f.slept += d + f.current = f.current.Add(d) + return ctx.Err() } func newTestApp(repo repository) (*app, *bytes.Buffer, *bytes.Buffer, *fakeClock, *[]string) { @@ -200,7 +221,7 @@ func TestTopLevelRunAndNewApp(t *testing.T) { stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) - if got := Run([]string{"help"}, stdout, stderr); got != 0 { + if got := Run(context.Background(), []string{"help"}, stdout, stderr); got != 0 { t.Fatalf("Run(help) = %d, want 0", got) } if !strings.Contains(stdout.String(), "git real once") { @@ -222,7 +243,7 @@ func TestRunHelpAndUnknown(t *testing.T) { app, stdout, stderr, _, _ := newTestApp(&fakeRepo{}) - if got := app.run(nil); got != 0 { + if got := app.run(context.Background(), nil); got != 0 { t.Fatalf("run(nil) = %d, want 0", got) } if !strings.Contains(stdout.String(), "Usage:") { @@ -231,7 +252,7 @@ func TestRunHelpAndUnknown(t *testing.T) { stdout.Reset() stderr.Reset() - if got := app.run([]string{"wat"}); got != 2 { + if got := app.run(context.Background(), []string{"wat"}); got != 2 { t.Fatalf("run(unknown) = %d, want 2", got) } if !strings.Contains(stderr.String(), "unknown command: wat") { @@ -252,7 +273,7 @@ func TestInitArmDisarmAndStatus(t *testing.T) { } app, stdout, _, _, _ := newTestApp(repo) - if got := app.run([]string{"init"}); got != 0 { + if got := app.run(context.Background(), []string{"init"}); got != 0 { t.Fatalf("init exit code = %d, want 0", got) } if !repo.setBoolCalls["gitreal.enabled"] || repo.setBoolCalls["gitreal.armed"] { @@ -263,7 +284,7 @@ func TestInitArmDisarmAndStatus(t *testing.T) { } stdout.Reset() - if got := app.run([]string{"arm"}); got != 0 { + if got := app.run(context.Background(), []string{"arm"}); got != 0 { t.Fatalf("arm exit code = %d, want 0", got) } if !repo.configBools["gitreal.armed"] { @@ -271,7 +292,7 @@ func TestInitArmDisarmAndStatus(t *testing.T) { } stdout.Reset() - if got := app.run([]string{"disarm"}); got != 0 { + if got := app.run(context.Background(), []string{"disarm"}); got != 0 { t.Fatalf("disarm exit code = %d, want 0", got) } if repo.configBools["gitreal.armed"] { @@ -279,7 +300,7 @@ func TestInitArmDisarmAndStatus(t *testing.T) { } stdout.Reset() - if got := app.run([]string{"status"}); got != 0 { + if got := app.run(context.Background(), []string{"status"}); got != 0 { t.Fatalf("status exit code = %d, want 0", got) } @@ -311,7 +332,7 @@ func TestStatusWithoutUpstream(t *testing.T) { } app, stdout, _, _, _ := newTestApp(repo) - if got := app.run([]string{"status"}); got != 0 { + if got := app.run(context.Background(), []string{"status"}); got != 0 { t.Fatalf("status exit code = %d, want 0", got) } if !strings.Contains(stdout.String(), "upstream: ") || !strings.Contains(stdout.String(), "ahead: unknown") { @@ -329,10 +350,10 @@ func TestInitIsIdempotentAndResetsMode(t *testing.T) { } app, _, _, _, _ := newTestApp(repo) - if got := app.run([]string{"init"}); got != 0 { + if got := app.run(context.Background(), []string{"init"}); got != 0 { t.Fatalf("first init exit code = %d, want 0", got) } - if got := app.run([]string{"init"}); got != 0 { + if got := app.run(context.Background(), []string{"init"}); got != 0 { t.Fatalf("second init exit code = %d, want 0", got) } if !repo.configBools["gitreal.enabled"] { @@ -385,7 +406,7 @@ func TestCommandOnceViaRun(t *testing.T) { } app, stdout, _, _, _ := newTestApp(repo) - if got := app.run([]string{"once"}); got != 0 { + if got := app.run(context.Background(), []string{"once"}); got != 0 { t.Fatalf("once exit code = %d, want 0", got) } if !strings.Contains(stdout.String(), "nothing to do") { @@ -405,7 +426,7 @@ func TestCommandParseFailures(t *testing.T) { } app, _, stderr, _, _ := newTestApp(repo) - if got := app.run([]string{"once", "--grace-seconds=nope"}); got != 2 { + if got := app.run(context.Background(), []string{"once", "--grace-seconds=nope"}); got != 2 { t.Fatalf("once invalid exit code = %d, want 2", got) } if !strings.Contains(stderr.String(), "invalid value") { @@ -413,7 +434,7 @@ func TestCommandParseFailures(t *testing.T) { } stderr.Reset() - if got := app.run([]string{"start", "extra"}); got != 2 { + if got := app.run(context.Background(), []string{"start", "extra"}); got != 2 { t.Fatalf("start invalid exit code = %d, want 2", got) } } @@ -429,7 +450,7 @@ func TestRunChallengeNoUnpushedCommits(t *testing.T) { } app, stdout, _, _, notifications := newTestApp(repo) - if err := app.runChallenge(repo, 120, false); err != nil { + if err := app.runChallenge(context.Background(), repo, 120, false); err != nil { t.Fatalf("runChallenge() error = %v", err) } if repo.fetchCalls != 1 { @@ -454,7 +475,7 @@ func TestRunChallengeDryRun(t *testing.T) { } app, stdout, _, clock, notifications := newTestApp(repo) - if err := app.runChallenge(repo, 120, false); err != nil { + if err := app.runChallenge(context.Background(), repo, 120, false); err != nil { t.Fatalf("runChallenge() error = %v", err) } if !strings.Contains(stdout.String(), "dry-run: would reset 2 commit(s) to @{u}") { @@ -479,7 +500,7 @@ func TestRunChallengePushConfirmed(t *testing.T) { } app, stdout, _, _, notifications := newTestApp(repo) - if err := app.runChallenge(repo, 30, false); err != nil { + if err := app.runChallenge(context.Background(), repo, 30, false); err != nil { t.Fatalf("runChallenge() error = %v", err) } if !strings.Contains(stdout.String(), "push confirmed") { @@ -502,7 +523,7 @@ func TestRunChallengePreflightFetchFailureContinues(t *testing.T) { } app, stdout, _, _, _ := newTestApp(repo) - if err := app.runChallenge(repo, 30, false); err != nil { + if err := app.runChallenge(context.Background(), repo, 30, false); err != nil { t.Fatalf("runChallenge() error = %v", err) } if !strings.Contains(stdout.String(), "preflight fetch failed; continuing with last known upstream state: fetch failed") { @@ -525,7 +546,7 @@ func TestRunChallengeFetchFailureSkipsPunishment(t *testing.T) { } app, stdout, _, _, _ := newTestApp(repo) - if err := app.runChallenge(repo, 30, true); err != nil { + if err := app.runChallenge(context.Background(), repo, 30, true); err != nil { t.Fatalf("runChallenge() error = %v", err) } if len(repo.resetCalls) != 0 { @@ -544,12 +565,12 @@ func TestRunChallengeArmed(t *testing.T) { currentBranch: "main", upstream: "origin/main", aheadCounts: []int{2, 2}, - backupRef: "refs/gitreal/backups/main/20260501T120200Z", + backupRef: "refs/gitreal/backups/main/20260501T120200Z-000000000", stashDirty: true, } app, stdout, _, _, notifications := newTestApp(repo) - if err := app.runChallenge(repo, 120, true); err != nil { + if err := app.runChallenge(context.Background(), repo, 120, true); err != nil { t.Fatalf("runChallenge() error = %v", err) } if len(repo.resetCalls) != 1 || repo.resetCalls[0] != "@{u}" { @@ -566,6 +587,63 @@ func TestRunChallengeArmed(t *testing.T) { } } +func TestRunChallengeCancelDuringGracePeriodSkipsReset(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{2, 2}, + } + app, stdout, _, clock, _ := newTestApp(repo) + + ctx, cancel := context.WithCancel(context.Background()) + clock.cancelFn = cancel + clock.cancelAt = 30 * time.Second // fire cancel mid-grace + + if err := app.runChallenge(ctx, repo, 120, true); err == nil || !errors.Is(err, errInterrupted) { + t.Fatalf("runChallenge() error = %v, want errInterrupted", err) + } + if len(repo.resetCalls) != 0 { + t.Fatalf("resetCalls = %v, want none after cancel", repo.resetCalls) + } + if len(repo.backupCalls) != 0 { + t.Fatalf("backupCalls = %v, want none after cancel", repo.backupCalls) + } + if strings.Contains(stdout.String(), "Local commits made unreal") { + t.Fatalf("stdout = %q, must not contain penalty message", stdout.String()) + } +} + +func TestCommandOnceTreatsCancelAsCleanExit(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": true, "gitreal.armed": true}, + configInts: map[string]int{"gitreal.graceSeconds": 30}, + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{1, 1}, + } + app, stdout, _, clock, _ := newTestApp(repo) + + ctx, cancel := context.WithCancel(context.Background()) + clock.cancelFn = cancel + clock.cancelAt = 5 * time.Second + + if got := app.run(ctx, []string{"once"}); got != 0 { + t.Fatalf("once after cancel exit = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "interrupted") { + t.Fatalf("stdout = %q, want interrupted notice", stdout.String()) + } + if len(repo.resetCalls) != 0 { + t.Fatalf("resetCalls = %v, want none", repo.resetCalls) + } +} + func TestRunChallengeStashPopFailure(t *testing.T) { t.Parallel() @@ -574,13 +652,13 @@ func TestRunChallengeStashPopFailure(t *testing.T) { currentBranch: "main", upstream: "origin/main", aheadCounts: []int{1, 1}, - backupRef: "refs/gitreal/backups/main/1", + backupRef: "refs/gitreal/backups/main/20260501T120000Z-000000001", stashDirty: true, stashPopErr: errors.New("conflict"), } app, stdout, _, _, _ := newTestApp(repo) - if err := app.runChallenge(repo, 1, true); err != nil { + if err := app.runChallenge(context.Background(), repo, 1, true); err != nil { t.Fatalf("runChallenge() error = %v", err) } if !strings.Contains(stdout.String(), "stash pop failed") { @@ -613,11 +691,11 @@ func TestRunChallengeErrors(t *testing.T) { }, { name: "stash error", - repo: &fakeRepo{currentBranch: "main", upstream: "origin/main", aheadCounts: []int{1, 1}, backupRef: "refs/gitreal/backups/main/1", stashErr: errors.New("boom")}, + repo: &fakeRepo{currentBranch: "main", upstream: "origin/main", aheadCounts: []int{1, 1}, backupRef: "refs/gitreal/backups/main/20260501T120000Z-000000001", stashErr: errors.New("boom")}, }, { name: "reset error", - repo: &fakeRepo{currentBranch: "main", upstream: "origin/main", aheadCounts: []int{1, 1}, backupRef: "refs/gitreal/backups/main/1", resetErr: errors.New("boom")}, + repo: &fakeRepo{currentBranch: "main", upstream: "origin/main", aheadCounts: []int{1, 1}, backupRef: "refs/gitreal/backups/main/20260501T120000Z-000000001", resetErr: errors.New("boom")}, }, } @@ -626,7 +704,7 @@ func TestRunChallengeErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() app, _, _, _, _ := newTestApp(tc.repo) - if err := app.runChallenge(tc.repo, 1, true); err == nil { + if err := app.runChallenge(context.Background(), tc.repo, 1, true); err == nil { t.Fatalf("runChallenge() error = nil, want non-nil") } }) @@ -653,29 +731,29 @@ func TestRescueCommands(t *testing.T) { repo := &fakeRepo{ currentBranch: "main", - backupRef: "refs/gitreal/backups/main/current", + backupRef: "refs/gitreal/backups/main/20260501T120100Z-000000000", rescueRefs: []string{ - "refs/gitreal/backups/main/1", - "refs/gitreal/backups/main/2", + "refs/gitreal/backups/main/20260501T120000Z-000000001", + "refs/gitreal/backups/main/20260501T120000Z-000000002", }, } app, stdout, stderr, _, _ := newTestApp(repo) - if got := app.run([]string{"rescue", "list"}); got != 0 { + if got := app.run(context.Background(), []string{"rescue", "list"}); got != 0 { t.Fatalf("rescue list exit code = %d, want 0", got) } - if !strings.Contains(stdout.String(), "refs/gitreal/backups/main/1") { + if !strings.Contains(stdout.String(), "refs/gitreal/backups/main/20260501T120000Z-000000001") { t.Fatalf("stdout = %q, want rescue refs", stdout.String()) } stdout.Reset() - if got := app.run([]string{"rescue", "restore", "refs/gitreal/backups/main/1"}); got != 0 { + if got := app.run(context.Background(), []string{"rescue", "restore", "refs/gitreal/backups/main/20260501T120000Z-000000001"}); got != 0 { t.Fatalf("rescue restore exit code = %d, want 0", got) } - if len(repo.resetCalls) != 1 || repo.resetCalls[0] != "refs/gitreal/backups/main/1" { + if len(repo.resetCalls) != 1 || repo.resetCalls[0] != "refs/gitreal/backups/main/20260501T120000Z-000000001" { t.Fatalf("resetCalls = %v, want restore ref", repo.resetCalls) } - if !strings.Contains(stdout.String(), "Current branch reset to backup ref: refs/gitreal/backups/main/1") { + if !strings.Contains(stdout.String(), "Current branch reset to backup ref: refs/gitreal/backups/main/20260501T120000Z-000000001") { t.Fatalf("stdout = %q, want restore success message", stdout.String()) } if !strings.Contains(stdout.String(), "previous HEAD backed up to: "+repo.backupRef) { @@ -687,10 +765,10 @@ func TestRescueCommands(t *testing.T) { stdout.Reset() stderr.Reset() - if got := app.run([]string{"rescue", "restore", "refs/heads/main"}); got != 2 { + if got := app.run(context.Background(), []string{"rescue", "restore", "refs/heads/main"}); got != 2 { t.Fatalf("rescue restore invalid exit code = %d, want 2", got) } - if !strings.Contains(stderr.String(), "ref must start with refs/gitreal/backups/") { + if !strings.Contains(stderr.String(), "ref must be a GitReal backup ref") { t.Fatalf("stderr = %q, want backup prefix error", stderr.String()) } } @@ -700,13 +778,13 @@ func TestRescueRestoreStashPopFailure(t *testing.T) { repo := &fakeRepo{ currentBranch: "main", - backupRef: "refs/gitreal/backups/main/current", + backupRef: "refs/gitreal/backups/main/20260501T120100Z-000000000", stashDirty: true, stashPopErr: errors.New("conflict"), } app, stdout, _, _, _ := newTestApp(repo) - if got := app.run([]string{"rescue", "restore", "refs/gitreal/backups/main/1"}); got != 0 { + if got := app.run(context.Background(), []string{"rescue", "restore", "refs/gitreal/backups/main/20260501T120000Z-000000001"}); got != 0 { t.Fatalf("rescue restore exit code = %d, want 0", got) } if !strings.Contains(stdout.String(), "stash pop failed") { @@ -721,7 +799,7 @@ func TestRescueCommandErrors(t *testing.T) { t.Parallel() app, _, stderr, _, _ := newTestApp(&fakeRepo{}) - if got := app.run([]string{"rescue"}); got != 2 { + if got := app.run(context.Background(), []string{"rescue"}); got != 2 { t.Fatalf("rescue exit code = %d, want 2", got) } if !strings.Contains(stderr.String(), "expected subcommand list or restore ") { @@ -729,7 +807,7 @@ func TestRescueCommandErrors(t *testing.T) { } stderr.Reset() - if got := app.run([]string{"rescue", "wat"}); got != 2 { + if got := app.run(context.Background(), []string{"rescue", "wat"}); got != 2 { t.Fatalf("rescue unknown exit code = %d, want 2", got) } if !strings.Contains(stderr.String(), "unknown subcommand: wat") { @@ -737,12 +815,12 @@ func TestRescueCommandErrors(t *testing.T) { } stderr.Reset() - if got := app.run([]string{"rescue", "list", "extra"}); got != 2 { + if got := app.run(context.Background(), []string{"rescue", "list", "extra"}); got != 2 { t.Fatalf("rescue list exit code = %d, want 2", got) } stderr.Reset() - if got := app.run([]string{"rescue", "restore"}); got != 2 { + if got := app.run(context.Background(), []string{"rescue", "restore"}); got != 2 { t.Fatalf("rescue restore exit code = %d, want 2", got) } } @@ -761,7 +839,7 @@ func TestCommandStartSingleIteration(t *testing.T) { app, stdout, _, _, _ := newTestApp(repo) app.startIterations = 1 - if got := app.run([]string{"start"}); got != 0 { + if got := app.run(context.Background(), []string{"start"}); got != 0 { t.Fatalf("start exit code = %d, want 0", got) } if !strings.Contains(stdout.String(), "GitReal started for /tmp/repo") || !strings.Contains(stdout.String(), "next challenge:") { @@ -781,7 +859,7 @@ func TestCommandStartHandlesChallengeError(t *testing.T) { app, _, stderr, _, _ := newTestApp(repo) app.startIterations = 1 - if got := app.run([]string{"start"}); got != 0 { + if got := app.run(context.Background(), []string{"start"}); got != 0 { t.Fatalf("start exit code = %d, want 0", got) } if !strings.Contains(stderr.String(), "detached") { @@ -789,6 +867,131 @@ func TestCommandStartHandlesChallengeError(t *testing.T) { } } +func TestSleepUntilWithPastDeadline(t *testing.T) { + t.Parallel() + + app, _, _, clock, _ := newTestApp(&fakeRepo{}) + if err := app.sleepUntil(context.Background(), clock.now().Add(-time.Hour)); err != nil { + t.Fatalf("sleepUntil(past) = %v, want nil", err) + } +} + +func TestSleepWithContextHonoursCancellationAndZeroDuration(t *testing.T) { + t.Parallel() + + if err := sleepWithContext(context.Background(), 0); err != nil { + t.Fatalf("sleepWithContext(zero) error = %v, want nil", err) + } + + if err := sleepWithContext(context.Background(), -time.Second); err != nil { + t.Fatalf("sleepWithContext(negative) error = %v, want nil", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := sleepWithContext(ctx, time.Hour) + if !errors.Is(err, context.Canceled) { + t.Fatalf("sleepWithContext(cancelled) error = %v, want canceled", err) + } + + start := time.Now() + if err := sleepWithContext(context.Background(), 5*time.Millisecond); err != nil { + t.Fatalf("sleepWithContext(positive) error = %v", err) + } + if elapsed := time.Since(start); elapsed < 4*time.Millisecond { + t.Fatalf("sleepWithContext returned too fast: %v", elapsed) + } +} + +func TestRunStartCancelBetweenIterations(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": true, "gitreal.armed": false}, + configInts: map[string]int{"gitreal.graceSeconds": 30}, + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{0}, + } + app, stdout, _, _, _ := newTestApp(repo) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled before runStart begins + + if got := app.runStart(ctx, repo, 30, 5); got != 0 { + t.Fatalf("runStart() exit = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "interrupted; stopping scheduler") { + t.Fatalf("stdout = %q, want scheduler stop notice", stdout.String()) + } +} + +func TestRunStartContinuesPastChallengeError(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": true, "gitreal.armed": false}, + configInts: map[string]int{"gitreal.graceSeconds": 30}, + currentBranchErr: errors.New("detached"), + } + app, _, stderr, _, _ := newTestApp(repo) + + if got := app.runStart(context.Background(), repo, 30, 1); got != 0 { + t.Fatalf("runStart() exit = %d, want 0", got) + } + if !strings.Contains(stderr.String(), "detached") { + t.Fatalf("stderr = %q, want challenge error", stderr.String()) + } +} + +func TestRunStartCancelStopsLoopWithoutChallenge(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": true, "gitreal.armed": false}, + configInts: map[string]int{"gitreal.graceSeconds": 30}, + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{0}, + } + app, stdout, _, clock, _ := newTestApp(repo) + + ctx, cancel := context.WithCancel(context.Background()) + clock.cancelFn = cancel + clock.cancelAt = 1 * time.Second // cancel during the wait for next slot + + if got := app.runStart(ctx, repo, 30, 5); got != 0 { + t.Fatalf("runStart() exit = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "interrupted; stopping scheduler") { + t.Fatalf("stdout = %q, want scheduler stop notice", stdout.String()) + } + if repo.fetchCalls != 0 { + t.Fatalf("fetchCalls = %d, want 0 (no challenge ran)", repo.fetchCalls) + } +} + +func TestRestoreBackupRefBackupHeadFailure(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + currentBranch: "main", + backupErr: errors.New("update-ref failed"), + } + app, _, stderr, _, _ := newTestApp(repo) + + if got := app.run(context.Background(), []string{"rescue", "restore", "refs/gitreal/backups/main/20260501T120000Z-000000001"}); got != 1 { + t.Fatalf("rescue restore exit = %d, want 1", got) + } + if !strings.Contains(stderr.String(), "update-ref failed") { + t.Fatalf("stderr = %q, want backup error", stderr.String()) + } +} + func TestNextRandomSlot(t *testing.T) { t.Parallel() @@ -821,7 +1024,7 @@ func TestCommandFailures(t *testing.T) { return nil, discoverErr }, now: time.Now, - sleep: time.Sleep, + sleep: sleepWithContext, sendNotification: func(title, message string) error { return nil }, rng: rand.New(rand.NewSource(1)), stdout: stdout, @@ -831,7 +1034,7 @@ func TestCommandFailures(t *testing.T) { for _, args := range [][]string{{"init"}, {"status"}, {"once"}, {"start"}, {"arm"}, {"disarm"}, {"rescue", "list"}} { stdout.Reset() stderr.Reset() - if got := app.run(args); got != 1 { + if got := app.run(context.Background(), args); got != 1 { t.Fatalf("run(%v) = %d, want 1", args, got) } if !strings.Contains(stderr.String(), "not inside a Git repository") { @@ -862,7 +1065,7 @@ func TestCommandsRequireInitialization(t *testing.T) { } app, _, stderr, _, _ := newTestApp(repo) - if got := app.run(tc.args); got != 1 { + if got := app.run(context.Background(), tc.args); got != 1 { t.Fatalf("run(%v) = %d, want 1", tc.args, got) } if !strings.Contains(stderr.String(), "repository is not initialized for GitReal; run: git real init") { @@ -881,11 +1084,11 @@ func TestStatusAndRescueWorkWithoutInitialization(t *testing.T) { configInts: map[string]int{"gitreal.graceSeconds": 120}, currentBranch: "main", upstreamErr: errors.New("missing"), - rescueRefs: []string{"refs/gitreal/backups/main/1"}, + rescueRefs: []string{"refs/gitreal/backups/main/20260501T120000Z-000000001"}, } app, stdout, stderr, _, _ := newTestApp(repo) - if got := app.run([]string{"status"}); got != 0 { + if got := app.run(context.Background(), []string{"status"}); got != 0 { t.Fatalf("status exit code = %d, want 0", got) } if !strings.Contains(stdout.String(), "enabled: false") { @@ -894,10 +1097,10 @@ func TestStatusAndRescueWorkWithoutInitialization(t *testing.T) { stdout.Reset() stderr.Reset() - if got := app.run([]string{"rescue", "list"}); got != 0 { + if got := app.run(context.Background(), []string{"rescue", "list"}); got != 0 { t.Fatalf("rescue list exit code = %d, want 0", got) } - if !strings.Contains(stdout.String(), "refs/gitreal/backups/main/1") { + if !strings.Contains(stdout.String(), "refs/gitreal/backups/main/20260501T120000Z-000000001") { t.Fatalf("stdout = %q, want rescue refs", stdout.String()) } } @@ -907,7 +1110,7 @@ func TestConfigWriteFailures(t *testing.T) { repo := &fakeRepo{setBoolErr: errors.New("boom")} app, _, stderr, _, _ := newTestApp(repo) - if got := app.run([]string{"init"}); got != 1 { + if got := app.run(context.Background(), []string{"init"}); got != 1 { t.Fatalf("init exit code = %d, want 1", got) } if !strings.Contains(stderr.String(), "boom") { @@ -928,14 +1131,14 @@ func TestConfigWriteFailures(t *testing.T) { setBoolErr: errors.New("boom"), } app, _, stderr, _, _ = newTestApp(repo) - if got := app.run([]string{"arm"}); got != 1 { + if got := app.run(context.Background(), []string{"arm"}); got != 1 { t.Fatalf("arm exit code = %d, want 1", got) } if !strings.Contains(stderr.String(), "boom") { t.Fatalf("stderr = %q, want config error", stderr.String()) } stderr.Reset() - if got := app.run([]string{"disarm"}); got != 1 { + if got := app.run(context.Background(), []string{"disarm"}); got != 1 { t.Fatalf("disarm exit code = %d, want 1", got) } if !strings.Contains(stderr.String(), "boom") { diff --git a/internal/git/git.go b/internal/git/git.go index 49fc419..bab8850 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -1,15 +1,33 @@ package git import ( + "crypto/sha256" + "encoding/hex" "fmt" "os/exec" - "path/filepath" + "regexp" "strconv" "strings" "time" ) -const backupRefPrefix = "refs/gitreal/backups/" +const ( + backupRefPrefix = "refs/gitreal/backups/" + maxSafeBranchBytes = 64 +) + +// backupRefPattern enforces the exact shape that BackupHead produces: +// +// refs/gitreal/backups//- +// +// The safeBranch segment is restricted to allowlisted characters so the +// downstream `git update-ref` and `git reset --hard` cannot be tricked into +// resolving git revision syntax (`@{N}`, `^`, `~`, `:`) or shell metacharacters. +var backupRefPattern = regexp.MustCompile( + `^refs/gitreal/backups/[A-Za-z0-9._-]+/[0-9]{8}T[0-9]{6}Z-[0-9]{9}$`, +) + +var safeBranchAllowed = regexp.MustCompile(`[^A-Za-z0-9._-]+`) type runner interface { run(dir string, args ...string) (string, error) @@ -141,11 +159,18 @@ func (r *Repository) AheadCount() (int, error) { } func (r *Repository) BackupHead(branch string, now time.Time) (string, error) { - safeBranch := strings.ReplaceAll(branch, string(filepath.Separator), "-") - safeBranch = strings.ReplaceAll(safeBranch, "/", "-") + safeBranch := sanitizeBranchSegment(branch) timestamp := fmt.Sprintf("%s-%09d", now.UTC().Format("20060102T150405Z"), now.UTC().Nanosecond()) backupRef := fmt.Sprintf("%s%s/%s", backupRefPrefix, safeBranch, timestamp) + + if !backupRefPattern.MatchString(backupRef) { + // Defense in depth: this should be impossible because + // sanitizeBranchSegment guarantees the allowed character class, but + // we never want to hand a malformed ref to `git update-ref`. + return "", fmt.Errorf("refusing to write malformed backup ref: %q", backupRef) + } + _, err := r.run("update-ref", backupRef, "HEAD") if err != nil { return "", err @@ -154,6 +179,26 @@ func (r *Repository) BackupHead(branch string, now time.Time) (string, error) { return backupRef, nil } +// sanitizeBranchSegment maps an arbitrary branch name (including hostile or +// non-ASCII names) to a single ref path segment matching [A-Za-z0-9._-]+. +// If the input would collapse to empty or to a leading dot/dash (which git +// rejects), it falls back to a "branch-" hash so we always +// produce a stable, valid identifier. +func sanitizeBranchSegment(branch string) string { + collapsed := safeBranchAllowed.ReplaceAllString(branch, "-") + collapsed = strings.Trim(collapsed, "-.") + + for strings.Contains(collapsed, "--") { + collapsed = strings.ReplaceAll(collapsed, "--", "-") + } + + if collapsed == "" || len(collapsed) > maxSafeBranchBytes { + sum := sha256.Sum256([]byte(branch)) + return "branch-" + hex.EncodeToString(sum[:6]) + } + return collapsed +} + func (r *Repository) StashDirtyWorktree(message string) (bool, error) { out, err := r.run("status", "--porcelain=v1", "-z") if err != nil { @@ -196,8 +241,12 @@ func (r *Repository) RescueRefs() ([]string, error) { return strings.Split(text, "\n"), nil } +// IsBackupRef reports whether ref is a well-formed GitReal backup ref produced +// by BackupHead. It performs a full pattern match (not just a prefix check) so +// that user-supplied refs containing git revision syntax such as `@{-1}`, +// `^`, `~`, or `:` cannot reach `git reset --hard` via `rescue restore`. func IsBackupRef(ref string) bool { - return strings.HasPrefix(ref, backupRefPrefix) + return backupRefPattern.MatchString(ref) } func (r *Repository) run(args ...string) (string, error) { diff --git a/internal/git/git_test.go b/internal/git/git_test.go index bc52a95..ab7b0cb 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "os/exec" + "strings" "testing" "time" ) @@ -292,12 +293,152 @@ func TestBackupHead(t *testing.T) { if ref != want { t.Fatalf("BackupHead() = %q, want %q", ref, want) } + if !IsBackupRef(ref) { + t.Fatalf("IsBackupRef(%q) = false, want true", ref) + } if _, err := repo.BackupHead("main", now); err == nil { t.Fatalf("BackupHead() error = nil, want non-nil") } } +func TestSanitizeBranchSegment(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + want string // empty means "fallback to hash, just check it's well-formed" + }{ + {name: "simple", input: "main", want: "main"}, + {name: "slash", input: "feature/test", want: "feature-test"}, + {name: "backslash", input: "feature\\sub", want: "feature-sub"}, + {name: "release dotted", input: "release-1.0", want: "release-1.0"}, + {name: "git revision", input: "main@{1}", want: "main-1"}, + {name: "caret", input: "main^", want: "main"}, + {name: "tilde", input: "main~3", want: "main-3"}, + {name: "glob asterisk", input: "*", want: ""}, + {name: "glob brackets", input: "[abc]", want: "abc"}, + {name: "two dots", input: "..", want: ""}, + {name: "single dot", input: ".", want: ""}, + {name: "leading dash", input: "-leading", want: "leading"}, + {name: "trailing dot", input: "trailing.", want: "trailing"}, + {name: "with space", input: "with space", want: "with-space"}, + {name: "with tab", input: "with\ttab", want: "with-tab"}, + {name: "with newline", input: "with\nnewline", want: "with-newline"}, + {name: "with null", input: "with/null\x00here", want: "with-null-here"}, + {name: "very long", input: strings.Repeat("a", 256)}, + {name: "japanese", input: "機能/テスト"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := sanitizeBranchSegment(tc.input) + if got == "" { + t.Fatalf("sanitizeBranchSegment(%q) = empty string", tc.input) + } + candidate := backupRefPrefix + got + "/20260501T120000Z-000000000" + if !IsBackupRef(candidate) { + t.Fatalf("sanitizeBranchSegment(%q) = %q, produces invalid backup ref %q", tc.input, got, candidate) + } + if tc.want != "" && got != tc.want { + t.Fatalf("sanitizeBranchSegment(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestAheadCountRunError(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|rev-list\x00--count\x00@{u}..HEAD": {err: errors.New("boom")}, + }, + }, + } + if _, err := repo.AheadCount(); err == nil { + t.Fatalf("AheadCount() error = nil, want non-nil") + } +} + +func TestSanitizeBranchSegmentFallback(t *testing.T) { + t.Parallel() + + // All-special input collapses to empty before fallback. + got := sanitizeBranchSegment("***///") + if !strings.HasPrefix(got, "branch-") || len(got) != len("branch-")+12 { + t.Fatalf("sanitizeBranchSegment(all-special) = %q, want branch-<12hex>", got) + } + + // Excessively long input falls back to the hash form. + got = sanitizeBranchSegment(strings.Repeat("a", 256)) + if !strings.HasPrefix(got, "branch-") { + t.Fatalf("sanitizeBranchSegment(long) = %q, want branch- prefix", got) + } +} + +func TestBackupHeadRejectsCraftedSafeBranch(t *testing.T) { + t.Parallel() + + // Force-feed sanitizeBranchSegment a value that the regex rejects to + // exercise the defense-in-depth check that refuses to run `update-ref` + // with a malformed ref. We bypass sanitizeBranchSegment by constructing + // the ref through internal helpers indirectly: build a Repository whose + // runner records arguments, then invoke BackupHead with a name that we + // confirm sanitizes to something the pattern accepts; then independently + // validate that the regex would reject a hand-crafted bad ref. + now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + runner := &fakeRunner{responses: map[string]fakeResponse{ + "/tmp/repo|update-ref\x00refs/gitreal/backups/main/20260501T120000Z-000000000\x00HEAD": {}, + }} + repo := &Repository{root: "/tmp/repo", runner: runner} + if _, err := repo.BackupHead("main", now); err != nil { + t.Fatalf("BackupHead(main) error = %v", err) + } + + if backupRefPattern.MatchString("refs/gitreal/backups/main@{1}/20260501T120000Z-000000000") { + t.Fatalf("backupRefPattern accepted a ref with revision syntax") + } +} + +func TestIsBackupRefRejectsRevisionSyntax(t *testing.T) { + t.Parallel() + + cases := []struct { + ref string + want bool + }{ + {ref: "refs/gitreal/backups/main/20260501T120000Z-000000000", want: true}, + {ref: "refs/gitreal/backups/feature-test/20260501T120000Z-123456789", want: true}, + {ref: "refs/heads/main", want: false}, + {ref: "refs/gitreal/backups/main@{-1}/20260501T120000Z-000000000", want: false}, + {ref: "refs/gitreal/backups/main^/20260501T120000Z-000000000", want: false}, + {ref: "refs/gitreal/backups/main~3/20260501T120000Z-000000000", want: false}, + {ref: "refs/gitreal/backups/main:passwd/20260501T120000Z-000000000", want: false}, + {ref: "refs/gitreal/backups/main/20260501T120000Z-000000000 --no-ff", want: false}, + {ref: "refs/gitreal/backups/main/20260501T120000Z", want: false}, + {ref: "refs/gitreal/backups/main/", want: false}, + {ref: "refs/gitreal/backups//20260501T120000Z-000000000", want: false}, + {ref: "refs/gitreal/backups/main/20260501T120000Z-000000000/extra", want: false}, + {ref: "refs/gitreal/backups/../../../etc/passwd", want: false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.ref, func(t *testing.T) { + t.Parallel() + if got := IsBackupRef(tc.ref); got != tc.want { + t.Fatalf("IsBackupRef(%q) = %t, want %t", tc.ref, got, tc.want) + } + }) + } +} + func TestStashDirtyWorktree(t *testing.T) { t.Parallel() @@ -379,21 +520,22 @@ func TestStashDirtyWorktreeEdgeCases(t *testing.T) { func TestResetRescueAndHelpers(t *testing.T) { t.Parallel() + validRef := "refs/gitreal/backups/main/20260501T120000Z-000000001" + validRef2 := "refs/gitreal/backups/main/20260501T120000Z-000000002" + repo := &Repository{ root: "/tmp/repo", runner: &fakeRunner{ responses: map[string]fakeResponse{ - "/tmp/repo|reset\x00--hard\x00refs/gitreal/backups/main/1": {}, - "/tmp/repo|stash\x00pop": {}, - "/tmp/repo|fetch\x00--quiet\x00--prune": {}, - "/tmp/repo|for-each-ref\x00refs/gitreal/backups/\x00--format=%(refname)": { - output: "refs/gitreal/backups/main/1\nrefs/gitreal/backups/main/2\n", - }, + "/tmp/repo|reset\x00--hard\x00" + validRef: {}, + "/tmp/repo|stash\x00pop": {}, + "/tmp/repo|fetch\x00--quiet\x00--prune": {}, + "/tmp/repo|for-each-ref\x00refs/gitreal/backups/\x00--format=%(refname)": {output: validRef + "\n" + validRef2 + "\n"}, }, }, } - if err := repo.ResetHard("refs/gitreal/backups/main/1"); err != nil { + if err := repo.ResetHard(validRef); err != nil { t.Fatalf("ResetHard() error = %v", err) } if err := repo.StashPop(); err != nil { diff --git a/internal/notify/notify.go b/internal/notify/notify.go index a362d81..29c4d76 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -2,14 +2,20 @@ package notify import ( "bytes" + "encoding/base64" + "encoding/binary" "encoding/xml" "fmt" "os/exec" "runtime" "strconv" "strings" + "unicode/utf16" + "unicode/utf8" ) +const maxFieldBytes = 1024 + var currentGOOS = runtime.GOOS var runCommand = func(name string, args ...string) error { @@ -22,7 +28,7 @@ func Send(title, message string) error { } func send(goos, title, message string, run func(name string, args ...string) error) error { - name, args, ok := command(goos, title, message) + name, args, ok := command(goos, sanitizeField(title), sanitizeField(message)) if !ok { return fmt.Errorf("notifications are not supported on %s", goos) } @@ -36,22 +42,66 @@ func command(goos, title, message string) (string, []string, bool) { script := fmt.Sprintf("display notification %s with title %s", strconv.Quote(message), strconv.Quote(title)) return "osascript", []string{"-e", script}, true case "linux": - return "notify-send", []string{title, message}, true + // `--` prevents notify-send from interpreting a leading "-" in title or message as a flag. + return "notify-send", []string{"--", title, message}, true case "windows": template := fmt.Sprintf( `%s%s`, xmlEscape(title), xmlEscape(message), ) - script := fmt.Sprintf(`[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null; $template = '%s'; $xml = New-Object Windows.Data.Xml.Dom.XmlDocument; $xml.LoadXml($template); $toast = [Windows.UI.Notifications.ToastNotification]::new($xml); $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("git-real"); $notifier.Show($toast)`, powerShellSingleQuote(template)) - return "powershell", []string{"-NoProfile", "-Command", script}, true + // -EncodedCommand takes a UTF-16LE base64 blob, so any quote/newline/$/backtick + // inside `template` cannot escape the surrounding PowerShell context. + script := fmt.Sprintf(`[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null; $template = @' +%s +'@; $xml = New-Object Windows.Data.Xml.Dom.XmlDocument; $xml.LoadXml($template); $toast = [Windows.UI.Notifications.ToastNotification]::new($xml); $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("git-real"); $notifier.Show($toast)`, template) + return "powershell", []string{"-NoProfile", "-EncodedCommand", encodePowerShellCommand(script)}, true default: return "", nil, false } } -func powerShellSingleQuote(value string) string { - return strings.ReplaceAll(value, `'`, `''`) +// sanitizeField strips control characters and caps the length so a malicious +// branch name or backupRef cannot blow up the external command or smuggle +// terminal escape sequences into a notification. +func sanitizeField(value string) string { + var b strings.Builder + b.Grow(len(value)) + for _, r := range value { + switch { + case r == '\n', r == '\t', r == ' ': + b.WriteRune(' ') + case r < 0x20, r == 0x7f: + b.WriteRune('?') + default: + b.WriteRune(r) + } + } + + out := b.String() + if len(out) > maxFieldBytes { + // Truncate on a rune boundary so we never produce invalid UTF-8. + truncated := out[:maxFieldBytes] + for len(truncated) > 0 { + r, size := utf8.DecodeLastRuneInString(truncated) + if r == utf8.RuneError && size <= 1 { + truncated = truncated[:len(truncated)-1] + continue + } + break + } + out = truncated + } + return out +} + +func encodePowerShellCommand(script string) string { + runes := utf16.Encode([]rune(script)) + buf := make([]byte, 2*len(runes)) + for i, r := range runes { + binary.LittleEndian.PutUint16(buf[i*2:], r) + } + return base64.StdEncoding.EncodeToString(buf) } func xmlEscape(value string) string { diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index b7b0289..7a37846 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -1,9 +1,13 @@ package notify import ( + "encoding/base64" + "encoding/binary" "errors" + "fmt" "strings" "testing" + "unicode/utf16" ) func TestCommand(t *testing.T) { @@ -51,8 +55,8 @@ func TestSend(t *testing.T) { if name != "notify-send" { t.Fatalf("runner name = %q, want notify-send", name) } - if len(args) != 2 { - t.Fatalf("runner args = %v, want 2 args", args) + if len(args) != 3 || args[0] != "--" { + t.Fatalf("runner args = %v, want [-- title message]", args) } return nil }) @@ -120,11 +124,11 @@ func TestCommandEscapesWindowsToastText(t *testing.T) { if !ok { t.Fatalf("command(windows) ok = false, want true") } - if len(args) != 3 { - t.Fatalf("command(windows) args = %v, want 3 args", args) + if len(args) != 3 || args[0] != "-NoProfile" || args[1] != "-EncodedCommand" { + t.Fatalf("command(windows) args = %v, want [-NoProfile -EncodedCommand ]", args) } - script := args[2] + script := decodePowerShellCommand(t, args[2]) for _, want := range []string{ `O'Hare <title>`, `line 1 & line 2`, @@ -134,3 +138,127 @@ func TestCommandEscapesWindowsToastText(t *testing.T) { } } } + +// TestNotificationFieldsNeverEscapeShellOrPowerShell exercises a battery of +// hostile payloads (branch-name shaped strings, command-injection attempts, +// control characters, multi-byte runes) and asserts that on every supported +// OS the would-be exec arguments are argv-shaped (no shell), and that any +// PowerShell encoded blob round-trips back to a script that does not contain +// the raw payload escaping its quote/here-string context. +func TestNotificationFieldsNeverEscapeShellOrPowerShell(t *testing.T) { + t.Parallel() + + payloads := []string{ + `'); Write-Host pwned; '`, + `$(calc.exe)`, + `'@` + "\n" + `Write-Host pwned` + "\n" + `@'`, + "line1\nline2; rm -rf /", + "\x00null-byte", + "\x07\x08\x1b[31mred", + "--leading-flag", + "with\ttab", + strings.Repeat("A", 4096), + "japanese-日本語-rtl-\u202e", + "refs/gitreal/backups/main@{-1}/x", + } + + for i, payload := range payloads { + payload := payload + t.Run(fmt.Sprintf("payload-%d", i), func(t *testing.T) { + t.Parallel() + + for _, goos := range []string{"darwin", "linux", "windows"} { + name, args, ok := command(goos, sanitizeField(payload), sanitizeField(payload)) + if !ok { + t.Fatalf("command(%s) returned ok=false", goos) + } + if name == "" { + t.Fatalf("command(%s) returned empty name", goos) + } + + switch goos { + case "linux": + if len(args) != 3 || args[0] != "--" { + t.Fatalf("linux args = %v, want [-- title message]", args) + } + if strings.ContainsAny(args[1]+args[2], "\n\r\x00") { + t.Fatalf("linux argv contains forbidden control chars: %q / %q", args[1], args[2]) + } + case "darwin": + if len(args) != 2 || args[0] != "-e" { + t.Fatalf("darwin args = %v, want [-e script]", args) + } + // strconv.Quote-d strings always start and end with `"` and + // contain no unescaped newlines. + if strings.Count(args[1], "\n") > 0 { + t.Fatalf("darwin script contains literal newline: %q", args[1]) + } + case "windows": + if len(args) != 3 || args[1] != "-EncodedCommand" { + t.Fatalf("windows args = %v, want [-NoProfile -EncodedCommand b64]", args) + } + script := decodePowerShellCommand(t, args[2]) + // The here-string terminator `'@` must only appear once + // (at the closing line). If user content smuggled `'@` + // onto a line start, the script would close early and the + // remaining bytes would run as code. + if got := strings.Count(script, "\n'@"); got != 1 { + t.Fatalf("windows script has %d here-string terminators, want 1: %q", got, script) + } + // Single-quoted here-strings do not expand $(...) or $var, + // so a raw payload appearing inside @'...'@ is harmless, + // but quote characters that could close the here-string + // must be neutralized. + if strings.Contains(script, "'); Write-Host") { + t.Fatalf("windows script leaked unescaped quote: %q", script) + } + } + } + }) + } +} + +func TestSanitizeFieldNormalizesControlChars(t *testing.T) { + t.Parallel() + + got := sanitizeField("hello\x00world\x07\x1b[0m\nbar\ttab") + want := "hello?world??[0m bar tab" + if got != want { + t.Fatalf("sanitizeField() = %q, want %q", got, want) + } + + long := strings.Repeat("a", maxFieldBytes+50) + if got := sanitizeField(long); len(got) != maxFieldBytes { + t.Fatalf("sanitizeField(long) len = %d, want %d", len(got), maxFieldBytes) + } + + // Truncating must not split a multi-byte rune. + multibyte := strings.Repeat("あ", 1000) // 3 bytes per rune + out := sanitizeField(multibyte) + if len(out) > maxFieldBytes { + t.Fatalf("sanitizeField(multibyte) len = %d, exceeds cap %d", len(out), maxFieldBytes) + } + for i, r := range out { + if r == '�' { + t.Fatalf("sanitizeField(multibyte) produced replacement rune at byte %d", i) + } + } +} + +func decodePowerShellCommand(t *testing.T, encoded string) string { + t.Helper() + + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("base64 decode error = %v", err) + } + if len(raw)%2 != 0 { + t.Fatalf("decoded length %d is not utf16-aligned", len(raw)) + } + + codepoints := make([]uint16, len(raw)/2) + for i := range codepoints { + codepoints[i] = binary.LittleEndian.Uint16(raw[i*2:]) + } + return string(utf16.Decode(codepoints)) +} From 83f29f3c28b84e3808545d4f72eb10b516f6f33b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 05:39:15 +0000 Subject: [PATCH 2/2] ci: harden GitHub Actions per actionlint and zizmor zizmor (v1.24.1) flagged 22 supply-chain and least-privilege findings on the existing workflows. Address all of them: - Pin every action to a full commit SHA with the corresponding semver tag in a comment, so a tag-rewrite attack on a popular action repository (e.g. tj-actions/changed-files) cannot reach our builds. - Switch the workflow-level `permissions` to deny-by-default and grant the minimum scope per job: read for build, write only for the publish job that mints the release and signs blobs via OIDC. - Add `permissions: contents: read` to the ci check job. - Pass `persist-credentials: false` to actions/checkout so the GITHUB_TOKEN is not persisted into `.git/config` for later steps. - Disable the setup-go cache in the release workflow to avoid cache poisoning influencing released binaries. - Replace softprops/action-gh-release with the runner-resident `gh release create`, removing one third-party dependency from the publish path. actionlint reports no findings; zizmor now reports zero findings (the 5 remaining are low-confidence suppressions). https://claude.ai/code/session_01BQTaxBPYMX7YFsicvwNdUG --- .github/workflows/ci.yml | 13 +++++++-- .github/workflows/release.yml | 51 ++++++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdf2ad7..385f0eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,23 @@ on: branches: - main +# Default to read-only. Individual jobs may grant additional scopes if needed. +permissions: + contents: read + jobs: check: runs-on: ubuntu-latest + permissions: + contents: read + steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19d9b3c..c617f0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,18 @@ on: tags: - "v*" -permissions: - contents: write - id-token: write # required for cosign keyless signing via OIDC +# Deny by default at the workflow level. Each job opts into the smallest scope +# it needs (read for source, write only for the publish job that mints a +# release and signs blobs via OIDC). +permissions: {} jobs: build-release: runs-on: ubuntu-latest + + permissions: + contents: read + strategy: fail-fast: false matrix: @@ -38,13 +43,17 @@ jobs: binary_name: git-real.exe steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 + persist-credentials: false - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod + # Disable Go module/build cache: a poisoned cache shared across + # workflows could otherwise influence the binaries we ship. + cache: false - run: go mod download @@ -84,7 +93,7 @@ jobs: -czf "dist/${archive_name}" "${artifact_name}" fi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-${{ matrix.goos }}-${{ matrix.goarch }} path: dist/*.* @@ -94,8 +103,12 @@ jobs: runs-on: ubuntu-latest needs: build-release + permissions: + contents: write # required to create the GitHub Release + id-token: write # required for cosign keyless signing via OIDC + steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: dist pattern: release-* @@ -106,7 +119,7 @@ jobs: set -euo pipefail (cd dist && sha256sum git-real_*.tar.gz git-real_*.zip > SHA256SUMS) - - uses: sigstore/cosign-installer@v3 + - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 - name: Sign checksums with cosign (keyless OIDC) env: @@ -119,11 +132,19 @@ jobs: --output-certificate dist/SHA256SUMS.pem \ dist/SHA256SUMS - - uses: softprops/action-gh-release@v2 - with: - files: | - dist/git-real_*.tar.gz - dist/git-real_*.zip - dist/SHA256SUMS - dist/SHA256SUMS.sig + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + gh release create "${TAG}" \ + --verify-tag \ + --title "${TAG}" \ + --generate-notes \ + dist/git-real_*.tar.gz \ + dist/git-real_*.zip \ + dist/SHA256SUMS \ + dist/SHA256SUMS.sig \ dist/SHA256SUMS.pem