package git import ( "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) func TestBlobReaderReadsLooseAndPackedObjects(t *testing.T) { for _, packed := range []bool{false, true} { name := "loose" if packed { name = "packed" } t.Run(name, func(t *testing.T) { fixture := newRemoteFixture(t) binary := []byte{0, '\n', 0xff, 'b', '\n', 0} writeRepoFile(t, fixture.work, "empty.dat", "") writeRepoFile(t, fixture.work, "binary.dat", string(binary)) gitRun(t, fixture.work, "add", ".") gitRun(t, fixture.work, "commit", "-m", "add binary fixtures") want := map[string][]byte{ gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"): []byte("candidate"), gitText(t, fixture.work, "rev-parse", "HEAD:empty.dat"): {}, gitText(t, fixture.work, "rev-parse", "HEAD:binary.dat"): binary, } if packed { gitRun(t, fixture.work, "gc", "--aggressive", "--prune=now") } reader := NewBlobReader(t.Context(), fixture.work) defer reader.Close() for repeat := 0; repeat < 2; repeat++ { for objectID, expected := range want { got, err := reader.Read(t.Context(), objectID) if err != nil { t.Fatalf("Read(%s) error = %v", objectID, err) } if !bytes.Equal(got, expected) { t.Errorf("Read(%s) = %v, want %v", objectID, got, expected) } } } }) } } func TestBlobReaderMissingObjectKeepsBatchSessionUsable(t *testing.T) { fixture := newRemoteFixture(t) objectID := gitText(t, fixture.work, "rev-parse", "HEAD:source.txt") reader := NewBlobReader(t.Context(), fixture.work) defer reader.Close() missing := strings.Repeat("0", 40) if _, err := reader.Read(t.Context(), missing); err == nil || !strings.Contains(err.Error(), "missing") { t.Fatalf("Read(missing) error = %v, want contextual missing-object error", err) } contents, err := reader.Read(t.Context(), objectID) if err != nil || string(contents) != "candidate" { t.Fatalf("Read(existing) = %q, %v after missing request", contents, err) } } func TestBlobReaderPreservesHexCaseAndArbitraryBodyBytes(t *testing.T) { installFakeGit(t, `while IFS= read -r object; do upper=$(printf '%s' "$object" | tr 'a-f' 'A-F') printf '%s blob 4\n' "$upper" printf 'a\000b\n\n' done`) objectID := strings.Repeat("abcdef0123", 4) reader := NewBlobReader(t.Context(), t.TempDir()) contents, err := reader.Read(t.Context(), objectID) if err != nil { t.Fatal(err) } if want := []byte{'a', 0, 'b', '\n'}; !bytes.Equal(contents, want) { t.Fatalf("Read() = %v, want %v", contents, want) } if err := reader.Close(); err != nil { t.Fatal(err) } } func TestBlobReaderRejectsInvalidHeadersAndFraming(t *testing.T) { for _, testCase := range []struct { name string response string want string }{ {name: "wrong object", response: `printf '%040d blob 0\n\n' 0`, want: "does not match request"}, {name: "wrong type", response: `printf '%s tree 0\n\n' "$object"`, want: `has type "tree"`}, {name: "invalid size", response: `printf '%s blob -1\n' "$object"`, want: "invalid Git blob size"}, {name: "invalid framing", response: `printf '%s blob 0\nx' "$object"`, want: "invalid Git blob framing"}, } { t.Run(testCase.name, func(t *testing.T) { installFakeGit(t, "IFS= read -r object\n"+testCase.response) reader := NewBlobReader(t.Context(), t.TempDir()) _, err := reader.Read(t.Context(), strings.Repeat("d", 40)) if err == nil || !strings.Contains(err.Error(), testCase.want) { t.Fatalf("Read() error = %v, want %q", err, testCase.want) } if closeErr := reader.Close(); !strings.Contains(closeErr.Error(), testCase.want) { t.Fatalf("Close() error = %v, want original protocol error", closeErr) } }) } } func TestBlobReaderTruncatedBodyAbortsAndKeepsOriginalCause(t *testing.T) { installFakeGit(t, `IFS= read -r object printf '%s blob 8\n' "$object" printf 'short' exit 0`) reader := NewBlobReader(t.Context(), t.TempDir()) _, err := reader.Read(t.Context(), strings.Repeat("a", 40)) if !errors.Is(err, io.ErrUnexpectedEOF) { t.Fatalf("Read() error = %v, want truncated-body cause", err) } if closeErr := reader.Close(); !errors.Is(closeErr, io.ErrUnexpectedEOF) { t.Fatalf("Close() error = %v, want original read cause", closeErr) } } func TestBlobReaderCancellationDuringBodyReadReapsChild(t *testing.T) { ready := filepath.Join(t.TempDir(), "ready") installFakeGit(t, `IFS= read -r object printf '%s blob 1048576\npartial' "$object" : > "$RELEASEKIT_GIT_READER_READY" exec sleep 60`) t.Setenv("RELEASEKIT_GIT_READER_READY", ready) ctx, cancel := context.WithCancel(t.Context()) reader := NewBlobReader(ctx, t.TempDir()) result := make(chan error, 1) go func() { _, err := reader.Read(ctx, strings.Repeat("b", 40)) result <- err }() waitForFile(t, ready) cancel() select { case err := <-result: if !errors.Is(err, context.Canceled) { t.Fatalf("Read() error = %v, want context cancellation", err) } case <-time.After(5 * time.Second): t.Fatal("Read() did not return after cancellation") } if err := reader.Close(); !errors.Is(err, context.Canceled) { t.Fatalf("Close() error = %v, want original cancellation cause", err) } } func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) { installFakeGit(t, `printf 'fixture Git failure\n' >&2 exit 23`) reader := NewBlobReader(t.Context(), t.TempDir()) _, err := reader.Read(t.Context(), strings.Repeat("c", 40)) var exitErr *exec.ExitError if !errors.As(err, &exitErr) || exitErr.ExitCode() != 23 { t.Fatalf("Read() error = %v, want Git exit code 23", err) } if !strings.Contains(err.Error(), "fixture Git failure") { t.Fatalf("Read() error = %v, want captured Git stderr", err) } } func installFakeGit(t *testing.T, script string) { t.Helper() directory := t.TempDir() path := filepath.Join(directory, "git") if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o700); err != nil { t.Fatal(err) } t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) } func waitForFile(t *testing.T, path string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if _, err := os.Stat(path); err == nil { return } else if !errors.Is(err, os.ErrNotExist) { t.Fatal(fmt.Errorf("inspect readiness signal: %w", err)) } time.Sleep(5 * time.Millisecond) } t.Fatal("child did not create readiness signal") }