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 5: LFS objects, over SSH for writes and the proxy for reads

64ac5071a173e4e9ce8bc8d7170f9b0411bee52a · Keenan Nemetz · 18d ago · parent 35eaeccf

Phase 5: LFS objects, over SSH for writes and the proxy for reads

Large files leave the git object database and land in one content-addressed
store per instance. Reads are anonymous like clone; writes ride the SSH key
that already authenticates the push, so no token exists anywhere.

- internal/lfs: sha256-addressed store that verifies content and size before
  an object becomes visible; parser for the pointer format a client writes
- HTTP: POST .../info/lfs/objects/batch for downloads, GET .../objects/<oid>
  with the object id as ETag; upload over HTTP is refused and names SSH
- SSH: git-lfs-transfer speaking the pure SSH protocol (pkt-line), so
  `git lfs push` needs nothing but the key at the handshake
- pre-receive: a pointer whose object is missing, or whose size disagrees
  with the stored object, fails the push that introduced it; a binary over
  lfs_warn_bytes is accepted with a `git lfs track` suggestion. Checks run
  before the protected-branch gate, because an admin's approval bypass is
  about review, not about whether the commit can be checked out
- runs: a finished log becomes a content-addressed object in the same write
  that records the status, served at /r/<repo>/runs/<id>/log
- vole gc [--dry-run] [--grace]: marks pointer targets from every ref in
  every repository plus archived logs, sweeps the rest. Mark-and-sweep
  rather than reference counts: a counter that is one too low deletes an
  artifact, a walk over the refs cannot be wrong about what is reachable

internal/lfs/transfer_test.go +314

