detached 02b51ba

fix(security): use crypto-random UUIDs for blob uploads

Replace timestamp-based blob naming with cryptographically secure UUIDs
to prevent predictable file paths and potential security issues.
Update validation regex and tests to reflect new UUID format.
commit
02b51ba76905501ca645be71ea17de2f51a07362
author
brice · · 2026-02-06 05:32 UTC
parent
df25f46
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +31 -33
M internal/adapters/in/http/registry/handler_test.go +9 -9
@@ -257,7 +257,7 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) {
257257
258258 handler := NewHandler(registrySvc, testLogger())
259259
260- registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("1234567890-myapp", nil)
260+ registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
261261
262262 req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
263263 rec := httptest.NewRecorder()
@@ -265,7 +265,7 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) {
265265 handler.ServeHTTP(rec, req)
266266
267267 assert.Equal(t, http.StatusAccepted, rec.Code)
268- assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/1234567890-myapp")
268+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000")
269269 assert.Equal(t, "0-0", rec.Header().Get("Range"))
270270 }
271271
@@ -291,15 +291,15 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
291291 handler := NewHandler(registrySvc, testLogger())
292292
293293 chunkData := []byte("chunk content")
294- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil)
294+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
295295
296- req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/1234567890-myapp", bytes.NewReader(chunkData))
296+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
297297 rec := httptest.NewRecorder()
298298
299299 handler.ServeHTTP(rec, req)
300300
301301 assert.Equal(t, http.StatusAccepted, rec.Code)
302- assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/1234567890-myapp")
302+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000")
303303 }
304304
305305 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
@@ -308,10 +308,10 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
308308 handler := NewHandler(registrySvc, testLogger())
309309
310310 chunkData := []byte("final chunk")
311- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil)
312- registrySvc.EXPECT().FinishUpload(mock.Anything, "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
311+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
312+ registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
313313
314- req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/1234567890-myapp?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
314+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
315315 rec := httptest.NewRecorder()
316316
317317 handler.ServeHTTP(rec, req)
@@ -330,7 +330,7 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
330330 // Note: Invalid digest format is rejected by validation before reaching storage layer
331331 // This test verifies that invalid digests are properly rejected
332332
333- req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/1234567890-myapp?digest=sha256:wrong", bytes.NewReader(chunkData))
333+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:wrong", bytes.NewReader(chunkData))
334334 rec := httptest.NewRecorder()
335335
336336 handler.ServeHTTP(rec, req)
@@ -257,7 +257,7 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) {
257 257
258 handler := NewHandler(registrySvc, testLogger())258 handler := NewHandler(registrySvc, testLogger())
259 259
260- registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("1234567890-myapp", nil)260+ registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
261 261
262 req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)262 req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
263 rec := httptest.NewRecorder()263 rec := httptest.NewRecorder()
@@ -265,7 +265,7 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) {
265 handler.ServeHTTP(rec, req)265 handler.ServeHTTP(rec, req)
266 266
267 assert.Equal(t, http.StatusAccepted, rec.Code)267 assert.Equal(t, http.StatusAccepted, rec.Code)
268- assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/1234567890-myapp")268+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000")
269 assert.Equal(t, "0-0", rec.Header().Get("Range"))269 assert.Equal(t, "0-0", rec.Header().Get("Range"))
270 }270 }
271 271
@@ -291,15 +291,15 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
291 handler := NewHandler(registrySvc, testLogger())291 handler := NewHandler(registrySvc, testLogger())
292 292
293 chunkData := []byte("chunk content")293 chunkData := []byte("chunk content")
294- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil)294+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
295 295
296- req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/1234567890-myapp", bytes.NewReader(chunkData))296+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
297 rec := httptest.NewRecorder()297 rec := httptest.NewRecorder()
298 298
299 handler.ServeHTTP(rec, req)299 handler.ServeHTTP(rec, req)
300 300
301 assert.Equal(t, http.StatusAccepted, rec.Code)301 assert.Equal(t, http.StatusAccepted, rec.Code)
302- assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/1234567890-myapp")302+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000")
303 }303 }
304 304
305 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {305 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
@@ -308,10 +308,10 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
308 handler := NewHandler(registrySvc, testLogger())308 handler := NewHandler(registrySvc, testLogger())
309 309
310 chunkData := []byte("final chunk")310 chunkData := []byte("final chunk")
311- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "1234567890-myapp", chunkData).Return(int64(len(chunkData)), nil)311+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
312- registrySvc.EXPECT().FinishUpload(mock.Anything, "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)312+ registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
313 313
314- req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/1234567890-myapp?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))314+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
315 rec := httptest.NewRecorder()315 rec := httptest.NewRecorder()
316 316
317 handler.ServeHTTP(rec, req)317 handler.ServeHTTP(rec, req)
@@ -330,7 +330,7 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
330 // Note: Invalid digest format is rejected by validation before reaching storage layer330 // Note: Invalid digest format is rejected by validation before reaching storage layer
331 // This test verifies that invalid digests are properly rejected331 // This test verifies that invalid digests are properly rejected
332 332
333- req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/1234567890-myapp?digest=sha256:wrong", bytes.NewReader(chunkData))333+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:wrong", bytes.NewReader(chunkData))
334 rec := httptest.NewRecorder()334 rec := httptest.NewRecorder()
335 335
336 handler.ServeHTTP(rec, req)336 handler.ServeHTTP(rec, req)
M internal/adapters/out/filesystem/blob.go +5 -6
@@ -11,9 +11,9 @@ import (
1111 "os"
1212 "path/filepath"
1313 "strings"
14- "time"
1514
1615 "github.com/bnema/zerowrap"
16+ "github.com/google/uuid"
1717
1818 "github.com/bnema/gordon/pkg/validation"
1919 )
@@ -167,10 +167,9 @@ func (s *BlobStorage) BlobExists(digest string) bool {
167167
168168 // StartBlobUpload starts a new blob upload and returns the upload UUID.
169169 func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
170- // Generate UUID-like upload ID using timestamp
171- uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_"))
170+ uploadID := uuid.New().String()
172171
173- uploadPath, err := s.getUploadPath(uuid)
172+ uploadPath, err := s.getUploadPath(uploadID)
174173 if err != nil {
175174 return "", fmt.Errorf("invalid upload path: %w", err)
176175 }
@@ -190,11 +189,11 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
190189 s.log.Info().
191190 Str(zerowrap.FieldLayer, "adapter").
192191 Str(zerowrap.FieldAdapter, "filesystem").
193- Str("uuid", uuid).
192+ Str("uuid", uploadID).
194193 Str("name", name).
195194 Msg("blob upload started")
196195
197- return uuid, nil
196+ return uploadID, nil
198197 }
199198
200199 // AppendBlobChunk appends data to an in-progress upload.
@@ -11,9 +11,9 @@ import (
11 "os"11 "os"
12 "path/filepath"12 "path/filepath"
13 "strings"13 "strings"
14- "time"
15 14
16 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
16+ "github.com/google/uuid"
17 17
18 "github.com/bnema/gordon/pkg/validation"18 "github.com/bnema/gordon/pkg/validation"
19 )19 )
@@ -167,10 +167,9 @@ func (s *BlobStorage) BlobExists(digest string) bool {
167 167
168 // StartBlobUpload starts a new blob upload and returns the upload UUID.168 // StartBlobUpload starts a new blob upload and returns the upload UUID.
169 func (s *BlobStorage) StartBlobUpload(name string) (string, error) {169 func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
170- // Generate UUID-like upload ID using timestamp170+ uploadID := uuid.New().String()
171- uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_"))
172 171
173- uploadPath, err := s.getUploadPath(uuid)172+ uploadPath, err := s.getUploadPath(uploadID)
174 if err != nil {173 if err != nil {
175 return "", fmt.Errorf("invalid upload path: %w", err)174 return "", fmt.Errorf("invalid upload path: %w", err)
176 }175 }
@@ -190,11 +189,11 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
190 s.log.Info().189 s.log.Info().
191 Str(zerowrap.FieldLayer, "adapter").190 Str(zerowrap.FieldLayer, "adapter").
192 Str(zerowrap.FieldAdapter, "filesystem").191 Str(zerowrap.FieldAdapter, "filesystem").
193- Str("uuid", uuid).192+ Str("uuid", uploadID).
194 Str("name", name).193 Str("name", name).
195 Msg("blob upload started")194 Msg("blob upload started")
196 195
197- return uuid, nil196+ return uploadID, nil
198 }197 }
199 198
200 // AppendBlobChunk appends data to an in-progress upload.199 // AppendBlobChunk appends data to an in-progress upload.
M internal/adapters/out/filesystem/blob_test.go +4 -4
@@ -222,7 +222,7 @@ func TestBlobStorage_StartBlobUpload(t *testing.T) {
222222
223223 require.NoError(t, err)
224224 assert.NotEmpty(t, uuid)
225- assert.Contains(t, uuid, "myapp")
225+ assert.Len(t, uuid, 36) // standard UUID format
226226
227227 // Verify upload file was created
228228 uploadPath := filepath.Join(tmpDir, "uploads", uuid)
@@ -260,7 +260,7 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) {
260260 storage, err := NewBlobStorage(tmpDir, log)
261261 require.NoError(t, err)
262262
263- _, err = storage.AppendBlobChunk("myapp", "1234567890-invalid", []byte("data"))
263+ _, err = storage.AppendBlobChunk("myapp", "00000000-0000-0000-0000-000000000000", []byte("data"))
264264
265265 assert.Error(t, err)
266266 assert.Contains(t, err.Error(), "upload not found")
@@ -292,7 +292,7 @@ func TestBlobStorage_GetBlobUpload_NotFound(t *testing.T) {
292292 storage, err := NewBlobStorage(tmpDir, log)
293293 require.NoError(t, err)
294294
295- writer, err := storage.GetBlobUpload("1234567890-invalid")
295+ writer, err := storage.GetBlobUpload("00000000-0000-0000-0000-000000000000")
296296
297297 assert.Error(t, err)
298298 assert.Nil(t, writer)
@@ -355,7 +355,7 @@ func TestBlobStorage_CancelBlobUpload_NotFound(t *testing.T) {
355355 storage, err := NewBlobStorage(tmpDir, log)
356356 require.NoError(t, err)
357357
358- err = storage.CancelBlobUpload("1234567890-invalid")
358+ err = storage.CancelBlobUpload("00000000-0000-0000-0000-000000000000")
359359
360360 assert.Error(t, err)
361361 assert.Contains(t, err.Error(), "upload not found")
@@ -222,7 +222,7 @@ func TestBlobStorage_StartBlobUpload(t *testing.T) {
222 222
223 require.NoError(t, err)223 require.NoError(t, err)
224 assert.NotEmpty(t, uuid)224 assert.NotEmpty(t, uuid)
225- assert.Contains(t, uuid, "myapp")225+ assert.Len(t, uuid, 36) // standard UUID format
226 226
227 // Verify upload file was created227 // Verify upload file was created
228 uploadPath := filepath.Join(tmpDir, "uploads", uuid)228 uploadPath := filepath.Join(tmpDir, "uploads", uuid)
@@ -260,7 +260,7 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) {
260 storage, err := NewBlobStorage(tmpDir, log)260 storage, err := NewBlobStorage(tmpDir, log)
261 require.NoError(t, err)261 require.NoError(t, err)
262 262
263- _, err = storage.AppendBlobChunk("myapp", "1234567890-invalid", []byte("data"))263+ _, err = storage.AppendBlobChunk("myapp", "00000000-0000-0000-0000-000000000000", []byte("data"))
264 264
265 assert.Error(t, err)265 assert.Error(t, err)
266 assert.Contains(t, err.Error(), "upload not found")266 assert.Contains(t, err.Error(), "upload not found")
@@ -292,7 +292,7 @@ func TestBlobStorage_GetBlobUpload_NotFound(t *testing.T) {
292 storage, err := NewBlobStorage(tmpDir, log)292 storage, err := NewBlobStorage(tmpDir, log)
293 require.NoError(t, err)293 require.NoError(t, err)
294 294
295- writer, err := storage.GetBlobUpload("1234567890-invalid")295+ writer, err := storage.GetBlobUpload("00000000-0000-0000-0000-000000000000")
296 296
297 assert.Error(t, err)297 assert.Error(t, err)
298 assert.Nil(t, writer)298 assert.Nil(t, writer)
@@ -355,7 +355,7 @@ func TestBlobStorage_CancelBlobUpload_NotFound(t *testing.T) {
355 storage, err := NewBlobStorage(tmpDir, log)355 storage, err := NewBlobStorage(tmpDir, log)
356 require.NoError(t, err)356 require.NoError(t, err)
357 357
358- err = storage.CancelBlobUpload("1234567890-invalid")358+ err = storage.CancelBlobUpload("00000000-0000-0000-0000-000000000000")
359 359
360 assert.Error(t, err)360 assert.Error(t, err)
361 assert.Contains(t, err.Error(), "upload not found")361 assert.Contains(t, err.Error(), "upload not found")
M pkg/validation/path.go +2 -2
@@ -28,8 +28,8 @@ var referenceRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`)
2828 var digestRegex = regexp.MustCompile(`^(sha256:[a-f0-9]{64}|sha512:[a-f0-9]{128})$`)
2929
3030 // UUID validation for blob uploads:
31-// - Format: timestamp-name (where name has / replaced with _)
32-var uuidRegex = regexp.MustCompile(`^[0-9]+-[a-zA-Z0-9_-]+$`)
31+// - Format: standard UUID v4 (e.g., 550e8400-e29b-41d4-a716-446655440000)
32+var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
3333
3434 // MaxRepositoryNameLength is the maximum allowed length for repository names.
3535 const MaxRepositoryNameLength = 256
@@ -28,8 +28,8 @@ var referenceRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`)
28 var digestRegex = regexp.MustCompile(`^(sha256:[a-f0-9]{64}|sha512:[a-f0-9]{128})$`)28 var digestRegex = regexp.MustCompile(`^(sha256:[a-f0-9]{64}|sha512:[a-f0-9]{128})$`)
29 29
30 // UUID validation for blob uploads:30 // UUID validation for blob uploads:
31-// - Format: timestamp-name (where name has / replaced with _)31+// - Format: standard UUID v4 (e.g., 550e8400-e29b-41d4-a716-446655440000)
32-var uuidRegex = regexp.MustCompile(`^[0-9]+-[a-zA-Z0-9_-]+$`)32+var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
33 33
34 // MaxRepositoryNameLength is the maximum allowed length for repository names.34 // MaxRepositoryNameLength is the maximum allowed length for repository names.
35 const MaxRepositoryNameLength = 25635 const MaxRepositoryNameLength = 256
M pkg/validation/path_test.go +11 -12
@@ -163,23 +163,22 @@ func TestValidateUUID(t *testing.T) {
163163 wantErr bool
164164 errMsg string
165165 }{
166- // Valid cases
167- {"valid uuid", "1234567890-myapp", false, ""},
168- {"with underscore", "1234567890-my_app", false, ""},
169- {"with hyphen in name", "1234567890-my-app", false, ""},
170- {"nested repo uuid", "1234567890-myorg_myapp", false, ""},
166+ // Valid cases - standard UUID v4
167+ {"valid uuid", "550e8400-e29b-41d4-a716-446655440000", false, ""},
168+ {"another valid uuid", "6ba7b810-9dad-11d1-80b4-00c04fd430c8", false, ""},
169+ {"all zeros", "00000000-0000-0000-0000-000000000000", false, ""},
171170
172171 // Invalid cases - path traversal
173- {"path traversal", "1234567890-../etc/passwd", true, "path traversal"},
174- {"traversal in middle", "1234567890-app/../etc", true, "path traversal"},
172+ {"path traversal", "../etc/passwd", true, "path traversal"},
173+ {"traversal in middle", "app/../etc", true, "path traversal"},
175174
176175 // Invalid cases - format
177176 {"empty", "", true, "cannot be empty"},
178- {"no timestamp", "myapp", true, "invalid UUID format"},
179- {"no hyphen", "1234567890myapp", true, "invalid UUID format"},
180- {"special chars", "1234567890-my@app", true, "invalid UUID format"},
181- {"spaces", "1234567890-my app", true, "invalid UUID format"},
182- {"slash not replaced", "1234567890-myorg/myapp", true, "invalid UUID format"},
177+ {"random string", "myapp", true, "invalid UUID format"},
178+ {"old timestamp format", "1234567890-myapp", true, "invalid UUID format"},
179+ {"uppercase hex", "550E8400-E29B-41D4-A716-446655440000", true, "invalid UUID format"},
180+ {"spaces", "550e8400 e29b 41d4 a716 446655440000", true, "invalid UUID format"},
181+ {"missing section", "550e8400-e29b-41d4-a716", true, "invalid UUID format"},
183182 }
184183
185184 for _, tt := range tests {
@@ -163,23 +163,22 @@ func TestValidateUUID(t *testing.T) {
163 wantErr bool163 wantErr bool
164 errMsg string164 errMsg string
165 }{165 }{
166- // Valid cases166+ // Valid cases - standard UUID v4
167- {"valid uuid", "1234567890-myapp", false, ""},167+ {"valid uuid", "550e8400-e29b-41d4-a716-446655440000", false, ""},
168- {"with underscore", "1234567890-my_app", false, ""},168+ {"another valid uuid", "6ba7b810-9dad-11d1-80b4-00c04fd430c8", false, ""},
169- {"with hyphen in name", "1234567890-my-app", false, ""},169+ {"all zeros", "00000000-0000-0000-0000-000000000000", false, ""},
170- {"nested repo uuid", "1234567890-myorg_myapp", false, ""},
171 170
172 // Invalid cases - path traversal171 // Invalid cases - path traversal
173- {"path traversal", "1234567890-../etc/passwd", true, "path traversal"},172+ {"path traversal", "../etc/passwd", true, "path traversal"},
174- {"traversal in middle", "1234567890-app/../etc", true, "path traversal"},173+ {"traversal in middle", "app/../etc", true, "path traversal"},
175 174
176 // Invalid cases - format175 // Invalid cases - format
177 {"empty", "", true, "cannot be empty"},176 {"empty", "", true, "cannot be empty"},
178- {"no timestamp", "myapp", true, "invalid UUID format"},177+ {"random string", "myapp", true, "invalid UUID format"},
179- {"no hyphen", "1234567890myapp", true, "invalid UUID format"},178+ {"old timestamp format", "1234567890-myapp", true, "invalid UUID format"},
180- {"special chars", "1234567890-my@app", true, "invalid UUID format"},179+ {"uppercase hex", "550E8400-E29B-41D4-A716-446655440000", true, "invalid UUID format"},
181- {"spaces", "1234567890-my app", true, "invalid UUID format"},180+ {"spaces", "550e8400 e29b 41d4 a716 446655440000", true, "invalid UUID format"},
182- {"slash not replaced", "1234567890-myorg/myapp", true, "invalid UUID format"},181+ {"missing section", "550e8400-e29b-41d4-a716", true, "invalid UUID format"},
183 }182 }
184 183
185 for _, tt := range tests {184 for _, tt := range tests {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close