Commit 2b840816c9

2b840816c95322a8365802797cc5fc8063af3f8d

parent: c0969481b0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 06:41 UTC

e2e: removing a key cuts its multiplexed connection and a push in flight

Ref #256

Layout: unified · split

e2e/revoke_test.go added +155
@@ -0,0 +1,155 @@
1package e2e
2
3import (
4 "bufio"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "testing"
13 "time"
14)
15
16// pkt frames one pkt-line.
17func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
18
19// readPkt reads one pkt-line; a flush reads as "".
20func readPkt(r *bufio.Reader) (string, error) {
21 var n [4]byte
22 if _, err := io.ReadFull(r, n[:]); err != nil {
23 return "", err
24 }
25 size, err := strconv.ParseUint(string(n[:]), 16, 16)
26 if err != nil {
27 return "", err
28 }
29 if size == 0 {
30 return "", nil
31 }
32 buf := make([]byte, size-4)
33 _, err = io.ReadFull(r, buf)
34 return string(buf), err
35}
36
37// fingerprint is the SHA256 fingerprint of a public key file.
38func fingerprint(t *testing.T, pubPath string) string {
39 t.Helper()
40 out, err := exec.Command("ssh-keygen", "-lf", pubPath).Output()
41 if err != nil {
42 t.Fatalf("ssh-keygen -lf: %v", err)
43 }
44 return strings.Fields(string(out))[1]
45}
46
47// Removing a key cuts the connections it opened: every session
48// multiplexed on a ControlMaster, and a push in flight, which moves no
49// ref (#256).
50func TestRemovedKeyCutsMultiplexedConnection(t *testing.T) {
51 t.Parallel()
52 inst := startInstance(t)
53 aliceKey := setupPublicRepo(t, inst, "alice/app")
54 spare := inst.newKey(t, "spare")
55 pub, err := os.ReadFile(spare + ".pub")
56 if err != nil {
57 t.Fatal(err)
58 }
59 if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "keys", "add"); code != 0 {
60 t.Fatalf("keys add: %s", errOut)
61 }
62
63 // The control socket sits under the system temp dir with a short
64 // name: t.TempDir() or %C on macOS passes the 104-byte socket path
65 // limit.
66 cmDir, err := os.MkdirTemp("", "cm")
67 if err != nil {
68 t.Fatal(err)
69 }
70 t.Cleanup(func() { os.RemoveAll(cmDir) })
71 muxArgs := []string{
72 "-p", fmt.Sprint(inst.port),
73 "-i", aliceKey,
74 "-o", "IdentitiesOnly=yes",
75 "-o", "StrictHostKeyChecking=no",
76 "-o", "UserKnownHostsFile=" + filepath.Join(inst.sshDir, "known_hosts"),
77 "-o", "BatchMode=yes",
78 "-o", "ControlMaster=auto",
79 "-o", "ControlPath=" + filepath.Join(cmDir, "s"),
80 "-o", "ControlPersist=60",
81 }
82 mux := func(args ...string) *exec.Cmd {
83 return exec.Command("ssh", append(append([]string{}, muxArgs...), args...)...)
84 }
85 t.Cleanup(func() { mux("-O", "exit", "git@127.0.0.1").Run() })
86
87 if out, err := mux("git@127.0.0.1", "whoami").Output(); err != nil || strings.TrimSpace(string(out)) != "alice" {
88 var stderr []byte
89 if ee, ok := err.(*exec.ExitError); ok {
90 stderr = ee.Stderr
91 }
92 t.Fatalf("whoami over the master: %v %q %s", err, out, stderr)
93 }
94
95 // A push held open mid-pack: the ref update is sent, the pack is not.
96 push := mux("git@127.0.0.1", "git-receive-pack", "alice/app")
97 stdin, err := push.StdinPipe()
98 if err != nil {
99 t.Fatal(err)
100 }
101 stdout, err := push.StdoutPipe()
102 if err != nil {
103 t.Fatal(err)
104 }
105 if err := push.Start(); err != nil {
106 t.Fatal(err)
107 }
108 t.Cleanup(func() { push.Process.Kill() })
109 adv := bufio.NewReader(stdout)
110 first, err := readPkt(adv)
111 if err != nil || len(first) < 40 {
112 t.Fatalf("advertisement: %q %v", first, err)
113 }
114 oldSHA := first[:40]
115 for {
116 line, err := readPkt(adv)
117 if err != nil {
118 t.Fatalf("advertisement: %v", err)
119 }
120 if line == "" {
121 break
122 }
123 }
124 newSHA := strings.Repeat("1", 40)
125 io.WriteString(stdin, pkt(oldSHA+" "+newSHA+" refs/heads/main\x00report-status\n")+"0000")
126 // A pack header announcing one object, and no object.
127 stdin.Write([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01"))
128 exited := make(chan error, 1)
129 go func() {
130 io.Copy(io.Discard, adv)
131 exited <- push.Wait()
132 }()
133
134 if _, errOut, code := inst.ssh(t, spare, "", "keys", "remove", fingerprint(t, aliceKey+".pub")); code != 0 {
135 t.Fatalf("keys remove: %s", errOut)
136 }
137 select {
138 case err := <-exited:
139 if err == nil {
140 t.Fatal("the push exited cleanly after its key was removed")
141 }
142 case <-time.After(10 * time.Second):
143 t.Fatal("the push outlived its key")
144 }
145
146 // The master went with the connection; a new one authenticates
147 // again, and the key is unknown.
148 if out, err := mux("git@127.0.0.1", "whoami").CombinedOutput(); err == nil {
149 t.Fatalf("whoami after removal succeeded: %s", out)
150 }
151 refs := mustGit(t, t.TempDir(), inst.gitEnv(spare), "ls-remote", inst.sshURL("alice/app"), "refs/heads/main")
152 if !strings.HasPrefix(refs, oldSHA) {
153 t.Fatalf("main moved: %s, want %s", refs, oldSHA)
154 }
155}