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.
|
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 | } |