diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 0a680ac5..3cf7553d 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -34,7 +34,8 @@ are in [`../upstream/openapi.yaml`](../upstream/openapi.yaml). token, or a fork's `base_repo.auth.token`, lacks a required scope), `repo_name_mismatch` (400: the create body's `repo_name` differs from the token's `repo` claim), `branch_not_found` (404), `ref_not_found` (404: a ref, tag, or - revision does not resolve), `credential_not_found` (404), `credential_exists` (409), + revision does not resolve; an existing ref or object whose commit git cannot + read is a 500), `credential_not_found` (404), `credential_exists` (409), `repository_deleted` (409: `DELETE /api/v1/repos/delete` on a deleted repository; `DELETE /api/repos/{repo_name}` answers that case with 404 `repository_not_found`, because its name lookup skips deleted repositories), `ref_guard_failed` (409: a diff --git a/internal/api/broken_ref_test.go b/internal/api/broken_ref_test.go new file mode 100644 index 00000000..779028ea --- /dev/null +++ b/internal/api/broken_ref_test.go @@ -0,0 +1,100 @@ +package api + +import ( + "context" + "net/http" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/coder/openstorage/internal/auth" + "github.com/coder/openstorage/internal/blob" + "github.com/coder/openstorage/internal/ref" +) + +// A tag whose commit is in no pack is an existing ref that git cannot +// read: each endpoint that resolves a ref answers 500 for it, and for the +// tag's object id, not 404. A ref that does not exist is still 404. +func TestReadEndpointsAnswer500ForARefGitCannotRead(t *testing.T) { + runBackends(t, func(t *testing.T, e *testEnv) { + ctx := context.Background() + org := e.newOrg(t) + repoURL := "https://example.com/" + org + "/broken-" + randomHex(t, 4) + repo, err := e.store.CreateRepo(ctx, org, repoURL, "main") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + coreGit(t, dir, "init", "-q", "-b", "main", ".") + coreWriteFile(t, dir, "a.txt", "one\n") + coreGit(t, dir, "add", ".") + coreGit(t, dir, "commit", "-q", "-m", "c1") + c1 := coreRevParse(t, dir, "HEAD") + c2 := strings.TrimSpace(coreGit(t, dir, "commit-tree", coreRevParse(t, dir, "HEAD^{tree}"), "-p", c1, "-m", "c2")) + coreGit(t, dir, "tag", "-a", "-m", "gone", "gone-tag", c2) + tagObj := coreRevParse(t, dir, "refs/tags/gone-tag") + coreGit(t, dir, "tag", "-d", "gone-tag") + coreSeedStores(t, e, repo.RepoID, dir, map[string]string{"refs/heads/main": c1}) + + // A pack with the tag object alone: its commit is in no pack. + packDir := t.TempDir() + pk := exec.Command("git", "pack-objects", filepath.Join(packDir, "pack")) + pk.Dir = dir + pk.Stdin = strings.NewReader(tagObj + "\n") + out, err := pk.Output() + if err != nil { + t.Fatal(err) + } + id := strings.TrimSpace(string(out)) + key := blob.PackKey(repo.RepoID, id) + if err := e.blobStore.UploadFile(ctx, key, filepath.Join(packDir, "pack-"+id+".pack")); err != nil { + t.Fatal(err) + } + if err := e.refStore.RegisterPacks(ctx, repo.RepoID, []ref.Pack{{ID: id, Key: key}}); err != nil { + t.Fatal(err) + } + if won, err := e.refStore.CASRef(ctx, repo.RepoID, "refs/tags/gone-tag", ref.ZeroSHA, tagObj); err != nil || !won { + t.Fatalf("CASRef(refs/tags/gone-tag) = %v, %v", won, err) + } + + token := e.mint(t, tokenSpec{iss: org, repo: repoURL, scopes: []string{auth.ScopeGitRead}}) + for _, tc := range []struct { + path string + want int + }{ + {"/api/v1/repos/commits?ref=gone-tag", http.StatusInternalServerError}, + {"/api/v1/repos/commits?ref=" + tagObj, http.StatusInternalServerError}, + {"/api/v1/repos/commits?ref=" + tagObj[:12], http.StatusInternalServerError}, + {"/api/v1/repos/commit?ref=gone-tag", http.StatusInternalServerError}, + {"/api/v1/repos/files?ref=gone-tag", http.StatusInternalServerError}, + {"/api/v1/repos/file?ref=gone-tag&path=a.txt", http.StatusInternalServerError}, + {"/api/v1/repos/blame?ref=gone-tag&path=a.txt", http.StatusInternalServerError}, + {"/api/v1/repos/diff?ref=gone-tag", http.StatusInternalServerError}, + {"/api/v1/repos/commits?ref=main", http.StatusOK}, + {"/api/v1/repos/commits?ref=nope", http.StatusNotFound}, + } { + resp, body := e.doReq(t, http.MethodGet, tc.path, token, nil, "") + if resp.StatusCode != tc.want { + t.Errorf("GET %s = %d, want %d: %.200s", tc.path, resp.StatusCode, tc.want, body) + } + } + // create-branch falls back to preferred_base only for a base that + // names no ref: a base git cannot read is a 500. + writeTok := e.mint(t, tokenSpec{iss: org, repo: repoURL, scopes: []string{auth.ScopeGitWrite}}) + srv := newWrefServer(t, e) + for _, tc := range []struct { + base string + want int + }{ + {"gone-tag", http.StatusInternalServerError}, + {"nope", http.StatusOK}, + } { + resp, body := wrefDo(t, srv, http.MethodPost, "/api/v1/repos/branches/create", writeTok, + map[string]any{"target_branch": "from-" + tc.base, "base_ref": tc.base, "preferred_base": "main"}) + if resp.StatusCode != tc.want { + t.Errorf("create-branch from %s with preferred_base main = %d, want %d: %v", tc.base, resp.StatusCode, tc.want, body) + } + } + }) +} diff --git a/internal/api/handlers_fork.go b/internal/api/handlers_fork.go index 50007cb7..f6818d5a 100644 --- a/internal/api/handlers_fork.go +++ b/internal/api/handlers_fork.go @@ -236,7 +236,8 @@ func (s *server) handleForkRepo(w http.ResponseWriter, r *http.Request, verifier } // Fork point (docs/reference/http-api.md): sha overrides ref; default = the source's - // default branch. Everything unresolvable is invalid input (400). + // default branch. A fork point that names no ref is invalid input + // (400), and one whose commit git cannot read is a 500. point := spec.SHA if point == "" { point = spec.Ref diff --git a/internal/api/handlers_write_commit.go b/internal/api/handlers_write_commit.go index 26074a51..350039c2 100644 --- a/internal/api/handlers_write_commit.go +++ b/internal/api/handlers_write_commit.go @@ -1089,8 +1089,10 @@ func (sc *wcScratch) resolveTree(ctx context.Context, commitSHA string) (string, // is false when the target names no commit: no object, a tree, a blob, // or a tag of one. A commit whose tree, or a tag whose commit, git cannot // read is the repository's failure, an error, and so is any other git -// failure (a fatal exit, a killed git). +// failure (a fatal exit, a killed git). target is a full object id, so +// that cat-file -e exit 1 below means only that no such object exists. func (sc *wcScratch) restoreTargetTree(ctx context.Context, target string) (tree string, found bool, err error) { + assertf(ref.ValidSHA(target), "restoreTargetTree: malformed sha %q", target) _, kindErr := sc.git(ctx, nil, nil, "rev-parse", "--verify", "--quiet", "--end-of-options", target+"^{commit}") if kindErr == nil { out, err := sc.git(ctx, nil, nil, "rev-parse", "--verify", "--quiet", "--end-of-options", target+"^{commit}^{tree}") diff --git a/internal/api/handlers_write_refs.go b/internal/api/handlers_write_refs.go index ea87f07f..0048dd87 100644 --- a/internal/api/handlers_write_refs.go +++ b/internal/api/handlers_write_refs.go @@ -262,7 +262,8 @@ type wrefCreateBranchRequest struct { // stay subject to the no-force-push ref-policy op (real ancestry check). Force bool `json:"force"` // PreferredBase is the fallback base: used when no base_ref/base_branch - // is given, or when the primary base does not resolve. + // is given, or when the primary base names no ref. A primary base + // whose commit git cannot read is a 500, not a reason to fall back. PreferredBase string `json:"preferred_base"` } @@ -290,7 +291,8 @@ type wrefCreateBranchResponse struct { // handleCreateBranch resolves the base to a commit (branch, tag — peeled — // or hex sha via gitread.ResolveRef; preferred_base is the fallback when -// the primary base is absent or unresolvable), policy-checks the new ref, +// the primary base is absent or names no ref, and a base whose commit git +// cannot read is a 500, with no fallback), policy-checks the new ref, // and CAS creates it. With force, an existing target is CAS-recreated at // the base — no-force-push policies still deny non-fast-forward moves via a // real ancestry check. No new objects are created, so there is nothing to @@ -333,7 +335,8 @@ func (s *server) handleCreateBranch(w http.ResponseWriter, r *http.Request) { sha, err := gitread.ResolveRef(ctx, repoPath, base, req.BaseIsEphemeral) switch { case errors.Is(err, gitread.ErrNotFound): - // Fall through to preferred_base below (when given). + // Fall through to preferred_base below (when given). An + // unreadable base is an error below, never a fallback. case err != nil: s.internalErr(w, r, "resolve base ref", err) return diff --git a/internal/api/read_query_test.go b/internal/api/read_query_test.go index 1560b936..ab9034dc 100644 --- a/internal/api/read_query_test.go +++ b/internal/api/read_query_test.go @@ -1144,7 +1144,7 @@ func testQueryGrep(t *testing.T, e *testEnv) { t.Fatal(err) } bin := t.TempDir() - wrapper := "#!/bin/sh\ncase \"$*\" in *" + fx.c4 + "^{commit}*) (sleep 5) & ;; esac\nexec " + realGit + " \"$@\"\n" + wrapper := "#!/bin/sh\nif [ \"$1\" = rev-parse ]; then for a in \"$@\"; do [ \"$a\" = '" + fx.c4 + "' ] && { (sleep 5) & }; done; fi\nexec " + realGit + " \"$@\"\n" if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0o755); err != nil { t.Fatal(err) } diff --git a/internal/api/restore_target_test.go b/internal/api/restore_target_test.go index 5d8f8463..34857ac6 100644 --- a/internal/api/restore_target_test.go +++ b/internal/api/restore_target_test.go @@ -1,7 +1,10 @@ package api import ( + "bytes" + "compress/zlib" "context" + "io" "os" "os/exec" "path/filepath" @@ -76,6 +79,46 @@ func TestRestoreTargetTreeTellsMissingFromBroken(t *testing.T) { t.Fatal(err) } } + // Commits whose object file is there but whose header git cannot + // read: cat-file -e finds them, and rev-parse exits 128 for them. + badHeader := strings.TrimSpace(coreGit(t, dir, "commit-tree", tree1, "-p", c1, "-m", "bad header")) + tagBadHeader := tag("bad-header", badHeader) + notZlib := strings.TrimSpace(coreGit(t, dir, "commit-tree", tree1, "-p", c1, "-m", "not zlib")) + rewriteLoose := func(oid string, data func(body []byte) []byte) { + t.Helper() + p := filepath.Join(dir, ".git", "objects", oid[:2], oid[2:]) + f, err := os.Open(p) + if err != nil { + t.Fatal(err) + } + zr, err := zlib.NewReader(f) + if err != nil { + t.Fatal(err) + } + raw, err := io.ReadAll(zr) + _ = f.Close() + if err != nil { + t.Fatal(err) + } + _, body, ok := bytes.Cut(raw, []byte{0}) + if !ok { + t.Fatalf("loose object %s has no header", oid) + } + if err := os.Remove(p); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, data(body), 0o444); err != nil { + t.Fatal(err) + } + } + rewriteLoose(badHeader, func(body []byte) []byte { + var z bytes.Buffer + zw := zlib.NewWriter(&z) + _, _ = zw.Write(append([]byte("commit 12x\x00"), body...)) + _ = zw.Close() + return z.Bytes() + }) + rewriteLoose(notZlib, func([]byte) []byte { return []byte("not zlib data\n") }) sc, err := wcNewScratch(filepath.Join(dir, ".git")) if err != nil { @@ -100,6 +143,9 @@ func TestRestoreTargetTreeTellsMissingFromBroken(t *testing.T) { {"commit whose tree is missing", c2, "", false, true}, {"tag of a missing commit", tagC3, "", false, true}, {"tag of a tag of a missing commit", tagTagC3, "", false, true}, + {"commit with a header git cannot parse", badHeader, "", false, true}, + {"tag of a commit with a header git cannot parse", tagBadHeader, "", false, true}, + {"commit whose object file is not zlib data", notZlib, "", false, true}, } { tree, found, err := sc.restoreTargetTree(context.Background(), tc.target) if tree != tc.tree || found != tc.found || (err != nil) != tc.broken { @@ -112,6 +158,16 @@ func TestRestoreTargetTreeTellsMissingFromBroken(t *testing.T) { if _, err := sc.resolveTree(context.Background(), c4); err != nil { t.Errorf("resolveTree(commit that a replacement makes a tree): %v", err) } + // Only a full object id reaches restoreTargetTree: for an abbreviated + // one, cat-file -e exit 1 can also mean an ambiguous name. + t.Run("abbreviated target", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("restoreTargetTree(abbreviated id) did not panic") + } + }() + _, _, _ = sc.restoreTargetTree(context.Background(), c1[:12]) + }) // git peels the tags: nothing reads a tag object's message, which // can be as large as the repository allows. t.Run("no tag body read", func(t *testing.T) { @@ -121,3 +177,38 @@ func TestRestoreTargetTreeTellsMissingFromBroken(t *testing.T) { } }) } + +// A git run in restoreTargetTree that fails other than with its "no such +// object" exit is the server's failure, an error, never "not found". So +// is a commit at the end of the chain that rev-parse ^{commit} cannot +// read. +func TestRestoreTargetTreeGitFailures(t *testing.T) { + dir := t.TempDir() + coreGit(t, dir, "init", "-q", "-b", "main", ".") + coreWriteFile(t, dir, "a.txt", "one\n") + coreGit(t, dir, "add", ".") + coreGit(t, dir, "commit", "-q", "-m", "c1") + c1 := coreRevParse(t, dir, "HEAD") + tree1 := coreRevParse(t, dir, "HEAD^{tree}") + sc, err := wcNewScratch(filepath.Join(dir, ".git")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(sc.close) + for _, tc := range []struct{ name, target, arg, how string }{ + {"rev-parse ^{commit} of a tree exits 128", tree1, tree1 + "^{commit}", "128"}, + {"rev-parse ^{commit} of a tree is killed", tree1, tree1 + "^{commit}", "kill"}, + {"cat-file -e exits 128", tree1, "-e", "128"}, + {"cat-file -e is killed", tree1, "-e", "kill"}, + {"cat-file -t exits 128", tree1, "-t", "128"}, + {"cat-file -t is killed", tree1, "-t", "kill"}, + {"rev-parse ^{commit} of a commit exits 1", c1, c1 + "^{commit}", "1"}, + } { + t.Run(tc.name, func(t *testing.T) { + gitFailsOnArg(t, tc.arg, tc.how) + if tree, found, err := sc.restoreTargetTree(context.Background(), tc.target); tree != "" || found || err == nil { + t.Fatalf("tree %q found %v err %v, want an error", tree, found, err) + } + }) + } +} diff --git a/internal/gitread/README.md b/internal/gitread/README.md index 2b9ce53d..6d26343c 100644 --- a/internal/gitread/README.md +++ b/internal/gitread/README.md @@ -21,7 +21,8 @@ Read-side git plumbing for the `git:read` API. It hydrates a cache repo, resolve ## Invariants - `assertf` panics on programming errors such as an empty repo path or a non-SHA commit argument [gitread.go:assertf] -- `ResolveRef` builds candidates only from `check-ref-format`-valid names and admits raw revisions only if they match `hexRevRe`. Only git's exit 1 makes it answer `ErrNotFound`: every other git failure (a budget error, a kill, a fatal exit) is returned as is. An empty spec, a spec with a NUL byte, and one longer than `maxRefSpecBytes` (4096 bytes) are `ErrNotFound` before git runs [gitread.go:ResolveRef] +- `ResolveRef` reads each candidate name directly with `rev-parse --verify --quiet` without a peel (so a ref's missing object still resolves), and its full id's type and peels with `cat-file --batch-check`. A hex abbreviation is read as `^{commit}` first, as on main: only the peel picks a commit that shares the abbreviation with a blob or a tree. On git's exit 1 it gets the plain lookup too. No command lists ref directories. git's DWIM rules stay in use, and revision operators and control bytes are refused before git starts: the hex fallback is the only object revision. Only git's exit 1 makes the name unknown: every other git failure (a budget error, a kill, a fatal exit) is returned as is. An empty spec, a spec with a NUL byte, and one longer than `maxRefSpecBytes` (4096 bytes) are `ErrNotFound` before git runs [gitread.go:ResolveRef] +- `resolveCommitObject` reads three batch-check answers (the id's type, its `^{commit}` peel and its `^{}` peel). A full id that names no object and a tree, blob or tag of one are not found. A ref whose object is missing, a broken tag chain, and a commit git cannot read are errors [gitread.go:resolveCommitObject] - `IsBudgetError` errors must not be reported as not-found [budget.go:IsBudgetError] - `(*Budget).start` asserts the budget is open and returns `ErrTimeBudget` past the deadline; `limitedBuffer.Write` kills git once output is exhausted [budget.go:Budget.start] - `Run` never wraps `ExitError` for limit outcomes [budget.go:Run] diff --git a/internal/gitread/gitread.go b/internal/gitread/gitread.go index 20f59855..a198b027 100644 --- a/internal/gitread/gitread.go +++ b/internal/gitread/gitread.go @@ -116,7 +116,9 @@ var fullHexRe = regexp.MustCompile(`\A[0-9a-fA-F]{40}\z`) // Everything unresolvable wraps ErrNotFound — including the empty spec and // the unborn-branch case, where the DB knows no such ref yet (invariant 3: // handlers map it to 404). Only git's own negative answer counts as -// unresolvable (gitSaidNo); any other git failure is returned as is. +// unresolvable (gitSaidNo); any other git failure is returned as is. An +// existing ref, or an object, whose way to a commit git cannot read (a +// missing object, a broken tag) is an error too (resolveCommitObject). func ResolveRef(ctx context.Context, repoPath, spec string, ephemeral bool) (string, error) { assertf(repoPath != "", "ResolveRef: empty repoPath") if spec == "" { @@ -132,34 +134,27 @@ func ResolveRef(ctx context.Context, repoPath, spec string, ephemeral bool) (str // invalid name. return "", fmt.Errorf("resolve ref %q: %w", spec, ErrNotFound) } - // failErr records a git failure that is not git's answer (a resource - // limit, a cancellation, a kill, an exec.ErrWaitDelay): those must - // surface as errors, never as "not found". - var failErr error - // Ref candidates are built only from well-formed ref names - // (check-ref-format): git forbids the rev-expression metacharacters - // (~ ^ : @{ ...) in refnames, so a validated candidate cannot smuggle - // an expression like "main~1" through the rev-parse below. - validRefName := func(name string) bool { - _, err := runGit(ctx, Light, repoPath, "check-ref-format", name) - if err != nil && failErr == nil && !gitSaidNo(err) { - failErr = err + // rev-parse reads each candidate as a ref name, including its DWIM + // rules ("refs/r" can name refs/heads/refs/r). It rejects malformed + // names itself, but also accepts revision expressions: refuse every + // revision operator and control byte before any spec reaches it. + // Those bytes cannot be in a ref's name either. Only the hex fallback + // below is an object revision. + if strings.ContainsAny(spec, "~^:?*[\\") || strings.Contains(spec, "..") || strings.Contains(spec, "@{") { + return "", fmt.Errorf("resolve ref %q: %w", spec, ErrNotFound) + } + for _, c := range []byte(spec) { + if c <= ' ' || c == 0x7f { + return "", fmt.Errorf("resolve ref %q: %w", spec, ErrNotFound) } - return err == nil } var candidates []string if ephemeral { - // Branch names resolve inside the ephemeral namespace only; SHAs - // (below) address objects, which namespaces share anyway. - if name := auth.EphemeralRefName(spec); validRefName(name) { - candidates = append(candidates, name) - } - } else if !strings.HasPrefix(spec, "refs/") { - if validRefName("refs/heads/" + spec) { - candidates = append(candidates, "refs/heads/"+spec, "refs/tags/"+spec) - } - } else if validRefName(spec) { + candidates = append(candidates, auth.EphemeralRefName(spec)) + } else if strings.HasPrefix(spec, "refs/") { candidates = append(candidates, spec) + } else { + candidates = append(candidates, "refs/heads/"+spec, "refs/tags/"+spec) } hex := spec if fullHexRe.MatchString(spec) { @@ -168,24 +163,44 @@ func ResolveRef(ctx context.Context, repoPath, spec string, ephemeral bool) (str if hexRevRe.MatchString(hex) { candidates = append(candidates, hex) } - if failErr != nil { - return "", failErr - } for _, cand := range candidates { - // --end-of-options stops a hostile spec from being parsed as an - // option; ^{commit} peels annotated tags and rejects non-commits. - out, err := runGit(ctx, Light, repoPath, "rev-parse", "--verify", "--quiet", "--end-of-options", cand+"^{commit}") - if err != nil { - if !gitSaidNo(err) { + if hexRevRe.MatchString(cand) && !ref.ValidSHA(cand) { + // An abbreviation peels first, as on main: when a commit + // shares it with a blob or a tree, only ^{commit} + // picks the commit, and the plain name is ambiguous. On exit + // 1, the plain lookup below still reads a tag whose commit + // git cannot read. + out, err := runGit(ctx, Light, repoPath, "rev-parse", "--verify", "--quiet", "--end-of-options", cand+"^{commit}") + if err == nil { + sha := strings.TrimSpace(out) + if !ref.ValidSHA(sha) { + return "", fmt.Errorf("rev-parse %q emitted %q", cand+"^{commit}", out) + } + return sha, nil + } else if !gitSaidNo(err) { return "", err } + } + // No peel: rev-parse reads a ref's value even when its object is + // missing, and exits 1 only for a name that does not resolve. + // Each lookup reads one name, never a ref directory listing. + out, err := runGit(ctx, Light, repoPath, "rev-parse", "--verify", "--quiet", "--end-of-options", cand) + if gitSaidNo(err) { continue + } else if err != nil { + return "", err } - sha := strings.TrimSpace(out) - if !ref.ValidSHA(sha) { - return "", fmt.Errorf("rev-parse %q emitted %q", cand, sha) + id := strings.TrimSpace(out) + if !ref.ValidSHA(id) { + return "", fmt.Errorf("rev-parse %q emitted %q", cand, out) + } + sha, err := resolveCommitObject(ctx, repoPath, cand, id) + if err != nil { + return "", fmt.Errorf("resolve ref %q: %w", spec, err) + } + if sha != "" { + return sha, nil } - return sha, nil } return "", fmt.Errorf("resolve ref %q: %w", spec, ErrNotFound) } @@ -199,9 +214,8 @@ func ResolveRef(ctx context.Context, repoPath, spec string, ephemeral bool) (str const maxRefSpecBytes = 4096 // gitSaidNo reports whether err is git's own negative answer: the child -// ran to the end and exited 1. check-ref-format exits 1 for a name that is -// not a valid ref name, and rev-parse --verify --quiet for one that names -// no commit (missing, ambiguous, or not a commit). Any other failure says +// ran to the end and exited 1. rev-parse --verify --quiet exits 1 for a +// name that names no commit (missing, ambiguous, or not a commit). Any other failure says // nothing about the ref: a resource limit or cancellation (gitread limit // errors never wrap *exec.ExitError), a kill, exec.ErrWaitDelay after a // long stall, or a fatal exit 128. @@ -209,3 +223,49 @@ func gitSaidNo(err error) bool { var exitErr *exec.ExitError return errors.As(err, &exitErr) && exitErr.ExitCode() == 1 } + +// resolveCommitObject resolves a candidate's full object id to a commit. +// One batch-check reads the object's type and its peel to a commit. git +// answers a ref whose object is missing as " missing", and a tag +// whose commit is missing as "^{commit} missing". An id with no +// object, and an object that is not a commit (or a tag of one), answer +// with no sha and no error. A ref whose object is missing, a broken tag +// chain, and a commit git cannot read are errors. +func resolveCommitObject(ctx context.Context, repoPath, cand, id string) (string, error) { + out, err := Run(ctx, Cmd{Class: Light, Dir: repoPath, + Stdin: strings.NewReader(id + "\n" + id + "^{commit}\n" + id + "^{}\n"), + Args: []string{"cat-file", "--batch-check=%(objectname) %(objecttype)"}}) + if err != nil { + return "", err + } + lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") + if len(lines) != 3 { + return "", fmt.Errorf("cat-file --batch-check for %s emitted %q", id, out) + } + if lines[0] == id+" missing" { + if cand == id { + return "", nil // an unknown full id + } + return "", fmt.Errorf("%s names object %s, which is missing", cand, id) + } + oid, typ, _ := strings.Cut(lines[0], " ") + if oid != id || (typ != "commit" && typ != "tag" && typ != "tree" && typ != "blob") { + return "", fmt.Errorf("cat-file --batch-check for %s emitted %q", id, out) + } + if sha, kind, ok := strings.Cut(lines[1], " "); ok && ref.ValidSHA(sha) && kind == "commit" { + return sha, nil + } + if lines[1] != id+"^{commit} missing" { + return "", fmt.Errorf("cat-file --batch-check for %s emitted %q", id, out) + } + if typ == "tree" || typ == "blob" { + return "", nil + } + // A tag of a tree or a blob is not a commit. A failed ^{commit} + // peel, and anything else at the chain's end, is git's failure to + // read a commit or to peel the tag chain, not an unknown candidate. + if end, kind, ok := strings.Cut(lines[2], " "); ok && ref.ValidSHA(end) && (kind == "tree" || kind == "blob") { + return "", nil + } + return "", fmt.Errorf("git cannot read commit or tag chain %s", id) +} diff --git a/internal/gitread/gitread_test.go b/internal/gitread/gitread_test.go index 434c117b..9289de17 100644 --- a/internal/gitread/gitread_test.go +++ b/internal/gitread/gitread_test.go @@ -171,7 +171,7 @@ func TestResolveRefGitFailureIsNotNotFound(t *testing.T) { if err != nil { t.Fatal(err) } - for _, sub := range []string{"check-ref-format", "rev-parse"} { + for _, sub := range []string{"rev-parse", "cat-file"} { t.Run(sub, func(t *testing.T) { // A background child keeps git's stdout open after git exits. fakeGit(t, `if [ "$1" = `+sub+` ]; then (sleep 5) & fi diff --git a/internal/gitread/resolveref_failure_test.go b/internal/gitread/resolveref_failure_test.go index b12a6570..e45bcd76 100644 --- a/internal/gitread/resolveref_failure_test.go +++ b/internal/gitread/resolveref_failure_test.go @@ -2,7 +2,10 @@ package gitread import ( "context" + "crypto/sha1" + "encoding/hex" "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -10,32 +13,39 @@ import ( "testing" ) -// Only git's own "no" (exit 1 from check-ref-format or from rev-parse -// --verify --quiet) makes ResolveRef answer ErrNotFound. A fatal exit, a -// killed git or a corrupt repository says nothing about the ref. +// Only git's own "no" (exit 1 from rev-parse --verify --quiet) makes +// ResolveRef answer ErrNotFound. Every failure of cat-file --batch-check +// is an error. A fatal exit, a killed git or a corrupt repository says +// nothing about the ref. func TestResolveRefOnlyExitOneIsNotFound(t *testing.T) { f := newFixture(t) realGit, err := exec.LookPath("git") if err != nil { t.Fatal(err) } - for _, sub := range []string{"check-ref-format", "rev-parse"} { - for _, tc := range []struct { - name, sh string - notFound bool - }{ - {"exit 1", "exit 1", true}, - {"exit 128", "echo 'fatal: bad' >&2; exit 128", false}, - {"SIGKILL", "kill -KILL $$", false}, - } { - t.Run(sub+"/"+tc.name, func(t *testing.T) { - fakeGit(t, "if [ \"$1\" = "+sub+" ]; then "+tc.sh+"; fi\nexec '"+realGit+"' \"$@\"") - sha, err := ResolveRef(context.Background(), f.dir, "main", false) - if err == nil || errors.Is(err, ErrNotFound) != tc.notFound { - t.Fatalf("ResolveRef(main) = (%q, %v), want an error that is ErrNotFound: %v", sha, err, tc.notFound) - } - }) - } + // rev-parse peels an abbreviation that no object and no ref has, and + // cat-file reads main's object. + const noObject = "0000000000ab" + for _, tc := range []struct { + sub, spec, name, sh string + notFound bool + }{ + {"cat-file", "main", "exit 1", "exit 1", false}, + {"cat-file", "main", "exit 128", "echo 'fatal: bad' >&2; exit 128", false}, + {"cat-file", "main", "SIGKILL", "kill -KILL $$", false}, + {"rev-parse", noObject, "exit 1", "exit 1", true}, + {"rev-parse", noObject, "exit 128", "echo 'fatal: bad' >&2; exit 128", false}, + {"rev-parse", noObject, "SIGKILL", "kill -KILL $$", false}, + {"rev-parse", noObject, "peel exits 128", "case \"$*\" in *'^{commit}') exit 128;; esac", false}, + {"rev-parse", noObject, "peel is killed", "case \"$*\" in *'^{commit}') kill -KILL $$;; esac", false}, + } { + t.Run(tc.sub+"/"+tc.name, func(t *testing.T) { + fakeGit(t, "if [ \"$1\" = "+tc.sub+" ]; then "+tc.sh+"; fi\nexec '"+realGit+"' \"$@\"") + sha, err := ResolveRef(context.Background(), f.dir, tc.spec, false) + if err == nil || errors.Is(err, ErrNotFound) != tc.notFound { + t.Fatalf("ResolveRef(%s) = (%q, %v), want an error that is ErrNotFound: %v", tc.spec, sha, err, tc.notFound) + } + }) } t.Run("corrupt packed-refs", func(t *testing.T) { dir := t.TempDir() @@ -53,6 +63,7 @@ func TestResolveRefOnlyExitOneIsNotFound(t *testing.T) { // git, and leaves the spec out of the error: a spec can be request-sized. func TestResolveRefLongSpecSkipsGit(t *testing.T) { marker := filepath.Join(t.TempDir(), "ran") + // A git that resolves no ref. fakeGit(t, "echo x >> '"+marker+"'\nexit 1") spec := strings.Repeat("x", 4097) _, err := ResolveRef(context.Background(), t.TempDir(), spec, false) @@ -72,3 +83,312 @@ func TestResolveRefLongSpecSkipsGit(t *testing.T) { t.Fatal("git did not run for a 4096-byte spec") } } + +// rev-parse --verify --quiet ^{commit} exits 1 for a ref that does +// not exist, and also for one whose commit git cannot read. Only the +// first is ErrNotFound: an existing branch or tag whose commit object is +// missing is the repository's failure. A ref or id that names a tree is +// still no commit. +func TestResolveRefUnreadableCommitIsAnError(t *testing.T) { + dir := t.TempDir() + mustGit(t, dir, "init", "-q", "--initial-branch=main") + writeFile(t, dir, "a.txt", "one\n", 0o644) + mustGit(t, dir, "add", "-A") + mustGit(t, dir, "commit", "-q", "-m", "c1") + tree := strings.TrimSpace(mustGit(t, dir, "rev-parse", "HEAD^{tree}")) + mustGit(t, dir, "tag", "-a", "-m", "tree", "tree-tag", tree) + blob := strings.TrimSpace(mustGit(t, dir, "rev-parse", "HEAD:a.txt")) + mustGit(t, dir, "tag", "-a", "-m", "blob", "blob-tag", blob) + writeFile(t, dir, "a.txt", "two\n", 0o644) + mustGit(t, dir, "commit", "-q", "-am", "c2") + c2 := strings.TrimSpace(mustGit(t, dir, "rev-parse", "HEAD")) + mustGit(t, dir, "branch", "gone", c2) + mustGit(t, dir, "tag", "-a", "-m", "gone", "gone-tag", c2) + mustGit(t, dir, "tag", "-a", "-m", "gone again", "gone-tag-tag", "gone-tag") + tagObj := strings.TrimSpace(mustGit(t, dir, "rev-parse", "refs/tags/gone-tag")) + mustGit(t, dir, "reset", "-q", "--hard", "HEAD~1") + c1 := strings.TrimSpace(mustGit(t, dir, "rev-parse", "HEAD")) + // A broken branch is not shadowed by a good tag of its name, and a + // broken branch named like an id not by that id. + mustGit(t, dir, "branch", "dup", c2) + mustGit(t, dir, "tag", "dup", c1) + mustGit(t, dir, "update-ref", "refs/heads/"+c1, c2) + if err := os.Remove(filepath.Join(dir, ".git", "objects", c2[:2], c2[2:])); err != nil { + t.Fatal(err) + } + ctx := context.Background() + for _, spec := range []string{"gone", "refs/heads/gone", "gone-tag", "gone-tag-tag", tagObj, strings.ToUpper(tagObj), tagObj[:12], tagObj[:7], "dup", c1} { + if sha, err := ResolveRef(ctx, dir, spec, false); err == nil || errors.Is(err, ErrNotFound) { + t.Errorf("ResolveRef(%q) = (%q, %v), want an error that is not ErrNotFound", spec, sha, err) + } + } + for _, spec := range []string{"nope", "refs/heads/nope", "tree-tag", "blob-tag", tree, c2, c2[:12], strings.Repeat("1", 40)} { + if sha, err := ResolveRef(ctx, dir, spec, false); !errors.Is(err, ErrNotFound) { + t.Errorf("ResolveRef(%q) = (%q, %v), want ErrNotFound", spec, sha, err) + } + } + if _, err := ResolveRef(ctx, dir, "main", false); err != nil { + t.Errorf("ResolveRef(main): %v", err) + } +} + +// The batch-check is a structured answer to three fixed requests: the +// object's type, its peel to a commit, and its peel through any tag +// chain. Every failed run and every incomplete or unexpected answer is +// git's failure, not "not found". +func TestResolveCommitObjectGitFailures(t *testing.T) { + f := newFixture(t) + tagObj := strings.TrimSpace(mustGit(t, f.dir, "rev-parse", "refs/tags/v1")) + tree := strings.TrimSpace(mustGit(t, f.dir, "rev-parse", f.c1+"^{tree}")) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + q := func(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + for _, tc := range []struct{ name, spec, script string }{ + {"batch-check exits 128", "main", "exit 128"}, + {"batch-check is killed", "main", "kill -KILL $$"}, + {"batch-check for a full id exits 128", f.c1, "exit 128"}, + {"batch-check for an abbreviation exits 128", tree[:12], "exit 128"}, // a commit's abbreviation peels without it + {"batch-check answers for another object", "main", "printf '%s' " + q(tree+" tree\n"+f.c1+" commit\n"+tree+" tree\n")}, + {"no batch answer", "main", "exit 0"}, + {"invalid peel answer", "main", "printf '%s' " + q(f.c2+" commit\nzz commit\n"+f.c2+" commit\n")}, + {"invalid peel answer for a tree", tree, "printf '%s' " + q(tree+" tree\nzz commit\n"+tree+" tree\n")}, + {"a commit with no readable commit peel", "main", "printf '%s' " + q(f.c2+" commit\n"+f.c2+"^{commit} missing\n"+f.c2+" commit\n")}, + // A survivor in the earlier design: cat-file -t named a commit + // at the end of a tag chain, but the code read it as not found. + {"tag chain ends at a commit but commit peel failed", "v1", "printf '%s' " + q(tagObj+" tag\n"+tagObj+"^{commit} missing\n"+f.c1+" commit\n")}, + {"tag chain cannot be read", "v1", "printf '%s' " + q(tagObj+" tag\n"+tagObj+"^{commit} missing\n"+tagObj+"^{} missing\n")}, + } { + t.Run(tc.name, func(t *testing.T) { + fakeGit(t, "if [ \"$1\" = cat-file ]; then "+tc.script+"; exit 0; fi\nexec '"+realGit+"' \"$@\"") + if sha, err := ResolveRef(context.Background(), f.dir, tc.spec, false); err == nil || errors.Is(err, ErrNotFound) { + t.Fatalf("ResolveRef(%q) = (%q, %v), want an error that is not ErrNotFound", tc.spec, sha, err) + } + }) + } +} + +// ResolveRef reads one candidate at a time with rev-parse (no peel, so +// a ref's missing object still resolves), and the object's type and peel +// with one batch-check. An abbreviation peels first, as on main. Each +// read that finds a commit takes at most main's git runs. An +// abbreviation of a blob takes one run more than on main. +func TestResolveRefGitRuns(t *testing.T) { + f := newFixture(t) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + tree := strings.TrimSpace(mustGit(t, f.dir, "rev-parse", f.c1+"^{tree}")) + blob := strings.TrimSpace(mustGit(t, f.dir, "rev-parse", f.c1+":README.md")) + for _, tc := range []struct { + name, spec string + runs, main int + found bool + }{ + {"branch", "main", 2, 2, true}, + {"tag", "v1", 3, 3, true}, + {"full id", f.c1, 4, 4, true}, + {"unknown name", "nope", 2, 3, false}, + {"abbreviation of a commit", f.c1[:12], 3, 4, true}, + {"tree id", tree, 4, 4, false}, + {"abbreviation of a blob", blob[:12], 5, 4, false}, + } { + t.Run(tc.name, func(t *testing.T) { + log := filepath.Join(t.TempDir(), "runs") + fakeGit(t, "echo \"$1\" >> '"+log+"'\nexec '"+realGit+"' \"$@\"") + if _, err := ResolveRef(context.Background(), f.dir, tc.spec, false); (err == nil) != tc.found || (err != nil && !errors.Is(err, ErrNotFound)) { + t.Fatalf("ResolveRef(%s): %v, want found %v", tc.spec, err, tc.found) + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + if n := strings.Count(string(got), "\n"); n != tc.runs || (tc.found && n > tc.main) { + t.Fatalf("ResolveRef(%s) ran git %d times (%q), want %d (main ran %d)", tc.spec, n, got, tc.runs, tc.main) + } + }) + } +} + +// Refs under a name that is no ref (refs/heads/pfx/a for "pfx") are +// not candidates. No candidate resolution lists ref directories. +func TestResolveRefListsNoRefsUnderTheName(t *testing.T) { + f := newFixture(t) + for _, r := range []string{"refs/heads/pfx/a", "refs/heads/pfx/b", "refs/tags/pfx/c"} { + mustGit(t, f.dir, "update-ref", r, f.c1) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + log := filepath.Join(t.TempDir(), "for-each-ref") + fakeGit(t, "if [ \"$1\" = for-each-ref ]; then echo listing >> '"+log+"'; exit 128; fi\nexec '"+realGit+"' \"$@\"") + if sha, err := ResolveRef(context.Background(), f.dir, "pfx", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("ResolveRef(pfx) = (%q, %v), want ErrNotFound", sha, err) + } + if got, err := os.ReadFile(log); !os.IsNotExist(err) || len(got) != 0 { + t.Fatalf("a ref-listing command ran: %q (%v)", got, err) + } + if sha, err := ResolveRef(context.Background(), f.dir, "pfx/a", false); err != nil || sha != f.c1 { + t.Fatalf("ResolveRef(pfx/a) = (%q, %v), want c1", sha, err) + } + // A glob character cannot be in a ref's name: a spec with one names + // no ref, though as a pattern it would match main. + for _, spec := range []string{"ma?n", "m*", "[m]ain", "mai\\n"} { + if sha, err := ResolveRef(context.Background(), f.dir, spec, false); !errors.Is(err, ErrNotFound) { + t.Errorf("ResolveRef(%q) = (%q, %v), want ErrNotFound", spec, sha, err) + } + } +} + +// An abbreviation that more than one object has names no one object: +// ErrNotFound, as an unknown one. +func TestResolveRefAmbiguousAbbreviationIsNotFound(t *testing.T) { + dir := t.TempDir() + mustGit(t, dir, "init", "-q", "--initial-branch=main") + // 400 blobs: two of them share a 4-character prefix. + var paths []string + for i := range 400 { + p := filepath.Join(dir, fmt.Sprintf("b%d", i)) + if err := os.WriteFile(p, []byte(fmt.Sprintf("blob %d\n", i)), 0o644); err != nil { + t.Fatal(err) + } + paths = append(paths, p) + } + ids := strings.Fields(mustGit(t, dir, append([]string{"hash-object", "-w"}, paths...)...)) + seen := map[string]bool{} + prefix := "" + for _, id := range ids { + if seen[id[:4]] { + prefix = id[:4] + break + } + seen[id[:4]] = true + } + if prefix == "" { + t.Fatal("no two of 400 blobs share a 4-character prefix") + } + if sha, err := ResolveRef(context.Background(), dir, prefix, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("ResolveRef(%s) = (%q, %v), want ErrNotFound", prefix, sha, err) + } +} + +// A commit and a blob share a 4-character prefix. Without a peel git +// calls the prefix ambiguous, and only ^{commit} names the +// commit: the prefix resolves to the commit, as on main. Two blobs that +// share a prefix stay not found (above). +func TestResolveRefAbbreviationSharedWithABlob(t *testing.T) { + f := newFixture(t) + for i := 0; ; i++ { + content := fmt.Sprintf("blob %d\n", i) + sum := sha1.Sum([]byte(fmt.Sprintf("blob %d\x00%s", len(content), content))) + id := hex.EncodeToString(sum[:]) + if id[:4] != f.c1[:4] { + continue + } + writeFile(t, f.dir, "shared.txt", content, 0o644) + if got := strings.TrimSpace(mustGit(t, f.dir, "hash-object", "-w", "shared.txt")); got != id { + t.Fatalf("hash-object wrote %s, want %s", got, id) + } + break + } + cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", f.c1[:4]) + cmd.Dir, cmd.Env = f.dir, append(os.Environ(), gitEnv...) + if out, err := cmd.Output(); !gitSaidNo(err) { + t.Fatalf("git rev-parse %s = (%q, %v), want exit 1 for a shared prefix", f.c1[:4], out, err) + } + for _, spec := range []string{f.c1[:4], f.c1[:12], f.c1} { + if sha, err := ResolveRef(context.Background(), f.dir, spec, false); err != nil || sha != f.c1 { + t.Errorf("ResolveRef(%s) = (%q, %v), want c1", spec, sha, err) + } + } +} + +// ResolveRef must read candidate names directly, not list the cache's +// loose-ref directories. The ref debug trace records both direct reads +// and iterator steps. 20,000 irrelevant loose refs must add neither. +func TestResolveRefManyLooseRefsDoNotAddWork(t *testing.T) { + f := newFixture(t) + trace := func() string { + t.Helper() + log := filepath.Join(t.TempDir(), "refs.trace") + t.Setenv("GIT_TRACE_REFS", log) + for _, spec := range []string{"main", "v1", f.c1} { + if _, err := ResolveRef(context.Background(), f.dir, spec, false); err != nil { + t.Fatalf("ResolveRef(%s): %v", spec, err) + } + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatalf("read GIT_TRACE_REFS: %v", err) + } + if !strings.Contains(string(got), "read_raw_ref:") { + t.Fatal("git did not trace ref reads") + } + return string(got) + } + before := trace() + dir := filepath.Join(f.dir, ".git", "refs", "heads", "unrelated") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for i := range 20_000 { + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("r%05d", i)), []byte(f.c1+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + after := trace() + if steps := strings.Count(after, "iterator_advance:"); steps != 0 { + t.Fatalf("with 20,000 loose refs: %d ref iterator steps, want 0 (no listing)", steps) + } + if b, a := strings.Count(before, "read_raw_ref:"), strings.Count(after, "read_raw_ref:"); a != b { + t.Fatalf("20,000 loose refs changed direct ref reads from %d to %d", b, a) + } +} + +// Keep rev-parse's short-name DWIM rules, also for a "refs/" spec: main +// resolved refs/r to refs/heads/refs/r. No name is a revision expression. +func TestResolveRefKeepsGitDWIM(t *testing.T) { + f := newFixture(t) + for name, sha := range map[string]string{ + "refs/heads/refs/r": f.c1, + "refs/heads/refs/heads/r": f.c2, + "refs/tags/refs/t": f.c1, + } { + mustGit(t, f.dir, "update-ref", name, sha) + } + for _, tc := range []struct{ spec, sha string }{{"refs/r", f.c1}, {"r", f.c2}, {"refs/t", f.c1}} { + if got, err := ResolveRef(context.Background(), f.dir, tc.spec, false); err != nil || got != tc.sha { + t.Errorf("ResolveRef(%s) = (%q, %v), want %s", tc.spec, got, err, tc.sha) + } + } + for _, spec := range []string{"refs/r~1", "refs/r^{tree}", "refs/r:a.txt", "refs/r@{0}", "refs/r\nrefs/heads/main", "refs/r\r", "refs/r\x01"} { + if got, err := ResolveRef(context.Background(), f.dir, spec, false); !errors.Is(err, ErrNotFound) { + t.Errorf("ResolveRef(%q) = (%q, %v), want ErrNotFound", spec, got, err) + } + } +} + +// Name lookups must not interpret a request as a revision expression or +// a batch frame. Ref names contain none of these bytes, so they can be +// refused before git starts. An ordinary name still reaches git. +func TestResolveRefRevisionSyntaxSkipsGit(t *testing.T) { + marker := filepath.Join(t.TempDir(), "ran") + fakeGit(t, "echo x >> '"+marker+"'\nexit 1") + for _, spec := range []string{"refs/r~1", "refs/r^{}", "refs/r:path", "refs/r@{0}", "refs/r..refs/t", "r?", "r*", "[r]", "r\\x", "refs/r\nmain", "refs/r\x01", "refs/r\x7f", "refs/r "} { + if _, err := ResolveRef(context.Background(), t.TempDir(), spec, false); !errors.Is(err, ErrNotFound) { + t.Errorf("ResolveRef(%q): %v, want ErrNotFound", spec, err) + } + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("git started for a revision operator or control byte: %v", err) + } + if _, err := ResolveRef(context.Background(), t.TempDir(), "refs/r", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("ordinary name: %v, want ErrNotFound from git", err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("ordinary name did not reach git: %v", err) + } +} diff --git a/internal/gitread/tree_resume.go b/internal/gitread/tree_resume.go index 9cbd1a44..5866cc6b 100644 --- a/internal/gitread/tree_resume.go +++ b/internal/gitread/tree_resume.go @@ -833,10 +833,11 @@ func (s *treeSource) close(abort bool) error { // leaves the reader at the object's first byte: its type and size. func (s *treeSource) request(rev string) (objType string, size int64, err error) { if _, err := io.WriteString(s.req, rev+"\n"); err != nil { - // The write fails only once git is gone. A git that failed says - // why. One that ended cleanly without reading the request gave - // no answer, as when its output ends first (readErr): which of - // the two the walk sees depends on how soon git ends. + // The write fails once git is gone, or after close, which ends + // git too. A git that failed says why. One that ended cleanly + // without reading the request gave no answer, as when its output + // ends first (readErr): which of the two the walk sees depends on + // how soon git ends. if runErr := s.wait(); runErr != nil { return "", 0, runErr } diff --git a/internal/gitread/tree_resume_fail_test.go b/internal/gitread/tree_resume_fail_test.go index 5a2d4b49..200adab9 100644 --- a/internal/gitread/tree_resume_fail_test.go +++ b/internal/gitread/tree_resume_fail_test.go @@ -84,23 +84,41 @@ func TestLsTreePageCursorWhenTheWalksGitFails(t *testing.T) { // gives git's error, and one that ended cleanly declines, as when its output // ends before the answer. A walk sees one or the other, the failed write or // the end of the output, depending on how soon the child ends (#764's merge -// group run saw the write's broken pipe). +// group run saw the write's broken pipe). Each case writes after git ended, +// so the write fails every time: a heap cap, a crash and a canceled request +// stay errors there too. func TestTreeSourceRequestAfterGitEnded(t *testing.T) { dir, commit := fiRepo(t, nestedFiles()) for name, c := range map[string]struct { - script string - want func(error) bool + script string + budgeted bool + cancel bool + want func(error) bool }{ - "silent end": {`exit 0`, func(err error) bool { return errors.Is(err, errDeclined) }}, - "exit": {`echo "fatal: boom" >&2; exit 3`, func(err error) bool { return err != nil && !errors.Is(err, errDeclined) }}, + "silent end": {`exit 0`, false, false, func(err error) bool { return errors.Is(err, errDeclined) }}, + "exit": {`echo "fatal: boom" >&2; exit 3`, false, false, func(err error) bool { return err != nil && !errors.Is(err, errDeclined) }}, + "heap cap": {`echo "fatal: Out of memory, malloc failed (tried to allocate 1 bytes)" >&2; exit 128`, true, false, func(err error) bool { return errors.Is(err, ErrMemoryLimit) }}, + "crash": {`kill -SEGV $$`, false, false, func(err error) bool { return err != nil && !errors.Is(err, errDeclined) }}, + "canceled": {`exec sleep 30`, false, true, func(err error) bool { return errors.Is(err, context.Canceled) }}, } { t.Run(name, func(t *testing.T) { + if c.budgeted && !HeapLimitSupported() { + t.Skip("heap cap is Linux-only") + } fakeGit(t, c.script) - src, err := startTreeSource(context.Background(), dir) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if c.budgeted { + ctx, _ = budgeted(t, 1<<20) + } + src, err := startTreeSource(ctx, dir) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = src.close(true) }) + if c.cancel { + cancel() + } _ = src.wait() // git is gone, and so is the reader of its request pipe if _, _, err := src.request(commit); !c.want(err) { t.Fatalf("request after git ended = %v", err)