v3.3.0 b164fe9

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	"sync"
15	"time"
16
17	"github.com/bnema/zerowrap"
18	"github.com/google/uuid"
19
20	"github.com/bnema/gordon/internal/domain"
21	"github.com/bnema/gordon/pkg/validation"
22)
23
24// BlobStorage implements the BlobStorage interface using the local filesystem.
25type BlobStorage struct {
26	rootDir     string
27	log         zerowrap.Logger
28	uploadLocks sync.Map
29}
30
31// NewBlobStorage creates a new filesystem blob storage instance.
32func NewBlobStorage(rootDir string, log zerowrap.Logger) (*BlobStorage, error) {
33	// Create directory structure if it doesn't exist
34	dirs := []string{
35		filepath.Join(rootDir, "blobs"),
36		filepath.Join(rootDir, "uploads"),
37		filepath.Join(rootDir, "ownership"),
38	}
39
40	for _, dir := range dirs {
41		if err := os.MkdirAll(dir, 0750); err != nil {
42			return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
43		}
44	}
45
46	log.Info().
47		Str(zerowrap.FieldLayer, "adapter").
48		Str(zerowrap.FieldAdapter, "filesystem").
49		Str("root_dir", rootDir).
50		Msg("blob storage initialized")
51
52	return &BlobStorage{
53		rootDir: rootDir,
54		log:     log,
55	}, nil
56}
57
58// GetBlob retrieves a blob by digest.
59func (s *BlobStorage) GetBlob(digest string) (io.ReadCloser, error) {
60	blobPath, err := s.getBlobPath(digest)
61	if err != nil {
62		return nil, fmt.Errorf("invalid path: %w", err)
63	}
64
65	file, err := os.Open(blobPath)
66	if err != nil {
67		if os.IsNotExist(err) {
68			return nil, fmt.Errorf("blob not found: %s", digest)
69		}
70		return nil, fmt.Errorf("failed to open blob: %w", err)
71	}
72
73	return file, nil
74}
75
76// GetBlobPath returns the filesystem path to a blob.
77func (s *BlobStorage) GetBlobPath(digest string) (string, error) {
78	path, err := s.getBlobPath(digest)
79	if err != nil {
80		return "", fmt.Errorf("invalid path: %w", err)
81	}
82	if _, err := os.Stat(path); err != nil {
83		if os.IsNotExist(err) {
84			return "", fmt.Errorf("blob not found: %s", digest)
85		}
86		return "", err
87	}
88	return path, nil
89}
90
91// PutBlob stores a blob with the given digest.
92func (s *BlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
93	blobPath, err := s.getBlobPath(digest)
94	if err != nil {
95		return fmt.Errorf("invalid path: %w", err)
96	}
97
98	// Create directory if it doesn't exist
99	if err := os.MkdirAll(filepath.Dir(blobPath), 0750); err != nil {
100		return fmt.Errorf("failed to create blob directory: %w", err)
101	}
102
103	// Create temporary file first
104	tmpPath := blobPath + ".tmp"
105	file, err := os.Create(tmpPath)
106	if err != nil {
107		return fmt.Errorf("failed to create temporary blob file: %w", err)
108	}
109	defer file.Close()
110
111	// Copy data to file
112	written, err := io.Copy(file, data)
113	if err != nil {
114		os.Remove(tmpPath)
115		return fmt.Errorf("failed to write blob data: %w", err)
116	}
117
118	// Verify size if provided
119	if size > 0 && written != size {
120		os.Remove(tmpPath)
121		return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written)
122	}
123
124	// Move to final location
125	if err := os.Rename(tmpPath, blobPath); err != nil {
126		os.Remove(tmpPath)
127		return fmt.Errorf("failed to move blob to final location: %w", err)
128	}
129
130	s.log.Info().
131		Str(zerowrap.FieldLayer, "adapter").
132		Str(zerowrap.FieldAdapter, "filesystem").
133		Str("digest", digest).
134		Int64(zerowrap.FieldSize, written).
135		Msg("blob stored")
136
137	return nil
138}
139
140// DeleteBlob removes a blob by digest.
141// Returns the size in bytes of the removed blob.
142func (s *BlobStorage) DeleteBlob(digest string) (int64, error) {
143	blobPath, err := s.getBlobPath(digest)
144	if err != nil {
145		return 0, fmt.Errorf("invalid path: %w", err)
146	}
147
148	info, err := os.Stat(blobPath)
149	if err != nil {
150		if os.IsNotExist(err) {
151			return 0, fmt.Errorf("blob not found: %s", digest)
152		}
153		return 0, fmt.Errorf("failed to stat blob: %w", err)
154	}
155	size := info.Size()
156
157	if err := os.Remove(blobPath); err != nil {
158		return 0, fmt.Errorf("failed to delete blob: %w", err)
159	}
160
161	s.log.Info().
162		Str(zerowrap.FieldLayer, "adapter").
163		Str(zerowrap.FieldAdapter, "filesystem").
164		Str("digest", digest).
165		Int64(zerowrap.FieldSize, size).
166		Msg("blob deleted")
167
168	return size, nil
169}
170
171// BlobExists checks if a blob exists.
172func (s *BlobStorage) BlobExists(digest string) bool {
173	blobPath, err := s.getBlobPath(digest)
174	if err != nil {
175		return false // Invalid path means blob doesn't exist
176	}
177	_, err = os.Stat(blobPath)
178	return err == nil
179}
180
181// ListBlobs returns all blob digests.
182func (s *BlobStorage) ListBlobs() ([]string, error) {
183	blobsDir := filepath.Join(s.rootDir, "blobs")
184
185	if _, err := os.Stat(blobsDir); err != nil {
186		if os.IsNotExist(err) {
187			return []string{}, nil
188		}
189		return nil, fmt.Errorf("failed to access blobs directory: %w", err)
190	}
191
192	digests := make([]string, 0)
193	err := filepath.Walk(blobsDir, func(path string, info os.FileInfo, err error) error {
194		if err != nil {
195			return err
196		}
197
198		if info.IsDir() {
199			return nil
200		}
201
202		relPath, err := filepath.Rel(blobsDir, path)
203		if err != nil {
204			return err
205		}
206
207		parts := strings.Split(relPath, string(filepath.Separator))
208		if len(parts) < 2 {
209			return nil
210		}
211
212		digest := parts[0] + ":" + parts[len(parts)-1]
213		if err := validation.ValidateDigest(digest); err != nil {
214			return nil
215		}
216
217		digests = append(digests, digest)
218		return nil
219	})
220	if err != nil {
221		return nil, fmt.Errorf("failed to list blobs: %w", err)
222	}
223
224	return digests, nil
225}
226
227// GetBlobModTime returns the last modification time for a blob.
228func (s *BlobStorage) GetBlobModTime(digest string) (time.Time, error) {
229	blobPath, err := s.getBlobPath(digest)
230	if err != nil {
231		return time.Time{}, fmt.Errorf("invalid blob path: %w", err)
232	}
233
234	info, err := os.Stat(blobPath)
235	if err != nil {
236		if os.IsNotExist(err) {
237			return time.Time{}, fmt.Errorf("blob not found: %s", digest)
238		}
239		return time.Time{}, fmt.Errorf("failed to stat blob: %w", err)
240	}
241	return info.ModTime(), nil
242}
243
244// StartBlobUpload starts a new blob upload and returns the upload UUID.
245func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
246	uploadID := uuid.New().String()
247
248	uploadPath, err := s.getUploadPath(uploadID)
249	if err != nil {
250		return "", fmt.Errorf("invalid upload path: %w", err)
251	}
252
253	// Create directory if it doesn't exist
254	if err := os.MkdirAll(filepath.Dir(uploadPath), 0750); err != nil {
255		return "", fmt.Errorf("failed to create upload directory: %w", err)
256	}
257
258	// Create empty upload file
259	file, err := os.Create(uploadPath)
260	if err != nil {
261		return "", fmt.Errorf("failed to create upload file: %w", err)
262	}
263	file.Close()
264
265	// Bind the upload to the repository that started it. Every later
266	// operation on this UUID must present the same repository, so a known
267	// upload UUID is not a bearer token for another repository.
268	if err := os.WriteFile(uploadRepoPath(uploadPath), []byte(name), 0600); err != nil {
269		_ = os.Remove(uploadPath)
270		return "", fmt.Errorf("failed to record upload repository: %w", err)
271	}
272
273	s.log.Info().
274		Str(zerowrap.FieldLayer, "adapter").
275		Str(zerowrap.FieldAdapter, "filesystem").
276		Str("uuid", uploadID).
277		Str("name", name).
278		Msg("blob upload started")
279
280	return uploadID, nil
281}
282
283// verifyUploadRepository reports the repository that started an upload and
284// fails closed when it does not match the requesting repository.
285func (s *BlobStorage) verifyUploadRepository(uploadPath, name string) error {
286	raw, err := os.ReadFile(uploadRepoPath(uploadPath))
287	if err != nil {
288		if os.IsNotExist(err) {
289			return fmt.Errorf("%w: %s", domain.ErrUploadNotFound, filepath.Base(uploadPath))
290		}
291		return fmt.Errorf("read upload repository: %w", err)
292	}
293	if string(raw) != name {
294		return fmt.Errorf("%w: %s", domain.ErrUploadNotFound, filepath.Base(uploadPath))
295	}
296	return nil
297}
298
299// uploadRepoPath returns the sidecar recording an upload's repository.
300func uploadRepoPath(uploadPath string) string {
301	return uploadPath + ".repo"
302}
303
304// AppendBlobChunk appends data to an in-progress upload.
305func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) {
306	uploadPath, err := s.getUploadPath(uuid)
307	if err != nil {
308		return 0, fmt.Errorf("invalid upload path: %w", err)
309	}
310	if err := s.verifyUploadRepository(uploadPath, name); err != nil {
311		return 0, err
312	}
313
314	mu := s.getUploadLock(uuid)
315	mu.Lock()
316	defer mu.Unlock()
317
318	file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
319	if err != nil {
320		if os.IsNotExist(err) {
321			s.cleanupUploadLock(uuid)
322			return 0, fmt.Errorf("upload not found: %s", uuid)
323		}
324		return 0, fmt.Errorf("failed to open upload file: %w", err)
325	}
326	defer file.Close()
327
328	fi, err := file.Stat()
329	if err != nil {
330		return 0, fmt.Errorf("failed to get file info: %w", err)
331	}
332	originalSize := fi.Size()
333
334	if maxBlobSize > 0 {
335		remaining := maxBlobSize - originalSize
336		if remaining < 0 || (contentLength >= 0 && contentLength > remaining) {
337			return 0, domain.ErrBlobSizeExceeded
338		}
339		data = io.LimitReader(data, remaining+1)
340	}
341
342	written, err := io.Copy(file, data)
343	if err != nil {
344		return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
345	}
346
347	if maxBlobSize > 0 && originalSize+written > maxBlobSize {
348		if err := file.Truncate(originalSize); err != nil {
349			return 0, fmt.Errorf("failed to truncate oversized upload: %w", err)
350		}
351		return 0, domain.ErrBlobSizeExceeded
352	}
353
354	fi, err = file.Stat()
355	if err != nil {
356		return 0, fmt.Errorf("failed to get file info: %w", err)
357	}
358
359	s.log.Debug().
360		Str(zerowrap.FieldLayer, "adapter").
361		Str(zerowrap.FieldAdapter, "filesystem").
362		Str("uuid", uuid).
363		Str("name", name).
364		Int64("chunk_size", written).
365		Int64("total_size", fi.Size()).
366		Msg("appended chunk to blob upload")
367
368	return fi.Size(), nil
369}
370
371// GetBlobUpload returns a writer for the upload.
372// The returned WriteCloser holds the per-upload lock; callers must Close it to release the lock.
373func (s *BlobStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
374	uploadPath, err := s.getUploadPath(uuid)
375	if err != nil {
376		return nil, fmt.Errorf("invalid upload path: %w", err)
377	}
378
379	mu := s.getUploadLock(uuid)
380	mu.Lock()
381
382	file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0600)
383	if err != nil {
384		mu.Unlock()
385		if os.IsNotExist(err) {
386			s.cleanupUploadLock(uuid)
387			return nil, fmt.Errorf("upload not found: %s", uuid)
388		}
389		return nil, fmt.Errorf("failed to open upload file: %w", err)
390	}
391
392	return &lockedWriteCloser{file: file, mu: mu}, nil
393}
394
395// lockedWriteCloser wraps an io.WriteCloser and releases a mutex on Close.
396type lockedWriteCloser struct {
397	file      io.WriteCloser
398	mu        *sync.Mutex
399	closeOnce sync.Once
400	closeErr  error
401}
402
403func (lw *lockedWriteCloser) Write(p []byte) (int, error) {
404	return lw.file.Write(p)
405}
406
407func (lw *lockedWriteCloser) Close() error {
408	lw.closeOnce.Do(func() {
409		defer lw.mu.Unlock()
410		lw.closeErr = lw.file.Close()
411	})
412	return lw.closeErr
413}
414
415// FinishBlobUpload completes an upload for the named repository, moves it
416// to blob storage, and records the repository/blob association. The
417// association is written only after the content-addressed blob exists and
418// only for the repository that started the upload.
419func (s *BlobStorage) FinishBlobUpload(name, uuid, digest string) error {
420	uploadPath, err := s.getUploadPath(uuid)
421	if err != nil {
422		return fmt.Errorf("invalid upload path: %w", err)
423	}
424	if err := s.verifyUploadRepository(uploadPath, name); err != nil {
425		return err
426	}
427
428	mu := s.getUploadLock(uuid)
429	mu.Lock()
430	var finalized bool
431	defer func() {
432		mu.Unlock()
433		if finalized {
434			s.cleanupUploadLock(uuid)
435		}
436	}()
437
438	blobPath, err := s.getBlobPath(digest)
439	if err != nil {
440		return fmt.Errorf("invalid blob path: %w", err)
441	}
442
443	// Verify digest before moving to ensure integrity
444	if err := s.verifyUploadDigest(uploadPath, digest); err != nil {
445		return fmt.Errorf("digest verification failed: %w", err)
446	}
447
448	// Create blob directory if it doesn't exist
449	if err := os.MkdirAll(filepath.Dir(blobPath), 0750); err != nil {
450		return fmt.Errorf("failed to create blob directory: %w", err)
451	}
452
453	// Update the timestamp before rename so it survives a restart even when the
454	// upload has been open longer than the pending-blob grace period.
455	now := time.Now()
456	if err := os.Chtimes(uploadPath, now, now); err != nil {
457		return fmt.Errorf("failed to mark blob as finalized: %w", err)
458	}
459
460	// Move upload file to blob location.
461	if err := os.Rename(uploadPath, blobPath); err != nil {
462		return fmt.Errorf("failed to move upload to blob location: %w", err)
463	}
464	finalized = true
465	_ = os.Remove(uploadRepoPath(uploadPath))
466
467	if err := s.recordBlobOwnership(name, digest); err != nil {
468		return err
469	}
470
471	s.log.Info().
472		Str(zerowrap.FieldLayer, "adapter").
473		Str(zerowrap.FieldAdapter, "filesystem").
474		Str("uuid", uuid).
475		Str("name", name).
476		Str("digest", digest).
477		Msg("blob upload finished")
478
479	return nil
480}
481
482// CancelBlobUpload cancels an in-progress upload owned by the named
483// repository.
484func (s *BlobStorage) CancelBlobUpload(name, uuid string) error {
485	uploadPath, err := s.getUploadPath(uuid)
486	if err != nil {
487		return fmt.Errorf("invalid upload path: %w", err)
488	}
489	if err := s.verifyUploadRepository(uploadPath, name); err != nil {
490		return err
491	}
492
493	mu := s.getUploadLock(uuid)
494	mu.Lock()
495	var finalized bool
496	defer func() {
497		mu.Unlock()
498		if finalized {
499			s.cleanupUploadLock(uuid)
500		}
501	}()
502
503	if err := os.Remove(uploadPath); err != nil {
504		if os.IsNotExist(err) {
505			s.cleanupUploadLock(uuid)
506			return fmt.Errorf("%w: %s", domain.ErrUploadNotFound, uuid)
507		}
508		return fmt.Errorf("failed to cancel upload: %w", err)
509	}
510	_ = os.Remove(uploadRepoPath(uploadPath))
511	finalized = true
512
513	s.log.Info().
514		Str(zerowrap.FieldLayer, "adapter").
515		Str(zerowrap.FieldAdapter, "filesystem").
516		Str("uuid", uuid).
517		Str("name", name).
518		Msg("blob upload cancelled")
519
520	return nil
521}
522
523// BlobOwnedByRepository reports whether the repository completed an upload
524// of the digest. Ownership comes only from that authenticated completion;
525// a manifest that merely names a digest is not evidence of ownership.
526func (s *BlobStorage) BlobOwnedByRepository(name, digest string) (bool, error) {
527	if name == "" || digest == "" {
528		return false, nil
529	}
530	path, err := s.getOwnershipPath(name, digest)
531	if err != nil {
532		return false, fmt.Errorf("invalid ownership path: %w", err)
533	}
534	if _, err := os.Stat(path); err != nil {
535		if os.IsNotExist(err) {
536			return false, nil
537		}
538		return false, fmt.Errorf("stat blob ownership: %w", err)
539	}
540	return true, nil
541}
542
543// recordBlobOwnership persists the repository/blob association as a durable
544// marker file.
545func (s *BlobStorage) recordBlobOwnership(name, digest string) error {
546	path, err := s.getOwnershipPath(name, digest)
547	if err != nil {
548		return fmt.Errorf("invalid ownership path: %w", err)
549	}
550	if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
551		return fmt.Errorf("failed to create ownership directory: %w", err)
552	}
553	if err := os.WriteFile(path, []byte(name), 0600); err != nil {
554		return fmt.Errorf("failed to record blob ownership: %w", err)
555	}
556	return nil
557}
558
559// CleanupStaleUploads removes upload files older than maxAge.
560func (s *BlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) {
561	uploadsDir := filepath.Join(s.rootDir, "uploads")
562
563	entries, err := os.ReadDir(uploadsDir)
564	if err != nil {
565		if os.IsNotExist(err) {
566			return 0, 0, nil
567		}
568		return 0, 0, fmt.Errorf("failed to read uploads directory: %w", err)
569	}
570
571	cutoff := time.Now().Add(-maxAge)
572	var removed int
573	var bytesReclaimed int64
574
575	for _, entry := range entries {
576		if entry.IsDir() {
577			continue
578		}
579		if strings.HasSuffix(entry.Name(), ".repo") {
580			uploadPath := filepath.Join(uploadsDir, strings.TrimSuffix(entry.Name(), ".repo"))
581			if _, err := os.Stat(uploadPath); os.IsNotExist(err) {
582				if err := os.Remove(filepath.Join(uploadsDir, entry.Name())); err != nil && !os.IsNotExist(err) {
583					s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to remove orphan upload sidecar")
584				}
585			}
586			continue
587		}
588
589		info, err := entry.Info()
590		if err != nil {
591			s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to get file info for stale upload")
592			continue
593		}
594
595		if info.ModTime().Before(cutoff) {
596			path := filepath.Join(uploadsDir, entry.Name())
597
598			mu := s.getUploadLock(entry.Name())
599			mu.Lock()
600			currentInfo, err := os.Stat(path)
601			if err != nil {
602				mu.Unlock()
603				if !os.IsNotExist(err) {
604					s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to stat stale upload")
605				}
606				continue
607			}
608			if !currentInfo.ModTime().Before(cutoff) {
609				mu.Unlock()
610				continue
611			}
612			size := currentInfo.Size()
613			if err := os.Remove(path); err != nil {
614				mu.Unlock()
615				s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to remove stale upload")
616				continue
617			}
618			_ = os.Remove(uploadRepoPath(path))
619
620			mu.Unlock()
621			s.cleanupUploadLock(entry.Name())
622			removed++
623			bytesReclaimed += size
624
625			s.log.Info().
626				Str(zerowrap.FieldLayer, "adapter").
627				Str(zerowrap.FieldAdapter, "filesystem").
628				Str("uuid", entry.Name()).
629				Int64("size", size).
630				Msg("removed stale upload")
631		}
632	}
633
634	return removed, bytesReclaimed, nil
635}
636
637// Helper methods for path generation with security validation
638
639func (s *BlobStorage) getBlobPath(digest string) (string, error) {
640	// Validate digest to prevent path traversal (defense in depth)
641	if err := validation.ValidateDigest(digest); err != nil {
642		return "", fmt.Errorf("invalid digest: %w", err)
643	}
644
645	// Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...)
646	parts := strings.SplitN(digest, ":", 2)
647	if len(parts) != 2 {
648		return "", fmt.Errorf("invalid digest format: missing algorithm separator")
649	}
650
651	algorithm := parts[0]
652	hashPart := parts[1]
653
654	var path string
655	// Create two-level directory structure for better performance
656	if len(hashPart) >= 2 {
657		path = filepath.Join(s.rootDir, "blobs", algorithm, hashPart[:2], hashPart)
658	} else {
659		path = filepath.Join(s.rootDir, "blobs", algorithm, hashPart)
660	}
661
662	// Verify the path stays within root directory
663	if err := validation.ValidatePathWithinRoot(s.rootDir, path); err != nil {
664		return "", fmt.Errorf("path validation failed: %w", err)
665	}
666
667	return path, nil
668}
669
670func (s *BlobStorage) getUploadPath(uuid string) (string, error) {
671	// Validate UUID to prevent path traversal
672	if err := validation.ValidateUUID(uuid); err != nil {
673		return "", fmt.Errorf("invalid UUID: %w", err)
674	}
675
676	path := filepath.Join(s.rootDir, "uploads", uuid)
677
678	// Verify the path stays within root directory
679	if err := validation.ValidatePathWithinRoot(s.rootDir, path); err != nil {
680		return "", fmt.Errorf("path validation failed: %w", err)
681	}
682
683	return path, nil
684}
685
686// getOwnershipPath returns the durable marker path for a repository/blob
687// association. The repository is hashed so its name never becomes a path
688// component.
689func (s *BlobStorage) getOwnershipPath(name, digest string) (string, error) {
690	if err := validation.ValidateRepositoryName(name); err != nil {
691		return "", fmt.Errorf("invalid repository name: %w", err)
692	}
693	if err := validation.ValidateDigest(digest); err != nil {
694		return "", fmt.Errorf("invalid digest: %w", err)
695	}
696	sum := sha256.Sum256([]byte(name))
697	parts := strings.SplitN(digest, ":", 2)
698	path := filepath.Join(s.rootDir, "ownership", hex.EncodeToString(sum[:]), parts[0], parts[1])
699	if err := validation.ValidatePathWithinRoot(s.rootDir, path); err != nil {
700		return "", fmt.Errorf("path validation failed: %w", err)
701	}
702	return path, nil
703}
704
705func (s *BlobStorage) getUploadLock(uuid string) *sync.Mutex {
706	v, _ := s.uploadLocks.LoadOrStore(uuid, &sync.Mutex{})
707	return v.(*sync.Mutex)
708}
709
710func (s *BlobStorage) cleanupUploadLock(uuid string) {
711	s.uploadLocks.Delete(uuid)
712}
713
714// verifyUploadDigest computes and verifies the digest of an uploaded file.
715func (s *BlobStorage) verifyUploadDigest(uploadPath, expectedDigest string) error {
716	parts := strings.SplitN(expectedDigest, ":", 2)
717	if len(parts) != 2 {
718		return fmt.Errorf("invalid digest format")
719	}
720
721	algorithm := parts[0]
722	expectedHash := parts[1]
723
724	var hasher hash.Hash
725	switch algorithm {
726	case "sha256":
727		hasher = sha256.New()
728	case "sha512":
729		hasher = sha512.New()
730	default:
731		return fmt.Errorf("unsupported hash algorithm: %s", algorithm)
732	}
733
734	file, err := os.Open(uploadPath)
735	if err != nil {
736		return fmt.Errorf("failed to open upload file: %w", err)
737	}
738	defer file.Close()
739
740	if _, err := io.Copy(hasher, file); err != nil {
741		return fmt.Errorf("failed to compute digest: %w", err)
742	}
743
744	actualHash := hex.EncodeToString(hasher.Sum(nil))
745	if actualHash != expectedHash {
746		return fmt.Errorf("digest mismatch: expected %s, got %s", expectedHash, actualHash)
747	}
748
749	return nil
750}

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