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.
| 1 | // Release artifacts are the files attached to a release. |
| 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. |
| 18 | package metadata |
| 19 | |
| 20 | import ( |
| 21 | "fmt" |
| 22 | "sort" |
| 23 | "strings" |
| 24 | |
| 25 | "rodent.software/vole/core/git" |
| 26 | "rodent.software/vole/core/lfs" |
| 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. |
| 37 | func ReleasePath(tag, name string) string { return ReleasesDir + "/" + tag + "/" + name } |
| 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. |
| 43 | func ValidReleaseName(name string) error { |
| 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. |
| 62 | func ListReleaseArtifacts(g *git.Repo, tag string) ([]Artifact, error) { |
| 63 | entries, err := g.LsTree("refs/heads/vole:" + ReleasesDir + "/" + tag) |
| 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 | } |
| 72 | data, err := g.BlobAt("refs/heads/vole", ReleasePath(tag, e.Name)) |
| 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. |
| 94 | func ReleaseProblems(g *git.Repo, commit string) []string { |
| 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 |
| 103 | var paths, shas []string |
| 104 | for _, e := range blobs { |
| 105 | if !strings.HasPrefix(e.Name, ReleasesDir+"/") { |
| 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", |
| 113 | e.Name, ReleasesDir)) |
| 114 | continue |
| 115 | case parts[1] == "": |
| 116 | problems = append(problems, fmt.Sprintf("%s: missing tag name", e.Name)) |
| 117 | continue |
| 118 | } |
| 119 | if err := ValidReleaseName(parts[2]); err != nil { |
| 120 | problems = append(problems, e.Name+": "+err.Error()) |
| 121 | continue |
| 122 | } |
| 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 { |
| 134 | problems = append(problems, fmt.Sprintf( |
| 135 | "%s: an artifact must be a git-lfs pointer; write it with vole release attach", path)) |
| 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. |
| 143 | func TagWithArtifacts(g *git.Repo, tag string) bool { |
| 144 | _, err := g.LsTree("refs/heads/vole:" + ReleasesDir + "/" + tag) |
| 145 | return err == nil |
| 146 | } |
| 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 | } |
| 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 | } |
| 206 |