diff --git a/src/cmd/approve.go b/src/cmd/approve.go new file mode 100644 index 0000000..786611e --- /dev/null +++ b/src/cmd/approve.go @@ -0,0 +1,183 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// approveNamePattern bounds the gate name to chars that are safe as a +// filename on every target filesystem and unambiguously non-traversal. +// Rejecting leading `.` also prevents shadowing dotfiles like `.gitignore` +// if someone ever puts the gates dir in a more sensitive location. +var approveNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`) + +var approveCmd = &cobra.Command{ + Use: "approve ", + Short: "Approve a workflow gate", + Long: `Approve a named gate so a polling gate step in a workflow can continue. + +A gate step in a workflow is a command that blocks until a marker file +exists under .devkit/gates/. This command writes that marker with a +timestamp and approver so the gate step can exit cleanly. + +The name must match [a-zA-Z0-9][a-zA-Z0-9_-]{0,63} — no path separators, +no leading dot, 1-64 chars.`, + Example: ` devkit approve plan + devkit approve deploy-prod`, + Args: cobra.ExactArgs(1), + // Skip the root PersistentPreRunE — approve only needs a repo root, + // not the sessions DB. Avoids creating .devkit/devkit.db for users + // who only want to approve a gate without ever running a workflow + // in this directory (e.g. CI runners approving from a script). + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return nil }, + PersistentPostRunE: func(cmd *cobra.Command, args []string) error { return nil }, + RunE: func(cmd *cobra.Command, args []string) error { + return runApprove(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, +} + +func init() { + rootCmd.AddCommand(approveCmd) +} + +func runApprove(ctx context.Context, out io.Writer, name string) error { + if !approveNamePattern.MatchString(name) { + return fmt.Errorf("invalid gate name %q — must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$", name) + } + + root, err := findRepoRoot() + if err != nil { + return fmt.Errorf("not inside a git repo — run devkit approve from a project directory") + } + + gatesDir := filepath.Join(root, ".devkit", "gates") + if err := os.MkdirAll(gatesDir, 0o700); err != nil { + return fmt.Errorf("create gates dir: %w", err) + } + + markerPath := filepath.Join(gatesDir, name+".approved") + if existing, err := os.ReadFile(markerPath); err == nil { + fmt.Fprintf(out, "gate %q already approved:\n%s", name, existing) + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("read existing marker: %w", err) + } + + approver := approverIdentity(ctx) + content := fmt.Sprintf("approved_at: %s\napproved_by: %s\n", + time.Now().UTC().Format(time.RFC3339), + approver) + + // Atomic publish: write to a temp file in the same directory, then + // rename. A polling gate does `[ -f ... ]`, which sees the marker + // only after rename makes the directory entry visible — so a crash + // or write error mid-approve can no longer unblock the workflow on + // a partially-written file. The O_EXCL-on-the-final-path approach + // was unsafe because the marker became visible before WriteString + // and Close had returned. + tmp, err := os.CreateTemp(gatesDir, name+".approved.tmp-*") + if err != nil { + return fmt.Errorf("create temp marker: %w", err) + } + tmpPath := tmp.Name() + committed := false + defer func() { + if !committed { + os.Remove(tmpPath) + } + }() + + if _, err := tmp.WriteString(content); err != nil { + tmp.Close() + return fmt.Errorf("write temp marker: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync temp marker: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp marker: %w", err) + } + + if err := os.Rename(tmpPath, markerPath); err != nil { + return fmt.Errorf("publish marker: %w", err) + } + committed = true + + fmt.Fprintf(out, "gate %q approved by %s\nmarker: %s\n", name, approver, markerPath) + return nil +} + +// approverIdentity prefers git config so the marker reflects the repo's +// commit-author identity (which is what reviewers recognise). Falls back +// through $USER and finally "unknown" so the marker is always written — +// refusing to approve because we can't identify the user would be worse +// than a marker that says "unknown". +// +// Runs git under a 2s timeout because `git config --get` has been +// observed to hang on locked-index or misconfigured repos, which would +// deadlock the CLI indefinitely. A single --get-regexp call fetches +// both user.name and user.email in one subprocess instead of two. +func approverIdentity(ctx context.Context) string { + tctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + out, err := exec.CommandContext(tctx, "git", "config", "--get-regexp", `^user\.(name|email)$`).Output() + if err == nil { + name, email := parseGitUserRegexp(string(out)) + switch { + case name != "" && email != "": + return fmt.Sprintf("%s <%s>", name, email) + case name != "": + return name + } + } + + fallback := "unknown" + if u := strings.TrimSpace(os.Getenv("USER")); u != "" { + fallback = u + } + // Surface git failures so the user can tell "git timed out" from + // "git wasn't configured" from "git config is corrupt" — without + // this, every such case silently produces approver= + // with no diagnostic trail. We only log when git actually failed + // (err != nil); a git run that succeeded but returned no user.* keys + // is a legitimate unconfigured-repo case and doesn't need noise. + if err != nil { + fmt.Fprintf(os.Stderr, "devkit approve: git identity unavailable (%v); approver=%s\n", err, fallback) + } + return fallback +} + +// parseGitUserRegexp extracts user.name and user.email from the output +// of `git config --get-regexp`. Each line is " " separated +// by a single space; values may themselves contain spaces (e.g. a full +// name) so we split on the first space only. +func parseGitUserRegexp(out string) (name, email string) { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + key, val, ok := strings.Cut(line, " ") + if !ok { + continue + } + switch key { + case "user.name": + name = val + case "user.email": + email = val + } + } + return +} diff --git a/src/cmd/approve_test.go b/src/cmd/approve_test.go new file mode 100644 index 0000000..a848add --- /dev/null +++ b/src/cmd/approve_test.go @@ -0,0 +1,257 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestApproveNameValidation(t *testing.T) { + tests := []struct { + name string + gate string + wantMatch bool + }{ + {"simple", "plan", true}, + {"with dash", "deploy-prod", true}, + {"with underscore", "gate_1", true}, + {"alphanumeric", "Gate42", true}, + {"max length", strings.Repeat("a", 64), true}, + {"leading digit ok", "1plan", true}, + {"empty", "", false}, + {"too long", strings.Repeat("a", 65), false}, + {"leading dot", ".hidden", false}, + {"leading dash", "-flag", false}, + {"path traversal dotdot", "../escape", false}, + {"path separator unix", "a/b", false}, + {"path separator windows", "a\\b", false}, + {"space", "plan v2", false}, + {"null byte", "plan\x00", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := approveNamePattern.MatchString(tc.gate) + if got != tc.wantMatch { + t.Fatalf("name %q: match=%v, wantMatch=%v", tc.gate, got, tc.wantMatch) + } + }) + } +} + +func TestRunApproveWritesMarker(t *testing.T) { + dir := newTestRepo(t) + t.Chdir(dir) + + var out bytes.Buffer + if err := runApprove(context.Background(), &out, "plan"); err != nil { + t.Fatalf("runApprove: %v", err) + } + + markerPath := filepath.Join(dir, ".devkit", "gates", "plan.approved") + data, err := os.ReadFile(markerPath) + if err != nil { + t.Fatalf("read marker: %v", err) + } + if !strings.Contains(string(data), "approved_at:") { + t.Errorf("marker missing approved_at: %q", data) + } + if !strings.Contains(string(data), "approved_by:") { + t.Errorf("marker missing approved_by: %q", data) + } + if !strings.Contains(out.String(), "approved by") { + t.Errorf("stdout missing approval confirmation: %q", out.String()) + } +} + +func TestRunApproveIdempotent(t *testing.T) { + dir := newTestRepo(t) + t.Chdir(dir) + + if err := runApprove(context.Background(), io.Discard, "plan"); err != nil { + t.Fatalf("first approve: %v", err) + } + markerPath := filepath.Join(dir, ".devkit", "gates", "plan.approved") + first, err := os.ReadFile(markerPath) + if err != nil { + t.Fatalf("read after first: %v", err) + } + + // Backdate the marker's mtime so the "mtime preserved" check below + // works on filesystems with second-level mtime resolution (ext3, + // older HFS+, some CI overlays). Without this, two back-to-back + // approves finish in microseconds and the mtime comparison becomes + // fs-dependent — green on APFS/ext4, flaky on CI. + backdated := time.Now().Add(-10 * time.Second).Truncate(time.Second) + if err := os.Chtimes(markerPath, backdated, backdated); err != nil { + t.Fatalf("chtimes: %v", err) + } + + if err := runApprove(context.Background(), io.Discard, "plan"); err != nil { + t.Fatalf("second approve: %v", err) + } + second, err := os.ReadFile(markerPath) + if err != nil { + t.Fatalf("read after second: %v", err) + } + secondStat, err := os.Stat(markerPath) + if err != nil { + t.Fatalf("stat after second: %v", err) + } + + if string(first) != string(second) { + t.Errorf("marker content changed on idempotent re-approve\nfirst: %q\nsecond: %q", first, second) + } + if !secondStat.ModTime().Equal(backdated) { + t.Errorf("marker mtime changed on idempotent re-approve: want %v, got %v — file was rewritten", backdated, secondStat.ModTime()) + } +} + +func TestRunApproveRejectsInvalidName(t *testing.T) { + dir := newTestRepo(t) + t.Chdir(dir) + + err := runApprove(context.Background(), io.Discard, "../escape") + if err == nil { + t.Fatal("expected error for path traversal name") + } + if !strings.Contains(err.Error(), "invalid gate name") { + t.Errorf("expected 'invalid gate name' in error, got: %v", err) + } + // Error message should include the regex anchors so users don't + // read the allowlist as a partial-match pattern. + if !strings.Contains(err.Error(), "^") || !strings.Contains(err.Error(), "$") { + t.Errorf("expected regex anchors in error message, got: %v", err) + } + + // Ensure no file was created at the traversal target. + escaped := filepath.Join(dir, "..", "escape.approved") + if _, err := os.Stat(escaped); err == nil { + t.Errorf("traversal created %s", escaped) + } +} + +func TestRunApproveRejectsNullByteAtRegex(t *testing.T) { + // Null-byte rejection must happen at the regex layer, not via the + // filesystem refusing the name later. Otherwise the same input + // could sneak past on an OS with lax filename validation. + dir := newTestRepo(t) + t.Chdir(dir) + + err := runApprove(context.Background(), io.Discard, "plan\x00") + if err == nil { + t.Fatal("expected error for null-byte name") + } + if !strings.Contains(err.Error(), "invalid gate name") { + t.Errorf("expected regex-layer rejection, got: %v", err) + } +} + +func TestRunApproveOutsideRepo(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + err := runApprove(context.Background(), io.Discard, "plan") + if err == nil { + t.Fatal("expected error outside git repo") + } + if !strings.Contains(err.Error(), "not inside a git repo") { + t.Errorf("expected 'not inside a git repo' in error, got: %v", err) + } +} + +func TestApproverIdentityFallbackToUser(t *testing.T) { + isolateGitConfig(t) + t.Setenv("USER", "alice") + + got := approverIdentity(context.Background()) + if got != "alice" { + t.Errorf("want 'alice' (USER fallback), got %q", got) + } +} + +func TestApproverIdentityFallbackToUnknown(t *testing.T) { + isolateGitConfig(t) + t.Setenv("USER", "") + + got := approverIdentity(context.Background()) + if got != "unknown" { + t.Errorf("want 'unknown' (final fallback), got %q", got) + } +} + +func TestParseGitUserRegexp(t *testing.T) { + tests := []struct { + name string + input string + wantName string + wantEmail string + }{ + {"both present", "user.name Alice Example\nuser.email alice@example.com\n", "Alice Example", "alice@example.com"}, + {"name only", "user.name Bob\n", "Bob", ""}, + {"email only", "user.email c@d.io\n", "", "c@d.io"}, + {"empty", "", "", ""}, + {"crlf", "user.name Dana\r\nuser.email d@d.io\r\n", "Dana", "d@d.io"}, + {"blank lines", "\n\nuser.name Eve\n\n", "Eve", ""}, + {"name with spaces", "user.name Two Spaces\n", " Two Spaces", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n, e := parseGitUserRegexp(tc.input) + if n != tc.wantName { + t.Errorf("name: got %q, want %q", n, tc.wantName) + } + if e != tc.wantEmail { + t.Errorf("email: got %q, want %q", e, tc.wantEmail) + } + }) + } +} + +// isolateGitConfig points git at empty config files for the duration of +// the test so approverIdentity sees no user.name/user.email anywhere — +// exercising the USER / "unknown" fallback paths that newTestRepo hides +// by pre-seeding a valid identity. Also switches HOME so any ambient +// ~/.gitconfig is ignored and we don't leak the real developer identity +// into test output on a machine that has a global git config. +func isolateGitConfig(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(dir, "empty-global")) + t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) + t.Setenv("HOME", dir) + t.Chdir(dir) +} + +// newTestRepo creates a temp directory with a .git sentinel dir so +// findRepoRoot resolves to it. No real git history is needed — the repo +// root check is a single filepath.Stat for .git. We also pre-seed a +// minimal git config so approverIdentity doesn't reach up to the real +// user's ~/.gitconfig and leak their identity into a CI test log. +func newTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + // Confine git config lookups to this repo so the test is hermetic. + // os.DevNull is portable across Unix and Windows ("/dev/null" vs + // "NUL"); hardcoding a Unix path would break the test on Windows. + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(dir, ".gitconfig-global")) + t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) + t.Setenv("HOME", dir) + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not in PATH — approver identity test needs git") + } + cmd := exec.Command("git", "-C", dir, "init", "--quiet") + if err := cmd.Run(); err == nil { + _ = exec.Command("git", "-C", dir, "config", "user.name", "Test User").Run() + _ = exec.Command("git", "-C", dir, "config", "user.email", "test@example.com").Run() + } + return dir +}