v2.15.0 cf76057

blob.go RawHistoryFolder

1// Package filesystem implements storage adapters using the local filesystem.
2package filesystem
3
4import (
5	"crypto/sha256"
6	"crypto/sha512"
7	"encoding/hex"
8	"fmt"
9	"hash"
10	"io"
11	"os"
12	"path/filepath"
13	"strings"
14
15	"github.com/bnema/zerowrap"
16	"github.com/google/uuid"
17
18	"github.com/bnema/gordon/pkg/validation"
19)
20
21// BlobStorage implements the BlobStorage interface using the local filesystem.
22type BlobStorage struct {
23	rootDir string
24	log     zerowrap.Logger
25}
26
27// NewBlobStorage creates a new filesystem blob storage instance.
28func NewBlobStorage(rootDir string, log zerowrap.Logger) (*BlobStorage, error) {
29	// Create directory structure if it doesn't exist
30	dirs := []string{
31		filepath.Join(rootDir, "blobs"),
32		filepath.Join(rootDir, "uploads"),
33	}
34
35	for _, dir := range dirs {
36		if err := os.MkdirAll(dir, 0750); err != nil {
37			return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
38		}
39	}
40
41	log.Info().
42		Str(zerowrap.FieldLayer, "adapter").
43		Str(zerowrap.FieldAdapter, "filesystem").
44		Str("root_dir", rootDir).
45		Msg("blob storage initialized")
46
47	return &BlobStorage{
48		rootDir: rootDir,
49		log:     log,
50	}, nil
51}
52
53// GetBlob retrieves a blob by digest.
54func (s *BlobStorage) GetBlob(digest string) (io.ReadCloser, error) {
55	blobPath, err := s.getBlobPath(digest)
56	if err != nil {
57		return nil, fmt.Errorf("invalid path: %w", err)
58	}
59
60	file, err := os.Open(blobPath)
61	if err != nil {
62		if os.IsNotExist(err) {
63			return nil, fmt.Errorf("blob not found: %s", digest)
64		}
65		return nil, fmt.Errorf("failed to open blob: %w", err)
66	}
67
68	return file, nil
69}
70
71// GetBlobPath returns the filesystem path to a blob.
72func (s *BlobStorage) GetBlobPath(digest string) (string, error) {
73	path, err := s.getBlobPath(digest)
74	if err != nil {
75		return "", fmt.Errorf("invalid path: %w", err)
76	}
77	if _, err := os.Stat(path); err != nil {
78		if os.IsNotExist(err) {
79			return "", fmt.Errorf("blob not found: %s", digest)
80		}
81		return "", err
82	}
83	return path, nil
84}
85
86// PutBlob stores a blob with the given digest.
87func (s *BlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
88	blobPath, err := s.getBlobPath(digest)
89	if err != nil {
90		return fmt.Errorf("invalid path: %w", err)
91	}
92
93	// Create directory if it doesn't exist
94	if err := os.MkdirAll(filepath.Dir(blobPath), 0750); err != nil {
95		return fmt.Errorf("failed to create blob directory: %w", err)
96	}
97
98	// Create temporary file first
99	tmpPath := blobPath + ".tmp"
100	file, err := os.Create(tmpPath)
101	if err != nil {
102		return fmt.Errorf("failed to create temporary blob file: %w", err)
103	}
104	defer file.Close()
105
106	// Copy data to file
107	written, err := io.Copy(file, data)
108	if err != nil {
109		os.Remove(tmpPath)
110		return fmt.Errorf("failed to write blob data: %w", err)
111	}
112
113	// Verify size if provided
114	if size > 0 && written != size {
115		os.Remove(tmpPath)
116		return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written)
117	}
118
119	// Move to final location
120	if err := os.Rename(tmpPath, blobPath); err != nil {
121		os.Remove(tmpPath)
122		return fmt.Errorf("failed to move blob to final location: %w", err)
123	}
124
125	s.log.Info().
126		Str(zerowrap.FieldLayer, "adapter").
127		Str(zerowrap.FieldAdapter, "filesystem").
128		Str("digest", digest).
129		Int64(zerowrap.FieldSize, written).
130		Msg("blob stored")
131
132	return nil
133}
134
135// DeleteBlob removes a blob by digest.
136func (s *BlobStorage) DeleteBlob(digest string) error {
137	blobPath, err := s.getBlobPath(digest)
138	if err != nil {
139		return fmt.Errorf("invalid path: %w", err)
140	}
141
142	if err := os.Remove(blobPath); err != nil {
143		if os.IsNotExist(err) {
144			return fmt.Errorf("blob not found: %s", digest)
145		}
146		return fmt.Errorf("failed to delete blob: %w", err)
147	}
148
149	s.log.Info().
150		Str(zerowrap.FieldLayer, "adapter").
151		Str(zerowrap.FieldAdapter, "filesystem").
152		Str("digest", digest).
153		Msg("blob deleted")
154
155	return nil
156}
157
158// BlobExists checks if a blob exists.
159func (s *BlobStorage) BlobExists(digest string) bool {
160	blobPath, err := s.getBlobPath(digest)
161	if err != nil {
162		return false // Invalid path means blob doesn't exist
163	}
164	_, err = os.Stat(blobPath)
165	return err == nil
166}
167
168// ListBlobs returns all blob digests.
169func (s *BlobStorage) ListBlobs() ([]string, error) {
170	blobsDir := filepath.Join(s.rootDir, "blobs")
171
172	if _, err := os.Stat(blobsDir); err != nil {
173		if os.IsNotExist(err) {
174			return []string{}, nil
175		}
176		return nil, fmt.Errorf("failed to access blobs directory: %w", err)
177	}
178
179	digests := make([]string, 0)
180	err := filepath.Walk(blobsDir, func(path string, info os.FileInfo, err error) error {
181		if err != nil {
182			return err
183		}
184
185		if info.IsDir() {
186			return nil
187		}
188
189		relPath, err := filepath.Rel(blobsDir, path)
190		if err != nil {
191			return err
192		}
193
194		parts := strings.Split(relPath, string(filepath.Separator))
195		if len(parts) < 2 {
196			return nil
197		}
198
199		digest := parts[0] + ":" + parts[len(parts)-1]
200		if err := validation.ValidateDigest(digest); err != nil {
201			return nil
202		}
203
204		digests = append(digests, digest)
205		return nil
206	})
207	if err != nil {
208		return nil, fmt.Errorf("failed to list blobs: %w", err)
209	}
210
211	return digests, nil
212}
213
214// StartBlobUpload starts a new blob upload and returns the upload UUID.
215func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
216	uploadID := uuid.New().String()
217
218	uploadPath, err := s.getUploadPath(uploadID)
219	if err != nil {
220		return "", fmt.Errorf("invalid upload path: %w", err)
221	}
222
223	// Create directory if it doesn't exist
224	if err := os.MkdirAll(filepath.Dir(uploadPath), 0750); err != nil {
225		return "", fmt.Errorf("failed to create upload directory: %w", err)
226	}
227
228	// Create empty upload file
229	file, err := os.Create(uploadPath)
230	if err != nil {
231		return "", fmt.Errorf("failed to create upload file: %w", err)
232	}
233	file.Close()
234
235	s.log.Info().
236		Str(zerowrap.FieldLayer, "adapter").
237		Str(zerowrap.FieldAdapter, "filesystem").
238		Str("uuid", uploadID).
239		Str("name", name).
240		Msg("blob upload started")
241
242	return uploadID, nil
243}
244
245// AppendBlobChunk appends data to an in-progress upload.
246func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) {
247	uploadPath, err := s.getUploadPath(uuid)
248	if err != nil {
249		return 0, fmt.Errorf("invalid upload path: %w", err)
250	}
251
252	file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
253	if err != nil {
254		if os.IsNotExist(err) {
255			return 0, fmt.Errorf("upload not found: %s", uuid)
256		}
257		return 0, fmt.Errorf("failed to open upload file: %w", err)
258	}
259	defer file.Close()
260
261	written, err := io.Copy(file, data)
262	if err != nil {
263		return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
264	}
265
266	fi, err := file.Stat()
267	if err != nil {
268		return 0, fmt.Errorf("failed to get file info: %w", err)
269	}
270
271	s.log.Debug().
272		Str(zerowrap.FieldLayer, "adapter").
273		Str(zerowrap.FieldAdapter, "filesystem").
274		Str("uuid", uuid).
275		Str("name", name).
276		Int64("chunk_size", written).
277		Int64("total_size", fi.Size()).
278		Msg("appended chunk to blob upload")
279
280	return fi.Size(), nil
281}
282
283// GetBlobUpload returns a writer for the upload.
284func (s *BlobStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
285	uploadPath, err := s.getUploadPath(uuid)
286	if err != nil {
287		return nil, fmt.Errorf("invalid upload path: %w", err)
288	}
289
290	file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
291	if err != nil {
292		if os.IsNotExist(err) {
293			return nil, fmt.Errorf("upload not found: %s", uuid)
294		}
295		return nil, fmt.Errorf("failed to open upload file: %w", err)
296	}
297
298	return file, nil
299}
300
301// FinishBlobUpload completes an upload and moves it to blob storage.
302func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error {
303	uploadPath, err := s.getUploadPath(uuid)
304	if err != nil {
305		return fmt.Errorf("invalid upload path: %w", err)
306	}
307	blobPath, err := s.getBlobPath(digest)
308	if err != nil {
309		return fmt.Errorf("invalid blob path: %w", err)
310	}
311
312	// Verify digest before moving to ensure integrity
313	if err := s.verifyUploadDigest(uploadPath, digest); err != nil {
314		return fmt.Errorf("digest verification failed: %w", err)
315	}
316
317	// Create blob directory if it doesn't exist
318	if err := os.MkdirAll(filepath.Dir(blobPath), 0750); err != nil {
319		return fmt.Errorf("failed to create blob directory: %w", err)
320	}
321
322	// Move upload file to blob location
323	if err := os.Rename(uploadPath, blobPath); err != nil {
324		return fmt.Errorf("failed to move upload to blob location: %w", err)
325	}
326
327	s.log.Info().
328		Str(zerowrap.FieldLayer, "adapter").
329		Str(zerowrap.FieldAdapter, "filesystem").
330		Str("uuid", uuid).
331		Str("digest", digest).
332		Msg("blob upload finished")
333
334	return nil
335}
336
337// CancelBlobUpload cancels an in-progress upload.
338func (s *BlobStorage) CancelBlobUpload(uuid string) error {
339	uploadPath, err := s.getUploadPath(uuid)
340	if err != nil {
341		return fmt.Errorf("invalid upload path: %w", err)
342	}
343
344	if err := os.Remove(uploadPath); err != nil {
345		if os.IsNotExist(err) {
346			return fmt.Errorf("upload not found: %s", uuid)
347		}
348		return fmt.Errorf("failed to cancel upload: %w", err)
349	}
350
351	s.log.Info().
352		Str(zerowrap.FieldLayer, "adapter").
353		Str(zerowrap.FieldAdapter, "filesystem").
354		Str("uuid", uuid).
355		Msg("blob upload cancelled")
356
357	return nil
358}
359
360// Helper methods for path generation with security validation
361
362func (s *BlobStorage) getBlobPath(digest string) (string, error) {
363	// Validate digest to prevent path traversal (defense in depth)
364	if err := validation.ValidateDigest(digest); err != nil {
365		return "", fmt.Errorf("invalid digest: %w", err)
366	}
367
368	// Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...)
369	parts := strings.SplitN(digest, ":", 2)
370	if len(parts) != 2 {
371		return "", fmt.Errorf("invalid digest format: missing algorithm separator")
372	}
373
374	algorithm := parts[0]
375	hashPart := parts[1]
376
377	var path string
378	// Create two-level directory structure for better performance
379	if len(hashPart) >= 2 {
380		path = filepath.Join(s.rootDir, "blobs", algorithm, hashPart[:2], hashPart)
381	} else {
382		path = filepath.Join(s.rootDir, "blobs", algorithm, hashPart)
383	}
384
385	// Verify the path stays within root directory
386	if err := validation.ValidatePathWithinRoot(s.rootDir, path); err != nil {
387		return "", fmt.Errorf("path validation failed: %w", err)
388	}
389
390	return path, nil
391}
392
393func (s *BlobStorage) getUploadPath(uuid string) (string, error) {
394	// Validate UUID to prevent path traversal
395	if err := validation.ValidateUUID(uuid); err != nil {
396		return "", fmt.Errorf("invalid UUID: %w", err)
397	}
398
399	path := filepath.Join(s.rootDir, "uploads", uuid)
400
401	// Verify the path stays within root directory
402	if err := validation.ValidatePathWithinRoot(s.rootDir, path); err != nil {
403		return "", fmt.Errorf("path validation failed: %w", err)
404	}
405
406	return path, nil
407}
408
409// verifyUploadDigest computes and verifies the digest of an uploaded file.
410func (s *BlobStorage) verifyUploadDigest(uploadPath, expectedDigest string) error {
411	parts := strings.SplitN(expectedDigest, ":", 2)
412	if len(parts) != 2 {
413		return fmt.Errorf("invalid digest format")
414	}
415
416	algorithm := parts[0]
417	expectedHash := parts[1]
418
419	var hasher hash.Hash
420	switch algorithm {
421	case "sha256":
422		hasher = sha256.New()
423	case "sha512":
424		hasher = sha512.New()
425	default:
426		return fmt.Errorf("unsupported hash algorithm: %s", algorithm)
427	}
428
429	file, err := os.Open(uploadPath)
430	if err != nil {
431		return fmt.Errorf("failed to open upload file: %w", err)
432	}
433	defer file.Close()
434
435	if _, err := io.Copy(hasher, file); err != nil {
436		return fmt.Errorf("failed to compute digest: %w", err)
437	}
438
439	actualHash := hex.EncodeToString(hasher.Sum(nil))
440	if actualHash != expectedHash {
441		return fmt.Errorf("digest mismatch: expected %s, got %s", expectedHash, actualHash)
442	}
443
444	return nil
445}

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