Commit 40dab3e11b
Verified · cmc ci/build: success
cmd/gitbay/main.go +2
| @@ -239,6 +239,8 @@ func repoCmd() *cobra.Command { | ||
| 239 | 239 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 240 | 240 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), |
| 241 | 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 | 244 | pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), |
| 243 | 245 | pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}), |
| 244 | 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 | } | |