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, chunk []byte) (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 := file.Write(chunk)
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 Int("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}