From 3d216ef09b7c853e9e825f3dfd72fb2eb1a3d804 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Tue, 31 Mar 2026 07:36:22 -0400 Subject: [PATCH 1/4] Fix remote URL parsing for paths with spaces. Parse `git remote -v` output without truncating whitespace-containing URLs and add a regression test to lock in behavior for local remotes with spaced paths. Made-with: Cursor --- fixtures.go | 23 ++++++++++++++++++----- fixtures_test.go | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/fixtures.go b/fixtures.go index acf4d83..57e4d00 100644 --- a/fixtures.go +++ b/fixtures.go @@ -200,14 +200,27 @@ func GetRemotes(t *testing.T, repoPath string) map[string]string { } for _, line := range strings.Split(lines, "\n") { + line = strings.TrimSpace(line) if line == "" { continue } - parts := strings.Fields(line) - if len(parts) >= 2 { - name := parts[0] - url := parts[1] - remotes[name] = url + + name, remainder, ok := strings.Cut(line, "\t") + if !ok { + // Fallback for unusual formatting that does not use tabs. + idx := strings.IndexAny(line, " \t") + if idx == -1 { + continue + } + name = line[:idx] + remainder = strings.TrimSpace(line[idx+1:]) + } + + remainder = strings.TrimSuffix(remainder, " (fetch)") + remainder = strings.TrimSuffix(remainder, " (push)") + + if name != "" && remainder != "" { + remotes[name] = remainder } } diff --git a/fixtures_test.go b/fixtures_test.go index 641605d..77f5af1 100644 --- a/fixtures_test.go +++ b/fixtures_test.go @@ -79,6 +79,26 @@ func TestCreateTestRepo_WithRemotes(t *testing.T) { } } +func TestCreateTestRepo_WithRemotePathContainingSpaces(t *testing.T) { + remotePath := testutil.CreateBareRemote(t, "origin with space") + + repoPath := testutil.CreateTestRepo(t, testutil.RepoOptions{ + Name: "remote-space-repo", + Remotes: map[string]string{ + "origin": remotePath, + }, + }) + + remotes := testutil.GetRemotes(t, repoPath) + originURL, exists := remotes["origin"] + if !exists { + t.Fatal("Expected 'origin' remote to be configured") + } + if originURL != remotePath { + t.Fatalf("Expected origin URL %q, got %q", remotePath, originURL) + } +} + func TestCreateBareRemote(t *testing.T) { remotePath := testutil.CreateBareRemote(t, "test-remote") From 58fe7d0f4031fbfaa130df5e81c6606f5b620fe9 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 5 Apr 2026 01:36:45 -0400 Subject: [PATCH 2/4] feat: add USB fixture helpers for target-volume testing Provide reusable helpers for creating .git-fire volume roots, reading/writing marker config, asserting bare/non-bare git destinations, and file URL conversion. --- usb_fixtures.go | 134 +++++++++++++++++++++++++++++++++++++++++++ usb_fixtures_test.go | 48 ++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 usb_fixtures.go create mode 100644 usb_fixtures_test.go diff --git a/usb_fixtures.go b/usb_fixtures.go new file mode 100644 index 0000000..b40f85f --- /dev/null +++ b/usb_fixtures.go @@ -0,0 +1,134 @@ +package testutil + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +type USBVolumeOptions struct { + LayoutDir string + Strategy string + CreateReposDir bool +} + +type USBVolumeConfig struct { + SchemaVersion int + LayoutDir string + Strategy string + CreatedAt time.Time +} + +func MustUSBVolumeRoot(t *testing.T, opts USBVolumeOptions) string { + t.Helper() + root := t.TempDir() + cfg := USBVolumeConfig{ + SchemaVersion: 1, + LayoutDir: opts.LayoutDir, + Strategy: opts.Strategy, + CreatedAt: time.Now().UTC(), + } + if cfg.LayoutDir == "" { + cfg.LayoutDir = "repos" + } + if cfg.Strategy == "" { + cfg.Strategy = "git-mirror" + } + WriteUSBVolumeConfig(t, root, cfg) + if opts.CreateReposDir { + if err := os.MkdirAll(filepath.Join(root, cfg.LayoutDir), 0o755); err != nil { + t.Fatalf("failed creating repos dir: %v", err) + } + } + return root +} + +func WriteUSBVolumeConfig(t *testing.T, root string, cfg USBVolumeConfig) { + t.Helper() + if cfg.SchemaVersion <= 0 { + cfg.SchemaVersion = 1 + } + if cfg.LayoutDir == "" { + cfg.LayoutDir = "repos" + } + if cfg.Strategy == "" { + cfg.Strategy = "git-mirror" + } + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = time.Now().UTC() + } + content := fmt.Sprintf( + "schema_version = %d\nlayout_dir = %q\nstrategy = %q\ncreated_at = %q\n", + cfg.SchemaVersion, + cfg.LayoutDir, + cfg.Strategy, + cfg.CreatedAt.Format(time.RFC3339), + ) + if err := os.WriteFile(filepath.Join(root, ".git-fire"), []byte(content), 0o644); err != nil { + t.Fatalf("failed writing .git-fire: %v", err) + } +} + +func ReadUSBVolumeConfig(t *testing.T, root string) USBVolumeConfig { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, ".git-fire")) + if err != nil { + t.Fatalf("failed reading .git-fire: %v", err) + } + cfg := USBVolumeConfig{} + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.Trim(strings.TrimSpace(val), "\"") + switch key { + case "schema_version": + n, _ := strconv.Atoi(val) + cfg.SchemaVersion = n + case "layout_dir": + cfg.LayoutDir = val + case "strategy": + cfg.Strategy = val + case "created_at": + if ts, err := time.Parse(time.RFC3339, val); err == nil { + cfg.CreatedAt = ts + } + } + } + return cfg +} + +func AssertGitDirAt(t *testing.T, path string, wantBare bool) { + t.Helper() + if wantBare { + if _, err := os.Stat(filepath.Join(path, "HEAD")); err != nil { + t.Fatalf("expected bare repo at %s: %v", path, err) + } + return + } + if _, err := os.Stat(filepath.Join(path, ".git")); err != nil { + t.Fatalf("expected non-bare repo at %s: %v", path, err) + } +} + +func FileURLForPath(t *testing.T, path string) string { + t.Helper() + abs, err := filepath.Abs(path) + if err != nil { + t.Fatalf("failed to make abs path: %v", err) + } + u := &url.URL{Scheme: "file", Path: filepath.ToSlash(abs)} + return u.String() +} diff --git a/usb_fixtures_test.go b/usb_fixtures_test.go new file mode 100644 index 0000000..7d8ae96 --- /dev/null +++ b/usb_fixtures_test.go @@ -0,0 +1,48 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMustUSBVolumeRoot(t *testing.T) { + root := MustUSBVolumeRoot(t, USBVolumeOptions{ + LayoutDir: "repos", + Strategy: "git-mirror", + CreateReposDir: true, + }) + if _, err := os.Stat(filepath.Join(root, ".git-fire")); err != nil { + t.Fatalf("expected .git-fire marker: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "repos")); err != nil { + t.Fatalf("expected repos dir: %v", err) + } +} + +func TestReadWriteUSBVolumeConfig(t *testing.T) { + root := t.TempDir() + WriteUSBVolumeConfig(t, root, USBVolumeConfig{ + SchemaVersion: 2, + LayoutDir: "custom", + Strategy: "git-clone", + }) + cfg := ReadUSBVolumeConfig(t, root) + if cfg.SchemaVersion != 2 { + t.Fatalf("schema mismatch: %d", cfg.SchemaVersion) + } + if cfg.LayoutDir != "custom" { + t.Fatalf("layout mismatch: %s", cfg.LayoutDir) + } + if cfg.Strategy != "git-clone" { + t.Fatalf("strategy mismatch: %s", cfg.Strategy) + } +} + +func TestFileURLForPath(t *testing.T) { + root := t.TempDir() + got := FileURLForPath(t, root) + if len(got) < 7 || got[:7] != "file://" { + t.Fatalf("expected file:// URL, got %s", got) + } +} From 929834e498508dd111f7debd7dbb3f6e3199c0c7 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 5 Apr 2026 16:07:13 -0400 Subject: [PATCH 3/4] fix(testutil): harden USB fixtures per review feedback Validate layout_dir stays relative under the fixture root, surface invalid schema_version when parsing .git-fire, and build RFC 8089-style file URLs on Windows drive-letter paths. Tighten FileURLForPath test. Made-with: Cursor --- usb_fixtures.go | 35 ++++++++++++++++++++++++++--------- usb_fixtures_test.go | 16 ++++++++++++++-- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/usb_fixtures.go b/usb_fixtures.go index b40f85f..454f701 100644 --- a/usb_fixtures.go +++ b/usb_fixtures.go @@ -24,18 +24,30 @@ type USBVolumeConfig struct { CreatedAt time.Time } +func mustRelativeLayoutDir(t *testing.T, layoutDir string) string { + t.Helper() + if layoutDir == "" { + return "repos" + } + clean := filepath.Clean(layoutDir) + if filepath.IsAbs(clean) { + t.Fatalf("layout_dir must be relative to fixture root: %q", layoutDir) + } + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + t.Fatalf("layout_dir must be relative to fixture root: %q", layoutDir) + } + return clean +} + func MustUSBVolumeRoot(t *testing.T, opts USBVolumeOptions) string { t.Helper() root := t.TempDir() cfg := USBVolumeConfig{ SchemaVersion: 1, - LayoutDir: opts.LayoutDir, + LayoutDir: mustRelativeLayoutDir(t, opts.LayoutDir), Strategy: opts.Strategy, CreatedAt: time.Now().UTC(), } - if cfg.LayoutDir == "" { - cfg.LayoutDir = "repos" - } if cfg.Strategy == "" { cfg.Strategy = "git-mirror" } @@ -53,9 +65,7 @@ func WriteUSBVolumeConfig(t *testing.T, root string, cfg USBVolumeConfig) { if cfg.SchemaVersion <= 0 { cfg.SchemaVersion = 1 } - if cfg.LayoutDir == "" { - cfg.LayoutDir = "repos" - } + cfg.LayoutDir = mustRelativeLayoutDir(t, cfg.LayoutDir) if cfg.Strategy == "" { cfg.Strategy = "git-mirror" } @@ -95,7 +105,10 @@ func ReadUSBVolumeConfig(t *testing.T, root string) USBVolumeConfig { val = strings.Trim(strings.TrimSpace(val), "\"") switch key { case "schema_version": - n, _ := strconv.Atoi(val) + n, err := strconv.Atoi(val) + if err != nil { + t.Fatalf("invalid schema_version %q: %v", val, err) + } cfg.SchemaVersion = n case "layout_dir": cfg.LayoutDir = val @@ -129,6 +142,10 @@ func FileURLForPath(t *testing.T, path string) string { if err != nil { t.Fatalf("failed to make abs path: %v", err) } - u := &url.URL{Scheme: "file", Path: filepath.ToSlash(abs)} + uPath := filepath.ToSlash(abs) + if filepath.VolumeName(abs) != "" && !strings.HasPrefix(uPath, "/") { + uPath = "/" + uPath + } + u := &url.URL{Scheme: "file", Path: uPath} return u.String() } diff --git a/usb_fixtures_test.go b/usb_fixtures_test.go index 7d8ae96..1a944b5 100644 --- a/usb_fixtures_test.go +++ b/usb_fixtures_test.go @@ -1,8 +1,10 @@ package testutil import ( + "net/url" "os" "path/filepath" + "strings" "testing" ) @@ -42,7 +44,17 @@ func TestReadWriteUSBVolumeConfig(t *testing.T) { func TestFileURLForPath(t *testing.T) { root := t.TempDir() got := FileURLForPath(t, root) - if len(got) < 7 || got[:7] != "file://" { - t.Fatalf("expected file:// URL, got %s", got) + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("parse URL: %v", err) + } + if parsed.Scheme != "file" { + t.Fatalf("scheme %q, want file", parsed.Scheme) + } + if parsed.Path == "" || parsed.Path[0] != '/' { + t.Fatalf("expected absolute path in URL, got path=%q for %q", parsed.Path, got) + } + if !strings.HasPrefix(got, "file:///") { + t.Fatalf("expected canonical file URL with empty authority (file:///...), got %q", got) } } From c2b6dc48a820742fb5d1f48c395eb2367ccd83b7 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger Date: Sun, 5 Apr 2026 16:11:21 -0400 Subject: [PATCH 4/4] fix(testutil): stricter .git-fire parsing and layout validation tests Extract validateFixtureLayoutDir and readUSBVolumeConfigBytes so reads reject bad layout_dir, invalid schema_version, and bad or empty created_at. Normalize non-empty layout_dir on read. Add table tests for validation and parse errors. Made-with: Cursor --- usb_fixtures.go | 66 ++++++++++++++++++++++++--------- usb_fixtures_test.go | 87 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 17 deletions(-) diff --git a/usb_fixtures.go b/usb_fixtures.go index 454f701..c43324a 100644 --- a/usb_fixtures.go +++ b/usb_fixtures.go @@ -11,6 +11,22 @@ import ( "time" ) +// validateFixtureLayoutDir reports whether layoutDir may be joined under a fixture root. +// Empty layoutDir is allowed (caller may default it). +func validateFixtureLayoutDir(layoutDir string) error { + if layoutDir == "" { + return nil + } + clean := filepath.Clean(layoutDir) + if filepath.IsAbs(clean) { + return fmt.Errorf("must be relative to fixture root: %q", layoutDir) + } + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("must be relative to fixture root: %q", layoutDir) + } + return nil +} + type USBVolumeOptions struct { LayoutDir string Strategy string @@ -29,14 +45,10 @@ func mustRelativeLayoutDir(t *testing.T, layoutDir string) string { if layoutDir == "" { return "repos" } - clean := filepath.Clean(layoutDir) - if filepath.IsAbs(clean) { - t.Fatalf("layout_dir must be relative to fixture root: %q", layoutDir) - } - if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { - t.Fatalf("layout_dir must be relative to fixture root: %q", layoutDir) + if err := validateFixtureLayoutDir(layoutDir); err != nil { + t.Fatalf("layout_dir %v", err) } - return clean + return filepath.Clean(layoutDir) } func MustUSBVolumeRoot(t *testing.T, opts USBVolumeOptions) string { @@ -84,12 +96,7 @@ func WriteUSBVolumeConfig(t *testing.T, root string, cfg USBVolumeConfig) { } } -func ReadUSBVolumeConfig(t *testing.T, root string) USBVolumeConfig { - t.Helper() - data, err := os.ReadFile(filepath.Join(root, ".git-fire")) - if err != nil { - t.Fatalf("failed reading .git-fire: %v", err) - } +func readUSBVolumeConfigBytes(data []byte) (USBVolumeConfig, error) { cfg := USBVolumeConfig{} lines := strings.Split(string(data), "\n") for _, line := range lines { @@ -107,19 +114,44 @@ func ReadUSBVolumeConfig(t *testing.T, root string) USBVolumeConfig { case "schema_version": n, err := strconv.Atoi(val) if err != nil { - t.Fatalf("invalid schema_version %q: %v", val, err) + return cfg, fmt.Errorf("invalid schema_version %q: %w", val, err) } cfg.SchemaVersion = n case "layout_dir": - cfg.LayoutDir = val + if err := validateFixtureLayoutDir(val); err != nil { + return cfg, fmt.Errorf("layout_dir: %w", err) + } + if val == "" { + cfg.LayoutDir = "" + } else { + cfg.LayoutDir = filepath.Clean(val) + } case "strategy": cfg.Strategy = val case "created_at": - if ts, err := time.Parse(time.RFC3339, val); err == nil { - cfg.CreatedAt = ts + if val == "" { + return cfg, fmt.Errorf("created_at: empty value") } + ts, err := time.Parse(time.RFC3339, val) + if err != nil { + return cfg, fmt.Errorf("invalid created_at %q: %w", val, err) + } + cfg.CreatedAt = ts } } + return cfg, nil +} + +func ReadUSBVolumeConfig(t *testing.T, root string) USBVolumeConfig { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, ".git-fire")) + if err != nil { + t.Fatalf("failed reading .git-fire: %v", err) + } + cfg, err := readUSBVolumeConfigBytes(data) + if err != nil { + t.Fatalf("parse .git-fire: %v", err) + } return cfg } diff --git a/usb_fixtures_test.go b/usb_fixtures_test.go index 1a944b5..458c934 100644 --- a/usb_fixtures_test.go +++ b/usb_fixtures_test.go @@ -41,6 +41,93 @@ func TestReadWriteUSBVolumeConfig(t *testing.T) { } } +func TestValidateFixtureLayoutDir(t *testing.T) { + t.Parallel() + root := t.TempDir() + absUnderRoot := filepath.Join(root, "abs-layout") + if err := os.MkdirAll(absUnderRoot, 0o755); err != nil { + t.Fatal(err) + } + bad := []string{ + "..", + "../escape", + "nested/../../../escape", + absUnderRoot, + } + for _, dir := range bad { + if err := validateFixtureLayoutDir(dir); err == nil { + t.Errorf("validateFixtureLayoutDir(%q): want error, got nil", dir) + } + } + good := []string{"", "repos", "nested", "nested/../repos"} + for _, dir := range good { + if err := validateFixtureLayoutDir(dir); err != nil { + t.Errorf("validateFixtureLayoutDir(%q): %v", dir, err) + } + } +} + +func TestReadUSBVolumeConfigBytes_roundTrip(t *testing.T) { + t.Parallel() + input := "schema_version = 2\nlayout_dir = \"custom\"\nstrategy = \"git-clone\"\ncreated_at = \"2020-01-02T15:04:05Z\"\n" + cfg, err := readUSBVolumeConfigBytes([]byte(input)) + if err != nil { + t.Fatal(err) + } + if cfg.SchemaVersion != 2 { + t.Fatalf("schema: got %d", cfg.SchemaVersion) + } + if cfg.LayoutDir != "custom" { + t.Fatalf("layout: got %q", cfg.LayoutDir) + } + if cfg.Strategy != "git-clone" { + t.Fatalf("strategy: got %q", cfg.Strategy) + } + if cfg.CreatedAt.IsZero() { + t.Fatal("created_at: zero") + } +} + +func TestReadUSBVolumeConfigBytes_errors(t *testing.T) { + t.Parallel() + cases := []struct { + name, content, wantSubstring string + }{ + { + name: "invalid_schema_version", + content: "schema_version = notint\n", + wantSubstring: "schema_version", + }, + { + name: "layout_dir_escape", + content: "layout_dir = ../x\n", + wantSubstring: "layout_dir", + }, + { + name: "invalid_created_at", + content: "created_at = not-a-date\n", + wantSubstring: "created_at", + }, + { + name: "empty_created_at", + content: "created_at = \n", + wantSubstring: "created_at", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := readUSBVolumeConfigBytes([]byte(tc.content)) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tc.wantSubstring) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSubstring) + } + }) + } +} + func TestFileURLForPath(t *testing.T) { root := t.TempDir() got := FileURLForPath(t, root)