Commit 2b840816c9
Verified · cmc
Layout: unified · split
e2e/revoke_test.go added +155
| @@ -0,0 +1,155 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 17 | func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) } | |
| 18 | ||
| 19 | // readPkt reads one pkt-line; a flush reads as "". | |
| 20 | func 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. | |
| 38 | func 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). | |
| 50 | func 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 | } | |