diff --git a/fixtures.go b/fixtures.go index e18ba38..a06526b 100644 --- a/fixtures.go +++ b/fixtures.go @@ -204,16 +204,25 @@ func GetRemotes(t *testing.T, repoPath string) map[string]string { if line == "" { continue } - name, rest, ok := strings.Cut(line, "\t") + name, remainder, ok := strings.Cut(line, "\t") if !ok { - continue + // Fallback for unusual formatting that does not use tabs. + idx := strings.IndexAny(line, " \t") + if idx == -1 { + continue + } + name = strings.TrimSpace(line[:idx]) + remainder = strings.TrimSpace(line[idx+1:]) + } else { + name = strings.TrimSpace(name) + remainder = strings.TrimSpace(remainder) } - name = strings.TrimSpace(name) - rest = strings.TrimSpace(rest) - rest = strings.TrimSuffix(rest, " (fetch)") - rest = strings.TrimSuffix(rest, " (push)") - if name != "" && rest != "" { - remotes[name] = rest + + 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") diff --git a/usb_fixtures.go b/usb_fixtures.go new file mode 100644 index 0000000..c43324a --- /dev/null +++ b/usb_fixtures.go @@ -0,0 +1,183 @@ +package testutil + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "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 + CreateReposDir bool +} + +type USBVolumeConfig struct { + SchemaVersion int + LayoutDir string + Strategy string + CreatedAt time.Time +} + +func mustRelativeLayoutDir(t *testing.T, layoutDir string) string { + t.Helper() + if layoutDir == "" { + return "repos" + } + if err := validateFixtureLayoutDir(layoutDir); err != nil { + t.Fatalf("layout_dir %v", err) + } + return filepath.Clean(layoutDir) +} + +func MustUSBVolumeRoot(t *testing.T, opts USBVolumeOptions) string { + t.Helper() + root := t.TempDir() + cfg := USBVolumeConfig{ + SchemaVersion: 1, + LayoutDir: mustRelativeLayoutDir(t, opts.LayoutDir), + Strategy: opts.Strategy, + CreatedAt: time.Now().UTC(), + } + 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 + } + cfg.LayoutDir = mustRelativeLayoutDir(t, cfg.LayoutDir) + 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 readUSBVolumeConfigBytes(data []byte) (USBVolumeConfig, error) { + 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, err := strconv.Atoi(val) + if err != nil { + return cfg, fmt.Errorf("invalid schema_version %q: %w", val, err) + } + cfg.SchemaVersion = n + case "layout_dir": + 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 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 +} + +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) + } + 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 new file mode 100644 index 0000000..458c934 --- /dev/null +++ b/usb_fixtures_test.go @@ -0,0 +1,147 @@ +package testutil + +import ( + "net/url" + "os" + "path/filepath" + "strings" + "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 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) + 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) + } +}