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}