v2.7.0 fac7705

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

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