v2.13.0 7748089

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// StartBlobUpload starts a new blob upload and returns the upload UUID.
169func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
170	uploadID := uuid.New().String()
171
172	uploadPath, err := s.getUploadPath(uploadID)
173	if err != nil {
174		return "", fmt.Errorf("invalid upload path: %w", err)
175	}
176
177	// Create directory if it doesn't exist
178	if err := os.MkdirAll(filepath.Dir(uploadPath), 0750); err != nil {
179		return "", fmt.Errorf("failed to create upload directory: %w", err)
180	}
181
182	// Create empty upload file
183	file, err := os.Create(uploadPath)
184	if err != nil {
185		return "", fmt.Errorf("failed to create upload file: %w", err)
186	}
187	file.Close()
188
189	s.log.Info().
190		Str(zerowrap.FieldLayer, "adapter").
191		Str(zerowrap.FieldAdapter, "filesystem").
192		Str("uuid", uploadID).
193		Str("name", name).
194		Msg("blob upload started")
195
196	return uploadID, nil
197}
198
199// AppendBlobChunk appends data to an in-progress upload.
200func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) {
201	uploadPath, err := s.getUploadPath(uuid)
202	if err != nil {
203		return 0, fmt.Errorf("invalid upload path: %w", err)
204	}
205
206	file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
207	if err != nil {
208		if os.IsNotExist(err) {
209			return 0, fmt.Errorf("upload not found: %s", uuid)
210		}
211		return 0, fmt.Errorf("failed to open upload file: %w", err)
212	}
213	defer file.Close()
214
215	written, err := io.Copy(file, data)
216	if err != nil {
217		return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
218	}
219
220	fi, err := file.Stat()
221	if err != nil {
222		return 0, fmt.Errorf("failed to get file info: %w", err)
223	}
224
225	s.log.Debug().
226		Str(zerowrap.FieldLayer, "adapter").
227		Str(zerowrap.FieldAdapter, "filesystem").
228		Str("uuid", uuid).
229		Str("name", name).
230		Int64("chunk_size", written).
231		Int64("total_size", fi.Size()).
232		Msg("appended chunk to blob upload")
233
234	return fi.Size(), nil
235}
236
237// GetBlobUpload returns a writer for the upload.
238func (s *BlobStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
239	uploadPath, err := s.getUploadPath(uuid)
240	if err != nil {
241		return nil, fmt.Errorf("invalid upload path: %w", err)
242	}
243
244	file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
245	if err != nil {
246		if os.IsNotExist(err) {
247			return nil, fmt.Errorf("upload not found: %s", uuid)
248		}
249		return nil, fmt.Errorf("failed to open upload file: %w", err)
250	}
251
252	return file, nil
253}
254
255// FinishBlobUpload completes an upload and moves it to blob storage.
256func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error {
257	uploadPath, err := s.getUploadPath(uuid)
258	if err != nil {
259		return fmt.Errorf("invalid upload path: %w", err)
260	}
261	blobPath, err := s.getBlobPath(digest)
262	if err != nil {
263		return fmt.Errorf("invalid blob path: %w", err)
264	}
265
266	// Verify digest before moving to ensure integrity
267	if err := s.verifyUploadDigest(uploadPath, digest); err != nil {
268		return fmt.Errorf("digest verification failed: %w", err)
269	}
270
271	// Create blob directory if it doesn't exist
272	if err := os.MkdirAll(filepath.Dir(blobPath), 0750); err != nil {
273		return fmt.Errorf("failed to create blob directory: %w", err)
274	}
275
276	// Move upload file to blob location
277	if err := os.Rename(uploadPath, blobPath); err != nil {
278		return fmt.Errorf("failed to move upload to blob location: %w", err)
279	}
280
281	s.log.Info().
282		Str(zerowrap.FieldLayer, "adapter").
283		Str(zerowrap.FieldAdapter, "filesystem").
284		Str("uuid", uuid).
285		Str("digest", digest).
286		Msg("blob upload finished")
287
288	return nil
289}
290
291// CancelBlobUpload cancels an in-progress upload.
292func (s *BlobStorage) CancelBlobUpload(uuid string) error {
293	uploadPath, err := s.getUploadPath(uuid)
294	if err != nil {
295		return fmt.Errorf("invalid upload path: %w", err)
296	}
297
298	if err := os.Remove(uploadPath); err != nil {
299		if os.IsNotExist(err) {
300			return fmt.Errorf("upload not found: %s", uuid)
301		}
302		return fmt.Errorf("failed to cancel upload: %w", err)
303	}
304
305	s.log.Info().
306		Str(zerowrap.FieldLayer, "adapter").
307		Str(zerowrap.FieldAdapter, "filesystem").
308		Str("uuid", uuid).
309		Msg("blob upload cancelled")
310
311	return nil
312}
313
314// Helper methods for path generation with security validation
315
316func (s *BlobStorage) getBlobPath(digest string) (string, error) {
317	// Validate digest to prevent path traversal (defense in depth)
318	if err := validation.ValidateDigest(digest); err != nil {
319		return "", fmt.Errorf("invalid digest: %w", err)
320	}
321
322	// Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...)
323	parts := strings.SplitN(digest, ":", 2)
324	if len(parts) != 2 {
325		return "", fmt.Errorf("invalid digest format: missing algorithm separator")
326	}
327
328	algorithm := parts[0]
329	hashPart := parts[1]
330
331	var path string
332	// Create two-level directory structure for better performance
333	if len(hashPart) >= 2 {
334		path = filepath.Join(s.rootDir, "blobs", algorithm, hashPart[:2], hashPart)
335	} else {
336		path = filepath.Join(s.rootDir, "blobs", algorithm, hashPart)
337	}
338
339	// Verify the path stays within root directory
340	if err := validation.ValidatePathWithinRoot(s.rootDir, path); err != nil {
341		return "", fmt.Errorf("path validation failed: %w", err)
342	}
343
344	return path, nil
345}
346
347func (s *BlobStorage) getUploadPath(uuid string) (string, error) {
348	// Validate UUID to prevent path traversal
349	if err := validation.ValidateUUID(uuid); err != nil {
350		return "", fmt.Errorf("invalid UUID: %w", err)
351	}
352
353	path := filepath.Join(s.rootDir, "uploads", uuid)
354
355	// Verify the path stays within root directory
356	if err := validation.ValidatePathWithinRoot(s.rootDir, path); err != nil {
357		return "", fmt.Errorf("path validation failed: %w", err)
358	}
359
360	return path, nil
361}
362
363// verifyUploadDigest computes and verifies the digest of an uploaded file.
364func (s *BlobStorage) verifyUploadDigest(uploadPath, expectedDigest string) error {
365	parts := strings.SplitN(expectedDigest, ":", 2)
366	if len(parts) != 2 {
367		return fmt.Errorf("invalid digest format")
368	}
369
370	algorithm := parts[0]
371	expectedHash := parts[1]
372
373	var hasher hash.Hash
374	switch algorithm {
375	case "sha256":
376		hasher = sha256.New()
377	case "sha512":
378		hasher = sha512.New()
379	default:
380		return fmt.Errorf("unsupported hash algorithm: %s", algorithm)
381	}
382
383	file, err := os.Open(uploadPath)
384	if err != nil {
385		return fmt.Errorf("failed to open upload file: %w", err)
386	}
387	defer file.Close()
388
389	if _, err := io.Copy(hasher, file); err != nil {
390		return fmt.Errorf("failed to compute digest: %w", err)
391	}
392
393	actualHash := hex.EncodeToString(hasher.Sum(nil))
394	if actualHash != expectedHash {
395		return fmt.Errorf("digest mismatch: expected %s, got %s", expectedHash, actualHash)
396	}
397
398	return nil
399}

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