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.

Phase 7 slice 3: artifacts attach over SSH and are one pointer deep

85cc89fe9eafd1793702d143ea2eb41db635fbe2 · Keenan Nemetz · 17d ago · parent c56911e2

Phase 7 slice 3: artifacts attach over SSH and are one pointer deep

A released binary is a pointer blob at releases/<tag>/<name> on the metadata
branch. That is the whole record, and it was chosen because the machinery that
already exists does the work: gc marks LFS objects named by pointers in any
tree reachable from any ref (refs/heads/vole is one), and pre-receive's pointer
hygiene already refuses a pointer whose bytes are missing. A JSON manifest of
sha256 strings would have parsed as no pointer and been swept once the grace
period passed.

- cmd/vole attach speaks the pure-SSH LFS transfer protocol itself (version,
  batch, put-object, verify-object), so bytes move over the key that already
  writes: no token, no second endpoint, no new write surface
- upload happens before the pointer commit, because the reverse order publishes
  a download that 404s and receive-pack cannot see bytes that were never in the
  pack. A test pushes a pointer with no object and asserts the push is refused
- the artifact list is read from the pointer tree (internal/releases), which the
  metadata validator now knows about; a file under releases/ that is not a
  pointer is refused at push instead of silently unservable
- downloads: /r/<repo>/releases/<tag>/artifacts/<name> out of the store, ETag =
  object id, always Content-Disposition: attachment with nosniff, ServeContent
  for Range and 304, clone read rule so private artifacts are 404
- the "not on this server" hook message now names vole release attach as well as
  git lfs push, because it fires for both now

Measured end to end through the binary: two 2.9 MiB files attached over SSH in
390 ms, the identical second file deduplicating to one object; both downloaded
in 13.9 ms with matching sha256, 304 on If-None-Match, 206 on Range; vole gc
--dry-run reports the published artifact reachable and removes nothing.

The planned releases/<tag>.json record and drafts are dropped with it: the
pointer directory is the artifact list and being in the ref is what published
means, so the file would only have duplicated the tag.

internal/releases/releases.go +142

@@ -0,0 +1,142 @@
1 // Package releases reads the artifacts 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 releases
19
20 import (
21 "fmt"
22 "sort"
23 "strings"
24
25 "github.com/rodent-software/vole/internal/gitx"
26 "github.com/rodent-software/vole/internal/lfs"
27 )
28
29 // Dir is the metadata-branch directory releases live under.
30 const Dir = "releases"
31
32 // Artifact is one file attached to a release.
33 type Artifact struct {
34 Name string
35 OID string
36 Size int64
37 }
38
39 // Path is where an artifact's pointer lives on the metadata branch.
40 func Path(tag, name string) string { return Dir + "/" + tag + "/" + name }
41
42 // ValidName rejects artifact names that would not be one path segment under the
43 // release's directory. A name is a filename, not a path: allowing "/" would let
44 // an attach write outside releases/<tag>/, and the download route could not name
45 // the result.
46 func ValidName(name string) error {
47 switch {
48 case name == "":
49 return fmt.Errorf("an artifact needs a name")
50 case name == "." || name == "..":
51 return fmt.Errorf("%q is not a filename", name)
52 case strings.ContainsAny(name, "/\\"):
53 return fmt.Errorf("artifact name %q contains a path separator; attach a file, not a path tree", name)
54 case len(name) > 200:
55 return fmt.Errorf("artifact name is %d bytes, limit 200", len(name))
56 case strings.ContainsAny(name, "\x00\n\r\t"):
57 return fmt.Errorf("artifact name %q contains a control character", name)
58 }
59 return nil
60 }
61
62 // List reads the artifacts of one tag, sorted by name. A tag with nothing
63 // attached is an empty list rather than an error: the directory only exists once
64 // something is attached to it.
65 func List(g *gitx.Repo, tag string) ([]Artifact, error) {
66 entries, err := g.LsTree("refs/heads/vole:" + Dir + "/" + tag)
67 if err != nil {
68 return nil, nil // no releases/<tag>/ directory: no artifacts
69 }
70 out := make([]Artifact, 0, len(entries))
71 for _, e := range entries {
72 if e.Type != "blob" {
73 continue
74 }
75 data, err := g.BlobAt("refs/heads/vole", Path(tag, e.Name))
76 if err != nil {
77 return nil, fmt.Errorf("read artifact pointer %s: %w", e.Name, err)
78 }
79 p, ok := lfs.ParsePointer(data)
80 if !ok {
81 // A file that is not a pointer is not an artifact: it may be a note a
82 // human put there. Serving it would mean serving something no upload
83 // ever vouched for.
84 continue
85 }
86 out = append(out, Artifact{Name: e.Name, OID: p.OID, Size: p.Size})
87 }
88 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
89 return out, nil
90 }
91
92 // Problems validates the releases/ portion of the metadata tree at commit. The
93 // layout is closed for the same reason the rest of the metadata branch is: a file
94 // that is not releases/<tag>/<name> pointing at an object nobody uploaded is
95 // either a mistake or a download that will 404, and both are cheaper to refuse
96 // here than to discover later.
97 func Problems(g *gitx.Repo, commit string) []string {
98 if commit == "" {
99 return nil
100 }
101 blobs, err := g.LsTreeR(commit)
102 if err != nil {
103 return []string{fmt.Sprintf("cannot read the metadata tree: %v", err)}
104 }
105 var problems []string
106 for _, e := range blobs {
107 if !strings.HasPrefix(e.Name, Dir+"/") {
108 continue
109 }
110 parts := strings.Split(e.Name, "/")
111 switch {
112 case len(parts) != 3:
113 problems = append(problems, fmt.Sprintf(
114 "%s: releases are %s/<tag>/<name>; a release directory holds files, not more directories",
115 e.Name, Dir))
116 continue
117 case parts[1] == "":
118 problems = append(problems, fmt.Sprintf("%s: missing tag name", e.Name))
119 continue
120 }
121 if err := ValidName(parts[2]); err != nil {
122 problems = append(problems, e.Name+": "+err.Error())
123 continue
124 }
125 data, err := g.BlobBySHA(e.SHA)
126 if err != nil {
127 return append(problems, fmt.Sprintf("cannot read %s: %v", e.Name, err))
128 }
129 if _, ok := lfs.ParsePointer(data); !ok {
130 problems = append(problems, fmt.Sprintf(
131 "%s: an artifact must be a git-lfs pointer; write it with vole release attach", e.Name))
132 }
133 }
134 return problems
135 }
136
137 // TagWithArtifacts reports whether a tag has an artifacts directory. The release
138 // page uses it to say "no artifacts" without a second tree read.
139 func TagWithArtifacts(g *gitx.Repo, tag string) bool {
140 _, err := g.LsTree("refs/heads/vole:" + Dir + "/" + tag)
141 return err == nil
142 }