Go · 6629 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package storage
4
5 import (
6 "context"
7 "errors"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 )
14
15 func mustNewRepoFS(t *testing.T) (*RepoFS, string) {
16 t.Helper()
17 dir := t.TempDir()
18 r, err := NewRepoFS(dir)
19 if err != nil {
20 t.Fatalf("NewRepoFS: %v", err)
21 }
22 return r, dir
23 }
24
25 func TestNewRepoFS_RejectsRelativeRoot(t *testing.T) {
26 t.Parallel()
27 if _, err := NewRepoFS("relative/path"); err == nil {
28 t.Fatal("expected error for relative root")
29 }
30 }
31
32 func TestNewRepoFS_RejectsMissingRoot(t *testing.T) {
33 t.Parallel()
34 if _, err := NewRepoFS("/this/path/should/not/exist/abc123xyz"); err == nil {
35 t.Fatal("expected error for missing root")
36 }
37 }
38
39 func TestRepoPath_HappyPath(t *testing.T) {
40 t.Parallel()
41 r, root := mustNewRepoFS(t)
42 got, err := r.RepoPath("alice", "my-project")
43 if err != nil {
44 t.Fatalf("RepoPath: %v", err)
45 }
46 want := filepath.Join(root, "al", "alice", "my-project.git")
47 if got != want {
48 t.Fatalf("RepoPath = %q, want %q", got, want)
49 }
50 }
51
52 func TestRepoPath_AcceptsRepoExtraChars(t *testing.T) {
53 t.Parallel()
54 r, _ := mustNewRepoFS(t)
55 for _, name := range []string{"name.with.dots", "name_under", "rust-by-example", "a1.b2_c3"} {
56 if _, err := r.RepoPath("alice", name); err != nil {
57 t.Errorf("RepoPath %q: %v", name, err)
58 }
59 }
60 }
61
62 func TestRepoPath_ShortOwnerPaddedShard(t *testing.T) {
63 t.Parallel()
64 r, root := mustNewRepoFS(t)
65 got, err := r.RepoPath("a", "x")
66 if err != nil {
67 t.Fatalf("RepoPath: %v", err)
68 }
69 want := filepath.Join(root, "a_", "a", "x.git")
70 if got != want {
71 t.Fatalf("RepoPath = %q, want %q", got, want)
72 }
73 }
74
75 func TestRepoPath_LowercasesOwner(t *testing.T) {
76 t.Parallel()
77 r, root := mustNewRepoFS(t)
78 got, err := r.RepoPath("Alice", "Project")
79 if err != nil {
80 t.Fatalf("RepoPath: %v", err)
81 }
82 want := filepath.Join(root, "al", "alice", "project.git")
83 if got != want {
84 t.Fatalf("RepoPath = %q, want %q", got, want)
85 }
86 }
87
88 // TestRepoPath_RejectsUnsafe is the critical path-validation table-driven
89 // test mandated by S04. Every entry MUST be rejected.
90 func TestRepoPath_RejectsUnsafe(t *testing.T) {
91 t.Parallel()
92 r, _ := mustNewRepoFS(t)
93
94 cases := []struct {
95 owner, name, why string
96 }{
97 {"", "name", "empty owner"},
98 {"alice", "", "empty repo"},
99 {"..", "name", "owner is .."},
100 {"alice", "..", "repo is .."},
101 {"al/ice", "name", "owner contains slash"},
102 {"alice", "na/me", "repo contains slash"},
103 {"alice", "../escape", "repo path traversal"},
104 {"-leading", "name", "owner leading dash"},
105 {"trailing-", "name", "owner trailing dash"},
106 {"alice", "-leading", "repo leading dash"},
107 {"alice", "trailing-", "repo trailing dash"},
108 {".hidden", "name", "owner leading dot"},
109 {"alice", ".hidden", "repo leading dot"},
110 {"alice", ".git", "repo dotfile"},
111 {"/absolute", "name", "owner absolute"},
112 {"alice", "/absolute", "repo absolute"},
113 {"alice", "name with space", "repo space"},
114 {"alice", "name\x00null", "repo nul"},
115 {"alice", "name\nnewline", "repo newline"},
116 {"АliCe", "name", "owner non-ASCII (Cyrillic A)"},
117 {"alice", "café", "repo non-ASCII"},
118 {strings.Repeat("a", 40), "name", "owner too long"},
119 {"alice", strings.Repeat("b", 101), "repo too long"},
120 {"alice", "al!ice", "repo punctuation"},
121 {"alice", "name@thing", "repo @"},
122 {"al!ice", "name", "owner punctuation"},
123 }
124
125 for _, c := range cases {
126 c := c
127 t.Run(c.why, func(t *testing.T) {
128 t.Parallel()
129 _, err := r.RepoPath(c.owner, c.name)
130 if err == nil {
131 t.Fatalf("expected error for %s (owner=%q, name=%q)", c.why, c.owner, c.name)
132 }
133 if !errors.Is(err, ErrInvalidPath) {
134 t.Fatalf("expected ErrInvalidPath for %s, got %v", c.why, err)
135 }
136 })
137 }
138 }
139
140 func TestExists_RejectsOutsideRoot(t *testing.T) {
141 t.Parallel()
142 r, _ := mustNewRepoFS(t)
143 _, err := r.Exists("/etc/passwd")
144 if !errors.Is(err, ErrEscapesRoot) {
145 t.Fatalf("expected ErrEscapesRoot, got %v", err)
146 }
147 }
148
149 func TestInitBare_HEADIsTrunk(t *testing.T) {
150 t.Parallel()
151 if _, err := exec.LookPath("git"); err != nil {
152 t.Skip("git not in PATH")
153 }
154 r, _ := mustNewRepoFS(t)
155 path, err := r.RepoPath("alice", "trunktest")
156 if err != nil {
157 t.Fatalf("RepoPath: %v", err)
158 }
159 if err := r.InitBare(context.Background(), path); err != nil {
160 t.Fatalf("InitBare: %v", err)
161 }
162 // G204: path comes from RepoPath in test setup (whitelisted).
163 out, err := exec.Command("git", "--git-dir", path, "symbolic-ref", "HEAD").Output() //nolint:gosec
164 if err != nil {
165 t.Fatalf("symbolic-ref: %v", err)
166 }
167 got := strings.TrimSpace(string(out))
168 if got != "refs/heads/trunk" {
169 t.Fatalf("HEAD = %q, want refs/heads/trunk", got)
170 }
171 }
172
173 func TestInitBare_RefusesNonEmpty(t *testing.T) {
174 t.Parallel()
175 if _, err := exec.LookPath("git"); err != nil {
176 t.Skip("git not in PATH")
177 }
178 r, _ := mustNewRepoFS(t)
179 path, err := r.RepoPath("alice", "twice")
180 if err != nil {
181 t.Fatalf("RepoPath: %v", err)
182 }
183 if err := r.InitBare(context.Background(), path); err != nil {
184 t.Fatalf("first InitBare: %v", err)
185 }
186 if err := r.InitBare(context.Background(), path); !errors.Is(err, ErrAlreadyExists) {
187 t.Fatalf("expected ErrAlreadyExists on second init, got %v", err)
188 }
189 }
190
191 func TestMove_AtomicAndRefusesOverwrite(t *testing.T) {
192 t.Parallel()
193 if _, err := exec.LookPath("git"); err != nil {
194 t.Skip("git not in PATH")
195 }
196 r, _ := mustNewRepoFS(t)
197 src, _ := r.RepoPath("alice", "src")
198 dst, _ := r.RepoPath("alice", "dst")
199 if err := r.InitBare(context.Background(), src); err != nil {
200 t.Fatalf("InitBare src: %v", err)
201 }
202 if err := r.Move(src, dst); err != nil {
203 t.Fatalf("Move: %v", err)
204 }
205 srcExists, _ := r.Exists(src)
206 dstExists, _ := r.Exists(dst)
207 if srcExists || !dstExists {
208 t.Fatalf("expected src absent and dst present, got src=%v dst=%v", srcExists, dstExists)
209 }
210
211 // Refuses overwrite: re-create src then attempt to move into existing dst.
212 if err := r.InitBare(context.Background(), src); err != nil {
213 t.Fatalf("re-init src: %v", err)
214 }
215 if err := r.Move(src, dst); !errors.Is(err, ErrAlreadyExists) {
216 t.Fatalf("expected ErrAlreadyExists, got %v", err)
217 }
218 }
219
220 func TestDelete_RefusesEscape(t *testing.T) {
221 t.Parallel()
222 r, _ := mustNewRepoFS(t)
223 if err := r.Delete("/etc/passwd"); !errors.Is(err, ErrEscapesRoot) {
224 t.Fatalf("expected ErrEscapesRoot, got %v", err)
225 }
226 }
227
228 func TestDelete_RefusesRoot(t *testing.T) {
229 t.Parallel()
230 r, root := mustNewRepoFS(t)
231 if err := r.Delete(root); !errors.Is(err, ErrEscapesRoot) {
232 t.Fatalf("expected ErrEscapesRoot for root, got %v", err)
233 }
234 // Root must still exist.
235 if _, err := os.Stat(root); err != nil {
236 t.Fatalf("root removed: %v", err)
237 }
238 }
239