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 "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 // 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}