@@ -0,0 +1,314 @@
1 package lfs
2
3 // These tests speak the wire protocol the way git-lfs does, so a change in
4 // framing or in the shape of a status response fails here rather than in a
5 // client's cache directory. The real client is exercised end to end in the
6 // server's integration tests when git-lfs is installed.
7
8 import (
9 "bytes"
10 "crypto/sha256"
11 "encoding/hex"
12 "io"
13 "net"
14 "strconv"
15 "strings"
16 "testing"
17 )
18
19 type wire struct {
20 t *testing.T
21 pr *pktReader
22 pw *pktWriter
23 }
24
25 func newWire(t *testing.T, r io.Reader, w io.Writer) *wire {
26 return &wire{t: t, pr: newPktReader(r), pw: newPktWriter(w)}
27 }
28
29 // capabilities reads the advertisement the server sends unprompted.
30 func (c *wire) capabilities() []string {
31 var out []string
32 for {
33 data, kind, err := c.pr.next()
34 if err != nil {
35 c.t.Fatalf("read capabilities: %v", err)
36 }
37 if kind == pktFlush {
38 return out
39 }
40 out = append(out, strings.TrimRight(string(data), "\n"))
41 }
42 }
43
44 func (c *wire) send(cmd string, args ...string) {
45 c.t.Helper()
46 if err := c.pw.pkt(cmd); err != nil {
47 c.t.Fatal(err)
48 }
49 for _, a := range args {
50 if err := c.pw.pkt(a); err != nil {
51 c.t.Fatal(err)
52 }
53 }
54 if err := c.pw.flush(); err != nil {
55 c.t.Fatal(err)
56 }
57 }
58
59 func (c *wire) sendBatch(args, lines []string) {
60 c.t.Helper()
61 if err := c.pw.pkt("batch"); err != nil {
62 c.t.Fatal(err)
63 }
64 for _, a := range args {
65 c.pw.pkt(a)
66 }
67 c.pw.delim()
68 for _, l := range lines {
69 c.pw.pkt(l)
70 }
71 c.pw.flush()
72 }
73
74 // status reads a status line, its arguments, and the lines after the delimiter.
75 func (c *wire) status() (int, []string, []string) {
76 c.t.Helper()
77 data, _, err := c.pr.next()
78 if err != nil {
79 c.t.Fatalf("read status: %v", err)
80 }
81 line := strings.TrimRight(string(data), "\n")
82 code, err := strconv.Atoi(strings.TrimPrefix(line, "status "))
83 if err != nil {
84 c.t.Fatalf("status line %q: %v", line, err)
85 }
86 var args, lines []string
87 inLines := false
88 for {
89 data, kind, err := c.pr.next()
90 if err != nil {
91 c.t.Fatalf("read status body: %v", err)
92 }
93 switch kind {
94 case pktFlush:
95 return code, args, lines
96 case pktDelim:
97 inLines = true
98 default:
99 s := strings.TrimRight(string(data), "\n")
100 if inLines {
101 lines = append(lines, s)
102 } else {
103 args = append(args, s)
104 }
105 }
106 }
107 }
108
109 // statusData reads a status frame whose body is binary.
110 func (c *wire) statusData() (int, []string, []byte) {
111 c.t.Helper()
112 data, _, err := c.pr.next()
113 if err != nil {
114 c.t.Fatal(err)
115 }
116 code, _ := strconv.Atoi(strings.TrimPrefix(strings.TrimRight(string(data), "\n"), "status "))
117 var args []string
118 for {
119 data, kind, err := c.pr.next()
120 if err != nil {
121 c.t.Fatal(err)
122 }
123 if kind == pktDelim {
124 break
125 }
126 args = append(args, strings.TrimRight(string(data), "\n"))
127 }
128 var body bytes.Buffer
129 for {
130 data, kind, err := c.pr.next()
131 if err != nil {
132 c.t.Fatal(err)
133 }
134 if kind == pktFlush {
135 return code, args, body.Bytes()
136 }
137 body.Write(data)
138 }
139 }
140
141 // session starts a transfer over a socket pair and returns a client-side wire
142 // already past the version negotiation.
143 func session(t *testing.T, tr *Transfer, op string) (*wire, func()) {
144 t.Helper()
145 clientConn, serverConn := net.Pipe()
146 go func() {
147 if err := tr.Serve(op, serverConn, serverConn); err != nil {
148 serverConn.Close()
149 return
150 }
151 serverConn.Close()
152 }()
153 c := newWire(t, clientConn, clientConn)
154 caps := c.capabilities()
155 if !contains(caps, "version=1") {
156 t.Fatalf("capabilities %v do not offer version=1", caps)
157 }
158 c.send("version 1")
159 if code, _, _ := c.status(); code != 200 {
160 t.Fatalf("version negotiation answered %d", code)
161 }
162 return c, func() { clientConn.Close() }
163 }
164
165 func contains(list []string, want string) bool {
166 for _, s := range list {
167 if s == want {
168 return true
169 }
170 }
171 return false
172 }
173
174 func TestTransferUploadThenDownload(t *testing.T) {
175 store := New(t.TempDir(), 1<<20)
176 data := []byte("a release artifact that is not source code")
177 sum := sha256.Sum256(data)
178 oid := hex.EncodeToString(sum[:])
179 size := strconv.Itoa(len(data))
180
181 up := &Transfer{Store: store, Repo: "demo", CanRead: true, CanWrite: true}
182 c, done := session(t, up, "upload")
183 c.sendBatch([]string{"transfer=ssh", "hash-algo=sha256", "refname=refs/heads/main"},
184 []string{oid + " " + size})
185 code, args, lines := c.status()
186 if code != 200 {
187 t.Fatalf("upload batch answered %d (%v)", code, lines)
188 }
189 if !contains(args, "hash-algo=sha256") {
190 t.Fatalf("batch arguments %v do not name sha256", args)
191 }
192 if len(lines) != 1 || lines[0] != oid+" "+size+" upload" {
193 t.Fatalf("batch said %v; want an upload action for %s", lines, oid)
194 }
195
196 // put-object: command, size argument, delimiter, binary payload, flush.
197 c.pw.pkt("put-object " + oid)
198 c.pw.pkt("size=" + size)
199 c.pw.delim()
200 c.pw.Write(data)
201 c.pw.flush()
202 if code, _, lines := c.status(); code != 200 {
203 t.Fatalf("put-object answered %d (%v)", code, lines)
204 }
205
206 c.send("verify-object "+oid, "size="+size)
207 if code, _, lines := c.status(); code != 200 {
208 t.Fatalf("verify-object answered %d (%v)", code, lines)
209 }
210 c.send("quit")
211 done()
212
213 if !store.Has(oid) {
214 t.Fatal("the uploaded object is not in the store")
215 }
216
217 // A second session, download: the object is already there, so the batch
218 // offers download and get-object returns the bytes.
219 down := &Transfer{Store: store, Repo: "demo", CanRead: true, CanWrite: true}
220 c2, done2 := session(t, down, "download")
221 c2.sendBatch([]string{"transfer=ssh", "hash-algo=sha256"}, []string{oid + " " + size})
222 code, _, lines = c2.status()
223 if code != 200 || len(lines) != 1 || lines[0] != oid+" "+size+" download" {
224 t.Fatalf("download batch answered %d %v", code, lines)
225 }
226 c2.send("get-object "+oid, "size="+size)
227 code, args, body := c2.statusData()
228 if code != 200 {
229 t.Fatalf("get-object answered %d", code)
230 }
231 if !contains(args, "size="+size) {
232 t.Fatalf("get-object arguments %v do not carry the size", args)
233 }
234 if !bytes.Equal(body, data) {
235 t.Fatalf("get-object returned %q", body)
236 }
237 c2.send("quit")
238 done2()
239 }
240
241 func TestTransferRefusesUploadsWithoutWriteAccess(t *testing.T) {
242 store := New(t.TempDir(), 1<<20)
243 data := []byte("nope")
244 oid := oidOf(data)
245
246 tr := &Transfer{Store: store, Repo: "demo", CanRead: true, CanWrite: false}
247 c, done := session(t, tr, "upload")
248 defer done()
249 c.sendBatch([]string{"transfer=ssh"}, []string{oid + " " + strconv.Itoa(len(data))})
250 code, _, lines := c.status()
251 if code != 403 {
252 t.Fatalf("batch answered %d; want 403", code)
253 }
254 if len(lines) == 0 || !strings.Contains(strings.Join(lines, " "), "write access") {
255 t.Fatalf("refusal does not explain itself: %v", lines)
256 }
257 }
258
259 func TestTransferRejectsContentThatDoesNotMatchItsID(t *testing.T) {
260 store := New(t.TempDir(), 1<<20)
261 claimed := oidOf([]byte("what the client says it sent"))
262 actual := []byte("what the client actually sent")
263
264 tr := &Transfer{Store: store, Repo: "demo", CanRead: true, CanWrite: true}
265 c, done := session(t, tr, "upload")
266 defer done()
267 c.pw.pkt("put-object " + claimed)
268 c.pw.pkt("size=" + strconv.Itoa(len(actual)))
269 c.pw.delim()
270 c.pw.Write(actual)
271 c.pw.flush()
272 code, _, lines := c.status()
273 if code != 422 {
274 t.Fatalf("put-object answered %d (%v); want 422", code, lines)
275 }
276 if store.Has(claimed) {
277 t.Fatal("mismatched content was stored")
278 }
279
280 // The session is still usable: the client may send its next object.
281 c.sendBatch([]string{"transfer=ssh"}, []string{claimed + " 5"})
282 if code, _, _ := c.status(); code != 200 {
283 t.Fatalf("batch after a refused object answered %d", code)
284 }
285 }
286
287 func TestTransferMissingObjectIs404(t *testing.T) {
288 store := New(t.TempDir(), 1<<20)
289 tr := &Transfer{Store: store, Repo: "demo", CanRead: true, CanWrite: true}
290 c, done := session(t, tr, "download")
291 defer done()
292 c.send("get-object "+strings.Repeat("a", 64), "size=10")
293 code, _, _ := c.statusData()
294 if code != 404 {
295 t.Fatalf("get-object of an absent object answered %d; want 404", code)
296 }
297 }
298
299 func TestTransferBatchReportsNoopForObjectsItAlreadyHas(t *testing.T) {
300 store := New(t.TempDir(), 1<<20)
301 data := []byte("already uploaded")
302 oid := oidOf(data)
303 if _, _, err := store.Put(oid, int64(len(data)), bytes.NewReader(data)); err != nil {
304 t.Fatal(err)
305 }
306 tr := &Transfer{Store: store, Repo: "demo", CanRead: true, CanWrite: true}
307 c, done := session(t, tr, "upload")
308 defer done()
309 c.sendBatch([]string{"transfer=ssh"}, []string{oid + " " + strconv.Itoa(len(data))})
310 code, _, lines := c.status()
311 if code != 200 || len(lines) != 1 || !strings.HasSuffix(lines[0], " noop") {
312 t.Fatalf("batch answered %d %v; want a noop for an object already stored", code, lines)
313 }
314 }