control: repo tree and repo cat !92
3 files changed, +365 −0
Layout: unified · split
cmd/gitbay/main.go +2
| @@ -239,6 +239,8 @@ func repoCmd() *cobra.Command { | |||
| 239 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), | 239 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 240 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), | 240 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), |
| 241 | pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), | 241 | pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), |
| 242 | pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), | ||
| 243 | pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}), | ||
| 242 | pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), | 244 | pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), |
| 243 | pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}), | 245 | pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}), |
| 244 | pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), | 246 | pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), |
e2e/apiread_test.go added +152
| @@ -0,0 +1,152 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/base64" | ||
| 5 | "encoding/json" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // TestRepoTreeAndCat covers reading repository contents over the control | ||
| 13 | // plane — the capability a native client needs and could not reach at all | ||
| 14 | // before: no command returned file contents, and the web's raw route | ||
| 15 | // authenticates by session cookie, not bearer token. | ||
| 16 | func TestRepoTreeAndCat(t *testing.T) { | ||
| 17 | inst := startInstance(t) | ||
| 18 | aliceKey := inst.newKey(t, "alice") | ||
| 19 | bobKey := inst.newKey(t, "bob") | ||
| 20 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 21 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 22 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app", "--private"); code != 0 { | ||
| 23 | t.Fatalf("repo create: %s", errOut) | ||
| 24 | } | ||
| 25 | |||
| 26 | work := t.TempDir() | ||
| 27 | env := inst.gitEnv(aliceKey) | ||
| 28 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 29 | dir := filepath.Join(work, "w") | ||
| 30 | os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n\nhello\n"), 0o644) | ||
| 31 | os.MkdirAll(filepath.Join(dir, "src"), 0o755) | ||
| 32 | os.WriteFile(filepath.Join(dir, "src", "main.go"), []byte("package main\n"), 0o644) | ||
| 33 | os.WriteFile(filepath.Join(dir, "logo.bin"), []byte{0x00, 0x01, 0x02, 0xff, 0x00}, 0o644) | ||
| 34 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 35 | mustGit(t, dir, env, "add", ".") | ||
| 36 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 37 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 38 | |||
| 39 | // Tree at the root: directories and files, with sizes and object ids. | ||
| 40 | out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "--json") | ||
| 41 | if code != 0 { | ||
| 42 | t.Fatalf("repo tree: %s", errOut) | ||
| 43 | } | ||
| 44 | var tree struct { | ||
| 45 | Data struct { | ||
| 46 | Ref string `json:"ref"` | ||
| 47 | Dir string `json:"dir"` | ||
| 48 | Entries []struct { | ||
| 49 | Name string `json:"name"` | ||
| 50 | Type string `json:"type"` | ||
| 51 | SHA string `json:"sha"` | ||
| 52 | Size int64 `json:"size"` | ||
| 53 | } `json:"entries"` | ||
| 54 | } `json:"data"` | ||
| 55 | } | ||
| 56 | if err := json.Unmarshal([]byte(out), &tree); err != nil { | ||
| 57 | t.Fatalf("tree JSON: %v\n%s", err, out) | ||
| 58 | } | ||
| 59 | if tree.Data.Ref != "main" { | ||
| 60 | t.Errorf("ref = %q, want main", tree.Data.Ref) | ||
| 61 | } | ||
| 62 | byName := map[string]string{} | ||
| 63 | for _, e := range tree.Data.Entries { | ||
| 64 | byName[e.Name] = e.Type | ||
| 65 | if e.SHA == "" { | ||
| 66 | t.Errorf("%s has no object id, so a client cannot cache it", e.Name) | ||
| 67 | } | ||
| 68 | if e.Type == "blob" && e.Size == 0 { | ||
| 69 | t.Errorf("%s reports no size", e.Name) | ||
| 70 | } | ||
| 71 | } | ||
| 72 | if byName["src"] != "tree" || byName["README.md"] != "blob" { | ||
| 73 | t.Fatalf("root listing wrong: %v", byName) | ||
| 74 | } | ||
| 75 | |||
| 76 | // A subdirectory, and a file's contents. | ||
| 77 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "src", "--json") | ||
| 78 | if !strings.Contains(out, `"main.go"`) { | ||
| 79 | t.Errorf("subdirectory listing: %s", out) | ||
| 80 | } | ||
| 81 | out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", "README.md", "--json") | ||
| 82 | if code != 0 { | ||
| 83 | t.Fatalf("repo cat: %s", errOut) | ||
| 84 | } | ||
| 85 | var file struct { | ||
| 86 | Data struct { | ||
| 87 | File string `json:"file"` | ||
| 88 | Content string `json:"content"` | ||
| 89 | Base64 string `json:"base64"` | ||
| 90 | Binary bool `json:"binary"` | ||
| 91 | Truncated bool `json:"truncated"` | ||
| 92 | } `json:"data"` | ||
| 93 | } | ||
| 94 | if err := json.Unmarshal([]byte(out), &file); err != nil { | ||
| 95 | t.Fatalf("cat JSON: %v\n%q", err, out) | ||
| 96 | } | ||
| 97 | if file.Data.Content != "# app\n\nhello\n" || file.Data.Binary { | ||
| 98 | t.Fatalf("cat returned %+v", file.Data) | ||
| 99 | } | ||
| 100 | |||
| 101 | // Binary content comes back base64, never as broken UTF-8 in "content". | ||
| 102 | file.Data = struct { | ||
| 103 | File string `json:"file"` | ||
| 104 | Content string `json:"content"` | ||
| 105 | Base64 string `json:"base64"` | ||
| 106 | Binary bool `json:"binary"` | ||
| 107 | Truncated bool `json:"truncated"` | ||
| 108 | }{} | ||
| 109 | out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", "logo.bin", "--json") | ||
| 110 | if code != 0 { | ||
| 111 | t.Fatalf("cat binary: exit %d %s", code, errOut) | ||
| 112 | } | ||
| 113 | if err := json.Unmarshal([]byte(out), &file); err != nil { | ||
| 114 | t.Fatalf("binary cat JSON: %v\n%q", err, out) | ||
| 115 | } | ||
| 116 | if !file.Data.Binary || file.Data.Content != "" || file.Data.Base64 == "" { | ||
| 117 | t.Fatalf("binary file not base64: %+v", file.Data) | ||
| 118 | } | ||
| 119 | if raw, err := base64.StdEncoding.DecodeString(file.Data.Base64); err != nil || | ||
| 120 | len(raw) != 5 || raw[3] != 0xff { | ||
| 121 | t.Fatalf("base64 does not round-trip: %v %v", raw, err) | ||
| 122 | } | ||
| 123 | |||
| 124 | // Plain output is the file itself, so `repo cat` pipes like cat does. | ||
| 125 | out, _, code = inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", "README.md") | ||
| 126 | if code != 0 || out != "# app\n\nhello\n" { | ||
| 127 | t.Fatalf("plain cat = %q", out) | ||
| 128 | } | ||
| 129 | |||
| 130 | // Reads honour repository visibility: this repo is private. | ||
| 131 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "tree", "alice/app"); code == 0 { | ||
| 132 | t.Error("a stranger listed a private repository's tree") | ||
| 133 | } | ||
| 134 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "cat", "alice/app", "README.md"); code == 0 { | ||
| 135 | t.Error("a stranger read a private repository's file") | ||
| 136 | } | ||
| 137 | |||
| 138 | // Paths cannot climb out of the repository. | ||
| 139 | for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "src/../../.."} { | ||
| 140 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", bad); code == 0 { | ||
| 141 | t.Errorf("path %q was accepted", bad) | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | // Missing things are not found rather than server errors. | ||
| 146 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", "nope.txt"); code != 3 { | ||
| 147 | t.Errorf("missing file exit = %d, want 3", code) | ||
| 148 | } | ||
| 149 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "--ref", "nosuch"); code != 3 { | ||
| 150 | t.Errorf("missing ref exit = %d, want 3", code) | ||
| 151 | } | ||
| 152 | } | ||
internal/control/read.go added +211
| @@ -0,0 +1,211 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/base64" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "path" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "gitbay.org/gitbay/internal/gitutil" | ||
| 11 | "gitbay.org/gitbay/internal/policy" | ||
| 12 | "gitbay.org/gitbay/internal/protocol" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func init() { | ||
| 16 | register(Command{ | ||
| 17 | Path: []string{"repo", "tree"}, | ||
| 18 | Summary: "list a directory: repo tree <owner/name> [<path>] [--ref <ref>]", | ||
| 19 | ReadOnly: true, | ||
| 20 | Run: runRepoTree, | ||
| 21 | }) | ||
| 22 | register(Command{ | ||
| 23 | Path: []string{"repo", "cat"}, | ||
| 24 | Summary: "read a file: repo cat <owner/name> <path> [--ref <ref>]", | ||
| 25 | ReadOnly: true, | ||
| 26 | Run: runRepoCat, | ||
| 27 | }) | ||
| 28 | } | ||
| 29 | |||
| 30 | // readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape | ||
| 31 | // off argv. Positionals are returned in order so each command can name them | ||
| 32 | // in its own usage message. | ||
| 33 | func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) { | ||
| 34 | for i := 0; i < len(args); i++ { | ||
| 35 | switch args[i] { | ||
| 36 | case "--ref": | ||
| 37 | if i+1 >= len(args) { | ||
| 38 | return nil, "", c.fail(protocol.ExitUsage, "--ref requires a value") | ||
| 39 | } | ||
| 40 | ref = args[i+1] | ||
| 41 | i++ | ||
| 42 | default: | ||
| 43 | if strings.HasPrefix(args[i], "--") { | ||
| 44 | return nil, "", c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage) | ||
| 45 | } | ||
| 46 | if len(pos) >= maxPos { | ||
| 47 | return nil, "", c.fail(protocol.ExitUsage, "usage: %s", usage) | ||
| 48 | } | ||
| 49 | pos = append(pos, args[i]) | ||
| 50 | } | ||
| 51 | } | ||
| 52 | return pos, ref, -1 | ||
| 53 | } | ||
| 54 | |||
| 55 | // cleanRepoPath keeps a caller inside the repository: no absolute paths, no | ||
| 56 | // "..", no leading slash. git would resolve those against the work tree. | ||
| 57 | func cleanRepoPath(p string) (string, bool) { | ||
| 58 | p = strings.Trim(p, "/") | ||
| 59 | if p == "" { | ||
| 60 | return "", true | ||
| 61 | } | ||
| 62 | cleaned := path.Clean(p) | ||
| 63 | if cleaned == "." || cleaned == ".." || | ||
| 64 | strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") { | ||
| 65 | return "", false | ||
| 66 | } | ||
| 67 | return cleaned, true | ||
| 68 | } | ||
| 69 | |||
| 70 | // entryOut is one tree entry. The sha lets a client cache by object id | ||
| 71 | // rather than by path and ref, which is what makes an offline client | ||
| 72 | // tractable. | ||
| 73 | type entryOut struct { | ||
| 74 | Name string `json:"name"` | ||
| 75 | Type string `json:"type"` // blob | tree | ||
| 76 | Mode string `json:"mode"` | ||
| 77 | SHA string `json:"sha"` | ||
| 78 | Size int64 `json:"size,omitempty"` // absent for trees | ||
| 79 | } | ||
| 80 | |||
| 81 | func runRepoTree(c *Ctx, args []string) int { | ||
| 82 | const usage = "repo tree <owner/name> [<path>] [--ref <ref>]" | ||
| 83 | pos, ref, code := readArgs(c, args, usage, 2) | ||
| 84 | if code >= 0 { | ||
| 85 | return code | ||
| 86 | } | ||
| 87 | if len(pos) == 0 { | ||
| 88 | return c.fail(protocol.ExitUsage, "usage: %s", usage) | ||
| 89 | } | ||
| 90 | repo, code := resolveRepo(c, pos[0], policy.CanRead) | ||
| 91 | if code >= 0 { | ||
| 92 | return code | ||
| 93 | } | ||
| 94 | dirPath := "" | ||
| 95 | if len(pos) == 2 { | ||
| 96 | var ok bool | ||
| 97 | if dirPath, ok = cleanRepoPath(pos[1]); !ok { | ||
| 98 | return c.fail(protocol.ExitUsage, "path must stay inside the repository") | ||
| 99 | } | ||
| 100 | } | ||
| 101 | if ref == "" { | ||
| 102 | ref = repo.DefaultBranch | ||
| 103 | } | ||
| 104 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | ||
| 105 | if _, err := gitutil.ResolveRef(dir, ref); err != nil { | ||
| 106 | return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) | ||
| 107 | } | ||
| 108 | entries, err := gitutil.ListTree(dir, ref, dirPath) | ||
| 109 | if err != nil { | ||
| 110 | return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref) | ||
| 111 | } | ||
| 112 | |||
| 113 | type out struct { | ||
| 114 | Path string `json:"path"` | ||
| 115 | Ref string `json:"ref"` | ||
| 116 | Dir string `json:"dir"` | ||
| 117 | Entries []entryOut `json:"entries"` | ||
| 118 | } | ||
| 119 | d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}} | ||
| 120 | for _, e := range entries { | ||
| 121 | eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA} | ||
| 122 | if e.Type != "tree" && e.Size >= 0 { | ||
| 123 | eo.Size = e.Size | ||
| 124 | } | ||
| 125 | d.Entries = append(d.Entries, eo) | ||
| 126 | } | ||
| 127 | return c.emit(d, func(w io.Writer) { | ||
| 128 | for _, e := range d.Entries { | ||
| 129 | name := e.Name | ||
| 130 | if e.Type == "tree" { | ||
| 131 | name += "/" | ||
| 132 | } | ||
| 133 | fmt.Fprintf(w, "%s\t%s\t%s\n", e.SHA[:min(10, len(e.SHA))], sizeCol(e), name) | ||
| 134 | } | ||
| 135 | }) | ||
| 136 | } | ||
| 137 | |||
| 138 | func sizeCol(e entryOut) string { | ||
| 139 | if e.Type == "tree" { | ||
| 140 | return "-" | ||
| 141 | } | ||
| 142 | return fmt.Sprintf("%d", e.Size) | ||
| 143 | } | ||
| 144 | |||
| 145 | func runRepoCat(c *Ctx, args []string) int { | ||
| 146 | const usage = "repo cat <owner/name> <path> [--ref <ref>]" | ||
| 147 | pos, ref, code := readArgs(c, args, usage, 2) | ||
| 148 | if code >= 0 { | ||
| 149 | return code | ||
| 150 | } | ||
| 151 | if len(pos) != 2 { | ||
| 152 | return c.fail(protocol.ExitUsage, "usage: %s", usage) | ||
| 153 | } | ||
| 154 | repo, code := resolveRepo(c, pos[0], policy.CanRead) | ||
| 155 | if code >= 0 { | ||
| 156 | return code | ||
| 157 | } | ||
| 158 | filePath, ok := cleanRepoPath(pos[1]) | ||
| 159 | if !ok || filePath == "" { | ||
| 160 | return c.fail(protocol.ExitUsage, "path must stay inside the repository") | ||
| 161 | } | ||
| 162 | if ref == "" { | ||
| 163 | ref = repo.DefaultBranch | ||
| 164 | } | ||
| 165 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | ||
| 166 | if _, err := gitutil.ResolveRef(dir, ref); err != nil { | ||
| 167 | return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) | ||
| 168 | } | ||
| 169 | // One byte over the cap distinguishes "exactly at the limit" from | ||
| 170 | // "truncated", so a client is told which it got. | ||
| 171 | limit := c.Cfg.Limits.MaxBlobBytes | ||
| 172 | data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1) | ||
| 173 | if err != nil { | ||
| 174 | return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref) | ||
| 175 | } | ||
| 176 | truncated := int64(len(data)) > limit | ||
| 177 | if truncated { | ||
| 178 | data = data[:limit] | ||
| 179 | } | ||
| 180 | binary := gitutil.IsBinary(data) | ||
| 181 | |||
| 182 | type out struct { | ||
| 183 | Path string `json:"path"` | ||
| 184 | Ref string `json:"ref"` | ||
| 185 | File string `json:"file"` | ||
| 186 | Size int `json:"size"` | ||
| 187 | Binary bool `json:"binary"` | ||
| 188 | Truncated bool `json:"truncated,omitempty"` | ||
| 189 | // Exactly one of these is set: text for UTF-8-safe content, | ||
| 190 | // base64 for anything else, so a client never has to guess. | ||
| 191 | Content string `json:"content,omitempty"` | ||
| 192 | Base64 string `json:"base64,omitempty"` | ||
| 193 | } | ||
| 194 | d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data), | ||
| 195 | Binary: binary, Truncated: truncated} | ||
| 196 | if binary { | ||
| 197 | d.Base64 = base64.StdEncoding.EncodeToString(data) | ||
| 198 | } else { | ||
| 199 | d.Content = string(data) | ||
| 200 | } | ||
| 201 | return c.emit(d, func(w io.Writer) { | ||
| 202 | if binary { | ||
| 203 | fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data)) | ||
| 204 | return | ||
| 205 | } | ||
| 206 | w.Write(data) | ||
| 207 | if len(data) > 0 && data[len(data)-1] != '\n' { | ||
| 208 | fmt.Fprintln(w) | ||
| 209 | } | ||
| 210 | }) | ||
| 211 | } | ||