v2.30.1 1983e1b

manifest.go RawHistoryFolder

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

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