v3.4.1 de8f68e

manifest.go RawHistoryFolder

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}

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