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}