Commit ec82c4bca9
Verified · cmc ci/build: success
e2e/webonly_test.go added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | // The three reads that were reachable only in a browser: history at a | |
| 11 | // ref, an archive, and the public listing. Each existed as a web route | |
| 12 | // whose handler went around the registry, so no other surface had them. | |
| 13 | func TestWebOnlyReadsAreCommands(t *testing.T) { | |
| 14 | inst := startInstance(t) | |
| 15 | aliceKey := inst.newKey(t, "alice") | |
| 16 | bobKey := inst.newKey(t, "bob") | |
| 17 | inst.admin(t, "admin", "user", "create", "alice", | |
| 18 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 19 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 20 | ||
| 21 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 22 | t.Fatalf("repo create: %s", errOut) | |
| 23 | } | |
| 24 | work := t.TempDir() | |
| 25 | env := inst.gitEnv(aliceKey) | |
| 26 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 27 | dir := filepath.Join(work, "w") | |
| 28 | ||
| 29 | os.WriteFile(filepath.Join(dir, "f.txt"), []byte("main line\n"), 0o644) | |
| 30 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 31 | mustGit(t, dir, env, "add", ".") | |
| 32 | mustGit(t, dir, env, "commit", "-q", "-m", "on main") | |
| 33 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 34 | ||
| 35 | mustGit(t, dir, env, "checkout", "-q", "-b", "side") | |
| 36 | os.WriteFile(filepath.Join(dir, "f.txt"), []byte("side line\n"), 0o644) | |
| 37 | mustGit(t, dir, env, "add", ".") | |
| 38 | mustGit(t, dir, env, "commit", "-q", "-m", "only on side") | |
| 39 | mustGit(t, dir, env, "push", "-q", "origin", "side") | |
| 40 | ||
| 41 | // --- history at a ref --- | |
| 42 | out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/app", "--json") | |
| 43 | if code != 0 { | |
| 44 | t.Fatalf("repo log: %s", errOut) | |
| 45 | } | |
| 46 | if strings.Contains(out, "only on side") { | |
| 47 | t.Error("the default branch log carried a side-branch commit") | |
| 48 | } | |
| 49 | out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "log", "alice/app", "--ref", "side", "--json") | |
| 50 | if code != 0 { | |
| 51 | t.Fatalf("repo log --ref: %s", errOut) | |
| 52 | } | |
| 53 | if !strings.Contains(out, "only on side") { | |
| 54 | t.Errorf("log at a ref missed its commit: %s", out) | |
| 55 | } | |
| 56 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/app", "--ref", "nope"); code == 0 { | |
| 57 | t.Error("an unknown ref resolved") | |
| 58 | } | |
| 59 | ||
| 60 | // --- archive --- | |
| 61 | out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "download", "alice/app") | |
| 62 | if code != 0 { | |
| 63 | t.Fatalf("repo download: %s", errOut) | |
| 64 | } | |
| 65 | // A gzip stream, not an error page: magic bytes 0x1f 0x8b. | |
| 66 | if len(out) < 2 || out[0] != 0x1f || out[1] != 0x8b { | |
| 67 | t.Errorf("repo download did not produce gzip (%d bytes)", len(out)) | |
| 68 | } | |
| 69 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "download", "alice/app", "--ref", "nope"); code == 0 { | |
| 70 | t.Error("archived an unknown ref") | |
| 71 | } | |
| 72 | ||
| 73 | // --- the public listing --- | |
| 74 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/hidden", "--private"); code != 0 { | |
| 75 | t.Fatal("private repo create") | |
| 76 | } | |
| 77 | out, errOut, code = inst.ssh(t, bobKey, "", "explore", "--json") | |
| 78 | if code != 0 { | |
| 79 | t.Fatalf("explore: %s", errOut) | |
| 80 | } | |
| 81 | if !strings.Contains(out, `"path":"alice/app"`) { | |
| 82 | t.Errorf("explore missed a public repository: %s", out) | |
| 83 | } | |
| 84 | if strings.Contains(out, "alice/hidden") { | |
| 85 | t.Errorf("explore leaked a private repository: %s", out) | |
| 86 | } | |
| 87 | ||
| 88 | // Paginated like the other listings. | |
| 89 | out, _, code = inst.ssh(t, bobKey, "", "explore", "--limit", "1", "--json") | |
| 90 | if code != 0 || !strings.Contains(out, `"items"`) { | |
| 91 | t.Fatalf("explore --limit: %s", out) | |
| 92 | } | |
| 93 | } | |
internal/control/explore.go added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "io" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/gitutil" | |
| 8 | "gitbay.org/gitbay/internal/policy" | |
| 9 | "gitbay.org/gitbay/internal/protocol" | |
| 10 | ) | |
| 11 | ||
| 12 | func init() { | |
| 13 | register(Command{ | |
| 14 | Path: []string{"explore"}, | |
| 15 | Summary: "list public repositories: explore [--limit <n>] [--cursor <c>]", | |
| 16 | ReadOnly: true, | |
| 17 | Run: runExplore, | |
| 18 | }) | |
| 19 | register(Command{ | |
| 20 | Path: []string{"repo", "download"}, | |
| 21 | // Not "repo archive": that name is taken by the read-only flag, | |
| 22 | // and renaming it would break every script that sets it. | |
| 23 | Summary: "write a tar.gz of a ref to stdout: repo download <owner/name> [--ref <r>] > repo.tar.gz", | |
| 24 | ReadOnly: true, | |
| 25 | Run: runRepoDownload, | |
| 26 | }) | |
| 27 | } | |
| 28 | ||
| 29 | // runExplore is the public listing the web serves at /explore. Without a | |
| 30 | // command there was no way to browse what an instance hosts without | |
| 31 | // already knowing a name to search for. | |
| 32 | func runExplore(c *Ctx, args []string) int { | |
| 33 | rest, p, code := parsePageFlags(c, args, "explore", false) | |
| 34 | if code >= 0 { | |
| 35 | return code | |
| 36 | } | |
| 37 | if len(rest) != 0 { | |
| 38 | return c.fail(protocol.ExitUsage, "usage: explore [--limit <n>] [--cursor <c>]") | |
| 39 | } | |
| 40 | repos, err := c.Store.ListPublicRepos() | |
| 41 | if err != nil { | |
| 42 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 43 | } | |
| 44 | // Public means public, but an archived repo is still worth marking, | |
| 45 | // and the cursor is the path since the listing is ordered by it. | |
| 46 | type out struct { | |
| 47 | Path string `json:"path"` | |
| 48 | Description string `json:"description,omitempty"` | |
| 49 | Archived bool `json:"archived,omitempty"` | |
| 50 | Topics []string `json:"topics,omitempty"` | |
| 51 | } | |
| 52 | var ds []out | |
| 53 | for _, repo := range repos { | |
| 54 | if p.key != "" && repo.Path() <= p.key { | |
| 55 | continue | |
| 56 | } | |
| 57 | topics, _ := c.Store.ListTopics(repo.ID) | |
| 58 | ds = append(ds, out{ | |
| 59 | Path: repo.Path(), | |
| 60 | Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)), | |
| 61 | Archived: repo.Settings.Archived, | |
| 62 | Topics: topics, | |
| 63 | }) | |
| 64 | if p.limit > 0 && len(ds) > p.limit { | |
| 65 | break | |
| 66 | } | |
| 67 | } | |
| 68 | ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path }) | |
| 69 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 70 | for _, d := range ds { | |
| 71 | fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Description) | |
| 72 | } | |
| 73 | }) | |
| 74 | } | |
| 75 | ||
| 76 | // runRepoDownload writes a gzipped tarball of a ref to stdout, the way | |
| 77 | // release asset get writes an asset. The web's /archive route is the | |
| 78 | // same bytes with a Content-Disposition on them. | |
| 79 | func runRepoDownload(c *Ctx, args []string) int { | |
| 80 | const usage = "repo download <owner/name> [--ref <r>] > repo.tar.gz" | |
| 81 | var rest []string | |
| 82 | var ref string | |
| 83 | for i := 0; i < len(args); i++ { | |
| 84 | switch args[i] { | |
| 85 | case "--ref": | |
| 86 | if i+1 >= len(args) { | |
| 87 | return c.fail(protocol.ExitUsage, "--ref requires a value") | |
| 88 | } | |
| 89 | ref = args[i+1] | |
| 90 | i++ | |
| 91 | default: | |
| 92 | rest = append(rest, args[i]) | |
| 93 | } | |
| 94 | } | |
| 95 | if len(rest) != 1 { | |
| 96 | return c.fail(protocol.ExitUsage, "usage: %s", usage) | |
| 97 | } | |
| 98 | repo, code := resolveRepo(c, rest[0], policy.CanRead) | |
| 99 | if code >= 0 { | |
| 100 | return code | |
| 101 | } | |
| 102 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 103 | if ref == "" { | |
| 104 | ref = repo.DefaultBranch | |
| 105 | } | |
| 106 | if _, err := gitutil.ResolveRef(dir, ref); err != nil { | |
| 107 | return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) | |
| 108 | } | |
| 109 | // The prefix git puts on every path inside the archive, so unpacking | |
| 110 | // lands in a named directory rather than the current one. | |
| 111 | prefix := repo.Name + "-" + ref | |
| 112 | if err := gitutil.Archive(dir, ref, prefix, c.Stdout); err != nil { | |
| 113 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 114 | } | |
| 115 | return protocol.ExitOK | |
| 116 | } | |
internal/control/sig.go +18 −6
| @@ -27,7 +27,7 @@ func init() { | ||
| 27 | 27 | Summary: "show one commit with its patch: repo commit <owner/name> <sha>", |
| 28 | 28 | ReadOnly: true, Run: runRepoCommit}) |
| 29 | 29 | register(Command{Path: []string{"repo", "log"}, |
| 30 | Summary: "commit log with signature states: repo log <owner/name> [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog}) | |
| 30 | Summary: "commit log with signature states: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog}) | |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | func runPGPAdd(c *Ctx, args []string) int { |
| @@ -123,9 +123,15 @@ func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sh | ||
| 123 | 123 | |
| 124 | 124 | func runRepoLog(c *Ctx, args []string) int { |
| 125 | 125 | limit := 30 |
| 126 | var path, filePath string | |
| 126 | var path, filePath, ref string | |
| 127 | 127 | for i := 0; i < len(args); i++ { |
| 128 | 128 | switch args[i] { |
| 129 | case "--ref": | |
| 130 | if i+1 >= len(args) { | |
| 131 | return c.fail(protocol.ExitUsage, "--ref requires a value") | |
| 132 | } | |
| 133 | ref = args[i+1] | |
| 134 | i++ | |
| 129 | 135 | case "--limit": |
| 130 | 136 | if i+1 >= len(args) { |
| 131 | 137 | return c.fail(protocol.ExitUsage, "--limit requires a value") |
| @@ -144,25 +150,31 @@ func runRepoLog(c *Ctx, args []string) int { | ||
| 144 | 150 | i++ |
| 145 | 151 | default: |
| 146 | 152 | if path != "" { |
| 147 | return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n] [--path <file>]") | |
| 153 | return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]") | |
| 148 | 154 | } |
| 149 | 155 | path = args[i] |
| 150 | 156 | } |
| 151 | 157 | } |
| 152 | 158 | if path == "" { |
| 153 | return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n] [--path <file>]") | |
| 159 | return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]") | |
| 154 | 160 | } |
| 155 | 161 | repo, code := resolveRepo(c, path, policy.CanRead) |
| 156 | 162 | if code >= 0 { |
| 157 | 163 | return code |
| 158 | 164 | } |
| 159 | 165 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 166 | if ref == "" { | |
| 167 | ref = repo.DefaultBranch | |
| 168 | } | |
| 169 | if _, err := gitutil.ResolveRef(dir, ref); err != nil { | |
| 170 | return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) | |
| 171 | } | |
| 160 | 172 | var shas []string |
| 161 | 173 | var err error |
| 162 | 174 | if filePath != "" { |
| 163 | shas, err = gitutil.RevListPath(dir, repo.DefaultBranch, filePath, limit) | |
| 175 | shas, err = gitutil.RevListPath(dir, ref, filePath, limit) | |
| 164 | 176 | } else { |
| 165 | shas, err = gitutil.RevList(dir, repo.DefaultBranch, limit) | |
| 177 | shas, err = gitutil.RevList(dir, ref, limit) | |
| 166 | 178 | } |
| 167 | 179 | if err != nil { |
| 168 | 180 | return c.fail(protocol.ExitFailure, "reading log: %v", err) |