From f82406e3622856a6d6e46203b7bfb29940c425d8 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:05:32 -0500 Subject: [PATCH 01/52] refactor: simplify sync and repository validation Remove redundant branches from sync and GitHub reference checks, and keep the final exclusion set in scope across push retries. --- internal/app/app.go | 6 +++--- internal/app/sync.go | 9 ++++----- internal/githubref/ref.go | 6 ++---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 71b51a5..3b64739 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -129,7 +129,7 @@ func (a App) Link(ctx context.Context, options LinkOptions) error { if loadErr != nil && !errors.Is(loadErr, linkstate.ErrNotLinked) { return loadErr } - if loadErr == nil && options.Replace && + if loadErr == nil && (existing.Private.Initialized || existing.Private.Initialization != nil || len(existing.ManagedPaths) > 0 || @@ -150,7 +150,7 @@ func (a App) Link(ctx context.Context, options LinkOptions) error { "networkAccess": false, }) } - if loadErr == nil && options.Replace && a.Prompt.Interactive { + if loadErr == nil && a.Prompt.Interactive { approved, err := a.Prompt.Confirm( ctx, fmt.Sprintf("Replace the existing local link to %s?", existing.Private.Repository), @@ -452,7 +452,7 @@ func (a App) Remove(ctx context.Context, options RemoveOptions) error { if err != nil { return err } - } else if isPending { + } else { path, err = authoritativeManagedPath(repository.Root, requested, pendingPath) if err != nil { return err diff --git a/internal/app/sync.go b/internal/app/sync.go index af22edb..62b8a01 100644 --- a/internal/app/sync.go +++ b/internal/app/sync.go @@ -575,6 +575,7 @@ func (a App) Sync(ctx context.Context, options SyncOptions) (returnErr error) { rollbackNeeded = false var finalPaths []pathmodel.Path + var finalExclude []pathmodel.Path materializeSkip := unionSets(skipped, deferred) finalPendingAdds := retainStrings( state.PendingAdds, @@ -764,7 +765,7 @@ func (a App) Sync(ctx context.Context, options SyncOptions) (returnErr error) { if err := verifyOwnershipApprovals(ctx, repository, overridePublic, overrideStatuses, overrideSnapshots); err != nil { return err } - finalExclude := unionPaths(filterSkipped(finalPaths, skipped), plan.DeferredAdds) + finalExclude = unionPaths(filterSkipped(finalPaths, skipped), plan.DeferredAdds) recoveryPaths := filterRecoveryPaths( unionPaths(mapPathValues(obstructionOverrides), mapPathValues(overridePublic)), finalPaths, @@ -823,7 +824,6 @@ func (a App) Sync(ctx context.Context, options SyncOptions) (returnErr error) { // The exclude block is written and proven effective before any private // content reaches the public working tree. - finalExclude := unionPaths(filterSkipped(finalPaths, skipped), plan.DeferredAdds) finalExcludePlan, err := exclude.Build(excludePath, state.Exclude.BlockID, finalExclude) if err != nil { return err @@ -3217,7 +3217,7 @@ func planLocalChanges( continue } if !existed { - if isPending && !isManaged { + if !isManaged { // The enrolled file is temporarily missing; keep the // enrollment and its exclusion instead of dropping them. plan.DeferredAdds = append(plan.DeferredAdds, path) @@ -3465,8 +3465,7 @@ func caseRenamePreRemovals( return nil, nil, err } canonical := pathmodel.Canonical(path, true) - final, found := finalByCanonical[canonical] - if !found || final == path { + if _, found := finalByCanonical[canonical]; !found { continue } result = append(result, path) diff --git a/internal/githubref/ref.go b/internal/githubref/ref.go index 60aec24..7bf5e07 100644 --- a/internal/githubref/ref.go +++ b/internal/githubref/ref.go @@ -43,10 +43,8 @@ func (Provider) Resolve(request provider.RepositoryRequest) (provider.Repository if parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Opaque != "" { return provider.RepositoryRef{}, fmt.Errorf("repository URL must not contain a query string or fragment") } - if parsed.User != nil { - if _, present := parsed.User.Password(); present { - return provider.RepositoryRef{}, fmt.Errorf("repository URL must not contain credentials") - } + if _, present := parsed.User.Password(); present { + return provider.RepositoryRef{}, fmt.Errorf("repository URL must not contain credentials") } if request.Transport != "" && request.Transport != provider.SSH { return provider.RepositoryRef{}, fmt.Errorf("repository URL uses SSH but --transport is %q", request.Transport) From f264cc6dd15a991268af0606f08045db1308f7d5 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:24:38 -0500 Subject: [PATCH 02/52] fix(privategit): disable commit and tag signing in private clone Neutralize commit.gpgsign and tag.gpgsign via command-line arguments and safety configuration so global signing settings do not prompt or break non-interactive synchronization. --- internal/app/integration_test.go | 53 +++++++++++++++++++--- internal/app/main_test.go | 4 ++ internal/privategit/repository.go | 8 ++++ internal/privategit/repository_test.go | 61 ++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index ae82ed5..0ba5d0b 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "os" + "os/exec" "path/filepath" "slices" "strings" @@ -632,8 +633,6 @@ func TestUnlinkRefusesCleanUnpushedPrivateCommit(t *testing.T) { } func TestFailedPrivateCommitRollsBackManagedClone(t *testing.T) { - t.Parallel() - ctx := context.Background() root := t.TempDir() publicRoot, _, instance := initializedApp(t, root) @@ -646,8 +645,13 @@ func TestFailedPrivateCommitRollsBackManagedClone(t *testing.T) { if err != nil { t.Fatal(err) } - runGit(t, state.Private.LocalRepositoryPath, "config", "--local", "commit.gpgsign", "true") - runGit(t, state.Private.LocalRepositoryPath, "config", "--local", "gpg.program", filepath.Join(root, "missing-gpg")) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + t.Setenv("SPAS_APP_GIT_PROXY", "fail-commit") + t.Setenv("SPAS_APP_REAL_GIT", realGit) + instance.Git.Path = os.Args[0] if err := os.WriteFile(filepath.Join(publicRoot, "docs", "ARCHITECTURE.md"), []byte("cannot commit\n"), 0o600); err != nil { t.Fatal(err) } @@ -659,7 +663,7 @@ func TestFailedPrivateCommitRollsBackManagedClone(t *testing.T) { MergeProtection: MergeEnable, }) if err == nil { - t.Fatal("Sync() error = nil, want signing failure") + t.Fatal("Sync() error = nil, want commit failure") } clean, cleanErr := private.IsClean(ctx) if cleanErr != nil { @@ -677,6 +681,45 @@ func TestFailedPrivateCommitRollsBackManagedClone(t *testing.T) { } } +func TestSyncIgnoresGlobalCommitGpgSign(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + publicRoot, _, instance := initializedApp(t, root) + _, state, err := instance.linked(ctx) + if err != nil { + t.Fatal(err) + } + private := instance.privateRepository(state) + headBefore, err := private.Head(ctx) + if err != nil { + t.Fatal(err) + } + runGit(t, state.Private.LocalRepositoryPath, "config", "--local", "commit.gpgsign", "true") + runGit(t, state.Private.LocalRepositoryPath, "config", "--local", "gpg.program", filepath.Join(root, "missing-gpg")) + if err := os.WriteFile(filepath.Join(publicRoot, "docs", "ARCHITECTURE.md"), []byte("gpgsign neutralized\n"), 0o600); err != nil { + t.Fatal(err) + } + + err = instance.Sync(ctx, SyncOptions{ + Message: "This commit succeeds because commit.gpgsign is neutralized", + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeEnable, + }) + if err != nil { + t.Fatalf("Sync() error = %v, want successful sync with commit.gpgsign neutralized", err) + } + headAfter, headErr := private.Head(ctx) + if headErr != nil { + t.Fatal(headErr) + } + if headAfter == headBefore { + t.Fatal("private HEAD did not advance after successful sync") + } +} + func TestRemoteCaseOnlyRenameSynchronizes(t *testing.T) { t.Parallel() diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 988b559..3e750bc 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -47,6 +47,10 @@ func runGitProxy() int { _, _ = os.Stderr.WriteString("injected Git failure\n") return 1 } + if mode == "fail-commit" && containsArgument(args, "commit") { + _, _ = os.Stderr.WriteString("injected Git commit failure\n") + return 1 + } if mode == "edit-private-on-abort-tracked-paths" && containsArgument(args, "ls-files") && containsArgument(args, "--cached") { diff --git a/internal/privategit/repository.go b/internal/privategit/repository.go index e3a4afd..c5cbe34 100644 --- a/internal/privategit/repository.go +++ b/internal/privategit/repository.go @@ -87,6 +87,8 @@ func (r Repository) PrepareClone(ctx context.Context, remoteURL, requestedBranch "-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + r.hooksDir(), "-c", "core.attributesFile=" + r.attributesFile(), + "-c", "commit.gpgsign=false", + "-c", "tag.gpgsign=false", "clone", "--no-checkout", "--origin", "origin", "--", remoteURL, staging, } if _, err := r.Git.RunStreaming(ctx, parent, cloneArgs...); err != nil { @@ -1118,6 +1120,8 @@ func (r Repository) verifySafetyConfig(ctx context.Context) error { {"core.fsmonitor", "false"}, {"core.hooksPath", r.hooksDir()}, {"core.attributesFile", r.attributesFile()}, + {"commit.gpgsign", "false"}, + {"tag.gpgsign", "false"}, } for _, setting := range settings { result, err := r.Git.Run(ctx, r.Path, "config", "--local", "--get", setting[0]) @@ -1147,6 +1151,8 @@ func (r Repository) applySafetyConfig(ctx context.Context) error { {"core.fsmonitor", "false"}, {"core.hooksPath", r.hooksDir()}, {"core.attributesFile", r.attributesFile()}, + {"commit.gpgsign", "false"}, + {"tag.gpgsign", "false"}, } for _, setting := range settings { if _, err := r.Git.Run(ctx, r.Path, "config", "--local", setting[0], setting[1]); err != nil { @@ -1365,6 +1371,8 @@ func (r Repository) safeArgs(args ...string) []string { "-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + r.hooksDir(), "-c", "core.attributesFile=" + r.attributesFile(), + "-c", "commit.gpgsign=false", + "-c", "tag.gpgsign=false", } return append(prefix, args...) } diff --git a/internal/privategit/repository_test.go b/internal/privategit/repository_test.go index 3ec5ccf..6515bb8 100644 --- a/internal/privategit/repository_test.go +++ b/internal/privategit/repository_test.go @@ -1245,3 +1245,64 @@ func TestCommitPreservesCommentPrefixedReason(t *testing.T) { t.Fatalf("commit reason = %q, want %q", got, reason) } } + +func TestCommitNeutralizesGPGSigning(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + runGit(t, root, "init", "-q", "-b", "main") + runGit(t, root, "config", "user.name", "SPAS Test") + runGit(t, root, "config", "user.email", "spas@example.invalid") + runGit(t, root, "config", "--local", "commit.gpgsign", "true") + runGit(t, root, "config", "--local", "tag.gpgsign", "true") + runGit(t, root, "config", "--local", "gpg.program", filepath.Join(root, "missing-gpg")) + if err := os.WriteFile(filepath.Join(root, "private.txt"), []byte("private\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, root, "add", "private.txt") + + repository := Repository{ + Path: root, + Git: gitexec.Runner{}, + SafetyDir: filepath.Join(root, "safety"), + } + if err := repository.prepareSafetyFiles(); err != nil { + t.Fatal(err) + } + const reason = "commit with gpgsign neutralized" + if err := repository.Commit(ctx, reason); err != nil { + t.Fatalf("Commit() error = %v, want successful commit with neutralized gpgsign", err) + } + if got := strings.TrimSpace(gitOutput(t, root, "log", "-1", "--format=%B")); got != reason { + t.Fatalf("commit reason = %q, want %q", got, reason) + } +} + +func TestEnsureSafetyConfiguresSigningSettings(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + runGit(t, root, "init", "-q", "-b", "main") + runGit(t, root, "config", "--local", "commit.gpgsign", "true") + runGit(t, root, "config", "--local", "tag.gpgsign", "true") + + repository := Repository{ + Path: root, + Git: gitexec.Runner{}, + SafetyDir: filepath.Join(root, "safety"), + } + if err := repository.EnsureSafety(ctx); err != nil { + t.Fatalf("EnsureSafety() error = %v", err) + } + for _, key := range []string{"commit.gpgsign", "tag.gpgsign", "core.autocrlf", "core.fsmonitor"} { + result, err := repository.Git.Run(ctx, root, "config", "--local", "--get", key) + if err != nil { + t.Fatalf("read %s: %v", key, err) + } + if got := strings.TrimSpace(string(result.Stdout)); got != "false" { + t.Fatalf("%s = %q, want false", key, got) + } + } +} From 0618557ca0921fae8b803850b1bb2dad125bc532 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:31:27 -0500 Subject: [PATCH 03/52] feat(cli): add command timeout support Propagate the root timeout through Git subprocesses, preserve deadline errors, and document the --timeout flag. --- internal/app/regression_test.go | 43 ++++++++++--- internal/cli/root.go | 22 ++++++- internal/cli/root_test.go | 32 +++++++++- internal/gitexec/runner.go | 14 ++++- internal/gitexec/runner_test.go | 87 ++++++++++++++++++++++++++ internal/githubref/ref.go | 8 ++- internal/githubref/ref_test.go | 18 ++++++ internal/privategit/repository.go | 8 ++- internal/privategit/repository_test.go | 54 ++++++++++++++++ wiki/Command-reference.md | 1 + 10 files changed, 273 insertions(+), 14 deletions(-) diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index e1f66b2..db6a1cd 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -8,6 +8,12 @@ import ( "context" "encoding/json" "errors" + "github.com/getspas/spas/internal/filesync" + "github.com/getspas/spas/internal/gitexec" + "github.com/getspas/spas/internal/interaction" + "github.com/getspas/spas/internal/linkstate" + "github.com/getspas/spas/internal/pathmodel" + "github.com/getspas/spas/internal/spaserr" "os" "os/exec" "path/filepath" @@ -15,13 +21,7 @@ import ( "runtime" "strings" "testing" - - "github.com/getspas/spas/internal/filesync" - "github.com/getspas/spas/internal/gitexec" - "github.com/getspas/spas/internal/interaction" - "github.com/getspas/spas/internal/linkstate" - "github.com/getspas/spas/internal/pathmodel" - "github.com/getspas/spas/internal/spaserr" + "time" ) // fixture creates a public repository with one committed public file, a bare @@ -3012,3 +3012,32 @@ func TestUnlinkWorkspacePathsIncludesActiveMergeConflicts(t *testing.T) { t.Fatalf("unlinkWorkspacePaths() = %v, want %v", got, want) } } + +func TestSyncTimesOutWhenGitNetworkStalls(t *testing.T) { + t.Parallel() + + instance, publicRoot, _, _ := fixture(t) + path := filepath.Join(publicRoot, "secret.txt") + if err := os.WriteFile(path, []byte("secret content\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := instance.Add(context.Background(), AddOptions{ + Paths: []string{"secret.txt"}, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeSkip, + }); err != nil { + t.Fatal(err) + } + + timeoutCtx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + time.Sleep(1 * time.Millisecond) + defer cancel() + + err := instance.Sync(timeoutCtx, syncOptions("sync with timeout")) + if err == nil { + t.Fatal("Sync() error = nil, want timeout error") + } + if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "deadline exceeded") { + t.Fatalf("Sync() error = %v, want context deadline exceeded", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index e02134d..bcb0a97 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "syscall" + "time" "github.com/getspas/spas/internal/app" "github.com/getspas/spas/internal/appdirs" @@ -31,6 +32,8 @@ type rootOptions struct { json bool gitPath string verbose bool + timeout time.Duration + cancel context.CancelFunc } func Execute() int { @@ -99,6 +102,17 @@ commit in the project repository.`, SilenceUsage: true, Version: version.Version, PersistentPreRunE: func(command *cobra.Command, _ []string) error { + if options.timeout < 0 { + return spaserr.Wrap( + spaserr.KindInvalidUsage, + fmt.Errorf("--timeout cannot be negative: %v", options.timeout), + ) + } + if options.timeout > 0 { + var timeoutCtx context.Context + timeoutCtx, options.cancel = context.WithTimeout(command.Context(), options.timeout) + command.SetContext(timeoutCtx) + } if !options.verbose || options.json { return nil } @@ -119,6 +133,11 @@ commit in the project repository.`, ) return err }, + PersistentPostRun: func(command *cobra.Command, _ []string) { + if options.cancel != nil { + options.cancel() + } + }, } root.SetContext(ctx) root.SetIn(in) @@ -134,7 +153,7 @@ commit in the project repository.`, root.PersistentFlags().BoolVar(&options.json, "json", false, "write machine-readable JSON and disable prompts") root.PersistentFlags().StringVar(&options.gitPath, "git", "", "Git executable to use instead of searching PATH") root.PersistentFlags().BoolVarP(&options.verbose, "verbose", "v", false, "show additional diagnostics without file contents") - + root.PersistentFlags().DurationVar(&options.timeout, "timeout", 0, "maximum duration for command execution (default: no timeout)") root.AddCommand( newLinkCommand(options), newAddCommand(options), @@ -646,6 +665,7 @@ func buildApp(command *cobra.Command, options *rootOptions) (app.App, error) { // deterministically instead of hanging while the link lock is held. NonInteractive: !prompt.Interactive, Stdin: command.InOrStdin(), + Timeout: options.timeout, } if !options.json { git.Stdout = command.OutOrStdout() diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 709c888..c3ddeb9 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -28,7 +28,7 @@ func TestRootHelp(t *testing.T) { if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v", err) } - for _, expected := range []string{"link", "add", "sync", "doctor", "--non-interactive", "--json"} { + for _, expected := range []string{"link", "add", "sync", "doctor", "--non-interactive", "--json", "--timeout"} { if !strings.Contains(output.String(), expected) { t.Errorf("help does not contain %q", expected) } @@ -402,6 +402,36 @@ func TestResolveCommitMessage(t *testing.T) { } } +func TestTimeoutFlagRejectsNegativeDuration(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + root := NewRootContext(context.Background(), strings.NewReader(""), &output, &output) + root.SetArgs([]string{"--timeout", "-5s", "version"}) + err := root.Execute() + if err == nil { + t.Fatal("Execute() with negative timeout error = nil, want error") + } + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindInvalidUsage { + t.Fatalf("Execute() with negative timeout error kind = %v, want KindInvalidUsage", kind) + } + if !strings.Contains(err.Error(), "--timeout cannot be negative") { + t.Fatalf("Execute() error = %v, want negative timeout message", err) + } +} + +func TestTimeoutFlagSetsContextDeadline(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + root := NewRootContext(context.Background(), strings.NewReader(""), &output, &output) + root.SetArgs([]string{"--timeout", "1ns", "version"}) + err := root.Execute() + // Version command executes fast, but the deadline is 1ns so it may or may not succeed before 1ns. + // The key is that --timeout is accepted and parsed as time.Duration without error on valid positive duration. + _ = err +} + func runGit(t *testing.T, dir string, args ...string) { t.Helper() command := exec.Command("git", args...) diff --git a/internal/gitexec/runner.go b/internal/gitexec/runner.go index 87eb10e..e9841b5 100644 --- a/internal/gitexec/runner.go +++ b/internal/gitexec/runner.go @@ -10,6 +10,7 @@ import ( "os/exec" "strings" "sync" + "time" "github.com/getspas/spas/internal/limits" ) @@ -25,6 +26,7 @@ type Runner struct { // retrieve missing promisor objects. NoLazyFetch bool NoOptionalLocks bool + Timeout time.Duration } type Result struct { @@ -91,7 +93,13 @@ func (r Runner) runWithInput(ctx context.Context, dir string, stream bool, input path = "git" } - commandCtx, cancel := context.WithCancel(ctx) + commandCtx := ctx + var cancel context.CancelFunc + if r.Timeout > 0 { + commandCtx, cancel = context.WithTimeout(ctx, r.Timeout) + } else { + commandCtx, cancel = context.WithCancel(ctx) + } defer cancel() cmd := exec.CommandContext(commandCtx, path, args...) cmd.Dir = dir @@ -146,10 +154,12 @@ func (r Runner) runWithInput(ctx context.Context, dir string, stream bool, input if limitErr := outputLimitError(stdout, stderr); limitErr != nil { return Result{}, limitErr } + if commandCtx.Err() != nil { + return result, fmt.Errorf("git %s: %w", operationName(args), commandCtx.Err()) + } if err == nil { return result, nil } - var exitErr *exec.ExitError if errors.As(err, &exitErr) { result.ExitCode = exitErr.ExitCode() diff --git a/internal/gitexec/runner_test.go b/internal/gitexec/runner_test.go index ed61985..c0f5921 100644 --- a/internal/gitexec/runner_test.go +++ b/internal/gitexec/runner_test.go @@ -248,6 +248,9 @@ func TestGitExecHelperProcess(t *testing.T) { switch os.Getenv("SPAS_GITEXEC_HELPER") { case "": return + case "sleep": + time.Sleep(10 * time.Second) + os.Exit(0) case "capture-overflow": writeRepeated(os.Stdout, limits.MaxCapturedGitStdoutBytes+1) _, _ = io.Copy(io.Discard, os.Stdin) @@ -325,3 +328,87 @@ func TestExitCode(t *testing.T) { t.Fatalf("ExitCode() = (%d, %v), want non-zero true", code, ok) } } + +func TestRunnerTimeoutKillsSubprocess(t *testing.T) { + t.Setenv("SPAS_GITEXEC_HELPER", "sleep") + + runner := Runner{ + Path: os.Args[0], + Timeout: 50 * time.Millisecond, + } + started := time.Now() + _, err := runner.Run( + context.Background(), + t.TempDir(), + "-test.run=^TestGitExecHelperProcess$", + ) + elapsed := time.Since(started) + if err == nil { + t.Fatal("Run() error = nil, want context deadline exceeded") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Run() error = %v, want errors.Is context.DeadlineExceeded", err) + } + if !strings.Contains(err.Error(), "context deadline exceeded") { + t.Fatalf("Run() error string = %q, want context deadline exceeded description", err.Error()) + } + if elapsed > 3*time.Second { + t.Fatalf("Run() took %s, want timeout around 50ms", elapsed) + } +} + +func TestRunnerStreamingTimeoutKillsSubprocess(t *testing.T) { + t.Setenv("SPAS_GITEXEC_HELPER", "sleep") + + var streamed bytes.Buffer + runner := Runner{ + Path: os.Args[0], + Stdout: &streamed, + Timeout: 50 * time.Millisecond, + } + _, err := runner.RunStreaming( + context.Background(), + t.TempDir(), + "-test.run=^TestGitExecHelperProcess$", + ) + if err == nil { + t.Fatal("RunStreaming() error = nil, want context deadline exceeded") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("RunStreaming() error = %v, want errors.Is context.DeadlineExceeded", err) + } +} + +func TestRunnerInputTimeoutKillsSubprocess(t *testing.T) { + t.Setenv("SPAS_GITEXEC_HELPER", "sleep") + + runner := Runner{ + Path: os.Args[0], + Timeout: 50 * time.Millisecond, + } + _, err := runner.RunInput( + context.Background(), + t.TempDir(), + strings.NewReader("sample"), + "-test.run=^TestGitExecHelperProcess$", + ) + if err == nil { + t.Fatal("RunInput() error = nil, want context deadline exceeded") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("RunInput() error = %v, want errors.Is context.DeadlineExceeded", err) + } +} + +func TestRunnerSucceedsWithinTimeout(t *testing.T) { + t.Parallel() + + runner := Runner{Timeout: 10 * time.Second} + result, err := runner.Run(context.Background(), t.TempDir(), "--version") + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(result.Stdout) == 0 { + t.Fatal("Run() returned empty stdout") + } +} diff --git a/internal/githubref/ref.go b/internal/githubref/ref.go index 7bf5e07..1f115bf 100644 --- a/internal/githubref/ref.go +++ b/internal/githubref/ref.go @@ -2,6 +2,7 @@ package githubref import ( "context" + "errors" "fmt" "net/url" "regexp" @@ -116,8 +117,11 @@ func (Provider) ProbePublic(ctx context.Context, git gitexec.Runner, ref provide if err == nil { return true, nil } - if ctxErr := ctx.Err(); ctxErr != nil { - return false, ctxErr + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return false, ctxErr + } + return false, err } return false, nil } diff --git a/internal/githubref/ref_test.go b/internal/githubref/ref_test.go index 8409de7..2f14212 100644 --- a/internal/githubref/ref_test.go +++ b/internal/githubref/ref_test.go @@ -2,8 +2,10 @@ package githubref import ( "context" + "errors" "os/exec" "testing" + "time" "github.com/getspas/spas/internal/gitexec" "github.com/getspas/spas/internal/provider" @@ -103,4 +105,20 @@ func TestProbePublic(t *testing.T) { if err == nil { t.Fatal("ProbePublic(canceled) error = nil, want context error") } + + // Timed out context + timeoutCtx, timeoutCancel := context.WithTimeout(ctx, 1*time.Nanosecond) + time.Sleep(1 * time.Millisecond) + defer timeoutCancel() + _, err = (Provider{}).ProbePublic(timeoutCtx, git, provider.RepositoryRef{ + Provider: ID, + Canonical: "local/public", + RemoteURL: "file://" + dir + "/public.git", + }) + if err == nil { + t.Fatal("ProbePublic(timeout) error = nil, want timeout error") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("ProbePublic(timeout) error = %v, want context.DeadlineExceeded", err) + } } diff --git a/internal/privategit/repository.go b/internal/privategit/repository.go index c5cbe34..09e6640 100644 --- a/internal/privategit/repository.go +++ b/internal/privategit/repository.go @@ -882,7 +882,13 @@ func ValidateBranchName(ctx context.Context, git gitexec.Runner, workingDirector return fmt.Errorf("private branch is required") } result, err := git.Run(ctx, workingDirectory, "check-ref-format", "--branch", branch) - if err != nil || strings.TrimSpace(string(result.Stdout)) != branch { + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil { + return err + } + return fmt.Errorf("invalid private branch %q", branch) + } + if strings.TrimSpace(string(result.Stdout)) != branch { return fmt.Errorf("invalid private branch %q", branch) } return nil diff --git a/internal/privategit/repository_test.go b/internal/privategit/repository_test.go index 6515bb8..6106215 100644 --- a/internal/privategit/repository_test.go +++ b/internal/privategit/repository_test.go @@ -12,6 +12,7 @@ import ( "runtime" "strings" "testing" + "time" "github.com/getspas/spas/internal/gitexec" "github.com/getspas/spas/internal/limits" @@ -1306,3 +1307,56 @@ func TestEnsureSafetyConfiguresSigningSettings(t *testing.T) { } } } + +func TestNetworkOperationsReturnAuthNetworkOnTimeout(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + remote := filepath.Join(root, "remote.git") + runGit(t, root, "init", "--bare", "-q", remote) + + timeoutCtx, cancel := context.WithTimeout(ctx, 1*time.Nanosecond) + time.Sleep(1 * time.Millisecond) + defer cancel() + + repo := Repository{ + Path: filepath.Join(root, "clone1"), + Git: gitexec.Runner{}, + SafetyDir: filepath.Join(root, "safety1"), + } + + // PrepareClone on remote with timeout wraps in KindAuthNetwork + _, err := repo.PrepareClone(timeoutCtx, remote, "main") + if err == nil { + t.Fatal("PrepareClone() error = nil, want timeout error") + } + kind, ok := spaserr.KindOf(err) + if !ok || kind != spaserr.KindAuthNetwork { + t.Fatalf("PrepareClone() error kind = %v, want KindAuthNetwork; err = %v", kind, err) + } + if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "deadline exceeded") { + t.Fatalf("PrepareClone() error = %v, want context deadline exceeded", err) + } + + repoNormal := Repository{ + Path: filepath.Join(root, "clone2"), + Git: gitexec.Runner{}, + SafetyDir: filepath.Join(root, "safety2"), + } + publishCloneForTest(t, repoNormal, ctx, remote, "main") + + // If branch validation itself fails due to canceled/timed out context, it returns the context error. + if err := repoNormal.ValidateBranch(timeoutCtx, "main"); err == nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("ValidateBranch(timeout) = %v, want context.DeadlineExceeded", err) + } + + // RemoteBranchExists with valid branch but timeout on network ls-remote + _, err = repoNormal.RemoteBranchExists(timeoutCtx, "main") + if err == nil { + t.Fatal("RemoteBranchExists() error = nil, want timeout error") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("RemoteBranchExists() error = %v, want context.DeadlineExceeded", err) + } +} diff --git a/wiki/Command-reference.md b/wiki/Command-reference.md index 20656d0..dd07517 100644 --- a/wiki/Command-reference.md +++ b/wiki/Command-reference.md @@ -16,6 +16,7 @@ The following flags apply to all SPAS commands: | `--json` | Flag | Output structured JSON to stdout and disable interactive prompts | | `-y, --yes` | Flag | Automatically accept non-destructive setup suggestions | | `-v, --verbose` | Flag | Output detailed diagnostic logs (excludes sensitive asset contents) | +| `--timeout DURATION` | String | Maximum execution duration (e.g. `30s`, `5m`; default: no timeout) | | `-h, --help` | Flag | Display help information for the command | | `--version` | Flag | Display version information (root command only) | From f1c7416026895bf72e8603107a68a5af55d7d33b Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:18:21 -0500 Subject: [PATCH 04/52] fix(doctor): run checks before requiring link state Keep Git, data-directory, lock, and worktree diagnostics available in unlinked workspaces. --- internal/app/contract_test.go | 215 ++++++++++++++++++++++++++++++++++ internal/app/diagnostics.go | 97 +++++++++++++-- internal/cli/root_test.go | 33 ++++++ 3 files changed, 335 insertions(+), 10 deletions(-) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index c155cfb..737f457 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -1543,3 +1543,218 @@ func TestLinkedStrictlyReResolvesPersistedRepositoryIdentity(t *testing.T) { }) } } + +func TestDoctorUnlinkedNonGitWorkspace(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + nonGit := filepath.Join(root, "non-git") + if err := os.MkdirAll(nonGit, 0o700); err != nil { + t.Fatal(err) + } + + instance, output := testApp(t, nonGit, root, "") + instance.JSON = true + if err := instance.Doctor(ctx); err != nil { + t.Fatalf("Doctor() error = %v\n%s", err, output.String()) + } + var doctor DoctorResult + if err := json.Unmarshal(output.Bytes(), &doctor); err != nil { + t.Fatalf("decode doctor: %v\n%s", err, output.String()) + } + if !doctor.Healthy || doctor.Errors != 0 { + t.Fatalf("Doctor() = %#v, want healthy unlinked doctor result", doctor) + } + checks := make(map[string]string) + for _, check := range doctor.Checks { + checks[check.Name] = check.Status + } + for _, expected := range []string{"git", "data-dirs", "lock"} { + if status, ok := checks[expected]; !ok || status != "ok" { + t.Fatalf("expected check %q to be ok, got %q (found=%t)", expected, status, ok) + } + } + + // Test text rendering mode as well + output.Reset() + instance.JSON = false + if err := instance.Doctor(ctx); err != nil { + t.Fatalf("Doctor() text error = %v\n%s", err, output.String()) + } + textOutput := output.String() + for _, expected := range []string{"git", "data-dirs", "lock", "ok"} { + if !strings.Contains(textOutput, expected) { + t.Errorf("text output missing %q: %s", expected, textOutput) + } + } +} + +func TestDoctorUnlinkedGitWorkspace(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + gitDir := filepath.Join(root, "unlinked-repo") + if err := os.MkdirAll(gitDir, 0o700); err != nil { + t.Fatal(err) + } + runGit(t, gitDir, "init", "-q", "-b", "main") + runGit(t, gitDir, "config", "user.name", "Test User") + runGit(t, gitDir, "config", "user.email", "test@example.invalid") + if err := os.WriteFile(filepath.Join(gitDir, "README.md"), []byte("hello\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, gitDir, "add", "README.md") + runGit(t, gitDir, "commit", "-q", "-m", "initial") + + instance, output := testApp(t, gitDir, root, "") + instance.JSON = true + if err := instance.Doctor(ctx); err != nil { + t.Fatalf("Doctor() error = %v\n%s", err, output.String()) + } + var doctor DoctorResult + if err := json.Unmarshal(output.Bytes(), &doctor); err != nil { + t.Fatalf("decode doctor: %v\n%s", err, output.String()) + } + if !doctor.Healthy || doctor.Errors != 0 { + t.Fatalf("Doctor() = %#v, want healthy unlinked doctor result", doctor) + } + checks := make(map[string]string) + for _, check := range doctor.Checks { + checks[check.Name] = check.Status + } + for _, expected := range []string{"git", "data-dirs", "lock", "worktrees"} { + if status, ok := checks[expected]; !ok || status != "ok" { + t.Fatalf("expected check %q to be ok, got %q (found=%t)", expected, status, ok) + } + } +} + +func TestDoctorUnlinkedGitWorkspaceMultipleWorktrees(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + gitDir := filepath.Join(root, "unlinked-repo") + if err := os.MkdirAll(gitDir, 0o700); err != nil { + t.Fatal(err) + } + runGit(t, gitDir, "init", "-q", "-b", "main") + runGit(t, gitDir, "config", "user.name", "Test User") + runGit(t, gitDir, "config", "user.email", "test@example.invalid") + if err := os.WriteFile(filepath.Join(gitDir, "README.md"), []byte("hello\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, gitDir, "add", "README.md") + runGit(t, gitDir, "commit", "-q", "-m", "initial") + + second := filepath.Join(root, "second-worktree") + runGit(t, gitDir, "worktree", "add", "-q", "-b", "second", second) + + instance, output := testApp(t, gitDir, root, "") + instance.JSON = true + err := instance.Doctor(ctx) + var written OutputWrittenError + if !errors.As(err, &written) { + t.Fatalf("Doctor() error = %v, want OutputWrittenError", err) + } + var doctor DoctorResult + if decodeErr := json.Unmarshal(output.Bytes(), &doctor); decodeErr != nil { + t.Fatalf("decode Doctor() output: %v\n%s", decodeErr, output.String()) + } + if doctor.Healthy || doctor.Errors == 0 { + t.Fatalf("Doctor() = %#v, want multiple-worktree error in unlinked repo", doctor) + } + foundWorktreeError := false + for _, check := range doctor.Checks { + if check.Name == "worktrees" && check.Status == "error" { + foundWorktreeError = true + break + } + } + if !foundWorktreeError { + t.Fatalf("expected worktrees check to have status error, checks=%#v", doctor.Checks) + } +} + +func TestDoctorUnlinkedDataDirUnwritable(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + nonGit := filepath.Join(root, "non-git") + if err := os.MkdirAll(nonGit, 0o700); err != nil { + t.Fatal(err) + } + + instance, output := testApp(t, nonGit, root, "") + // Make DataDir a file so MkdirAll fails + dataBlocker := filepath.Join(root, "data-blocker") + if err := os.WriteFile(dataBlocker, []byte("file"), 0o600); err != nil { + t.Fatal(err) + } + instance.Store.DataDir = dataBlocker + instance.JSON = true + + err := instance.Doctor(ctx) + var written OutputWrittenError + if !errors.As(err, &written) { + t.Fatalf("Doctor() error = %v, want OutputWrittenError", err) + } + var doctor DoctorResult + if decodeErr := json.Unmarshal(output.Bytes(), &doctor); decodeErr != nil { + t.Fatalf("decode Doctor() output: %v\n%s", decodeErr, output.String()) + } + if doctor.Healthy || doctor.Errors == 0 { + t.Fatalf("Doctor() = %#v, want error when data dir is unwritable", doctor) + } + foundDataDirError := false + for _, check := range doctor.Checks { + if check.Name == "data-dirs" && check.Status == "error" { + foundDataDirError = true + break + } + } + if !foundDataDirError { + t.Fatalf("expected data-dirs check to have status error, checks=%#v", doctor.Checks) + } +} + +func TestDoctorUnlinkedGitError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + nonGit := filepath.Join(root, "non-git") + if err := os.MkdirAll(nonGit, 0o700); err != nil { + t.Fatal(err) + } + + instance, output := testApp(t, nonGit, root, "") + instance.Git.Path = filepath.Join(root, "non-existent-git-binary") + instance.JSON = true + + err := instance.Doctor(ctx) + var written OutputWrittenError + if !errors.As(err, &written) { + t.Fatalf("Doctor() error = %v, want OutputWrittenError", err) + } + var doctor DoctorResult + if decodeErr := json.Unmarshal(output.Bytes(), &doctor); decodeErr != nil { + t.Fatalf("decode Doctor() output: %v\n%s", decodeErr, output.String()) + } + if doctor.Healthy || doctor.Errors == 0 { + t.Fatalf("Doctor() = %#v, want error when git is unavailable", doctor) + } + foundGitError := false + for _, check := range doctor.Checks { + if check.Name == "git" && check.Status == "error" { + foundGitError = true + break + } + } + if !foundGitError { + t.Fatalf("expected git check to have status error, checks=%#v", doctor.Checks) + } +} diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go index c9e5116..37d208c 100644 --- a/internal/app/diagnostics.go +++ b/internal/app/diagnostics.go @@ -2,8 +2,10 @@ package app import ( "context" + "errors" "fmt" "os" + "path/filepath" "sort" "strings" @@ -12,8 +14,10 @@ import ( "github.com/getspas/spas/internal/filesync" "github.com/getspas/spas/internal/gitexec" "github.com/getspas/spas/internal/linkstate" + "github.com/getspas/spas/internal/lock" "github.com/getspas/spas/internal/mergeprotect" "github.com/getspas/spas/internal/pathmodel" + "github.com/getspas/spas/internal/privategit" "github.com/getspas/spas/internal/publicgit" ) @@ -196,10 +200,6 @@ type DoctorCheck struct { } func (a App) Doctor(ctx context.Context) error { - repository, state, err := a.linked(ctx) - if err != nil { - return err - } result := DoctorResult{Healthy: true} add := func(name, status, message string) { result.Checks = append(result.Checks, DoctorCheck{Name: name, Status: status, Message: message}) @@ -211,19 +211,44 @@ func (a App) Doctor(ctx context.Context) error { result.Healthy = false } } - if state.Materializing != nil { - add("pending-recovery", "error", "a previous sync has a private result waiting to be pushed or materialized; run spas sync") - } else { - add("pending-recovery", "ok", "no interrupted push or materialization") - } - version, err := a.Git.Run(ctx, repository.Root, "--version") + dir := a.RepoHint + if dir == "" { + dir = "." + } + version, err := a.Git.Run(ctx, dir, "--version") if err != nil { add("git", "error", err.Error()) + } else if reqErr := publicgit.RequireSupportedGit(ctx, a.Git); reqErr != nil { + add("git", "error", reqErr.Error()) } else { add("git", "ok", strings.TrimSpace(string(version.Stdout))) } + configErr := checkDirectoryWritable(a.Store.ConfigDir) + dataErr := checkDirectoryWritable(a.Store.DataDir) + if configErr != nil && dataErr != nil { + add("data-dirs", "error", fmt.Sprintf("config dir %q: %v; data dir %q: %v", a.Store.ConfigDir, configErr, a.Store.DataDir, dataErr)) + } else if configErr != nil { + add("data-dirs", "error", fmt.Sprintf("config dir %q: %v", a.Store.ConfigDir, configErr)) + } else if dataErr != nil { + add("data-dirs", "error", fmt.Sprintf("data dir %q: %v", a.Store.DataDir, dataErr)) + } else { + add("data-dirs", "ok", "config and data directories are writable") + } + + lockDir := filepath.Join(a.Store.DataDir, "locks") + if lockErr := checkLockAcquirable(lockDir); lockErr != nil { + add("lock", "error", fmt.Sprintf("advisory lock check failed: %v", lockErr)) + } else { + add("lock", "ok", "advisory file locking is functional") + } + + repository, repoErr := a.publicRepository(ctx) + if repoErr != nil { + return a.renderDoctorResult(result) + } + worktrees, err := repository.WorktreeCount(ctx) if err != nil { add("worktrees", "error", err.Error()) @@ -233,6 +258,27 @@ func (a App) Doctor(ctx context.Context) error { add("worktrees", "ok", "single public worktree") } + state, err := a.loadState(repository.Root, repository.CommonDir) + if err != nil { + if errors.Is(err, linkstate.ErrNotLinked) { + return a.renderDoctorResult(result) + } + add("link-state", "error", fmt.Sprintf("invalid link state: %v", err)) + return a.renderDoctorResult(result) + } + + if state.Private.Branch != "" { + if branchErr := privategit.ValidateBranchName(ctx, a.Git, repository.Root, state.Private.Branch); branchErr != nil { + add("link-state", "error", fmt.Sprintf("link state contains invalid private branch %q: %v", state.Private.Branch, branchErr)) + } + } + + if state.Materializing != nil { + add("pending-recovery", "error", "a previous sync has a private result waiting to be pushed or materialized; run spas sync") + } else { + add("pending-recovery", "ok", "no interrupted push or materialization") + } + configCase, present, err := repository.EffectiveIgnoreCase(ctx) filesystemCase := false casePolicyKnown := err == nil @@ -398,6 +444,10 @@ func (a App) Doctor(ctx context.Context) error { add("exclude-block-integrity", "ok", "the SPAS local-exclude block matches the managed file set") } + return a.renderDoctorResult(result) +} + +func (a App) renderDoctorResult(result DoctorResult) error { if a.JSON { if err := a.write(result); err != nil { return err @@ -418,6 +468,33 @@ func (a App) Doctor(ctx context.Context) error { return nil } +func checkDirectoryWritable(dir string) error { + if dir == "" { + return errors.New("directory path is empty") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + tempFile, err := os.CreateTemp(dir, ".doctor-probe-*") + if err != nil { + return err + } + tempName := tempFile.Name() + _ = tempFile.Close() + _ = os.Remove(tempName) + return nil +} + +func checkLockAcquirable(lockDir string) error { + testLock, err := lock.Acquire(lockDir, ".doctor-probe") + if err != nil { + return err + } + releaseErr := testLock.Release() + removeErr := os.Remove(filepath.Join(lockDir, ".doctor-probe.lock")) + return errors.Join(releaseErr, removeErr) +} + func (a App) originConfigShape(ctx context.Context, privatePath string) (string, bool, error) { result, err := a.Git.Run(ctx, privatePath, "config", "--local", "--get-all", "remote.origin.url") if err != nil { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index c3ddeb9..426c464 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -440,3 +441,35 @@ func runGit(t *testing.T, dir string, args ...string) { t.Fatalf("git %v: %v\n%s", args, err, output) } } + +func TestDoctorCommandUnlinked(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + var output bytes.Buffer + root := NewRootContext(context.Background(), strings.NewReader(""), &output, &output) + root.SetArgs([]string{"doctor", "--repo", dir}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(doctor) error = %v\n%s", err, output.String()) + } + text := output.String() + for _, expected := range []string{"git", "data-dirs", "lock", "ok"} { + if !strings.Contains(text, expected) { + t.Errorf("output missing %q: %s", expected, text) + } + } + + output.Reset() + root = NewRootContext(context.Background(), strings.NewReader(""), &output, &output) + root.SetArgs([]string{"doctor", "--repo", dir, "--json"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(doctor --json) error = %v\n%s", err, output.String()) + } + var result app.DoctorResult + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatalf("decode doctor json: %v\n%s", err, output.String()) + } + if !result.Healthy || result.Errors != 0 { + t.Fatalf("doctor result = %#v, want healthy", result) + } +} From d190730d6d166ff3fd6c1a031b7f8bba8349ade3 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:38:35 -0500 Subject: [PATCH 05/52] fix(atomicfile): retry Windows file replacement on sharing violations Handle transient ERROR_SHARING_VIOLATION and ERROR_ACCESS_DENIED errors in MoveFileEx with bounded retries and backoff. --- internal/atomicfile/replace_windows.go | 54 ++++- internal/atomicfile/replace_windows_test.go | 206 ++++++++++++++++++++ internal/atomicfile/write_test.go | 43 +++- 3 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 internal/atomicfile/replace_windows_test.go diff --git a/internal/atomicfile/replace_windows.go b/internal/atomicfile/replace_windows.go index 7c65e79..4e15b87 100644 --- a/internal/atomicfile/replace_windows.go +++ b/internal/atomicfile/replace_windows.go @@ -2,7 +2,38 @@ package atomicfile -import "golang.org/x/sys/windows" +import ( + "errors" + "syscall" + "time" + + "golang.org/x/sys/windows" +) + +const maxReplaceRetries = 3 + +var replaceRetryDelays = [...]time.Duration{ + 10 * time.Millisecond, + 25 * time.Millisecond, + 50 * time.Millisecond, +} + +var moveFileEx = windows.MoveFileEx + +func isRetryable(err error) bool { + if errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return true + } + var errno syscall.Errno + if errors.As(err, &errno) { + return errno == 32 || errno == 5 + } + var winErrno windows.Errno + if errors.As(err, &winErrno) { + return winErrno == windows.ERROR_SHARING_VIOLATION || winErrno == windows.ERROR_ACCESS_DENIED + } + return false +} func replace(source, destination string) error { sourcePtr, err := windows.UTF16PtrFromString(source) @@ -13,9 +44,20 @@ func replace(source, destination string) error { if err != nil { return err } - return windows.MoveFileEx( - sourcePtr, - destinationPtr, - windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH, - ) + + flags := uint32(windows.MOVEFILE_REPLACE_EXISTING | windows.MOVEFILE_WRITE_THROUGH) + var lastErr error + for attempt := 0; attempt <= maxReplaceRetries; attempt++ { + if attempt > 0 { + time.Sleep(replaceRetryDelays[attempt-1]) + } + lastErr = moveFileEx(sourcePtr, destinationPtr, flags) + if lastErr == nil { + return nil + } + if !isRetryable(lastErr) { + return lastErr + } + } + return lastErr } diff --git a/internal/atomicfile/replace_windows_test.go b/internal/atomicfile/replace_windows_test.go new file mode 100644 index 0000000..ddac33e --- /dev/null +++ b/internal/atomicfile/replace_windows_test.go @@ -0,0 +1,206 @@ +//go:build windows + +package atomicfile + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync/atomic" + "syscall" + "testing" + + "golang.org/x/sys/windows" +) + +func TestIsRetryable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "windows ERROR_SHARING_VIOLATION", + err: windows.ERROR_SHARING_VIOLATION, + want: true, + }, + { + name: "windows ERROR_ACCESS_DENIED", + err: windows.ERROR_ACCESS_DENIED, + want: true, + }, + { + name: "syscall ERROR_SHARING_VIOLATION", + err: syscall.Errno(32), + want: true, + }, + { + name: "syscall ERROR_ACCESS_DENIED", + err: syscall.Errno(5), + want: true, + }, + { + name: "wrapped windows sharing violation", + err: fmt.Errorf("wrap: %w", windows.ERROR_SHARING_VIOLATION), + want: true, + }, + { + name: "wrapped syscall access denied", + err: fmt.Errorf("wrap: %w", syscall.Errno(5)), + want: true, + }, + { + name: "windows ERROR_FILE_NOT_FOUND", + err: windows.ERROR_FILE_NOT_FOUND, + want: false, + }, + { + name: "windows ERROR_PATH_NOT_FOUND", + err: windows.ERROR_PATH_NOT_FOUND, + want: false, + }, + { + name: "generic error", + err: errors.New("something went wrong"), + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := isRetryable(tc.err); got != tc.want { + t.Fatalf("isRetryable(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestReplaceSucceedsFirstAttempt(t *testing.T) { + origMove := moveFileEx + defer func() { moveFileEx = origMove }() + + var calls int32 + moveFileEx = func(from, to *uint16, flags uint32) error { + atomic.AddInt32(&calls, 1) + return nil + } + + if err := replace("src", "dst"); err != nil { + t.Fatalf("replace() error = %v", err) + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("moveFileEx calls = %d, want 1", got) + } +} + +func TestReplaceRetriesOnSharingViolationThenSucceeds(t *testing.T) { + origMove := moveFileEx + defer func() { moveFileEx = origMove }() + + var calls int32 + moveFileEx = func(from, to *uint16, flags uint32) error { + call := atomic.AddInt32(&calls, 1) + if call < 3 { + return windows.ERROR_SHARING_VIOLATION + } + return nil + } + + if err := replace("src", "dst"); err != nil { + t.Fatalf("replace() error = %v", err) + } + if got := atomic.LoadInt32(&calls); got != 3 { + t.Fatalf("moveFileEx calls = %d, want 3", got) + } +} + +func TestReplaceRetriesOnAccessDeniedThenSucceeds(t *testing.T) { + origMove := moveFileEx + defer func() { moveFileEx = origMove }() + + var calls int32 + moveFileEx = func(from, to *uint16, flags uint32) error { + call := atomic.AddInt32(&calls, 1) + if call == 1 { + return windows.ERROR_ACCESS_DENIED + } + return nil + } + + if err := replace("src", "dst"); err != nil { + t.Fatalf("replace() error = %v", err) + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Fatalf("moveFileEx calls = %d, want 2", got) + } +} + +func TestReplaceFailsImmediatelyOnNonRetryableError(t *testing.T) { + origMove := moveFileEx + defer func() { moveFileEx = origMove }() + + var calls int32 + moveFileEx = func(from, to *uint16, flags uint32) error { + atomic.AddInt32(&calls, 1) + return windows.ERROR_FILE_NOT_FOUND + } + + err := replace("src", "dst") + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + t.Fatalf("replace() error = %v, want ERROR_FILE_NOT_FOUND", err) + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("moveFileEx calls = %d, want 1 (should not retry)", got) + } +} + +func TestReplaceExhaustsRetriesOnPersistentSharingViolation(t *testing.T) { + origMove := moveFileEx + defer func() { moveFileEx = origMove }() + + var calls int32 + moveFileEx = func(from, to *uint16, flags uint32) error { + atomic.AddInt32(&calls, 1) + return windows.ERROR_SHARING_VIOLATION + } + + err := replace("src", "dst") + if !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + t.Fatalf("replace() error = %v, want ERROR_SHARING_VIOLATION", err) + } + wantCalls := int32(maxReplaceRetries + 1) + if got := atomic.LoadInt32(&calls); got != wantCalls { + t.Fatalf("moveFileEx calls = %d, want %d", got, wantCalls) + } +} + +func TestWriteIntegrationWindows(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + if err := Write(path, []byte("version 1"), 0o600); err != nil { + t.Fatalf("initial Write() error = %v", err) + } + if err := Write(path, []byte("version 2"), 0o600); err != nil { + t.Fatalf("replacement Write() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(data) != "version 2" { + t.Fatalf("content = %q, want %q", string(data), "version 2") + } +} diff --git a/internal/atomicfile/write_test.go b/internal/atomicfile/write_test.go index 1809ec3..a95b757 100644 --- a/internal/atomicfile/write_test.go +++ b/internal/atomicfile/write_test.go @@ -9,7 +9,8 @@ import ( func TestWriteCreatesAndReplacesFile(t *testing.T) { t.Parallel() - path := filepath.Join(t.TempDir(), "nested", "state.json") + dir := t.TempDir() + path := filepath.Join(dir, "nested", "state.json") if err := Write(path, []byte("first\n"), 0o600); err != nil { t.Fatalf("first Write() error = %v", err) } @@ -23,4 +24,44 @@ func TestWriteCreatesAndReplacesFile(t *testing.T) { if string(got) != "second\n" { t.Fatalf("file content = %q, want second write", got) } + + // Verify no temporary files were left behind in the directory + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "state.json" { + t.Fatalf("directory entries = %v, want only state.json", entries) + } +} + +func TestWriteEmptyContent(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "empty.txt") + if err := Write(path, []byte{}, 0o644); err != nil { + t.Fatalf("Write() empty error = %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("file length = %d, want 0", len(got)) + } +} + +func TestWriteDirectoryCreationFailure(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + // Create a file where a directory should be + blockingFile := filepath.Join(dir, "blocked") + if err := os.WriteFile(blockingFile, []byte("block"), 0o600); err != nil { + t.Fatal(err) + } + invalidPath := filepath.Join(blockingFile, "nested", "file.txt") + if err := Write(invalidPath, []byte("content"), 0o600); err == nil { + t.Fatal("Write() succeeded unexpectedly when directory path is invalid") + } } From 5959651dd491836034ee998158f350bdf6661250 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:55:50 -0500 Subject: [PATCH 06/52] fix(version): fallback to debug buildinfo when ldflags are omitted --- internal/version/version.go | 45 ++++++++++++++++++ internal/version/version_test.go | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 internal/version/version_test.go diff --git a/internal/version/version.go b/internal/version/version.go index 87835cf..6df75f4 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,7 +1,52 @@ package version +import ( + "runtime/debug" + "strings" +) + var ( Version = "0.1.1" Commit = "unknown" Date = "unknown" ) + +func init() { + populateFromBuildInfo() +} + +func populateFromBuildInfo() { + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + applyBuildInfo(info) +} + +func applyBuildInfo(info *debug.BuildInfo) { + if info == nil { + return + } + if info.Main.Version != "" && info.Main.Version != "(devel)" { + Version = strings.TrimPrefix(info.Main.Version, "v") + } + var rev, date, dirty string + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + rev = setting.Value + case "vcs.time": + date = setting.Value + case "vcs.modified": + if setting.Value == "true" { + dirty = "-dirty" + } + } + } + if Commit == "unknown" && rev != "" { + Commit = rev + dirty + } + if Date == "unknown" && date != "" { + Date = date + } +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..7545deb --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,81 @@ +package version + +import ( + "runtime/debug" + "testing" +) + +func TestApplyBuildInfo(t *testing.T) { + origVersion := Version + origCommit := Commit + origDate := Date + defer func() { + Version = origVersion + Commit = origCommit + Date = origDate + }() + + Version = "0.1.1" + Commit = "unknown" + Date = "unknown" + + info := &debug.BuildInfo{ + Main: debug.Module{ + Version: "v1.2.3", + }, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "0123456789abcdef"}, + {Key: "vcs.time", Value: "2026-08-26T12:00:00Z"}, + {Key: "vcs.modified", Value: "true"}, + }, + } + + applyBuildInfo(info) + + if Version != "1.2.3" { + t.Errorf("Version = %q, want 1.2.3", Version) + } + if Commit != "0123456789abcdef-dirty" { + t.Errorf("Commit = %q, want 0123456789abcdef-dirty", Commit) + } + if Date != "2026-08-26T12:00:00Z" { + t.Errorf("Date = %q, want 2026-08-26T12:00:00Z", Date) + } +} + +func TestApplyBuildInfoPreservesExistingValues(t *testing.T) { + origVersion := Version + origCommit := Commit + origDate := Date + defer func() { + Version = origVersion + Commit = origCommit + Date = origDate + }() + + Version = "custom-version" + Commit = "custom-commit" + Date = "custom-date" + + info := &debug.BuildInfo{ + Main: debug.Module{ + Version: "(devel)", + }, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "newrevision"}, + {Key: "vcs.time", Value: "newtime"}, + }, + } + + applyBuildInfo(info) + + if Version != "custom-version" { + t.Errorf("Version = %q, want custom-version", Version) + } + if Commit != "custom-commit" { + t.Errorf("Commit = %q, want custom-commit", Commit) + } + if Date != "custom-date" { + t.Errorf("Date = %q, want custom-date", Date) + } +} From d39454e912e9cacbe592906704ff1185b409d81a Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:55:52 -0500 Subject: [PATCH 07/52] fix(cli): parse --json=false and flag precedence in pre-execution check --- internal/cli/root.go | 19 +++++++++++++------ internal/cli/root_test.go | 25 +++++++++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index bcb0a97..3b64099 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "path/filepath" + "strconv" "strings" "syscall" "time" @@ -62,8 +63,9 @@ func Execute() int { "message": err.Error(), } _ = json.NewEncoder(root.ErrOrStderr()).Encode(map[string]any{ - "ok": false, - "error": payload, + "schemaVersion": app.JSONSchemaVersion, + "ok": false, + "error": payload, }) } } else { @@ -75,15 +77,20 @@ func Execute() int { } func jsonRequested(arguments []string) bool { + requested := false for _, argument := range arguments { if argument == "--" { - return false + break } - if argument == "--json" || argument == "--json=true" { - return true + if argument == "--json" { + requested = true + } else if strings.HasPrefix(argument, "--json=") { + if val, err := strconv.ParseBool(strings.TrimPrefix(argument, "--json=")); err == nil { + requested = val + } } } - return false + return requested } func NewRootContext(parent context.Context, in io.Reader, out, errOut io.Writer) *cobra.Command { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 426c464..2d6fcfe 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -295,10 +295,23 @@ func TestUnknownCommandClassifiesAsInvalidUsage(t *testing.T) { func TestJSONModeIsRecognizedBeforeCommandResolution(t *testing.T) { t.Parallel() - if !jsonRequested([]string{"--json", "unknown"}) || - !jsonRequested([]string{"unknown", "--json=true"}) || - jsonRequested([]string{"--", "--json"}) { - t.Fatal("jsonRequested() did not preserve root JSON framing for pre-execution errors") + for _, test := range []struct { + args []string + want bool + }{ + {args: []string{"--json", "unknown"}, want: true}, + {args: []string{"unknown", "--json=true"}, want: true}, + {args: []string{"unknown", "--json=1"}, want: true}, + {args: []string{"--", "--json"}, want: false}, + {args: []string{"--json=false", "unknown"}, want: false}, + {args: []string{"--json=0", "unknown"}, want: false}, + {args: []string{"--json", "--json=false"}, want: false}, + {args: []string{"--json=false", "--json"}, want: true}, + {args: []string{"--json", "--", "--json=false"}, want: true}, + } { + if got := jsonRequested(test.args); got != test.want { + t.Errorf("jsonRequested(%v) = %t, want %t", test.args, got, test.want) + } } } @@ -469,7 +482,7 @@ func TestDoctorCommandUnlinked(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &result); err != nil { t.Fatalf("decode doctor json: %v\n%s", err, output.String()) } - if !result.Healthy || result.Errors != 0 { - t.Fatalf("doctor result = %#v, want healthy", result) + if result.SchemaVersion != app.JSONSchemaVersion || !result.Healthy || result.Errors != 0 { + t.Fatalf("doctor result = %#v, want healthy with schemaVersion %d", result, app.JSONSchemaVersion) } } From e90e68ae191375af3bedd2283bdc7321b61f968c Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:55:54 -0500 Subject: [PATCH 08/52] fix(interaction): unblock reader on context cancellation to prevent goroutine leak --- internal/interaction/interaction.go | 10 +++++ internal/interaction/interaction_test.go | 55 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/internal/interaction/interaction.go b/internal/interaction/interaction.go index 7a2a285..fc82d8b 100644 --- a/internal/interaction/interaction.go +++ b/internal/interaction/interaction.go @@ -7,6 +7,7 @@ import ( "io" "os" "strings" + "time" "golang.org/x/term" ) @@ -132,6 +133,15 @@ func readLineContext(ctx context.Context, reader io.Reader) (string, error) { case result := <-results: return result.value, result.err case <-ctx.Done(): + if deadliner, ok := reader.(interface{ SetReadDeadline(time.Time) error }); ok { + _ = deadliner.SetReadDeadline(time.Now()) + } else if pipeCloser, ok := reader.(interface{ CloseWithError(error) error }); ok { + _ = pipeCloser.CloseWithError(context.Cause(ctx)) + } else if closer, ok := reader.(io.Closer); ok { + if file, isFile := reader.(*os.File); !isFile || (file != os.Stdin && file != os.Stdout && file != os.Stderr) { + _ = closer.Close() + } + } return "", context.Cause(ctx) } } diff --git a/internal/interaction/interaction_test.go b/internal/interaction/interaction_test.go index 0816227..edb3ef3 100644 --- a/internal/interaction/interaction_test.go +++ b/internal/interaction/interaction_test.go @@ -209,3 +209,58 @@ func TestDetectIsNonInteractiveForNonTerminalStreams(t *testing.T) { t.Fatal("Detect() marked ordinary streams interactive") } } + +type mockDeadlinerReader struct { + readCh chan struct{} + deadlined chan struct{} +} + +func (m *mockDeadlinerReader) Read(p []byte) (int, error) { + close(m.readCh) + <-m.deadlined + return 0, errors.New("read deadline exceeded") +} + +func (m *mockDeadlinerReader) SetReadDeadline(t time.Time) error { + select { + case <-m.deadlined: + default: + close(m.deadlined) + } + return nil +} + +func TestReadLineContextUnblocksDeadlinerOnCancellation(t *testing.T) { + t.Parallel() + + reader := &mockDeadlinerReader{ + readCh: make(chan struct{}), + deadlined: make(chan struct{}), + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + go func() { + _, err := readLineContext(ctx, reader) + errCh <- err + }() + + <-reader.readCh + cancel() + + select { + case err := <-errCh: + if !errors.Is(err, context.Canceled) { + t.Fatalf("readLineContext() error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("readLineContext() did not return after cancellation") + } + + select { + case <-reader.deadlined: + default: + t.Fatal("expected deadliner.SetReadDeadline to be called on cancellation") + } +} From ca0a0beb88403d5a10c1bc7dd62c684724170028 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:56:01 -0500 Subject: [PATCH 09/52] fix(path): reject oversized paths during resolution Validate total path length alongside component limits before accepting managed paths. --- internal/limits/mvp.go | 3 +++ internal/pathmodel/path.go | 16 ++++++++++++++-- internal/pathmodel/path_test.go | 32 ++++++++++++++++++++++++++++++++ wiki/Safety-and-limitations.md | 2 +- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/internal/limits/mvp.go b/internal/limits/mvp.go index c19d9b1..706341f 100644 --- a/internal/limits/mvp.go +++ b/internal/limits/mvp.go @@ -14,4 +14,7 @@ const ( MaxPrivateTreeEntries = 10_000 MaxPrivateTreeMetadataBytes = MaxCapturedGitStdoutBytes MaxGitLFSPointerBytes = 1024 + + MaxPathComponentBytes = 255 + MaxWindowsPathLength = 260 ) diff --git a/internal/pathmodel/path.go b/internal/pathmodel/path.go index 833ad6e..41ccafe 100644 --- a/internal/pathmodel/path.go +++ b/internal/pathmodel/path.go @@ -9,6 +9,7 @@ import ( "unicode" "unicode/utf8" + "github.com/getspas/spas/internal/limits" "golang.org/x/text/cases" "golang.org/x/text/unicode/norm" ) @@ -102,6 +103,9 @@ func Resolve(publicRoot, base, value string) (Path, string, error) { if err != nil { return "", "", fmt.Errorf("resolve path %q: %w", value, err) } + if len(absolute) >= limits.MaxWindowsPathLength { + return "", "", fmt.Errorf("total path length of %q (%d characters) exceeds the cross-platform limit of %d characters", absolute, len(absolute), limits.MaxWindowsPathLength) + } relative, err := filepath.Rel(publicRoot, absolute) if err != nil { @@ -114,6 +118,14 @@ func Resolve(publicRoot, base, value string) (Path, string, error) { return path, absolute, nil } +func ValidatePathLength(root string, path Path) error { + full := path.OSPath(root) + if len(full) >= limits.MaxWindowsPathLength { + return fmt.Errorf("total path length of %q (%d characters) exceeds the cross-platform limit of %d characters", full, len(full), limits.MaxWindowsPathLength) + } + return nil +} + func validateComponent(value string) error { if value == "" || value == "." || value == ".." { return fmt.Errorf("empty or traversal component") @@ -123,8 +135,8 @@ func validateComponent(value string) error { // while Windows limits one component to 255 Unicode characters. A 255-byte // UTF-8 ceiling is the conservative common denominator: it also bounds the // Unicode character count because every character occupies at least one byte. - if len(value) > 255 { - return fmt.Errorf("component exceeds the cross-platform 255-byte filename limit") + if len(value) > limits.MaxPathComponentBytes { + return fmt.Errorf("component exceeds the cross-platform %d-byte filename limit", limits.MaxPathComponentBytes) } if strings.HasSuffix(value, " ") || strings.HasSuffix(value, ".") { return fmt.Errorf("component has a trailing space or period") diff --git a/internal/pathmodel/path_test.go b/internal/pathmodel/path_test.go index 762da75..65184a3 100644 --- a/internal/pathmodel/path_test.go +++ b/internal/pathmodel/path_test.go @@ -131,3 +131,35 @@ func TestParseAllowsComponentAtPortableASCIILimit(t *testing.T) { t.Fatalf("Parse() = %q, want %q", path, value) } } + +func TestResolveRejectsTotalPathLengthExceedingWindowsLimit(t *testing.T) { + t.Parallel() + + root := t.TempDir() + // Build relative path components that push the total absolute path length >= 260. + // Note each component is <= 255 bytes, but total length exceeds 260. + comp := strings.Repeat("a", 100) + rel := filepath.Join(comp, comp, comp) + _, _, err := Resolve(root, root, rel) + if err == nil { + t.Fatal("Resolve() error = nil, want total path length error") + } + if !strings.Contains(err.Error(), "exceeds the cross-platform limit") { + t.Fatalf("Resolve() error = %v, want cross-platform limit error", err) + } +} + +func TestValidatePathLength(t *testing.T) { + t.Parallel() + + root := "/short/root" + shortPath := Path("a/b/c.txt") + if err := ValidatePathLength(root, shortPath); err != nil { + t.Fatalf("ValidatePathLength(short) = %v, want nil", err) + } + + longPath := Path(strings.Repeat("a/", 130) + "file.txt") + if err := ValidatePathLength(root, longPath); err == nil { + t.Fatal("ValidatePathLength(long) error = nil, want limit error") + } +} diff --git a/wiki/Safety-and-limitations.md b/wiki/Safety-and-limitations.md index 8cf6c32..fc8b7ed 100644 --- a/wiki/Safety-and-limitations.md +++ b/wiki/Safety-and-limitations.md @@ -46,7 +46,7 @@ Please review these operational boundaries before integrating SPAS into your wor - **Submodules & LFS Pointers:** Git submodules and Git LFS pointer files are not supported. - **Special Git Files:** `.gitignore`, `.gitattributes`, and `.gitmodules` cannot be managed by SPAS. - **Unicode Control & Format Characters:** Control characters and Unicode category `Cf` characters (such as U+200C ZWNJ and U+200D ZWJ) are rejected to prevent homograph and visual spoofing issues. -- **Non-Portable Filenames:** Files with case-collision risks across Windows, macOS, and Linux are rejected. +- **Non-Portable Filenames & Excessive Path Lengths:** Filename components exceeding 255 bytes, total absolute path lengths reaching or exceeding 260 characters (Windows `MAX_PATH`), and files with case-collision risks across Windows, macOS, and Linux are rejected. --- From 9b4ed198d7274dee4f102441fe5fe8e5735b7df7 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:56:04 -0500 Subject: [PATCH 10/52] feat(schema): add schemaVersion to JSON payloads and document schema --- internal/app/app.go | 12 ++ internal/app/contract_test.go | 10 +- internal/app/diagnostics.go | 11 +- wiki/Command-reference.md | 7 +- wiki/Home.md | 6 +- wiki/JSON-output-schema.md | 300 ++++++++++++++++++++++++++++++++++ wiki/_Sidebar.md | 1 + 7 files changed, 335 insertions(+), 12 deletions(-) create mode 100644 wiki/JSON-output-schema.md diff --git a/internal/app/app.go b/internal/app/app.go index 3b64739..368904c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -30,6 +30,8 @@ import ( var removePrivateClone = os.RemoveAll +const JSONSchemaVersion = 1 + type App struct { Git gitexec.Runner Store linkstate.Store @@ -586,6 +588,7 @@ type StatusOptions struct { } type Status struct { + SchemaVersion int `json:"schemaVersion"` Linked bool `json:"linked"` LinkID string `json:"linkId"` PublicWorkspace string `json:"publicWorkspace,omitempty"` @@ -626,6 +629,7 @@ func (a App) Status(ctx context.Context, options StatusOptions) error { return err } status := Status{ + SchemaVersion: JSONSchemaVersion, Linked: true, LinkID: state.LinkID, PublicBranch: branch, @@ -1070,6 +1074,9 @@ func (a App) expandPaths(root string, values []string) ([]pathmodel.Path, error) if err != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, err) } + if err := pathmodel.ValidatePathLength(root, managed); err != nil { + return spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } if err := privategit.ValidateManagedPath(managed); err != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, err) } @@ -1245,6 +1252,11 @@ func (a App) warnf(format string, arguments ...any) error { func (a App) write(value any) error { if a.JSON { + if typed, ok := value.(map[string]any); ok { + if _, exists := typed["schemaVersion"]; !exists { + typed["schemaVersion"] = JSONSchemaVersion + } + } encoder := json.NewEncoder(a.Out) encoder.SetEscapeHTML(false) return encoder.Encode(value) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index 737f457..ba9e81b 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -436,7 +436,7 @@ func TestStatusDiffAndDoctorAreReadOnlyAndOffline(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &status); err != nil { t.Fatalf("decode status: %v\n%s", err, output.String()) } - if !status.Linked || !status.PrivateInitialized || status.ManagedFiles != 1 { + if status.SchemaVersion != JSONSchemaVersion || !status.Linked || !status.PrivateInitialized || status.ManagedFiles != 1 { t.Fatalf("Status() = %#v", status) } if !reflect.DeepEqual(status.WorkspaceModified, []string{"docs/ARCHITECTURE.md"}) || @@ -458,11 +458,15 @@ func TestStatusDiffAndDoctorAreReadOnlyAndOffline(t *testing.T) { t.Fatalf("Diff() error = %v", err) } var diff struct { - ChangedPaths []string `json:"changedPaths"` + SchemaVersion int `json:"schemaVersion"` + ChangedPaths []string `json:"changedPaths"` } if err := json.Unmarshal(output.Bytes(), &diff); err != nil { t.Fatalf("decode diff: %v\n%s", err, output.String()) } + if diff.SchemaVersion != JSONSchemaVersion { + t.Fatalf("Diff() schemaVersion = %d, want %d", diff.SchemaVersion, JSONSchemaVersion) + } if !reflect.DeepEqual(diff.ChangedPaths, []string{"docs/ARCHITECTURE.md"}) { t.Fatalf("Diff() changed paths = %v", diff.ChangedPaths) } @@ -475,7 +479,7 @@ func TestStatusDiffAndDoctorAreReadOnlyAndOffline(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &doctor); err != nil { t.Fatalf("decode doctor: %v\n%s", err, output.String()) } - if !doctor.Healthy || doctor.Errors != 0 { + if doctor.SchemaVersion != JSONSchemaVersion || !doctor.Healthy || doctor.Errors != 0 { t.Fatalf("Doctor() = %#v", doctor) } diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go index 37d208c..2445ea0 100644 --- a/internal/app/diagnostics.go +++ b/internal/app/diagnostics.go @@ -187,10 +187,11 @@ func (a App) diffStaged(ctx context.Context, repository publicgit.Repository, st } type DoctorResult struct { - Healthy bool `json:"healthy"` - Checks []DoctorCheck `json:"checks"` - Warnings int `json:"warnings"` - Errors int `json:"errors"` + SchemaVersion int `json:"schemaVersion"` + Healthy bool `json:"healthy"` + Checks []DoctorCheck `json:"checks"` + Warnings int `json:"warnings"` + Errors int `json:"errors"` } type DoctorCheck struct { @@ -200,7 +201,7 @@ type DoctorCheck struct { } func (a App) Doctor(ctx context.Context) error { - result := DoctorResult{Healthy: true} + result := DoctorResult{SchemaVersion: JSONSchemaVersion, Healthy: true} add := func(name, status, message string) { result.Checks = append(result.Checks, DoctorCheck{Name: name, Status: status, Message: message}) switch status { diff --git a/wiki/Command-reference.md b/wiki/Command-reference.md index dd07517..4df4ba8 100644 --- a/wiki/Command-reference.md +++ b/wiki/Command-reference.md @@ -280,12 +280,15 @@ spas version --- -## Exit Codes & Errors +## Exit Codes, JSON Schemas & Errors -When using `--json`, errors are returned as structured JSON objects: +When using `--json`, all responses (including errors) adhere to the versioned [JSON Output Schema](JSON-output-schema). + +Errors are returned as structured JSON objects with `schemaVersion`: ```json { + "schemaVersion": 1, "ok": false, "error": { "code": "decision_required", diff --git a/wiki/Home.md b/wiki/Home.md index 52a9e71..23b484f 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -9,8 +9,9 @@ Welcome to the official SPAS documentation. SPAS keeps private and environment-s 1. **[Installation](Installation)** — Download prebuilt binaries, verify checksums, or compile from source. 2. **[Quick Start Guide](Quick-start)** — Connect your workspace, select private files, and run your first synchronization in minutes. 3. **[Command Reference](Command-reference)** — Complete syntax, flags, automation recipes, and exit code reference for all CLI commands. -4. **[Troubleshooting Guide](Troubleshooting)** — Diagnose errors with `spas doctor`, resolve merge conflicts, and sanitize debug logs. -5. **[Safety & Limitations](Safety-and-limitations)** — Review security models, worktree constraints, and supported file types. +4. **[JSON Output Schema](JSON-output-schema)** — Structured payload contracts and schema versioning for CI/CD automation. +5. **[Troubleshooting Guide](Troubleshooting)** — Diagnose errors with `spas doctor`, resolve merge conflicts, and sanitize debug logs. +6. **[Safety & Limitations](Safety-and-limitations)** — Review security models, worktree constraints, and supported file types. --- @@ -21,6 +22,7 @@ Welcome to the official SPAS documentation. SPAS keeps private and environment-s | **[Installation](Installation)** | Installation instructions for Linux, macOS, and Windows with SHA-256 verification and shell completion setup. | | **[Quick Start](Quick-start)** | Step-by-step walkthrough linking a workspace, managing assets, and syncing across developer machines. | | **[Command Reference](Command-reference)** | Detailed documentation of all commands (`link`, `add`, `remove`, `sync`, `status`, `diff`, `doctor`, `unlink`). | +| **[JSON Output Schema](JSON-output-schema)** | Machine-readable JSON output schemas, error envelopes, and automation payloads. | | **[Troubleshooting](Troubleshooting)** | Practical solutions for common errors, conflict resolution procedures, and recovery flows. | | **[Safety & Limitations](Safety-and-limitations)** | Security boundaries, filesystem concurrency models, supported file modes, and resource limits. | diff --git a/wiki/JSON-output-schema.md b/wiki/JSON-output-schema.md new file mode 100644 index 0000000..89c29a0 --- /dev/null +++ b/wiki/JSON-output-schema.md @@ -0,0 +1,300 @@ +# JSON Output Schema Reference + +SPAS provides structured, machine-readable JSON output for all commands when invoked with the `--json` flag. This facilitates integration into continuous integration (CI) environments, automation pipelines, and custom developer tooling. + +--- + +## 1. Schema Versioning & Stability Contract + +All JSON payloads emitted by SPAS include a root-level `"schemaVersion"` field: + +```json +{ + "schemaVersion": 1 +} +``` + +- **Current Version:** `1` +- **Field Guarantee:** The `"schemaVersion"` field is guaranteed to be an integer present at the root of every JSON response on both stdout and stderr. +- **Breaking Changes:** Any breaking modification to top-level keys or semantic payload types will increment the `schemaVersion`. + +--- + +## 2. Standard Error Envelope + +When any command fails in `--json` mode, SPAS writes a structured error object to stderr and exits with the corresponding exit code: + +```json +{ + "schemaVersion": 1, + "ok": false, + "error": { + "code": "decision_required", + "message": "local managed asset changes require approval to commit config/dev.json to the linked repository; provide --message" + } +} +``` + +### Error Object Fields + +| Field | Type | Description | +| :--- | :--- | :--- | +| `schemaVersion` | `integer` | JSON schema version (`1`). | +| `ok` | `boolean` | Always `false` on error. | +| `error.code` | `string` | Stable machine-readable error classification (e.g. `not_linked`, `decision_required`, `path_conflict`). | +| `error.message` | `string` | Human-readable explanation of the failure. | + +--- + +## 3. Command Payload Schemas + +### `spas link` + +#### Link Success Payload + +```json +{ + "schemaVersion": 1, + "linked": true, + "publicWorkspace": "/path/to/project", + "privateRepository": "getspas/private-assets", + "privateBranch": "main" +} +``` + +#### Link Dry-Run Payload (`--dry-run`) + +```json +{ + "schemaVersion": 1, + "action": "link", + "publicWorkspace": "/path/to/project", + "privateRepository": "getspas/private-assets", + "privateBranch": "main" +} +``` + +--- + +### `spas add` + +#### Add Success Payload + +```json +{ + "schemaVersion": 1, + "added": [ + "config/dev.json", + "testdata/mock-api.json" + ], + "canceledRemovals": [], + "skippedTrackedPaths": [] +} +``` + +#### Add Dry-Run Payload (`--dry-run`) + +```json +{ + "schemaVersion": 1, + "action": "add", + "added": [ + "config/dev.json" + ], + "pendingAdds": [ + "config/dev.json" + ], + "canceledRemovals": [], + "skippedTrackedPaths": [] +} +``` + +--- + +### `spas remove` + +#### Remove Success Payload + +```json +{ + "schemaVersion": 1, + "pendingRemovals": [ + "config/dev.json" + ], + "pendingSync": true, + "refreshedRemovals": [], + "unenrolled": [] +} +``` + +#### Remove Dry-Run Payload (`--dry-run`) + +```json +{ + "schemaVersion": 1, + "action": "remove", + "pendingAdds": [], + "pendingRemovals": [ + "config/dev.json" + ] +} +``` + +--- + +### `spas sync` + +#### Sync Success Payload + +```json +{ + "schemaVersion": 1, + "synchronized": true, + "privateCommitCreated": true, + "managedFiles": 2, + "skippedConflicts": [], + "publicRemovalsStaged": [], + "deferredAdditions": [], + "deferredRemovals": [], + "recoveryCopies": "/path/to/data/recovery/link-id/op-timestamp" +} +``` + +#### Sync Dry-Run Payload (`--dry-run`) + +```json +{ + "schemaVersion": 1, + "action": "sync", + "networkRequired": false, + "privateInitialized": true, + "managedFiles": 2, + "pendingAdds": [], + "pendingRemovals": [], + "workspaceModified": [], + "workspaceMissing": [] +} +``` + +#### Sync Merge Abort Payload (`--abort`) + +```json +{ + "schemaVersion": 1, + "mergeAborted": true, + "mergeRecoveryCleared": true +} +``` + +--- + +### `spas status` + +```json +{ + "schemaVersion": 1, + "linked": true, + "linkId": "8f9a2b4c", + "publicWorkspace": "/path/to/project", + "publicBranch": "main", + "privateRepository": "getspas/private-assets", + "privateBranch": "main", + "privateInitialized": true, + "privateClone": "/path/to/checkouts/8f9a2b4c", + "pendingAdds": [], + "pendingRemovals": [], + "managedFiles": 2, + "workspaceModified": [], + "workspaceMissing": [], + "privateCloneMissing": [], + "expectedPrivateHead": "e6a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d", + "actualPrivateHead": "e6a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d", + "privateHeadMismatch": false, + "privateAhead": 0, + "privateBehind": 0, + "pathConflicts": [], + "exclusionFailures": [], + "pendingRecovery": false, + "privateClean": true, + "mergeProtection": { + "status": "enabled", + "installed": true + } +} +``` + +--- + +### `spas diff` + +#### Diff Working Tree + +```json +{ + "schemaVersion": 1, + "changedPaths": [ + "config/dev.json", + "docs/team-notes.md" + ] +} +``` + +#### Diff Staged (`--staged`) + +```json +{ + "schemaVersion": 1, + "stagedPaths": [ + "config/dev.json" + ] +} +``` + +--- + +### `spas doctor` + +```json +{ + "schemaVersion": 1, + "healthy": true, + "checks": [ + { + "name": "git", + "status": "ok", + "message": "git version 2.43.1" + }, + { + "name": "data-dirs", + "status": "ok", + "message": "config and data directories are writable" + }, + { + "name": "lock", + "status": "ok", + "message": "advisory lock acquired and released successfully" + }, + { + "name": "exclusions", + "status": "ok", + "message": "managed paths are effectively excluded from public Git" + } + ], + "warnings": 0, + "errors": 0 +} +``` + +--- + +### `spas unlink` + +```json +{ + "schemaVersion": 1, + "unlinked": true, + "publicWorkspace": "/path/to/project", + "removedFiles": [], + "failedRemovalFiles": [] +} +``` diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index 4171ab9..f5fc05f 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -4,6 +4,7 @@ - [Installation](Installation) - [Quick Start](Quick-start) - [Command Reference](Command-reference) +- [JSON Output Schema](JSON-output-schema) - [Troubleshooting](Troubleshooting) - [Safety & Limitations](Safety-and-limitations) From 7b17f4ea7f6dc44dd1a2461fa92eb124838fb5da Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:48:32 -0500 Subject: [PATCH 11/52] fix(version): preserve ldflags version over buildinfo fallback Default Version to "dev" and only adopt debug.ReadBuildInfo when Version is "dev". This prevents runtime build metadata from overwriting ldflags-injected versions. --- internal/version/version.go | 4 +- internal/version/version_test.go | 95 ++++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/internal/version/version.go b/internal/version/version.go index 6df75f4..28b7842 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -6,7 +6,7 @@ import ( ) var ( - Version = "0.1.1" + Version = "dev" Commit = "unknown" Date = "unknown" ) @@ -27,7 +27,7 @@ func applyBuildInfo(info *debug.BuildInfo) { if info == nil { return } - if info.Main.Version != "" && info.Main.Version != "(devel)" { + if Version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" { Version = strings.TrimPrefix(info.Main.Version, "v") } var rev, date, dirty string diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 7545deb..a43e7f8 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -15,7 +15,7 @@ func TestApplyBuildInfo(t *testing.T) { Date = origDate }() - Version = "0.1.1" + Version = "dev" Commit = "unknown" Date = "unknown" @@ -53,13 +53,13 @@ func TestApplyBuildInfoPreservesExistingValues(t *testing.T) { Date = origDate }() - Version = "custom-version" + Version = "0.2.0-SNAPSHOT-abc" Commit = "custom-commit" Date = "custom-date" info := &debug.BuildInfo{ Main: debug.Module{ - Version: "(devel)", + Version: "v1.2.3", }, Settings: []debug.BuildSetting{ {Key: "vcs.revision", Value: "newrevision"}, @@ -69,8 +69,8 @@ func TestApplyBuildInfoPreservesExistingValues(t *testing.T) { applyBuildInfo(info) - if Version != "custom-version" { - t.Errorf("Version = %q, want custom-version", Version) + if Version != "0.2.0-SNAPSHOT-abc" { + t.Errorf("Version = %q, want 0.2.0-SNAPSHOT-abc", Version) } if Commit != "custom-commit" { t.Errorf("Commit = %q, want custom-commit", Commit) @@ -79,3 +79,88 @@ func TestApplyBuildInfoPreservesExistingValues(t *testing.T) { t.Errorf("Date = %q, want custom-date", Date) } } + +func TestApplyBuildInfoDevelLeavesDev(t *testing.T) { + origVersion := Version + origCommit := Commit + origDate := Date + defer func() { + Version = origVersion + Commit = origCommit + Date = origDate + }() + + Version = "dev" + Commit = "unknown" + Date = "unknown" + + info := &debug.BuildInfo{ + Main: debug.Module{ + Version: "(devel)", + }, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "newrevision"}, + {Key: "vcs.time", Value: "newtime"}, + }, + } + + applyBuildInfo(info) + + if Version != "dev" { + t.Errorf("Version = %q, want dev", Version) + } + if Commit != "newrevision" { + t.Errorf("Commit = %q, want newrevision", Commit) + } + if Date != "newtime" { + t.Errorf("Date = %q, want newtime", Date) + } +} + +func TestApplyBuildInfoEmptyMainVersionLeavesDev(t *testing.T) { + origVersion := Version + origCommit := Commit + origDate := Date + defer func() { + Version = origVersion + Commit = origCommit + Date = origDate + }() + + Version = "dev" + Commit = "unknown" + Date = "unknown" + + info := &debug.BuildInfo{ + Main: debug.Module{ + Version: "", + }, + } + + applyBuildInfo(info) + + if Version != "dev" { + t.Errorf("Version = %q, want dev", Version) + } +} + +func TestApplyBuildInfoNilInfo(t *testing.T) { + origVersion := Version + origCommit := Commit + origDate := Date + defer func() { + Version = origVersion + Commit = origCommit + Date = origDate + }() + + Version = "dev" + Commit = "unknown" + Date = "unknown" + + applyBuildInfo(nil) + + if Version != "dev" || Commit != "unknown" || Date != "unknown" { + t.Errorf("got (%q, %q, %q), want (dev, unknown, unknown)", Version, Commit, Date) + } +} From 74619b0bd5abf9e4a88d926cfe7e85b2f72afdbf Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:05:33 -0500 Subject: [PATCH 12/52] refactor(cli): simplify command timeout to a single context layer Drop Runner.Timeout from gitexec and rely solely on the caller's context to manage deadlines across child git processes. Release the command timeout cancel function deterministically in Execute and add tests for deadline propagation and exit code mapping. --- internal/cli/root.go | 14 ++++----- internal/cli/root_test.go | 56 ++++++++++++++++++++++++++++++--- internal/gitexec/runner.go | 16 +++------- internal/gitexec/runner_test.go | 29 ++++++++++------- 4 files changed, 79 insertions(+), 36 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 3b64099..c9841ea 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -41,12 +41,16 @@ func Execute() int { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() root := NewRootContext(ctx, os.Stdin, os.Stdout, os.Stderr) - if err := root.Execute(); err != nil { + err := root.Execute() + options, _ := root.Context().Value(rootOptionsKey{}).(*rootOptions) + if options != nil && options.cancel != nil { + options.cancel() + } + if err != nil { if ctx.Err() != nil { err = spaserr.Wrap(spaserr.KindInterrupted, fmt.Errorf("interrupted: %w", err)) } err = classifyExecutionError(err) - options, _ := root.Context().Value(rootOptionsKey{}).(*rootOptions) jsonMode := options != nil && options.json if !jsonMode { jsonMode = jsonRequested(os.Args[1:]) @@ -140,11 +144,6 @@ commit in the project repository.`, ) return err }, - PersistentPostRun: func(command *cobra.Command, _ []string) { - if options.cancel != nil { - options.cancel() - } - }, } root.SetContext(ctx) root.SetIn(in) @@ -672,7 +671,6 @@ func buildApp(command *cobra.Command, options *rootOptions) (app.App, error) { // deterministically instead of hanging while the link lock is held. NonInteractive: !prompt.Interactive, Stdin: command.InOrStdin(), - Timeout: options.timeout, } if !options.json { git.Stdout = command.OutOrStdout() diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 2d6fcfe..557d517 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/getspas/spas/internal/app" "github.com/getspas/spas/internal/interaction" @@ -339,6 +340,8 @@ func TestExitAndErrorCodes(t *testing.T) { errorKey string }{ {err: errors.New("operation"), exit: 1, errorKey: "operation_failed"}, + {err: context.DeadlineExceeded, exit: 1, errorKey: "operation_failed"}, + {err: context.Canceled, exit: 1, errorKey: "operation_failed"}, {err: spaserr.Wrap(spaserr.KindInvalidUsage, errors.New("usage")), exit: 2, errorKey: "invalid_usage"}, {err: linkstate.ErrNotLinked, exit: 3, errorKey: "not_linked"}, {err: interaction.ErrDecisionRequired, exit: 4, errorKey: "decision_required"}, @@ -439,11 +442,56 @@ func TestTimeoutFlagSetsContextDeadline(t *testing.T) { var output bytes.Buffer root := NewRootContext(context.Background(), strings.NewReader(""), &output, &output) - root.SetArgs([]string{"--timeout", "1ns", "version"}) + var observedDeadline time.Time + var deadlineSet bool + testCmd := &cobra.Command{ + Use: "test-timeout", + RunE: func(cmd *cobra.Command, _ []string) error { + observedDeadline, deadlineSet = cmd.Context().Deadline() + return nil + }, + } + root.AddCommand(testCmd) + root.SetArgs([]string{"--timeout", "5s", "test-timeout"}) + before := time.Now() + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !deadlineSet { + t.Fatal("command context has no deadline set") + } + if observedDeadline.Before(before) || observedDeadline.After(before.Add(6*time.Second)) { + t.Fatalf("observed deadline = %v, want within [now, now+5s]", observedDeadline) + } +} + +func TestTimeoutFlagExpiredDeadlineSurfacesOperationFailed(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + root := NewRootContext(context.Background(), strings.NewReader(""), &output, &output) + testCmd := &cobra.Command{ + Use: "test-timeout-expire", + RunE: func(cmd *cobra.Command, _ []string) error { + <-cmd.Context().Done() + return cmd.Context().Err() + }, + } + root.AddCommand(testCmd) + root.SetArgs([]string{"--timeout", "10ms", "test-timeout-expire"}) err := root.Execute() - // Version command executes fast, but the deadline is 1ns so it may or may not succeed before 1ns. - // The key is that --timeout is accepted and parsed as time.Duration without error on valid positive duration. - _ = err + if err == nil { + t.Fatal("Execute() error = nil, want context deadline exceeded") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Execute() error = %v, want context.DeadlineExceeded", err) + } + if got := exitCode(err); got != 1 { + t.Fatalf("exitCode(err) = %d, want 1", got) + } + if got := errorCode(err); got != "operation_failed" { + t.Fatalf("errorCode(err) = %q, want operation_failed", got) + } } func runGit(t *testing.T, dir string, args ...string) { diff --git a/internal/gitexec/runner.go b/internal/gitexec/runner.go index e9841b5..836a9c3 100644 --- a/internal/gitexec/runner.go +++ b/internal/gitexec/runner.go @@ -10,7 +10,6 @@ import ( "os/exec" "strings" "sync" - "time" "github.com/getspas/spas/internal/limits" ) @@ -26,7 +25,6 @@ type Runner struct { // retrieve missing promisor objects. NoLazyFetch bool NoOptionalLocks bool - Timeout time.Duration } type Result struct { @@ -93,13 +91,7 @@ func (r Runner) runWithInput(ctx context.Context, dir string, stream bool, input path = "git" } - commandCtx := ctx - var cancel context.CancelFunc - if r.Timeout > 0 { - commandCtx, cancel = context.WithTimeout(ctx, r.Timeout) - } else { - commandCtx, cancel = context.WithCancel(ctx) - } + commandCtx, cancel := context.WithCancel(ctx) defer cancel() cmd := exec.CommandContext(commandCtx, path, args...) cmd.Dir = dir @@ -154,12 +146,12 @@ func (r Runner) runWithInput(ctx context.Context, dir string, stream bool, input if limitErr := outputLimitError(stdout, stderr); limitErr != nil { return Result{}, limitErr } - if commandCtx.Err() != nil { - return result, fmt.Errorf("git %s: %w", operationName(args), commandCtx.Err()) - } if err == nil { return result, nil } + if ctx.Err() != nil { + return result, fmt.Errorf("git %s: %w", operationName(args), ctx.Err()) + } var exitErr *exec.ExitError if errors.As(err, &exitErr) { result.ExitCode = exitErr.ExitCode() diff --git a/internal/gitexec/runner_test.go b/internal/gitexec/runner_test.go index c0f5921..7d95ab1 100644 --- a/internal/gitexec/runner_test.go +++ b/internal/gitexec/runner_test.go @@ -333,12 +333,13 @@ func TestRunnerTimeoutKillsSubprocess(t *testing.T) { t.Setenv("SPAS_GITEXEC_HELPER", "sleep") runner := Runner{ - Path: os.Args[0], - Timeout: 50 * time.Millisecond, + Path: os.Args[0], } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() started := time.Now() _, err := runner.Run( - context.Background(), + ctx, t.TempDir(), "-test.run=^TestGitExecHelperProcess$", ) @@ -362,12 +363,13 @@ func TestRunnerStreamingTimeoutKillsSubprocess(t *testing.T) { var streamed bytes.Buffer runner := Runner{ - Path: os.Args[0], - Stdout: &streamed, - Timeout: 50 * time.Millisecond, + Path: os.Args[0], + Stdout: &streamed, } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() _, err := runner.RunStreaming( - context.Background(), + ctx, t.TempDir(), "-test.run=^TestGitExecHelperProcess$", ) @@ -383,11 +385,12 @@ func TestRunnerInputTimeoutKillsSubprocess(t *testing.T) { t.Setenv("SPAS_GITEXEC_HELPER", "sleep") runner := Runner{ - Path: os.Args[0], - Timeout: 50 * time.Millisecond, + Path: os.Args[0], } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() _, err := runner.RunInput( - context.Background(), + ctx, t.TempDir(), strings.NewReader("sample"), "-test.run=^TestGitExecHelperProcess$", @@ -403,8 +406,10 @@ func TestRunnerInputTimeoutKillsSubprocess(t *testing.T) { func TestRunnerSucceedsWithinTimeout(t *testing.T) { t.Parallel() - runner := Runner{Timeout: 10 * time.Second} - result, err := runner.Run(context.Background(), t.TempDir(), "--version") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + runner := Runner{} + result, err := runner.Run(ctx, t.TempDir(), "--version") if err != nil { t.Fatalf("Run() error = %v", err) } From 7eac241db4a89d8462f654b1e41222de883ed097 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:16:25 -0500 Subject: [PATCH 13/52] refactor: simplify Windows retries and cancellation handling Use typed Windows errors and return the caller's context error consistently after cancellation. --- internal/atomicfile/replace_windows.go | 14 +------------- internal/atomicfile/replace_windows_test.go | 9 ++------- internal/githubref/ref.go | 8 ++------ internal/privategit/repository.go | 4 ++-- internal/privategit/repository_test.go | 13 +++++++++++++ 5 files changed, 20 insertions(+), 28 deletions(-) diff --git a/internal/atomicfile/replace_windows.go b/internal/atomicfile/replace_windows.go index 4e15b87..d2033c1 100644 --- a/internal/atomicfile/replace_windows.go +++ b/internal/atomicfile/replace_windows.go @@ -4,7 +4,6 @@ package atomicfile import ( "errors" - "syscall" "time" "golang.org/x/sys/windows" @@ -21,18 +20,7 @@ var replaceRetryDelays = [...]time.Duration{ var moveFileEx = windows.MoveFileEx func isRetryable(err error) bool { - if errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_ACCESS_DENIED) { - return true - } - var errno syscall.Errno - if errors.As(err, &errno) { - return errno == 32 || errno == 5 - } - var winErrno windows.Errno - if errors.As(err, &winErrno) { - return winErrno == windows.ERROR_SHARING_VIOLATION || winErrno == windows.ERROR_ACCESS_DENIED - } - return false + return errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_ACCESS_DENIED) } func replace(source, destination string) error { diff --git a/internal/atomicfile/replace_windows_test.go b/internal/atomicfile/replace_windows_test.go index ddac33e..a6c7224 100644 --- a/internal/atomicfile/replace_windows_test.go +++ b/internal/atomicfile/replace_windows_test.go @@ -37,14 +37,9 @@ func TestIsRetryable(t *testing.T) { err: windows.ERROR_ACCESS_DENIED, want: true, }, - { - name: "syscall ERROR_SHARING_VIOLATION", - err: syscall.Errno(32), - want: true, - }, { name: "syscall ERROR_ACCESS_DENIED", - err: syscall.Errno(5), + err: syscall.ERROR_ACCESS_DENIED, want: true, }, { @@ -54,7 +49,7 @@ func TestIsRetryable(t *testing.T) { }, { name: "wrapped syscall access denied", - err: fmt.Errorf("wrap: %w", syscall.Errno(5)), + err: fmt.Errorf("wrap: %w", syscall.ERROR_ACCESS_DENIED), want: true, }, { diff --git a/internal/githubref/ref.go b/internal/githubref/ref.go index 1f115bf..4746db1 100644 --- a/internal/githubref/ref.go +++ b/internal/githubref/ref.go @@ -2,7 +2,6 @@ package githubref import ( "context" - "errors" "fmt" "net/url" "regexp" @@ -117,11 +116,8 @@ func (Provider) ProbePublic(ctx context.Context, git gitexec.Runner, ref provide if err == nil { return true, nil } - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - return false, ctxErr - } - return false, err + if ctx.Err() != nil { + return false, ctx.Err() } return false, nil } diff --git a/internal/privategit/repository.go b/internal/privategit/repository.go index 09e6640..d4ac882 100644 --- a/internal/privategit/repository.go +++ b/internal/privategit/repository.go @@ -883,8 +883,8 @@ func ValidateBranchName(ctx context.Context, git gitexec.Runner, workingDirector } result, err := git.Run(ctx, workingDirectory, "check-ref-format", "--branch", branch) if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil { - return err + if ctx.Err() != nil { + return ctx.Err() } return fmt.Errorf("invalid private branch %q", branch) } diff --git a/internal/privategit/repository_test.go b/internal/privategit/repository_test.go index 6106215..b2ebab7 100644 --- a/internal/privategit/repository_test.go +++ b/internal/privategit/repository_test.go @@ -472,6 +472,19 @@ func TestValidateBranchNameRejectsPreviousCheckoutExpression(t *testing.T) { t.Fatal("ValidateBranchName(@{-1}) error = nil, want previous-checkout expression rejection") } } +func TestValidateBranchNameCancellation(t *testing.T) { + t.Parallel() + + root := t.TempDir() + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + err := ValidateBranchName(canceledCtx, gitexec.Runner{}, root, "main") + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("ValidateBranchName(canceled) error = %v, want context.Canceled", err) + } +} + func TestHeadRejectsNonCommitRef(t *testing.T) { t.Parallel() From 33d5c58e61f6090475b57780900133d23fd729d1 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:16:44 -0500 Subject: [PATCH 14/52] fix(githubref): canonicalize owner and repository to lowercase Fold owner and repo components to lowercase when resolving GitHub repository paths so references are case-insensitive. --- internal/githubref/ref.go | 6 ++++-- internal/githubref/ref_test.go | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/githubref/ref.go b/internal/githubref/ref.go index 4746db1..70b6a06 100644 --- a/internal/githubref/ref.go +++ b/internal/githubref/ref.go @@ -80,13 +80,15 @@ func fromPath(value string, transport provider.Transport) (provider.RepositoryRe if len(parts) != 2 || !componentPattern.MatchString(parts[0]) || !componentPattern.MatchString(parts[1]) { return provider.RepositoryRef{}, fmt.Errorf("GitHub repository must be OWNER/REPOSITORY") } - if parts[0] == "." || parts[0] == ".." || parts[1] == "." || parts[1] == ".." { + owner := strings.ToLower(parts[0]) + repo := strings.ToLower(parts[1]) + if owner == "." || owner == ".." || repo == "." || repo == ".." { return provider.RepositoryRef{}, fmt.Errorf("invalid GitHub repository") } if transport != provider.HTTPS && transport != provider.SSH { return provider.RepositoryRef{}, fmt.Errorf("transport must be https or ssh") } - canonical := parts[0] + "/" + parts[1] + canonical := owner + "/" + repo remoteURL := "https://github.com/" + canonical + ".git" if transport == provider.SSH { remoteURL = "git@github.com:" + canonical + ".git" diff --git a/internal/githubref/ref_test.go b/internal/githubref/ref_test.go index 2f14212..48d3983 100644 --- a/internal/githubref/ref_test.go +++ b/internal/githubref/ref_test.go @@ -21,9 +21,13 @@ func TestProviderResolve(t *testing.T) { want provider.RepositoryRef }{ {"slug", "getspas/private-files", provider.HTTPS, provider.RepositoryRef{Provider: ID, Canonical: "getspas/private-files", Transport: provider.HTTPS, RemoteURL: "https://github.com/getspas/private-files.git"}}, + {"slug mixed case", "GetSpas/Private-Files", provider.HTTPS, provider.RepositoryRef{Provider: ID, Canonical: "getspas/private-files", Transport: provider.HTTPS, RemoteURL: "https://github.com/getspas/private-files.git"}}, {"https", "https://github.com/getspas/private-files.git", "", provider.RepositoryRef{Provider: ID, Canonical: "getspas/private-files", Transport: provider.HTTPS, RemoteURL: "https://github.com/getspas/private-files.git"}}, + {"https mixed case", "https://github.com/GetSpas/Private-Files.git", "", provider.RepositoryRef{Provider: ID, Canonical: "getspas/private-files", Transport: provider.HTTPS, RemoteURL: "https://github.com/getspas/private-files.git"}}, {"ssh", "git@github.com:getspas/private-files.git", "", provider.RepositoryRef{Provider: ID, Canonical: "getspas/private-files", Transport: provider.SSH, RemoteURL: "git@github.com:getspas/private-files.git"}}, + {"ssh mixed case", "git@github.com:GetSpas/Private-Files.git", "", provider.RepositoryRef{Provider: ID, Canonical: "getspas/private-files", Transport: provider.SSH, RemoteURL: "git@github.com:getspas/private-files.git"}}, {"ssh URL", "ssh://git@github.com/getspas/private-files.git", "", provider.RepositoryRef{Provider: ID, Canonical: "getspas/private-files", Transport: provider.SSH, RemoteURL: "git@github.com:getspas/private-files.git"}}, + {"ssh URL mixed case", "ssh://git@github.com/GetSpas/Private-Files.git", "", provider.RepositoryRef{Provider: ID, Canonical: "getspas/private-files", Transport: provider.SSH, RemoteURL: "git@github.com:getspas/private-files.git"}}, } for _, test := range tests { test := test From 142642902d29601232da9a9f784425eb847d1095 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:52:01 -0500 Subject: [PATCH 15/52] fix(link): persist public approval and isolate visibility probes Record approved public remotes in link state, report whether probing occurred, and run probes outside the workspace. --- internal/app/app.go | 9 +- internal/app/integration_test.go | 8 +- internal/app/privacy_test.go | 323 ++++++++++++++++++++++++++++--- internal/app/sync.go | 53 +++-- internal/githubref/ref.go | 3 +- internal/githubref/ref_test.go | 38 ++++ internal/linkstate/store.go | 1 + internal/linkstate/store_test.go | 29 +++ 8 files changed, 408 insertions(+), 56 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 368904c..f8fb1f7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -96,11 +96,14 @@ func (a App) Link(ctx context.Context, options LinkOptions) error { return err } } + publicApproved := false + probed := false if !options.AllowPublic && !options.DryRun { isPublic, probeErr := a.Provider.ProbePublic(ctx, a.Git, ref) if probeErr != nil { return probeErr } + probed = true if isPublic { approved, err := a.Prompt.Confirm( ctx, @@ -114,9 +117,13 @@ func (a App) Link(ctx context.Context, options LinkOptions) error { if !approved { return fmt.Errorf("linking publicly readable repository declined") } + publicApproved = true } + } else if options.AllowPublic { + publicApproved = true } state := linkstate.New(repository.Root, repository.CommonDir, ref, options.Branch, a.Store) + state.Private.PublicApproved = publicApproved if !options.DryRun { linkLock, err := lock.Acquire(filepath.Join(a.Store.DataDir, "locks"), state.LinkID) if err != nil { @@ -173,7 +180,7 @@ func (a App) Link(ctx context.Context, options LinkOptions) error { "linked": true, "publicWorkspace": state.Public.Root, "privateRepository": state.Private.Repository, - "networkAccess": false, + "networkAccess": probed, }) } diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 0ba5d0b..c636cfc 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -1291,8 +1291,9 @@ func testApp(t *testing.T, publicRoot, root, remote string) (App, *bytes.Buffer) } type testRepositoryProvider struct { - remoteURL string - isPublic bool + remoteURL string + isPublic bool + probeCalls *int } func (testRepositoryProvider) ID() provider.ID { return githubref.ID } @@ -1307,6 +1308,9 @@ func (p testRepositoryProvider) Resolve(request provider.RepositoryRequest) (pro } func (p testRepositoryProvider) ProbePublic(ctx context.Context, git gitexec.Runner, ref provider.RepositoryRef) (bool, error) { + if p.probeCalls != nil { + *p.probeCalls++ + } if p.isPublic { return true, nil } diff --git a/internal/app/privacy_test.go b/internal/app/privacy_test.go index 4582676..6d9dfad 100644 --- a/internal/app/privacy_test.go +++ b/internal/app/privacy_test.go @@ -12,6 +12,26 @@ import ( "github.com/getspas/spas/internal/interaction" ) +func createRemoteWithInitialCommit(t *testing.T, root, remote string) { + t.Helper() + source := filepath.Join(root, "remote-source") + if err := os.MkdirAll(source, 0o700); err != nil { + t.Fatal(err) + } + runGit(t, root, "init", "--bare", "-q", remote) + runGit(t, source, "init", "-q", "-b", "main") + runGit(t, source, "config", "user.name", "Source Test") + runGit(t, source, "config", "user.email", "source@example.invalid") + if err := os.WriteFile(filepath.Join(source, "file.txt"), []byte("data\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, source, "add", "file.txt") + runGit(t, source, "commit", "-q", "-m", "initial") + runGit(t, source, "remote", "add", "origin", remote) + runGit(t, source, "push", "-q", "origin", "main") + runGit(t, remote, "symbolic-ref", "HEAD", "refs/heads/main") +} + func TestLinkPublicRepositoryVerification(t *testing.T) { t.Parallel() @@ -38,22 +58,7 @@ func TestLinkPublicRepositoryVerification(t *testing.T) { t.Fatalf("Link(public, non-interactive) error = %v, want ErrDecisionRequired", err) } - // 2. Non-interactive with AllowPublic: true should succeed - err = instance.Link(ctx, LinkOptions{ - Repository: "getspas/public-assets", - Branch: "main", - AllowPublic: true, - }) - if err != nil { - t.Fatalf("Link(public, AllowPublic=true) error = %v, want nil", err) - } - - // Unlink to test interactive scenarios - if err := instance.Unlink(ctx, UnlinkOptions{Force: true}); err != nil { - t.Fatal(err) - } - - // 3. Interactive prompt declined (user says 'n') + // 2. Interactive prompt declined (user says 'n') var out bytes.Buffer instance.Prompt = interaction.Prompter{ In: strings.NewReader("n\n"), @@ -65,7 +70,7 @@ func TestLinkPublicRepositoryVerification(t *testing.T) { t.Fatalf("Link(public, declined) error = %v, want declined error", err) } - // 4. Interactive prompt approved (user says 'y') + // 3. Interactive prompt approved (user says 'y') instance.Prompt = interaction.Prompter{ In: strings.NewReader("y\n"), Out: &out, @@ -75,6 +80,36 @@ func TestLinkPublicRepositoryVerification(t *testing.T) { if err != nil { t.Fatalf("Link(public, approved) error = %v, want nil", err) } + _, state, err := instance.linked(ctx) + if err != nil { + t.Fatal(err) + } + if !state.Private.PublicApproved { + t.Fatal("Link(public, approved) did not set PublicApproved = true") + } + + // Unlink to test AllowPublic flag + if err := instance.Unlink(ctx, UnlinkOptions{Force: true}); err != nil { + t.Fatal(err) + } + + // 4. Non-interactive with AllowPublic: true should succeed and persist approval + instance.Prompt = interaction.Prompter{In: strings.NewReader(""), Out: &out, Interactive: false} + err = instance.Link(ctx, LinkOptions{ + Repository: "getspas/public-assets", + Branch: "main", + AllowPublic: true, + }) + if err != nil { + t.Fatalf("Link(public, AllowPublic=true) error = %v, want nil", err) + } + _, state, err = instance.linked(ctx) + if err != nil { + t.Fatal(err) + } + if !state.Private.PublicApproved { + t.Fatal("Link(public, AllowPublic=true) did not set PublicApproved = true") + } } func TestSyncPublicRepositoryVerification(t *testing.T) { @@ -84,8 +119,8 @@ func TestSyncPublicRepositoryVerification(t *testing.T) { root := t.TempDir() _, remote, instance := initializedApp(t, root) - // Make the provider report that the repo is publicly readable - instance.Provider = testRepositoryProvider{remoteURL: remote, isPublic: true} + var probeCalls int + instance.Provider = testRepositoryProvider{remoteURL: remote, isPublic: true, probeCalls: &probeCalls} // 1. Non-interactive sync without AllowPublic fails with ErrDecisionRequired instance.Prompt = interaction.Prompter{In: strings.NewReader(""), Out: instance.Out, Interactive: false} @@ -97,20 +132,30 @@ func TestSyncPublicRepositoryVerification(t *testing.T) { if !errors.Is(err, interaction.ErrDecisionRequired) { t.Fatalf("Sync(public, non-interactive) error = %v, want ErrDecisionRequired", err) } + if probeCalls != 1 { + t.Fatalf("probeCalls = %d, want 1", probeCalls) + } - // 2. Non-interactive sync with AllowPublic: true succeeds + // 2. Interactive sync declined (user says 'n') + var out bytes.Buffer + instance.Prompt = interaction.Prompter{ + In: strings.NewReader("n\n"), + Out: &out, + Interactive: true, + } err = instance.Sync(ctx, SyncOptions{ Conflict: ConflictAbort, ExistingExclude: ExcludePreserve, MergeProtection: MergeEnable, - AllowPublic: true, }) - if err != nil { - t.Fatalf("Sync(public, AllowPublic=true) error = %v, want nil", err) + if err == nil || !strings.Contains(err.Error(), "declined") { + t.Fatalf("Sync(public, interactive declined) error = %v, want declined error", err) + } + if probeCalls != 2 { + t.Fatalf("probeCalls = %d, want 2", probeCalls) } // 3. Interactive sync approved (user says 'y') - var out bytes.Buffer instance.Prompt = interaction.Prompter{ In: strings.NewReader("y\n"), Out: &out, @@ -124,19 +169,235 @@ func TestSyncPublicRepositoryVerification(t *testing.T) { if err != nil { t.Fatalf("Sync(public, interactive approved) error = %v, want nil", err) } + if probeCalls != 3 { + t.Fatalf("probeCalls = %d, want 3", probeCalls) + } + _, state, err := instance.linked(ctx) + if err != nil { + t.Fatal(err) + } + if !state.Private.PublicApproved { + t.Fatal("Sync(public, approved) did not persist PublicApproved = true") + } - // 4. Interactive sync declined (user says 'n') - instance.Prompt = interaction.Prompter{ - In: strings.NewReader("n\n"), - Out: &out, - Interactive: true, + // 4. Subsequent sync non-interactively without AllowPublic succeeds with 0 new probe calls + instance.Prompt = interaction.Prompter{In: strings.NewReader(""), Out: instance.Out, Interactive: false} + err = instance.Sync(ctx, SyncOptions{ + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeEnable, + }) + if err != nil { + t.Fatalf("Sync(public, approved subsequent) error = %v, want nil", err) } + if probeCalls != 3 { + t.Fatalf("probeCalls after subsequent sync = %d, want 3 (0 additional probes)", probeCalls) + } +} + +func TestLinkPublicApprovalPersistsToSync(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + createRemoteWithInitialCommit(t, root, remote) + var probeCalls int + instance, _ := testApp(t, publicRoot, root, remote) + instance.Provider = testRepositoryProvider{remoteURL: remote, isPublic: true, probeCalls: &probeCalls} + + // Link with AllowPublic: true + err := instance.Link(ctx, LinkOptions{ + Repository: "getspas/public-assets", + Branch: "main", + AllowPublic: true, + }) + if err != nil { + t.Fatalf("Link(AllowPublic=true) error = %v", err) + } + if probeCalls != 0 { + t.Fatalf("probeCalls during Link(AllowPublic=true) = %d, want 0", probeCalls) + } + + // Sync non-interactively without AllowPublic flag — should succeed and perform zero probe calls err = instance.Sync(ctx, SyncOptions{ Conflict: ConflictAbort, ExistingExclude: ExcludePreserve, MergeProtection: MergeEnable, }) - if err == nil || !strings.Contains(err.Error(), "declined") { - t.Fatalf("Sync(public, interactive declined) error = %v, want declined error", err) + if err != nil { + t.Fatalf("Sync() after Link(AllowPublic=true) error = %v", err) + } + if probeCalls != 0 { + t.Fatalf("probeCalls during Sync after Link(AllowPublic=true) = %d, want 0", probeCalls) + } +} + +func TestSyncPublicApprovalWithFlagPersists(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + _, remote, instance := initializedApp(t, root) + + var probeCalls int + instance.Provider = testRepositoryProvider{remoteURL: remote, isPublic: true, probeCalls: &probeCalls} + + // Sync with AllowPublic: true in non-interactive mode + instance.Prompt = interaction.Prompter{In: strings.NewReader(""), Out: instance.Out, Interactive: false} + err := instance.Sync(ctx, SyncOptions{ + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeEnable, + AllowPublic: true, + }) + if err != nil { + t.Fatalf("Sync(AllowPublic=true) error = %v", err) + } + if probeCalls != 0 { + t.Fatalf("probeCalls during Sync(AllowPublic=true) = %d, want 0", probeCalls) + } + + // Second sync without AllowPublic flag — should succeed with 0 probe calls + err = instance.Sync(ctx, SyncOptions{ + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeEnable, + }) + if err != nil { + t.Fatalf("Second Sync() error = %v", err) + } + if probeCalls != 0 { + t.Fatalf("probeCalls after second sync = %d, want 0", probeCalls) + } +} + +func TestPrivateRepositoryProbesEverySync(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + createRemoteWithInitialCommit(t, root, remote) + var probeCalls int + instance, _ := testApp(t, publicRoot, root, remote) + // isPublic is false, but remoteURL is valid bare repo so git ls-remote succeeds + instance.Provider = testRepositoryProvider{remoteURL: remote, isPublic: false, probeCalls: &probeCalls} + + if err := instance.Link(ctx, LinkOptions{ + Repository: "getspas/private-assets", + Branch: "main", + }); err != nil { + t.Fatalf("Link() error = %v", err) + } + if probeCalls != 1 { + t.Fatalf("probeCalls after Link = %d, want 1", probeCalls) + } + + // Sync 1 + if err := instance.Sync(ctx, SyncOptions{ + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeEnable, + }); err != nil { + t.Fatalf("Sync 1 error = %v", err) + } + if probeCalls != 2 { + t.Fatalf("probeCalls after Sync 1 = %d, want 2", probeCalls) + } + + // Sync with DryRun — should NOT probe + if err := instance.Sync(ctx, SyncOptions{ + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeEnable, + DryRun: true, + }); err != nil { + t.Fatalf("Sync DryRun error = %v", err) + } + if probeCalls != 2 { + t.Fatalf("probeCalls after Sync DryRun = %d, want 2", probeCalls) + } + + // Sync 2 + if err := instance.Sync(ctx, SyncOptions{ + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeEnable, + }); err != nil { + t.Fatalf("Sync 2 error = %v", err) + } + if probeCalls != 3 { + t.Fatalf("probeCalls after Sync 2 = %d, want 3", probeCalls) + } +} + +func TestLinkNetworkAccessReporting(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Case 1: normal Link without AllowPublic probes GitHub -> networkAccess: true + { + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + runGit(t, root, "init", "--bare", "-q", remote) + instance, out := testApp(t, publicRoot, root, remote) + instance.JSON = true + instance.Provider = testRepositoryProvider{remoteURL: remote, isPublic: false} + if err := instance.Link(ctx, LinkOptions{ + Repository: "getspas/private-assets", + Branch: "main", + }); err != nil { + t.Fatalf("Link() error = %v", err) + } + if !strings.Contains(out.String(), `"networkAccess":true`) && !strings.Contains(out.String(), `"networkAccess": true`) { + t.Fatalf("Link output %s does not contain networkAccess: true", out.String()) + } + } + + // Case 2: Link with AllowPublic skips probe -> networkAccess: false + { + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + runGit(t, root, "init", "--bare", "-q", remote) + + instance, out := testApp(t, publicRoot, root, remote) + instance.JSON = true + instance.Provider = testRepositoryProvider{remoteURL: remote, isPublic: false} + if err := instance.Link(ctx, LinkOptions{ + Repository: "getspas/private-assets", + Branch: "main", + AllowPublic: true, + }); err != nil { + t.Fatalf("Link(AllowPublic=true) error = %v", err) + } + if !strings.Contains(out.String(), `"networkAccess":false`) && !strings.Contains(out.String(), `"networkAccess": false`) { + t.Fatalf("Link(AllowPublic=true) output %s does not contain networkAccess: false", out.String()) + } + } + // Case 3: Link with DryRun skips probe -> networkAccess: false + { + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + runGit(t, root, "init", "--bare", "-q", remote) + instance, out := testApp(t, publicRoot, root, remote) + instance.JSON = true + instance.Provider = testRepositoryProvider{remoteURL: remote, isPublic: false} + if err := instance.Link(ctx, LinkOptions{ + Repository: "getspas/private-assets", + Branch: "main", + DryRun: true, + }); err != nil { + t.Fatalf("Link(DryRun=true) error = %v", err) + } + if !strings.Contains(out.String(), `"networkAccess":false`) && !strings.Contains(out.String(), `"networkAccess": false`) { + t.Fatalf("Link(DryRun=true) output %s does not contain networkAccess: false", out.String()) + } } } diff --git a/internal/app/sync.go b/internal/app/sync.go index 62b8a01..a0f4d18 100644 --- a/internal/app/sync.go +++ b/internal/app/sync.go @@ -129,29 +129,40 @@ func (a App) Sync(ctx context.Context, options SyncOptions) (returnErr error) { return err } - if !options.AllowPublic && a.Provider != nil { - ref := provider.RepositoryRef{ - Provider: state.Private.Provider, - Canonical: state.Private.Repository, - Transport: state.Private.Transport, - RemoteURL: state.Private.RemoteURL, - } - isPublic, probeErr := a.Provider.ProbePublic(ctx, a.Git, ref) - if probeErr != nil { - return probeErr - } - if isPublic { - approved, err := a.Prompt.Confirm( - ctx, - fmt.Sprintf("Repository %q is publicly readable on GitHub. Syncing will make managed assets publicly accessible. Continue?", state.Private.Repository), - false, - false, - ) - if err != nil { + if !state.Private.PublicApproved && a.Provider != nil { + if options.AllowPublic { + state.Private.PublicApproved = true + if err := a.Store.Save(state); err != nil { return err } - if !approved { - return fmt.Errorf("syncing to publicly readable repository declined") + } else { + ref := provider.RepositoryRef{ + Provider: state.Private.Provider, + Canonical: state.Private.Repository, + Transport: state.Private.Transport, + RemoteURL: state.Private.RemoteURL, + } + isPublic, probeErr := a.Provider.ProbePublic(ctx, a.Git, ref) + if probeErr != nil { + return probeErr + } + if isPublic { + approved, err := a.Prompt.Confirm( + ctx, + fmt.Sprintf("Repository %q is publicly readable on GitHub. Syncing will make managed assets publicly accessible. Continue?", state.Private.Repository), + false, + false, + ) + if err != nil { + return err + } + if !approved { + return fmt.Errorf("syncing to publicly readable repository declined") + } + state.Private.PublicApproved = true + if err := a.Store.Save(state); err != nil { + return err + } } } } diff --git a/internal/githubref/ref.go b/internal/githubref/ref.go index 70b6a06..4f1296d 100644 --- a/internal/githubref/ref.go +++ b/internal/githubref/ref.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "os" "regexp" "strings" @@ -114,7 +115,7 @@ func (Provider) ProbePublic(ctx context.Context, git gitexec.Runner, ref provide } probeGit := git probeGit.NonInteractive = true - _, err := probeGit.Run(ctx, ".", "-c", "credential.helper=", "ls-remote", url) + _, err := probeGit.Run(ctx, os.TempDir(), "-c", "credential.helper=", "ls-remote", url) if err == nil { return true, nil } diff --git a/internal/githubref/ref_test.go b/internal/githubref/ref_test.go index 48d3983..eee056d 100644 --- a/internal/githubref/ref_test.go +++ b/internal/githubref/ref_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os/exec" + "path/filepath" "testing" "time" @@ -126,3 +127,40 @@ func TestProbePublic(t *testing.T) { t.Fatalf("ProbePublic(timeout) error = %v, want context.DeadlineExceeded", err) } } + +func TestProbePublicIgnoresLocalGitConfig(t *testing.T) { + t.Parallel() + + ctx := context.Background() + git := gitexec.Runner{} + + dir := t.TempDir() + bareDir := filepath.Join(dir, "public.git") + cmd := exec.Command("git", "init", "--bare", "-q", bareDir) + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + + localRepo := filepath.Join(dir, "localrepo") + cmd = exec.Command("git", "init", "-q", localRepo) + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + + targetURL := "file://" + filepath.ToSlash(bareDir) + // Configure local repo to rewrite the target URL to a nonexistent path. + cmd = exec.Command("git", "-C", localRepo, "config", "url.file:///nonexistent-path-12345/.insteadOf", targetURL) + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + + // ProbePublic should run in a neutral directory outside localrepo and ignore its local config. + isPublic, err := (Provider{}).ProbePublic(ctx, git, provider.RepositoryRef{ + Provider: ID, + Canonical: "local/public", + RemoteURL: targetURL, + }) + if err != nil || !isPublic { + t.Fatalf("ProbePublic(local repo config rewrite) = %v, %v, want true, nil", isPublic, err) + } +} diff --git a/internal/linkstate/store.go b/internal/linkstate/store.go index 362840c..19a65e7 100644 --- a/internal/linkstate/store.go +++ b/internal/linkstate/store.go @@ -62,6 +62,7 @@ type Private struct { ExpectedHead string `json:"expectedHead,omitempty"` Initialization *CloneInitialization `json:"initialization,omitempty"` RemoteEmpty bool `json:"remoteEmpty,omitempty"` + PublicApproved bool `json:"publicApproved,omitempty"` } type CloneInitialization struct { diff --git a/internal/linkstate/store_test.go b/internal/linkstate/store_test.go index 149b9c1..19048bd 100644 --- a/internal/linkstate/store_test.go +++ b/internal/linkstate/store_test.go @@ -84,6 +84,35 @@ func TestSaveLoad(t *testing.T) { } } +func TestSaveLoadPublicApproved(t *testing.T) { + t.Parallel() + + root := t.TempDir() + store := Store{ + ConfigDir: filepath.Join(root, "config"), + DataDir: filepath.Join(root, "data"), + } + state := New( + filepath.Join(root, "public"), + filepath.Join(root, "public", ".git"), + testRepositoryRef(), + "main", + store, + ) + state.Private.PublicApproved = true + + if err := store.Save(state); err != nil { + t.Fatalf("Save() error = %v", err) + } + got, err := store.Load(state.Public.Root, state.Public.GitCommonDir) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !got.Private.PublicApproved { + t.Fatalf("Load().Private.PublicApproved = false, want true") + } +} + func TestSaveLoadAcceptsAbortOnlyMergeRecovery(t *testing.T) { t.Parallel() From 8d5248ba9ad50fb70519d9872b502d00695fbe5d Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:18:33 -0500 Subject: [PATCH 16/52] fix(doctor): report warnings for unlinked workspaces and isolate lock probe Add workspace and link-state warnings when link checks are skipped in non-git or unlinked directories. Deduplicate the git version check to a single process spawn, and use PID-scoped lock probe files to prevent race conditions during concurrent runs. --- internal/app/contract_test.go | 39 ++++++++++++++++++--- internal/app/diagnostics.go | 19 +++++----- internal/app/regression_test.go | 59 ++++++++++++++++++++++++++++---- internal/cli/root_test.go | 16 +++++++-- internal/publicgit/repository.go | 11 +++--- 5 files changed, 115 insertions(+), 29 deletions(-) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index ba9e81b..80c4ed1 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -1567,18 +1567,26 @@ func TestDoctorUnlinkedNonGitWorkspace(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &doctor); err != nil { t.Fatalf("decode doctor: %v\n%s", err, output.String()) } - if !doctor.Healthy || doctor.Errors != 0 { - t.Fatalf("Doctor() = %#v, want healthy unlinked doctor result", doctor) + if !doctor.Healthy || doctor.Errors != 0 || doctor.Warnings != 1 { + t.Fatalf("Doctor() = %#v, want healthy unlinked doctor result with 1 warning", doctor) } checks := make(map[string]string) + messages := make(map[string]string) for _, check := range doctor.Checks { checks[check.Name] = check.Status + messages[check.Name] = check.Message } for _, expected := range []string{"git", "data-dirs", "lock"} { if status, ok := checks[expected]; !ok || status != "ok" { t.Fatalf("expected check %q to be ok, got %q (found=%t)", expected, status, ok) } } + if status, ok := checks["workspace"]; !ok || status != "warning" { + t.Fatalf("expected workspace check to be warning, got %q (found=%t)", status, ok) + } + if !strings.Contains(messages["workspace"], "not a Git repository — link checks skipped:") { + t.Fatalf("workspace message = %q, want link checks skipped notice", messages["workspace"]) + } // Test text rendering mode as well output.Reset() @@ -1587,7 +1595,7 @@ func TestDoctorUnlinkedNonGitWorkspace(t *testing.T) { t.Fatalf("Doctor() text error = %v\n%s", err, output.String()) } textOutput := output.String() - for _, expected := range []string{"git", "data-dirs", "lock", "ok"} { + for _, expected := range []string{"git", "data-dirs", "lock", "ok", "workspace", "warning", "not a Git repository — link checks skipped:"} { if !strings.Contains(textOutput, expected) { t.Errorf("text output missing %q: %s", expected, textOutput) } @@ -1621,18 +1629,39 @@ func TestDoctorUnlinkedGitWorkspace(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &doctor); err != nil { t.Fatalf("decode doctor: %v\n%s", err, output.String()) } - if !doctor.Healthy || doctor.Errors != 0 { - t.Fatalf("Doctor() = %#v, want healthy unlinked doctor result", doctor) + if !doctor.Healthy || doctor.Errors != 0 || doctor.Warnings != 1 { + t.Fatalf("Doctor() = %#v, want healthy unlinked doctor result with 1 warning", doctor) } checks := make(map[string]string) + messages := make(map[string]string) for _, check := range doctor.Checks { checks[check.Name] = check.Status + messages[check.Name] = check.Message } for _, expected := range []string{"git", "data-dirs", "lock", "worktrees"} { if status, ok := checks[expected]; !ok || status != "ok" { t.Fatalf("expected check %q to be ok, got %q (found=%t)", expected, status, ok) } } + if status, ok := checks["link-state"]; !ok || status != "warning" { + t.Fatalf("expected link-state check to be warning, got %q (found=%t)", status, ok) + } + if messages["link-state"] != "workspace is not linked; run spas link — link checks skipped" { + t.Fatalf("link-state message = %q, want unlinked notice", messages["link-state"]) + } + + // Test text rendering mode as well + output.Reset() + instance.JSON = false + if err := instance.Doctor(ctx); err != nil { + t.Fatalf("Doctor() text error = %v\n%s", err, output.String()) + } + textOutput := output.String() + for _, expected := range []string{"git", "data-dirs", "lock", "worktrees", "ok", "link-state", "warning", "workspace is not linked; run spas link — link checks skipped"} { + if !strings.Contains(textOutput, expected) { + t.Errorf("text output missing %q: %s", expected, textOutput) + } + } } func TestDoctorUnlinkedGitWorkspaceMultipleWorktrees(t *testing.T) { diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go index 2445ea0..5515dbe 100644 --- a/internal/app/diagnostics.go +++ b/internal/app/diagnostics.go @@ -213,17 +213,11 @@ func (a App) Doctor(ctx context.Context) error { } } - dir := a.RepoHint - if dir == "" { - dir = "." - } - version, err := a.Git.Run(ctx, dir, "--version") + gitVersion, err := publicgit.RequireSupportedGit(ctx, a.Git) if err != nil { add("git", "error", err.Error()) - } else if reqErr := publicgit.RequireSupportedGit(ctx, a.Git); reqErr != nil { - add("git", "error", reqErr.Error()) } else { - add("git", "ok", strings.TrimSpace(string(version.Stdout))) + add("git", "ok", gitVersion) } configErr := checkDirectoryWritable(a.Store.ConfigDir) @@ -247,6 +241,7 @@ func (a App) Doctor(ctx context.Context) error { repository, repoErr := a.publicRepository(ctx) if repoErr != nil { + add("workspace", "warning", fmt.Sprintf("not a Git repository — link checks skipped: %v", repoErr)) return a.renderDoctorResult(result) } @@ -262,6 +257,7 @@ func (a App) Doctor(ctx context.Context) error { state, err := a.loadState(repository.Root, repository.CommonDir) if err != nil { if errors.Is(err, linkstate.ErrNotLinked) { + add("link-state", "warning", "workspace is not linked; run spas link — link checks skipped") return a.renderDoctorResult(result) } add("link-state", "error", fmt.Sprintf("invalid link state: %v", err)) @@ -487,13 +483,14 @@ func checkDirectoryWritable(dir string) error { } func checkLockAcquirable(lockDir string) error { - testLock, err := lock.Acquire(lockDir, ".doctor-probe") + name := fmt.Sprintf(".doctor-probe-%d", os.Getpid()) + testLock, err := lock.Acquire(lockDir, name) if err != nil { return err } releaseErr := testLock.Release() - removeErr := os.Remove(filepath.Join(lockDir, ".doctor-probe.lock")) - return errors.Join(releaseErr, removeErr) + _ = os.Remove(filepath.Join(lockDir, name+".lock")) + return releaseErr } func (a App) originConfigShape(ctx context.Context, privatePath string) (string, bool, error) { diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index db6a1cd..f20794f 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -8,20 +8,24 @@ import ( "context" "encoding/json" "errors" - "github.com/getspas/spas/internal/filesync" - "github.com/getspas/spas/internal/gitexec" - "github.com/getspas/spas/internal/interaction" - "github.com/getspas/spas/internal/linkstate" - "github.com/getspas/spas/internal/pathmodel" - "github.com/getspas/spas/internal/spaserr" + "fmt" "os" "os/exec" "path/filepath" "reflect" "runtime" "strings" + "sync" "testing" "time" + + "github.com/getspas/spas/internal/filesync" + "github.com/getspas/spas/internal/gitexec" + "github.com/getspas/spas/internal/interaction" + "github.com/getspas/spas/internal/linkstate" + "github.com/getspas/spas/internal/lock" + "github.com/getspas/spas/internal/pathmodel" + "github.com/getspas/spas/internal/spaserr" ) // fixture creates a public repository with one committed public file, a bare @@ -3041,3 +3045,46 @@ func TestSyncTimesOutWhenGitNetworkStalls(t *testing.T) { t.Fatalf("Sync() error = %v, want context deadline exceeded", err) } } + +func TestDoctorConcurrentLockProbe(t *testing.T) { + t.Parallel() + + lockDir := filepath.Join(t.TempDir(), "locks") + const concurrency = 8 + var wg sync.WaitGroup + errs := make(chan error, concurrency) + + for i := range concurrency { + wg.Add(1) + go func(id int) { + defer wg.Done() + // Simulate distinct process PIDs for concurrent doctor probe executions + name := fmt.Sprintf(".doctor-probe-%d", 20000+id) + testLock, err := lock.Acquire(lockDir, name) + if err != nil { + errs <- fmt.Errorf("concurrent probe %d acquire failed: %w", id, err) + return + } + releaseErr := testLock.Release() + removeErr := os.Remove(filepath.Join(lockDir, name+".lock")) + if releaseErr != nil { + errs <- fmt.Errorf("concurrent probe %d release failed: %w", id, releaseErr) + return + } + _ = removeErr + }(i) + } + + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Errorf("unexpected error in concurrent lock probe: %v", err) + } + } + + // Also verify that checkLockAcquirable runs cleanly on this lockDir + if err := checkLockAcquirable(lockDir); err != nil { + t.Fatalf("checkLockAcquirable() error = %v", err) + } +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 557d517..0070217 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -514,7 +514,7 @@ func TestDoctorCommandUnlinked(t *testing.T) { t.Fatalf("Execute(doctor) error = %v\n%s", err, output.String()) } text := output.String() - for _, expected := range []string{"git", "data-dirs", "lock", "ok"} { + for _, expected := range []string{"git", "data-dirs", "lock", "ok", "workspace", "warning", "not a Git repository — link checks skipped:"} { if !strings.Contains(text, expected) { t.Errorf("output missing %q: %s", expected, text) } @@ -530,7 +530,17 @@ func TestDoctorCommandUnlinked(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &result); err != nil { t.Fatalf("decode doctor json: %v\n%s", err, output.String()) } - if result.SchemaVersion != app.JSONSchemaVersion || !result.Healthy || result.Errors != 0 { - t.Fatalf("doctor result = %#v, want healthy with schemaVersion %d", result, app.JSONSchemaVersion) + if result.SchemaVersion != app.JSONSchemaVersion || !result.Healthy || result.Errors != 0 || result.Warnings != 1 { + t.Fatalf("doctor result = %#v, want healthy with 1 warning and schemaVersion %d", result, app.JSONSchemaVersion) + } + foundWorkspaceWarning := false + for _, check := range result.Checks { + if check.Name == "workspace" && check.Status == "warning" && strings.Contains(check.Message, "not a Git repository — link checks skipped:") { + foundWorkspaceWarning = true + break + } + } + if !foundWorkspaceWarning { + t.Fatalf("doctor result checks = %#v, want workspace warning", result.Checks) } } diff --git a/internal/publicgit/repository.go b/internal/publicgit/repository.go index ee97b78..98d6948 100644 --- a/internal/publicgit/repository.go +++ b/internal/publicgit/repository.go @@ -21,7 +21,7 @@ type Repository struct { } func Discover(ctx context.Context, git gitexec.Runner, hint string) (Repository, error) { - if err := RequireSupportedGit(ctx, git); err != nil { + if _, err := RequireSupportedGit(ctx, git); err != nil { return Repository{}, err } if hint == "" { @@ -66,12 +66,15 @@ func Discover(ctx context.Context, git gitexec.Runner, hint string) (Repository, return Repository{Root: root, GitDir: gitDir, CommonDir: common, Git: git}, nil } -func RequireSupportedGit(ctx context.Context, git gitexec.Runner) error { +func RequireSupportedGit(ctx context.Context, git gitexec.Runner) (string, error) { result, err := git.Run(ctx, ".", "--version") if err != nil { - return fmt.Errorf("Git 2.43.1 or newer is required: %w", err) + return "", fmt.Errorf("Git 2.43.1 or newer is required: %w", err) } - return validateGitVersion(string(result.Stdout)) + if err := validateGitVersion(string(result.Stdout)); err != nil { + return "", err + } + return strings.TrimSpace(string(result.Stdout)), nil } func validateGitVersion(output string) error { From 92cccdc5805588f25eea48ba7d3d3626a6c3d072 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:08:10 -0500 Subject: [PATCH 17/52] fix(path): restrict MAX_PATH preflight to Windows roots Check workspace and clone roots on Windows during add and sync instead of rejecting long paths globally. --- internal/app/app.go | 25 ++- internal/app/contract_test.go | 247 +++++++++++++++++++++++++ internal/app/sync.go | 14 ++ internal/pathmodel/path.go | 11 +- internal/pathmodel/path_test.go | 34 +++- internal/privategit/repository.go | 3 + internal/privategit/repository_test.go | 36 +++- 7 files changed, 347 insertions(+), 23 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index f8fb1f7..d611864 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -230,7 +230,7 @@ func (a App) Add(ctx context.Context, options AddOptions) error { if err != nil { return err } - files, err := a.expandPaths(repository.Root, options.Paths) + files, err := a.expandPaths(repository.Root, state.Private.LocalRepositoryPath, options.Paths) if err != nil { return err } @@ -1026,10 +1026,10 @@ func (a App) validateRepositoryIdentity(state linkstate.State) error { return nil } -func (a App) expandPaths(root string, values []string) ([]pathmodel.Path, error) { +func (a App) expandPaths(workspaceRoot, privateRoot string, values []string) ([]pathmodel.Path, error) { set := make(map[string]pathmodel.Path) for _, value := range values { - path, absolute, err := pathmodel.Resolve(root, a.PathBase, value) + path, absolute, err := pathmodel.Resolve(workspaceRoot, a.PathBase, value) if err != nil { return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf("resolve managed path %q: %w", value, err)) } @@ -1038,13 +1038,19 @@ func (a App) expandPaths(root string, values []string) ([]pathmodel.Path, error) return nil, fmt.Errorf("inspect %q: %w", value, err) } if info.Mode().IsRegular() { + if err := pathmodel.ValidatePathLength(workspaceRoot, path); err != nil { + return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } + if err := pathmodel.ValidatePathLength(privateRoot, path); err != nil { + return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } if err := privategit.ValidateManagedPath(path); err != nil { return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, err) } - if err := pathmodel.ValidateNoSymlinkComponents(root, path); err != nil { + if err := pathmodel.ValidateNoSymlinkComponents(workspaceRoot, path); err != nil { return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, err) } - if _, statErr := os.Lstat(path.OSPath(root)); statErr != nil { + if _, statErr := os.Lstat(path.OSPath(workspaceRoot)); statErr != nil { return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf( "%q: the on-disk name does not match its Unicode NFC form and cannot be enrolled portably; rename the file to its NFC spelling", value)) } @@ -1073,7 +1079,7 @@ func (a App) expandPaths(root string, values []string) ([]pathmodel.Path, error) if !entry.Type().IsRegular() { return spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf("directory %q contains unsupported file type %q", value, current)) } - relative, err := filepath.Rel(root, current) + relative, err := filepath.Rel(workspaceRoot, current) if err != nil { return err } @@ -1081,13 +1087,16 @@ func (a App) expandPaths(root string, values []string) ([]pathmodel.Path, error) if err != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, err) } - if err := pathmodel.ValidatePathLength(root, managed); err != nil { + if err := pathmodel.ValidatePathLength(workspaceRoot, managed); err != nil { + return spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } + if err := pathmodel.ValidatePathLength(privateRoot, managed); err != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, err) } if err := privategit.ValidateManagedPath(managed); err != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, err) } - if _, statErr := os.Lstat(managed.OSPath(root)); statErr != nil { + if _, statErr := os.Lstat(managed.OSPath(workspaceRoot)); statErr != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf( "%q: the on-disk name does not match its Unicode NFC form and cannot be enrolled portably; rename the file to its NFC spelling", current)) } diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index 80c4ed1..2520036 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strconv" "strings" "testing" @@ -1791,3 +1792,249 @@ func TestDoctorUnlinkedGitError(t *testing.T) { t.Fatalf("expected git check to have status error, checks=%#v", doctor.Checks) } } + +func TestWindowsPathLengthPreflightRejectsWorkspaceRoot(t *testing.T) { + t.Parallel() + if runtime.GOOS != "windows" { + t.Skipf("skipping Windows path length preflight test on %s", runtime.GOOS) + } + + ctx := context.Background() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "private.git") + runGit(t, root, "init", "--bare", "-q", remote) + + sub := filepath.Join(publicRoot, strings.Repeat("a", 100), strings.Repeat("b", 100)) + if err := os.MkdirAll(sub, 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(sub, "secret.json") + if err := os.WriteFile(target, []byte("SECRET=1\n"), 0o600); err != nil { + t.Fatal(err) + } + + rel, err := filepath.Rel(publicRoot, target) + if err != nil { + t.Fatal(err) + } + + instance, _ := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + + err = instance.Add(ctx, AddOptions{ + Paths: []string{rel}, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeSkip, + }) + if err == nil { + t.Fatal("Add() error = nil, want path length error on Windows") + } + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("Add() error kind = %v, want KindUnsupportedPath", kind) + } + if !strings.Contains(err.Error(), "reaches or exceeds the Windows limit") { + t.Fatalf("Add() error = %v, want Windows limit error", err) + } +} + +func TestWindowsPathLengthPreflightRejectsPrivateCloneRoot(t *testing.T) { + t.Parallel() + if runtime.GOOS != "windows" { + t.Skipf("skipping Windows path length preflight test on %s", runtime.GOOS) + } + + ctx := context.Background() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "private.git") + runGit(t, root, "init", "--bare", "-q", remote) + + longDataDir := filepath.Join(root, strings.Repeat("d", 120), strings.Repeat("e", 100)) + if err := os.MkdirAll(longDataDir, 0o700); err != nil { + t.Fatal(err) + } + + rel := "nested/secret.json" + target := filepath.Join(publicRoot, "nested", "secret.json") + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("SECRET=1\n"), 0o600); err != nil { + t.Fatal(err) + } + + instance, _ := testApp(t, publicRoot, root, remote) + instance.Store.DataDir = longDataDir + + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + + _, state, err := instance.linked(ctx) + if err != nil { + t.Fatal(err) + } + + err = instance.Add(ctx, AddOptions{ + Paths: []string{rel}, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeSkip, + }) + if err == nil { + t.Fatal("Add() error = nil, want private clone path length error on Windows") + } + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("Add() error kind = %v, want KindUnsupportedPath", kind) + } + if !strings.Contains(err.Error(), "reaches or exceeds the Windows limit") { + t.Fatalf("Add() error = %v, want Windows limit error", err) + } + if !strings.Contains(err.Error(), "data") && !strings.Contains(err.Error(), "repos") { + t.Fatalf("Add() error = %v, want error naming private clone root %q", err, state.Private.LocalRepositoryPath) + } +} + +func TestWindowsPathLengthPreflightRejectsSyncRemotePath(t *testing.T) { + t.Parallel() + if runtime.GOOS != "windows" { + t.Skipf("skipping Windows path length preflight test on %s", runtime.GOOS) + } + + ctx := context.Background() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "private.git") + runGit(t, root, "init", "--bare", "-q", remote) + + tempClone := filepath.Join(root, "temp-clone") + runGit(t, root, "clone", "-q", remote, tempClone) + runGit(t, tempClone, "config", "core.longpaths", "true") + runGit(t, tempClone, "config", "user.name", "SPAS Test") + runGit(t, tempClone, "config", "user.email", "spas@example.invalid") + longRel := filepath.Join(strings.Repeat("r", 100), strings.Repeat("s", 100), "remote.json") + fullTemp := filepath.Join(tempClone, longRel) + if err := os.MkdirAll(filepath.Dir(fullTemp), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullTemp, []byte("REMOTE=1\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, tempClone, "add", "-A") + runGit(t, tempClone, "commit", "-q", "-m", "add long path") + runGit(t, tempClone, "push", "-q", "origin", "HEAD:refs/heads/main") + + instance, _ := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + + err := instance.Sync(ctx, SyncOptions{ + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeSkip, + }) + if err == nil { + t.Fatal("Sync() error = nil, want path length error on Windows") + } + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("Sync() error = %v, kind = %v, want KindUnsupportedPath", err, kind) + } +} + +func TestRemoveAndDiffAllowAlreadyEnrolledPathsExceedingLimit(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "private.git") + runGit(t, root, "init", "--bare", "-q", remote) + + instance, _ := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + + longPath := strings.Repeat("x", 100) + "/" + strings.Repeat("y", 100) + "/enrolled.json" + state, err := instance.Store.Load(publicRoot, filepath.Join(publicRoot, ".git")) + if err != nil { + t.Fatal(err) + } + state.ManagedPaths = []string{longPath} + if err := instance.Store.Save(state); err != nil { + t.Fatal(err) + } + + err = instance.Remove(ctx, RemoveOptions{Paths: []string{longPath}}) + if err != nil { + t.Fatalf("Remove() error = %v, want nil for enrolled path", err) + } + + err = instance.Diff(ctx, DiffOptions{Paths: []string{longPath}}) + if err != nil { + t.Fatalf("Diff() error = %v, want nil for enrolled path", err) + } +} + +func TestLinuxMacAllowsPathLengthExceedingWindowsLimit(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skipf("skipping Linux/macOS long path test on Windows") + } + + ctx := context.Background() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "private.git") + runGit(t, root, "init", "--bare", "-q", remote) + + sub := filepath.Join(publicRoot, strings.Repeat("a", 100), strings.Repeat("b", 100)) + if err := os.MkdirAll(sub, 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(sub, "secret.json") + if err := os.WriteFile(target, []byte("SECRET=1\n"), 0o600); err != nil { + t.Fatal(err) + } + + rel, err := filepath.Rel(publicRoot, target) + if err != nil { + t.Fatal(err) + } + if len(target) < 260 { + t.Fatalf("len(target) = %d, want >= 260", len(target)) + } + + instance, _ := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + + if err := instance.Add(ctx, AddOptions{ + Paths: []string{rel}, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeSkip, + }); err != nil { + t.Fatalf("Add() error = %v, want nil on non-Windows", err) + } + + if err := instance.Sync(ctx, SyncOptions{ + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeSkip, + Message: "sync long path", + }); err != nil { + t.Fatalf("Sync() error = %v, want nil on non-Windows", err) + } + + if err := instance.Diff(ctx, DiffOptions{Paths: []string{rel}}); err != nil { + t.Fatalf("Diff() error = %v, want nil on non-Windows", err) + } + + if err := instance.Remove(ctx, RemoveOptions{Paths: []string{rel}}); err != nil { + t.Fatalf("Remove() error = %v, want nil on non-Windows", err) + } +} diff --git a/internal/app/sync.go b/internal/app/sync.go index a0f4d18..d85c3f0 100644 --- a/internal/app/sync.go +++ b/internal/app/sync.go @@ -277,6 +277,14 @@ func (a App) Sync(ctx context.Context, options SyncOptions) (returnErr error) { if err := validateProspectivePrivateTreeSize(candidatePrivate); err != nil { return err } + for _, path := range candidatePrivate { + if err := pathmodel.ValidatePathLength(repository.Root, path); err != nil { + return spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } + if err := pathmodel.ValidatePathLength(private.Path, path); err != nil { + return spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } + } // groupByCanonical supports the case-only override's ambiguity check. publicPaths, err := repository.TrackedPaths(ctx) @@ -3197,6 +3205,12 @@ func planLocalChanges( if _, skip := skipped[value]; skip { continue } + if err := pathmodel.ValidatePathLength(publicRoot, path); err != nil { + return localChangePlan{}, spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } + if err := pathmodel.ValidatePathLength(privateRoot, path); err != nil { + return localChangePlan{}, spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } publicPath := path.OSPath(publicRoot) snapshot, err := snapshotFile(publicPath) if err != nil { diff --git a/internal/pathmodel/path.go b/internal/pathmodel/path.go index 41ccafe..0d634d2 100644 --- a/internal/pathmodel/path.go +++ b/internal/pathmodel/path.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "strings" "unicode" "unicode/utf8" @@ -103,10 +104,6 @@ func Resolve(publicRoot, base, value string) (Path, string, error) { if err != nil { return "", "", fmt.Errorf("resolve path %q: %w", value, err) } - if len(absolute) >= limits.MaxWindowsPathLength { - return "", "", fmt.Errorf("total path length of %q (%d characters) exceeds the cross-platform limit of %d characters", absolute, len(absolute), limits.MaxWindowsPathLength) - } - relative, err := filepath.Rel(publicRoot, absolute) if err != nil { return "", "", fmt.Errorf("make path relative to public workspace: %w", err) @@ -117,11 +114,13 @@ func Resolve(publicRoot, base, value string) (Path, string, error) { } return path, absolute, nil } - func ValidatePathLength(root string, path Path) error { + if runtime.GOOS != "windows" { + return nil + } full := path.OSPath(root) if len(full) >= limits.MaxWindowsPathLength { - return fmt.Errorf("total path length of %q (%d characters) exceeds the cross-platform limit of %d characters", full, len(full), limits.MaxWindowsPathLength) + return fmt.Errorf("total path length of %q (%d characters) in root %q reaches or exceeds the Windows limit of %d characters", full, len(full), root, limits.MaxWindowsPathLength) } return nil } diff --git a/internal/pathmodel/path_test.go b/internal/pathmodel/path_test.go index 65184a3..11937fb 100644 --- a/internal/pathmodel/path_test.go +++ b/internal/pathmodel/path_test.go @@ -3,6 +3,7 @@ package pathmodel import ( "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -132,7 +133,7 @@ func TestParseAllowsComponentAtPortableASCIILimit(t *testing.T) { } } -func TestResolveRejectsTotalPathLengthExceedingWindowsLimit(t *testing.T) { +func TestResolveAllowsTotalPathLengthExceedingWindowsLimit(t *testing.T) { t.Parallel() root := t.TempDir() @@ -140,12 +141,16 @@ func TestResolveRejectsTotalPathLengthExceedingWindowsLimit(t *testing.T) { // Note each component is <= 255 bytes, but total length exceeds 260. comp := strings.Repeat("a", 100) rel := filepath.Join(comp, comp, comp) - _, _, err := Resolve(root, root, rel) - if err == nil { - t.Fatal("Resolve() error = nil, want total path length error") + path, abs, err := Resolve(root, root, rel) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil", err) } - if !strings.Contains(err.Error(), "exceeds the cross-platform limit") { - t.Fatalf("Resolve() error = %v, want cross-platform limit error", err) + if len(abs) < 260 { + t.Fatalf("len(abs) = %d, want >= 260", len(abs)) + } + expected := comp + "/" + comp + "/" + comp + if path.String() != expected { + t.Fatalf("Resolve() = %q, want %q", path, expected) } } @@ -153,13 +158,26 @@ func TestValidatePathLength(t *testing.T) { t.Parallel() root := "/short/root" + if runtime.GOOS == "windows" { + root = `C:\short\root` + } shortPath := Path("a/b/c.txt") if err := ValidatePathLength(root, shortPath); err != nil { t.Fatalf("ValidatePathLength(short) = %v, want nil", err) } longPath := Path(strings.Repeat("a/", 130) + "file.txt") - if err := ValidatePathLength(root, longPath); err == nil { - t.Fatal("ValidatePathLength(long) error = nil, want limit error") + err := ValidatePathLength(root, longPath) + if runtime.GOOS == "windows" { + if err == nil { + t.Fatal("ValidatePathLength(long) error = nil on Windows, want limit error") + } + if !strings.Contains(err.Error(), "reaches or exceeds the Windows limit") || !strings.Contains(err.Error(), "short") { + t.Fatalf("ValidatePathLength(long) error = %v, want Windows limit error naming root", err) + } + } else { + if err != nil { + t.Fatalf("ValidatePathLength(long) error = %v on %s, want nil", err, runtime.GOOS) + } } } diff --git a/internal/privategit/repository.go b/internal/privategit/repository.go index d4ac882..c678cef 100644 --- a/internal/privategit/repository.go +++ b/internal/privategit/repository.go @@ -927,6 +927,9 @@ func (r Repository) ValidateTree(ctx context.Context, revision string) error { if (entry.Mode != "100644" && entry.Mode != "100755") || entry.Type != "blob" { return spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf("private path %q uses unsupported Git mode %s", entry.Path, entry.Mode)) } + if err := pathmodel.ValidatePathLength(r.Path, entry.Path); err != nil { + return spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } if err := ValidateManagedPath(entry.Path); err != nil { return err } diff --git a/internal/privategit/repository_test.go b/internal/privategit/repository_test.go index b2ebab7..b5f3405 100644 --- a/internal/privategit/repository_test.go +++ b/internal/privategit/repository_test.go @@ -485,7 +485,6 @@ func TestValidateBranchNameCancellation(t *testing.T) { } } - func TestHeadRejectsNonCommitRef(t *testing.T) { t.Parallel() @@ -782,6 +781,41 @@ func TestValidateTreeRejectsPortableCaseConflict(t *testing.T) { } } +func TestValidateTreeRejectsPathLengthExceedingWindowsLimit(t *testing.T) { + t.Parallel() + + root := t.TempDir() + runGit(t, root, "init", "-q", "-b", "main") + runGit(t, root, "config", "user.name", "SPAS Test") + runGit(t, root, "config", "user.email", "spas@example.invalid") + first := hashBlob(t, root, "first") + longPath := strings.Repeat("a", 100) + "/" + strings.Repeat("b", 100) + "/deep.json" + runGit(t, root, "update-index", "--add", "--cacheinfo", "100644,"+first+","+longPath) + runGit(t, root, "commit", "-q", "-m", "long path commit") + + repository := Repository{ + Path: root, + Git: gitexec.Runner{}, + SafetyDir: filepath.Join(t.TempDir(), "safety"), + } + err := repository.ValidateTree(context.Background(), "HEAD") + if runtime.GOOS == "windows" { + if err == nil { + t.Fatal("ValidateTree() error = nil on Windows, want path length error") + } + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("ValidateTree() error kind = %v, want KindUnsupportedPath", kind) + } + if !strings.Contains(err.Error(), "reaches or exceeds the Windows limit") { + t.Fatalf("ValidateTree() error = %v, want Windows limit error", err) + } + } else { + if err != nil { + t.Fatalf("ValidateTree() error = %v on %s, want nil", err, runtime.GOOS) + } + } +} + func TestVerifyOriginRejectsPushURLAndMultipleOrigins(t *testing.T) { t.Parallel() From 10d3263f9c31b08593df8f66577be10dc9e131d5 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:31:38 -0500 Subject: [PATCH 18/52] perf(publicgit): batch exclusion checks into single check-ignore process Pass candidate paths over stdin with -z, --verbose, and --non-matching to verify all exclusions in one Git process. --- internal/app/contract_test.go | 4 + internal/app/integration_test.go | 6 +- internal/app/regression_test.go | 2 + internal/publicgit/repository.go | 42 +++++-- internal/publicgit/repository_test.go | 164 +++++++++++++++++++++++++- 5 files changed, 206 insertions(+), 12 deletions(-) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index 2520036..694ceb2 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -1188,6 +1188,8 @@ func initializePublicRepository(t *testing.T, root string) string { runGit(t, publicRoot, "init", "-q", "-b", "main") runGit(t, publicRoot, "config", "user.name", "SPAS Test") runGit(t, publicRoot, "config", "user.email", "spas@example.invalid") + runGit(t, publicRoot, "config", "commit.gpgsign", "false") + runGit(t, publicRoot, "config", "tag.gpgsign", "false") if err := os.WriteFile(filepath.Join(publicRoot, "README.md"), []byte("public\n"), 0o600); err != nil { t.Fatal(err) } @@ -1207,6 +1209,8 @@ func initializePrivateRemoteWithFile(t *testing.T, root, relativePath, content s runGit(t, root, "init", "-q", "-b", "main", source) runGit(t, source, "config", "user.name", "Private Source") runGit(t, source, "config", "user.email", "private@example.invalid") + runGit(t, source, "config", "commit.gpgsign", "false") + runGit(t, source, "config", "tag.gpgsign", "false") if err := os.WriteFile(filepath.Join(source, relativePath), []byte(content), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index c636cfc..7d86908 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -1319,14 +1319,16 @@ func (p testRepositoryProvider) ProbePublic(ctx context.Context, git gitexec.Run func runGit(t *testing.T, dir string, args ...string) { t.Helper() - if _, err := (gitexec.Runner{}).Run(context.Background(), dir, args...); err != nil { + cmdArgs := append([]string{"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"}, args...) + if _, err := (gitexec.Runner{}).Run(context.Background(), dir, cmdArgs...); err != nil { t.Fatalf("git %v: %v", args, err) } } func gitOutput(t *testing.T, dir string, args ...string) string { t.Helper() - result, err := (gitexec.Runner{}).Run(context.Background(), dir, args...) + cmdArgs := append([]string{"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"}, args...) + result, err := (gitexec.Runner{}).Run(context.Background(), dir, cmdArgs...) if err != nil { t.Fatalf("git %v: %v", args, err) } diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index f20794f..faf51ed 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -42,6 +42,8 @@ func fixture(t *testing.T) (App, string, string, string) { runGit(t, publicRoot, "init", "-q", "-b", "main") runGit(t, publicRoot, "config", "user.name", "SPAS Test") runGit(t, publicRoot, "config", "user.email", "spas@example.invalid") + runGit(t, publicRoot, "config", "commit.gpgsign", "false") + runGit(t, publicRoot, "config", "tag.gpgsign", "false") if err := os.WriteFile(filepath.Join(publicRoot, "README.md"), []byte("public\n"), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/publicgit/repository.go b/internal/publicgit/repository.go index 98d6948..3c3b543 100644 --- a/internal/publicgit/repository.go +++ b/internal/publicgit/repository.go @@ -160,6 +160,11 @@ func (r Repository) InfoExcludePath(ctx context.Context) (string, error) { return strings.TrimSpace(string(result.Stdout)), nil } +// ExcludedPaths checks effective exclusion for candidate paths in a single +// Git check-ignore invocation. Output is parsed as NUL-delimited quadruplets +// (\0\0\0\0) produced by --verbose --non-matching. +// At the maximum supported tree size of 10,000 entries, the output (~5 MiB max) +// stays well within the 16 MiB stdout capture limit. func (r Repository) ExcludedPaths(ctx context.Context, paths []pathmodel.Path) (map[pathmodel.Path]bool, error) { if len(paths) == 0 { return make(map[pathmodel.Path]bool), nil @@ -172,21 +177,15 @@ func (r Repository) ExcludedPaths(ctx context.Context, paths []pathmodel.Path) ( "--no-index", "--stdin", "-z", + "--verbose", + "--non-matching", ) if err != nil { if code, ok := gitexec.ExitCode(err); !ok || code != 1 { return nil, fmt.Errorf("check public exclusions: %w", err) } } - ignored, err := parsePaths(result.Stdout) - if err != nil { - return nil, fmt.Errorf("parse ignored paths: %w", err) - } - set := make(map[pathmodel.Path]bool, len(ignored)) - for _, path := range ignored { - set[path] = true - } - return set, nil + return parseCheckIgnoreOutput(result.Stdout) } func (r Repository) UnexcludedPaths(ctx context.Context, paths []pathmodel.Path) ([]pathmodel.Path, error) { @@ -346,6 +345,31 @@ func parsePaths(output []byte) ([]pathmodel.Path, error) { return paths, nil } +func parseCheckIgnoreOutput(output []byte) (map[pathmodel.Path]bool, error) { + if len(output) == 0 { + return make(map[pathmodel.Path]bool), nil + } + fields := strings.Split(strings.TrimSuffix(string(output), "\x00"), "\x00") + if len(fields)%4 != 0 { + return nil, fmt.Errorf("malformed check-ignore output: expected quadruplets, got %d fields", len(fields)) + } + set := make(map[pathmodel.Path]bool) + for i := 0; i < len(fields); i += 4 { + pattern := fields[i+2] + pathname := fields[i+3] + if pathname == "" { + continue + } + path, err := pathmodel.ParseObserved(pathname) + if err != nil { + return nil, fmt.Errorf("public Git returned unusable path %q: %w", pathname, err) + } + if pattern != "" && !strings.HasPrefix(pattern, "!") { + set[path] = true + } + } + return set, nil +} func swapASCIIcase(value string) string { var result strings.Builder result.Grow(len(value)) diff --git a/internal/publicgit/repository_test.go b/internal/publicgit/repository_test.go index 16c1d2a..4e5e6b1 100644 --- a/internal/publicgit/repository_test.go +++ b/internal/publicgit/repository_test.go @@ -33,6 +33,17 @@ func runPublicGitProxy() int { return 1 } } + if logPath := os.Getenv("SPAS_PUBLICGIT_LOG"); logPath != "" { + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return 1 + } + defer f.Close() + record := strings.Join(os.Args[1:], " ") + "\n" + if _, err := f.WriteString(record); err != nil { + return 1 + } + } command := exec.Command(os.Getenv("SPAS_PUBLICGIT_REAL_GIT"), os.Args[1:]...) command.Stdin = bytes.NewReader(input) command.Stdout = os.Stdout @@ -539,6 +550,156 @@ func TestBatchExclusionChecks(t *testing.T) { t.Fatalf("UnexcludedPaths(mixed) = %v, want [%v]", unexcluded, p3) } } +func TestParseCheckIgnoreOutput(t *testing.T) { + t.Parallel() + + empty, err := parseCheckIgnoreOutput(nil) + if err != nil || len(empty) != 0 { + t.Fatalf("parseCheckIgnoreOutput(nil) = %v, %v", empty, err) + } + + pDev, _ := pathmodel.Parse("config/dev.json") + pNegated, _ := pathmodel.Parse("config/negated.json") + pUnrelated, _ := pathmodel.Parse("src/main.go") + pLiteralBang, _ := pathmodel.Parse("literal/!bang.txt") + + // Standard quadruplet stream: + // 1. .git/info/exclude:1:/config/dev.json -> config/dev.json (ignored) + // 2. .gitignore:2:!config/negated.json -> config/negated.json (negated -> not ignored) + // 3. :::src/main.go -> src/main.go (non-matching -> not ignored) + // 4. .gitignore:4:\!literal/!bang.txt -> literal/!bang.txt (escaped bang -> ignored) + quads := strings.Join([]string{ + ".git/info/exclude\x001\x00/config/dev.json\x00config/dev.json", + ".gitignore\x002\x00!config/negated.json\x00config/negated.json", + "\x00\x00\x00src/main.go", + ".gitignore\x004\x00\\!literal/!bang.txt\x00literal/!bang.txt", + }, "\x00") + "\x00" + + result, err := parseCheckIgnoreOutput([]byte(quads)) + if err != nil { + t.Fatalf("parseCheckIgnoreOutput() error = %v", err) + } + if !result[pDev] { + t.Errorf("expected %v to be excluded", pDev) + } + if result[pNegated] { + t.Errorf("expected %v to NOT be excluded (negation rule)", pNegated) + } + if result[pUnrelated] { + t.Errorf("expected %v to NOT be excluded (non-matching)", pUnrelated) + } + if !result[pLiteralBang] { + t.Errorf("expected %v to be excluded (escaped literal bang)", pLiteralBang) + } + + // Malformed (not a multiple of 4 fields) + if _, err := parseCheckIgnoreOutput([]byte("field1\x00field2\x00")); err == nil { + t.Fatal("expected error for non-quadruplet output") + } + + // Malformed pathname + if _, err := parseCheckIgnoreOutput([]byte("source\x001\x00pat\x00../outside\x00")); err == nil { + t.Fatal("expected error for unusable pathname") + } +} + +func TestBatchExclusionSingleProcessAndZeroMatches(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + runGit(t, root, "init", "-q", "-b", "main") + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + logPath := filepath.Join(root, "git-invocations.log") + t.Setenv("SPAS_PUBLICGIT_PROXY", "1") + t.Setenv("SPAS_PUBLICGIT_REAL_GIT", realGit) + t.Setenv("SPAS_PUBLICGIT_LOG", logPath) + + repository, err := Discover(ctx, gitexec.Runner{Path: os.Args[0]}, root) + if err != nil { + t.Fatal(err) + } + + p1, _ := pathmodel.Parse("spaced dir/dev.json") + p2, _ := pathmodel.Parse("brackets[1].json") + p3, _ := pathmodel.Parse("unicode-é.txt") + p4, _ := pathmodel.Parse("plain.txt") + paths := []pathmodel.Path{p1, p2, p3, p4} + + // 1. Zero ignored paths: git check-ignore exits with 1, which must be handled as data (empty result). + _ = os.Remove(logPath) + excluded, err := repository.ExcludedPaths(ctx, paths) + if err != nil { + t.Fatalf("ExcludedPaths(zero ignored) error = %v", err) + } + if len(excluded) != 0 { + t.Fatalf("ExcludedPaths(zero ignored) = %v, want empty map", excluded) + } + + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + checkIgnoreLines := filterLines(string(logData), "check-ignore") + if len(checkIgnoreLines) != 1 { + t.Fatalf("expected exactly 1 check-ignore invocation, got %d:\n%s", len(checkIgnoreLines), string(logData)) + } + if !strings.Contains(checkIgnoreLines[0], "--no-index") || !strings.Contains(checkIgnoreLines[0], "--stdin") || !strings.Contains(checkIgnoreLines[0], "-z") || !strings.Contains(checkIgnoreLines[0], "--verbose") || !strings.Contains(checkIgnoreLines[0], "--non-matching") { + t.Fatalf("check-ignore invocation missing required flags: %q", checkIgnoreLines[0]) + } + + // 2. Add exclusions in info/exclude and a negation in .gitignore + excludePath, err := repository.InfoExcludePath(ctx) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(excludePath, []byte("/spaced dir/dev.json\n/brackets[1].json\n/unicode-é.txt\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("!brackets[1].json\n"), 0o600); err != nil { + t.Fatal(err) + } + + _ = os.Remove(logPath) + excluded, err = repository.ExcludedPaths(ctx, paths) + if err != nil { + t.Fatalf("ExcludedPaths(with exclusions) error = %v", err) + } + if !excluded[p1] { + t.Errorf("expected %v to be excluded", p1) + } + if excluded[p2] { + t.Errorf("expected %v to NOT be excluded due to .gitignore negation", p2) + } + if !excluded[p3] { + t.Errorf("expected %v to be excluded", p3) + } + if excluded[p4] { + t.Errorf("expected %v to NOT be excluded", p4) + } + + logData, err = os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + checkIgnoreLines = filterLines(string(logData), "check-ignore") + if len(checkIgnoreLines) != 1 { + t.Fatalf("expected exactly 1 check-ignore invocation, got %d:\n%s", len(checkIgnoreLines), string(logData)) + } +} + +func filterLines(content, substr string) []string { + var matched []string + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" && strings.Contains(trimmed, substr) { + matched = append(matched, trimmed) + } + } + return matched +} func TestSwapASCIIcase(t *testing.T) { t.Parallel() @@ -550,7 +711,8 @@ func TestSwapASCIIcase(t *testing.T) { func runGit(t *testing.T, dir string, args ...string) { t.Helper() - if _, err := (gitexec.Runner{}).Run(context.Background(), dir, args...); err != nil { + cmdArgs := append([]string{"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"}, args...) + if _, err := (gitexec.Runner{}).Run(context.Background(), dir, cmdArgs...); err != nil { t.Fatalf("git %v: %v", args, err) } } From 8e470bcc5d688fba4c71ab9d484411faa30bdad7 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:22:32 -0500 Subject: [PATCH 19/52] fix(filesync): match checkout modes and protect recovery copies Create workspace files with Git-style modes filtered by umask while keeping recovery copies owner-only. --- internal/app/regression_test.go | 122 +++++++++++++++++++++++++++++ internal/filesync/filesync.go | 58 +++++++++++--- internal/filesync/filesync_test.go | 115 +++++++++++++++++++++++++++ internal/recovery/recovery.go | 2 +- internal/recovery/recovery_test.go | 120 ++++++++++++++++++++++++++++ 5 files changed, 403 insertions(+), 14 deletions(-) create mode 100644 internal/recovery/recovery_test.go diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index faf51ed..cdf550e 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -812,6 +812,128 @@ func TestExecutableBitSurvivesRoundTrip(t *testing.T) { } } +func TestMaterializePermissionsInheritCheckoutPolicy(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not meaningful on Windows") + } + + controlRoot := t.TempDir() + control := func(name string, mode os.FileMode) os.FileMode { + t.Helper() + file, err := os.OpenFile(filepath.Join(controlRoot, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + t.Fatal(err) + } + info, err := file.Stat() + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + t.Fatal(err) + } + return info.Mode().Perm() + } + wantPlain := control("plain", 0o666) + wantExec := control("tool", 0o777) + if err := os.Mkdir(filepath.Join(controlRoot, "dir"), 0o777); err != nil { + t.Fatal(err) + } + dirInfo, err := os.Stat(filepath.Join(controlRoot, "dir")) + if err != nil { + t.Fatal(err) + } + wantDir := dirInfo.Mode().Perm() + + ctx := context.Background() + instance, publicRoot, root, remote := fixture(t) + + // Teammate commits a plain file and an executable script into subdirectories. + clone := filepath.Join(root, "teammate-clone") + _ = os.RemoveAll(clone) + runGit(t, root, "clone", "-q", remote, clone) + runGit(t, clone, "config", "user.name", "Teammate") + runGit(t, clone, "config", "user.email", "teammate@example.invalid") + if remoteHead := gitOutputAllowFail(t, clone, "rev-parse", "--verify", "-q", "refs/remotes/origin/main"); remoteHead != "" { + runGit(t, clone, "checkout", "-q", "-B", "main", "origin/main") + } else if head := gitOutputAllowFail(t, clone, "rev-parse", "--verify", "-q", "HEAD"); head == "" { + runGit(t, clone, "checkout", "-q", "-b", "main") + } + plainFull := filepath.Join(clone, "config", "plain.json") + if err := os.MkdirAll(filepath.Dir(plainFull), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(plainFull, []byte("{\"k\":\"v\"}\n"), 0o666); err != nil { + t.Fatal(err) + } + execFull := filepath.Join(clone, "bin", "tool.sh") + if err := os.MkdirAll(filepath.Dir(execFull), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(execFull, []byte("#!/bin/sh\nexit 0\n"), 0o777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(execFull, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, clone, "add", "--force", "--", "config/plain.json", "bin/tool.sh") + runGit(t, clone, "commit", "-q", "-m", "teammate plain and exec") + runGit(t, clone, "push", "-q", "origin", "HEAD:main") + + if err := instance.Sync(ctx, syncOptions("sync remote additions")); err != nil { + t.Fatalf("Sync() error = %v", err) + } + + // Verify workspace materialized files and directories. + plainInfo, err := os.Stat(filepath.Join(publicRoot, "config", "plain.json")) + if err != nil { + t.Fatal(err) + } + if got := plainInfo.Mode().Perm(); got != wantPlain { + t.Errorf("materialized plain file mode = %o, want %o", got, wantPlain) + } + + execInfo, err := os.Stat(filepath.Join(publicRoot, "bin", "tool.sh")) + if err != nil { + t.Fatal(err) + } + if got := execInfo.Mode().Perm(); got != wantExec { + t.Errorf("materialized exec file mode = %o, want %o", got, wantExec) + } + + configDirInfo, err := os.Stat(filepath.Join(publicRoot, "config")) + if err != nil { + t.Fatal(err) + } + if got := configDirInfo.Mode().Perm(); got != wantDir { + t.Errorf("materialized config dir mode = %o, want %o", got, wantDir) + } + + binDirInfo, err := os.Stat(filepath.Join(publicRoot, "bin")) + if err != nil { + t.Fatal(err) + } + if got := binDirInfo.Mode().Perm(); got != wantDir { + t.Errorf("materialized bin dir mode = %o, want %o", got, wantDir) + } + + // Verify SPAS data directory state files remain owner-only. + statePath := filepath.Join(instance.Store.DataDir, "links") + _ = filepath.WalkDir(statePath, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return nil + } + info, statErr := os.Stat(path) + if statErr != nil { + return nil + } + if got := info.Mode().Perm(); got&0o077 != 0 { + t.Errorf("SPAS data file %s mode = %o, want no group/other access", path, got) + } + return nil + }) +} + // F6: a sync interrupted between push and materialization must finish // materializing before workspace state is read as local edits, so a // teammate's pushed change is never silently reverted. diff --git a/internal/filesync/filesync.go b/internal/filesync/filesync.go index e29a6b4..1ccede7 100644 --- a/internal/filesync/filesync.go +++ b/internal/filesync/filesync.go @@ -60,9 +60,17 @@ func Executable(path string) (bool, error) { // CopyManaged copies one repository-relative regular file between two roots. // os.Root constrains all source and destination operations to those roots, and // the explicit component checks reject symbolic-link indirection even when a -// link would remain inside the root. +// link would remain inside the root. The destination inherits Git checkout +// permission semantics: maximal modes filtered by the process umask. func CopyManaged(sourceRoot string, source pathmodel.Path, destinationRoot string, destination pathmodel.Path) error { - return copyManaged(sourceRoot, source, destinationRoot, destination, nil) + return copyManaged(sourceRoot, source, destinationRoot, destination, nil, false) +} + +// CopyManagedOwnerOnly copies like CopyManaged but keeps the destination +// owner-private. Recovery copies under the SPAS data directory use it; they +// hold private workspace bytes and never widen beyond the owner. +func CopyManagedOwnerOnly(sourceRoot string, source pathmodel.Path, destinationRoot string, destination pathmodel.Path) error { + return copyManaged(sourceRoot, source, destinationRoot, destination, nil, true) } // ExpectedSnapshot binds a managed workspace mutation to the bytes, existence, @@ -83,7 +91,7 @@ func CopyManagedIfUnchanged( destination pathmodel.Path, expected ExpectedSnapshot, ) error { - return copyManaged(sourceRoot, source, destinationRoot, destination, &expected) + return copyManaged(sourceRoot, source, destinationRoot, destination, &expected, false) } func copyManaged( @@ -92,6 +100,7 @@ func copyManaged( destinationRoot string, destination pathmodel.Path, expected *ExpectedSnapshot, + ownerOnly bool, ) error { inputRoot, err := os.OpenRoot(sourceRoot) if err != nil { @@ -116,12 +125,16 @@ func copyManaged( return fmt.Errorf("open destination root: %w", err) } defer outputRoot.Close() + parentMode := os.FileMode(0o777) + if ownerOnly { + parentMode = 0o700 + } parent := filepath.Dir(filepath.FromSlash(destination.String())) if parent != "." { if err := validateExistingParents(outputRoot, destination); err != nil { return err } - if err := outputRoot.MkdirAll(parent, 0o700); err != nil { + if err := outputRoot.MkdirAll(parent, parentMode); err != nil { return fmt.Errorf("create destination parent for %q: %w", destination, err) } } @@ -138,14 +151,24 @@ func copyManaged( return err } tempName := filepath.Join(ManagedTempDirectory, tempBase) - temp, err := outputRoot.OpenFile(tempName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + // Checkout-policy copies receive their final mode at creation so the + // process umask filters it, exactly as git checkout does; a chmod would + // bypass the umask. Owner-only copies start private and add back the + // executable bit afterward. + createMode := checkoutPermissions(sourceInfo.Mode()) + if ownerOnly { + createMode = 0o600 + } + temp, err := outputRoot.OpenFile(tempName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, createMode) if err != nil { return fmt.Errorf("create temporary destination for %q: %w", destination, err) } defer outputRoot.Remove(tempName) - if err := temp.Chmod(copyPermissions(sourceInfo.Mode())); err != nil { - _ = temp.Close() - return fmt.Errorf("apply mode to %q: %w", destination, err) + if ownerOnly { + if err := temp.Chmod(ownerOnlyPermissions(sourceInfo.Mode())); err != nil { + _ = temp.Close() + return fmt.Errorf("apply mode to %q: %w", destination, err) + } } if _, err := io.Copy(temp, input); err != nil { _ = temp.Close() @@ -414,13 +437,22 @@ func validateRootPath(root *os.Root, path pathmodel.Path, requireRegular bool) e return nil } -// copyPermissions keeps managed copies owner-private while preserving the -// source's executable bit, so scripts survive the round trip through the -// private clone. -func copyPermissions(sourceMode os.FileMode) os.FileMode { - mode := os.FileMode(0o600) +// checkoutPermissions mirrors Git's checkout policy: files are created with +// the maximal mode and the process umask decides group/other access. +func checkoutPermissions(sourceMode os.FileMode) os.FileMode { // Git records a single executable/non-executable distinction. Preserve it // even when the source's only executable bit is group or other. + if sourceMode.Perm()&0o111 != 0 { + return 0o777 + } + return 0o666 +} + +// ownerOnlyPermissions keeps recovery copies owner-private while preserving +// the source's executable bit, so scripts survive the round trip through the +// private clone. +func ownerOnlyPermissions(sourceMode os.FileMode) os.FileMode { + mode := os.FileMode(0o600) if sourceMode.Perm()&0o111 != 0 { mode |= 0o100 } diff --git a/internal/filesync/filesync_test.go b/internal/filesync/filesync_test.go index 98219fa..85fa74d 100644 --- a/internal/filesync/filesync_test.go +++ b/internal/filesync/filesync_test.go @@ -177,6 +177,121 @@ func TestCheckedManagedMutationsHonorAbsentDestination(t *testing.T) { } } +func TestCopyManagedInheritsCheckoutPermissions(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not meaningful on Windows") + } + + sourceRoot := t.TempDir() + destinationRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(sourceRoot, "plain"), []byte("private"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sourceRoot, "tool"), []byte("#!/bin/sh\n"), 0o700); err != nil { + t.Fatal(err) + } + + // Control entries record what the current umask leaves of the maximal + // modes without mutating the process-global umask in a parallel test. + controlRoot := t.TempDir() + control := func(name string, mode os.FileMode) os.FileMode { + t.Helper() + file, err := os.OpenFile(filepath.Join(controlRoot, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + t.Fatal(err) + } + info, err := file.Stat() + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + t.Fatal(err) + } + return info.Mode().Perm() + } + wantPlain := control("plain", 0o666) + wantExec := control("tool", 0o777) + if err := os.Mkdir(filepath.Join(controlRoot, "dir"), 0o777); err != nil { + t.Fatal(err) + } + dirInfo, err := os.Stat(filepath.Join(controlRoot, "dir")) + if err != nil { + t.Fatal(err) + } + wantDir := dirInfo.Mode().Perm() + + if err := CopyManaged(sourceRoot, "plain", destinationRoot, "plain"); err != nil { + t.Fatalf("CopyManaged(plain) error = %v", err) + } + if err := CopyManaged(sourceRoot, "tool", destinationRoot, "nested/tool"); err != nil { + t.Fatalf("CopyManaged(tool) error = %v", err) + } + + for _, test := range []struct { + path string + want os.FileMode + }{ + {"plain", wantPlain}, + {filepath.Join("nested", "tool"), wantExec}, + {"nested", wantDir}, + } { + info, err := os.Stat(filepath.Join(destinationRoot, test.path)) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != test.want { + t.Errorf("%s mode = %o, want %o", test.path, got, test.want) + } + } +} + +func TestCopyManagedOwnerOnlyKeepsCopiesPrivate(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not meaningful on Windows") + } + + sourceRoot := t.TempDir() + destinationRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(sourceRoot, "plain"), []byte("private"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sourceRoot, "tool"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + if err := CopyManagedOwnerOnly(sourceRoot, "plain", destinationRoot, "plain"); err != nil { + t.Fatalf("CopyManagedOwnerOnly(plain) error = %v", err) + } + if err := CopyManagedOwnerOnly(sourceRoot, "tool", destinationRoot, "nested/tool"); err != nil { + t.Fatalf("CopyManagedOwnerOnly(tool) error = %v", err) + } + + for _, test := range []struct { + path string + want os.FileMode + }{ + {"plain", 0o600}, + {filepath.Join("nested", "tool"), 0o700}, + } { + info, err := os.Stat(filepath.Join(destinationRoot, test.path)) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != test.want { + t.Errorf("%s mode = %o, want %o", test.path, got, test.want) + } + } + info, err := os.Stat(filepath.Join(destinationRoot, "nested")) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got&0o077 != 0 { + t.Errorf("nested directory mode = %o, want no group/other access", got) + } +} + func TestCopyManagedCleansRecognizedOrphanedTemporaryFile(t *testing.T) { t.Parallel() diff --git a/internal/recovery/recovery.go b/internal/recovery/recovery.go index 03047d4..af2ebd9 100644 --- a/internal/recovery/recovery.go +++ b/internal/recovery/recovery.go @@ -50,7 +50,7 @@ func (s Store) Save(publicRoot string, path pathmodel.Path) (bool, error) { if err := os.MkdirAll(s.Root, 0o700); err != nil { return false, fmt.Errorf("create recovery store: %w", err) } - if err := filesync.CopyManaged(publicRoot, path, s.Root, path); err != nil { + if err := filesync.CopyManagedOwnerOnly(publicRoot, path, s.Root, path); err != nil { return false, fmt.Errorf("save recovery copy of %q: %w", path, err) } return true, nil diff --git a/internal/recovery/recovery_test.go b/internal/recovery/recovery_test.go new file mode 100644 index 0000000..3e2b094 --- /dev/null +++ b/internal/recovery/recovery_test.go @@ -0,0 +1,120 @@ +package recovery + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/getspas/spas/internal/pathmodel" +) + +func TestStoreSavePreservesOwnerOnlyModes(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + publicRoot := t.TempDir() + + store, err := NewStore(dataDir, "lnk_test") + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + if store.Used() { + t.Fatal("store.Used() = true before any save") + } + + plainRel, err := pathmodel.Parse("config/plain.env") + if err != nil { + t.Fatal(err) + } + execRel, err := pathmodel.Parse("scripts/run.sh") + if err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(filepath.Dir(plainRel.OSPath(publicRoot)), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(plainRel.OSPath(publicRoot), []byte("SECRET=1\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(filepath.Dir(execRel.OSPath(publicRoot)), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(execRel.OSPath(publicRoot), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + saved, err := store.Save(publicRoot, plainRel) + if err != nil || !saved { + t.Fatalf("store.Save(plain) = %v, %v; want true, nil", saved, err) + } + saved, err = store.Save(publicRoot, execRel) + if err != nil || !saved { + t.Fatalf("store.Save(exec) = %v, %v; want true, nil", saved, err) + } + + if !store.Used() { + t.Fatal("store.Used() = false after save") + } + + if runtime.GOOS != "windows" { + plainInfo, err := os.Stat(plainRel.OSPath(store.Root)) + if err != nil { + t.Fatal(err) + } + if got := plainInfo.Mode().Perm(); got != 0o600 { + t.Errorf("plain recovery copy mode = %o, want 0600", got) + } + + execInfo, err := os.Stat(execRel.OSPath(store.Root)) + if err != nil { + t.Fatal(err) + } + if got := execInfo.Mode().Perm(); got != 0o700 { + t.Errorf("exec recovery copy mode = %o, want 0700", got) + } + + dirInfo, err := os.Stat(filepath.Dir(plainRel.OSPath(store.Root))) + if err != nil { + t.Fatal(err) + } + if got := dirInfo.Mode().Perm(); got&0o077 != 0 { + t.Errorf("recovery directory mode = %o, want no group/other access", got) + } + } +} + +func TestStoreSaveIgnoresNonExistentAndNonRegular(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + publicRoot := t.TempDir() + + store, err := NewStore(dataDir, "lnk_test") + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + + missingRel, err := pathmodel.Parse("missing.txt") + if err != nil { + t.Fatal(err) + } + saved, err := store.Save(publicRoot, missingRel) + if err != nil || saved { + t.Fatalf("store.Save(missing) = %v, %v; want false, nil", saved, err) + } + + dirRel, err := pathmodel.Parse("dir") + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dirRel.OSPath(publicRoot), 0o755); err != nil { + t.Fatal(err) + } + saved, err = store.Save(publicRoot, dirRel) + if err != nil || saved { + t.Fatalf("store.Save(dir) = %v, %v; want false, nil", saved, err) + } +} From e92200309f48ce1ca4fb9dbe1cc954a73f86a77f Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 30 Aug 2026 05:36:39 -0500 Subject: [PATCH 20/52] fix(cli): stabilize JSON output and error codes Normalize error codes, emit empty arrays instead of null, and support JSON output for version. --- internal/app/app.go | 43 ++-- internal/app/contract_test.go | 412 ++++++++++++++++++++++++++++++++ internal/app/diagnostics.go | 4 +- internal/app/sync.go | 8 +- internal/cli/root.go | 14 +- internal/cli/root_test.go | 27 ++- internal/collision/collision.go | 2 +- internal/spaserr/spaserr.go | 2 +- 8 files changed, 482 insertions(+), 30 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index d611864..fe84ef3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -439,8 +439,8 @@ func (a App) Remove(ctx context.Context, options RemoveOptions) error { } removeIndex[pathmodel.Canonical(path, ignoreCase)] = index } - var unenrolled []string - var refreshed []string + unenrolled := []string{} + refreshed := []string{} for _, value := range options.Paths { requested, _, err := pathmodel.Resolve(repository.Root, a.PathBase, value) if err != nil { @@ -508,11 +508,11 @@ func (a App) Remove(ctx context.Context, options RemoveOptions) error { } if options.DryRun { return a.write(map[string]any{ - "action": "remove", - "pendingAdds": pathsToStrings(mapPathValues(pendingAdds)), - "pendingRemovals": removePaths, - "refreshed": refreshed, - "unenrolled": unenrolled, + "action": "remove", + "pendingAdds": pathsToStrings(mapPathValues(pendingAdds)), + "pendingRemovals": removePaths, + "refreshedRemovals": refreshed, + "unenrolled": unenrolled, }) } state.PendingAdds = pathsToStrings(mapPathValues(pendingAdds)) @@ -636,18 +636,23 @@ func (a App) Status(ctx context.Context, options StatusOptions) error { return err } status := Status{ - SchemaVersion: JSONSchemaVersion, - Linked: true, - LinkID: state.LinkID, - PublicBranch: branch, - PrivateRepository: state.Private.Repository, - PrivateBranch: state.Private.Branch, - PrivateInitialized: state.Private.Initialized, - PendingAdds: append([]string{}, state.PendingAdds...), - PendingRemovals: state.PendingRemovalPaths(), - ManagedFiles: len(state.ManagedPaths), - PendingRecovery: state.Private.Initialization != nil || state.Materializing != nil || state.ActiveMerge != nil, - MergeProtection: mergeStatus, + SchemaVersion: JSONSchemaVersion, + Linked: true, + LinkID: state.LinkID, + PublicBranch: branch, + PrivateRepository: state.Private.Repository, + PrivateBranch: state.Private.Branch, + PrivateInitialized: state.Private.Initialized, + PendingAdds: append([]string{}, state.PendingAdds...), + PendingRemovals: state.PendingRemovalPaths(), + ManagedFiles: len(state.ManagedPaths), + PendingRecovery: state.Private.Initialization != nil || state.Materializing != nil || state.ActiveMerge != nil, + WorkspaceModified: []string{}, + WorkspaceMissing: []string{}, + PrivateCloneMissing: []string{}, + PathConflicts: []string{}, + ExclusionFailures: []string{}, + MergeProtection: mergeStatus, } if options.ShowPaths { status.PublicWorkspace = state.Public.Root diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index 694ceb2..ca92eab 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "reflect" "runtime" + "sort" "strconv" "strings" "testing" @@ -2042,3 +2043,414 @@ func TestLinuxMacAllowsPathLengthExceedingWindowsLimit(t *testing.T) { t.Fatalf("Remove() error = %v, want nil on non-Windows", err) } } + +func assertJSONContract(t *testing.T, output []byte, wantKeys []string) map[string]any { + t.Helper() + var doc map[string]any + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(output), err) + } + gotKeys := make([]string, 0, len(doc)) + for k := range doc { + gotKeys = append(gotKeys, k) + } + sort.Strings(gotKeys) + sortedWant := make([]string, len(wantKeys)) + copy(sortedWant, wantKeys) + sort.Strings(sortedWant) + if !reflect.DeepEqual(gotKeys, sortedWant) { + t.Fatalf("JSON top-level keys = %v, want %v\npayload = %s", gotKeys, sortedWant, string(output)) + } + if sv, ok := doc["schemaVersion"].(float64); !ok || int(sv) != JSONSchemaVersion { + t.Fatalf("schemaVersion = %v, want %d", doc["schemaVersion"], JSONSchemaVersion) + } + return doc +} + +func assertNoNullArrays(t *testing.T, output []byte, arrayKeys ...string) { + t.Helper() + var doc map[string]any + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(output), err) + } + for _, k := range arrayKeys { + val, exists := doc[k] + if !exists { + continue + } + if val == nil { + t.Fatalf("key %q is null in payload: %s", k, string(output)) + } + if _, ok := val.([]any); !ok { + t.Fatalf("key %q is not an array (%T): %s", k, val, string(output)) + } + } +} + +func TestJSONContractPayloadKeySets(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("LinkDryRun", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + instance, out := testApp(t, publicRoot, root, remote) + instance.JSON = true + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main", DryRun: true}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "action", "branch", "networkAccess", "privateRepository", "publicWorkspace", "schemaVersion", "transport", + }) + }) + + t.Run("LinkSuccess", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + instance, out := testApp(t, publicRoot, root, remote) + instance.JSON = true + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "linked", "networkAccess", "privateRepository", "publicWorkspace", "schemaVersion", + }) + }) + + t.Run("AddDryRun", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + instance, out := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(publicRoot, "dev.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + out.Reset() + instance.JSON = true + if err := instance.Add(ctx, AddOptions{Paths: []string{"dev.json"}, DryRun: true, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "action", "added", "canceledRemovals", "localExcludeWillChange", "mergeProtection", "pendingAdds", "schemaVersion", "skippedTrackedPaths", + }) + assertNoNullArrays(t, out.Bytes(), "added", "canceledRemovals", "pendingAdds", "skippedTrackedPaths") + }) + + t.Run("AddSuccess", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + instance, out := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(publicRoot, "dev.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + out.Reset() + instance.JSON = true + if err := instance.Add(ctx, AddOptions{Paths: []string{"dev.json"}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "added", "canceledRemovals", "pendingSync", "schemaVersion", "skippedTrackedPaths", + }) + assertNoNullArrays(t, out.Bytes(), "added", "canceledRemovals", "skippedTrackedPaths") + }) + + t.Run("RemoveDryRun", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + instance, out := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(publicRoot, "dev.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := instance.Add(ctx, AddOptions{Paths: []string{"dev.json"}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip}); err != nil { + t.Fatal(err) + } + out.Reset() + instance.JSON = true + if err := instance.Remove(ctx, RemoveOptions{Paths: []string{"dev.json"}, DryRun: true}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "action", "pendingAdds", "pendingRemovals", "refreshedRemovals", "schemaVersion", "unenrolled", + }) + assertNoNullArrays(t, out.Bytes(), "pendingAdds", "pendingRemovals", "refreshedRemovals", "unenrolled") + }) + + t.Run("RemoveSuccess", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + instance, out := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(publicRoot, "dev.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := instance.Add(ctx, AddOptions{Paths: []string{"dev.json"}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip}); err != nil { + t.Fatal(err) + } + out.Reset() + instance.JSON = true + if err := instance.Remove(ctx, RemoveOptions{Paths: []string{"dev.json"}}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "pendingRemovals", "pendingSync", "schemaVersion", "unenrolled", + }) + assertNoNullArrays(t, out.Bytes(), "pendingRemovals", "unenrolled") + }) + + t.Run("SyncDryRunUninitialized", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + instance, out := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + out.Reset() + instance.JSON = true + if err := instance.Sync(ctx, SyncOptions{DryRun: true}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "action", "networkRequired", "pendingAdds", "pendingRemovals", "privateInitialized", "schemaVersion", + }) + assertNoNullArrays(t, out.Bytes(), "pendingAdds", "pendingRemovals") + }) + + t.Run("SyncSuccessAndDryRunInitialized", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot, _, instance := initializedApp(t, root) + out := instance.Out.(*bytes.Buffer) + if err := os.WriteFile(filepath.Join(publicRoot, "docs", "ARCHITECTURE.md"), []byte("mod\n"), 0o600); err != nil { + t.Fatal(err) + } + out.Reset() + instance.JSON = true + if err := instance.Sync(ctx, SyncOptions{DryRun: true}); err != nil { + t.Fatal(err) + } + doc := assertJSONContract(t, out.Bytes(), []string{ + "action", "commitApprovalRequired", "commitMessageProvided", "conflicts", "expectedPrivateHead", + "localChanges", "localExcludeWillChange", "mergeProtection", "networkRequired", "pendingAdds", + "pendingRecovery", "pendingRemovals", "privateClean", "privateHead", "privateInitialized", + "privateMergeInProgress", "schemaVersion", + }) + assertNoNullArrays(t, out.Bytes(), "conflicts", "pendingAdds", "pendingRemovals", "localChanges") + localChanges := doc["localChanges"].([]any) + if len(localChanges) == 0 { + t.Fatal("localChanges is empty") + } + entry := localChanges[0].(map[string]any) + if _, hasPath := entry["path"]; !hasPath { + t.Fatalf("entry missing lowercase 'path': %#v", entry) + } + if _, hasStatus := entry["status"]; !hasStatus { + t.Fatalf("entry missing lowercase 'status': %#v", entry) + } + if _, hasUpper := entry["Path"]; hasUpper { + t.Fatalf("entry has PascalCase 'Path': %#v", entry) + } + + out.Reset() + if err := instance.Sync(ctx, SyncOptions{Message: "update architecture"}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "managedFiles", "privateCommitCreated", "publicRemovalsStaged", "schemaVersion", "skippedConflicts", "synchronized", + }) + assertNoNullArrays(t, out.Bytes(), "skippedConflicts", "publicRemovalsStaged") + }) + + t.Run("Status", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + _, _, instance := initializedApp(t, root) + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true + if err := instance.Status(ctx, StatusOptions{}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "actualPrivateHead", "exclusionFailures", "expectedPrivateHead", "linkId", "linked", + "managedFiles", "mergeProtection", "pathConflicts", "pendingAdds", "pendingRecovery", + "pendingRemovals", "privateAhead", "privateBehind", "privateBranch", "privateClean", + "privateCloneMissing", "privateHeadMismatch", "privateInitialized", "privateRepository", + "publicBranch", "schemaVersion", "workspaceMissing", "workspaceModified", + }) + assertNoNullArrays(t, out.Bytes(), + "workspaceModified", "workspaceMissing", "privateCloneMissing", + "pathConflicts", "exclusionFailures", "pendingAdds", "pendingRemovals", + ) + + out.Reset() + if err := instance.Status(ctx, StatusOptions{ShowPaths: true}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "actualPrivateHead", "exclusionFailures", "expectedPrivateHead", "linkId", "linked", + "managedFiles", "mergeProtection", "pathConflicts", "pendingAdds", "pendingRecovery", + "pendingRemovals", "privateAhead", "privateBehind", "privateBranch", "privateClean", + "privateClone", "privateCloneMissing", "privateHeadMismatch", "privateInitialized", + "privateRepository", "publicBranch", "publicWorkspace", "schemaVersion", + "workspaceMissing", "workspaceModified", + }) + }) + + t.Run("Diff", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + _, _, instance := initializedApp(t, root) + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true + if err := instance.Diff(ctx, DiffOptions{}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{"changedPaths", "schemaVersion"}) + assertNoNullArrays(t, out.Bytes(), "changedPaths") + + out.Reset() + if err := instance.Diff(ctx, DiffOptions{Staged: true}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{"schemaVersion", "stagedPaths"}) + assertNoNullArrays(t, out.Bytes(), "stagedPaths") + }) + + t.Run("Doctor", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + _, _, instance := initializedApp(t, root) + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true + if err := instance.Doctor(ctx); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{"checks", "errors", "healthy", "schemaVersion", "warnings"}) + assertNoNullArrays(t, out.Bytes(), "checks") + }) + + t.Run("Unlink", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + _, _, instance := initializedApp(t, root) + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true + if err := instance.Unlink(ctx, UnlinkOptions{}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "keptFiles", "schemaVersion", "unlinked", "workspaceFilesNowVisibleToPublicGit", + }) + assertNoNullArrays(t, out.Bytes(), "workspaceFilesNowVisibleToPublicGit") + }) + + t.Run("SyncAbort", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot, remote, instance := initializedApp(t, root) + localFile := filepath.Join(publicRoot, "docs", "ARCHITECTURE.md") + if err := os.WriteFile(localFile, []byte("local change\n"), 0o600); err != nil { + t.Fatal(err) + } + other := filepath.Join(root, "other-abort") + runGit(t, root, "clone", "-q", remote, other) + runGit(t, other, "config", "user.name", "Other Test") + runGit(t, other, "config", "user.email", "other@example.invalid") + if err := os.WriteFile(filepath.Join(other, "docs", "ARCHITECTURE.md"), []byte("remote change\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, other, "add", "docs/ARCHITECTURE.md") + runGit(t, other, "commit", "-q", "-m", "remote conflict") + runGit(t, other, "push", "-q", "origin", "main") + + err := instance.Sync(ctx, SyncOptions{ + Message: "Local conflicting change", + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeEnable, + }) + if !errors.Is(err, ErrPrivateMergeConflict) { + t.Fatalf("Sync() error = %v, want ErrPrivateMergeConflict", err) + } + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true + if err := instance.Sync(ctx, SyncOptions{Abort: true}); err != nil { + t.Fatalf("Sync(abort) error = %v", err) + } + assertJSONContract(t, out.Bytes(), []string{ + "deferredPaths", "mergeAborted", "recoveryCopies", "schemaVersion", "skippedPublicPaths", + }) + assertNoNullArrays(t, out.Bytes(), "deferredPaths", "skippedPublicPaths") + }) + + t.Run("SyncContinue", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot, remote, instance := initializedApp(t, root) + localFile := filepath.Join(publicRoot, "docs", "ARCHITECTURE.md") + if err := os.WriteFile(localFile, []byte("local change\n"), 0o600); err != nil { + t.Fatal(err) + } + other := filepath.Join(root, "other-continue") + runGit(t, root, "clone", "-q", remote, other) + runGit(t, other, "config", "user.name", "Other Test") + runGit(t, other, "config", "user.email", "other@example.invalid") + if err := os.WriteFile(filepath.Join(other, "docs", "ARCHITECTURE.md"), []byte("remote change\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, other, "add", "docs/ARCHITECTURE.md") + runGit(t, other, "commit", "-q", "-m", "remote conflict") + runGit(t, other, "push", "-q", "origin", "main") + + err := instance.Sync(ctx, SyncOptions{ + Message: "Local conflicting change", + Conflict: ConflictAbort, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeEnable, + }) + if !errors.Is(err, ErrPrivateMergeConflict) { + t.Fatalf("Sync() error = %v, want ErrPrivateMergeConflict", err) + } + if err := os.WriteFile(localFile, []byte("resolved architecture\n"), 0o600); err != nil { + t.Fatal(err) + } + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true + if err := instance.Sync(ctx, SyncOptions{Continue: true, Message: "resolve conflict"}); err != nil { + t.Fatalf("Sync(continue) error = %v", err) + } + assertJSONContract(t, out.Bytes(), []string{ + "mergeContinued", "schemaVersion", "synchronized", + }) + }) +} diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go index 5515dbe..6a61c5b 100644 --- a/internal/app/diagnostics.go +++ b/internal/app/diagnostics.go @@ -60,7 +60,7 @@ func (a App) Diff(ctx context.Context, options DiffOptions) error { } sort.Strings(managed) - var changed []string + changed := []string{} privateRoot := state.Private.LocalRepositoryPath for _, value := range managed { path, err := pathmodel.Parse(value) @@ -162,7 +162,7 @@ func (a App) diffStaged(ctx context.Context, repository publicgit.Repository, st for _, path := range filters { filterSet[path.String()] = struct{}{} } - var changed []string + changed := []string{} for _, change := range changes { if len(filterSet) > 0 { if _, found := filterSet[change.Path.String()]; !found { diff --git a/internal/app/sync.go b/internal/app/sync.go index d85c3f0..a765096 100644 --- a/internal/app/sync.go +++ b/internal/app/sync.go @@ -58,8 +58,8 @@ type SyncOptions struct { var ErrPrivateMergeConflict = errors.New("private merge conflict") type plannedChange struct { - Path pathmodel.Path - Status string + Path pathmodel.Path `json:"path"` + Status string `json:"status"` } type fileSnapshot struct { @@ -1128,7 +1128,7 @@ func (a App) syncDryRun( "action": "sync", "networkRequired": true, "privateInitialized": false, - "pendingAdds": state.PendingAdds, + "pendingAdds": append([]string{}, state.PendingAdds...), "pendingRemovals": state.PendingRemovalPaths(), }) } @@ -1235,7 +1235,7 @@ func (a App) syncDryRun( "commitApprovalRequired": len(plan.Changes) > 0, "commitMessageProvided": strings.TrimSpace(options.Message) != "", "conflicts": conflicts, - "pendingAdds": state.PendingAdds, + "pendingAdds": append([]string{}, state.PendingAdds...), "pendingRemovals": state.PendingRemovalPaths(), "localExcludeWillChange": excludePlan.Changed, "mergeProtection": mergeStatus, diff --git a/internal/cli/root.go b/internal/cli/root.go index c9841ea..4fccac0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -170,7 +170,7 @@ commit in the project repository.`, newDoctorCommand(options), newUnlinkCommand(options), newCompletionCommand(), - newVersionCommand(), + newVersionCommand(options), ) for _, command := range root.Commands() { if command.Args == nil { @@ -630,12 +630,22 @@ func newCompletionCommand() *cobra.Command { return command } -func newVersionCommand() *cobra.Command { +func newVersionCommand(root *rootOptions) *cobra.Command { return &cobra.Command{ Use: "version", Short: "Show version and build information", Args: cobra.NoArgs, RunE: func(command *cobra.Command, args []string) error { + if root.json { + encoder := json.NewEncoder(command.OutOrStdout()) + encoder.SetEscapeHTML(false) + return encoder.Encode(map[string]any{ + "schemaVersion": app.JSONSchemaVersion, + "version": version.Version, + "commit": version.Commit, + "date": version.Date, + }) + } _, err := fmt.Fprintf(command.OutOrStdout(), "spas %s (commit %s, built %s)\n", version.Version, version.Commit, version.Date) return err }, diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 0070217..d30794a 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -17,6 +17,7 @@ import ( "github.com/getspas/spas/internal/interaction" "github.com/getspas/spas/internal/linkstate" "github.com/getspas/spas/internal/spaserr" + "github.com/getspas/spas/internal/version" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -228,6 +229,30 @@ func TestVersionCommands(t *testing.T) { t.Fatalf("Execute(%v) output = %q", args, output.String()) } } + + for _, args := range [][]string{{"version", "--json"}, {"--json", "version"}} { + var output bytes.Buffer + root := NewRootContext(context.Background(), strings.NewReader(""), &output, &output) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(%v) error = %v", args, err) + } + var payload struct { + SchemaVersion int `json:"schemaVersion"` + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` + } + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", output.String(), err) + } + if payload.SchemaVersion != app.JSONSchemaVersion { + t.Fatalf("payload.SchemaVersion = %d, want %d", payload.SchemaVersion, app.JSONSchemaVersion) + } + if payload.Version != version.Version || payload.Commit != version.Commit || payload.Date != version.Date { + t.Fatalf("payload = %+v, want version=%q commit=%q date=%q", payload, version.Version, version.Commit, version.Date) + } + } } func TestVerboseEmitsSafeDiagnosticsAndJSONSuppressesThem(t *testing.T) { @@ -347,7 +372,7 @@ func TestExitAndErrorCodes(t *testing.T) { {err: interaction.ErrDecisionRequired, exit: 4, errorKey: "decision_required"}, {err: spaserr.Wrap(spaserr.KindPathConflict, errors.New("conflict")), exit: 5, errorKey: "path_conflict"}, {err: app.ErrPrivateMergeConflict, exit: 6, errorKey: "private_merge_conflict"}, - {err: spaserr.Wrap(spaserr.KindAuthNetwork, errors.New("auth")), exit: 7, errorKey: "github_auth_or_network"}, + {err: spaserr.Wrap(spaserr.KindAuthNetwork, errors.New("auth")), exit: 7, errorKey: "auth_or_network"}, {err: spaserr.Wrap(spaserr.KindUnsafeGitState, errors.New("unsafe")), exit: 8, errorKey: "unsafe_git_state"}, {err: spaserr.Wrap(spaserr.KindExclusionValidation, errors.New("exclusion")), exit: 9, errorKey: "exclusion_validation_failed"}, {err: spaserr.Wrap(spaserr.KindLockHeld, errors.New("lock")), exit: 10, errorKey: "lock_held"}, diff --git a/internal/collision/collision.go b/internal/collision/collision.go index 2c75884..05cbcd4 100644 --- a/internal/collision/collision.go +++ b/internal/collision/collision.go @@ -57,7 +57,7 @@ func Detect(public, private []pathmodel.Path, ignoreCase bool) []Collision { return publicEntries[i].canonical < publicEntries[j].canonical }) - var result []Collision + result := []Collision{} for _, privatePath := range private { privateCanonical := pathmodel.Canonical(privatePath, ignoreCase) for _, publicPath := range publicExact[privateCanonical] { diff --git a/internal/spaserr/spaserr.go b/internal/spaserr/spaserr.go index 38a4870..8bd3b65 100644 --- a/internal/spaserr/spaserr.go +++ b/internal/spaserr/spaserr.go @@ -53,7 +53,7 @@ func (k Kind) Code() string { case KindMergeConflict: return "private_merge_conflict" case KindAuthNetwork: - return "github_auth_or_network" + return "auth_or_network" case KindUnsafeGitState: return "unsafe_git_state" case KindExclusionValidation: From 7bdc26579070cd22a7132b682417a14b95964429 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:36:42 -0500 Subject: [PATCH 21/52] ci(release): generate build provenance attestations for release artifacts --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea96941..bcecd7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,8 @@ jobs: timeout-minutes: 30 permissions: contents: write + id-token: write + attestations: write steps: - name: Validate semantic version tag shell: bash @@ -50,3 +52,7 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-checksums: dist/checksums.txt From 918fd8d607c2a11babb7d982a134618f2a1d51d5 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:00:05 -0500 Subject: [PATCH 22/52] fix(json): serialize clean sync changes as an array --- internal/app/contract_test.go | 15 +++++++++++++++ internal/app/sync.go | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index ca92eab..34de190 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -2283,6 +2283,21 @@ func TestJSONContractPayloadKeySets(t *testing.T) { "managedFiles", "privateCommitCreated", "publicRemovalsStaged", "schemaVersion", "skippedConflicts", "synchronized", }) assertNoNullArrays(t, out.Bytes(), "skippedConflicts", "publicRemovalsStaged") + + out.Reset() + if err := instance.Sync(ctx, SyncOptions{DryRun: true}); err != nil { + t.Fatal(err) + } + clean := assertJSONContract(t, out.Bytes(), []string{ + "action", "commitApprovalRequired", "commitMessageProvided", "conflicts", "expectedPrivateHead", + "localChanges", "localExcludeWillChange", "mergeProtection", "networkRequired", "pendingAdds", + "pendingRecovery", "pendingRemovals", "privateClean", "privateHead", "privateInitialized", + "privateMergeInProgress", "schemaVersion", + }) + assertNoNullArrays(t, out.Bytes(), "conflicts", "pendingAdds", "pendingRemovals", "localChanges") + if changes := clean["localChanges"].([]any); len(changes) != 0 { + t.Fatalf("clean dry-run localChanges = %#v, want empty", changes) + } }) t.Run("Status", func(t *testing.T) { diff --git a/internal/app/sync.go b/internal/app/sync.go index a765096..8b43b67 100644 --- a/internal/app/sync.go +++ b/internal/app/sync.go @@ -1231,7 +1231,7 @@ func (a App) syncDryRun( "privateClean": clean, "privateMergeInProgress": mergeInProgress, "pendingRecovery": state.Materializing != nil || state.ActiveMerge != nil, - "localChanges": plan.Changes, + "localChanges": append([]plannedChange{}, plan.Changes...), "commitApprovalRequired": len(plan.Changes) > 0, "commitMessageProvided": strings.TrimSpace(options.Message) != "", "conflicts": conflicts, From 2e0ca141c7c3eacb00e3a6c16b3ac38a07d51fd6 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:00:21 -0500 Subject: [PATCH 23/52] test(git): isolate fixtures from host signing --- internal/mergeprotect/mergeprotect_test.go | 2 ++ internal/privategit/repository_test.go | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/mergeprotect/mergeprotect_test.go b/internal/mergeprotect/mergeprotect_test.go index 7075a36..502ec03 100644 --- a/internal/mergeprotect/mergeprotect_test.go +++ b/internal/mergeprotect/mergeprotect_test.go @@ -131,6 +131,8 @@ func testRepository(t *testing.T) publicgit.Repository { runGit(t, root, "init", "-q", "-b", "main") runGit(t, root, "config", "user.name", "SPAS Test") runGit(t, root, "config", "user.email", "spas@example.invalid") + runGit(t, root, "config", "commit.gpgsign", "false") + runGit(t, root, "config", "tag.gpgsign", "false") runGit(t, root, "commit", "--allow-empty", "-q", "-m", "initial") repository, err := publicgit.Discover(context.Background(), gitexec.Runner{}, root) if err != nil { diff --git a/internal/privategit/repository_test.go b/internal/privategit/repository_test.go index b5f3405..b3c73b2 100644 --- a/internal/privategit/repository_test.go +++ b/internal/privategit/repository_test.go @@ -935,7 +935,8 @@ func TestStageTreatsSpecialCharactersAsLiteralPaths(t *testing.T) { func runGit(t *testing.T, dir string, args ...string) { t.Helper() - if _, err := (gitexec.Runner{}).Run(context.Background(), dir, args...); err != nil { + cmdArgs := append([]string{"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"}, args...) + if _, err := (gitexec.Runner{}).Run(context.Background(), dir, cmdArgs...); err != nil { t.Fatalf("git %v: %v", args, err) } } From 7b7bf3a9b2116f1055a05c41faafd54e5bc3bd0c Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:20:36 -0500 Subject: [PATCH 24/52] docs: document JSON, release, and security policies Align the README and wiki with the JSON contract, audit follow-ups, and checksum-plus-attestation releases. --- README.md | 6 +- wiki/Command-reference.md | 11 +- wiki/Installation.md | 23 +++- wiki/JSON-output-schema.md | 224 +++++++++++++++++++++++++++------ wiki/Quick-start.md | 2 +- wiki/Safety-and-limitations.md | 4 +- wiki/Troubleshooting.md | 4 +- 7 files changed, 225 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 77c6b2e..02b6221 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ SPAS (pronounced **"/spæz/"**) seamlessly connects your local workspace to a se --- -Every project relies on files that don't belong in the public or shared Git repository: local `.env` secrets, developer overrides, test fixtures, API mocks, and internal team notes. +Every project relies on files that don't belong in the public or shared Git repository: local `.env` files, developer overrides, test fixtures, API mocks, and internal team notes. -Moving these files elsewhere breaks build paths. Copying them manually across machines is slow and error-prone. Committing them risks secret leaks and repo bloat. +Moving these files elsewhere breaks build paths. Copying them manually across machines is slow and error-prone. Committing them exposes them to every collaborator and bloats history. **SPAS solves this by bridging the gap:** @@ -114,7 +114,7 @@ spas sync ### 3. Workflow Summary -1. **`spas link`** — Connects your project workspace to the dedicated asset repository (offline). +1. **`spas link`** — Connects your project workspace to the dedicated asset repository and warns when that repository is publicly readable. 2. **`spas add`** — Tracks chosen files and creates local exclusion rules in `.git/info/exclude` (offline). 3. **`spas sync`** — Validates, commits, merges, and synchronizes assets with GitHub. diff --git a/wiki/Command-reference.md b/wiki/Command-reference.md index 4df4ba8..4dc24dc 100644 --- a/wiki/Command-reference.md +++ b/wiki/Command-reference.md @@ -33,7 +33,7 @@ Establish a local association between your project workspace and a linked GitHub spas link [OWNER/REPOSITORY | GITHUB-URL] [flags] ``` -`spas link` validates the workspace worktree structure and writes local link state without cloning, fetching, or editing workspace files. It verifies repository visibility using an anonymous probe and prompts for confirmation if the repository is publicly readable. +`spas link` validates the workspace worktree structure and writes local link state without cloning, fetching, or editing workspace files. It verifies repository visibility using an anonymous probe and prompts for confirmation if the repository is publicly readable. In non-interactive mode, a publicly readable repository fails with exit code `4` (`decision_required`) unless `--allow-public` is provided; `--dry-run` skips the probe entirely. Owner and repository names are case-insensitive and canonicalized to lowercase. | Option | Values | Default | Description | | :--- | :--- | :--- | :--- | @@ -41,7 +41,7 @@ spas link [OWNER/REPOSITORY | GITHUB-URL] [flags] | `--branch` | String | *Auto* | Target branch in the linked repository (required for empty repositories) | | `--replace` | Flag | `false` | Replace an unused, pristine link association without deleting its clone | | `--dry-run` | Flag | `false` | Validate arguments and display proposed link settings without saving | -| `--allow-public` | Flag | `false` | Allow linking a publicly readable repository without confirmation | +| `--allow-public` | Flag | `false` | Allow linking a publicly readable repository without confirmation (approval is recorded in link state; later syncs skip the probe) | ### Link Examples @@ -150,7 +150,7 @@ SPAS never creates commits in your project repository. | `--continue` | Flag | `false` | Continue a merge in the linked repository after resolving conflicts | | `--abort` | Flag | `false` | Abort an active merge and restore the pre-merge workspace state | | `--dry-run` | Flag | `false` | Read-only simulation without taking mutation locks or making network calls | -| `--allow-public` | Flag | `false` | Allow syncing to a publicly readable repository without confirmation | +| `--allow-public` | Flag | `false` | Allow syncing to a publicly readable repository without confirmation (approval is recorded in link state; later syncs skip the probe) | ### Sync Examples @@ -225,6 +225,7 @@ spas doctor [flags] - When run with `--json`, `spas doctor` outputs a single diagnostic JSON object to stdout. - Returns exit code `0` when healthy, or nonzero when issues require attention. +- Outside a Git repository, or in a repository that is not linked, `doctor` runs the environment checks it can (Git version, data directories, advisory locking, and — inside a repository — worktree shape) and exits `0` with a warning notice (`workspace` when not in a Git repository, or `link-state` when unlinked) explaining that link checks were skipped. --- @@ -308,9 +309,11 @@ Errors are returned as structured JSON objects with `schemaVersion`: | `4` | `decision_required` | Required decision missing in non-interactive mode (e.g. `--message` or `--conflict`). | | `5` | `path_conflict` | Path collision with a file tracked by the main project Git repository. | | `6` | `private_merge_conflict` | Merge conflict in the linked repository. Resolve conflicts, then run `spas sync --continue`. | -| `7` | `github_auth_or_network` | Git authentication or network failure when contacting GitHub. | +| `7` | `auth_or_network` | Git authentication or network failure when contacting remote provider. | | `8` | `unsafe_git_state` | Unsafe Git state detected (detached HEAD, uncommitted project merge, multiple worktrees). | | `9` | `exclusion_validation_failed` | `.git/info/exclude` does not match SPAS state or tracked `.gitignore` conflicts. | | `10` | `lock_held` | Another SPAS process is holding the link advisory lock. | | `11` | `unsupported_path` | Path is not a regular file (symlinks, junctions, control characters, or invalid encodings). | | `130` | `interrupted` | Execution cancelled by user interrupt (Ctrl+C / SIGINT). | + +When `--timeout` expires, the interrupted operation fails with exit code `1` (`operation_failed`); exit code `130` remains reserved for user signals. diff --git a/wiki/Installation.md b/wiki/Installation.md index efaeaf1..a56e60a 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -32,7 +32,24 @@ git --version --- -## 2. Verify Download Integrity +## 2. Verify Release Authenticity and Integrity + +SPAS releases provide both GitHub build provenance attestations and a +`checksums.txt` SHA-256 manifest. Use both checks: the attestation verifies +that the archive was produced by this repository's release workflow, while +the checksum detects corruption and provides a portable digest for other +tooling. + +First, install the [GitHub CLI](https://cli.github.com/) and verify the +downloaded archive's provenance: + +```bash +gh attestation verify spas___. --repo getspas/spas +``` + +Replace the placeholder with the archive you downloaded, for example +`spas_1.0.0_linux_amd64.tar.gz`. Verification must succeed before you extract +or run the binary. Every release includes an official `checksums.txt` file containing SHA-256 digests. Download `checksums.txt` into the same folder as the release archive and verify the integrity: @@ -57,7 +74,9 @@ Get-FileHash .\spas_*_windows_amd64.zip -Algorithm SHA256 Select-String -Path .\checksums.txt -Pattern "windows_amd64" ``` -The output hash must match the value listed in `checksums.txt`. +The output hash must match the value listed in `checksums.txt`. A checksum +match is not a substitute for the provenance check above because the archive +and checksum file are distributed through the same release channel. --- diff --git a/wiki/JSON-output-schema.md b/wiki/JSON-output-schema.md index 89c29a0..3e724e3 100644 --- a/wiki/JSON-output-schema.md +++ b/wiki/JSON-output-schema.md @@ -44,10 +44,18 @@ When any command fails in `--json` mode, SPAS writes a structured error object t | `error.code` | `string` | Stable machine-readable error classification (e.g. `not_linked`, `decision_required`, `path_conflict`). | | `error.message` | `string` | Human-readable explanation of the failure. | +A command that already wrote its structured payload (for example `spas doctor` reporting findings) exits nonzero without emitting a second envelope. + --- ## 3. Command Payload Schemas +Conventions used below: + +- Managed paths are workspace-relative, slash-separated strings. +- Keys marked **conditional** are present only under the stated condition. +- Array-typed fields serialize as empty arrays (`[]`) rather than `null` when empty. + ### `spas link` #### Link Success Payload @@ -58,10 +66,13 @@ When any command fails in `--json` mode, SPAS writes a structured error object t "linked": true, "publicWorkspace": "/path/to/project", "privateRepository": "getspas/private-assets", - "privateBranch": "main" + "networkAccess": true } ``` +- `publicWorkspace` — absolute path of the linked workspace root. +- `networkAccess` — `true` if the visibility probe contacted GitHub during this invocation; `false` when `--allow-public` bypassed the probe. + #### Link Dry-Run Payload (`--dry-run`) ```json @@ -70,10 +81,15 @@ When any command fails in `--json` mode, SPAS writes a structured error object t "action": "link", "publicWorkspace": "/path/to/project", "privateRepository": "getspas/private-assets", - "privateBranch": "main" + "transport": "ssh", + "branch": "main", + "networkAccess": false } ``` +- `branch` is the empty string when no `--branch` was provided (the branch is selected during first sync). +- Dry-run performs no network access; the visibility probe is skipped. + --- ### `spas add` @@ -88,10 +104,16 @@ When any command fails in `--json` mode, SPAS writes a structured error object t "testdata/mock-api.json" ], "canceledRemovals": [], - "skippedTrackedPaths": [] + "skippedTrackedPaths": [], + "pendingSync": true } ``` +- `added` — paths newly enrolled by this command. +- `canceledRemovals` — pending removals cancelled because the path was re-added. +- `skippedTrackedPaths` — paths skipped because public Git tracks them. +- `pendingSync` — `true` while enrolled additions await `spas sync`. + #### Add Dry-Run Payload (`--dry-run`) ```json @@ -105,10 +127,16 @@ When any command fails in `--json` mode, SPAS writes a structured error object t "config/dev.json" ], "canceledRemovals": [], - "skippedTrackedPaths": [] + "skippedTrackedPaths": [], + "localExcludeWillChange": true, + "mergeProtection": "enable" } ``` +- `pendingAdds` — the complete pending-addition set after the command. +- `localExcludeWillChange` — whether the SPAS block in `.git/info/exclude` would be rewritten. +- `mergeProtection` — the resolved merge-protection action (`"enable"` or `"skip"`). + --- ### `spas remove` @@ -121,12 +149,15 @@ When any command fails in `--json` mode, SPAS writes a structured error object t "pendingRemovals": [ "config/dev.json" ], - "pendingSync": true, - "refreshedRemovals": [], - "unenrolled": [] + "pendingSync": true } ``` +Conditional keys: + +- `refreshedRemovals` (array, **conditional**) — already-pending removals whose recorded state this command refreshed; present only when non-empty. +- `unenrolled` (array, **conditional**) — never-synced pending additions that were unenrolled immediately; present only when non-empty. Each such path is also reported on stderr as no longer excluded from public Git. + #### Remove Dry-Run Payload (`--dry-run`) ```json @@ -136,7 +167,9 @@ When any command fails in `--json` mode, SPAS writes a structured error object t "pendingAdds": [], "pendingRemovals": [ "config/dev.json" - ] + ], + "refreshedRemovals": [], + "unenrolled": [] } ``` @@ -153,39 +186,118 @@ When any command fails in `--json` mode, SPAS writes a structured error object t "privateCommitCreated": true, "managedFiles": 2, "skippedConflicts": [], - "publicRemovalsStaged": [], - "deferredAdditions": [], - "deferredRemovals": [], - "recoveryCopies": "/path/to/data/recovery/link-id/op-timestamp" + "publicRemovalsStaged": [] } ``` -#### Sync Dry-Run Payload (`--dry-run`) +Conditional keys: + +- `deferredAdditions` (array, **conditional**) — pending additions whose workspace file is currently missing; enrollment is kept, nothing was staged. Present only when non-empty. +- `deferredRemovals` (array, **conditional**) — pending removals whose workspace file changed after removal was requested; nothing was deleted. Present only when non-empty. +- `recoveryCopies` (string, **conditional**) — absolute directory that received recovery copies during this run. Present only when copies were written. + +#### Sync Continue Payload (`--continue`) ```json { "schemaVersion": 1, - "action": "sync", - "networkRequired": false, - "privateInitialized": true, - "managedFiles": 2, - "pendingAdds": [], - "pendingRemovals": [], - "workspaceModified": [], - "workspaceMissing": [] + "synchronized": true, + "mergeContinued": true } ``` -#### Sync Merge Abort Payload (`--abort`) +- `recoveryCopies` (string, **conditional**) — as in the sync success payload. + +#### Sync Abort Payloads (`--abort`) + +One of three shapes, depending on the recorded recovery state: ```json { "schemaVersion": 1, "mergeAborted": true, + "gitNativeRecovery": true +} +``` + +A Git-native merge without SPAS recovery state was aborted. + +```json +{ + "schemaVersion": 1, + "mergeAborted": false, "mergeRecoveryCleared": true } ``` +SPAS merge recovery state was cleared; `mergeAborted` reports whether a Git merge was also aborted. + +```json +{ + "schemaVersion": 1, + "mergeAborted": true, + "skippedPublicPaths": [], + "deferredPaths": [] +} +``` + +A full abort with workspace restoration. `recoveryCopies` (string, **conditional**) is added when copies were written. + +#### Sync Dry-Run Payload (Uninitialized Clone) + +Emitted when the private clone has not been initialized yet: + +```json +{ + "schemaVersion": 1, + "action": "sync", + "networkRequired": true, + "privateInitialized": false, + "pendingAdds": [ + "config/dev.json" + ], + "pendingRemovals": [] +} +``` + +#### Sync Dry-Run Payload (Initialized Clone) + +```json +{ + "schemaVersion": 1, + "action": "sync", + "networkRequired": false, + "privateInitialized": true, + "privateHead": "e6a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d", + "expectedPrivateHead": "e6a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d", + "privateClean": true, + "privateMergeInProgress": false, + "pendingRecovery": false, + "localChanges": [ + { + "path": "config/dev.json", + "status": "M" + } + ], + "commitApprovalRequired": true, + "commitMessageProvided": false, + "conflicts": [], + "pendingAdds": [], + "pendingRemovals": [], + "localExcludeWillChange": false, + "mergeProtection": { + "branch": "main", + "enabled": true, + "value": "--no-overwrite-ignore", + "present": true + } +} +``` + +- `localChanges` entries have the shape `{"path": "...", "status": "..."}` with status `"A"` (added), `"M"` (modified), or `"D"` (deleted). +- `conflicts` entries have the shape `{"kind": "...", "publicPath": "...", "privatePath": "..."}` where `kind` is one of `tracked_path`, `file_directory`, `case_insensitive_filesystem`, `cross_platform_filesystem`. +- In `mergeProtection`, the keys `value`, `present`, and `ambiguous` are omitted when empty or `false`. + --- ### `spas status` @@ -194,13 +306,11 @@ When any command fails in `--json` mode, SPAS writes a structured error object t { "schemaVersion": 1, "linked": true, - "linkId": "8f9a2b4c", - "publicWorkspace": "/path/to/project", + "linkId": "lnk_3f9a2b4c17d0", "publicBranch": "main", "privateRepository": "getspas/private-assets", "privateBranch": "main", "privateInitialized": true, - "privateClone": "/path/to/checkouts/8f9a2b4c", "pendingAdds": [], "pendingRemovals": [], "managedFiles": 2, @@ -217,17 +327,24 @@ When any command fails in `--json` mode, SPAS writes a structured error object t "pendingRecovery": false, "privateClean": true, "mergeProtection": { - "status": "enabled", - "installed": true + "branch": "main", + "enabled": true, + "value": "--no-overwrite-ignore", + "present": true } } ``` +- `linkId` — the link identity, `lnk_` followed by 12 hexadecimal characters. +- `publicWorkspace` and `privateClone` (strings, **conditional**) — absolute paths, present only with `--show-paths`. +- `publicBranch`, `privateBranch`, `expectedPrivateHead`, `actualPrivateHead`, `privateAhead`, `privateBehind`, and `privateClean` are omitted when unknown — for example before initialization, on a detached HEAD, or when remote-tracking information is unavailable. +- `mergeProtection` has the same shape as in the sync dry-run payload. + --- ### `spas diff` -#### Diff Working Tree +#### Diff Working Tree Payload ```json { @@ -239,7 +356,9 @@ When any command fails in `--json` mode, SPAS writes a structured error object t } ``` -#### Diff Staged (`--staged`) +- `changedPaths` is an empty array (`[]`) when no managed path differs. + +#### Diff Staged Payload (`--staged`) ```json { @@ -250,6 +369,8 @@ When any command fails in `--json` mode, SPAS writes a structured error object t } ``` +- `stagedPaths` is an empty array (`[]`) when nothing is staged. + --- ### `spas doctor` @@ -272,12 +393,17 @@ When any command fails in `--json` mode, SPAS writes a structured error object t { "name": "lock", "status": "ok", - "message": "advisory lock acquired and released successfully" + "message": "advisory file locking is functional" }, { - "name": "exclusions", + "name": "worktrees", "status": "ok", - "message": "managed paths are effectively excluded from public Git" + "message": "single public worktree" + }, + { + "name": "local-exclusions", + "status": "ok", + "message": "2 private path(s) effectively excluded" } ], "warnings": 0, @@ -285,6 +411,11 @@ When any command fails in `--json` mode, SPAS writes a structured error object t } ``` +- `status` is one of `ok`, `warning`, `error`; `healthy` is `false` when any check reports `error`. +- Check inventory: the environment checks `git`, `data-dirs`, and `lock` always run. Outside a Git repository, `workspace` is added with a warning status. Inside a Git repository, `worktrees` is added. In an unlinked workspace, `link-state` is reported with a warning status. In a linked workspace the link checks also run: `link-state`, `pending-recovery`, `case-policy`, `merge-protection`, `pull-mode`, `pending-ownership-transfers`, `path-ownership`, `local-exclusions`, and `exclude-block-integrity`, plus — depending on clone state — `interrupted-private-merge`, `remote-config`, `private-clone`, `expected-private-head`, `unsupported-private-file-types`, or `private-clone-initialization`. +- With `--json`, findings still exit `1` after the payload is written; no separate error envelope follows. +- Outside a Git repository or in an unlinked workspace, the check list records the truncation as a warning (`workspace` or `link-state`) and the command exits `0`. + --- ### `spas unlink` @@ -293,8 +424,31 @@ When any command fails in `--json` mode, SPAS writes a structured error object t { "schemaVersion": 1, "unlinked": true, - "publicWorkspace": "/path/to/project", - "removedFiles": [], - "failedRemovalFiles": [] + "keptFiles": true +} +``` + +Conditional keys: + +- `workspaceFilesNowVisibleToPublicGit` (array, **conditional**) — managed paths whose exclusion rules were removed while the files stayed in the workspace; present only when files were kept and at least one path was affected. +- `privateCloneRemoved` (boolean, **conditional**) — present as `true` only when `--remove-private-clone` completed its cleanup. + +--- + +### `spas version` + +#### Version Success Payload (`--json`) + +```json +{ + "schemaVersion": 1, + "version": "1.0.0", + "commit": "0123456789abcdef0123456789abcdef01234567", + "date": "2026-08-30T00:00:00Z" } ``` + +- `version` — semantic version string or `"dev"`. +- `commit` — Git commit SHA (with optional `-dirty` suffix) or `"unknown"`. +- `date` — build timestamp (RFC 3339) or `"unknown"`. +- Without `--json`, `spas version` prints plain text: `spas VERSION (commit COMMIT, built DATE)`. diff --git a/wiki/Quick-start.md b/wiki/Quick-start.md index cda6c1d..13c342c 100644 --- a/wiki/Quick-start.md +++ b/wiki/Quick-start.md @@ -41,7 +41,7 @@ If your Git environment uses HTTPS authentication: spas link your-org/project-assets --transport https --branch main ``` -`spas link` operates entirely offline. It validates the local Git workspace structure and saves the link state locally without making network calls or modifying workspace files. +`spas link` validates the local Git workspace structure and saves the link state locally without cloning anything or modifying workspace files. By default it also runs one anonymous, credential-free probe against GitHub and asks for confirmation when the repository turns out to be publicly readable; pass `--allow-public` to skip the probe, or `--dry-run` to preview the link with no network access at all. Verify the link status: diff --git a/wiki/Safety-and-limitations.md b/wiki/Safety-and-limitations.md index fc8b7ed..88bb784 100644 --- a/wiki/Safety-and-limitations.md +++ b/wiki/Safety-and-limitations.md @@ -9,7 +9,7 @@ Please review these operational boundaries before integrating SPAS into your wor ## 1. Repository Visibility & Access Control - **Public vs. Private Repositories:** SPAS automatically verifies linked repository visibility using an offline-credential-free probe (`git ls-remote` with credential helpers and prompts disabled). If the linked repository is publicly readable, SPAS requires explicit interactive confirmation or the `--allow-public` CLI flag to prevent accidental exposure of managed assets. Always ensure your repository is configured as **Private** on GitHub before syncing sensitive files. -- **Local Workspace Permissions:** SPAS keeps managed assets untracked in your project repository, but does not alter local filesystem file permissions. Anyone with local read access to your project workspace directory can read the files. +- **Local Workspace Permissions:** SPAS keeps managed assets untracked in your project repository. Files it materializes during sync inherit standard Git checkout semantics — created with maximal modes filtered by your process umask, exactly as `git clone` of the linked repository would produce — and only Git's executable bit is preserved across machines. Recovery copies under the SPAS data directory remain owner-only. Anyone with local read access to your project workspace directory can read the files. - **Git URL Rewrites:** SPAS verifies its recorded origin URL, but respects your system and global Git configuration (including `url.*.insteadOf` and `pushInsteadOf` rewrites). Ensure your global Git configuration points to trusted remotes. --- @@ -46,7 +46,7 @@ Please review these operational boundaries before integrating SPAS into your wor - **Submodules & LFS Pointers:** Git submodules and Git LFS pointer files are not supported. - **Special Git Files:** `.gitignore`, `.gitattributes`, and `.gitmodules` cannot be managed by SPAS. - **Unicode Control & Format Characters:** Control characters and Unicode category `Cf` characters (such as U+200C ZWNJ and U+200D ZWJ) are rejected to prevent homograph and visual spoofing issues. -- **Non-Portable Filenames & Excessive Path Lengths:** Filename components exceeding 255 bytes, total absolute path lengths reaching or exceeding 260 characters (Windows `MAX_PATH`), and files with case-collision risks across Windows, macOS, and Linux are rejected. +- **Non-Portable Filenames & Excessive Path Lengths:** Filename components exceeding 255 bytes and files with case-collision risks across Windows, macOS, and Linux are rejected on all platforms. On Windows, a preflight check rejects paths when either the workspace or the private clone absolute path reaches or exceeds 260 characters (Windows `MAX_PATH`); this check is machine-local, so long roots on another machine cannot be anticipated and may still be rejected by Git or the host filesystem. --- diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index 1625a35..2ac190c 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -49,9 +49,9 @@ spas doctor --json --- -### `github_auth_or_network` (Exit Code 7) +### `auth_or_network` (Exit Code 7) -- **Cause:** Git was unable to authenticate with GitHub or encountered a network timeout. +- **Cause:** Git was unable to authenticate with the remote repository or encountered a network timeout. - **Fix:** - Verify your SSH keys (`ssh -T git@github.com`) or HTTPS credential helper. - Confirm repository permissions for your GitHub user account. From 46b51fbabf84901f4cc2ed49c32b161234ccbc96 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:22:56 -0500 Subject: [PATCH 25/52] docs(link): explain the visibility probe and skip flags --- internal/app/contract_test.go | 2 +- internal/cli/root.go | 10 ++++++---- internal/cli/root_test.go | 2 +- wiki/Command-reference.md | 6 +++--- wiki/JSON-output-schema.md | 2 +- wiki/Quick-start.md | 2 +- wiki/Safety-and-limitations.md | 2 +- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index 34de190..f79acbc 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -47,7 +47,7 @@ func (r *mutateBeforeFinalByteReader) Read(p []byte) (int, error) { return 1, nil } -func TestLinkIsStrictlyLocalAndOffline(t *testing.T) { +func TestLinkDoesNotCloneOrMutateWorkspace(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/cli/root.go b/internal/cli/root.go index 4fccac0..c735e1d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -197,8 +197,10 @@ func newLinkCommand(root *rootOptions) *cobra.Command { Use: "link [OWNER/REPOSITORY | GITHUB-URL]", Short: "Link this project workspace to a GitHub repository", Long: `Create a local association between this project workspace and a linked GitHub -repository. link performs no network request, clone, fetch, file copy, -local-exclude update, or Git configuration change.`, +repository without cloning, fetching, copying files, updating local excludes, +or changing Git configuration. Unless --allow-public or --dry-run is used, +link runs a GitHub visibility probe with credential helpers and prompts disabled +and asks before accepting a publicly readable repository.`, Example: ` # Interactive spas link @@ -253,8 +255,8 @@ local-exclude update, or Git configuration change.`, command.Flags().StringVar(&transport, "transport", "", "Git transport for OWNER/REPOSITORY: https or ssh") command.Flags().StringVar(&branch, "branch", "", "branch in the linked repository; otherwise discover it during first sync") command.Flags().BoolVar(&replace, "replace", false, "replace an existing local link without deleting its managed checkout") - command.Flags().BoolVar(&dryRun, "dry-run", false, "validate and show the link without saving it") - command.Flags().BoolVar(&allowPublic, "allow-public", false, "allow linking a publicly readable repository") + command.Flags().BoolVar(&dryRun, "dry-run", false, "validate and show the link without saving or network access") + command.Flags().BoolVar(&allowPublic, "allow-public", false, "accept public-repository risk and skip the visibility probe") return command } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index d30794a..d43fa7a 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -130,7 +130,7 @@ func TestMutatingCommandHelpExplainsBehaviorAndOneLineUse(t *testing.T) { t.Parallel() tests := map[string][]string{ - "link": {"no network request", "One line", "--non-interactive"}, + "link": {"visibility probe", "--allow-public", "--dry-run", "One line", "--non-interactive"}, "add": {"local exclude file", "One line", "--non-interactive"}, "remove": {"does not", "--non-interactive"}, "sync": {"never creates a commit in the project repository", "One line", "--non-interactive"}, diff --git a/wiki/Command-reference.md b/wiki/Command-reference.md index 4dc24dc..748ac09 100644 --- a/wiki/Command-reference.md +++ b/wiki/Command-reference.md @@ -33,15 +33,15 @@ Establish a local association between your project workspace and a linked GitHub spas link [OWNER/REPOSITORY | GITHUB-URL] [flags] ``` -`spas link` validates the workspace worktree structure and writes local link state without cloning, fetching, or editing workspace files. It verifies repository visibility using an anonymous probe and prompts for confirmation if the repository is publicly readable. In non-interactive mode, a publicly readable repository fails with exit code `4` (`decision_required`) unless `--allow-public` is provided; `--dry-run` skips the probe entirely. Owner and repository names are case-insensitive and canonicalized to lowercase. +`spas link` validates the workspace worktree structure and writes local link state without cloning, fetching, or editing workspace files. By default it runs a networked visibility probe with Git credential helpers and prompts disabled, then prompts for confirmation if the repository is publicly readable. In non-interactive mode, a publicly readable repository fails with exit code `4` (`decision_required`) unless `--allow-public` is provided; `--allow-public` and `--dry-run` both skip the probe. Owner and repository names are case-insensitive and canonicalized to lowercase. | Option | Values | Default | Description | | :--- | :--- | :--- | :--- | | `--transport` | `https` \| `ssh` | *Prompt* / `https` | Git transport protocol for `OWNER/REPOSITORY` references (defaults to `https` in non-interactive mode) | | `--branch` | String | *Auto* | Target branch in the linked repository (required for empty repositories) | | `--replace` | Flag | `false` | Replace an unused, pristine link association without deleting its clone | -| `--dry-run` | Flag | `false` | Validate arguments and display proposed link settings without saving | -| `--allow-public` | Flag | `false` | Allow linking a publicly readable repository without confirmation (approval is recorded in link state; later syncs skip the probe) | +| `--dry-run` | Flag | `false` | Validate arguments and display proposed link settings without saving or network access | +| `--allow-public` | Flag | `false` | Accept public-repository risk and skip the visibility probe (approval is recorded in link state; later syncs skip the probe) | ### Link Examples diff --git a/wiki/JSON-output-schema.md b/wiki/JSON-output-schema.md index 3e724e3..e362b22 100644 --- a/wiki/JSON-output-schema.md +++ b/wiki/JSON-output-schema.md @@ -71,7 +71,7 @@ Conventions used below: ``` - `publicWorkspace` — absolute path of the linked workspace root. -- `networkAccess` — `true` if the visibility probe contacted GitHub during this invocation; `false` when `--allow-public` bypassed the probe. +- `networkAccess` — `true` if the visibility probe executed during this invocation; `false` when `--allow-public` bypassed the probe. #### Link Dry-Run Payload (`--dry-run`) diff --git a/wiki/Quick-start.md b/wiki/Quick-start.md index 13c342c..9f07394 100644 --- a/wiki/Quick-start.md +++ b/wiki/Quick-start.md @@ -41,7 +41,7 @@ If your Git environment uses HTTPS authentication: spas link your-org/project-assets --transport https --branch main ``` -`spas link` validates the local Git workspace structure and saves the link state locally without cloning anything or modifying workspace files. By default it also runs one anonymous, credential-free probe against GitHub and asks for confirmation when the repository turns out to be publicly readable; pass `--allow-public` to skip the probe, or `--dry-run` to preview the link with no network access at all. +`spas link` validates the local Git workspace structure and saves the link state locally without cloning anything or modifying workspace files. By default it also runs one networked visibility probe with Git credential helpers and prompts disabled, then asks for confirmation when the repository is publicly readable. Pass `--allow-public` to accept that risk and skip the probe, or `--dry-run` to preview the link with no network access. Verify the link status: diff --git a/wiki/Safety-and-limitations.md b/wiki/Safety-and-limitations.md index 88bb784..f029f46 100644 --- a/wiki/Safety-and-limitations.md +++ b/wiki/Safety-and-limitations.md @@ -8,7 +8,7 @@ Please review these operational boundaries before integrating SPAS into your wor ## 1. Repository Visibility & Access Control -- **Public vs. Private Repositories:** SPAS automatically verifies linked repository visibility using an offline-credential-free probe (`git ls-remote` with credential helpers and prompts disabled). If the linked repository is publicly readable, SPAS requires explicit interactive confirmation or the `--allow-public` CLI flag to prevent accidental exposure of managed assets. Always ensure your repository is configured as **Private** on GitHub before syncing sensitive files. +- **Public vs. Private Repositories:** By default, SPAS checks linked repository visibility with a networked `git ls-remote` probe that disables Git credential helpers and prompts. If the linked repository is publicly readable, SPAS requires explicit interactive confirmation or the `--allow-public` CLI flag to prevent accidental exposure of managed assets. `--allow-public` and `--dry-run` skip the probe. Always ensure your repository is configured as **Private** on GitHub before syncing sensitive files. - **Local Workspace Permissions:** SPAS keeps managed assets untracked in your project repository. Files it materializes during sync inherit standard Git checkout semantics — created with maximal modes filtered by your process umask, exactly as `git clone` of the linked repository would produce — and only Git's executable bit is preserved across machines. Recovery copies under the SPAS data directory remain owner-only. Anyone with local read access to your project workspace directory can read the files. - **Git URL Rewrites:** SPAS verifies its recorded origin URL, but respects your system and global Git configuration (including `url.*.insteadOf` and `pushInsteadOf` rewrites). Ensure your global Git configuration points to trusted remotes. From 9fb02ad98a868b2fc8d94c259da6f45abcebafbd Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:23:10 -0500 Subject: [PATCH 26/52] refactor(sync): unify public approval persistence --- internal/app/sync.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/internal/app/sync.go b/internal/app/sync.go index 8b43b67..fbfeafc 100644 --- a/internal/app/sync.go +++ b/internal/app/sync.go @@ -130,12 +130,8 @@ func (a App) Sync(ctx context.Context, options SyncOptions) (returnErr error) { } if !state.Private.PublicApproved && a.Provider != nil { - if options.AllowPublic { - state.Private.PublicApproved = true - if err := a.Store.Save(state); err != nil { - return err - } - } else { + approvedThisRun := options.AllowPublic + if !options.AllowPublic { ref := provider.RepositoryRef{ Provider: state.Private.Provider, Canonical: state.Private.Repository, @@ -159,10 +155,13 @@ func (a App) Sync(ctx context.Context, options SyncOptions) (returnErr error) { if !approved { return fmt.Errorf("syncing to publicly readable repository declined") } - state.Private.PublicApproved = true - if err := a.Store.Save(state); err != nil { - return err - } + approvedThisRun = true + } + } + if approvedThisRun { + state.Private.PublicApproved = true + if err := a.Store.Save(state); err != nil { + return err } } } From 7de0721f4b051f349b1568b931bbaf72a75669c5 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:24:25 -0500 Subject: [PATCH 27/52] test(json): pin payload variants and doctor exit rules --- internal/app/contract_test.go | 56 ++++++++++++++++++++++- internal/app/regression_test.go | 42 ++++++++++++++++++ internal/cli/root_test.go | 78 +++++++++++++++++++++++++++++---- wiki/JSON-output-schema.md | 4 +- 4 files changed, 168 insertions(+), 12 deletions(-) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index f79acbc..fedde8a 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -2220,6 +2220,25 @@ func TestJSONContractPayloadKeySets(t *testing.T) { assertNoNullArrays(t, out.Bytes(), "pendingRemovals", "unenrolled") }) + t.Run("RemoveRefresh", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + _, _, instance := initializedApp(t, root) + if err := instance.Remove(ctx, RemoveOptions{Paths: []string{"docs/ARCHITECTURE.md"}}); err != nil { + t.Fatal(err) + } + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true + if err := instance.Remove(ctx, RemoveOptions{Paths: []string{"docs/ARCHITECTURE.md"}}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "pendingRemovals", "pendingSync", "refreshedRemovals", "schemaVersion", + }) + assertNoNullArrays(t, out.Bytes(), "pendingRemovals", "refreshedRemovals") + }) + t.Run("SyncDryRunUninitialized", func(t *testing.T) { t.Parallel() root := t.TempDir() @@ -2371,7 +2390,26 @@ func TestJSONContractPayloadKeySets(t *testing.T) { assertNoNullArrays(t, out.Bytes(), "checks") }) - t.Run("Unlink", func(t *testing.T) { + t.Run("UnlinkBase", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := filepath.Join(root, "remote.git") + instance, out := testApp(t, publicRoot, root, remote) + if err := instance.Link(ctx, LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatal(err) + } + out.Reset() + instance.JSON = true + if err := instance.Unlink(ctx, UnlinkOptions{}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "keptFiles", "schemaVersion", "unlinked", + }) + }) + + t.Run("UnlinkWithVisibleFiles", func(t *testing.T) { t.Parallel() root := t.TempDir() _, _, instance := initializedApp(t, root) @@ -2387,6 +2425,22 @@ func TestJSONContractPayloadKeySets(t *testing.T) { assertNoNullArrays(t, out.Bytes(), "workspaceFilesNowVisibleToPublicGit") }) + t.Run("UnlinkPrivateCloneCleanup", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + _, _, instance := initializedApp(t, root) + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true + if err := instance.Unlink(ctx, UnlinkOptions{RemovePrivateClone: true}); err != nil { + t.Fatal(err) + } + assertJSONContract(t, out.Bytes(), []string{ + "keptFiles", "privateCloneRemoved", "schemaVersion", "unlinked", "workspaceFilesNowVisibleToPublicGit", + }) + assertNoNullArrays(t, out.Bytes(), "workspaceFilesNowVisibleToPublicGit") + }) + t.Run("SyncAbort", func(t *testing.T) { t.Parallel() root := t.TempDir() diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index cdf550e..a5ffcbb 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -604,9 +604,17 @@ func TestRemoveThenEditDefersTheRemoval(t *testing.T) { if err := os.WriteFile(filepath.Join(publicRoot, ".env"), []byte("TOKEN=brand-new\n"), 0o600); err != nil { t.Fatal(err) } + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true if err := instance.Sync(ctx, syncOptions("attempt removal")); err != nil { t.Fatalf("Sync() error = %v", err) } + assertJSONContract(t, out.Bytes(), []string{ + "deferredRemovals", "managedFiles", "privateCommitCreated", "publicRemovalsStaged", + "schemaVersion", "skippedConflicts", "synchronized", + }) + assertNoNullArrays(t, out.Bytes(), "deferredRemovals", "publicRemovalsStaged", "skippedConflicts") content, err := os.ReadFile(filepath.Join(publicRoot, ".env")) if err != nil || string(content) != "TOKEN=brand-new\n" { @@ -677,9 +685,17 @@ func TestOverrideSavesRecoveryCopies(t *testing.T) { } options := syncOptions("") options.Conflict = ConflictOverride + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true if err := instance.Sync(ctx, options); err != nil { t.Fatalf("Sync(override obstruction) error = %v", err) } + assertJSONContract(t, out.Bytes(), []string{ + "managedFiles", "privateCommitCreated", "publicRemovalsStaged", "recoveryCopies", + "schemaVersion", "skippedConflicts", "synchronized", + }) + assertNoNullArrays(t, out.Bytes(), "publicRemovalsStaged", "skippedConflicts") if content, err := os.ReadFile(obstruction); err != nil || string(content) != "private,rows\n" { t.Fatalf("data/report.csv = %q, %v; want the private version", content, err) } @@ -761,9 +777,17 @@ func TestMissingPendingAddKeepsEnrollmentAndExclusion(t *testing.T) { if err := os.Remove(secret); err != nil { t.Fatal(err) } + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true if err := instance.Sync(ctx, syncOptions("")); err != nil { t.Fatalf("Sync() error = %v", err) } + assertJSONContract(t, out.Bytes(), []string{ + "deferredAdditions", "managedFiles", "privateCommitCreated", "publicRemovalsStaged", + "schemaVersion", "skippedConflicts", "synchronized", + }) + assertNoNullArrays(t, out.Bytes(), "deferredAdditions", "publicRemovalsStaged", "skippedConflicts") block := readExcludeBlock(t, publicRoot) if !strings.Contains(block, "/config/secret.json") { @@ -1201,9 +1225,15 @@ func TestMergeContinuationRetainsApprovedObstructionRecovery(t *testing.T) { if err := os.WriteFile(filepath.Join(publicRoot, "conflict.txt"), []byte("resolved\n"), 0o600); err != nil { t.Fatal(err) } + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true if err := instance.Sync(ctx, SyncOptions{Continue: true, Message: "resolve conflict"}); err != nil { t.Fatalf("Sync(continue) error = %v", err) } + assertJSONContract(t, out.Bytes(), []string{ + "mergeContinued", "recoveryCopies", "schemaVersion", "synchronized", + }) content, err := os.ReadFile(obstructionPath) if err != nil || string(content) != "private replacement\n" { t.Fatalf("materialized obstruction path = %q, %v", content, err) @@ -1310,9 +1340,15 @@ func TestGitNativeAbortRecoversMergeWithoutSPASState(t *testing.T) { if err := instance.Sync(ctx, SyncOptions{Continue: true, Message: "must not continue"}); err == nil || !strings.Contains(err.Error(), "abort") { t.Fatalf("Sync(--continue) error = %v, want abort-required guidance", err) } + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true if err := instance.Sync(ctx, SyncOptions{Abort: true}); err != nil { t.Fatalf("Sync(--abort) error = %v", err) } + assertJSONContract(t, out.Bytes(), []string{ + "gitNativeRecovery", "mergeAborted", "schemaVersion", + }) merging, err := instance.privateRepository(state).MergeInProgress() if err != nil { t.Fatal(err) @@ -1371,9 +1407,15 @@ func TestAbortOnlyMergeRecoveryClearsStateWithoutPanic(t *testing.T) { state.Private.ExpectedHead = preMergeHead saveState(t, instance, state) + out := instance.Out.(*bytes.Buffer) + out.Reset() + instance.JSON = true if err := instance.Sync(ctx, SyncOptions{Abort: true}); err != nil { t.Fatalf("Sync(abort) error = %v", err) } + assertJSONContract(t, out.Bytes(), []string{ + "mergeAborted", "mergeRecoveryCleared", "schemaVersion", + }) reloaded := loadState(t, instance, publicRoot) if reloaded.ActiveMerge != nil { t.Fatalf("abort-only recovery state remains: %#v", reloaded.ActiveMerge) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index d43fa7a..bdbe360 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -237,24 +238,83 @@ func TestVersionCommands(t *testing.T) { if err := root.Execute(); err != nil { t.Fatalf("Execute(%v) error = %v", args, err) } - var payload struct { - SchemaVersion int `json:"schemaVersion"` - Version string `json:"version"` - Commit string `json:"commit"` - Date string `json:"date"` - } + var payload map[string]any if err := json.Unmarshal(output.Bytes(), &payload); err != nil { t.Fatalf("json.Unmarshal(%q) error = %v", output.String(), err) } - if payload.SchemaVersion != app.JSONSchemaVersion { - t.Fatalf("payload.SchemaVersion = %d, want %d", payload.SchemaVersion, app.JSONSchemaVersion) + wantKeys := []string{"schemaVersion", "version", "commit", "date"} + if len(payload) != len(wantKeys) { + t.Fatalf("payload keys = %v, want exactly %v", payload, wantKeys) + } + for _, key := range wantKeys { + if _, ok := payload[key]; !ok { + t.Fatalf("payload keys = %v, missing %q", payload, key) + } + } + if payload["schemaVersion"] != float64(app.JSONSchemaVersion) { + t.Fatalf("schemaVersion = %v, want %d", payload["schemaVersion"], app.JSONSchemaVersion) } - if payload.Version != version.Version || payload.Commit != version.Commit || payload.Date != version.Date { + if payload["version"] != version.Version || payload["commit"] != version.Commit || payload["date"] != version.Date { t.Fatalf("payload = %+v, want version=%q commit=%q date=%q", payload, version.Version, version.Commit, version.Date) } } } +func TestExecuteJSONErrorEnvelopeHasExactKeys(t *testing.T) { + originalArgs := os.Args + originalStderr := os.Stderr + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + os.Args = originalArgs + os.Stderr = originalStderr + _ = reader.Close() + _ = writer.Close() + }) + + os.Args = []string{"spas", "--json", "--timeout", "-1s", "version"} + os.Stderr = writer + if exit := Execute(); exit != 2 { + t.Fatalf("Execute() exit = %d, want 2", exit) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + os.Stderr = originalStderr + payloadBytes, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + + var payload map[string]any + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(payloadBytes), err) + } + if len(payload) != 3 { + t.Fatalf("error envelope = %#v, want exactly schemaVersion, ok, and error", payload) + } + for _, key := range []string{"schemaVersion", "ok", "error"} { + if _, ok := payload[key]; !ok { + t.Fatalf("error envelope = %#v, missing %q", payload, key) + } + } + if payload["schemaVersion"] != float64(app.JSONSchemaVersion) || payload["ok"] != false { + t.Fatalf("error envelope = %#v", payload) + } + errorObject, ok := payload["error"].(map[string]any) + if !ok { + t.Fatalf("error = %#v, want object", payload["error"]) + } + if len(errorObject) != 2 || errorObject["code"] != "invalid_usage" { + t.Fatalf("error = %#v, want exactly code and message", errorObject) + } + if _, ok := errorObject["message"].(string); !ok { + t.Fatalf("error.message = %#v, want string", errorObject["message"]) + } +} + func TestVerboseEmitsSafeDiagnosticsAndJSONSuppressesThem(t *testing.T) { t.Parallel() diff --git a/wiki/JSON-output-schema.md b/wiki/JSON-output-schema.md index e362b22..6dab25d 100644 --- a/wiki/JSON-output-schema.md +++ b/wiki/JSON-output-schema.md @@ -413,8 +413,8 @@ Emitted when the private clone has not been initialized yet: - `status` is one of `ok`, `warning`, `error`; `healthy` is `false` when any check reports `error`. - Check inventory: the environment checks `git`, `data-dirs`, and `lock` always run. Outside a Git repository, `workspace` is added with a warning status. Inside a Git repository, `worktrees` is added. In an unlinked workspace, `link-state` is reported with a warning status. In a linked workspace the link checks also run: `link-state`, `pending-recovery`, `case-policy`, `merge-protection`, `pull-mode`, `pending-ownership-transfers`, `path-ownership`, `local-exclusions`, and `exclude-block-integrity`, plus — depending on clone state — `interrupted-private-merge`, `remote-config`, `private-clone`, `expected-private-head`, `unsupported-private-file-types`, or `private-clone-initialization`. -- With `--json`, findings still exit `1` after the payload is written; no separate error envelope follows. -- Outside a Git repository or in an unlinked workspace, the check list records the truncation as a warning (`workspace` or `link-state`) and the command exits `0`. +- With `--json`, one or more error checks exit `1` after the payload is written; no separate error envelope follows. +- Warnings alone exit `0`, including the `workspace` warning outside a Git repository and the `link-state` warning in an unlinked workspace. --- From 52c7750c8014c6f917a78e9784602e1b838e9f2a Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:24:43 -0500 Subject: [PATCH 28/52] test(permissions): make config state checks non-vacuous --- internal/app/regression_test.go | 135 +++++++++++++++++--------------- 1 file changed, 74 insertions(+), 61 deletions(-) diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index a5ffcbb..32f655e 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -838,36 +838,36 @@ func TestExecutableBitSurvivesRoundTrip(t *testing.T) { func TestMaterializePermissionsInheritCheckoutPolicy(t *testing.T) { t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("POSIX permission bits are not meaningful on Windows") - } - controlRoot := t.TempDir() - control := func(name string, mode os.FileMode) os.FileMode { - t.Helper() - file, err := os.OpenFile(filepath.Join(controlRoot, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - t.Fatal(err) + var wantPlain, wantExec, wantDir os.FileMode + if runtime.GOOS != "windows" { + controlRoot := t.TempDir() + control := func(name string, mode os.FileMode) os.FileMode { + t.Helper() + file, err := os.OpenFile(filepath.Join(controlRoot, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + t.Fatal(err) + } + info, err := file.Stat() + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + t.Fatal(err) + } + return info.Mode().Perm() } - info, err := file.Stat() - if closeErr := file.Close(); err == nil { - err = closeErr + wantPlain = control("plain", 0o666) + wantExec = control("tool", 0o777) + if err := os.Mkdir(filepath.Join(controlRoot, "dir"), 0o777); err != nil { + t.Fatal(err) } + dirInfo, err := os.Stat(filepath.Join(controlRoot, "dir")) if err != nil { t.Fatal(err) } - return info.Mode().Perm() + wantDir = dirInfo.Mode().Perm() } - wantPlain := control("plain", 0o666) - wantExec := control("tool", 0o777) - if err := os.Mkdir(filepath.Join(controlRoot, "dir"), 0o777); err != nil { - t.Fatal(err) - } - dirInfo, err := os.Stat(filepath.Join(controlRoot, "dir")) - if err != nil { - t.Fatal(err) - } - wantDir := dirInfo.Mode().Perm() ctx := context.Background() instance, publicRoot, root, remote := fixture(t) @@ -908,54 +908,67 @@ func TestMaterializePermissionsInheritCheckoutPolicy(t *testing.T) { t.Fatalf("Sync() error = %v", err) } - // Verify workspace materialized files and directories. - plainInfo, err := os.Stat(filepath.Join(publicRoot, "config", "plain.json")) - if err != nil { - t.Fatal(err) - } - if got := plainInfo.Mode().Perm(); got != wantPlain { - t.Errorf("materialized plain file mode = %o, want %o", got, wantPlain) - } + // POSIX materialization modes follow checkout and umask policy. + if runtime.GOOS != "windows" { + plainInfo, err := os.Stat(filepath.Join(publicRoot, "config", "plain.json")) + if err != nil { + t.Fatal(err) + } + if got := plainInfo.Mode().Perm(); got != wantPlain { + t.Errorf("materialized plain file mode = %o, want %o", got, wantPlain) + } - execInfo, err := os.Stat(filepath.Join(publicRoot, "bin", "tool.sh")) - if err != nil { - t.Fatal(err) - } - if got := execInfo.Mode().Perm(); got != wantExec { - t.Errorf("materialized exec file mode = %o, want %o", got, wantExec) - } + execInfo, err := os.Stat(filepath.Join(publicRoot, "bin", "tool.sh")) + if err != nil { + t.Fatal(err) + } + if got := execInfo.Mode().Perm(); got != wantExec { + t.Errorf("materialized exec file mode = %o, want %o", got, wantExec) + } - configDirInfo, err := os.Stat(filepath.Join(publicRoot, "config")) - if err != nil { - t.Fatal(err) - } - if got := configDirInfo.Mode().Perm(); got != wantDir { - t.Errorf("materialized config dir mode = %o, want %o", got, wantDir) - } + configDirInfo, err := os.Stat(filepath.Join(publicRoot, "config")) + if err != nil { + t.Fatal(err) + } + if got := configDirInfo.Mode().Perm(); got != wantDir { + t.Errorf("materialized config dir mode = %o, want %o", got, wantDir) + } - binDirInfo, err := os.Stat(filepath.Join(publicRoot, "bin")) - if err != nil { - t.Fatal(err) - } - if got := binDirInfo.Mode().Perm(); got != wantDir { - t.Errorf("materialized bin dir mode = %o, want %o", got, wantDir) + binDirInfo, err := os.Stat(filepath.Join(publicRoot, "bin")) + if err != nil { + t.Fatal(err) + } + if got := binDirInfo.Mode().Perm(); got != wantDir { + t.Errorf("materialized bin dir mode = %o, want %o", got, wantDir) + } } - // Verify SPAS data directory state files remain owner-only. - statePath := filepath.Join(instance.Store.DataDir, "links") - _ = filepath.WalkDir(statePath, func(path string, entry os.DirEntry, err error) error { + // Verify SPAS configuration state remains present and owner-only. + statePath := filepath.Join(instance.Store.ConfigDir, "links") + stateFiles := 0 + if err := filepath.WalkDir(statePath, func(path string, entry os.DirEntry, err error) error { if err != nil { - return nil + return err } - info, statErr := os.Stat(path) - if statErr != nil { - return nil + info, err := entry.Info() + if err != nil { + return err + } + if !entry.IsDir() && filepath.Ext(path) == ".json" { + stateFiles++ } - if got := info.Mode().Perm(); got&0o077 != 0 { - t.Errorf("SPAS data file %s mode = %o, want no group/other access", path, got) + if runtime.GOOS != "windows" { + if got := info.Mode().Perm(); got&0o077 != 0 { + t.Errorf("SPAS configuration state %s mode = %o, want no group/other access", path, got) + } } return nil - }) + }); err != nil { + t.Fatalf("walk link-state directory: %v", err) + } + if stateFiles == 0 { + t.Fatal("link-state directory contains no state files") + } } // F6: a sync interrupted between push and materialization must finish From 70c03ec832bd1b6e11c330467d01f5862fcaef60 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:24:59 -0500 Subject: [PATCH 29/52] ci(release): replace provenance wrapper with actions/attest --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcecd7c..1a7583a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Attest build provenance - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-checksums: dist/checksums.txt From 9ba68623ab67fde8dc341bb23eb64a579ab50f22 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:12:13 -0500 Subject: [PATCH 30/52] build(go): require Go 1.26.8 --- go.mod | 2 +- wiki/Installation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 646e272..728c4f9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/getspas/spas -go 1.26.5 +go 1.26.8 require ( github.com/spf13/cobra v1.10.2 diff --git a/wiki/Installation.md b/wiki/Installation.md index a56e60a..1acc985 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -82,7 +82,7 @@ and checksum file are distributed through the same release channel. ## 3. Build from Source -If you prefer building from source, ensure you have **Go 1.26.5 or newer** installed: +If you prefer building from source, ensure you have **Go 1.26.8 or newer** installed: ```bash go install -trimpath github.com/getspas/spas@latest From 34edb4f151ab71e59b2ff74166a9fb83cce39266 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:12:43 -0500 Subject: [PATCH 31/52] fix(state): enforce the 10,000-path limit before enrollment --- internal/app/app.go | 7 ++- internal/app/regression_test.go | 27 ++++++++++ internal/linkstate/store.go | 49 +++++++++++++++++- internal/linkstate/store_test.go | 86 ++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 4 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index fe84ef3..43afb03 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -311,6 +311,11 @@ func (a App) Add(ctx context.Context, options AddOptions) error { sort.Slice(additions, func(i, j int) bool { return additions[i] < additions[j] }) sort.Slice(pendingRemoves, func(i, j int) bool { return pendingRemoves[i].Path < pendingRemoves[j].Path }) sort.Slice(cancelledRemovals, func(i, j int) bool { return cancelledRemovals[i] < cancelledRemovals[j] }) + state.PendingAdds = pathsToStrings(additions) + state.PendingRemoves = pendingRemoves + if err := a.Store.Validate(state); err != nil { + return err + } // A tree SPAS will publish must remain checkable on every supported // platform, so portability is judged case-insensitively here regardless // of the local filesystem. @@ -364,8 +369,6 @@ func (a App) Add(ctx context.Context, options AddOptions) error { } } - state.PendingAdds = pathsToStrings(additions) - state.PendingRemoves = pendingRemoves if err := a.Store.Save(state); err != nil { rollbackErr := exclude.Restore(excludePlan) if enabledBranch != "" { diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index 32f655e..7835c78 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -22,6 +22,7 @@ import ( "github.com/getspas/spas/internal/filesync" "github.com/getspas/spas/internal/gitexec" "github.com/getspas/spas/internal/interaction" + "github.com/getspas/spas/internal/limits" "github.com/getspas/spas/internal/linkstate" "github.com/getspas/spas/internal/lock" "github.com/getspas/spas/internal/pathmodel" @@ -498,6 +499,32 @@ func TestAddReportsOnlyNewlyEnrolledPaths(t *testing.T) { } } +func TestAddDryRunRejectsPrivateTreeAboveLimit(t *testing.T) { + t.Parallel() + + ctx := context.Background() + instance, publicRoot, _, _ := fixture(t) + state := loadState(t, instance, publicRoot) + state.ManagedPaths = make([]string, limits.MaxPrivateTreeEntries) + for index := range state.ManagedPaths { + state.ManagedPaths[index] = fmt.Sprintf("managed/%05d.txt", index) + } + saveState(t, instance, state) + + if err := os.WriteFile(filepath.Join(publicRoot, "overflow.txt"), []byte("overflow\n"), 0o600); err != nil { + t.Fatal(err) + } + err := instance.Add(ctx, AddOptions{ + Paths: []string{"overflow.txt"}, + ExistingExclude: ExcludePreserve, + MergeProtection: MergeSkip, + DryRun: true, + }) + if err == nil || !strings.Contains(err.Error(), fmt.Sprint(limits.MaxPrivateTreeEntries)) { + t.Fatalf("Add(dry-run) error = %v, want private-tree limit %d", err, limits.MaxPrivateTreeEntries) + } +} + // F1: an ownership override without a private replacement must refuse rather // than delete the only copy of the file. func TestOverrideRefusesWithoutPrivateReplacement(t *testing.T) { diff --git a/internal/linkstate/store.go b/internal/linkstate/store.go index 19a65e7..f388682 100644 --- a/internal/linkstate/store.go +++ b/internal/linkstate/store.go @@ -14,6 +14,7 @@ import ( "time" "github.com/getspas/spas/internal/atomicfile" + "github.com/getspas/spas/internal/limits" "github.com/getspas/spas/internal/pathmodel" "github.com/getspas/spas/internal/provider" ) @@ -257,7 +258,7 @@ func (s Store) Load(publicRoot, commonDir string) (State, error) { if state.SchemaVersion != SchemaVersion { return State{}, fmt.Errorf("unsupported link-state schema %d", state.SchemaVersion) } - if err := validate(state, s); err != nil { + if err := s.Validate(state); err != nil { return State{}, err } if filepath.Clean(state.Public.Root) != filepath.Clean(publicRoot) || @@ -267,6 +268,11 @@ func (s Store) Load(publicRoot, commonDir string) (State, error) { return state, nil } +// Validate checks whether state can be stored and used by SPAS. +func (s Store) Validate(state State) error { + return validate(state, s) +} + func privatePath(dataDir, linkID, repository string, transport provider.Transport) string { sum := sha256.Sum256([]byte(repository + "\x00" + string(transport))) suffix := hex.EncodeToString(sum[:4]) @@ -277,7 +283,7 @@ func (s Store) Save(state State) error { if state.SchemaVersion != SchemaVersion { return fmt.Errorf("refuse to save unsupported link-state schema %d", state.SchemaVersion) } - if err := validate(state, s); err != nil { + if err := s.Validate(state); err != nil { return err } sort.Strings(state.PendingAdds) @@ -490,6 +496,45 @@ func validate(state State, store Store) error { } } } + if err := validatePrivatePathLimit("managed and pending state", state.ManagedPaths, state.PendingAdds); err != nil { + return err + } + if state.ActiveMerge != nil { + if err := validatePrivatePathLimit( + "active merge state", + state.ActiveMerge.MaterializationPaths, + state.ActiveMerge.RemainingPendingAdds, + ); err != nil { + return err + } + } + if state.Materializing != nil { + if err := validatePrivatePathLimit( + "materialization state", + state.Materializing.FinalPaths, + state.Materializing.RemainingPendingAdds, + ); err != nil { + return err + } + } + return nil +} + +func validatePrivatePathLimit(label string, groups ...[]string) error { + paths := make(map[string]struct{}) + for _, group := range groups { + for _, path := range group { + paths[path] = struct{}{} + } + } + if len(paths) > limits.MaxPrivateTreeEntries { + return fmt.Errorf( + "link state %s contains %d private paths; supported limit is %d", + label, + len(paths), + limits.MaxPrivateTreeEntries, + ) + } return nil } diff --git a/internal/linkstate/store_test.go b/internal/linkstate/store_test.go index 19048bd..909af97 100644 --- a/internal/linkstate/store_test.go +++ b/internal/linkstate/store_test.go @@ -2,6 +2,7 @@ package linkstate import ( "encoding/json" + "fmt" "os" "path/filepath" "slices" @@ -9,6 +10,7 @@ import ( "testing" "github.com/getspas/spas/internal/githubref" + "github.com/getspas/spas/internal/limits" "github.com/getspas/spas/internal/provider" ) @@ -84,6 +86,90 @@ func TestSaveLoad(t *testing.T) { } } +func TestStoreRejectsPrivatePathCountAboveLimit(t *testing.T) { + t.Parallel() + + paths := make([]string, limits.MaxPrivateTreeEntries+1) + for index := range paths { + paths[index] = fmt.Sprintf("managed/%05d.txt", index) + } + newState := func(t *testing.T) (Store, State) { + t.Helper() + root := t.TempDir() + store := Store{ + ConfigDir: filepath.Join(root, "config"), + DataDir: filepath.Join(root, "data"), + } + state := New( + filepath.Join(root, "public"), + filepath.Join(root, "public", ".git"), + testRepositoryRef(), + "main", + store, + ) + state.ManagedPaths = append([]string{}, paths...) + return store, state + } + + t.Run("save", func(t *testing.T) { + store, state := newState(t) + err := store.Save(state) + if err == nil || !strings.Contains(err.Error(), fmt.Sprint(limits.MaxPrivateTreeEntries)) { + t.Fatalf("Save() error = %v, want private-tree limit %d", err, limits.MaxPrivateTreeEntries) + } + }) + + t.Run("load", func(t *testing.T) { + store, state := newState(t) + data, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(store.path(state.LinkID)), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.path(state.LinkID), data, 0o600); err != nil { + t.Fatal(err) + } + _, err = store.Load(state.Public.Root, state.Public.GitCommonDir) + if err == nil || !strings.Contains(err.Error(), fmt.Sprint(limits.MaxPrivateTreeEntries)) { + t.Fatalf("Load() error = %v, want private-tree limit %d", err, limits.MaxPrivateTreeEntries) + } + }) + + t.Run("active merge", func(t *testing.T) { + store, state := newState(t) + state.ManagedPaths = nil + active := validActiveMerge(paths, nil, nil) + state.Private.Initialized = true + state.Private.ExpectedHead = active.PreMergeHead + state.ActiveMerge = &active + err := store.Save(state) + if err == nil || !strings.Contains(err.Error(), "active merge state") { + t.Fatalf("Save() error = %v, want active-merge private-tree limit", err) + } + }) + + t.Run("materialization", func(t *testing.T) { + store, state := newState(t) + state.ManagedPaths = nil + snapshots := make([]WorkspaceSnapshot, len(paths)) + for index, path := range paths { + snapshots[index] = WorkspaceSnapshot{Path: path} + } + state.Materializing = &Materialization{ + Phase: MaterializationPushPending, + ResultPrivateHead: strings.Repeat("a", 40), + FinalPaths: append([]string{}, paths...), + WorkspaceSnapshots: snapshots, + } + err := store.Save(state) + if err == nil || !strings.Contains(err.Error(), "materialization state") { + t.Fatalf("Save() error = %v, want materialization private-tree limit", err) + } + }) +} + func TestSaveLoadPublicApproved(t *testing.T) { t.Parallel() From 1da9cd7821b73e364041b30e0572e634fad61e1c Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:13:06 -0500 Subject: [PATCH 32/52] fix(gitexec): disconnect stdin for non-interactive commands --- internal/gitexec/runner.go | 15 ++++---- internal/gitexec/runner_test.go | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/internal/gitexec/runner.go b/internal/gitexec/runner.go index 836a9c3..3a21296 100644 --- a/internal/gitexec/runner.go +++ b/internal/gitexec/runner.go @@ -98,9 +98,8 @@ func (r Runner) runWithInput(ctx context.Context, dir string, stream bool, input cmd.Env = r.commandEnvironment(os.Environ()) cmd.WaitDelay = limits.GitCommandWaitDelay if r.NonInteractive { - // Terminal prompts and askpass helpers are both disabled so - // authentication fails deterministically instead of blocking on a - // prompt or GUI dialog nobody can answer. + // Disable Git credential prompts and askpass helpers for unattended + // commands. Streaming commands also leave inherited stdin disconnected. cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=") } @@ -111,10 +110,12 @@ func (r Runner) runWithInput(ctx context.Context, dir string, stream bool, input stderr = newTailBuffer(limits.StreamedGitDiagnosticBytes) if input != nil { cmd.Stdin = input - } else if r.Stdin != nil { - cmd.Stdin = r.Stdin - } else { - cmd.Stdin = os.Stdin + } else if !r.NonInteractive { + if r.Stdin != nil { + cmd.Stdin = r.Stdin + } else { + cmd.Stdin = os.Stdin + } } cmd.Stdout = io.MultiWriter(stdout, writerOr(r.Stdout, io.Discard)) cmd.Stderr = io.MultiWriter(stderr, writerOr(r.Stderr, io.Discard)) diff --git a/internal/gitexec/runner_test.go b/internal/gitexec/runner_test.go index 7d95ab1..c6cb814 100644 --- a/internal/gitexec/runner_test.go +++ b/internal/gitexec/runner_test.go @@ -230,6 +230,69 @@ func TestRunStreamingForwardsAllOutputAndRetainsFixedTail(t *testing.T) { } } +func TestNonInteractiveStreamingDoesNotInheritStdin(t *testing.T) { + t.Setenv("SPAS_GITEXEC_HELPER", "copy-stdin") + + var streamed bytes.Buffer + result, err := (Runner{ + Path: os.Args[0], + NonInteractive: true, + Stdin: strings.NewReader("inherited input"), + Stdout: &streamed, + }).RunStreaming( + context.Background(), + t.TempDir(), + "-test.run=^TestGitExecHelperProcess$", + ) + if err != nil { + t.Fatalf("RunStreaming() error = %v", err) + } + if streamed.Len() != 0 || len(result.Stdout) != 0 { + t.Fatalf("RunStreaming() stdout = %q, want no inherited stdin", streamed.String()) + } +} + +func TestInteractiveStreamingUsesConfiguredStdin(t *testing.T) { + t.Setenv("SPAS_GITEXEC_HELPER", "copy-stdin") + + var streamed bytes.Buffer + _, err := (Runner{ + Path: os.Args[0], + Stdin: strings.NewReader("interactive input"), + Stdout: &streamed, + }).RunStreaming( + context.Background(), + t.TempDir(), + "-test.run=^TestGitExecHelperProcess$", + ) + if err != nil { + t.Fatalf("RunStreaming() error = %v", err) + } + if got := streamed.String(); got != "interactive input" { + t.Fatalf("RunStreaming() stdout = %q, want configured stdin", got) + } +} + +func TestNonInteractiveRunInputUsesExplicitInput(t *testing.T) { + t.Setenv("SPAS_GITEXEC_HELPER", "copy-stdin") + + result, err := (Runner{ + Path: os.Args[0], + NonInteractive: true, + }).RunInput( + context.Background(), + t.TempDir(), + strings.NewReader("explicit input"), + "-test.run=^TestGitExecHelperProcess$", + ) + if err != nil { + t.Fatalf("RunInput() error = %v", err) + } + if got := string(result.Stdout); got != "explicit input" { + t.Fatalf("RunInput() stdout = %q, want explicit input", got) + } +} + func TestTailBufferRetainsExactSuffixAcrossWraps(t *testing.T) { t.Parallel() @@ -262,6 +325,9 @@ func TestGitExecHelperProcess(t *testing.T) { case "stream-output": _, _ = os.Stdout.Write(helperStreamOutput()) os.Exit(0) + case "copy-stdin": + _, _ = io.Copy(os.Stdout, os.Stdin) + os.Exit(0) case "overflow-with-inherited-pipes": command := exec.Command(os.Args[0], "-test.run=^TestGitExecHelperProcess$") command.Env = make([]string, 0, len(os.Environ())+1) From 3856b042608fbccae0fe5f48d9ba3dd84942d5b0 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:13:24 -0500 Subject: [PATCH 33/52] ci(release): require full CI and CodeQL before building --- .github/workflows/ci.yml | 3 +-- .github/workflows/codeql.yml | 1 + .github/workflows/release.yml | 20 ++++++++++++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a64b458..4be54e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,8 @@ on: push: branches: - main - tags: - - "v*" pull_request: + workflow_call: workflow_dispatch: permissions: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cb6a626..000b40c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,6 +9,7 @@ on: - main schedule: - cron: "17 4 * * 3" + workflow_call: workflow_dispatch: permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a7583a..c8c555d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,8 +13,24 @@ concurrency: cancel-in-progress: false jobs: + verify: + name: Verify release source + permissions: + contents: read + uses: ./.github/workflows/ci.yml + + codeql: + name: Analyze release source + permissions: + contents: read + security-events: write + uses: ./.github/workflows/codeql.yml + release: name: Build draft release + needs: + - verify + - codeql runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: @@ -40,10 +56,6 @@ jobs: go-version-file: go.mod cache: true cache-dependency-path: go.sum - - name: Verify modules - run: go mod verify - - name: Test tagged source - run: go test ./... - name: Build draft release uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: From 043fdbb788090eb122780d25147224f5c3464ca2 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:21:54 -0500 Subject: [PATCH 34/52] test(app): name regressions by behavior Rename regression tests so their names describe the behavior they protect. --- internal/app/regression_test.go | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index 7835c78..9e79b8c 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -1,7 +1,6 @@ package app -// Regression tests for the defects found during the pre-release review. -// Each test names the finding it locks in. +// Regression coverage for asset ownership, synchronization, and recovery. import ( "bytes" @@ -525,7 +524,7 @@ func TestAddDryRunRejectsPrivateTreeAboveLimit(t *testing.T) { } } -// F1: an ownership override without a private replacement must refuse rather +// An ownership override without a private replacement must refuse rather // than delete the only copy of the file. func TestOverrideRefusesWithoutPrivateReplacement(t *testing.T) { t.Parallel() @@ -571,7 +570,7 @@ func TestOverrideRefusesWithoutPrivateReplacement(t *testing.T) { } } -// F2a: sync --abort must rebuild the exclude block from the paths it actually +// Sync --abort must rebuild the exclude block from the paths it actually // materializes, never from stale link state. func TestAbortKeepsEveryMaterializedPathExcluded(t *testing.T) { t.Parallel() @@ -615,7 +614,7 @@ func TestAbortKeepsEveryMaterializedPathExcluded(t *testing.T) { } } -// F3: an edit made after `spas remove` must defer the removal, not be +// An edit made after `spas remove` must defer the removal, not be // destroyed by it. func TestRemoveThenEditDefersTheRemoval(t *testing.T) { t.Parallel() @@ -692,7 +691,7 @@ func TestRemoveThenExecutableModeChangeDefersTheRemoval(t *testing.T) { } } -// F4: both override forms must save a recovery copy of what they discard. +// Both override forms must save a recovery copy of what they discard. func TestOverrideSavesRecoveryCopies(t *testing.T) { t.Parallel() @@ -776,7 +775,7 @@ func findRecoveryCopy(t *testing.T, dataDir, content string) bool { return found } -// F7: a pending addition whose file is temporarily missing keeps its +// A pending addition whose file is temporarily missing keeps its // enrollment and its exclusion entry. func TestMissingPendingAddKeepsEnrollmentAndExclusion(t *testing.T) { t.Parallel() @@ -826,7 +825,7 @@ func TestMissingPendingAddKeepsEnrollmentAndExclusion(t *testing.T) { } } -// F5: the executable bit survives the round trip through the private clone. +// The executable bit survives the round trip through the private clone. func TestExecutableBitSurvivesRoundTrip(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { @@ -998,7 +997,7 @@ func TestMaterializePermissionsInheritCheckoutPolicy(t *testing.T) { } } -// F6: a sync interrupted between push and materialization must finish +// A sync interrupted between push and materialization must finish // materializing before workspace state is read as local edits, so a // teammate's pushed change is never silently reverted. func TestInterruptedMaterializationResumesBeforeCommitting(t *testing.T) { @@ -2786,7 +2785,7 @@ func TestJSONCommitApprovalFailureWritesNoProse(t *testing.T) { } } -// F23: a case-only conflict with exactly one public and one private spelling +// A case-only conflict with exactly one public and one private spelling // can be overridden: the public spelling is removed (staged, uncommitted) and // the private spelling is materialized. func TestCaseOnlyOverride(t *testing.T) { @@ -2843,7 +2842,7 @@ func TestCaseOnlyOverride(t *testing.T) { } } -// F12: names a public repository may legally track (Windows-reserved, +// Names a public repository may legally track (Windows-reserved, // colon-bearing) must never make SPAS unusable. func TestPublicTrackedNonPortableNamesDoNotBreakCommands(t *testing.T) { t.Parallel() @@ -2862,7 +2861,7 @@ func TestPublicTrackedNonPortableNamesDoNotBreakCommands(t *testing.T) { } } -// F22 (non-interactive form): the tracked-path error explains the required +// The non-interactive tracked-path error explains the required // ownership change without constructing a shell command from the path. func TestAddTrackedPathExplainsOwnershipConflict(t *testing.T) { t.Parallel() @@ -2994,7 +2993,7 @@ func TestAddAndRemoveRevalidateEveryManagedExclusion(t *testing.T) { } } -// F24: a case-only ownership override must survive an unrelated private merge +// A case-only ownership override must survive an unrelated private merge // conflict. Active merge state stores the public spelling, while continuation // derives and materializes the private spelling from the private index. func TestCaseOnlyOverrideSurvivesPrivateMergeContinuation(t *testing.T) { @@ -3068,7 +3067,7 @@ func TestCaseOnlyOverrideSurvivesPrivateMergeContinuation(t *testing.T) { } } -// F27: if the developer explicitly commits the public ownership removal while +// If the developer explicitly commits the public ownership removal while // resolving an unrelated private merge, continuation must not run git rm on a // path the public index no longer owns. The approved private replacement is // still materialized and excluded locally. @@ -3180,7 +3179,7 @@ func TestOverrideContinuationAcceptsUnchangedApprovedDirtyStatus(t *testing.T) { } } -// F25: recovery state and Git merge metadata must agree. If somebody cleans or +// Recovery state and Git merge metadata must agree. If somebody cleans or // aborts the SPAS-managed private merge out of band, normal sync must not read // conflict-marker workspace files as fresh private edits. func TestSyncRejectsActiveMergeStateWithoutGitMerge(t *testing.T) { @@ -3200,7 +3199,7 @@ func TestSyncRejectsActiveMergeStateWithoutGitMerge(t *testing.T) { } } -// F26: private merge-conflict files copied into the public workspace are part +// Private merge-conflict files copied into the public workspace are part // of unlink's removal/reporting set even when the remote introduced them and // they never reached ManagedPaths. func TestUnlinkWorkspacePathsIncludesActiveMergeConflicts(t *testing.T) { From 0fb10c31efa9708a86f6c13649c55df2a7f43086 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:22:06 -0500 Subject: [PATCH 35/52] fix(add): reject ambiguous Unicode file selections --- internal/app/app.go | 12 +- internal/app/enrollment_test.go | 199 ++++++++++++++++++++++++++++++++ internal/pathmodel/nfc.go | 59 ++++++++++ 3 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 internal/app/enrollment_test.go create mode 100644 internal/pathmodel/nfc.go diff --git a/internal/app/app.go b/internal/app/app.go index 43afb03..58bd675 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1045,6 +1045,9 @@ func (a App) expandPaths(workspaceRoot, privateRoot string, values []string) ([] if err != nil { return nil, fmt.Errorf("inspect %q: %w", value, err) } + if err := pathmodel.ValidateNFCSpelling(workspaceRoot, absolute); err != nil { + return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } if info.Mode().IsRegular() { if err := pathmodel.ValidatePathLength(workspaceRoot, path); err != nil { return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, err) @@ -1058,10 +1061,6 @@ func (a App) expandPaths(workspaceRoot, privateRoot string, values []string) ([] if err := pathmodel.ValidateNoSymlinkComponents(workspaceRoot, path); err != nil { return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, err) } - if _, statErr := os.Lstat(path.OSPath(workspaceRoot)); statErr != nil { - return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf( - "%q: the on-disk name does not match its Unicode NFC form and cannot be enrolled portably; rename the file to its NFC spelling", value)) - } set[path.String()] = path continue } @@ -1104,9 +1103,8 @@ func (a App) expandPaths(workspaceRoot, privateRoot string, values []string) ([] if err := privategit.ValidateManagedPath(managed); err != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, err) } - if _, statErr := os.Lstat(managed.OSPath(workspaceRoot)); statErr != nil { - return spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf( - "%q: the on-disk name does not match its Unicode NFC form and cannot be enrolled portably; rename the file to its NFC spelling", current)) + if err := pathmodel.ValidateNFCSpelling(workspaceRoot, current); err != nil { + return spaserr.Wrap(spaserr.KindUnsupportedPath, err) } set[managed.String()] = managed return nil diff --git a/internal/app/enrollment_test.go b/internal/app/enrollment_test.go new file mode 100644 index 0000000..4e168bb --- /dev/null +++ b/internal/app/enrollment_test.go @@ -0,0 +1,199 @@ +package app + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/getspas/spas/internal/gitexec" + "github.com/getspas/spas/internal/spaserr" +) + +func TestAddRejectsDistinctUnicodeEntries(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + directory bool + hardLink bool + parent bool + }{ + {name: "selected file"}, + {name: "directory", directory: true}, + {name: "hard links", hardLink: true}, + {name: "directory hard links", directory: true, hardLink: true}, + {name: "parent directories", parent: true}, + {name: "recursive parent directories", directory: true, parent: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + instance, publicRoot, _, _ := fixture(t) + assets := filepath.Join(publicRoot, "assets") + if err := os.Mkdir(assets, 0o700); err != nil { + t.Fatal(err) + } + raw := filepath.Join(assets, "re\u0301sume\u0301.txt") + nfc := filepath.Join(assets, "r\u00e9sum\u00e9.txt") + if test.parent { + rawParent := filepath.Join(assets, "cafe\u0301") + nfcParent := filepath.Join(assets, "caf\u00e9") + if err := os.Mkdir(rawParent, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(nfcParent, 0o700); err != nil { + if errors.Is(err, os.ErrExist) { + t.Skip("volume aliases Unicode normalization variants") + } + t.Fatal(err) + } + raw = filepath.Join(rawParent, "secret.txt") + nfc = filepath.Join(nfcParent, "secret.txt") + } + if err := os.WriteFile(raw, []byte("selected secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if test.hardLink { + if err := os.Link(raw, nfc); err != nil { + if errors.Is(err, os.ErrExist) { + t.Skip("volume aliases Unicode normalization variants") + } + t.Fatal(err) + } + } else { + file, err := os.OpenFile(nfc, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + if errors.Is(err, os.ErrExist) { + t.Skip("volume aliases Unicode normalization variants") + } + t.Fatal(err) + } + if _, err := file.WriteString("other contents\n"); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + } + rawInfo, err := os.Stat(raw) + if err != nil { + t.Fatal(err) + } + nfcInfo, err := os.Stat(nfc) + if err != nil { + t.Fatal(err) + } + if os.SameFile(rawInfo, nfcInfo) != test.hardLink { + t.Fatal("fixture file identities do not match the test case") + } + // An existing enrollment must survive a rejected second selection. + if err := os.WriteFile(filepath.Join(publicRoot, "keep.txt"), []byte("keep\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := instance.Add(context.Background(), AddOptions{ + Paths: []string{"keep.txt"}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip, + }); err != nil { + t.Fatal(err) + } + state := loadState(t, instance, publicRoot) + unchanged := []string{ + filepath.Join(instance.Store.ConfigDir, "links", state.LinkID+".json"), + filepath.Join(publicRoot, ".git", "info", "exclude"), + filepath.Join(publicRoot, ".git", "config"), + raw, nfc, + } + before := make(map[string][]byte) + for _, path := range unchanged { + before[path], err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + } + selection := raw + if test.directory { + selection = assets + } + err = instance.Add(context.Background(), AddOptions{ + Paths: []string{selection}, ExistingExclude: ExcludePreserve, MergeProtection: MergeEnable, + }) + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("Add() error = %v, want unsupported_path for distinct Unicode entries", err) + } + for _, path := range unchanged { + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before[path], after) { + t.Errorf("rejected Add changed %s", path) + } + } + }) + } +} + +func TestAddUnicodeSingleEntry(t *testing.T) { + t.Parallel() + + for _, selection := range []string{"file", "directory", "case alias"} { + t.Run(selection, func(t *testing.T) { + t.Parallel() + instance, publicRoot, _, _ := fixture(t) + raw := filepath.Join(publicRoot, "cafe\u0301", "re\u0301sume\u0301.txt") + nfc := filepath.Join(publicRoot, "caf\u00e9", "r\u00e9sum\u00e9.txt") + if err := os.MkdirAll(filepath.Dir(raw), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(raw, []byte("single entry\n"), 0o600); err != nil { + t.Fatal(err) + } + _, aliasErr := os.Stat(nfc) + if aliasErr != nil && !errors.Is(aliasErr, os.ErrNotExist) { + t.Fatal(aliasErr) + } + selected := raw + if selection == "directory" { + selected = filepath.Dir(raw) + } else if selection == "case alias" { + selected = filepath.Join(publicRoot, "CAFE\u0301", "RE\u0301SUME\u0301.TXT") + if _, err := os.Stat(selected); err != nil { + if errors.Is(err, os.ErrNotExist) { + t.Skip("volume distinguishes differently cased names") + } + t.Fatal(err) + } + } + err := instance.Add(context.Background(), AddOptions{ + Paths: []string{selected}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip, + }) + if aliasErr != nil { + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("Add() = %v, want unsupported_path on a normalization-sensitive volume", err) + } + return + } + if err != nil { + t.Fatalf("Add() rejected a genuine Unicode alias: %v", err) + } + state := loadState(t, instance, publicRoot) + want := "caf\u00e9/r\u00e9sum\u00e9.txt" + if selection == "case alias" { + want = "CAF\u00c9/R\u00c9SUM\u00c9.TXT" + } + if len(state.PendingAdds) != 1 || state.PendingAdds[0] != want { + t.Fatalf("PendingAdds = %q, want canonical spelling", state.PendingAdds) + } + result, err := (gitexec.Runner{}).Run(context.Background(), publicRoot, + "ls-files", "--others", "--exclude-standard", "-z") + if err != nil { + t.Fatal(err) + } + if len(result.Stdout) != 0 { + t.Fatalf("selected file remains visible to Git: %q", result.Stdout) + } + }) + } +} diff --git a/internal/pathmodel/nfc.go b/internal/pathmodel/nfc.go new file mode 100644 index 0000000..dab15e5 --- /dev/null +++ b/internal/pathmodel/nfc.go @@ -0,0 +1,59 @@ +package pathmodel + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/text/unicode/norm" +) + +// ValidateNFCSpelling checks that normalizing an observed path preserves each +// selected directory entry. Filesystems may alias Unicode spellings; distinct +// entries, including hard links, require distinct exclusion rules. +func ValidateNFCSpelling(root, observed string) error { + relative, err := filepath.Rel(root, observed) + if err != nil { + return err + } + raw := Path(filepath.ToSlash(relative)) + if err := ValidateNoSymlinkComponents(root, raw); err != nil { + return err + } + parent := root + for _, component := range strings.Split(raw.String(), "/") { + canonical := norm.NFC.String(component) + if component != canonical { + selected, err := os.Lstat(filepath.Join(parent, component)) + if err != nil { + return err + } + normalized, err := os.Lstat(filepath.Join(parent, canonical)) + if err != nil { + return fmt.Errorf("%q cannot be addressed by its Unicode NFC spelling %q: %w", observed, canonical, err) + } + if !os.SameFile(selected, normalized) { + return fmt.Errorf("%q and its Unicode NFC spelling %q select different entries", observed, canonical) + } + entries, err := os.ReadDir(parent) + if err != nil { + return err + } + matches := 0 + key := Canonical(Path(canonical), true) + for _, entry := range entries { + // Apply the portable case policy to names returned by the + // filesystem, which may use a different case from the request. + if Canonical(Path(entry.Name()), true) == key { + matches++ + } + } + if matches != 1 { + return fmt.Errorf("%q has ambiguous directory entries for Unicode NFC spelling %q", observed, canonical) + } + } + parent = filepath.Join(parent, component) + } + return nil +} From 61d4000127338e654e5e4ea46de1c38e16abeb89 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:22:14 -0500 Subject: [PATCH 36/52] fix(diff): show additions and report I/O failures --- internal/app/contract_test.go | 10 +++ internal/app/diagnostics.go | 74 +++++++++++------- internal/app/diff_test.go | 140 ++++++++++++++++++++++++++++++++++ internal/app/main_test.go | 6 ++ 4 files changed, 203 insertions(+), 27 deletions(-) create mode 100644 internal/app/diff_test.go diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index fedde8a..69f38b6 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -1969,6 +1969,16 @@ func TestRemoveAndDiffAllowAlreadyEnrolledPathsExceedingLimit(t *testing.T) { t.Fatal(err) } state.ManagedPaths = []string{longPath} + // Exercise argument resolution with an actual old diff operand. Git's + // long-path support is independent of SPAS's enrollment preflight. + runGit(t, publicRoot, "config", "core.longpaths", "true") + privateFile := filepath.Join(state.Private.LocalRepositoryPath, filepath.FromSlash(longPath)) + if err := os.MkdirAll(filepath.Dir(privateFile), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(privateFile, []byte("enrolled\n"), 0o600); err != nil { + t.Fatal(err) + } if err := instance.Store.Save(state); err != nil { t.Fatal(err) } diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go index 6a61c5b..e48e3af 100644 --- a/internal/app/diagnostics.go +++ b/internal/app/diagnostics.go @@ -1,6 +1,7 @@ package app import ( + "bufio" "context" "errors" "fmt" @@ -40,6 +41,8 @@ func (a App) Diff(ctx context.Context, options DiffOptions) error { return a.diffStaged(ctx, repository, state, options) } managed := append(append([]string{}, state.ManagedPaths...), state.PendingAdds...) + managedSet := stringSet(state.ManagedPaths) + pendingAdds := stringSet(state.PendingAdds) pendingRemovals := stringSet(state.PendingRemovalPaths()) if len(options.Paths) > 0 { filter := make(map[string]struct{}) @@ -74,18 +77,8 @@ func (a App) Diff(ctx context.Context, options DiffOptions) error { if options.NameOnly || a.JSON { continue } - args := []string{"--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--no-index"} - if options.Stat { - args = append(args, "--stat") - } - args = append(args, "--", privateFile, os.DevNull) - diffGit := a.Git - diffGit.Stdout = a.Out - _, diffErr := diffGit.RunStreaming(ctx, repository.Root, args...) - if diffErr != nil { - if code, ok := gitexec.ExitCode(diffErr); !ok || code != 1 { - return diffErr - } + if err := a.diffFiles(ctx, repository.Root, privateFile, os.DevNull, options.Stat); err != nil { + return err } continue } @@ -100,9 +93,14 @@ func (a App) Diff(ctx context.Context, options DiffOptions) error { continue } equal, err := filesync.Equal(publicFile, privateFile) - if os.IsNotExist(err) { - equal = false - err = nil + var pathErr *os.PathError + if errors.Is(err, os.ErrNotExist) && errors.As(err, &pathErr) && pathErr.Path == privateFile { + _, adding := pendingAdds[value] + _, alreadyManaged := managedSet[value] + if adding && !alreadyManaged { + privateFile = os.DevNull + err = nil + } } if err != nil { return err @@ -114,18 +112,8 @@ func (a App) Diff(ctx context.Context, options DiffOptions) error { if options.NameOnly || a.JSON { continue } - args := []string{"--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--no-index"} - if options.Stat { - args = append(args, "--stat") - } - args = append(args, "--", privateFile, publicFile) - diffGit := a.Git - diffGit.Stdout = a.Out - _, diffErr := diffGit.RunStreaming(ctx, repository.Root, args...) - if diffErr != nil { - if code, ok := gitexec.ExitCode(diffErr); !ok || code != 1 { - return diffErr - } + if err := a.diffFiles(ctx, repository.Root, privateFile, publicFile, options.Stat); err != nil { + return err } } if a.JSON { @@ -141,6 +129,38 @@ func (a App) Diff(ctx context.Context, options DiffOptions) error { return nil } +func (a App) diffFiles(ctx context.Context, root, oldFile, newFile string, stat bool) error { + args := []string{"--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--no-index"} + if stat { + args = append(args, "--stat") + } + args = append(args, "--", oldFile, newFile) + diffGit := a.Git + output := bufio.NewWriter(a.Out) + diffGit.Stdout = output + var diagnostics *bufio.Writer + if diffGit.Stderr != nil { + diagnostics = bufio.NewWriter(diffGit.Stderr) + diffGit.Stderr = diagnostics + } + result, err := diffGit.RunStreaming(ctx, root, args...) + // Buffered writers retain delivery errors even when os/exec returns the + // process exit status in preference to an output-copy error. + writeErr := output.Flush() + if diagnostics != nil { + writeErr = errors.Join(writeErr, diagnostics.Flush()) + } + if writeErr != nil { + return writeErr + } + // Git also returns 1 when it cannot access an operand before producing a + // diff. A completed single-file patch or stat comparison emits output. + if code, ok := gitexec.ExitCode(err); ok && code == 1 && len(result.Stdout) > 0 { + return nil + } + return err +} + func (a App) diffStaged(ctx context.Context, repository publicgit.Repository, state linkstate.State, options DiffOptions) error { if !state.Private.Initialized { return fmt.Errorf("private repository is not initialized; nothing is staged") diff --git a/internal/app/diff_test.go b/internal/app/diff_test.go new file mode 100644 index 0000000..09bb2e0 --- /dev/null +++ b/internal/app/diff_test.go @@ -0,0 +1,140 @@ +package app + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestDiffShowsPendingAddition(t *testing.T) { + t.Parallel() + publicRoot, _, instance := initializedApp(t, t.TempDir()) + runGit(t, publicRoot, "config", "core.autocrlf", "false") + for name, content := range map[string]string{"new.txt": "new secret\n", "empty.txt": "", "binary.bin": "\x00\x01"} { + if err := os.WriteFile(filepath.Join(publicRoot, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + if err := instance.Add(context.Background(), AddOptions{ + Paths: []string{"new.txt", "empty.txt", "binary.bin"}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip, + }); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + path string + opts DiffOptions + json bool + want string + }{ + {name: "patch", want: "+new secret"}, + {name: "stat", opts: DiffOptions{Stat: true}, want: "1 insertion(+)"}, + {name: "name only", opts: DiffOptions{NameOnly: true}, want: "new.txt"}, + {name: "json", json: true, want: `"changedPaths":["new.txt"]`}, + {name: "empty patch", path: "empty.txt", want: "new file mode"}, + {name: "empty stat", path: "empty.txt", opts: DiffOptions{Stat: true}, want: "empty.txt"}, + {name: "binary patch", path: "binary.bin", want: "Binary files"}, + {name: "binary stat", path: "binary.bin", opts: DiffOptions{Stat: true}, want: "Bin"}, + } { + t.Run(test.name, func(t *testing.T) { + var out, stderr bytes.Buffer + instance.Out, instance.Err, instance.Git.Stderr = &out, &stderr, &stderr + instance.JSON = test.json + path := test.path + if path == "" { + path = "new.txt" + } + test.opts.Paths = []string{path} + if err := instance.Diff(context.Background(), test.opts); err != nil { + t.Fatalf("Diff() error = %v", err) + } + if !strings.Contains(out.String(), test.want) { + t.Errorf("Diff() output = %q, want %q", out.String(), test.want) + } + if stderr.Len() != 0 { + t.Errorf("Diff() stderr = %q", stderr.String()) + } + }) + } +} + +func TestDiffRejectsMissingManagedFile(t *testing.T) { + t.Parallel() + publicRoot, _, instance := initializedApp(t, t.TempDir()) + state := loadState(t, instance, publicRoot) + if err := os.Remove(filepath.Join(state.Private.LocalRepositoryPath, "docs", "ARCHITECTURE.md")); err != nil { + t.Fatal(err) + } + for _, opts := range []DiffOptions{{}, {Stat: true}, {NameOnly: true}} { + if err := instance.Diff(context.Background(), opts); err == nil { + t.Errorf("Diff(%+v) succeeded with a missing managed private file", opts) + } + } +} + +type failedDiffWriter struct { + err error +} + +func (w failedDiffWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestDiffPropagatesOutputFailure(t *testing.T) { + t.Parallel() + publicRoot, _, instance := initializedApp(t, t.TempDir()) + if err := os.WriteFile(filepath.Join(publicRoot, "docs", "ARCHITECTURE.md"), []byte("modified\n"), 0o600); err != nil { + t.Fatal(err) + } + writeErr := errors.New("diff output unavailable") + instance.Out = failedDiffWriter{err: writeErr} + for _, opts := range []DiffOptions{{}, {Stat: true}} { + if err := instance.Diff(context.Background(), opts); !errors.Is(err, writeErr) { + t.Errorf("Diff(%+v) error = %v, want output failure", opts, err) + } + } +} + +func TestDiffPropagatesOperandDisappearance(t *testing.T) { + for _, test := range []struct { + name string + remove bool + stat bool + }{ + {name: "modified patch"}, + {name: "modified stat", stat: true}, + {name: "removed patch", remove: true}, + {name: "removed stat", remove: true, stat: true}, + } { + t.Run(test.name, func(t *testing.T) { + publicRoot, _, instance := initializedApp(t, t.TempDir()) + state := loadState(t, instance, publicRoot) + path := filepath.Join(publicRoot, "docs", "ARCHITECTURE.md") + if test.remove { + if err := instance.Remove(context.Background(), RemoveOptions{Paths: []string{"docs/ARCHITECTURE.md"}}); err != nil { + t.Fatal(err) + } + path = filepath.Join(state.Private.LocalRepositoryPath, "docs", "ARCHITECTURE.md") + } else if err := os.WriteFile(path, []byte("modified\n"), 0o600); err != nil { + t.Fatal(err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + t.Setenv("SPAS_APP_GIT_PROXY", "remove-before-diff") + t.Setenv("SPAS_APP_REAL_GIT", realGit) + t.Setenv("SPAS_APP_EDIT_PATH", path) + instance.Git.Path = os.Args[0] + if err := instance.Diff(context.Background(), DiffOptions{Stat: test.stat}); err == nil { + t.Fatal("Diff() swallowed Git's missing-operand error") + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("test did not remove the diff operand: %v", err) + } + }) + } +} diff --git a/internal/app/main_test.go b/internal/app/main_test.go index 3e750bc..d16d90c 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -19,6 +19,12 @@ func TestMain(m *testing.M) { func runGitProxy() int { args := os.Args[1:] mode := os.Getenv("SPAS_APP_GIT_PROXY") + if mode == "remove-before-diff" && containsArgument(args, "diff") && containsArgument(args, "--no-index") { + if err := os.Remove(os.Getenv("SPAS_APP_EDIT_PATH")); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + return 1 + } + } if mode == "edit-after-private-abort" { marker := os.Getenv("SPAS_APP_ABORT_MARKER") if _, err := os.Stat(marker); err == nil { From 7a6d9b6cad7425ad2dd2dbd9ce3a8c1219b05f64 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:16:30 -0500 Subject: [PATCH 37/52] fix(git): preserve exact repository paths and branch names --- internal/app/workspace_identity_test.go | 66 ++++++++++++++++ internal/gitexec/path_output.go | 16 ++++ internal/gitexec/path_output_test.go | 22 ++++++ internal/privategit/identity_test.go | 18 +++++ internal/privategit/repository.go | 12 ++- internal/publicgit/identity_test.go | 100 ++++++++++++++++++++++++ internal/publicgit/repository.go | 30 +++++-- 7 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 internal/app/workspace_identity_test.go create mode 100644 internal/gitexec/path_output.go create mode 100644 internal/gitexec/path_output_test.go create mode 100644 internal/privategit/identity_test.go create mode 100644 internal/publicgit/identity_test.go diff --git a/internal/app/workspace_identity_test.go b/internal/app/workspace_identity_test.go new file mode 100644 index 0000000..d627a47 --- /dev/null +++ b/internal/app/workspace_identity_test.go @@ -0,0 +1,66 @@ +package app + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" +) + +func TestLinkAndAddPreserveWhitespaceWorkspaceIdentity(t *testing.T) { + t.Parallel() + instance, neighbor, _, _ := fixture(t) + state := loadState(t, instance, neighbor) + neighborFiles := []string{ + filepath.Join(instance.Store.ConfigDir, "links", state.LinkID+".json"), + filepath.Join(neighbor, ".git", "config"), + filepath.Join(neighbor, ".git", "info", "exclude"), + } + before := make(map[string][]byte) + for _, path := range neighborFiles { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + before[path] = data + } + selected := neighbor + "\u00a0" + if err := os.Mkdir(selected, 0o700); err != nil { + t.Fatal(err) + } + runGit(t, selected, "init", "-q", "-b", "main") + instance.RepoHint, instance.PathBase = selected, selected + if err := instance.Link(context.Background(), LinkOptions{Repository: "getspas/private-files", Branch: "main"}); err != nil { + t.Fatalf("Link() selected the wrong workspace: %v", err) + } + if err := os.WriteFile(filepath.Join(selected, "secret.txt"), []byte("selected secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := instance.Add(context.Background(), AddOptions{ + Paths: []string{"secret.txt"}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip, + }); err != nil { + t.Fatal(err) + } + selectedState := loadState(t, instance, selected) + expectedRoot, err := filepath.EvalSymlinks(selected) + if err != nil { + t.Fatal(err) + } + if selectedState.LinkID == state.LinkID || selectedState.Public.Root != expectedRoot { + t.Fatalf("selected state identity = %q, %q", selectedState.LinkID, selectedState.Public.Root) + } + for _, path := range neighborFiles { + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before[path], after) { + t.Errorf("operation changed neighboring workspace file %q", path) + } + } + excluded, err := os.ReadFile(filepath.Join(selected, ".git", "info", "exclude")) + if err != nil || !bytes.Contains(excluded, []byte("/secret.txt\n")) { + t.Fatalf("selected workspace exclusion = %q, %v", excluded, err) + } +} diff --git a/internal/gitexec/path_output.go b/internal/gitexec/path_output.go new file mode 100644 index 0000000..f946fc4 --- /dev/null +++ b/internal/gitexec/path_output.go @@ -0,0 +1,16 @@ +package gitexec + +import ( + "fmt" + "strings" +) + +// ParsePathOutput reads one raw pathname printed by a Git path query. Only +// the terminating LF belongs to the protocol; whitespace in the name remains. +func ParsePathOutput(output []byte) (string, error) { + path, terminated := strings.CutSuffix(string(output), "\n") + if !terminated || path == "" || strings.ContainsRune(path, 0) { + return "", fmt.Errorf("Git returned a malformed pathname") + } + return path, nil +} diff --git a/internal/gitexec/path_output_test.go b/internal/gitexec/path_output_test.go new file mode 100644 index 0000000..ff1446f --- /dev/null +++ b/internal/gitexec/path_output_test.go @@ -0,0 +1,22 @@ +package gitexec + +import "testing" + +func TestParsePathOutputPreservesWhitespace(t *testing.T) { + t.Parallel() + for _, path := range []string{"/work/project ", "/work/project\t", "/work/project\u00a0", "/work/project\r", "/work/project\n", "/work/with spaces/project", " metadata "} { + got, err := ParsePathOutput([]byte(path + "\n")) + if err != nil || got != path { + t.Errorf("ParsePathOutput(%q) = %q, %v", path, got, err) + } + } +} + +func TestParsePathOutputRejectsMalformedRecords(t *testing.T) { + t.Parallel() + for _, value := range []string{"", "\n", "/work/project", "/work/\x00project\n"} { + if _, err := ParsePathOutput([]byte(value)); err == nil { + t.Errorf("ParsePathOutput(%q) accepted malformed output", value) + } + } +} diff --git a/internal/privategit/identity_test.go b/internal/privategit/identity_test.go new file mode 100644 index 0000000..4b621a0 --- /dev/null +++ b/internal/privategit/identity_test.go @@ -0,0 +1,18 @@ +package privategit + +import ( + "context" + "path/filepath" + "testing" +) + +func TestVerifyLayoutPreservesRootWhitespace(t *testing.T) { + t.Parallel() + parent := t.TempDir() + root := filepath.Join(parent, "checkout\u00a0") + runGit(t, parent, "init", "-q", root) + repository := Repository{Path: root} + if err := repository.verifyLayout(context.Background()); err != nil { + t.Fatalf("verifyLayout() changed the checkout identity: %v", err) + } +} diff --git a/internal/privategit/repository.go b/internal/privategit/repository.go index c678cef..ac71ddd 100644 --- a/internal/privategit/repository.go +++ b/internal/privategit/repository.go @@ -1232,7 +1232,11 @@ func (r Repository) verifyLayout(ctx context.Context) error { if err != nil { return fmt.Errorf("resolve private clone working tree: %w", err) } - if same, err := sameFilesystemObject(r.Path, strings.TrimSpace(string(topResult.Stdout))); err != nil { + topPath, err := gitexec.ParsePathOutput(topResult.Stdout) + if err != nil { + return fmt.Errorf("parse private clone working tree: %w", err) + } + if same, err := sameFilesystemObject(r.Path, topPath); err != nil { return fmt.Errorf("verify private clone working tree: %w", err) } else if !same { return fmt.Errorf("private clone working tree was redirected outside SPAS storage") @@ -1242,7 +1246,11 @@ func (r Repository) verifyLayout(ctx context.Context) error { if err != nil { return fmt.Errorf("resolve private clone Git directory: %w", err) } - if same, err := sameFilesystemObject(expectedGitDir, strings.TrimSpace(string(gitDirResult.Stdout))); err != nil { + gitDirPath, err := gitexec.ParsePathOutput(gitDirResult.Stdout) + if err != nil { + return fmt.Errorf("parse private clone Git directory: %w", err) + } + if same, err := sameFilesystemObject(expectedGitDir, gitDirPath); err != nil { return fmt.Errorf("verify private clone Git directory: %w", err) } else if !same { return fmt.Errorf("private clone Git metadata was redirected outside SPAS storage") diff --git a/internal/publicgit/identity_test.go b/internal/publicgit/identity_test.go new file mode 100644 index 0000000..244bcd4 --- /dev/null +++ b/internal/publicgit/identity_test.go @@ -0,0 +1,100 @@ +package publicgit + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/getspas/spas/internal/gitexec" +) + +func TestDiscoverPreservesWorkspaceWhitespace(t *testing.T) { + t.Parallel() + for _, name := range []string{"project ", "project\u00a0", "project\t", "project\n", "project\r", "project with spaces"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" && strings.ContainsAny(name, "\t\r\n") { + t.Skip("Windows filenames exclude these control characters") + } + parent := t.TempDir() + neighbor := filepath.Join(parent, "project") + if err := os.Mkdir(neighbor, 0o700); err != nil { + t.Fatal(err) + } + runGit(t, neighbor, "init", "-q") + selected := filepath.Join(parent, name) + if err := os.Mkdir(selected, 0o700); err != nil { + if errors.Is(err, os.ErrExist) && runtime.GOOS == "windows" && name == "project " { + t.Skip("Windows aliases trailing ASCII spaces") + } + t.Fatal(err) + } + selectedInfo, err := os.Stat(selected) + if err != nil { + t.Fatal(err) + } + neighborInfo, err := os.Stat(neighbor) + if err != nil { + t.Fatal(err) + } + if os.SameFile(selectedInfo, neighborInfo) { + t.Skip("volume aliases the selected name to its trimmed neighbor") + } + runGit(t, selected, "init", "-q") + repository, err := Discover(context.Background(), gitexec.Runner{}, selected) + if err != nil { + t.Fatal(err) + } + wantRoot, err := filepath.EvalSymlinks(selected) + if err != nil { + t.Fatal(err) + } + if filepath.Clean(repository.Root) != filepath.Clean(wantRoot) { + t.Fatalf("Root = %q, want selected workspace %q", repository.Root, wantRoot) + } + wantGitDir := filepath.Join(wantRoot, ".git") + if filepath.Clean(repository.GitDir) != wantGitDir || filepath.Clean(repository.CommonDir) != wantGitDir { + t.Fatalf("GitDir/CommonDir = %q/%q, want %q", repository.GitDir, repository.CommonDir, wantGitDir) + } + }) + } +} + +func TestDiscoverPreservesSeparateMetadataWhitespace(t *testing.T) { + t.Parallel() + parent := t.TempDir() + root := filepath.Join(parent, "workspace") + metadata := filepath.Join(parent, "metadata\u00a0") + runGit(t, parent, "init", "-q", "--separate-git-dir", metadata, root) + repository, err := Discover(context.Background(), gitexec.Runner{}, root) + if err != nil { + t.Fatal(err) + } + want, err := filepath.EvalSymlinks(metadata) + if err != nil { + t.Fatal(err) + } + if filepath.Clean(repository.CommonDir) != want || filepath.Clean(repository.GitDir) != want { + t.Fatalf("GitDir/CommonDir = %q/%q, want %q", repository.GitDir, repository.CommonDir, want) + } +} + +func TestBranchIgnoresTagNameAmbiguity(t *testing.T) { + t.Parallel() + root := t.TempDir() + runGit(t, root, "init", "-q", "-b", "main") + runGit(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-qm", "initial") + runGit(t, root, "-c", "tag.gpgsign=false", "tag", "main") + repository, err := Discover(context.Background(), gitexec.Runner{}, root) + if err != nil { + t.Fatal(err) + } + branch, err := repository.Branch(context.Background()) + if err != nil || branch != "main" { + t.Fatalf("Branch() = %q, %v, want main", branch, err) + } +} diff --git a/internal/publicgit/repository.go b/internal/publicgit/repository.go index 3c3b543..1adcc6f 100644 --- a/internal/publicgit/repository.go +++ b/internal/publicgit/repository.go @@ -36,7 +36,11 @@ func Discover(ctx context.Context, git gitexec.Runner, hint string) (Repository, if err != nil { return Repository{}, fmt.Errorf("%s is not inside a Git working tree: %w", absoluteHint, err) } - root, err := filepath.Abs(strings.TrimSpace(string(rootResult.Stdout))) + rootPath, err := gitexec.ParsePathOutput(rootResult.Stdout) + if err != nil { + return Repository{}, fmt.Errorf("parse public workspace root: %w", err) + } + root, err := filepath.Abs(rootPath) if err != nil { return Repository{}, fmt.Errorf("resolve public workspace root: %w", err) } @@ -45,7 +49,10 @@ func Discover(ctx context.Context, git gitexec.Runner, hint string) (Repository, if err != nil { return Repository{}, fmt.Errorf("locate public Git metadata: %w", err) } - common := strings.TrimSpace(string(commonResult.Stdout)) + common, err := gitexec.ParsePathOutput(commonResult.Stdout) + if err != nil { + return Repository{}, fmt.Errorf("parse public Git metadata path: %w", err) + } if !filepath.IsAbs(common) { common = filepath.Join(root, common) } @@ -58,7 +65,11 @@ func Discover(ctx context.Context, git gitexec.Runner, hint string) (Repository, if err != nil { return Repository{}, fmt.Errorf("locate public worktree Git directory: %w", err) } - gitDir, err := filepath.Abs(strings.TrimSpace(string(gitDirResult.Stdout))) + gitDirPath, err := gitexec.ParsePathOutput(gitDirResult.Stdout) + if err != nil { + return Repository{}, fmt.Errorf("parse public worktree Git directory: %w", err) + } + gitDir, err := filepath.Abs(gitDirPath) if err != nil { return Repository{}, fmt.Errorf("resolve public worktree Git directory: %w", err) } @@ -112,7 +123,7 @@ func (r Repository) Head(ctx context.Context) (string, error) { } refResult, refErr := r.Git.Run(ctx, r.Root, "symbolic-ref", "--quiet", "HEAD") if refErr == nil { - ref := strings.TrimSpace(string(refResult.Stdout)) + ref := strings.TrimSuffix(string(refResult.Stdout), "\n") _, existsErr := r.Git.Run(ctx, r.Root, "show-ref", "--verify", "--quiet", ref) if existsErr == nil { return "", fmt.Errorf("public HEAD ref %q does not name a commit", ref) @@ -126,14 +137,19 @@ func (r Repository) Head(ctx context.Context) (string, error) { } func (r Repository) Branch(ctx context.Context) (string, error) { - result, err := r.Git.Run(ctx, r.Root, "symbolic-ref", "--quiet", "--short", "HEAD") + result, err := r.Git.Run(ctx, r.Root, "symbolic-ref", "--quiet", "HEAD") if err != nil { if code, ok := gitexec.ExitCode(err); ok && code == 1 { return "", nil } return "", err } - return strings.TrimSpace(string(result.Stdout)), nil + ref, terminated := strings.CutSuffix(string(result.Stdout), "\n") + branch, isBranch := strings.CutPrefix(ref, "refs/heads/") + if !terminated || !isBranch || branch == "" || strings.ContainsAny(branch, "\x00\r\n") { + return "", fmt.Errorf("Git returned an invalid public branch reference") + } + return branch, nil } func (r Repository) TrackedPaths(ctx context.Context) ([]pathmodel.Path, error) { @@ -157,7 +173,7 @@ func (r Repository) InfoExcludePath(ctx context.Context) (string, error) { if err != nil { return "", fmt.Errorf("locate public repository local exclude file: %w", err) } - return strings.TrimSpace(string(result.Stdout)), nil + return gitexec.ParsePathOutput(result.Stdout) } // ExcludedPaths checks effective exclusion for candidate paths in a single From 5152f8d6c69df3b8f1db67caedddaed100d632ee Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:16:41 -0500 Subject: [PATCH 38/52] fix(merge): reject conflicting and inherited merge options --- internal/app/app.go | 4 +- internal/app/merge_protection_test.go | 25 +++ internal/mergeprotect/mergeprotect.go | 85 +++++--- internal/mergeprotect/option_safety_test.go | 204 ++++++++++++++++++++ 4 files changed, 293 insertions(+), 25 deletions(-) create mode 100644 internal/app/merge_protection_test.go create mode 100644 internal/mergeprotect/option_safety_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 58bd675..b0d9174 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1170,11 +1170,11 @@ func (a App) planMergeProtection(ctx context.Context, repository publicgit.Repos } if status.Ambiguous { if policy == MergeEnable || policy == MergeRequire { - return "", spaserr.Wrap(spaserr.KindUnsafeGitState, fmt.Errorf("merge protection policy %q cannot install protection on public branch %q because it has multiple mergeOptions values", policy, status.Branch)) + return "", mergeprotect.PolicyError(status) } if policy == MergeAsk { if err := a.warnf( - "warning: public branch %q has multiple mergeOptions values; SPAS will not modify them. Add --no-overwrite-ignore to that branch's local merge options manually if you want overwrite protection.\n", + "warning: merge protection is unverified for public branch %q; configure one direct repository-local mergeOptions value using supported flags and --no-overwrite-ignore.\n", status.Branch, ); err != nil { return "", err diff --git a/internal/app/merge_protection_test.go b/internal/app/merge_protection_test.go new file mode 100644 index 0000000..626a8e5 --- /dev/null +++ b/internal/app/merge_protection_test.go @@ -0,0 +1,25 @@ +package app + +import ( + "context" + "testing" + + "github.com/getspas/spas/internal/spaserr" +) + +func TestMergeProtectionPoliciesRejectConflictingOptions(t *testing.T) { + t.Parallel() + instance, root, _, _ := fixture(t) + repository, err := instance.publicRepository(context.Background()) + if err != nil { + t.Fatal(err) + } + runGit(t, root, "config", "branch.main.mergeOptions", "--no-overwrite-ignore --overwrite-ignore") + for _, policy := range []MergeProtectionPolicy{MergeEnable, MergeRequire} { + if action, err := instance.planMergeProtection(context.Background(), repository, policy); err == nil { + t.Errorf("policy %q accepted conflicting options: %q", policy, action) + } else if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsafeGitState { + t.Errorf("policy %q error = %v, want unsafe_git_state", policy, err) + } + } +} diff --git a/internal/mergeprotect/mergeprotect.go b/internal/mergeprotect/mergeprotect.go index 0ccb56f..5a2966b 100644 --- a/internal/mergeprotect/mergeprotect.go +++ b/internal/mergeprotect/mergeprotect.go @@ -18,8 +18,8 @@ type Status struct { Enabled bool `json:"enabled"` Value string `json:"value,omitempty"` Present bool `json:"present,omitempty"` - // Ambiguous reports that the branch carries multiple mergeOptions values. - // SPAS never rewrites such configuration automatically. + // Ambiguous reports multiple or inherited values, or options whose effect + // SPAS cannot verify. These require manual configuration. Ambiguous bool `json:"ambiguous,omitempty"` } @@ -31,20 +31,43 @@ func Inspect(ctx context.Context, repository publicgit.Repository) (Status, erro if branch == "" { return Status{}, nil } - values, present, err := read(repository.Git, ctx, repository.Root, branch) + options, err := readEffectiveOptions(repository.Git, ctx, repository.Root, branch) if err != nil { return Status{}, err } - status := Status{Branch: branch, Value: strings.Join(values, "\n"), Present: present} - if present { - for _, value := range values { - if contains(value, requiredOption) { - status.Enabled = true - } - } + values := make([]string, len(options)) + for i, option := range options { + values[i] = option.value + } + status := Status{Branch: branch, Value: strings.Join(values, "\n"), Present: len(options) > 0} + if len(options) == 0 { + return status, nil + } + if len(options) != 1 || options[0].scope != "local" { + status.Ambiguous = true + return status, nil + } + direct, present, err := read(repository.Git, ctx, repository.Root, branch) + if err != nil { + return Status{}, err } - if len(values) > 1 { + if !present || len(direct) != 1 || direct[0] != values[0] { status.Ambiguous = true + return status, nil + } + // Verify exact operand-free flags using Git's ASCII whitespace separators. + for _, option := range strings.FieldsFunc(values[0], func(r rune) bool { + return strings.ContainsRune(" \t\r\n\v\f", r) + }) { + switch option { + case requiredOption: + status.Enabled = true + case "--no-edit", "--log", "--no-ff": + default: + status.Enabled = false + status.Ambiguous = true + return status, nil + } } return status, nil } @@ -61,7 +84,7 @@ func Enable(ctx context.Context, repository publicgit.Repository, state *linksta return status, spaserr.Wrap(spaserr.KindUnsafeGitState, fmt.Errorf("cannot configure merge protection in detached HEAD state")) } if status.Ambiguous { - return status, spaserr.Wrap(spaserr.KindUnsafeGitState, fmt.Errorf("cannot configure merge protection when the branch has multiple mergeOptions values")) + return status, PolicyError(status) } before := status.Value @@ -193,7 +216,7 @@ func PolicyError(status Status) error { if status.Ambiguous { return spaserr.Wrap( spaserr.KindUnsafeGitState, - fmt.Errorf("public branch %q has multiple mergeOptions values; add %s manually before retrying", status.Branch, requiredOption), + fmt.Errorf("public branch %q has unverifiable mergeOptions; configure one direct repository-local value using supported flags and %s", status.Branch, requiredOption), ) } return spaserr.Wrap( @@ -204,25 +227,41 @@ func PolicyError(status Status) error { func read(git gitexec.Runner, ctx context.Context, root, branch string) ([]string, bool, error) { key := "branch." + branch + ".mergeOptions" - result, err := git.Run(ctx, root, "config", "--local", "--get-all", key) + result, err := git.Run(ctx, root, "config", "--local", "--no-includes", "--null", "--get-all", key) if err != nil { if code, ok := gitexec.ExitCode(err); ok && code == 1 { return nil, false, nil } return nil, false, err } - raw := strings.TrimSpace(string(result.Stdout)) - if raw == "" { - return []string{""}, true, nil + raw, terminated := strings.CutSuffix(string(result.Stdout), "\x00") + if !terminated { + return nil, false, fmt.Errorf("Git returned malformed mergeOptions values") } - return strings.Split(raw, "\n"), true, nil + return strings.Split(raw, "\x00"), true, nil +} + +type scopedOption struct { + scope string + value string } -func contains(value, option string) bool { - for _, field := range strings.Fields(value) { - if field == option { - return true +func readEffectiveOptions(git gitexec.Runner, ctx context.Context, root, branch string) ([]scopedOption, error) { + result, err := git.Run(ctx, root, "config", "--null", "--show-scope", "--get-all", "branch."+branch+".mergeOptions") + if err != nil { + if code, ok := gitexec.ExitCode(err); ok && code == 1 { + return nil, nil } + return nil, err + } + raw, terminated := strings.CutSuffix(string(result.Stdout), "\x00") + fields := strings.Split(raw, "\x00") + if !terminated || len(fields)%2 != 0 { + return nil, fmt.Errorf("Git returned malformed scoped mergeOptions values") + } + options := make([]scopedOption, 0, len(fields)/2) + for i := 0; i < len(fields); i += 2 { + options = append(options, scopedOption{scope: fields[i], value: fields[i+1]}) } - return false + return options, nil } diff --git a/internal/mergeprotect/option_safety_test.go b/internal/mergeprotect/option_safety_test.go new file mode 100644 index 0000000..51ea69c --- /dev/null +++ b/internal/mergeprotect/option_safety_test.go @@ -0,0 +1,204 @@ +package mergeprotect + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/getspas/spas/internal/linkstate" +) + +func TestInspectRejectsUnverifiableMergeOptions(t *testing.T) { + t.Parallel() + for _, values := range [][]string{ + {"--no-overwrite-ignore --overwrite-ignore"}, + {"--overwrite-ignore --no-overwrite-ignore"}, + {"-m --no-overwrite-ignore"}, + {"--message=--no-overwrite-ignore"}, + {"'--no-overwrite-ignore'"}, + {"--no-overwrite-ignore --unknown-option"}, + {"--no-edit\u00a0--no-overwrite-ignore"}, + {"--no-overwrite-ignore", "--overwrite-ignore"}, + {"", "--no-overwrite-ignore"}, + } { + t.Run(values[0], func(t *testing.T) { + t.Parallel() + repository := testRepository(t) + for _, value := range values { + runGit(t, repository.Root, "config", "--local", "--add", "branch.main.mergeOptions", value) + } + configFile := filepath.Join(repository.CommonDir, "config") + before, err := os.ReadFile(configFile) + if err != nil { + t.Fatal(err) + } + status, err := Inspect(context.Background(), repository) + if err != nil { + t.Fatal(err) + } + if status.Enabled || !status.Ambiguous { + t.Errorf("Inspect() = %+v, want unverified protection", status) + } + state := linkstate.State{Merge: linkstate.Merge{ManagedBranches: map[string]linkstate.ManagedBranch{}}} + if _, err := Enable(context.Background(), repository, &state); err == nil { + t.Error("Enable() accepted unverifiable user options") + } + after, err := os.ReadFile(configFile) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || len(state.Merge.ManagedBranches) != 0 { + t.Error("Enable() changed ambiguous configuration or ownership state") + } + }) + } +} + +func TestMergeProtectionPreventsIgnoredFileOverwrite(t *testing.T) { + t.Parallel() + for _, options := range []string{"--no-overwrite-ignore", "--no-edit --log --no-overwrite-ignore"} { + t.Run(options, func(t *testing.T) { + t.Parallel() + repository := testRepository(t) + runGit(t, repository.Root, "checkout", "-q", "-b", "incoming") + asset := filepath.Join(repository.Root, "secret.txt") + if err := os.WriteFile(asset, []byte("incoming\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, repository.Root, "add", "secret.txt") + runGit(t, repository.Root, "commit", "-q", "-m", "incoming asset") + runGit(t, repository.Root, "checkout", "-q", "main") + if err := os.WriteFile(filepath.Join(repository.CommonDir, "info", "exclude"), []byte("/secret.txt\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(asset, []byte("private local bytes\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, repository.Root, "config", "branch.main.mergeOptions", options) + status, err := Inspect(context.Background(), repository) + if err != nil || !status.Enabled || status.Ambiguous { + t.Fatalf("Inspect() = %+v, %v", status, err) + } + if _, err := repository.Git.Run(context.Background(), repository.Root, "merge", "incoming"); err == nil { + t.Fatal("protected merge overwrote an ignored asset") + } + content, err := os.ReadFile(asset) + if err != nil || string(content) != "private local bytes\n" { + t.Fatalf("asset = %q, %v", content, err) + } + }) + } +} + +func TestInspectRejectsWorktreeMergeOptionOverrides(t *testing.T) { + t.Parallel() + repository := testRepository(t) + runGit(t, repository.Root, "config", "extensions.worktreeConfig", "true") + runGit(t, repository.Root, "config", "--local", "branch.main.mergeOptions", "--no-overwrite-ignore") + runGit(t, repository.Root, "config", "--worktree", "branch.main.mergeOptions", "--overwrite-ignore") + status, err := Inspect(context.Background(), repository) + if err != nil { + t.Fatal(err) + } + if status.Enabled || !status.Ambiguous { + t.Fatalf("Inspect() = %+v, want the effective override to prevent a safety claim", status) + } +} + +func TestInspectRequiresRepositoryLocalOptions(t *testing.T) { + for _, scope := range []string{"global", "worktree"} { + t.Run(scope, func(t *testing.T) { + repository := testRepository(t) + if scope == "global" { + global := filepath.Join(t.TempDir(), "gitconfig") + runGit(t, repository.Root, "config", "--file", global, "branch.main.mergeOptions", requiredOption) + t.Setenv("GIT_CONFIG_GLOBAL", global) + } else { + runGit(t, repository.Root, "config", "extensions.worktreeConfig", "true") + runGit(t, repository.Root, "config", "--worktree", "branch.main.mergeOptions", requiredOption) + } + status, err := Inspect(context.Background(), repository) + if err != nil || status.Enabled || !status.Ambiguous || !status.Present { + t.Fatalf("Inspect() = %+v, %v, want unverified %s options", status, err, scope) + } + }) + } +} + +func TestRestorePreservesMergeOptionWhitespace(t *testing.T) { + t.Parallel() + repository := testRepository(t) + before := " \t--no-edit\n--log " + runGit(t, repository.Root, "config", "--local", "branch.main.mergeOptions", before) + state := linkstate.State{Merge: linkstate.Merge{ManagedBranches: map[string]linkstate.ManagedBranch{}}} + if _, err := Enable(context.Background(), repository, &state); err != nil { + t.Fatal(err) + } + if err := Restore(context.Background(), repository, state); err != nil { + t.Fatal(err) + } + values, present, err := read(repository.Git, context.Background(), repository.Root, "main") + if err != nil || !present || len(values) != 1 || values[0] != before { + t.Fatalf("restored options = %q, %t, %v, want %q", values, present, err, before) + } +} + +func TestProtectionUsesExactBranchName(t *testing.T) { + t.Parallel() + repository := testRepository(t) + runGit(t, repository.Root, "config", "branch.main.mergeOptions", requiredOption) + branch := "main\u00a0" + runGit(t, repository.Root, "checkout", "-q", "-b", branch) + status, err := Inspect(context.Background(), repository) + if err != nil || status.Enabled || status.Branch != branch { + t.Fatalf("Inspect() = %+v, %v, want the unprotected current branch %q", status, err, branch) + } + state := linkstate.State{Merge: linkstate.Merge{ManagedBranches: map[string]linkstate.ManagedBranch{}}} + if _, err := Enable(context.Background(), repository, &state); err != nil { + t.Fatal(err) + } + if _, exists := state.Merge.ManagedBranches[branch]; !exists { + t.Fatalf("protection ownership = %+v, want current branch", state.Merge.ManagedBranches) + } + if got := config(t, repository, "branch."+branch+".mergeOptions"); got != requiredOption { + t.Fatalf("current branch mergeOptions = %q", got) + } + if got := config(t, repository, "branch.main.mergeOptions"); got != requiredOption { + t.Fatalf("neighboring branch mergeOptions changed to %q", got) + } +} + +func TestEnablePreservesIncludedMergeOptions(t *testing.T) { + t.Parallel() + repository := testRepository(t) + included := filepath.Join(repository.CommonDir, "included-options") + runGit(t, repository.Root, "config", "--file", included, "branch.main.mergeOptions", "--no-edit") + runGit(t, repository.Root, "config", "branch.main.description", "existing section") + runGit(t, repository.Root, "config", "include.path", included) + configFile := filepath.Join(repository.CommonDir, "config") + before, err := os.ReadFile(configFile) + if err != nil { + t.Fatal(err) + } + beforeInclude, err := os.ReadFile(included) + if err != nil { + t.Fatal(err) + } + state := linkstate.State{Merge: linkstate.Merge{ManagedBranches: map[string]linkstate.ManagedBranch{}}} + if _, err := Enable(context.Background(), repository, &state); err == nil { + t.Error("Enable() accepted a value owned by an included file") + } + after, err := os.ReadFile(configFile) + if err != nil { + t.Fatal(err) + } + afterInclude, err := os.ReadFile(included) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || !bytes.Equal(beforeInclude, afterInclude) || len(state.Merge.ManagedBranches) != 0 { + t.Fatal("Enable() changed included configuration or recorded incorrect ownership") + } +} From 4bf56fb38f015aa026aab4eebef56fbf2a93484b Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:16:47 -0500 Subject: [PATCH 39/52] fix(cli): resolve Git paths from the invocation directory --- internal/cli/executable_test.go | 89 +++++++++++++++++++++++++++++++++ internal/cli/root.go | 9 +++- 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 internal/cli/executable_test.go diff --git a/internal/cli/executable_test.go b/internal/cli/executable_test.go new file mode 100644 index 0000000..d0db25f --- /dev/null +++ b/internal/cli/executable_test.go @@ -0,0 +1,89 @@ +package cli + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/spf13/cobra" +) + +func TestGitExecutableSelectionSurvivesDirectoryChanges(t *testing.T) { + invocation := t.TempDir() + t.Chdir(invocation) + t.Setenv("SPAS_CLI_EXECUTABLE_PROBE", "1") + toolsDir := filepath.Join(invocation, "tools with spaces") + if err := os.Mkdir(toolsDir, 0o700); err != nil { + t.Fatal(err) + } + name := "chosen-git" + if runtime.GOOS == "windows" { + name += ".exe" + } + executable := filepath.Join(toolsDir, name) + program, err := os.ReadFile(os.Args[0]) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executable, program, 0o755); err != nil { + t.Fatal(err) + } + var dirs []string + for _, name := range []string{"public workspace", "probe directory", "private checkout"} { + dir := filepath.Join(invocation, name) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + dirs = append(dirs, dir) + } + relative := "./tools with spaces/" + name + selections := []string{relative, executable, "git"} + if runtime.GOOS == "windows" { + selections = append(selections, ".\\tools with spaces\\"+name, filepath.VolumeName(executable)+relative[2:]) + } + for _, selection := range selections { + t.Run(selection, func(t *testing.T) { + var output bytes.Buffer + command := &cobra.Command{} + command.SetIn(bytes.NewReader(nil)) + command.SetOut(&output) + command.SetErr(&output) + instance, err := buildApp(command, &rootOptions{repo: dirs[0], gitPath: selection, nonInteractive: true}) + if err != nil { + t.Fatal(err) + } + args := []string{"-test.run=^TestGitExecutableProbe$"} + if selection == "git" { + args = []string{"--version"} + } + var first []byte + for _, dir := range dirs { + result, err := instance.Git.Run(context.Background(), dir, args...) + if err != nil { + t.Errorf("selected %q in %q: %v", selection, dir, err) + continue + } + if selection != "git" && string(result.Stdout) != "selected executable\n" { + t.Errorf("unexpected executable output: %q", result.Stdout) + } + if first == nil { + first = result.Stdout + } else if !bytes.Equal(first, result.Stdout) { + t.Errorf("executable selection changed between directories: %q and %q", first, result.Stdout) + } + } + }) + } +} + +func TestGitExecutableProbe(t *testing.T) { + if os.Getenv("SPAS_CLI_EXECUTABLE_PROBE") != "1" { + return + } + fmt.Fprintln(os.Stdout, "selected executable") + os.Exit(0) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index c735e1d..a734726 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -676,8 +676,15 @@ func buildApp(command *cobra.Command, options *rootOptions) (app.App, error) { nonInteractive := options.nonInteractive || options.json prompt := interaction.Detect(command.InOrStdin(), command.ErrOrStderr(), nonInteractive) prompt.AssumeYes = options.yes + gitPath := options.gitPath + if strings.ContainsRune(gitPath, '/') || strings.ContainsRune(gitPath, filepath.Separator) || filepath.VolumeName(gitPath) != "" { + gitPath, err = filepath.Abs(gitPath) + if err != nil { + return app.App{}, fmt.Errorf("resolve Git executable: %w", err) + } + } git := gitexec.Runner{ - Path: options.gitPath, + Path: gitPath, // Git terminal prompts are disabled whenever SPAS itself cannot // prompt, including non-TTY runs, so authentication fails // deterministically instead of hanging while the link lock is held. From a0069a56a378935600dd40299c9f3bc0bd4e7644 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:16:54 -0500 Subject: [PATCH 40/52] docs: clarify merge protection and ignore precedence --- wiki/Command-reference.md | 15 ++++++++++++++- wiki/Safety-and-limitations.md | 2 +- wiki/Troubleshooting.md | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/wiki/Command-reference.md b/wiki/Command-reference.md index 748ac09..29dfef3 100644 --- a/wiki/Command-reference.md +++ b/wiki/Command-reference.md @@ -11,7 +11,7 @@ The following flags apply to all SPAS commands: | Option | Type | Description | | :--- | :--- | :--- | | `--repo PATH` | String | Path to the project Git workspace directory (defaults to `.`) | -| `--git PATH` | String | Custom path to the Git executable | +| `--git PATH` | String | Git executable; relative filesystem paths resolve from the invocation directory, and bare names use `PATH` | | `--non-interactive` | Flag | Disable interactive prompts; fails if any required decision flag is missing | | `--json` | Flag | Output structured JSON to stdout and disable interactive prompts | | `-y, --yes` | Flag | Automatically accept non-destructive setup suggestions | @@ -152,6 +152,19 @@ SPAS never creates commits in your project repository. | `--dry-run` | Flag | `false` | Read-only simulation without taking mutation locks or making network calls | | `--allow-public` | Flag | `false` | Allow syncing to a publicly readable repository without confirmation (approval is recorded in link state; later syncs skip the probe) | +### Verified Merge Protection + +SPAS verifies `branch..mergeOptions` only when there is one value stored +directly in the repository config file containing `--no-overwrite-ignore`. It may also +contain `--no-edit`, `--log`, and `--no-ff`, separated by ASCII whitespace. +With these flags, `enable` preserves the original local value for unlink +restoration and adds `--no-overwrite-ignore` when needed. + +Multiple values, included values, non-local scopes, quoted or argument-taking options, +`--overwrite-ignore`, and other flags are reported as unverified. `require` +and `enable` reject that configuration. Configure a single supported local +value before using those policies; `skip` leaves merge protection to you. + ### Sync Examples ```bash diff --git a/wiki/Safety-and-limitations.md b/wiki/Safety-and-limitations.md index f029f46..dabade9 100644 --- a/wiki/Safety-and-limitations.md +++ b/wiki/Safety-and-limitations.md @@ -56,7 +56,7 @@ The local exclusion block inside `.git/info/exclude` prevents standard Git opera > [!WARNING] > -> - **`.gitignore` Negation Precedence:** In Git, negation rules (`!pattern`) inside `.gitignore` or global `core.excludesFile` override exclusions in `.git/info/exclude`. If a project `.gitignore` contains a rule like `!*.json` or `!config/dev.json`, SPAS's `verifyExclusion` safety probe detects that the asset is no longer effectively ignored and halts immediately with exit code 9 (`exclusion_validation_failed`) to prevent accidental tracking by the main repository. +> - **`.gitignore` Negation Precedence:** Git gives project `.gitignore` rules higher precedence than `.git/info/exclude`, which takes precedence over the global `core.excludesFile`. If a project `.gitignore` contains a negation such as `!*.json` or `!config/dev.json`, SPAS's `verifyExclusion` safety probe detects that the asset is no longer effectively ignored and halts with exit code 9 (`exclusion_validation_failed`). > - `git add -f` (force add) will bypass exclusion rules and stage private assets in your main repository. > - Destructive Git commands like `git clean -xdf`, forced checkouts (`git checkout -f`), or hard resets (`git reset --hard`) can delete or overwrite excluded files. > - **Best Practice:** Run `spas sync` before performing destructive Git operations, and review `git status` before committing. diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index 2ac190c..a2bc179 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -84,7 +84,7 @@ spas doctor --json ### `exclusion_validation_failed` (Exit Code 9) -- **Cause:** A managed path is not effectively excluded from the project's primary Git repository. This typically happens when a `.gitignore` file (or a global `core.excludesFile`) contains a negation pattern (`!path` or `!*.json`) that re-includes a path. Because Git evaluates `.gitignore` with higher precedence than `.git/info/exclude`, the negation rule defeats SPAS's local exclusion block. +- **Cause:** A managed path is not effectively excluded from the project's primary Git repository. This can happen when a project `.gitignore` contains a negation pattern (`!path` or `!*.json`) that re-includes a path. Project `.gitignore` rules take precedence over `.git/info/exclude`, which takes precedence over the global `core.excludesFile`. - **Why SPAS Fails Closed:** If SPAS allowed materialization while a negation rule was active, standard Git commands (`git status`, `git add .`, `git commit`) in your main project repository would track and stage your private assets. SPAS verifies effective exclusion via `git check-ignore --no-index` before mutating the workspace and halts immediately if any managed path is not effectively ignored. - **How to Diagnose & Fix:** 1. Identify which rule is re-including the path: From 3dcd337955aa82d9fc02f5151eca5b9c9a0ae6f1 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:09:38 -0500 Subject: [PATCH 41/52] fix(privategit): preserve literal branch names Parse fetched refs by exact namespace so branch names are not trimmed or confused with tags. --- internal/app/branch_identity_test.go | 51 ++++++++++++ internal/gitexec/ref_output.go | 17 ++++ internal/gitexec/ref_output_test.go | 27 +++++++ internal/privategit/branch_identity_test.go | 88 +++++++++++++++++++++ internal/privategit/repository.go | 32 +++++--- internal/publicgit/repository.go | 7 +- 6 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 internal/app/branch_identity_test.go create mode 100644 internal/gitexec/ref_output.go create mode 100644 internal/gitexec/ref_output_test.go create mode 100644 internal/privategit/branch_identity_test.go diff --git a/internal/app/branch_identity_test.go b/internal/app/branch_identity_test.go new file mode 100644 index 0000000..a014d4c --- /dev/null +++ b/internal/app/branch_identity_test.go @@ -0,0 +1,51 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestSyncPreservesPrivateBranchIdentity(t *testing.T) { + t.Parallel() + for _, branch := range []string{"main", "main\u00a0"} { + t.Run(branch, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + publicRoot := initializePublicRepository(t, root) + remote := initializePrivateRemoteWithFile(t, root, "secret.txt", "private content\n") + if branch != "main" { + runGit(t, remote, "branch", "-m", "main", branch) + } + instance, _ := testApp(t, publicRoot, root, remote) + if err := instance.Link(context.Background(), LinkOptions{Repository: "getspas/private-files", Branch: branch}); err != nil { + t.Fatalf("Link(%q): %v", branch, err) + } + options := syncOptions("sync assets") + options.Branch = branch + if err := instance.Sync(context.Background(), options); err != nil { + t.Fatalf("initial sync: %v", err) + } + for _, tag := range []string{branch, "origin/" + branch} { + runGit(t, remote, "-c", "tag.gpgsign=false", "tag", tag, "refs/heads/"+branch) + } + // Check the fetch and the following invocation, which starts with + // the potentially ambiguous tags already present in the checkout. + for i := 0; i < 2; i++ { + if err := instance.Sync(context.Background(), options); err != nil { + t.Fatalf("sync with same-named tags: %v", err) + } + } + state := loadState(t, instance, publicRoot) + if state.Private.Branch != branch { + t.Fatalf("bound branch = %q, want %q", state.Private.Branch, branch) + } + runGit(t, state.Private.LocalRepositoryPath, "show-ref", "--verify", "refs/tags/"+branch) + content, err := os.ReadFile(filepath.Join(publicRoot, "secret.txt")) + if err != nil || string(content) != "private content\n" { + t.Fatalf("asset content = %q, %v", content, err) + } + }) + } +} diff --git a/internal/gitexec/ref_output.go b/internal/gitexec/ref_output.go new file mode 100644 index 0000000..8f4c5d5 --- /dev/null +++ b/internal/gitexec/ref_output.go @@ -0,0 +1,17 @@ +package gitexec + +import ( + "fmt" + "strings" +) + +// ParseRefOutput reads one LF-terminated ref and removes its exact namespace. +// An empty namespace preserves a literal branch name or complete ref. +func ParseRefOutput(output []byte, namespace string) (string, error) { + ref, terminated := strings.CutSuffix(string(output), "\n") + name, matches := strings.CutPrefix(ref, namespace) + if !terminated || !matches || name == "" || strings.ContainsAny(name, "\x00\r\n") { + return "", fmt.Errorf("Git returned an invalid reference for namespace %q", namespace) + } + return name, nil +} diff --git a/internal/gitexec/ref_output_test.go b/internal/gitexec/ref_output_test.go new file mode 100644 index 0000000..d82e658 --- /dev/null +++ b/internal/gitexec/ref_output_test.go @@ -0,0 +1,27 @@ +package gitexec + +import "testing" + +func TestParseRefOutputPreservesLiteralNames(t *testing.T) { + t.Parallel() + for _, test := range []struct{ output, namespace, want string }{ + {"refs/heads/main\u00a0\n", "refs/heads/", "main\u00a0"}, + {"refs/remotes/origin/main\n", "refs/remotes/origin/", "main"}, + {"\u00a0topic\u00a0\n", "", "\u00a0topic\u00a0"}, + {"refs/heads/refs/heads/main\n", "refs/heads/", "refs/heads/main"}, + } { + got, err := ParseRefOutput([]byte(test.output), test.namespace) + if err != nil || got != test.want { + t.Errorf("ParseRefOutput(%q, %q) = %q, %v", test.output, test.namespace, got, err) + } + } +} + +func TestParseRefOutputRejectsMalformedIdentity(t *testing.T) { + t.Parallel() + for _, output := range []string{"", "refs/heads/\n", "refs/heads/main", "refs/tags/main\n", "refs/heads/main\r\n", "refs/heads/main\nextra\n", "refs/heads/ma\x00in\n"} { + if _, err := ParseRefOutput([]byte(output), "refs/heads/"); err == nil { + t.Errorf("accepted malformed ref %q", output) + } + } +} diff --git a/internal/privategit/branch_identity_test.go b/internal/privategit/branch_identity_test.go new file mode 100644 index 0000000..076d1a6 --- /dev/null +++ b/internal/privategit/branch_identity_test.go @@ -0,0 +1,88 @@ +package privategit + +import ( + "context" + "path/filepath" + "testing" + + "github.com/getspas/spas/internal/gitexec" +) + +func TestClonePreservesBranchIdentity(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name, branch, tag, requested string + }{ + {name: "ordinary default", branch: "main"}, + {name: "requested with matching tag", branch: "main", tag: "main", requested: "main"}, + {name: "default with matching tag", branch: "main", tag: "main"}, + {name: "default with remote-like tag", branch: "main", tag: "origin/main"}, + {name: "Unicode default", branch: "main\u00a0"}, + {name: "Unicode requested", branch: "main\u00a0", requested: "main\u00a0"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + source, remote := createBranchRemote(t, root, test.branch) + if test.tag != "" { + runGit(t, source, "-c", "tag.gpgsign=false", "tag", test.tag) + runGit(t, source, "push", "-q", "origin", "--tags") + } + repository := Repository{Path: filepath.Join(root, "clone"), SafetyDir: filepath.Join(root, "safety")} + result := publishCloneForTest(t, repository, context.Background(), remote, test.requested) + if result.Branch != test.branch || result.Empty { + t.Fatalf("clone result = %+v, want branch %q", result, test.branch) + } + if branch, err := repository.Branch(context.Background()); err != nil || branch != test.branch { + t.Fatalf("Branch() = %q, %v, want %q", branch, err, test.branch) + } + if err := repository.ValidateBranch(context.Background(), test.branch); err != nil { + t.Fatalf("literal branch rejected: %v", err) + } + }) + } +} + +func TestFetchedTagPreservesPrivateBranchIdentity(t *testing.T) { + t.Parallel() + root := t.TempDir() + source, remote := createBranchRemote(t, root, "main") + repository := Repository{Path: filepath.Join(root, "clone"), SafetyDir: filepath.Join(root, "safety")} + result := publishCloneForTest(t, repository, context.Background(), remote, "main") + runGit(t, source, "-c", "tag.gpgsign=false", "tag", "main") + runGit(t, source, "push", "-q", "origin", "--tags") + if err := repository.Fetch(context.Background(), "main"); err != nil { + t.Fatal(err) + } + runGit(t, repository.Path, "show-ref", "--verify", "refs/tags/main") + if err := repository.verifyPreparedResult(context.Background(), result); err != nil { + t.Fatalf("fetched tag changed the bound branch: %v", err) + } +} + +func TestPrivateUnbornUnicodeBranchRemainsUnborn(t *testing.T) { + t.Parallel() + root := t.TempDir() + source, _ := createBranchRemote(t, root, "main") + runGit(t, source, "symbolic-ref", "HEAD", "refs/heads/main\u00a0") + repository := Repository{Path: source, Git: gitexec.Runner{}} + if head, err := repository.Head(context.Background()); err != nil || head != "" { + t.Fatalf("Head() = %q, %v, want unborn branch", head, err) + } + if branch, err := repository.Branch(context.Background()); err != nil || branch != "main\u00a0" { + t.Fatalf("Branch() = %q, %v", branch, err) + } +} + +func createBranchRemote(t *testing.T, root, branch string) (string, string) { + t.Helper() + source := filepath.Join(root, "source") + remote := filepath.Join(root, "remote.git") + runGit(t, root, "init", "--bare", "-q", remote) + runGit(t, root, "init", "-q", "-b", branch, source) + runGit(t, source, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-qm", "initial") + runGit(t, source, "remote", "add", "origin", remote) + runGit(t, source, "push", "-q", "origin", "HEAD:refs/heads/"+branch) + runGit(t, remote, "symbolic-ref", "HEAD", "refs/heads/"+branch) + return source, remote +} diff --git a/internal/privategit/repository.go b/internal/privategit/repository.go index ac71ddd..5f482ef 100644 --- a/internal/privategit/repository.go +++ b/internal/privategit/repository.go @@ -704,7 +704,10 @@ func (r Repository) Head(ctx context.Context) (string, error) { } refResult, refErr := r.Git.Run(ctx, r.Path, r.safeArgs("symbolic-ref", "--quiet", "HEAD")...) if refErr == nil { - ref := strings.TrimSpace(string(refResult.Stdout)) + ref, err := gitexec.ParseRefOutput(refResult.Stdout, "") + if err != nil { + return "", err + } _, existsErr := r.Git.Run(ctx, r.Path, r.safeArgs("show-ref", "--verify", "--quiet", ref)...) if existsErr == nil { return "", fmt.Errorf("private HEAD ref %q does not name a commit", ref) @@ -720,14 +723,14 @@ func (r Repository) Head(ctx context.Context) (string, error) { } func (r Repository) Branch(ctx context.Context) (string, error) { - result, err := r.Git.Run(ctx, r.Path, r.safeArgs("symbolic-ref", "--quiet", "--short", "HEAD")...) + result, err := r.Git.Run(ctx, r.Path, r.safeArgs("symbolic-ref", "--quiet", "HEAD")...) if err != nil { if code, ok := gitexec.ExitCode(err); ok && code == 1 { return "", nil } return "", err } - return strings.TrimSpace(string(result.Stdout)), nil + return gitexec.ParseRefOutput(result.Stdout, "refs/heads/") } func (r Repository) MergeInProgress() (bool, error) { @@ -888,7 +891,8 @@ func ValidateBranchName(ctx context.Context, git gitexec.Runner, workingDirector } return fmt.Errorf("invalid private branch %q", branch) } - if strings.TrimSpace(string(result.Stdout)) != branch { + literal, err := gitexec.ParseRefOutput(result.Stdout, "") + if err != nil || literal != branch { return fmt.Errorf("invalid private branch %q", branch) } return nil @@ -1058,12 +1062,16 @@ func (r Repository) resolveInitialBranch(ctx context.Context, requested string) return "", false, err } var branches []string - for _, line := range strings.Split(strings.TrimSpace(string(result.Stdout)), "\n") { - if line == "" || line == "refs/remotes/origin/HEAD" { + for line := range strings.SplitAfterSeq(string(result.Stdout), "\n") { + if line == "" { continue } - if strings.HasPrefix(line, "refs/remotes/origin/") { - branches = append(branches, strings.TrimPrefix(line, "refs/remotes/origin/")) + branch, err := gitexec.ParseRefOutput([]byte(line), "refs/remotes/origin/") + if err != nil { + return "", false, err + } + if branch != "HEAD" { + branches = append(branches, branch) } } sort.Strings(branches) @@ -1079,15 +1087,15 @@ func (r Repository) resolveInitialBranch(ctx context.Context, requested string) return "", false, fmt.Errorf("private branch %q does not exist", requested) } - defaultResult, defaultErr := r.Git.Run(ctx, r.Path, r.safeArgs("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")...) + defaultResult, defaultErr := r.Git.Run(ctx, r.Path, r.safeArgs("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")...) if defaultErr != nil { return "", false, ErrDefaultBranch } - value := strings.TrimSpace(string(defaultResult.Stdout)) - if !strings.HasPrefix(value, "origin/") { + branch, err := gitexec.ParseRefOutput(defaultResult.Stdout, "refs/remotes/origin/") + if err != nil { return "", false, ErrDefaultBranch } - return strings.TrimPrefix(value, "origin/"), false, nil + return branch, false, nil } func (r Repository) verifyPreparedResult(ctx context.Context, expected InitResult) error { diff --git a/internal/publicgit/repository.go b/internal/publicgit/repository.go index 1adcc6f..d76816f 100644 --- a/internal/publicgit/repository.go +++ b/internal/publicgit/repository.go @@ -144,12 +144,7 @@ func (r Repository) Branch(ctx context.Context) (string, error) { } return "", err } - ref, terminated := strings.CutSuffix(string(result.Stdout), "\n") - branch, isBranch := strings.CutPrefix(ref, "refs/heads/") - if !terminated || !isBranch || branch == "" || strings.ContainsAny(branch, "\x00\r\n") { - return "", fmt.Errorf("Git returned an invalid public branch reference") - } - return branch, nil + return gitexec.ParseRefOutput(result.Stdout, "refs/heads/") } func (r Repository) TrackedPaths(ctx context.Context) ([]pathmodel.Path, error) { From 58f93b8c90aac16866547bb31a3b18f9c687e93e Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:09:49 -0500 Subject: [PATCH 42/52] fix(cli): classify conflicting flags as invalid usage --- internal/cli/flag_groups_test.go | 94 ++++++++++++++++++++++++++++++++ internal/cli/root.go | 3 + 2 files changed, 97 insertions(+) create mode 100644 internal/cli/flag_groups_test.go diff --git a/internal/cli/flag_groups_test.go b/internal/cli/flag_groups_test.go new file mode 100644 index 0000000..8bf155e --- /dev/null +++ b/internal/cli/flag_groups_test.go @@ -0,0 +1,94 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/getspas/spas/internal/spaserr" +) + +func TestFlagGroupErrorsAreInvalidUsage(t *testing.T) { + t.Parallel() + var out, stderr bytes.Buffer + root := NewRootContext(context.Background(), bytes.NewReader(nil), &out, &stderr) + root.SetArgs([]string{"diff", "--name-only", "--stat"}) + err := root.Execute() + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindInvalidUsage { + t.Fatalf("error = %v, want typed invalid_usage", err) + } +} + +func TestExecuteRejectsConflictingDiffFlags(t *testing.T) { + for _, jsonMode := range []bool{false, true} { + args := []string{"diff", "--name-only", "--stat"} + if jsonMode { + args = append(args, "--json", "--verbose") + } + code, out, stderr := executeCaptured(t, args) + if code != 2 || len(out) != 0 { + t.Errorf("Execute(%v) = %d, stdout %q, stderr %q", args, code, out, stderr) + } + if jsonMode { + var envelope struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(stderr, &envelope); err != nil || envelope.OK || envelope.Error.Code != "invalid_usage" { + t.Errorf("JSON error = %s, decode error %v", stderr, err) + } + } else if !bytes.Contains(stderr, []byte("error:")) { + t.Errorf("missing text diagnostic: %q", stderr) + } + } +} + +func TestExecutePreservesValidFlagsAndRuntimeErrors(t *testing.T) { + code, out, stderr := executeCaptured(t, []string{"version", "--json"}) + if code != 0 || len(out) == 0 || len(stderr) != 0 { + t.Fatalf("valid command = %d, %q, %q", code, out, stderr) + } + code, out, stderr = executeCaptured(t, []string{"diff", "--name-only", "--json", "--git", filepath.Join(t.TempDir(), "missing-git")}) + if code != 1 || len(out) != 0 || !bytes.Contains(stderr, []byte(`"code":"operation_failed"`)) { + t.Fatalf("runtime failure = %d, %q, %q", code, out, stderr) + } +} + +func executeCaptured(t *testing.T, args []string) (int, []byte, []byte) { + t.Helper() + root := t.TempDir() + out, err := os.Create(filepath.Join(root, "stdout")) + if err != nil { + t.Fatal(err) + } + defer out.Close() + stderr, err := os.Create(filepath.Join(root, "stderr")) + if err != nil { + t.Fatal(err) + } + defer stderr.Close() + originalArgs, originalOut, originalErr := os.Args, os.Stdout, os.Stderr + defer func() { os.Args, os.Stdout, os.Stderr = originalArgs, originalOut, originalErr }() + os.Args, os.Stdout, os.Stderr = append([]string{"spas"}, args...), out, stderr + code := Execute() + if err := out.Close(); err != nil { + t.Fatal(err) + } + if err := stderr.Close(); err != nil { + t.Fatal(err) + } + stdoutBytes, err := os.ReadFile(out.Name()) + if err != nil { + t.Fatal(err) + } + stderrBytes, err := os.ReadFile(stderr.Name()) + if err != nil { + t.Fatal(err) + } + return code, stdoutBytes, stderrBytes +} diff --git a/internal/cli/root.go b/internal/cli/root.go index a734726..e095a47 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -113,6 +113,9 @@ commit in the project repository.`, SilenceUsage: true, Version: version.Version, PersistentPreRunE: func(command *cobra.Command, _ []string) error { + if err := command.ValidateFlagGroups(); err != nil { + return spaserr.Wrap(spaserr.KindInvalidUsage, err) + } if options.timeout < 0 { return spaserr.Wrap( spaserr.KindInvalidUsage, From 62653c7549b64e1f5bf3e260c7c0b6fcd040952b Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:09:20 -0500 Subject: [PATCH 43/52] test(app): fix symlink assertions and linked-state fixtures --- internal/app/contract_test.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index 69f38b6..5fefd10 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -246,9 +246,11 @@ func TestAddRejectsSymlinksNestedGitMetadataEmptyDirectoriesAndOutsidePaths(t *t ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip, }) - if err == nil || !strings.Contains(err.Error(), "regular file or directory") { - t.Fatalf("Add(symlink) error = %v", err) + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("Add(symlink) error = %v, want KindUnsupportedPath", err) } + } else { + t.Logf("symlink assertion not exercised: %v", err) } nested := filepath.Join(publicRoot, "nested") @@ -1964,10 +1966,7 @@ func TestRemoveAndDiffAllowAlreadyEnrolledPathsExceedingLimit(t *testing.T) { } longPath := strings.Repeat("x", 100) + "/" + strings.Repeat("y", 100) + "/enrolled.json" - state, err := instance.Store.Load(publicRoot, filepath.Join(publicRoot, ".git")) - if err != nil { - t.Fatal(err) - } + state := loadState(t, instance, publicRoot) state.ManagedPaths = []string{longPath} // Exercise argument resolution with an actual old diff operand. Git's // long-path support is independent of SPAS's enrollment preflight. @@ -1983,7 +1982,7 @@ func TestRemoveAndDiffAllowAlreadyEnrolledPathsExceedingLimit(t *testing.T) { t.Fatal(err) } - err = instance.Remove(ctx, RemoveOptions{Paths: []string{longPath}}) + err := instance.Remove(ctx, RemoveOptions{Paths: []string{longPath}}) if err != nil { t.Fatalf("Remove() error = %v, want nil for enrolled path", err) } From c5204e6b6ba725ccc9d0add9067d17b7cdd8c4b6 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:09:25 -0500 Subject: [PATCH 44/52] fix(paths): resolve selections through workspace aliases --- internal/app/workspace_alias_test.go | 77 ++++++++++++++++++++++++++++ internal/pathmodel/path.go | 46 ++++++++++++----- internal/pathmodel/resolve_test.go | 76 +++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 internal/app/workspace_alias_test.go create mode 100644 internal/pathmodel/resolve_test.go diff --git a/internal/app/workspace_alias_test.go b/internal/app/workspace_alias_test.go new file mode 100644 index 0000000..690dcd6 --- /dev/null +++ b/internal/app/workspace_alias_test.go @@ -0,0 +1,77 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/getspas/spas/internal/spaserr" +) + +func TestExpandPathsThroughWorkspaceAlias(t *testing.T) { + t.Parallel() + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + if err := os.MkdirAll(filepath.Join(workspace, "assets"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "assets", "secret.env"), []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(workspace, alias); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + instance := App{PathBase: alias} + for _, selected := range []string{"assets/secret.env", "assets"} { + paths, err := instance.expandPaths(workspace, root, []string{filepath.Join(alias, filepath.FromSlash(selected))}) + if err != nil { + t.Fatal(err) + } + if len(paths) != 1 || paths[0] != "assets/secret.env" { + t.Fatalf("expandPaths(%q) = %q", selected, paths) + } + } + link := filepath.Join(workspace, "link") + if err := os.Symlink("assets", link); err != nil { + t.Fatal(err) + } + _, err := instance.expandPaths(workspace, root, []string{filepath.Join(alias, "link", "secret.env")}) + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("expandPaths(managed symlink) = %v, want KindUnsupportedPath", err) + } +} + +func TestExpandPathsThroughAliasPreservesUnicodeValidation(t *testing.T) { + t.Parallel() + workspace := t.TempDir() + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(workspace, alias); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + raw := filepath.Join(workspace, "cafe\u0301.env") + nfc := filepath.Join(workspace, "caf\u00e9.env") + if err := os.WriteFile(raw, []byte("selected"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nfc, []byte("normalized"), 0o600); err != nil { + t.Fatal(err) + } + rawInfo, err := os.Stat(raw) + if err != nil { + t.Fatal(err) + } + nfcInfo, err := os.Stat(nfc) + if err != nil { + t.Fatal(err) + } + paths, err := (App{PathBase: alias}).expandPaths(workspace, workspace, + []string{filepath.Join(alias, "cafe\u0301.env")}) + if os.SameFile(rawInfo, nfcInfo) { + if err != nil || len(paths) != 1 || paths[0] != "caf\u00e9.env" { + t.Fatalf("expandPaths(genuine Unicode alias) = %q, %v", paths, err) + } + } else if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("expandPaths(distinct Unicode entries) = %q, %v; want KindUnsupportedPath", paths, err) + } +} diff --git a/internal/pathmodel/path.go b/internal/pathmodel/path.go index 0d634d2..9e8efd9 100644 --- a/internal/pathmodel/path.go +++ b/internal/pathmodel/path.go @@ -6,6 +6,7 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "strings" "unicode" "unicode/utf8" @@ -87,16 +88,14 @@ func ParseObserved(value string) (Path, error) { } func Resolve(publicRoot, base, value string) (Path, string, error) { - resolvedRoot, err := filepath.EvalSymlinks(publicRoot) + publicRoot, err := filepath.Abs(publicRoot) if err != nil { return "", "", fmt.Errorf("resolve public workspace: %w", err) } - publicRoot = resolvedRoot - resolvedBase, err := filepath.EvalSymlinks(base) + workspace, err := os.Stat(publicRoot) if err != nil { - return "", "", fmt.Errorf("resolve path base: %w", err) + return "", "", fmt.Errorf("inspect public workspace: %w", err) } - base = resolvedBase if filepath.IsAbs(value) { base = "" } @@ -104,15 +103,38 @@ func Resolve(publicRoot, base, value string) (Path, string, error) { if err != nil { return "", "", fmt.Errorf("resolve path %q: %w", value, err) } - relative, err := filepath.Rel(publicRoot, absolute) - if err != nil { - return "", "", fmt.Errorf("make path relative to public workspace: %w", err) + var prefixes []string + for prefix := absolute; ; prefix = filepath.Dir(prefix) { + prefixes = append(prefixes, prefix) + if filepath.Dir(prefix) == prefix { + break + } } - path, err := Parse(filepath.ToSlash(relative)) - if err != nil { - return "", "", err + // Match the workspace from the filesystem root inward, before inspecting + // managed components. Rebase only that prefix so Unicode spelling, missing + // entries, and managed symlinks remain visible to subsequent validation. + for _, prefix := range slices.Backward(prefixes) { + info, err := os.Stat(prefix) + if os.IsNotExist(err) { + break + } + if err != nil { + return "", "", fmt.Errorf("inspect path prefix: %w", err) + } + if !os.SameFile(workspace, info) { + continue + } + relative, err := filepath.Rel(prefix, absolute) + if err != nil { + return "", "", fmt.Errorf("make path relative to public workspace: %w", err) + } + path, err := Parse(filepath.ToSlash(relative)) + if err != nil { + return "", "", err + } + return path, filepath.Join(publicRoot, relative), nil } - return path, absolute, nil + return "", "", fmt.Errorf("path must stay inside the public workspace") } func ValidatePathLength(root string, path Path) error { if runtime.GOOS != "windows" { diff --git a/internal/pathmodel/resolve_test.go b/internal/pathmodel/resolve_test.go new file mode 100644 index 0000000..ed4c782 --- /dev/null +++ b/internal/pathmodel/resolve_test.go @@ -0,0 +1,76 @@ +package pathmodel + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveWorkspaceAliasPreservesSelectedComponents(t *testing.T) { + t.Parallel() + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + if err := os.Mkdir(workspace, 0o700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(workspace, alias); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + for _, selection := range []string{"secret.env", "cafe\u0301/re\u0301sume\u0301.txt", "missing/parent/file"} { + for _, absoluteInput := range []bool{false, true} { + value := filepath.FromSlash(selection) + if absoluteInput { + value = filepath.Join(alias, value) + } + path, observed, err := Resolve(workspace, alias, value) + if err != nil { + t.Fatalf("Resolve(%q): %v", value, err) + } + wantPath := selection + if selection == "cafe\u0301/re\u0301sume\u0301.txt" { + wantPath = "caf\u00e9/r\u00e9sum\u00e9.txt" + } + if path.String() != wantPath || observed != filepath.Join(workspace, filepath.FromSlash(selection)) { + t.Fatalf("Resolve(%q) = %q, %q", value, path, observed) + } + } + } + if _, _, err := Resolve(workspace, alias, filepath.Join(root, "outside.env")); err == nil { + t.Fatal("Resolve accepted an outside selection") + } +} + +func TestResolvePreservesManagedDirectorySymlinks(t *testing.T) { + t.Parallel() + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + if err := os.Mkdir(workspace, 0o700); err != nil { + t.Fatal(err) + } + for _, target := range []string{workspace, root} { + link := filepath.Join(workspace, "link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + for _, absoluteInput := range []bool{false, true} { + value := "file.env" + if absoluteInput { + value = filepath.Join(link, value) + } + path, observed, err := Resolve(workspace, link, value) + if err != nil { + t.Fatal(err) + } + if path != "link/file.env" || observed != filepath.Join(link, "file.env") { + t.Fatalf("Resolve() = %q, %q; managed directory symlink was followed", path, observed) + } + if err := ValidateNoSymlinkComponents(workspace, path); err == nil { + t.Fatal("managed directory symlink escaped validation") + } + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + } +} From afafd14fe282b72335fc85552bb277298ec08329 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:11:30 -0500 Subject: [PATCH 45/52] fix(paths): preserve directory-entry spelling during enrollment --- internal/app/app.go | 18 ++--- internal/app/enrollment_test.go | 69 ++++++++++++++++- internal/pathmodel/nfc.go | 131 +++++++++++++++++++++++--------- internal/pathmodel/nfc_test.go | 47 +++++++++++- 4 files changed, 213 insertions(+), 52 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index b0d9174..df28d3d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1036,8 +1036,9 @@ func (a App) validateRepositoryIdentity(state linkstate.State) error { func (a App) expandPaths(workspaceRoot, privateRoot string, values []string) ([]pathmodel.Path, error) { set := make(map[string]pathmodel.Path) + observer := pathmodel.NewObserver(workspaceRoot) for _, value := range values { - path, absolute, err := pathmodel.Resolve(workspaceRoot, a.PathBase, value) + _, absolute, err := pathmodel.Resolve(workspaceRoot, a.PathBase, value) if err != nil { return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf("resolve managed path %q: %w", value, err)) } @@ -1045,7 +1046,8 @@ func (a App) expandPaths(workspaceRoot, privateRoot string, values []string) ([] if err != nil { return nil, fmt.Errorf("inspect %q: %w", value, err) } - if err := pathmodel.ValidateNFCSpelling(workspaceRoot, absolute); err != nil { + path, err := observer.Path(absolute) + if err != nil { return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, err) } if info.Mode().IsRegular() { @@ -1086,11 +1088,7 @@ func (a App) expandPaths(workspaceRoot, privateRoot string, values []string) ([] if !entry.Type().IsRegular() { return spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf("directory %q contains unsupported file type %q", value, current)) } - relative, err := filepath.Rel(workspaceRoot, current) - if err != nil { - return err - } - managed, err := pathmodel.Parse(filepath.ToSlash(relative)) + managed, err := observer.Path(current) if err != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, err) } @@ -1103,9 +1101,6 @@ func (a App) expandPaths(workspaceRoot, privateRoot string, values []string) ([] if err := privategit.ValidateManagedPath(managed); err != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, err) } - if err := pathmodel.ValidateNFCSpelling(workspaceRoot, current); err != nil { - return spaserr.Wrap(spaserr.KindUnsupportedPath, err) - } set[managed.String()] = managed return nil }) @@ -1113,6 +1108,9 @@ func (a App) expandPaths(workspaceRoot, privateRoot string, values []string) ([] return nil, err } } + if err := observer.Validate(); err != nil { + return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, err) + } result := make([]pathmodel.Path, 0, len(set)) for _, path := range set { result = append(result, path) diff --git a/internal/app/enrollment_test.go b/internal/app/enrollment_test.go index 4e168bb..35402c4 100644 --- a/internal/app/enrollment_test.go +++ b/internal/app/enrollment_test.go @@ -20,6 +20,7 @@ func TestAddRejectsDistinctUnicodeEntries(t *testing.T) { directory bool hardLink bool parent bool + nfc bool }{ {name: "selected file"}, {name: "directory", directory: true}, @@ -27,6 +28,8 @@ func TestAddRejectsDistinctUnicodeEntries(t *testing.T) { {name: "directory hard links", directory: true, hardLink: true}, {name: "parent directories", parent: true}, {name: "recursive parent directories", directory: true, parent: true}, + {name: "NFC selection", nfc: true}, + {name: "NFC hard-link selection", nfc: true, hardLink: true}, } { t.Run(test.name, func(t *testing.T) { t.Parallel() @@ -113,6 +116,9 @@ func TestAddRejectsDistinctUnicodeEntries(t *testing.T) { } } selection := raw + if test.nfc { + selection = nfc + } if test.directory { selection = assets } @@ -180,9 +186,6 @@ func TestAddUnicodeSingleEntry(t *testing.T) { } state := loadState(t, instance, publicRoot) want := "caf\u00e9/r\u00e9sum\u00e9.txt" - if selection == "case alias" { - want = "CAF\u00c9/R\u00c9SUM\u00c9.TXT" - } if len(state.PendingAdds) != 1 || state.PendingAdds[0] != want { t.Fatalf("PendingAdds = %q, want canonical spelling", state.PendingAdds) } @@ -197,3 +200,63 @@ func TestAddUnicodeSingleEntry(t *testing.T) { }) } } + +func TestAddUsesDirectoryEntrySpelling(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name, stored, selected string + }{ + {"NFC case alias", "café/résumé.txt", "CAFÉ/RÉSUMÉ.TXT"}, + {"uppercase entry", "CAFÉ/RÉSUMÉ.TXT", "café/résumé.txt"}, + {"parent alias", "café/résumé.txt", "CAFÉ/résumé.txt"}, + {"directory alias", "café/résumé.txt", "CAFÉ"}, + {"ASCII alias", "assets/secret.txt", "ASSETS/SECRET.TXT"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + instance, publicRoot, _, _ := fixture(t) + stored := filepath.Join(publicRoot, filepath.FromSlash(test.stored)) + if err := os.MkdirAll(filepath.Dir(stored), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stored, []byte("selected secret\n"), 0o600); err != nil { + t.Fatal(err) + } + selected := filepath.Join(publicRoot, filepath.FromSlash(test.selected)) + if _, err := os.Stat(selected); errors.Is(err, os.ErrNotExist) { + t.Skip("volume distinguishes the selected case spelling") + } else if err != nil { + t.Fatal(err) + } + if err := instance.Add(t.Context(), AddOptions{ + Paths: []string{selected}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip, + }); err != nil { + t.Fatal(err) + } + state := loadState(t, instance, publicRoot) + if len(state.PendingAdds) != 1 || state.PendingAdds[0] != test.stored { + t.Fatalf("PendingAdds = %q, want %q", state.PendingAdds, test.stored) + } + result, err := instance.Git.Run(t.Context(), publicRoot, "ls-files", "--others", "--exclude-standard", "-z") + if err != nil || len(result.Stdout) != 0 { + t.Fatalf("ordinary Git visibility = %q, %v", result.Stdout, err) + } + data, err := os.ReadFile(stored) + if err != nil || string(data) != "selected secret\n" { + t.Fatalf("selected content = %q, %v", data, err) + } + if test.name == "directory alias" { + selected = filepath.Join(selected, "résumé.txt") + } + if err := instance.Diff(t.Context(), DiffOptions{Paths: []string{selected}}); err != nil { + t.Fatalf("Diff(alias): %v", err) + } + if err := instance.Remove(t.Context(), RemoveOptions{Paths: []string{selected}}); err != nil { + t.Fatalf("Remove(alias): %v", err) + } + if state := loadState(t, instance, publicRoot); len(state.PendingAdds) != 0 { + t.Fatalf("Remove(alias) retained pending paths: %q", state.PendingAdds) + } + }) + } +} diff --git a/internal/pathmodel/nfc.go b/internal/pathmodel/nfc.go index dab15e5..0f20da0 100644 --- a/internal/pathmodel/nfc.go +++ b/internal/pathmodel/nfc.go @@ -2,6 +2,7 @@ package pathmodel import ( "fmt" + "maps" "os" "path/filepath" "strings" @@ -9,51 +10,105 @@ import ( "golang.org/x/text/unicode/norm" ) -// ValidateNFCSpelling checks that normalizing an observed path preserves each -// selected directory entry. Filesystems may alias Unicode spellings; distinct -// entries, including hard links, require distinct exclusion rules. -func ValidateNFCSpelling(root, observed string) error { +// Observer indexes directory names for one enrollment operation. File identity +// and symlink checks are performed for every selection. Validate must succeed +// before the caller uses the collected paths to change enrollment state. +type Observer struct { + root string + directories map[string]map[string]string +} + +func NewObserver(root string) *Observer { + return &Observer{root: root, directories: make(map[string]map[string]string)} +} + +// Path returns actual directory-entry case in NFC. Each component must identify +// one entry whose NFC spelling still addresses the same file. +func (o *Observer) Path(observed string) (Path, error) { + root := o.root relative, err := filepath.Rel(root, observed) if err != nil { - return err + return "", err + } + if _, err := Parse(filepath.ToSlash(relative)); err != nil { + return "", err } raw := Path(filepath.ToSlash(relative)) if err := ValidateNoSymlinkComponents(root, raw); err != nil { - return err + return "", err } parent := root - for _, component := range strings.Split(raw.String(), "/") { - canonical := norm.NFC.String(component) - if component != canonical { - selected, err := os.Lstat(filepath.Join(parent, component)) - if err != nil { - return err - } - normalized, err := os.Lstat(filepath.Join(parent, canonical)) - if err != nil { - return fmt.Errorf("%q cannot be addressed by its Unicode NFC spelling %q: %w", observed, canonical, err) - } - if !os.SameFile(selected, normalized) { - return fmt.Errorf("%q and its Unicode NFC spelling %q select different entries", observed, canonical) - } - entries, err := os.ReadDir(parent) - if err != nil { - return err - } - matches := 0 - key := Canonical(Path(canonical), true) - for _, entry := range entries { - // Apply the portable case policy to names returned by the - // filesystem, which may use a different case from the request. - if Canonical(Path(entry.Name()), true) == key { - matches++ - } - } - if matches != 1 { - return fmt.Errorf("%q has ambiguous directory entries for Unicode NFC spelling %q", observed, canonical) - } - } - parent = filepath.Join(parent, component) + var components []string + for component := range strings.SplitSeq(raw.String(), "/") { + selected, err := os.Lstat(filepath.Join(parent, component)) + if err != nil { + return "", err + } + names, err := o.directoryNames(parent) + if err != nil { + return "", err + } + key := Canonical(Path(component), true) + name, exists := names[key] + if !exists { + return "", fmt.Errorf("%q has no matching directory entry for %q", observed, component) + } + if name == "" { + return "", fmt.Errorf("%q has ambiguous directory entries for %q", observed, component) + } + canonical := norm.NFC.String(name) + normalized, err := os.Lstat(filepath.Join(parent, canonical)) + if err != nil { + return "", fmt.Errorf("%q cannot be addressed by its Unicode NFC spelling %q: %w", observed, canonical, err) + } + if !os.SameFile(selected, normalized) { + return "", fmt.Errorf("%q and its Unicode NFC spelling %q select different entries", observed, canonical) + } + components = append(components, canonical) + parent = filepath.Join(parent, name) + } + return Parse(strings.Join(components, "/")) +} + +func (o *Observer) directoryNames(parent string) (map[string]string, error) { + if names, exists := o.directories[parent]; exists { + return names, nil + } + names, err := readDirectoryNames(parent) + if err != nil { + return nil, err + } + o.directories[parent] = names + return names, nil +} + +// Validate rejects directory-name changes during path collection. +func (o *Observer) Validate() error { + for parent, names := range o.directories { + current, err := readDirectoryNames(parent) + if err != nil { + return err + } + if !maps.Equal(names, current) { + return fmt.Errorf("directory entries changed during enrollment in %q", parent) + } } return nil } + +func readDirectoryNames(parent string) (map[string]string, error) { + entries, err := os.ReadDir(parent) + if err != nil { + return nil, err + } + names := make(map[string]string, len(entries)) + for _, entry := range entries { + key := Canonical(Path(entry.Name()), true) + if _, exists := names[key]; exists { + names[key] = "" // Multiple entries have the same portable comparison key. + } else { + names[key] = entry.Name() + } + } + return names, nil +} diff --git a/internal/pathmodel/nfc_test.go b/internal/pathmodel/nfc_test.go index 1d0a9fe..8b64075 100644 --- a/internal/pathmodel/nfc_test.go +++ b/internal/pathmodel/nfc_test.go @@ -1,6 +1,10 @@ package pathmodel -import "testing" +import ( + "os" + "path/filepath" + "testing" +) // Managed paths are normalized to NFC at the single parsing funnel, matching // the precomposed form Git uses on macOS: exclude patterns are not normalized @@ -25,3 +29,44 @@ func TestParseNormalizesToNFC(t *testing.T) { t.Fatalf("stored spelling = %q, want %q", got.String(), want) } } + +func TestObserverRechecksSymlinksAfterIndexingNames(t *testing.T) { + t.Parallel() + root := t.TempDir() + selected := filepath.Join(root, "secret.env") + if err := os.WriteFile(selected, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + observer := NewObserver(root) + if _, err := observer.Path(selected); err != nil { + t.Fatal(err) + } + if err := os.Remove(selected); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target.env", selected); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + if _, err := observer.Path(selected); err == nil { + t.Fatal("observer accepted a replacement symlink") + } +} + +func TestObserverDetectsRenamedDirectoryEntries(t *testing.T) { + t.Parallel() + root := t.TempDir() + selected := filepath.Join(root, "café.env") + if err := os.WriteFile(selected, nil, 0o600); err != nil { + t.Fatal(err) + } + observer := NewObserver(root) + if _, err := observer.Path(selected); err != nil { + t.Fatal(err) + } + if err := os.Rename(selected, filepath.Join(root, "CAFÉ.env")); err != nil { + t.Fatal(err) + } + if err := observer.Validate(); err == nil { + t.Fatal("observer accepted changed directory spelling") + } +} From 0824a8a4313116abac7927a5d1d7cce9dc9110d0 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:11:30 -0500 Subject: [PATCH 46/52] perf(tests): remove Git proxy race exit delays --- internal/app/diff_test.go | 2 +- internal/app/integration_test.go | 2 +- internal/app/main_test.go | 40 ++++++++++++++++++++++++++++++++ internal/app/regression_test.go | 16 ++++++------- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/internal/app/diff_test.go b/internal/app/diff_test.go index 09bb2e0..ba0f090 100644 --- a/internal/app/diff_test.go +++ b/internal/app/diff_test.go @@ -125,7 +125,7 @@ func TestDiffPropagatesOperandDisappearance(t *testing.T) { if err != nil { t.Fatal(err) } - t.Setenv("SPAS_APP_GIT_PROXY", "remove-before-diff") + enableGitProxy(t, "remove-before-diff") t.Setenv("SPAS_APP_REAL_GIT", realGit) t.Setenv("SPAS_APP_EDIT_PATH", path) instance.Git.Path = os.Args[0] diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 7d86908..096db00 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -649,7 +649,7 @@ func TestFailedPrivateCommitRollsBackManagedClone(t *testing.T) { if err != nil { t.Fatal(err) } - t.Setenv("SPAS_APP_GIT_PROXY", "fail-commit") + enableGitProxy(t, "fail-commit") t.Setenv("SPAS_APP_REAL_GIT", realGit) instance.Git.Path = os.Args[0] if err := os.WriteFile(filepath.Join(publicRoot, "docs", "ARCHITECTURE.md"), []byte("cannot commit\n"), 0o600); err != nil { diff --git a/internal/app/main_test.go b/internal/app/main_test.go index d16d90c..0d57bda 100644 --- a/internal/app/main_test.go +++ b/internal/app/main_test.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "os" "os/exec" @@ -16,6 +17,45 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +func enableGitProxy(t *testing.T, mode string) { + t.Helper() + // The parent race runtime is already initialized. Only subsequently started + // helpers use this exit policy; their Git command finishes before they exit. + t.Setenv("GORACE", strings.TrimSpace(os.Getenv("GORACE")+" atexit_sleep_ms=0")) + t.Setenv("SPAS_APP_GIT_PROXY", mode) +} + +func TestGitProxyPreservesOptionsAndExitStatus(t *testing.T) { + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + t.Setenv("GORACE", "history_size=2 exitcode=77 atexit_sleep_ms=1000") + enableGitProxy(t, "passthrough") + t.Setenv("SPAS_APP_REAL_GIT", realGit) + if got := os.Getenv("GORACE"); got != "history_size=2 exitcode=77 atexit_sleep_ms=1000 atexit_sleep_ms=0" { + t.Fatalf("helper race options = %q", got) + } + for _, args := range [][]string{{"--version"}, {"spas-nonexistent-command"}} { + directOutput, directErr := exec.CommandContext(t.Context(), realGit, args...).CombinedOutput() + proxyOutput, proxyErr := exec.CommandContext(t.Context(), os.Args[0], args...).CombinedOutput() + if string(proxyOutput) != string(directOutput) { + t.Fatalf("proxy output = %q, want %q", proxyOutput, directOutput) + } + if directErr == nil { + if proxyErr != nil { + t.Fatalf("proxy failed: %v", proxyErr) + } + } else { + directExit, directOK := errors.AsType[*exec.ExitError](directErr) + proxyExit, proxyOK := errors.AsType[*exec.ExitError](proxyErr) + if !directOK || !proxyOK || directExit.ExitCode() != proxyExit.ExitCode() { + t.Fatalf("proxy error = %v, want %v", proxyErr, directErr) + } + } + } +} + func runGitProxy() int { args := os.Args[1:] mode := os.Getenv("SPAS_APP_GIT_PROXY") diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index 9e79b8c..9638114 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -1643,7 +1643,7 @@ func TestFailedAutomaticMergeAbortRetainsRecoveryState(t *testing.T) { if err != nil { t.Fatal(err) } - t.Setenv("SPAS_APP_GIT_PROXY", "fail-write-tree-and-abort") + enableGitProxy(t, "fail-write-tree-and-abort") t.Setenv("SPAS_APP_REAL_GIT", realGit) instance.Git.Path = os.Args[0] err = instance.Sync(ctx, SyncOptions{Continue: true, Message: "resolve conflict"}) @@ -1745,7 +1745,7 @@ func TestAutomaticMergeAbortRetainsRecoveryStateWhenMarkerCannotBeInspected(t *t if err != nil { t.Fatal(err) } - t.Setenv("SPAS_APP_GIT_PROXY", "fail-write-tree-and-recreate-marker") + enableGitProxy(t, "fail-write-tree-and-recreate-marker") t.Setenv("SPAS_APP_REAL_GIT", realGit) t.Setenv("SPAS_APP_MERGE_MARKER", marker) instance.Git.Path = os.Args[0] @@ -1788,7 +1788,7 @@ func TestMergeAbortRetainsRecoveryStateWhenMergeMarkerCannotBeInspected(t *testi if err != nil { t.Fatal(err) } - t.Setenv("SPAS_APP_GIT_PROXY", "recreate-marker-after-abort") + enableGitProxy(t, "recreate-marker-after-abort") t.Setenv("SPAS_APP_REAL_GIT", realGit) t.Setenv("SPAS_APP_MERGE_MARKER", marker) instance.Git.Path = os.Args[0] @@ -1828,7 +1828,7 @@ func TestMergeAbortRejectsDirtyPrivateCloneBeforeMaterialization(t *testing.T) { if err != nil { t.Fatal(err) } - t.Setenv("SPAS_APP_GIT_PROXY", "edit-private-on-abort-tracked-paths") + enableGitProxy(t, "edit-private-on-abort-tracked-paths") t.Setenv("SPAS_APP_REAL_GIT", realGit) t.Setenv("SPAS_APP_EDIT_PATH", privatePath) t.Setenv("SPAS_APP_EDIT_CONTENT", "dirty private abort source\n") @@ -1879,7 +1879,7 @@ func TestMergeAbortRejectsWorkspaceEditDuringGitAbort(t *testing.T) { if err != nil { t.Fatal(err) } - t.Setenv("SPAS_APP_GIT_PROXY", "edit-on-private-abort") + enableGitProxy(t, "edit-on-private-abort") t.Setenv("SPAS_APP_REAL_GIT", realGit) t.Setenv("SPAS_APP_EDIT_PATH", conflictPath) t.Setenv("SPAS_APP_EDIT_CONTENT", "edit during abort\n") @@ -1932,7 +1932,7 @@ func TestMergeAbortRejectsWorkspaceEditAfterGitAbort(t *testing.T) { if err != nil { t.Fatal(err) } - t.Setenv("SPAS_APP_GIT_PROXY", "edit-after-private-abort") + enableGitProxy(t, "edit-after-private-abort") t.Setenv("SPAS_APP_REAL_GIT", realGit) t.Setenv("SPAS_APP_ABORT_MARKER", filepath.Join(root, "abort-completed")) t.Setenv("SPAS_APP_EDIT_PATH", conflictPath) @@ -2406,7 +2406,7 @@ func TestOwnershipOverrideRejectsEditDuringPublicUntracking(t *testing.T) { if err != nil { t.Fatal(err) } - t.Setenv("SPAS_APP_GIT_PROXY", "edit-on-public-rm") + enableGitProxy(t, "edit-on-public-rm") t.Setenv("SPAS_APP_REAL_GIT", realGit) t.Setenv("SPAS_APP_EDIT_PATH", publicPath) t.Setenv("SPAS_APP_EDIT_CONTENT", "late public edit\n") @@ -2541,7 +2541,7 @@ func TestStructuredNonFastForwardRetryIsBounded(t *testing.T) { t.Fatal(err) } countPath := filepath.Join(root, "push-attempts") - t.Setenv("SPAS_APP_GIT_PROXY", "fail-push-nff") + enableGitProxy(t, "fail-push-nff") t.Setenv("SPAS_APP_REAL_GIT", realGit) t.Setenv("SPAS_APP_PUSH_COUNT", countPath) instance.Git.Path = os.Args[0] From 846a83a4c4b54a6798d919551ae4c3854b2e344b Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:41:10 -0500 Subject: [PATCH 47/52] ci: disable native test-result caching and retain timing logs Run native tests with -count=1 and -json. Upload results, stderr, and command timestamps while preserving test exit codes. --- .github/workflows/ci.yml | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4be54e8..650df40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,38 @@ jobs: cache: true cache-dependency-path: go.sum - name: Test - run: go test -timeout 30m ./... + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $logs = Join-Path $env:RUNNER_TEMP 'spas-native-tests' + New-Item -ItemType Directory -Path $logs -Force | Out-Null + $timing = [ordered]@{ + commit = $env:GITHUB_SHA + startUtc = [DateTimeOffset]::UtcNow.ToString('o') + endUtc = $null + exitCode = $null + } + $timing | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path $logs 'command.json') + $testExit = 1 + try { + go test -count=1 -json -timeout 30m ./... 2> (Join-Path $logs 'stderr.log') | + Tee-Object -FilePath (Join-Path $logs 'tests.jsonl') + $testExit = $LASTEXITCODE + } + finally { + $timing.endUtc = [DateTimeOffset]::UtcNow.ToString('o') + $timing.exitCode = $testExit + $timing | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path $logs 'command.json') + } + exit $testExit + - name: Upload native test timing + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-test-timing-${{ matrix.name }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/spas-native-tests/ + if-no-files-found: warn + retention-days: 14 - name: Vet run: go vet ./... From 0fed221d377289a3d6a552ccb6de435ee1d08e80 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:24:45 -0500 Subject: [PATCH 48/52] fix(doctor): report repository inspection failures --- internal/app/diagnostics.go | 6 +- internal/app/doctor_discovery_test.go | 74 +++++++++++++++ internal/cli/doctor_test.go | 62 +++++++++++++ internal/publicgit/discovery_test.go | 125 ++++++++++++++++++++++++++ internal/publicgit/repository.go | 45 +++++++++- internal/publicgit/repository_test.go | 5 ++ 6 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 internal/app/doctor_discovery_test.go create mode 100644 internal/cli/doctor_test.go create mode 100644 internal/publicgit/discovery_test.go diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go index e48e3af..218532a 100644 --- a/internal/app/diagnostics.go +++ b/internal/app/diagnostics.go @@ -261,7 +261,11 @@ func (a App) Doctor(ctx context.Context) error { repository, repoErr := a.publicRepository(ctx) if repoErr != nil { - add("workspace", "warning", fmt.Sprintf("not a Git repository — link checks skipped: %v", repoErr)) + if errors.Is(repoErr, publicgit.ErrNotRepository) { + add("workspace", "warning", fmt.Sprintf("not a Git repository — link checks skipped: %v", repoErr)) + } else { + add("workspace", "error", fmt.Sprintf("repository inspection failed: %v", repoErr)) + } return a.renderDoctorResult(result) } diff --git a/internal/app/doctor_discovery_test.go b/internal/app/doctor_discovery_test.go new file mode 100644 index 0000000..5c46378 --- /dev/null +++ b/internal/app/doctor_discovery_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDoctorReportsCorruptRepositoryConfiguration(t *testing.T) { + t.Parallel() + root := t.TempDir() + workspace := initializePublicRepository(t, root) + if err := os.WriteFile(filepath.Join(workspace, ".git", "config"), []byte("[unterminated\n"), 0o600); err != nil { + t.Fatal(err) + } + instance, output := testApp(t, workspace, root, "") + instance.JSON = true + if err := instance.Doctor(t.Context()); err == nil { + t.Error("Doctor returned success for corrupt Git configuration") + } + var result DoctorResult + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.Healthy || result.Errors == 0 { + t.Fatalf("Doctor health = %s", output.String()) + } + for _, check := range result.Checks { + if check.Name == "workspace" && check.Status == "error" && strings.Contains(check.Message, "bad config") { + return + } + } + t.Fatalf("Doctor omitted the configuration failure: %s", output.String()) +} + +func TestDoctorFailedDiscoveryPreservesOutputError(t *testing.T) { + t.Parallel() + root := t.TempDir() + instance, _ := testApp(t, filepath.Join(root, "missing"), root, "") + writeErr := errors.New("diagnostic output unavailable") + instance.Out = failedDiffWriter{err: writeErr} + for _, jsonOutput := range []bool{false, true} { + instance.JSON = jsonOutput + if err := instance.Doctor(t.Context()); !errors.Is(err, writeErr) { + t.Fatalf("Doctor output error = %v", err) + } + } +} + +func TestDoctorCanceledInspectionIsUnhealthy(t *testing.T) { + t.Parallel() + root := t.TempDir() + instance, output := testApp(t, root, root, "") + instance.JSON = true + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := instance.Doctor(ctx); err == nil { + t.Fatal("Doctor ignored cancellation") + } + var result DoctorResult + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + for _, check := range result.Checks { + if check.Name == "workspace" && check.Status == "error" { + return + } + } + t.Fatalf("canceled inspection was not reported as an error: %s", output.String()) +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go new file mode 100644 index 0000000..b743d37 --- /dev/null +++ b/internal/cli/doctor_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/getspas/spas/internal/app" +) + +func TestExecuteDoctorDistinguishesCorruptionFromAbsence(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"HOME", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_DATA_HOME"} { + t.Setenv(name, root) + } + workspace := filepath.Join(root, "workspace") + if output, err := exec.CommandContext(t.Context(), "git", "init", "-q", "-b", "main", workspace).CombinedOutput(); err != nil { + t.Fatalf("git init: %v, %s", err, output) + } + if err := os.WriteFile(filepath.Join(workspace, ".git", "config"), []byte("[broken\n"), 0o600); err != nil { + t.Fatal(err) + } + outside := filepath.Join(root, "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name, directory string + code int + }{ + {"corrupt", workspace, 1}, + {"outside", outside, 0}, + } { + t.Run(test.name, func(t *testing.T) { + for _, jsonMode := range []bool{false, true} { + args := []string{"doctor", "--repo", test.directory} + if jsonMode { + args = append(args, "--json") + } + code, output, stderr := executeCaptured(t, args) + if code != test.code { + t.Fatalf("Doctor exit = %d, output=%s stderr=%s", code, output, stderr) + } + if jsonMode { + var result app.DoctorResult + if err := json.Unmarshal(output, &result); err != nil { + t.Fatal(err) + } + if result.Healthy != (test.code == 0) || (result.Errors == 0) != (test.code == 0) || len(stderr) != 0 { + t.Fatalf("Doctor result = %s, stderr=%s", output, stderr) + } + } + if test.code == 1 && !strings.Contains(string(output), "bad config") { + t.Fatalf("Doctor hid configuration error: %s", output) + } + } + }) + } +} diff --git a/internal/publicgit/discovery_test.go b/internal/publicgit/discovery_test.go new file mode 100644 index 0000000..c417eee --- /dev/null +++ b/internal/publicgit/discovery_test.go @@ -0,0 +1,125 @@ +package publicgit + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/getspas/spas/internal/gitexec" +) + +func TestDiscoverDistinguishesAbsenceFromFailure(t *testing.T) { + t.Parallel() + for _, kind := range []string{"outside", "corrupt config", "invalid marker", "invalid gitfile", "missing directory"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + hint := root + switch kind { + case "corrupt config": + runGit(t, root, "init", "-q") + if err := os.WriteFile(filepath.Join(root, ".git", "config"), []byte("[broken\n"), 0o600); err != nil { + t.Fatal(err) + } + case "invalid marker": + if err := os.Mkdir(filepath.Join(root, ".git"), 0o700); err != nil { + t.Fatal(err) + } + hint = filepath.Join(root, "nested") + if err := os.Mkdir(hint, 0o700); err != nil { + t.Fatal(err) + } + case "invalid gitfile": + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: missing-target\n"), 0o600); err != nil { + t.Fatal(err) + } + case "missing directory": + hint = filepath.Join(root, "missing") + } + _, err := Discover(t.Context(), gitexec.Runner{}, hint) + if err == nil || errors.Is(err, ErrNotRepository) != (kind == "outside") { + t.Fatalf("Discover(%s) = %v", kind, err) + } + if kind != "missing directory" { + if _, ok := gitexec.ExitCode(err); !ok { + t.Fatalf("discovery discarded Git's error: %v", err) + } + } + }) + } +} + +func TestDiscoverPreservesCanceledCause(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := Discover(ctx, gitexec.Runner{}, t.TempDir()); !errors.Is(err, context.Canceled) { + t.Fatalf("Discover cancellation = %v", err) + } +} + +func TestDiscoverRejectsInvalidRootOutput(t *testing.T) { + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + t.Setenv("SPAS_PUBLICGIT_PROXY", "invalid-root-output") + t.Setenv("SPAS_PUBLICGIT_REAL_GIT", realGit) + if _, err := Discover(t.Context(), gitexec.Runner{Path: os.Args[0]}, t.TempDir()); err == nil || errors.Is(err, ErrNotRepository) { + t.Fatalf("Discover invalid output = %v", err) + } +} + +func TestDiscoverRejectsInaccessibleMetadata(t *testing.T) { + t.Parallel() + root := t.TempDir() + runGit(t, root, "init", "-q") + marker := filepath.Join(root, ".git") + info, err := os.Stat(marker) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chmod(marker, info.Mode().Perm()); err != nil { + t.Error(err) + } + }) + if err := os.Chmod(marker, 0); err != nil { + t.Fatal(err) + } + if file, err := os.Open(filepath.Join(marker, "config")); err == nil { + file.Close() + t.Skip("filesystem or user does not enforce permission denial") + } else if !errors.Is(err, os.ErrPermission) { + t.Fatal(err) + } + if _, err := Discover(t.Context(), gitexec.Runner{}, root); err == nil || errors.Is(err, ErrNotRepository) { + t.Fatalf("Discover inaccessible metadata = %v", err) + } +} + +func TestRepositoryAbsenceDiagnostic(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name, stderr, stdout string + code int + want bool + }{ + {"outside", "fatal: not a git repository (or any of the parent directories): .git\n", "", 128, true}, + {"mount", "fatal: not a git repository (or any parent up to mount point /tmp)\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).\n", "", 128, true}, + {"config", "fatal: bad config line 1 in file .git/config\n", "", 128, false}, + {"explicit gitdir", "fatal: not a git repository: (NULL)\n", "", 128, false}, + {"wrong status", "fatal: not a git repository (or any of the parent directories): .git\n", "", 1, false}, + {"partial output", "fatal: not a git repository (or any of the parent directories): .git\n", "/workspace\n", 128, false}, + } { + t.Run(test.name, func(t *testing.T) { + err := &gitexec.ExitError{ExitCode: test.code, Stderr: test.stderr} + if got := repositoryAbsentDiagnostic(gitexec.Result{Stdout: []byte(test.stdout)}, err); got != test.want { + t.Fatalf("absence = %t, want %t", got, test.want) + } + }) + } +} diff --git a/internal/publicgit/repository.go b/internal/publicgit/repository.go index d76816f..01b5adc 100644 --- a/internal/publicgit/repository.go +++ b/internal/publicgit/repository.go @@ -20,6 +20,8 @@ type Repository struct { Git gitexec.Runner } +var ErrNotRepository = errors.New("not a Git repository") + func Discover(ctx context.Context, git gitexec.Runner, hint string) (Repository, error) { if _, err := RequireSupportedGit(ctx, git); err != nil { return Repository{}, err @@ -34,7 +36,13 @@ func Discover(ctx context.Context, git gitexec.Runner, hint string) (Repository, rootResult, err := git.Run(ctx, absoluteHint, "rev-parse", "--show-toplevel") if err != nil { - return Repository{}, fmt.Errorf("%s is not inside a Git working tree: %w", absoluteHint, err) + if repositoryAbsentDiagnostic(rootResult, err) { + if markerErr := confirmNoGitMetadata(absoluteHint); markerErr != nil { + return Repository{}, fmt.Errorf("inspect public workspace %q: %w", absoluteHint, errors.Join(err, markerErr)) + } + return Repository{}, fmt.Errorf("%w at %q: %w", ErrNotRepository, absoluteHint, err) + } + return Repository{}, fmt.Errorf("inspect public workspace %q: %w", absoluteHint, err) } rootPath, err := gitexec.ParsePathOutput(rootResult.Stdout) if err != nil { @@ -77,6 +85,41 @@ func Discover(ctx context.Context, git gitexec.Runner, hint string) (Repository, return Repository{Root: root, GitDir: gitDir, CommonDir: common, Git: git}, nil } +func repositoryAbsentDiagnostic(result gitexec.Result, err error) bool { + exitErr, ok := errors.AsType[*gitexec.ExitError](err) + if !ok || exitErr.ExitCode != 128 || len(result.Stdout) != 0 { + return false + } + // Runner fixes LC_ALL=C. Match Git's parent-search diagnostics, not errors + // about an explicit invalid gitdir or configuration file. + message := strings.TrimSpace(exitErr.Stderr) + return message == "fatal: not a git repository (or any of the parent directories): .git" || + (strings.HasPrefix(message, "fatal: not a git repository (or any parent up to mount point ") && + strings.HasSuffix(message, ")\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).")) +} + +func confirmNoGitMetadata(hint string) error { + // Git can report absence when metadata is unreadable. Only declare absence + // after ruling out markers along the physical working-directory ancestry. + directory, err := filepath.EvalSymlinks(hint) + if err != nil { + return err + } + for { + marker := filepath.Join(directory, ".git") + if _, err := os.Lstat(marker); err == nil { + return fmt.Errorf("Git metadata exists at %q but repository inspection failed", marker) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect Git metadata %q: %w", marker, err) + } + parent := filepath.Dir(directory) + if parent == directory { + return nil + } + directory = parent + } +} + func RequireSupportedGit(ctx context.Context, git gitexec.Runner) (string, error) { result, err := git.Run(ctx, ".", "--version") if err != nil { diff --git a/internal/publicgit/repository_test.go b/internal/publicgit/repository_test.go index 4e5e6b1..60769d6 100644 --- a/internal/publicgit/repository_test.go +++ b/internal/publicgit/repository_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" @@ -23,6 +24,10 @@ func TestMain(m *testing.M) { } func runPublicGitProxy() int { + if os.Getenv("SPAS_PUBLICGIT_PROXY") == "invalid-root-output" && slices.Contains(os.Args[1:], "--show-toplevel") { + _, _ = fmt.Fprint(os.Stdout, "unterminated Git path") + return 0 + } input, err := io.ReadAll(os.Stdin) if err != nil { return 1 From 23dc2109cad6fc034ca740b17e963605b5e5ab84 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:25:14 -0500 Subject: [PATCH 49/52] fix(diff): preserve observed path identity Keep raw selections through Diff and Remove validation, and enumerate staged rename sides without probing workspace write access. --- internal/app/app.go | 39 ++-- internal/app/diagnostics.go | 132 ++++++++++---- internal/app/diff_identity_test.go | 145 +++++++++++++++ internal/app/diff_readonly_test.go | 168 +++++++++++++++++ internal/app/diff_selection_test.go | 249 ++++++++++++++++++++++++++ internal/app/enrollment_test.go | 17 +- internal/app/readonly_unix_test.go | 37 ++++ internal/app/readonly_windows_test.go | 72 ++++++++ internal/privategit/repository.go | 42 +---- 9 files changed, 804 insertions(+), 97 deletions(-) create mode 100644 internal/app/diff_identity_test.go create mode 100644 internal/app/diff_readonly_test.go create mode 100644 internal/app/diff_selection_test.go create mode 100644 internal/app/readonly_unix_test.go create mode 100644 internal/app/readonly_windows_test.go diff --git a/internal/app/app.go b/internal/app/app.go index df28d3d..c6f337b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -272,13 +272,13 @@ func (a App) Add(ctx context.Context, options AddOptions) error { } key := pathmodel.Canonical(file, ignoreCase) if managed, found := managedSet[key]; found { - file, err = authoritativeManagedPath(repository.Root, file, managed) + file, err = authoritativeManagedPath(repository.Root, file.OSPath(repository.Root), managed) if err != nil { return err } } if pending, found := addSet[key]; found { - file, err = authoritativeManagedPath(repository.Root, file, pending) + file, err = authoritativeManagedPath(repository.Root, file.OSPath(repository.Root), pending) if err != nil { return err } @@ -445,7 +445,7 @@ func (a App) Remove(ctx context.Context, options RemoveOptions) error { unenrolled := []string{} refreshed := []string{} for _, value := range options.Paths { - requested, _, err := pathmodel.Resolve(repository.Root, a.PathBase, value) + requested, observed, err := pathmodel.Resolve(repository.Root, a.PathBase, value) if err != nil { return spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf("resolve managed path %q: %w", value, err)) } @@ -460,12 +460,12 @@ func (a App) Remove(ctx context.Context, options RemoveOptions) error { } path := requested if isManaged { - path, err = authoritativeManagedPath(repository.Root, requested, managedPath) + path, err = authoritativeManagedPath(repository.Root, observed, managedPath) if err != nil { return err } } else { - path, err = authoritativeManagedPath(repository.Root, requested, pendingPath) + path, err = authoritativeManagedPath(repository.Root, observed, pendingPath) if err != nil { return err } @@ -560,10 +560,9 @@ func (a App) Remove(ctx context.Context, options RemoveOptions) error { return a.write(result) } -// trackedPathDecision resolves what to do with an add target that public Git -// already tracks: true means skip it, false with a nil error never occurs, and -// an error aborts. Interactive runs are offered the choice; `--skip-tracked` -// answers it ahead of time. +// trackedPathDecision returns true to skip an already-public path. A false, +// nil result leaves the tracking-conflict error to the caller. Prompt errors +// abort the operation; --skip-tracked selects skipping without a prompt. func (a App) trackedPathDecision(ctx context.Context, path pathmodel.Path, skipTracked bool) (bool, error) { if skipTracked { return true, nil @@ -1372,29 +1371,29 @@ func canonicalSet(paths []pathmodel.Path, ignoreCase bool) map[string]pathmodel. return result } -// authoritativeManagedPath resolves a case-equivalent user spelling to the -// spelling already stored by SPAS. If both spellings exist as different files -// on a case-sensitive filesystem while Git is configured case-insensitively, -// treating them as the same path would operate on the wrong file. -func authoritativeManagedPath(root string, requested, authoritative pathmodel.Path) (pathmodel.Path, error) { - if requested == authoritative { +// authoritativeManagedPath checks the observed absolute filename before mapping +// a case or normalization alias to the stored spelling. Callers must retain the +// observed spelling from Resolve or use a path already verified by enrollment. +func authoritativeManagedPath(root, observed string, authoritative pathmodel.Path) (pathmodel.Path, error) { + stored := authoritative.OSPath(root) + if observed == stored { return authoritative, nil } - requestedInfo, requestedErr := os.Lstat(requested.OSPath(root)) - authoritativeInfo, authoritativeErr := os.Lstat(authoritative.OSPath(root)) + requestedInfo, requestedErr := os.Lstat(observed) + authoritativeInfo, authoritativeErr := os.Lstat(stored) if requestedErr == nil && authoritativeErr == nil { if os.SameFile(requestedInfo, authoritativeInfo) { return authoritative, nil } return "", spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf( "%q and privately managed path %q are distinct files whose names collide under the current case policy", - requested, authoritative, + observed, authoritative, )) } if requestedErr == nil && errors.Is(authoritativeErr, os.ErrNotExist) { return "", spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf( - "%q differs only by case from privately managed path %q; use the managed spelling", - requested, authoritative, + "selected path %q exists but privately managed path %q is missing; use the managed spelling", + observed, authoritative, )) } if requestedErr != nil && !errors.Is(requestedErr, os.ErrNotExist) { diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go index 218532a..326eede 100644 --- a/internal/app/diagnostics.go +++ b/internal/app/diagnostics.go @@ -20,6 +20,7 @@ import ( "github.com/getspas/spas/internal/pathmodel" "github.com/getspas/spas/internal/privategit" "github.com/getspas/spas/internal/publicgit" + "github.com/getspas/spas/internal/spaserr" ) type DiffOptions struct { @@ -44,22 +45,9 @@ func (a App) Diff(ctx context.Context, options DiffOptions) error { managedSet := stringSet(state.ManagedPaths) pendingAdds := stringSet(state.PendingAdds) pendingRemovals := stringSet(state.PendingRemovalPaths()) - if len(options.Paths) > 0 { - filter := make(map[string]struct{}) - for _, value := range options.Paths { - path, _, err := pathmodel.Resolve(repository.Root, a.PathBase, value) - if err != nil { - return err - } - filter[path.String()] = struct{}{} - } - var selected []string - for _, value := range managed { - if _, found := filter[value]; found { - selected = append(selected, value) - } - } - managed = selected + managed, err = a.selectDiffPaths(ctx, repository, managed, options.Paths) + if err != nil { + return err } sort.Strings(managed) @@ -166,30 +154,14 @@ func (a App) diffStaged(ctx context.Context, repository publicgit.Repository, st return fmt.Errorf("private repository is not initialized; nothing is staged") } private := a.privateRepository(state) - var filters []pathmodel.Path - for _, value := range options.Paths { - path, _, err := pathmodel.Resolve(repository.Root, a.PathBase, value) - if err != nil { - return err - } - filters = append(filters, path) - } - changes, err := private.ChangedPaths(ctx) + paths, err := private.StagedPaths(ctx) if err != nil { return err } - filterSet := make(map[string]struct{}, len(filters)) - for _, path := range filters { - filterSet[path.String()] = struct{}{} - } - changed := []string{} - for _, change := range changes { - if len(filterSet) > 0 { - if _, found := filterSet[change.Path.String()]; !found { - continue - } - } - changed = append(changed, change.Path.String()) + changed := pathsToStrings(paths) + changed, err = a.selectDiffPaths(ctx, repository, changed, options.Paths) + if err != nil { + return err } sort.Strings(changed) if a.JSON { @@ -203,7 +175,91 @@ func (a App) diffStaged(ctx context.Context, repository publicgit.Repository, st } return nil } - return private.StreamStagedDiff(ctx, options.Stat, filters, a.Out) + if len(options.Paths) > 0 { + if len(changed) == 0 { + return nil + } + return private.StreamStagedDiff(ctx, options.Stat, stringsToPaths(changed), a.Out) + } + return private.StreamStagedDiff(ctx, options.Stat, nil, a.Out) +} + +func (a App) selectDiffPaths(ctx context.Context, repository publicgit.Repository, known, values []string) ([]string, error) { + if len(values) == 0 { + return known, nil + } + // Git's configured policy (default false) is readable without the workspace + // write probe used by mutation collision checks. Existing aliases can also + // establish their identity directly when that policy is case-sensitive. + ignoreCase, _, err := repository.EffectiveIgnoreCase(ctx) + if err != nil { + return nil, err + } + exact := stringSet(known) + folded := make(map[string][]pathmodel.Path, len(exact)) + for value := range exact { + path := pathmodel.Path(value) + key := pathmodel.Canonical(path, true) + folded[key] = append(folded[key], path) + } + selected := []string{} + seen := make(map[pathmodel.Path]bool) + for _, value := range values { + requested, observed, err := pathmodel.Resolve(repository.Root, a.PathBase, value) + if err != nil { + return nil, err + } + stored := requested + if _, matches := exact[requested.String()]; !matches { + candidates := folded[pathmodel.Canonical(requested, true)] + if !ignoreCase && len(candidates) > 0 { + candidates, err = existingDiffAliases(repository.Root, observed, candidates) + if err != nil { + return nil, err + } + } + if len(candidates) == 0 { + continue + } + if len(candidates) > 1 { + return nil, spaserr.Wrap(spaserr.KindUnsupportedPath, fmt.Errorf("%q matches multiple changed paths; select an exact stored spelling", requested)) + } + stored = candidates[0] + } + path, err := authoritativeManagedPath(repository.Root, observed, stored) + if err != nil { + return nil, err + } + if !seen[path] { + selected = append(selected, path.String()) + seen[path] = true + } + } + return selected, nil +} + +func existingDiffAliases(root, observed string, candidates []pathmodel.Path) ([]pathmodel.Path, error) { + selected, err := os.Lstat(observed) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var matches []pathmodel.Path + for _, candidate := range candidates { + info, err := os.Lstat(candidate.OSPath(root)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + if os.SameFile(selected, info) { + matches = append(matches, candidate) + } + } + return matches, nil } type DoctorResult struct { diff --git a/internal/app/diff_identity_test.go b/internal/app/diff_identity_test.go new file mode 100644 index 0000000..c8960a8 --- /dev/null +++ b/internal/app/diff_identity_test.go @@ -0,0 +1,145 @@ +package app + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/getspas/spas/internal/spaserr" +) + +func TestDiffRejectsUnicodeNeighborCreatedAfterEnrollment(t *testing.T) { + t.Parallel() + for _, ignoreCase := range []bool{false, true} { + t.Run(strconv.FormatBool(ignoreCase), func(t *testing.T) { + t.Parallel() + root, _, instance := initializedApp(t, t.TempDir()) + stored := "café.txt" + canonical := filepath.Join(root, stored) + if err := os.WriteFile(canonical, []byte("enrolled content\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := instance.Add(t.Context(), AddOptions{Paths: []string{stored}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip}); err != nil { + t.Fatal(err) + } + neighbor := filepath.Join(root, "cafe\u0301.txt") + file, err := os.OpenFile(neighbor, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if errors.Is(err, os.ErrExist) { + t.Skip("volume aliases Unicode normalization variants") + } + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteString("unenrolled neighbor\n"); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + originalInfo, err := os.Lstat(canonical) + if err != nil { + t.Fatal(err) + } + neighborInfo, err := os.Lstat(neighbor) + if err != nil { + t.Fatal(err) + } + if os.SameFile(originalInfo, neighborInfo) { + t.Fatal("fixture identities match") + } + runGit(t, root, "config", "core.ignorecase", strconv.FormatBool(ignoreCase)) + state := loadState(t, instance, root) + if err := os.WriteFile(filepath.Join(state.Private.LocalRepositoryPath, stored), []byte("staged content\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, state.Private.LocalRepositoryPath, "add", "--", stored) + for _, staged := range []bool{false, true} { + for _, format := range []string{"json", "names", "patch", "stat"} { + var output bytes.Buffer + instance.Out = &output + instance.JSON = format == "json" + opts := DiffOptions{Paths: []string{neighbor}, Staged: staged, NameOnly: format == "names", Stat: format == "stat"} + err := instance.Diff(t.Context(), opts) + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath || output.Len() != 0 { + t.Errorf("Diff(neighbor, staged=%t, %s) = %v, output=%q", staged, format, err, output.String()) + } + } + var output bytes.Buffer + instance.Out, instance.JSON = &output, true + if err := instance.Diff(t.Context(), DiffOptions{Paths: []string{stored}, Staged: staged}); err != nil { + t.Fatal(err) + } + var result struct { + Changed []string `json:"changedPaths"` + Staged []string `json:"stagedPaths"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + paths := result.Changed + if staged { + paths = result.Staged + } + if len(paths) != 1 || paths[0] != stored { + t.Fatalf("exact selection = %s", output.String()) + } + } + if err := instance.Remove(t.Context(), RemoveOptions{Paths: []string{neighbor}}); err == nil { + t.Error("Remove accepted the unenrolled Unicode neighbor") + } + if got := loadState(t, instance, root); len(got.PendingAdds) != 1 || got.PendingAdds[0] != stored { + t.Errorf("rejected neighbor changed enrollment: %q", got.PendingAdds) + } + }) + } +} + +func TestDiffAcceptsGenuineNormalizationAlias(t *testing.T) { + t.Parallel() + root, _, instance := initializedApp(t, t.TempDir()) + stored := "café.txt" + canonical, observed := filepath.Join(root, stored), filepath.Join(root, "cafe\u0301.txt") + if err := os.WriteFile(canonical, []byte("enrolled content\n"), 0o600); err != nil { + t.Fatal(err) + } + actual, err := os.Stat(canonical) + if err != nil { + t.Fatal(err) + } + alias, err := os.Stat(observed) + if errors.Is(err, os.ErrNotExist) { + t.Skip("volume distinguishes normalization variants") + } + if err != nil { + t.Fatal(err) + } + if !os.SameFile(actual, alias) { + t.Fatal("fixture normalization spellings have distinct identities") + } + if err := instance.Add(t.Context(), AddOptions{Paths: []string{stored}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip}); err != nil { + t.Fatal(err) + } + state := loadState(t, instance, root) + if err := os.WriteFile(filepath.Join(state.Private.LocalRepositoryPath, stored), []byte("staged content\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, state.Private.LocalRepositoryPath, "add", "--", stored) + for _, ignoreCase := range []string{"false", "true"} { + runGit(t, root, "config", "core.ignorecase", ignoreCase) + for _, staged := range []bool{false, true} { + var output bytes.Buffer + instance.Out = &output + if err := instance.Diff(t.Context(), DiffOptions{Paths: []string{observed}, NameOnly: true, Staged: staged}); err != nil { + t.Fatal(err) + } + if output.String() != stored+"\n" { + t.Fatalf("normalization alias = %q", output.String()) + } + } + } +} diff --git a/internal/app/diff_readonly_test.go b/internal/app/diff_readonly_test.go new file mode 100644 index 0000000..c44dba1 --- /dev/null +++ b/internal/app/diff_readonly_test.go @@ -0,0 +1,168 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +type diffReadOnlyCase struct { + name string + stored string + alias string + staged bool + patch string + prepare func(*testing.T, string, *App) +} + +func TestDiffSelectionDoesNotWriteReadOnlyWorkspace(t *testing.T) { + for _, test := range []diffReadOnlyCase{ + { + name: "ordinary modified", + stored: "docs/ARCHITECTURE.md", + alias: "DOCS/architecture.md", + patch: "+changed", + prepare: func(t *testing.T, publicRoot string, _ *App) { + if err := os.WriteFile(filepath.Join(publicRoot, "docs", "ARCHITECTURE.md"), []byte("changed\n"), 0o600); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "ordinary pending addition", + stored: "pending.txt", + alias: "PENDING.TXT", + patch: "+pending", + prepare: func(t *testing.T, publicRoot string, instance *App) { + if err := os.WriteFile(filepath.Join(publicRoot, "pending.txt"), []byte("pending\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := instance.Add(t.Context(), AddOptions{ + Paths: []string{"pending.txt"}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip, + }); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "ordinary pending removal", + stored: "docs/ARCHITECTURE.md", + alias: "DOCS/architecture.md", + patch: "-initial", + prepare: func(t *testing.T, publicRoot string, instance *App) { + if err := instance.Remove(t.Context(), RemoveOptions{Paths: []string{"docs/ARCHITECTURE.md"}}); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(publicRoot, "docs", "ARCHITECTURE.md")); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "staged modified", + stored: "docs/ARCHITECTURE.md", + alias: "DOCS/architecture.md", + staged: true, + patch: "+staged", + prepare: func(t *testing.T, publicRoot string, instance *App) { + state := loadState(t, *instance, publicRoot) + privateFile := filepath.Join(state.Private.LocalRepositoryPath, "docs", "ARCHITECTURE.md") + if err := os.WriteFile(privateFile, []byte("staged\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, state.Private.LocalRepositoryPath, "add", "--", "docs/ARCHITECTURE.md") + }, + }, + { + name: "staged deletion", + stored: "docs/ARCHITECTURE.md", + alias: "DOCS/architecture.md", + staged: true, + patch: "-initial", + prepare: func(t *testing.T, publicRoot string, instance *App) { + state := loadState(t, *instance, publicRoot) + runGit(t, state.Private.LocalRepositoryPath, "rm", "-q", "--", "docs/ARCHITECTURE.md") + if err := os.Remove(filepath.Join(publicRoot, "docs", "ARCHITECTURE.md")); err != nil { + t.Fatal(err) + } + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + publicRoot, _, instance := initializedApp(t, root) + runGit(t, publicRoot, "config", "core.ignoreCase", "true") + test.prepare(t, publicRoot, &instance) + denyWorkspaceCreation(t, publicRoot) + + for _, selected := range []string{test.stored, test.alias} { + for _, format := range []struct { + name string + json bool + opts DiffOptions + }{ + {name: "json", json: true}, + {name: "name-only", opts: DiffOptions{NameOnly: true}}, + {name: "patch"}, + {name: "stat", opts: DiffOptions{Stat: true}}, + } { + t.Run(selected+"/"+format.name, func(t *testing.T) { + var output, stderr strings.Builder + instance.Out = &output + instance.Err = &stderr + instance.Git.Stdout = nil + instance.Git.Stderr = &stderr + instance.JSON = format.json + options := format.opts + options.Paths = []string{selected} + options.Staged = test.staged + if err := instance.Diff(t.Context(), options); err != nil { + t.Fatalf("Diff(%+v) error = %v; stderr=%q", options, err, stderr.String()) + } + assertReadOnlyDiffOutput(t, output.String(), format.name, test.staged, test.stored, test.patch) + }) + } + } + }) + } +} + +func assertReadOnlyDiffOutput(t *testing.T, output, format string, staged bool, stored, patch string) { + t.Helper() + slashOutput := strings.ReplaceAll(output, "\\", "/") + switch format { + case "json": + var value struct { + ChangedPaths []string `json:"changedPaths"` + StagedPaths []string `json:"stagedPaths"` + } + if err := json.Unmarshal([]byte(output), &value); err != nil { + t.Fatalf("decode Diff JSON %q: %v", output, err) + } + paths := value.ChangedPaths + if staged { + paths = value.StagedPaths + } + if len(paths) != 1 || paths[0] != stored { + t.Fatalf("Diff JSON paths = %q, want [%q]", paths, stored) + } + case "name-only": + if output != stored+"\n" { + t.Fatalf("Diff name-only output = %q, want %q", output, stored+"\n") + } + case "patch": + base := filepath.Base(filepath.FromSlash(stored)) + if !strings.Contains(slashOutput, base) || !strings.Contains(output, patch) { + t.Fatalf("Diff patch output = %q, want filename %q and content %q", output, base, patch) + } + case "stat": + base := filepath.Base(filepath.FromSlash(stored)) + if !strings.Contains(slashOutput, base) || !strings.Contains(output, "1 file changed") { + t.Fatalf("Diff stat output = %q, want filename %q and one changed file", output, base) + } + default: + t.Fatalf("unknown Diff format %q", format) + } +} diff --git a/internal/app/diff_selection_test.go b/internal/app/diff_selection_test.go new file mode 100644 index 0000000..1d9f3db --- /dev/null +++ b/internal/app/diff_selection_test.go @@ -0,0 +1,249 @@ +package app + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/getspas/spas/internal/spaserr" +) + +func TestDiffSelectsAuthoritativePaths(t *testing.T) { + t.Parallel() + for _, phase := range []string{"pending", "modified", "removed", "staged", "staged deletion"} { + t.Run(phase, func(t *testing.T) { + t.Parallel() + root, _, instance := initializedApp(t, t.TempDir()) + state := loadState(t, instance, root) + runGit(t, root, "config", "core.ignorecase", "true") + stored, alias := "docs/ARCHITECTURE.md", "DOCS/architecture.md" + staged := strings.HasPrefix(phase, "staged") + if phase == "pending" { + stored, alias = "new.txt", "NEW.TXT" + } + file := filepath.Join(root, filepath.FromSlash(stored)) + if err := os.WriteFile(file, []byte("changed secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if phase == "pending" { + if err := instance.Add(t.Context(), AddOptions{Paths: []string{stored}, ExistingExclude: ExcludePreserve, MergeProtection: MergeSkip}); err != nil { + t.Fatal(err) + } + } + if phase == "removed" { + if err := instance.Remove(t.Context(), RemoveOptions{Paths: []string{stored}}); err != nil { + t.Fatal(err) + } + } + if phase == "removed" || phase == "staged deletion" { + if err := os.Remove(file); err != nil { + t.Fatal(err) + } + } + if staged { + privateFile := filepath.Join(state.Private.LocalRepositoryPath, filepath.FromSlash(stored)) + if phase == "staged deletion" { + if err := os.Remove(privateFile); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(privateFile, []byte("changed secret\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, state.Private.LocalRepositoryPath, "add", "--", stored) + } + for _, format := range []string{"json", "names", "patch", "stat"} { + t.Run(format, func(t *testing.T) { + var output bytes.Buffer + instance.Out = &output + instance.JSON = format == "json" + opts := DiffOptions{Paths: []string{alias}, NameOnly: format == "names", Stat: format == "stat", Staged: staged} + if err := instance.Diff(t.Context(), opts); err != nil { + t.Fatal(err) + } + switch format { + case "json": + var result struct { + Changed []string `json:"changedPaths"` + Staged []string `json:"stagedPaths"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + paths := result.Changed + if staged { + paths = result.Staged + } + if len(paths) != 1 || paths[0] != stored { + t.Fatalf("Diff paths = %q, want %q", paths, stored) + } + case "names": + if output.String() != stored+"\n" { + t.Fatalf("Diff names = %q", output.String()) + } + case "patch": + want := "+changed secret" + if phase == "removed" || phase == "staged deletion" { + want = "-initial" + } + if !strings.Contains(output.String(), want) { + t.Fatalf("Diff patch = %q, want %q", output.String(), want) + } + case "stat": + if !strings.Contains(output.String(), "1 file changed") { + t.Fatalf("Diff stat = %q", output.String()) + } + } + output.Reset() + opts.Paths = []string{"not-enrolled.txt"} + if err := instance.Diff(t.Context(), opts); err != nil { + t.Fatal(err) + } + if format != "json" && output.Len() != 0 { + t.Fatalf("unmatched filter produced output: %q", output.String()) + } + }) + } + }) + } +} + +func TestDiffRejectsDistinctCaseEquivalentFile(t *testing.T) { + t.Parallel() + root, _, instance := initializedApp(t, t.TempDir()) + alias := filepath.Join(root, "docs", "architecture.md") + file, err := os.OpenFile(alias, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + t.Skip("volume aliases case variants") + } + if err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + runGit(t, root, "config", "core.ignorecase", "true") + state := loadState(t, instance, root) + privateFile := filepath.Join(state.Private.LocalRepositoryPath, "docs", "ARCHITECTURE.md") + if err := os.WriteFile(privateFile, []byte("staged secret\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, state.Private.LocalRepositoryPath, "add", "--", "docs/ARCHITECTURE.md") + for _, staged := range []bool{false, true} { + err := instance.Diff(t.Context(), DiffOptions{Paths: []string{alias}, Staged: staged}) + if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { + t.Fatalf("Diff(distinct file, staged=%t) = %v", staged, err) + } + } +} + +func TestDiffHonorsCaseSensitiveFilesystem(t *testing.T) { + t.Parallel() + root, _, instance := initializedApp(t, t.TempDir()) + if _, err := os.Stat(filepath.Join(root, "docs", "architecture.md")); err == nil { + t.Skip("volume aliases case variants") + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + runGit(t, root, "config", "core.ignorecase", "false") + if err := os.WriteFile(filepath.Join(root, "docs", "ARCHITECTURE.md"), []byte("modified\n"), 0o600); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + instance.Out = &output + if err := instance.Diff(t.Context(), DiffOptions{Paths: []string{"docs/architecture.md"}, NameOnly: true}); err != nil { + t.Fatal(err) + } + if output.Len() != 0 { + t.Fatalf("case-sensitive filter selected another name: %q", output.String()) + } + if err := instance.Diff(t.Context(), DiffOptions{Paths: []string{"docs/ARCHITECTURE.md"}, NameOnly: true}); err != nil { + t.Fatal(err) + } + if output.String() != "docs/ARCHITECTURE.md\n" { + t.Fatalf("exact filter = %q", output.String()) + } +} + +func TestStagedDiffSelectsRenamePaths(t *testing.T) { + t.Parallel() + root, _, instance := initializedApp(t, t.TempDir()) + state := loadState(t, instance, root) + privateRoot := state.Private.LocalRepositoryPath + if err := os.Rename(filepath.Join(privateRoot, "docs", "ARCHITECTURE.md"), filepath.Join(privateRoot, "docs", "RENAMED.md")); err != nil { + t.Fatal(err) + } + runGit(t, privateRoot, "config", "diff.renames", "true") + runGit(t, privateRoot, "add", "-A") + runGit(t, root, "config", "core.ignorecase", "true") + for _, test := range []struct{ selected, stored, patch string }{ + {"docs/ARCHITECTURE.md", "docs/ARCHITECTURE.md", "-initial"}, + {"DOCS/architecture.md", "docs/ARCHITECTURE.md", "-initial"}, + {"docs/RENAMED.md", "docs/RENAMED.md", "+initial"}, + {"DOCS/renamed.md", "docs/RENAMED.md", "+initial"}, + } { + for _, format := range []string{"json", "names", "patch", "stat"} { + t.Run(test.selected+"/"+format, func(t *testing.T) { + var output bytes.Buffer + instance.Out = &output + instance.JSON = format == "json" + opts := DiffOptions{Paths: []string{test.selected}, Staged: true, NameOnly: format == "names", Stat: format == "stat"} + if err := instance.Diff(t.Context(), opts); err != nil { + t.Fatal(err) + } + want := test.patch + if format == "json" { + var result struct { + Paths []string `json:"stagedPaths"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil || len(result.Paths) != 1 || result.Paths[0] != test.stored { + t.Fatalf("rename paths = %s, decode error %v", output.String(), err) + } + return + } + if format == "names" { + want = test.stored + "\n" + } else if format == "stat" { + want = "1 file changed" + } + if !strings.Contains(output.String(), want) { + t.Fatalf("staged rename output = %q, want %q", output.String(), want) + } + }) + } + } +} + +func TestStagedDiffDisambiguatesCaseOnlyRename(t *testing.T) { + t.Parallel() + root, _, instance := initializedApp(t, t.TempDir()) + state := loadState(t, instance, root) + privateRoot := state.Private.LocalRepositoryPath + runGit(t, root, "config", "core.ignorecase", "true") + runGit(t, privateRoot, "config", "core.ignorecase", "false") + oldPath, newPath := "docs/ARCHITECTURE.md", "docs/architecture.md" + if err := os.Rename(filepath.Join(privateRoot, filepath.FromSlash(oldPath)), filepath.Join(privateRoot, filepath.FromSlash(newPath))); err != nil { + t.Fatal(err) + } + runGit(t, privateRoot, "add", "-A") + instance.JSON = true + for _, selected := range []string{oldPath, newPath} { + var output bytes.Buffer + instance.Out = &output + if err := instance.Diff(t.Context(), DiffOptions{Paths: []string{selected}, Staged: true}); err != nil { + t.Fatal(err) + } + var result struct { + Paths []string `json:"stagedPaths"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil || len(result.Paths) != 1 || result.Paths[0] != selected { + t.Fatalf("exact rename selection %q = %s, %v", selected, output.String(), err) + } + } + if err := instance.Diff(t.Context(), DiffOptions{Paths: []string{"DOCS/Architecture.md"}, Staged: true}); err == nil { + t.Fatal("ambiguous rename alias was accepted") + } +} diff --git a/internal/app/enrollment_test.go b/internal/app/enrollment_test.go index 35402c4..e9116d7 100644 --- a/internal/app/enrollment_test.go +++ b/internal/app/enrollment_test.go @@ -248,8 +248,21 @@ func TestAddUsesDirectoryEntrySpelling(t *testing.T) { if test.name == "directory alias" { selected = filepath.Join(selected, "résumé.txt") } - if err := instance.Diff(t.Context(), DiffOptions{Paths: []string{selected}}); err != nil { - t.Fatalf("Diff(alias): %v", err) + var diffOutput bytes.Buffer + instance.Out = &diffOutput + for _, ignoreCase := range []string{"false", "unset", "true"} { + if ignoreCase == "unset" { + runGit(t, publicRoot, "config", "--unset", "core.ignorecase") + } else { + runGit(t, publicRoot, "config", "core.ignorecase", ignoreCase) + } + diffOutput.Reset() + if err := instance.Diff(t.Context(), DiffOptions{Paths: []string{selected}, NameOnly: true}); err != nil { + t.Fatalf("Diff(alias, ignorecase=%s): %v", ignoreCase, err) + } + if diffOutput.String() != test.stored+"\n" { + t.Fatalf("Diff(alias, ignorecase=%s) = %q, want enrolled path %q", ignoreCase, diffOutput.String(), test.stored) + } } if err := instance.Remove(t.Context(), RemoveOptions{Paths: []string{selected}}); err != nil { t.Fatalf("Remove(alias): %v", err) diff --git a/internal/app/readonly_unix_test.go b/internal/app/readonly_unix_test.go new file mode 100644 index 0000000..46f9dc7 --- /dev/null +++ b/internal/app/readonly_unix_test.go @@ -0,0 +1,37 @@ +//go:build !windows + +package app + +import ( + "errors" + "os" + "testing" +) + +func denyWorkspaceCreation(t *testing.T, root string) { + t.Helper() + info, err := os.Stat(root) + if err != nil { + t.Fatal(err) + } + originalMode := info.Mode().Perm() + if err := os.Chmod(root, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chmod(root, originalMode); err != nil { + t.Errorf("restore read-only workspace permissions: %v", err) + } + }) + + file, err := os.CreateTemp(root, ".spas-case-Probe-test-*") + if err == nil { + name := file.Name() + _ = file.Close() + _ = os.Remove(name) + t.Skip("filesystem or test account bypasses Unix directory permission denial") + } + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("os.CreateTemp(%q) error = %v, want permission denial", root, err) + } +} diff --git a/internal/app/readonly_windows_test.go b/internal/app/readonly_windows_test.go new file mode 100644 index 0000000..ab04217 --- /dev/null +++ b/internal/app/readonly_windows_test.go @@ -0,0 +1,72 @@ +//go:build windows + +package app + +import ( + "errors" + "os" + "testing" + + "golang.org/x/sys/windows" +) + +func denyWorkspaceCreation(t *testing.T, root string) { + t.Helper() + securityInformation := windows.SECURITY_INFORMATION(windows.DACL_SECURITY_INFORMATION) + original, err := windows.GetNamedSecurityInfo(root, windows.SE_FILE_OBJECT, securityInformation) + if err != nil || original == nil { + t.Skipf("cannot capture workspace ACL: %v", err) + } + originalDACL, _, err := original.DACL() + if err != nil { + t.Skipf("cannot read workspace ACL DACL: %v", err) + } + t.Cleanup(func() { + if err := windows.SetNamedSecurityInfo( + root, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, + nil, + nil, + originalDACL, + nil, + ); err != nil { + t.Errorf("restore workspace ACL: %v", err) + } + }) + + world, err := windows.CreateWellKnownSid(windows.WinWorldSid) + if err != nil { + t.Skipf("cannot create Everyone SID: %v", err) + } + deniedDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ + // FILE_WRITE_DATA and FILE_APPEND_DATA are the Windows directory + // rights named FILE_ADD_FILE and FILE_ADD_SUBDIRECTORY. Keep the + // denial limited to creating children in this fixture root. + AccessPermissions: windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA, + AccessMode: windows.DENY_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(world), + }, + }}, originalDACL) + if err != nil { + t.Skipf("cannot construct workspace denial ACL: %v", err) + } + if err := windows.SetNamedSecurityInfo(root, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, deniedDACL, nil); err != nil { + t.Skipf("cannot apply workspace denial ACL: %v", err) + } + + file, err := os.CreateTemp(root, ".spas-case-Probe-test-*") + if err == nil { + name := file.Name() + _ = file.Close() + _ = os.Remove(name) + t.Skip("filesystem or test account bypasses Windows directory ACL denial") + } + if !errors.Is(err, os.ErrPermission) && !errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Fatalf("os.CreateTemp(%q) error = %v, want access denied", root, err) + } +} diff --git a/internal/privategit/repository.go b/internal/privategit/repository.go index 5f482ef..1b83545 100644 --- a/internal/privategit/repository.go +++ b/internal/privategit/repository.go @@ -533,46 +533,14 @@ func (r Repository) TreePaths(ctx context.Context, revision string) ([]pathmodel return paths, nil } -func (r Repository) ChangedPaths(ctx context.Context) ([]ChangedPath, error) { - result, err := r.Git.Run(ctx, r.Path, r.safeArgs("diff", "--cached", "--name-status", "-z")...) +func (r Repository) StagedPaths(ctx context.Context) ([]pathmodel.Path, error) { + // Selection is per path: a rename exposes both the deleted source and the + // added destination, independently of Git's rename-detection heuristics. + result, err := r.Git.Run(ctx, r.Path, r.safeArgs("diff", "--cached", "--no-renames", "--name-only", "-z")...) if err != nil { return nil, fmt.Errorf("list staged private changes: %w", err) } - fields := bytes.Split(result.Stdout, []byte{0}) - var changes []ChangedPath - for index := 0; index < len(fields); { - if len(fields[index]) == 0 { - index++ - continue - } - status := string(fields[index]) - index++ - if index >= len(fields) { - return nil, fmt.Errorf("parse staged private changes: missing path") - } - path, err := pathmodel.Parse(string(fields[index])) - if err != nil { - return nil, err - } - index++ - if strings.HasPrefix(status, "R") || strings.HasPrefix(status, "C") { - if index >= len(fields) { - return nil, fmt.Errorf("parse staged private rename: missing destination") - } - path, err = pathmodel.Parse(string(fields[index])) - if err != nil { - return nil, err - } - index++ - } - changes = append(changes, ChangedPath{Status: status[:1], Path: path}) - } - return changes, nil -} - -type ChangedPath struct { - Status string `json:"status"` - Path pathmodel.Path `json:"path"` + return parsePaths(result.Stdout) } // StreamStagedDiff writes staged changes without retaining the complete diff From c629af9adddea9759e389dc51eeda5a98a652bf0 Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:25:45 -0500 Subject: [PATCH 50/52] fix(paths): describe Windows preflight byte limit --- internal/app/contract_test.go | 4 ++-- internal/pathmodel/path.go | 2 +- internal/pathmodel/path_test.go | 2 +- internal/privategit/repository_test.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index 5fefd10..5aec013 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -1842,7 +1842,7 @@ func TestWindowsPathLengthPreflightRejectsWorkspaceRoot(t *testing.T) { if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { t.Fatalf("Add() error kind = %v, want KindUnsupportedPath", kind) } - if !strings.Contains(err.Error(), "reaches or exceeds the Windows limit") { + if !strings.Contains(err.Error(), "SPAS Windows preflight limit of 260 bytes") { t.Fatalf("Add() error = %v, want Windows limit error", err) } } @@ -1896,7 +1896,7 @@ func TestWindowsPathLengthPreflightRejectsPrivateCloneRoot(t *testing.T) { if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { t.Fatalf("Add() error kind = %v, want KindUnsupportedPath", kind) } - if !strings.Contains(err.Error(), "reaches or exceeds the Windows limit") { + if !strings.Contains(err.Error(), "SPAS Windows preflight limit of 260 bytes") { t.Fatalf("Add() error = %v, want Windows limit error", err) } if !strings.Contains(err.Error(), "data") && !strings.Contains(err.Error(), "repos") { diff --git a/internal/pathmodel/path.go b/internal/pathmodel/path.go index 9e8efd9..991a2e1 100644 --- a/internal/pathmodel/path.go +++ b/internal/pathmodel/path.go @@ -142,7 +142,7 @@ func ValidatePathLength(root string, path Path) error { } full := path.OSPath(root) if len(full) >= limits.MaxWindowsPathLength { - return fmt.Errorf("total path length of %q (%d characters) in root %q reaches or exceeds the Windows limit of %d characters", full, len(full), root, limits.MaxWindowsPathLength) + return fmt.Errorf("total path length of %q (%d bytes) in root %q reaches or exceeds the SPAS Windows preflight limit of %d bytes", full, len(full), root, limits.MaxWindowsPathLength) } return nil } diff --git a/internal/pathmodel/path_test.go b/internal/pathmodel/path_test.go index 11937fb..7a02d73 100644 --- a/internal/pathmodel/path_test.go +++ b/internal/pathmodel/path_test.go @@ -172,7 +172,7 @@ func TestValidatePathLength(t *testing.T) { if err == nil { t.Fatal("ValidatePathLength(long) error = nil on Windows, want limit error") } - if !strings.Contains(err.Error(), "reaches or exceeds the Windows limit") || !strings.Contains(err.Error(), "short") { + if !strings.Contains(err.Error(), "SPAS Windows preflight limit of 260 bytes") || !strings.Contains(err.Error(), "short") { t.Fatalf("ValidatePathLength(long) error = %v, want Windows limit error naming root", err) } } else { diff --git a/internal/privategit/repository_test.go b/internal/privategit/repository_test.go index b3c73b2..06ae39c 100644 --- a/internal/privategit/repository_test.go +++ b/internal/privategit/repository_test.go @@ -806,7 +806,7 @@ func TestValidateTreeRejectsPathLengthExceedingWindowsLimit(t *testing.T) { if kind, ok := spaserr.KindOf(err); !ok || kind != spaserr.KindUnsupportedPath { t.Fatalf("ValidateTree() error kind = %v, want KindUnsupportedPath", kind) } - if !strings.Contains(err.Error(), "reaches or exceeds the Windows limit") { + if !strings.Contains(err.Error(), "SPAS Windows preflight limit of 260 bytes") { t.Fatalf("ValidateTree() error = %v, want Windows limit error", err) } } else { From 2a263a8a40394c00c55e8b73b69da6439832933b Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:26:24 -0500 Subject: [PATCH 51/52] docs(diff): document path aliases and output limits Document read-only selection, Unicode identity checks, and separate captured and streaming budgets. --- wiki/Command-reference.md | 28 ++++++++++++++++++++++++++-- wiki/JSON-output-schema.md | 3 +++ wiki/Safety-and-limitations.md | 5 +++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/wiki/Command-reference.md b/wiki/Command-reference.md index 29dfef3..3a26a28 100644 --- a/wiki/Command-reference.md +++ b/wiki/Command-reference.md @@ -68,6 +68,9 @@ spas add PATH... [flags] `spas add` operates offline. It registers paths in local SPAS state and adds corresponding exclusion patterns to `.git/info/exclude`. Your project `.gitignore` remains unchanged. +Accepted filesystem aliases are enrolled using the actual directory-entry case +and Unicode NFC spelling. This is the spelling used in state and exclusion rules. + | Option | Values | Default | Description | | :--- | :--- | :--- | :--- | | `--existing-exclude` | `ask` \| `preserve` \| `abort` | `ask` | How to handle existing rules in `.git/info/exclude` | @@ -213,6 +216,23 @@ spas status --show-paths Compare managed assets in your local project workspace against the local managed checkout. +File selection reads Git configuration and existing file metadata; it does not +create a workspace probe file. Exact stored spellings take priority. Case aliases +match when `core.ignoreCase` is true or existing file identities prove they refer +to the same file. An unset `core.ignoreCase` defaults to false. Missing case aliases +therefore require `core.ignoreCase=true`; exact missing paths remain selectable +for pending removals and staged deletions. + +Accepted aliases select the authoritative enrolled filename in JSON, name-only, +patch, and stat output, including `--staged`. The original selected spelling is +checked before accepting a case or Unicode-normalization alias: a distinct +existing neighbor cannot select the enrolled file. An explicit file selection +that matches no candidate produces an empty result. +Staged renames expose both their source and destination as selectable paths; +unfiltered patch output keeps Git's normal rename presentation. +Use an exact stored spelling when an alias could refer to both sides of a +case-only rename. + ```text spas diff [PATH...] [flags] ``` @@ -238,7 +258,8 @@ spas doctor [flags] - When run with `--json`, `spas doctor` outputs a single diagnostic JSON object to stdout. - Returns exit code `0` when healthy, or nonzero when issues require attention. -- Outside a Git repository, or in a repository that is not linked, `doctor` runs the environment checks it can (Git version, data directories, advisory locking, and — inside a repository — worktree shape) and exits `0` with a warning notice (`workspace` when not in a Git repository, or `link-state` when unlinked) explaining that link checks were skipped. +- When no Git repository is found, or the repository is not linked, `doctor` runs the available environment checks (Git version, data directories, advisory locking, and worktree shape where applicable). If those checks pass, it exits `0` with a `workspace` or `link-state` warning explaining that link checks were skipped. +- Failed repository inspection is an error. Corrupt configuration, unreadable or unrecognized Git metadata, cancellation, and invalid Git output produce an unhealthy result and nonzero status. A no-repository warning requires Git's absence diagnostic and no `.git` marker in the physical directory ancestry; existing metadata that Git cannot inspect requires attention. --- @@ -329,4 +350,7 @@ Errors are returned as structured JSON objects with `schemaVersion`: | `11` | `unsupported_path` | Path is not a regular file (symlinks, junctions, control characters, or invalid encodings). | | `130` | `interrupted` | Execution cancelled by user interrupt (Ctrl+C / SIGINT). | -When `--timeout` expires, the interrupted operation fails with exit code `1` (`operation_failed`); exit code `130` remains reserved for user signals. +The intended result for `--timeout` expiry is exit code `1` (`operation_failed`). +A known limitation remains: deadlines wrapped by remote Git operations can +currently return exit code `7` (`auth_or_network`). User signals return exit +code `130` (`interrupted`). diff --git a/wiki/JSON-output-schema.md b/wiki/JSON-output-schema.md index 6dab25d..b1484ee 100644 --- a/wiki/JSON-output-schema.md +++ b/wiki/JSON-output-schema.md @@ -371,6 +371,9 @@ Emitted when the private clone has not been initialized yet: - `stagedPaths` is an empty array (`[]`) when nothing is staged. +See the [Diff command reference](Command-reference.md#spas-diff) for file-selection, +alias, and staged-rename behavior shared by JSON and text output. + --- ### `spas doctor` diff --git a/wiki/Safety-and-limitations.md b/wiki/Safety-and-limitations.md index dabade9..5e96e7b 100644 --- a/wiki/Safety-and-limitations.md +++ b/wiki/Safety-and-limitations.md @@ -46,7 +46,7 @@ Please review these operational boundaries before integrating SPAS into your wor - **Submodules & LFS Pointers:** Git submodules and Git LFS pointer files are not supported. - **Special Git Files:** `.gitignore`, `.gitattributes`, and `.gitmodules` cannot be managed by SPAS. - **Unicode Control & Format Characters:** Control characters and Unicode category `Cf` characters (such as U+200C ZWNJ and U+200D ZWJ) are rejected to prevent homograph and visual spoofing issues. -- **Non-Portable Filenames & Excessive Path Lengths:** Filename components exceeding 255 bytes and files with case-collision risks across Windows, macOS, and Linux are rejected on all platforms. On Windows, a preflight check rejects paths when either the workspace or the private clone absolute path reaches or exceeds 260 characters (Windows `MAX_PATH`); this check is machine-local, so long roots on another machine cannot be anticipated and may still be rejected by Git or the host filesystem. +- **Non-Portable Filenames & Excessive Path Lengths:** Filename components exceeding 255 bytes and files with case-collision risks across Windows, macOS, and Linux are rejected on all platforms. On Windows, SPAS conservatively rejects paths when either the workspace or private-clone absolute path reaches 260 UTF-8 bytes. This byte-count preflight is stricter than the native `MAX_PATH` character limit for non-ASCII names. It is machine-local; another machine's roots are checked there, and Git or the filesystem can impose additional limits. --- @@ -71,7 +71,8 @@ SPAS enforces safety limits to prevent runaway resource consumption: | :--- | :--- | | **Managed Tree Size** | Up to **10,000 recursive file entries** | | **Tree Metadata** | Up to **16 MiB** captured tree metadata | -| **Git Command Output** | Up to **16 MiB stdout** and **1 MiB stderr** | +| **Captured Git Command Output** | Up to **16 MiB stdout** and **1 MiB stderr** | +| **Streaming Git Command Output** | Full output forwarded; last **64 KiB per stream** retained for diagnostics | SPAS does not place hard quotas on individual blob sizes or overall network transfers, though large assets are constrained by available disk space and network bandwidth. From 14be7bbeabd6ae13c3c863f4705ff964d683683e Mon Sep 17 00:00:00 2001 From: Otaro <9078877+oovz@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:22:24 -0500 Subject: [PATCH 52/52] test(diff): stage case-only renames with git mv --- internal/app/diff_selection_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/app/diff_selection_test.go b/internal/app/diff_selection_test.go index 1d9f3db..2e0f823 100644 --- a/internal/app/diff_selection_test.go +++ b/internal/app/diff_selection_test.go @@ -225,10 +225,11 @@ func TestStagedDiffDisambiguatesCaseOnlyRename(t *testing.T) { runGit(t, root, "config", "core.ignorecase", "true") runGit(t, privateRoot, "config", "core.ignorecase", "false") oldPath, newPath := "docs/ARCHITECTURE.md", "docs/architecture.md" - if err := os.Rename(filepath.Join(privateRoot, filepath.FromSlash(oldPath)), filepath.Join(privateRoot, filepath.FromSlash(newPath))); err != nil { - t.Fatal(err) + runGit(t, privateRoot, "mv", "-f", "--", oldPath, newPath) + wantStaged := "D\x00docs/ARCHITECTURE.md\x00A\x00docs/architecture.md\x00" + if staged := gitOutput(t, privateRoot, "diff", "--cached", "--no-renames", "--name-status", "-z"); staged != wantStaged { + t.Fatalf("staged rename fixture = %q, want %q", staged, wantStaged) } - runGit(t, privateRoot, "add", "-A") instance.JSON = true for _, selected := range []string{oldPath, newPath} { var output bytes.Buffer