1package filesystem
2
3import (
4 "crypto/sha256"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/bnema/zerowrap"
13
14 "github.com/bnema/gordon/pkg/validation"
15)
16
17// ManifestStorage implements the ManifestStorage interface using the local filesystem.
18type ManifestStorage struct {
19 rootDir string
20 log zerowrap.Logger
21}
22
23// NewManifestStorage creates a new filesystem manifest storage instance.
24func NewManifestStorage(rootDir string, log zerowrap.Logger) (*ManifestStorage, error) {
25 // Create directory structure if it doesn't exist
26 reposDir := filepath.Join(rootDir, "repositories")
27 if err := os.MkdirAll(reposDir, 0750); err != nil {
28 return nil, fmt.Errorf("failed to create repositories directory: %w", err)
29 }
30
31 log.Info().
32 Str(zerowrap.FieldLayer, "adapter").
33 Str(zerowrap.FieldAdapter, "filesystem").
34 Str("root_dir", rootDir).
35 Msg("manifest storage initialized")
36
37 return &ManifestStorage{
38 rootDir: rootDir,
39 log: log,
40 }, nil
41}
42
43// GetManifest retrieves a manifest by name and reference.
44// Returns the manifest data and content type.
45func (s *ManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
46 manifestPath, err := s.getManifestPath(name, reference)
47 if err != nil {
48 return nil, "", fmt.Errorf("invalid path: %w", err)
49 }
50
51 data, err := os.ReadFile(manifestPath)
52 if err != nil {
53 if os.IsNotExist(err) {
54 return nil, "", fmt.Errorf("manifest not found: %s/%s", name, reference)
55 }
56 return nil, "", fmt.Errorf("failed to read manifest: %w", err)
57 }
58
59 contentType, err := s.getManifestContentType(name, reference)
60 if err != nil {
61 // Fallback for manifests stored before content type tracking
62 s.log.Warn().
63 Str(zerowrap.FieldLayer, "adapter").
64 Str(zerowrap.FieldAdapter, "filesystem").
65 Err(err).
66 Str("name", name).
67 Str("reference", reference).
68 Msg("could not get manifest content type, falling back to default")
69 contentType = "application/vnd.docker.distribution.manifest.v2+json"
70 }
71
72 return data, contentType, nil
73}
74
75// PutManifest stores a manifest.
76func (s *ManifestStorage) PutManifest(name, reference, contentType string, data []byte) error {
77 manifestPath, err := s.getManifestPath(name, reference)
78 if err != nil {
79 return fmt.Errorf("invalid path: %w", err)
80 }
81
82 // Create directory if it doesn't exist
83 if err := os.MkdirAll(filepath.Dir(manifestPath), 0750); err != nil {
84 return fmt.Errorf("failed to create manifest directory: %w", err)
85 }
86
87 // Write manifest file
88 if err := os.WriteFile(manifestPath, data, 0600); err != nil {
89 return fmt.Errorf("failed to write manifest: %w", err)
90 }
91
92 // Store content type
93 if err := s.putManifestContentType(name, reference, contentType); err != nil {
94 // Don't fail the whole operation, but log a warning
95 s.log.Warn().
96 Str(zerowrap.FieldLayer, "adapter").
97 Str(zerowrap.FieldAdapter, "filesystem").
98 Err(err).
99 Str("name", name).
100 Str("reference", reference).
101 Msg("failed to store manifest content type")
102 }
103
104 // Update tags list
105 if err := s.updateTagsList(name, reference); err != nil {
106 s.log.Warn().
107 Str(zerowrap.FieldLayer, "adapter").
108 Str(zerowrap.FieldAdapter, "filesystem").
109 Err(err).
110 Str("name", name).
111 Str("reference", reference).
112 Msg("failed to update tags list")
113 }
114
115 // Mirror tag-addressed manifests under their content digest so
116 // digest-addressed pulls (the pinned deploy path) resolve.
117 if !validation.IsDigest(reference) {
118 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
119 if digest != reference {
120 if err := s.putManifestByDigest(name, digest, contentType, data); err != nil {
121 s.log.Warn().
122 Str(zerowrap.FieldLayer, "adapter").
123 Str(zerowrap.FieldAdapter, "filesystem").
124 Err(err).
125 Str("name", name).
126 Str("digest", digest).
127 Msg("failed to mirror manifest by digest")
128 }
129 }
130 }
131
132 s.log.Info().
133 Str(zerowrap.FieldLayer, "adapter").
134 Str(zerowrap.FieldAdapter, "filesystem").
135 Str("name", name).
136 Str("reference", reference).
137 Msg("manifest stored")
138
139 return nil
140}
141
142// DeleteManifest removes a manifest.
143func (s *ManifestStorage) DeleteManifest(name, reference string) error {
144 manifestPath, err := s.getManifestPath(name, reference)
145 if err != nil {
146 return fmt.Errorf("invalid path: %w", err)
147 }
148
149 if err := os.Remove(manifestPath); err != nil {
150 if os.IsNotExist(err) {
151 return fmt.Errorf("manifest not found: %s/%s", name, reference)
152 }
153 return fmt.Errorf("failed to delete manifest: %w", err)
154 }
155
156 // Delete content type file
157 if err := s.deleteManifestContentType(name, reference); err != nil {
158 s.log.Warn().
159 Str(zerowrap.FieldLayer, "adapter").
160 Str(zerowrap.FieldAdapter, "filesystem").
161 Err(err).
162 Str("name", name).
163 Str("reference", reference).
164 Msg("failed to delete manifest content type")
165 }
166
167 // Remove from tags list
168 if err := s.removeFromTagsList(name, reference); err != nil {
169 s.log.Warn().
170 Str(zerowrap.FieldLayer, "adapter").
171 Str(zerowrap.FieldAdapter, "filesystem").
172 Err(err).
173 Str("name", name).
174 Str("reference", reference).
175 Msg("failed to update tags list")
176 }
177
178 s.log.Info().
179 Str(zerowrap.FieldLayer, "adapter").
180 Str(zerowrap.FieldAdapter, "filesystem").
181 Str("name", name).
182 Str("reference", reference).
183 Msg("manifest deleted")
184
185 return nil
186}
187
188// ListTags returns all tags for a repository.
189func (s *ManifestStorage) ListTags(name string) ([]string, error) {
190 tagsPath, err := s.getTagsPath(name)
191 if err != nil {
192 return nil, fmt.Errorf("invalid path: %w", err)
193 }
194
195 data, err := os.ReadFile(tagsPath)
196 if err != nil {
197 if os.IsNotExist(err) {
198 return []string{}, nil // No tags file means no tags
199 }
200 return nil, fmt.Errorf("failed to read tags file: %w", err)
201 }
202
203 var tags []string
204 if err := json.Unmarshal(data, &tags); err != nil {
205 return nil, fmt.Errorf("failed to parse tags file: %w", err)
206 }
207
208 return tags, nil
209}
210
211// ListRepositories returns all repository names.
212func (s *ManifestStorage) ListRepositories() ([]string, error) {
213 reposDir := filepath.Join(s.rootDir, "repositories")
214
215 var repositories []string
216 err := filepath.Walk(reposDir, func(path string, info os.FileInfo, err error) error {
217 if err != nil {
218 return err
219 }
220
221 if info.IsDir() && path != reposDir {
222 // Get relative path from repositories directory
223 relPath, err := filepath.Rel(reposDir, path)
224 if err != nil {
225 return err
226 }
227
228 // Check if this directory contains manifests
229 manifestsDir := filepath.Join(path, "manifests")
230 if _, err := os.Stat(manifestsDir); err == nil {
231 repositories = append(repositories, relPath)
232 }
233 }
234
235 return nil
236 })
237
238 if err != nil {
239 return nil, fmt.Errorf("failed to list repositories: %w", err)
240 }
241
242 return repositories, nil
243}
244
245// GetManifestModTime returns the manifest modification time.
246func (s *ManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
247 manifestPath, err := s.getManifestPath(name, reference)
248 if err != nil {
249 return time.Time{}, fmt.Errorf("invalid path: %w", err)
250 }
251
252 info, err := os.Stat(manifestPath)
253 if err != nil {
254 if os.IsNotExist(err) {
255 return time.Time{}, fmt.Errorf("manifest not found: %s/%s", name, reference)
256 }
257 return time.Time{}, fmt.Errorf("failed to stat manifest: %w", err)
258 }
259
260 return info.ModTime(), nil
261}
262
263// Helper methods for path generation with security validation
264
265func (s *ManifestStorage) getManifestPath(name, reference string) (string, error) {
266 // Validate name to prevent path traversal (defense in depth)
267 if strings.Contains(name, "..") || strings.Contains(reference, "..") {
268 return "", fmt.Errorf("path traversal not allowed")
269 }
270 if _, err := validation.ValidatePath(name); err != nil {
271 return "", fmt.Errorf("invalid repository name: %w", err)
272 }
273 if _, err := validation.ValidatePath(reference); err != nil {
274 return "", fmt.Errorf("invalid reference: %w", err)
275 }
276
277 path := filepath.Join(s.rootDir, "repositories", name, "manifests", reference)
278
279 // Verify the path stays within root directory
280 if err := validation.ValidatePathWithinRoot(s.rootDir, path); err != nil {
281 return "", fmt.Errorf("path validation failed: %w", err)
282 }
283
284 return path, nil
285}
286
287func (s *ManifestStorage) getManifestContentTypePath(name, reference string) (string, error) {
288 manifestPath, err := s.getManifestPath(name, reference)
289 if err != nil {
290 return "", err
291 }
292 return manifestPath + ".contenttype", nil
293}
294
295func (s *ManifestStorage) getTagsPath(name string) (string, error) {
296 // Validate name to prevent path traversal
297 if _, err := validation.ValidatePath(name); err != nil {
298 return "", fmt.Errorf("invalid repository name: %w", err)
299 }
300
301 path := filepath.Join(s.rootDir, "repositories", name, "tags.json")
302
303 // Verify the path stays within root directory
304 if err := validation.ValidatePathWithinRoot(s.rootDir, path); err != nil {
305 return "", fmt.Errorf("path validation failed: %w", err)
306 }
307
308 return path, nil
309}
310
311// Content type helpers
312
313func (s *ManifestStorage) getManifestContentType(name, reference string) (string, error) {
314 contentTypePath, err := s.getManifestContentTypePath(name, reference)
315 if err != nil {
316 return "", err
317 }
318 data, err := os.ReadFile(contentTypePath)
319 if err != nil {
320 return "", err
321 }
322 return string(data), nil
323}
324
325func (s *ManifestStorage) putManifestContentType(name, reference, contentType string) error {
326 contentTypePath, err := s.getManifestContentTypePath(name, reference)
327 if err != nil {
328 return err
329 }
330 return os.WriteFile(contentTypePath, []byte(contentType), 0600)
331}
332
333// putManifestByDigest mirrors one manifest under its content digest so
334// digest-addressed pulls resolve.
335func (s *ManifestStorage) putManifestByDigest(name, digest, contentType string, data []byte) error {
336 manifestPath, err := s.getManifestPath(name, digest)
337 if err != nil {
338 return err
339 }
340 if err := os.MkdirAll(filepath.Dir(manifestPath), 0750); err != nil {
341 return fmt.Errorf("failed to create manifest directory: %w", err)
342 }
343 if err := os.WriteFile(manifestPath, data, 0600); err != nil {
344 return fmt.Errorf("failed to write digest manifest: %w", err)
345 }
346 return s.putManifestContentType(name, digest, contentType)
347}
348
349func (s *ManifestStorage) deleteManifestContentType(name, reference string) error {
350 contentTypePath, err := s.getManifestContentTypePath(name, reference)
351 if err != nil {
352 return err
353 }
354 err = os.Remove(contentTypePath)
355 if err != nil && !os.IsNotExist(err) {
356 return err
357 }
358 return nil
359}
360
361// Tags management helpers
362
363// filterOutDigests removes digest entries from a tag list.
364func filterOutDigests(tags []string) []string {
365 filtered := make([]string, 0, len(tags))
366 for _, tag := range tags {
367 if !validation.IsDigest(tag) {
368 filtered = append(filtered, tag)
369 }
370 }
371 return filtered
372}
373
374func (s *ManifestStorage) updateTagsList(name, reference string) error {
375 if validation.IsDigest(reference) {
376 return nil
377 }
378
379 tags, err := s.ListTags(name)
380 if err != nil {
381 return err
382 }
383
384 tags = filterOutDigests(tags)
385
386 // Add tag if not already present
387 for _, tag := range tags {
388 if tag == reference {
389 return nil // Already exists
390 }
391 }
392
393 tags = append(tags, reference)
394 return s.saveTagsList(name, tags)
395}
396
397func (s *ManifestStorage) removeFromTagsList(name, reference string) error {
398 tags, err := s.ListTags(name)
399 if err != nil {
400 return err
401 }
402
403 tags = filterOutDigests(tags)
404
405 // Remove tag if present
406 var newTags []string
407 for _, tag := range tags {
408 if tag != reference {
409 newTags = append(newTags, tag)
410 }
411 }
412
413 return s.saveTagsList(name, newTags)
414}
415
416func (s *ManifestStorage) saveTagsList(name string, tags []string) error {
417 tagsPath, err := s.getTagsPath(name)
418 if err != nil {
419 return fmt.Errorf("invalid path: %w", err)
420 }
421
422 // Create directory if it doesn't exist
423 if err := os.MkdirAll(filepath.Dir(tagsPath), 0750); err != nil {
424 return fmt.Errorf("failed to create tags directory: %w", err)
425 }
426
427 data, err := json.Marshal(tags)
428 if err != nil {
429 return fmt.Errorf("failed to marshal tags: %w", err)
430 }
431
432 if err := os.WriteFile(tagsPath, data, 0600); err != nil {
433 return fmt.Errorf("failed to write tags file: %w", err)
434 }
435
436 return nil
437}