v2.8.0 6aced72

manifest.go RawHistoryFolder

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

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