v3.0.0-alpha.1 6fdddb8

manifest.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close