Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · metadata/releases.go

Line attribution on main · 205 line(s)

c5902451
Keenan Nemetz
17d ago
1 // Release artifacts are the files attached to a release.
85cc89fe
Keenan Nemetz
17d ago
2 //
3 // The artifact list is a directory on the metadata branch: releases/<tag>/<name>
4 // holds a standard git-lfs pointer blob, and the pointer is the whole record.
5 // Nothing else names the bytes, which is deliberate:
6 //
7 // - gc marks LFS objects named by pointer blobs in any tree reachable from any
8 // ref, and refs/heads/vole is one of those refs, so a published artifact is
9 // reachable without teaching the sweep a second file format;
10 // - pre-receive's pointer hygiene runs on the metadata branch like any other,
11 // so a pointer whose object was never uploaded cannot be published at all;
12 // - `git clone` of the metadata branch carries the artifact list, which is what
13 // makes the repository a complete backup of its own releases.
14 //
15 // A JSON manifest listing sha256 strings would have been none of those things: it
16 // parses as no pointer, so the sweep would treat every published artifact as
17 // garbage once the grace period passed.
c5902451
Keenan Nemetz
17d ago
18 package metadata
85cc89fe
Keenan Nemetz
17d ago
19
20 import (
21 "fmt"
22 "sort"
23 "strings"
24
d37d301f
Keenan Nemetz
22h ago
25 "rodent.software/vole/core/git"
26 "rodent.software/vole/core/lfs"
85cc89fe
Keenan Nemetz
17d ago
27 )
28
29 // Artifact is one file attached to a release.
30 type Artifact struct {
31 Name string
32 OID string
33 Size int64
34 }
35
36 // Path is where an artifact's pointer lives on the metadata branch.
c5902451
Keenan Nemetz
17d ago
37 func ReleasePath(tag, name string) string { return ReleasesDir + "/" + tag + "/" + name }
85cc89fe
Keenan Nemetz
17d ago
38
39 // ValidName rejects artifact names that would not be one path segment under the
40 // release's directory. A name is a filename, not a path: allowing "/" would let
41 // an attach write outside releases/<tag>/, and the download route could not name
42 // the result.
c5902451
Keenan Nemetz
17d ago
43 func ValidReleaseName(name string) error {
85cc89fe
Keenan Nemetz
17d ago
44 switch {
45 case name == "":
46 return fmt.Errorf("an artifact needs a name")
47 case name == "." || name == "..":
48 return fmt.Errorf("%q is not a filename", name)
49 case strings.ContainsAny(name, "/\\"):
50 return fmt.Errorf("artifact name %q contains a path separator; attach a file, not a path tree", name)
51 case len(name) > 200:
52 return fmt.Errorf("artifact name is %d bytes, limit 200", len(name))
53 case strings.ContainsAny(name, "\x00\n\r\t"):
54 return fmt.Errorf("artifact name %q contains a control character", name)
55 }
56 return nil
57 }
58
59 // List reads the artifacts of one tag, sorted by name. A tag with nothing
60 // attached is an empty list rather than an error: the directory only exists once
61 // something is attached to it.
c5902451
Keenan Nemetz
17d ago
62 func ListReleaseArtifacts(g *git.Repo, tag string) ([]Artifact, error) {
63 entries, err := g.LsTree("refs/heads/vole:" + ReleasesDir + "/" + tag)
85cc89fe
Keenan Nemetz
17d ago
64 if err != nil {
65 return nil, nil // no releases/<tag>/ directory: no artifacts
66 }
67 out := make([]Artifact, 0, len(entries))
68 for _, e := range entries {
69 if e.Type != "blob" {
70 continue
71 }
c5902451
Keenan Nemetz
17d ago
72 data, err := g.BlobAt("refs/heads/vole", ReleasePath(tag, e.Name))
85cc89fe
Keenan Nemetz
17d ago
73 if err != nil {
74 return nil, fmt.Errorf("read artifact pointer %s: %w", e.Name, err)
75 }
76 p, ok := lfs.ParsePointer(data)
77 if !ok {
78 // A file that is not a pointer is not an artifact: it may be a note a
79 // human put there. Serving it would mean serving something no upload
80 // ever vouched for.
81 continue
82 }
83 out = append(out, Artifact{Name: e.Name, OID: p.OID, Size: p.Size})
84 }
85 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
86 return out, nil
87 }
88
89 // Problems validates the releases/ portion of the metadata tree at commit. The
90 // layout is closed for the same reason the rest of the metadata branch is: a file
91 // that is not releases/<tag>/<name> pointing at an object nobody uploaded is
92 // either a mistake or a download that will 404, and both are cheaper to refuse
93 // here than to discover later.
c5902451
Keenan Nemetz
17d ago
94 func ReleaseProblems(g *git.Repo, commit string) []string {
85cc89fe
Keenan Nemetz
17d ago
95 if commit == "" {
96 return nil
97 }
98 blobs, err := g.LsTreeR(commit)
99 if err != nil {
100 return []string{fmt.Sprintf("cannot read the metadata tree: %v", err)}
101 }
102 var problems []string
5cecc618
Keenan Nemetz
10d ago
103 var paths, shas []string
85cc89fe
Keenan Nemetz
17d ago
104 for _, e := range blobs {
c5902451
Keenan Nemetz
17d ago
105 if !strings.HasPrefix(e.Name, ReleasesDir+"/") {
85cc89fe
Keenan Nemetz
17d ago
106 continue
107 }
108 parts := strings.Split(e.Name, "/")
109 switch {
110 case len(parts) != 3:
111 problems = append(problems, fmt.Sprintf(
112 "%s: releases are %s/<tag>/<name>; a release directory holds files, not more directories",
c5902451
Keenan Nemetz
17d ago
113 e.Name, ReleasesDir))
85cc89fe
Keenan Nemetz
17d ago
114 continue
115 case parts[1] == "":
116 problems = append(problems, fmt.Sprintf("%s: missing tag name", e.Name))
117 continue
118 }
c5902451
Keenan Nemetz
17d ago
119 if err := ValidReleaseName(parts[2]); err != nil {
85cc89fe
Keenan Nemetz
17d ago
120 problems = append(problems, e.Name+": "+err.Error())
121 continue
122 }
5cecc618
Keenan Nemetz
10d ago
123 paths = append(paths, e.Name)
124 shas = append(shas, e.SHA)
125 }
126 files, err := readBlobs(g, paths, shas)
127 if err != nil {
128 return append(problems, err.Error())
129 }
130 // The pointers are read together: a tree with many artifacts is one call, not
131 // one process per file.
132 for _, path := range paths {
133 if _, ok := lfs.ParsePointer([]byte(files[path])); !ok {
85cc89fe
Keenan Nemetz
17d ago
134 problems = append(problems, fmt.Sprintf(
5cecc618
Keenan Nemetz
10d ago
135 "%s: an artifact must be a git-lfs pointer; write it with vole release attach", path))
85cc89fe
Keenan Nemetz
17d ago
136 }
137 }
138 return problems
139 }
140
141 // TagWithArtifacts reports whether a tag has an artifacts directory. The release
142 // page uses it to say "no artifacts" without a second tree read.
c5902451
Keenan Nemetz
17d ago
143 func TagWithArtifacts(g *git.Repo, tag string) bool {
144 _, err := g.LsTree("refs/heads/vole:" + ReleasesDir + "/" + tag)
85cc89fe
Keenan Nemetz
17d ago
145 return err == nil
146 }
c5902451
Keenan Nemetz
17d ago
147
148 // WritePointers records artifacts on the metadata branch, one literal LFS
149 // pointer per name. It starts from the branch's current tree, so applying it
150 // again after a lost push race is idempotent.
151 func WriteReleasePointers(g *git.Repo, ref, tag string, arts []Artifact, message, author string) error {
152 _, err := git.CommitFiles(g, ref, func(files map[string][]byte) error {
153 for _, a := range arts {
154 files[ReleasePath(tag, a.Name)] = lfs.Pointer{OID: a.OID, Size: a.Size}.Encode()
155 }
156 return nil
157 }, message, author)
158 return err
159 }
c9202f7f
Keenan Nemetz
10d ago
160
161 // LoadArtifactsAt reads every release artifact on the metadata branch at commit,
162 // keyed by tag. It is one tree listing and one blob read for the whole set: a
163 // release with fifty files is not fifty processes, and the index reads the set to
164 // answer the artifact list without touching git.
165 func LoadArtifactsAt(g *git.Repo, commit string) (map[string][]Artifact, error) {
166 out := map[string][]Artifact{}
167 if commit == "" {
168 return out, nil
169 }
170 entries, err := g.LsTreeR(commit)
171 if err != nil {
172 return nil, err
173 }
174 var paths, shas []string
175 for _, e := range entries {
176 if e.Type != "blob" || !strings.HasPrefix(e.Name, ReleasesDir+"/") {
177 continue
178 }
179 // releases/<tag>/<name>; anything deeper is not an artifact.
180 if parts := strings.Split(e.Name, "/"); len(parts) != 3 {
181 continue
182 }
183 paths = append(paths, e.Name)
184 shas = append(shas, e.SHA)
185 }
186 files, err := readBlobs(g, paths, shas)
187 if err != nil {
188 return nil, err
189 }
190 for _, path := range paths {
191 parts := strings.Split(path, "/")
192 p, ok := lfs.ParsePointer([]byte(files[path]))
193 if !ok {
194 // A file that is not a pointer is not an artifact; the release
195 // validator refuses those at push time, and a reader skips one a
196 // human put there by hand.
197 continue
198 }
199 out[parts[1]] = append(out[parts[1]], Artifact{Name: parts[2], OID: p.OID, Size: p.Size})
200 }
201 for _, list := range out {
202 sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
203 }
204 return out, nil
205 }