| 1 | package tinygit |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | func browseFixture(t *testing.T) (*GitRelay, Repository, string) { |
| 14 | t.Helper() |
| 15 | root := t.TempDir() |
| 16 | r := Repository{Owner: strings.Repeat("a" , 64), Identifier: "browse" , Head: "refs/heads/main" } |
| 17 | g := &GitRelay{root: root, repos: map[string]Repository{}} |
| 18 | dir := g.repoPath(r) |
| 19 | if err := os.MkdirAll(dir, 0700); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | run := func(args ...string) string { |
| 23 | t.Helper() |
| 24 | c := exec.Command("git" , append([]string{"-C" , dir}, args...)...) |
| 25 | c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Browser Test" , "GIT_AUTHOR_EMAIL=test@example.test" , "GIT_COMMITTER_NAME=Browser Test" , "GIT_COMMITTER_EMAIL=test@example.test" ) |
| 26 | b, err := c.CombinedOutput() |
| 27 | if err != nil { |
| 28 | t.Fatalf("git %v: %v %s" , args, err, b) |
| 29 | } |
| 30 | return strings.TrimSpace(string(b)) |
| 31 | } |
| 32 | run("init" , "-b" , "main" ) |
| 33 | for name, body := range map[string]string{"source.go" : "package main\n// <script>alert(1)</script>\n" , "[literal]*.txt" : "literal path" , "large.txt" : strings.Repeat("x" , 300*1024), "binary.bin" : "a\x00b" } { |
| 34 | if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | } |
| 38 | run("add" , "." ) |
| 39 | run("commit" , "-m" , "first source" ) |
| 40 | first := run("rev-parse" , "HEAD" ) |
| 41 | if err := os.WriteFile(filepath.Join(dir, "source.go" ), []byte("package main\n// second\n" ), 0600); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | run("commit" , "-am" , "second source" ) |
| 45 | r.Refs = map[string]string{"refs/heads/main" : run("rev-parse" , "HEAD" )} |
| 46 | g.repos[key(r.Owner, r.Identifier)] = r |
| 47 | // Browsing normally uses a bare repository; this fixture switches --git-dir to .git. |
| 48 | g.root = filepath.Join(root, "bare" ) |
| 49 | if err := os.MkdirAll(filepath.Dir(g.repoPath(r)), 0700); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | cmd := exec.Command("git" , "clone" , "--bare" , dir, g.repoPath(r)) |
| 53 | if b, err := cmd.CombinedOutput(); err != nil { |
| 54 | t.Fatalf("bare clone: %v %s" , err, b) |
| 55 | } |
| 56 | return g, r, first |
| 57 | } |
| 58 | |
| 59 | func TestBrowseRepositorySourceAndHistory(t *testing.T) { |
| 60 | g, r, first := browseFixture(t) |
| 61 | ctx := context.Background() |
| 62 | q := BrowseRequest{Owner: r.Owner, Repo: r.Identifier} |
| 63 | page, err := g.Browse(ctx, q) |
| 64 | if err != nil || len(page.Entries) != 4 { |
| 65 | t.Fatalf("tree: %+v %v" , page, err) |
| 66 | } |
| 67 | q.View, q.Path, q.Ref = "file" , "[literal]*.txt" , first |
| 68 | page, err = g.Browse(ctx, q) |
| 69 | if err != nil || page.Content != "literal path" { |
| 70 | t.Fatalf("literal: %+v %v" , page, err) |
| 71 | } |
| 72 | q.Path = "source.go" |
| 73 | page, err = g.Browse(ctx, q) |
| 74 | if err != nil || !strings.Contains(page.Content, "<script>" ) { |
| 75 | t.Fatalf("historical source: %+v %v" , page, err) |
| 76 | } |
| 77 | q.View, q.Ref, q.Path, q.Limit = "history" , "main" , "" , 1 |
| 78 | page, err = g.Browse(ctx, q) |
| 79 | if err != nil || len(page.Commits) != 1 || page.NextOffset != 1 { |
| 80 | t.Fatalf("history: %+v %v" , page, err) |
| 81 | } |
| 82 | q.View = "commit" |
| 83 | page, err = g.Browse(ctx, q) |
| 84 | if err != nil || !strings.Contains(page.Diff, "+// second" ) { |
| 85 | t.Fatalf("diff: %+v %v" , page, err) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestFetchMissingUsesCompleteLocalObjects(t *testing.T) { |
| 90 | g, repo, _ := browseFixture(t) |
| 91 | expected := map[string]string{"refs/heads/repaired" : repo.Refs["refs/heads/main" ]} |
| 92 | if err := g.FetchMissing(context.Background(), repo, nil, expected); err != nil { |
| 93 | t.Fatalf("complete local inventory should not need a network source: %v" , err) |
| 94 | } |
| 95 | native, err := g.nativeRefs(repo) |
| 96 | if err != nil || native["refs/heads/repaired" ] != expected["refs/heads/repaired" ] { |
| 97 | t.Fatalf("locally available objects were not projected to expected refs: %v %v" , native, err) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestBrowseRejectsUnsafeOrUnpublishedObjects(t *testing.T) { |
| 102 | g, r, _ := browseFixture(t) |
| 103 | cmd := exec.Command("git" , "--git-dir" , g.repoPath(r), "hash-object" , "-w" , "--stdin" ) |
| 104 | cmd.Stdin = strings.NewReader("unpublished secret" ) |
| 105 | id, err := cmd.Output() |
| 106 | if err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | for _, q := range []BrowseRequest{ |
| 110 | {Owner: r.Owner, Repo: r.Identifier, Ref: "--output=/tmp/nope" }, |
| 111 | {Owner: r.Owner, Repo: r.Identifier, Ref: strings.TrimSpace(string(id))}, |
| 112 | {Owner: r.Owner, Repo: r.Identifier, Path: "../config" }, |
| 113 | {Owner: r.Owner, Repo: r.Identifier, Path: "/etc/passwd" }, |
| 114 | } { |
| 115 | if _, err := g.Browse(context.Background(), q); err == nil { |
| 116 | t.Fatalf("accepted unsafe request: %+v" , q) |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestBrowseFilePreviewIsBoundedAndBinaryAware(t *testing.T) { |
| 122 | g, r, _ := browseFixture(t) |
| 123 | for _, name := range []string{"large.txt" , "binary.bin" } { |
| 124 | page, err := g.Browse(context.Background(), BrowseRequest{Owner: r.Owner, Repo: r.Identifier, View: "file" , Path: name}) |
| 125 | if err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if name == "large.txt" && (!page.Truncated || len(page.Content) > 256*1024) { |
| 129 | t.Fatal("unbounded preview" ) |
| 130 | } |
| 131 | if name == "binary.bin" && (!page.Binary || page.Content != "" ) { |
| 132 | t.Fatal("binary shown as source" ) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestBrowseHiddenCommitAndFullBinaryDownload(t *testing.T) { |
| 138 | g, r, _ := browseFixture(t) |
| 139 | cmd := g.browseCommand(context.Background(), r, "rev-parse" , "HEAD^{tree}" ) |
| 140 | tree, err := cmd.Output() |
| 141 | if err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | cmd = g.browseCommand(context.Background(), r, "commit-tree" , strings.TrimSpace(string(tree))) |
| 145 | cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test" , "GIT_AUTHOR_EMAIL=test@example.test" , "GIT_COMMITTER_NAME=test" , "GIT_COMMITTER_EMAIL=test@example.test" ) |
| 146 | cmd.Stdin = strings.NewReader("unpublished commit" ) |
| 147 | hidden, err := cmd.Output() |
| 148 | if err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | oid := strings.TrimSpace(string(hidden)) |
| 152 | if err := g.browseCommand(context.Background(), r, "update-ref" , "refs/tinyrelay/pending/secret" , oid).Run(); err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | if _, err := g.Browse(context.Background(), BrowseRequest{Owner: r.Owner, Repo: r.Identifier, Ref: oid}); err == nil { |
| 156 | t.Fatal("unpublished commit exposed" ) |
| 157 | } |
| 158 | var result bytes.Buffer |
| 159 | if err := g.WriteSource(context.Background(), BrowseRequest{Owner: r.Owner, Repo: r.Identifier, Path: "large.txt" }, &result); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | if result.Len() != 300*1024 { |
| 163 | t.Fatalf("download truncated: %d" , result.Len()) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestBrowseTreePaginationHasNoMissingEntries(t *testing.T) { |
| 168 | g, r, _ := browseFixture(t) |
| 169 | names := map[string]bool{} |
| 170 | q := BrowseRequest{Owner: r.Owner, Repo: r.Identifier, Limit: 1} |
| 171 | for { |
| 172 | page, err := g.Browse(context.Background(), q) |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | for _, entry := range page.Entries { |
| 177 | if names[entry.Name] { |
| 178 | t.Fatal("duplicate entry" ) |
| 179 | } |
| 180 | names[entry.Name] = true |
| 181 | } |
| 182 | if page.NextOffset < 0 { |
| 183 | break |
| 184 | } |
| 185 | q.Offset = page.NextOffset |
| 186 | } |
| 187 | if len(names) != 4 { |
| 188 | t.Fatalf("missing entries: %v" , names) |
| 189 | } |
| 190 | } |