Files
trufflehog/pkg/sources/git/git_test.go
Andoni AlonsoandShahzad Haider 6f5220357f fix: add git worktree support in PrepareRepo (#4690)
* fix: add git worktree support for local file:// scanning

Fixes #4553

The issue was introduced in v3.90.10 by PR #4502 which added local git
config sanitization. When scanning a local file:// repository, the code
copies the git index file to support staged changes scanning. However,
it assumed .git is always a directory.

In git worktrees, .git is a file containing a "gitdir: <path>" reference
to the actual git directory location. This caused the error:
"failed to read index file: open .../.git/index: not a directory"

This commit adds a resolveGitDir() helper function that:
- Checks if .git is a file or directory
- If it's a file (worktree), parses the gitdir reference
- Returns the resolved path to the actual git directory

The PrepareRepo() function now uses this helper to correctly locate
the index file in both regular repositories and worktrees.

* fix: handle errcheck lint warning in git test

Explicitly discard the error return value from os.RemoveAll in deferred
cleanup to satisfy the errcheck linter.

* fix(git): add object store alternates for staged change visibility

After copying the index file from the source repo (or worktree) into
the clone, staged blobs are unreachable because git clone with file://
only transfers objects reachable from references. Add the source
object store as a git alternates entry so staged objects can be read.

For git worktrees the actual object store lives in the main repo; the
worktree-specific git dir carries a commondir file pointing there, so
read that to locate the correct objects path.

Also tighten TestPrepareRepoWithWorktree to unconditionally assert that
git diff --cached succeeds and returns the expected staged content,
replacing the silent guard that allowed the assertion to be skipped
entirely when git failed. Add commit.gpgsign=false to setupTestRepo
so test commits work in environments with global GPG signing enabled.

---------

Co-authored-by: Shahzad Haider <[email protected]>
2026-06-23 12:30:35 +05:00

1317 lines
41 KiB
Go

package git
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/kylelemons/godebug/pretty"
"github.com/stretchr/testify/assert"
"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
"google.golang.org/protobuf/types/known/anypb"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/credentialspb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
"github.com/trufflesecurity/trufflehog/v3/pkg/process"
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
"github.com/trufflesecurity/trufflehog/v3/pkg/sourcestest"
)
func TestClone_Timeout(t *testing.T) {
ctx := context.Background()
t.Run("an unset timeout should not cause a timeout", func(t *testing.T) {
_, _, err := CloneRepo(
ctx,
nil,
"https://github.com/dustin-decker/secretsandstuff.git",
"",
false)
// There shouldn't be an error - but if there is because of some other problem, we don't want this test to fail
if err != nil {
assert.NotContains(t, err.Error(), "timed out")
}
})
t.Run("a clone that times out should time out", func(t *testing.T) {
feature.GitCloneTimeoutDuration.Store(int64(1 * time.Nanosecond))
t.Cleanup(func() { feature.GitCloneTimeoutDuration.Store(0) })
_, _, err := CloneRepo(
ctx,
nil,
"https://github.com/dustin-decker/secretsandstuff.git",
"",
false)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "timed out")
}
})
}
func TestSource_Scan(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
basicUser := secret.MustGetField("GITLAB_USER")
basicPass := secret.MustGetField("GITLAB_PASS")
type init struct {
name string
verify bool
connection *sourcespb.Git
concurrency int
}
tests := []struct {
name string
init init
wantChunk *sources.Chunk
wantErr bool
}{
{
name: "local repo",
init: init{
name: "this repo",
connection: &sourcespb.Git{
Directories: []string{"../../../"},
Credential: &sourcespb.Git_Unauthenticated{
Unauthenticated: &credentialspb.Unauthenticated{},
},
},
concurrency: 4,
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GIT,
SourceName: "this repo",
SourceVerify: false,
},
wantErr: false,
},
{
name: "remote repo, unauthenticated",
init: init{
name: "test source",
connection: &sourcespb.Git{
Repositories: []string{"https://github.com/dustin-decker/secretsandstuff.git"},
Credential: &sourcespb.Git_Unauthenticated{
Unauthenticated: &credentialspb.Unauthenticated{},
},
},
concurrency: 4,
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GIT,
SourceName: "test source",
SourceVerify: false,
},
wantErr: false,
},
{
name: "remote repo, unauthenticated, concurrency 0",
init: init{
name: "test source",
connection: &sourcespb.Git{
Repositories: []string{"https://github.com/dustin-decker/secretsandstuff.git"},
Credential: &sourcespb.Git_Unauthenticated{
Unauthenticated: &credentialspb.Unauthenticated{},
},
},
concurrency: 0,
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GIT,
SourceName: "test source",
SourceVerify: false,
},
wantErr: false,
},
{
name: "remote repo, basic auth",
init: init{
name: "test source",
connection: &sourcespb.Git{
Repositories: []string{"https://github.com/dustin-decker/secretsandstuff.git"},
Credential: &sourcespb.Git_BasicAuth{
BasicAuth: &credentialspb.BasicAuth{
Username: basicUser,
Password: basicPass,
},
},
},
concurrency: 4,
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GIT,
SourceName: "test source",
SourceVerify: false,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Source{}
conn, err := anypb.New(tt.init.connection)
if err != nil {
t.Fatal(err)
}
err = s.Init(ctx, tt.init.name, 0, 0, tt.init.verify, conn, tt.init.concurrency)
if (err != nil) != tt.wantErr {
t.Errorf("Source.Init() error = %v, wantErr %v", err, tt.wantErr)
return
}
chunksCh := make(chan *sources.Chunk, 1)
go func() {
assert.NoError(t, s.Chunks(ctx, chunksCh))
}()
gotChunk := <-chunksCh
gotChunk.Data = nil
// Commits don't come in a deterministic order, so remove metadata comparison
gotChunk.SourceMetadata = nil
if diff := pretty.Compare(gotChunk, tt.wantChunk); diff != "" {
t.Errorf("Source.Chunks() %s diff: (-got +want)\n%s", tt.name, diff)
t.Errorf("Data: %s", string(gotChunk.Data))
}
})
}
}
// We ran into an issue where upgrading a dependency caused the git patch chunking to break
// So this test exists to make sure that when something changes, we know about it.
func TestSource_Chunks_Integration(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
type init struct {
name string
verify bool
connection *sourcespb.Git
}
type byteCompare struct {
B []byte
Found bool
Multi bool
}
tests := []struct {
name string
init init
// verified
repoURL string
expectedChunkData map[string]*byteCompare
scanOptions ScanOptions
}{
{
name: "remote repo, unauthenticated",
repoURL: "https://github.com/dustin-decker/secretsandstuff.git",
expectedChunkData: map[string]*byteCompare{
"70001020fab32b1fcf2f1f0e5c66424eae649826-": {B: []byte("Dustin Decker <[email protected]>\nGitHub <[email protected]>\nUpdate aws\n")},
"70001020fab32b1fcf2f1f0e5c66424eae649826-aws": {B: []byte("[default]\naws_access_key_id = AKIAXYZDQCEN4B6JSJQI\naws_secret_access_key = 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\noutput = json\nregion = us-east-2\n")},
"a6f8aa55736d4a85be31a0048a4607396898647a-": {B: []byte("Dustin Decker <[email protected]>\nGitHub <[email protected]>\nUpdate bump\n")},
"a6f8aa55736d4a85be31a0048a4607396898647a-bump": {B: []byte("\n\nf\n")},
"73ab4713057944753f1bdeb80e757380e64c6b5b-": {B: []byte("Dustin <[email protected]>\nDustin <[email protected]>\nbump\n")},
"73ab4713057944753f1bdeb80e757380e64c6b5b-bump": {B: []byte(" s \n\n")},
"2f251b8c1e72135a375b659951097ec7749d4af9-": {B: []byte("Dustin <[email protected]>\nDustin <[email protected]>\nbump\n")},
"2f251b8c1e72135a375b659951097ec7749d4af9-bump": {B: []byte(" \n\n")},
"e6c8bbabd8796ea3cd85bfc2e55b27e0a491747f-": {B: []byte("Dustin Decker <[email protected]>\nGitHub <[email protected]>\nUpdate bump\n")},
"e6c8bbabd8796ea3cd85bfc2e55b27e0a491747f-bump": {B: []byte("\noops \n")},
"735b52b0eb40610002bb1088e902bd61824eb305-": {B: []byte("Dustin Decker <[email protected]>\nGitHub <[email protected]>\nUpdate bump\n")},
"735b52b0eb40610002bb1088e902bd61824eb305-bump": {B: []byte("\noops\n")},
"ce62d79908803153ef6e145e042d3e80488ef747-": {B: []byte("Dustin Decker <[email protected]>\nGitHub <[email protected]>\nCreate bump\n")},
"ce62d79908803153ef6e145e042d3e80488ef747-bump": {B: []byte("\n")},
// Normally we might expect to see this commit, and we may in the future.
// But at the moment we're ignoring any commit unless it contains at least one non-space character.
"27fbead3bf883cdb7de9d7825ed401f28f9398f1-": {B: []byte("Dustin <[email protected]>\nDustin <[email protected]>\noops\n")},
"27fbead3bf883cdb7de9d7825ed401f28f9398f1-slack": {B: []byte("\n\n\nyup, just did that\n\ngithub_lol: \"ffc7e0f9400fb6300167009e42d2f842cd7956e2\"\n\noh, goodness. there's another one!\n")},
"8afb0ecd4998b1179e428db5ebbcdc8221214432-": {B: []byte("Dustin <[email protected]>\nDustin <[email protected]>\nadd slack token\n")},
"8afb0ecd4998b1179e428db5ebbcdc8221214432-slack": {B: []byte("oops might drop a slack token here\n\ngithub_secret=\"369963c1434c377428ca8531fbc46c0c43d037a0\"\n\nyup, just did that\n"), Multi: true},
"8fe6f04ef1839e3fc54b5147e3d0e0b7ab971bd5-": {B: []byte("Dustin <[email protected]>\nDustin <[email protected]>\noops, accidently commited AWS token...\n")}, //nolint:misspell
"8fe6f04ef1839e3fc54b5147e3d0e0b7ab971bd5-aws": {B: []byte("blah blaj\n\nthis is the secret: AKIA2E0A8F3B244C9986\n\nokay thank you bye\n"), Multi: true},
"84e9c75e388ae3e866e121087ea2dd45a71068f2-": {B: []byte("Dylan Ayrey <[email protected]>\nGitHub <[email protected]>\nUpdate aws\n")},
"84e9c75e388ae3e866e121087ea2dd45a71068f2-aws": {B: []byte("\n\nthis is the secret: [Default]\nAccess key Id: AKIAILE3JG6KMS3HZGCA\nSecret Access Key: 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\n\nokay thank you bye\n"), Multi: false},
},
},
{
name: "remote repo, limited",
repoURL: "https://github.com/dustin-decker/secretsandstuff.git",
expectedChunkData: map[string]*byteCompare{
"70001020fab32b1fcf2f1f0e5c66424eae649826-": {B: []byte("Dustin Decker <[email protected]>\nGitHub <[email protected]>\nUpdate aws\n")},
"70001020fab32b1fcf2f1f0e5c66424eae649826-aws": {B: []byte("[default]\naws_access_key_id = AKIAXYZDQCEN4B6JSJQI\naws_secret_access_key = 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\noutput = json\nregion = us-east-2\n")},
},
scanOptions: ScanOptions{
HeadHash: "70001020fab32b1fcf2f1f0e5c66424eae649826",
BaseHash: "a6f8aa55736d4a85be31a0048a4607396898647a",
},
},
{
name: "remote repo, main ahead of branch",
repoURL: "https://github.com/bill-rich/bad-secrets.git",
expectedChunkData: map[string]*byteCompare{
"547865c6cc0da46622306902b1b66f7e25dd0412-": {B: []byte("bill-rich <[email protected]>\nbill-rich <[email protected]>\nAdd some_branch_file\n")},
"547865c6cc0da46622306902b1b66f7e25dd0412-some_branch_file": {B: []byte("[default]\naws_access_key=AKIAYVP4CIPPH5TNP3SW\naws_secret_access_key=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\nregion=us-east-2\noutput=json\n\n#addibng a comment\n")},
},
scanOptions: ScanOptions{
HeadHash: "some_branch",
BaseHash: "master",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Source{}
beforeProcesses := process.GetGitProcessList()
conn, err := anypb.New(tt.init.connection)
if err != nil {
t.Fatal(err)
}
err = s.Init(ctx, tt.init.name, 0, 0, tt.init.verify, conn, 4)
if err != nil {
t.Fatal(err)
}
chunksCh := make(chan *sources.Chunk, 1)
go func() {
defer close(chunksCh)
repoPath, repo, err := CloneRepoUsingUnauthenticated(ctx, tt.repoURL, "")
if err != nil {
panic(err)
}
err = s.git.ScanRepo(ctx, repo, repoPath, &tt.scanOptions, sources.ChanReporter{Ch: chunksCh})
if err != nil {
panic(err)
}
}()
for chunk := range chunksCh {
key := ""
switch meta := chunk.SourceMetadata.GetData().(type) {
case *source_metadatapb.MetaData_Git:
key = strings.TrimRight(meta.Git.Commit+"-"+meta.Git.File, "\n")
}
if expectedData, exists := tt.expectedChunkData[key]; !exists {
t.Errorf("A chunk exists that was not expected with key %q", key)
} else {
if bytes.Equal(chunk.Data, expectedData.B) {
(*tt.expectedChunkData[key]).Found = true
} else if !expectedData.Multi {
t.Errorf("Got %q: %q, which was not expected", key, string(chunk.Data))
}
}
}
for key, expected := range tt.expectedChunkData {
if !expected.Found {
t.Errorf("Expected data with key %q not found", key)
}
}
afterProcesses := process.GetGitProcessList()
zombies := process.DetectGitZombies(beforeProcesses, afterProcesses)
if len(zombies) > 0 {
t.Errorf("Git zombies detected: %v", zombies)
}
})
}
}
func TestSource_Chunks_Edge_Cases(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
basicUser := secret.MustGetField("GITLAB_USER")
basicPass := secret.MustGetField("GITLAB_PASS")
type init struct {
name string
verify bool
connection *sourcespb.Git
}
tests := []struct {
name string
init init
wantErr string
}{
{
name: "empty repo",
init: init{
name: "test source",
connection: &sourcespb.Git{
Repositories: []string{"https://github.com/git-fixtures/empty.git"},
Credential: &sourcespb.Git_Unauthenticated{
Unauthenticated: &credentialspb.Unauthenticated{},
},
},
},
wantErr: "remote",
},
{
name: "no repo",
init: init{
name: "test source",
connection: &sourcespb.Git{
Repositories: []string{""},
Credential: &sourcespb.Git_Unauthenticated{
Unauthenticated: &credentialspb.Unauthenticated{},
},
},
},
wantErr: "remote",
},
{
name: "no repo, basic auth",
init: init{
name: "test source",
connection: &sourcespb.Git{
Repositories: []string{""},
Credential: &sourcespb.Git_BasicAuth{
BasicAuth: &credentialspb.BasicAuth{
Username: basicUser,
Password: basicPass,
},
},
},
},
wantErr: "remote",
},
{
name: "symlinks repo",
init: init{
name: "test source",
connection: &sourcespb.Git{
Repositories: []string{"https://github.com/git-fixtures/symlinks.git"},
Credential: &sourcespb.Git_Unauthenticated{
Unauthenticated: &credentialspb.Unauthenticated{},
},
},
},
},
{
name: "submodule repo",
init: init{
name: "test source",
connection: &sourcespb.Git{
Repositories: []string{"https://github.com/git-fixtures/submodule.git"},
Credential: &sourcespb.Git_Unauthenticated{
Unauthenticated: &credentialspb.Unauthenticated{},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Source{}
conn, err := anypb.New(tt.init.connection)
if err != nil {
t.Fatal(err)
}
err = s.Init(ctx, tt.init.name, 0, 0, tt.init.verify, conn, 4)
if err != nil {
t.Errorf("Source.Init() error = %v", err)
return
}
chunksCh := make(chan *sources.Chunk, 1)
go func() {
for chunk := range chunksCh {
chunk.Data = nil
}
}()
if err := s.Chunks(ctx, chunksCh); err != nil && !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("Source.Chunks() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestPrepareRepo(t *testing.T) {
tests := []struct {
uri string
path bool
remote bool
err error
}{
{
uri: "https://github.com/dustin-decker/secretsandstuff.git",
path: true,
remote: true,
err: nil,
},
{
uri: "http://github.com/dustin-decker/secretsandstuff.git",
path: true,
remote: true,
err: nil,
},
{
uri: "file:///path/to/file.json",
path: true,
remote: false,
err: nil,
// Note: If we set trustLocalGitConfig to false (below), we will get an error for this test
// b/c it's not a valid git repo and we will try to clone it.
},
{
uri: "no bueno",
path: false,
remote: false,
err: fmt.Errorf("unsupported Git URI: no bueno"),
},
}
for _, tt := range tests {
ctx := context.Background()
repo, b, err := PrepareRepo(ctx, tt.uri, "", true, false)
var repoLen bool
if len(repo) > 0 {
repoLen = true
} else {
repoLen = false
}
if repoLen != tt.path || b != tt.remote {
t.Errorf("PrepareRepo(%v) got: %v, %v, %v want: %v, %v, %v", tt.uri, repo, b, err, tt.path, tt.remote, tt.err)
}
}
}
func BenchmarkPrepareRepo(b *testing.B) {
uri := "https://github.com/dustin-decker/secretsandstuff.git"
ctx := context.Background()
for i := 0; i < b.N; i++ {
_, _, _ = PrepareRepo(ctx, uri, "", false, false)
}
}
func TestGitURLParse(t *testing.T) {
for _, tt := range []struct {
url string
host string
user string
password string
port string
path string
scheme string
}{
{
"https://[email protected]/org/repo",
"github.com",
"user",
"",
"",
"/org/repo",
"https",
},
{
"https://user:[email protected]/org/repo",
"github.com",
"user",
"pass",
"",
"/org/repo",
"https",
},
{
"ssh://[email protected]/org/repo",
"github.com",
"user",
"",
"",
"/org/repo",
"ssh",
},
{
"[email protected]:org/repo",
"github.com",
"user",
"",
"",
"/org/repo",
"ssh",
},
} {
u, err := GitURLParse(tt.url)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, tt.host, u.Host)
assert.Equal(t, tt.user, u.User.Username())
password, _ := u.User.Password()
assert.Equal(t, tt.password, password)
assert.Equal(t, tt.port, u.Port())
assert.Equal(t, tt.path, u.Path)
assert.Equal(t, tt.scheme, u.Scheme)
}
}
func TestEnumerate(t *testing.T) {
t.Parallel()
ctx := context.Background()
// Setup the connection to test enumeration.
units := []string{
"foo", "bar", "baz",
"/path/to/dir/", "/path/to/another/dir/",
}
conn, err := anypb.New(&sourcespb.Git{
Repositories: units[0:3],
Directories: units[3:],
})
assert.NoError(t, err)
// Initialize the source.
s := Source{}
err = s.Init(ctx, "test enumerate", 0, 0, true, conn, 1)
assert.NoError(t, err)
reporter := sourcestest.TestReporter{}
err = s.Enumerate(ctx, &reporter)
assert.NoError(t, err)
assert.Equal(t, len(units), len(reporter.Units))
assert.Equal(t, 0, len(reporter.UnitErrs))
for _, unit := range reporter.Units {
id, _ := unit.SourceUnitID()
assert.Contains(t, units, id)
}
for _, unit := range units[:3] {
assert.Contains(t, reporter.Units, SourceUnit{ID: unit, Kind: UnitRepo})
}
for _, unit := range units[3:] {
assert.Contains(t, reporter.Units, SourceUnit{ID: unit, Kind: UnitDir})
}
}
func TestChunkUnit(t *testing.T) {
t.Skip("flaky - INS-212")
t.Parallel()
ctx := context.Background()
// Initialize the source.
s := Source{}
conn, err := anypb.New(&sourcespb.Git{
Credential: &sourcespb.Git_Unauthenticated{},
})
assert.NoError(t, err)
err = s.Init(ctx, "test chunk", 0, 0, true, conn, 1)
assert.NoError(t, err)
reporter := sourcestest.TestReporter{}
// Happy path single repository.
err = s.ChunkUnit(ctx, SourceUnit{
ID: "https://github.com/dustin-decker/secretsandstuff.git",
Kind: UnitRepo,
}, &reporter)
assert.NoError(t, err)
// Error path - should return fatal error for missing directory.
err = s.ChunkUnit(ctx, SourceUnit{
ID: "/file/not/found",
Kind: UnitDir,
}, &reporter)
assert.Error(t, err)
assert.Contains(t, err.Error(), "directory does not exist")
assert.Equal(t, 22, len(reporter.Chunks))
assert.Equal(t, 0, len(reporter.ChunkErrs))
}
func setupTestRepo(t *testing.T, repoName string) string {
tempDir := t.TempDir()
repoPath := filepath.Join(tempDir, repoName)
assert.NoError(t, exec.Command("git", "init", repoPath).Run())
assert.NoError(t, exec.Command("git", "-C", repoPath, "config", "user.name", "Test User").Run())
assert.NoError(t, exec.Command("git", "-C", repoPath, "config", "user.email", "[email protected]").Run())
assert.NoError(t, exec.Command("git", "-C", repoPath, "config", "commit.gpgsign", "false").Run())
return repoPath
}
func addTestFileAndCommit(t *testing.T, repoPath, filename, content string) {
testFile := filepath.Join(repoPath, filename)
assert.NoError(t, os.WriteFile(testFile, []byte(content), 0644))
assert.NoError(t, exec.Command("git", "-C", repoPath, "add", filename).Run())
assert.NoError(t, exec.Command("git", "-C", repoPath, "commit", "-m", "Test commit").Run())
}
func addMaliciousGitConfig(t *testing.T, repoPath string) {
assert.NoError(t, exec.Command("git", "-C", repoPath, "config", "alias.test", "!touch malicious_alias.txt").Run())
}
func testPrepareRepoSanitization(t *testing.T, repoPath string, trustLocalGitConfig, isBare bool) {
ctx := context.Background()
fileURI := "file://" + repoPath
preparedPath, isRemote, err := PrepareRepo(ctx, fileURI, "", trustLocalGitConfig, isBare)
assert.NoError(t, err)
assert.False(t, isRemote, "Local file URI should not be considered remote")
if !trustLocalGitConfig {
assert.NotEqual(t, repoPath, preparedPath, "Sanitized repo path should be different from original")
_, err := os.Stat(preparedPath)
assert.NoError(t, err, "Cloned repository should exist")
if isBare {
_, err = os.Stat(filepath.Join(preparedPath, "refs"))
assert.NoError(t, err, "Bare repo should have refs directory")
_, err = os.Stat(filepath.Join(preparedPath, "HEAD"))
assert.NoError(t, err, "Bare repo should have HEAD file")
_, err = os.Stat(filepath.Join(preparedPath, ".git"))
assert.True(t, os.IsNotExist(err), "Bare repo should not have .git subdirectory")
} else {
_, err = exec.Command("git", "-C", preparedPath, "status").Output()
assert.NoError(t, err, "Cloned repo should be a valid git repository")
}
} else {
assert.Equal(t, repoPath, preparedPath, "Trusted repo should use original path")
output, err := exec.Command("git", "-C", preparedPath, "config", "--get", "alias.test").Output()
assert.NoError(t, err, "Malicious git config should be present")
assert.Equal(t, "!touch malicious_alias.txt", strings.TrimSpace(string(output)), "Malicious git config should be present")
}
}
func TestGitConfigSanitization(t *testing.T) {
t.Parallel()
repoPath := setupTestRepo(t, "test-repo")
addMaliciousGitConfig(t, repoPath)
addTestFileAndCommit(t, repoPath, "test.txt", "test content\nsecret: AKIA1234567890123456")
tests := []struct {
name string
trustLocalGitConfig bool
}{
{"sanitization enabled - should clone", false},
{"sanitization disabled - direct access", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testPrepareRepoSanitization(t, repoPath, tt.trustLocalGitConfig, false)
})
}
}
func TestGitConfigSanitizationWithBareRepo(t *testing.T) {
t.Parallel()
repoPath := setupTestRepo(t, "working-repo-original")
addTestFileAndCommit(t, repoPath, "test.txt", "test content\nsecret: AKIA1234567890123456")
tempDir := t.TempDir()
bareRepoPath := filepath.Join(tempDir, "working-repo")
assert.NoError(t, exec.Command("git", "clone", repoPath, bareRepoPath, "--bare").Run())
addMaliciousGitConfig(t, bareRepoPath)
tests := []struct {
name string
trustLocalGitConfig bool
isBare bool
}{
{"sanitization enabled - should clone bare repo", false, true},
{"sanitization disabled - direct access of bare repo", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testPrepareRepoSanitization(t, bareRepoPath, tt.trustLocalGitConfig, tt.isBare)
})
}
}
func TestGitConfigSecurityIsolation(t *testing.T) {
ctx := context.Background()
maliciousRepoPath := setupTestRepo(t, "malicious-repo")
addTestFileAndCommit(t, maliciousRepoPath, "test.txt", "test content")
addMaliciousGitConfig(t, maliciousRepoPath)
tests := []struct {
name string
trustLocalGitConfig bool
}{
{"sanitized repo should be isolated from malicious config", false},
{"trusted repo should use original path with all configs", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fileURI := "file://" + maliciousRepoPath
preparedPath, isRemote, err := PrepareRepo(ctx, fileURI, "", tt.trustLocalGitConfig, false)
assert.NoError(t, err)
assert.False(t, isRemote)
maliciousFilePath := filepath.Join(preparedPath, "malicious_alias.txt")
if tt.trustLocalGitConfig {
assert.Equal(t, maliciousRepoPath, preparedPath, "Trusted repo should use original path")
output, err := exec.Command("git", "-C", preparedPath, "config", "--get", "alias.test").Output()
assert.NoError(t, err, "Config alias.test should exist in trusted (original) repo")
assert.Equal(t, "!touch malicious_alias.txt", strings.TrimSpace(string(output)),
"Config alias.test should have original dangerous value")
err = exec.Command("git", "-C", preparedPath, "test").Run()
assert.NoError(t, err)
_, err = os.Stat(maliciousFilePath)
assert.False(t, os.IsNotExist(err), "Malicious file malicious_alias.txt should exist in trusted environment")
} else {
assert.NotEqual(t, maliciousRepoPath, preparedPath, "Sanitized repo should be different from original")
_, err = os.Stat(preparedPath)
assert.NoError(t, err, "Sanitized repository should exist")
_, err = exec.Command("git", "-C", preparedPath, "status").Output()
assert.NoError(t, err, "Sanitized repo should be a valid git repository")
err = exec.Command("git", "-C", preparedPath, "test").Run()
assert.Error(t, err)
_, err = os.Stat(maliciousFilePath)
assert.True(t, os.IsNotExist(err), "Malicious file malicious_alias.txt should not exist in sanitized environment")
}
// if the file exists, remove it
if _, err := os.Stat(maliciousFilePath); err == nil {
assert.NoError(t, os.Remove(maliciousFilePath))
}
})
}
}
func TestGitConfigSanitizationWithStagedChanges(t *testing.T) {
t.Parallel()
ctx := context.Background()
repoPath := setupTestRepo(t, "staged-changes-repo")
addMaliciousGitConfig(t, repoPath)
addTestFileAndCommit(t, repoPath, "test.txt", "test content\nsecret: AKIA1234567890123456")
testFile := filepath.Join(repoPath, "test.txt")
assert.NoError(t, os.WriteFile(testFile, []byte("modified content with secret: AKIA1234567890123456"), 0644))
assert.NoError(t, exec.Command("git", "-C", repoPath, "add", "test.txt").Run())
output, err := exec.Command("git", "-C", repoPath, "diff", "--cached").Output()
assert.NoError(t, err)
assert.Contains(t, string(output), "modified content", "Staged changes should exist in original repo")
t.Run("staged changes preserved during sanitization", func(t *testing.T) {
fileURI := "file://" + repoPath
preparedPath, isRemote, err := PrepareRepo(ctx, fileURI, "", false, false) // Enable sanitization
assert.NoError(t, err)
assert.False(t, isRemote)
assert.NotEqual(t, repoPath, preparedPath, "Sanitized repo should be cloned")
stagedOutput, err := exec.Command("git", "-C", preparedPath, "diff", "--cached").Output()
if err == nil && len(stagedOutput) > 0 {
assert.Contains(t, string(stagedOutput), "modified content", "Staged changes should be preserved in cloned repo")
}
})
}
func TestPrepareRepoErrorPaths(t *testing.T) {
t.Parallel()
ctx := context.Background()
t.Run("clone failure should return error", func(t *testing.T) {
invalidFileURI := "file:///nonexistent/invalid/repo/path"
preparedPath, isRemote, err := PrepareRepo(ctx, invalidFileURI, "", false, false)
assert.Error(t, err)
assert.False(t, isRemote)
assert.Equal(t, "", preparedPath)
})
}
func TestResolveGitDir(t *testing.T) {
t.Parallel()
t.Run("regular repository with .git directory", func(t *testing.T) {
repoPath := setupTestRepo(t, "regular-repo")
addTestFileAndCommit(t, repoPath, "test.txt", "test content")
gitDir, err := resolveGitDir(repoPath)
assert.NoError(t, err)
assert.Equal(t, filepath.Join(repoPath, ".git"), gitDir)
// Verify it's actually a directory
info, err := os.Stat(gitDir)
assert.NoError(t, err)
assert.True(t, info.IsDir())
})
t.Run("git worktree with .git file", func(t *testing.T) {
// Create main repository
mainRepoPath := setupTestRepo(t, "main-repo")
addTestFileAndCommit(t, mainRepoPath, "test.txt", "test content")
// Create a worktree
worktreePath := filepath.Join(filepath.Dir(mainRepoPath), "worktree")
err := exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "worktree-branch").Run()
assert.NoError(t, err)
// Verify .git is a file in the worktree
gitPath := filepath.Join(worktreePath, ".git")
info, err := os.Stat(gitPath)
assert.NoError(t, err)
assert.False(t, info.IsDir(), ".git should be a file in a worktree")
// Test resolveGitDir
gitDir, err := resolveGitDir(worktreePath)
assert.NoError(t, err)
assert.NotEqual(t, gitPath, gitDir, "resolved git dir should be different from .git file path")
// Verify the resolved path is a valid git directory (should contain index)
indexPath := filepath.Join(gitDir, "index")
_, err = os.Stat(indexPath)
assert.NoError(t, err, "resolved git dir should contain index file")
})
t.Run("nonexistent repository", func(t *testing.T) {
_, err := resolveGitDir("/nonexistent/path")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to stat .git")
})
t.Run("invalid .git file content", func(t *testing.T) {
tempDir := t.TempDir()
gitPath := filepath.Join(tempDir, ".git")
// Create an invalid .git file (not starting with "gitdir: ")
err := os.WriteFile(gitPath, []byte("invalid content"), 0644)
assert.NoError(t, err)
_, err = resolveGitDir(tempDir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid .git file format")
})
}
func TestPrepareRepoWithWorktree(t *testing.T) {
t.Parallel()
ctx := context.Background()
// Create main repository with staged changes
mainRepoPath := setupTestRepo(t, "main-repo-worktree")
addTestFileAndCommit(t, mainRepoPath, "test.txt", "initial content")
// Create a worktree
worktreePath := filepath.Join(filepath.Dir(mainRepoPath), "test-worktree")
err := exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "worktree-branch").Run()
assert.NoError(t, err)
// Stage some changes in the worktree
testFile := filepath.Join(worktreePath, "test.txt")
assert.NoError(t, os.WriteFile(testFile, []byte("modified content in worktree"), 0644))
assert.NoError(t, exec.Command("git", "-C", worktreePath, "add", "test.txt").Run())
// Verify staged changes exist in worktree
output, err := exec.Command("git", "-C", worktreePath, "diff", "--cached").Output()
assert.NoError(t, err)
assert.Contains(t, string(output), "modified content in worktree", "Staged changes should exist in worktree")
t.Run("PrepareRepo should work with git worktree", func(t *testing.T) {
fileURI := "file://" + worktreePath
preparedPath, isRemote, err := PrepareRepo(ctx, fileURI, "", false, false)
assert.NoError(t, err, "PrepareRepo should succeed with git worktree")
assert.False(t, isRemote)
assert.NotEmpty(t, preparedPath)
defer func() { _ = os.RemoveAll(preparedPath) }()
// Verify the cloned repo has the staged changes preserved
stagedOutput, err := exec.Command("git", "-C", preparedPath, "diff", "--cached").Output()
assert.NoError(t, err, "git diff --cached should succeed in prepared repo")
assert.Contains(t, string(stagedOutput), "modified content in worktree",
"Staged changes should be preserved when cloning from worktree")
})
}
func TestNormalizeFileURI(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "absolute file URI unchanged",
input: "file:///absolute/path",
expected: "",
},
{
name: "relative file URI with current directory",
input: "file://.",
expected: "", // Will be set to current working directory
},
{
name: "relative file URI with subdirectory",
input: "file://./subdir",
expected: "", // Will be set to current working directory + subdir
},
{
name: "relative file URI with parent directory",
input: "file://..",
expected: "", // Will be set to parent of current working directory
},
{
name: "non-file URI unchanged",
input: "https://github.com/user/repo.git",
expected: "https://github.com/user/repo.git",
},
{
name: "file URI with host and path",
input: "file://hostname/path",
expected: "", // Will be set to absolute path of hostname/path
},
}
// Get current working directory for expected results
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get current working directory: %v", err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputURI, err := GitURLParse(tt.input)
if err != nil {
t.Fatalf("failed to parse input URI: %v", err)
}
result, err := normalizeFileURI(inputURI)
assert.NoError(t, err)
var expected string
switch tt.name {
case "absolute file URI unchanged":
// On Windows, absolute paths get drive letter prepended
// On Unix, they remain as-is
if runtime.GOOS == "windows" {
expectedPath, _ := filepath.Abs("/absolute/path")
expected = "file://" + expectedPath
} else {
expected = "file:///absolute/path"
}
case "relative file URI with current directory":
expected = "file://" + cwd
case "relative file URI with subdirectory":
expected = "file://" + filepath.Join(cwd, "subdir")
case "relative file URI with parent directory":
expected = "file://" + filepath.Dir(cwd)
case "file URI with host and path":
expectedPath, _ := filepath.Abs(filepath.Join("hostname", "path"))
expected = "file://" + expectedPath
default:
expected = tt.expected
}
// Normalize slashes for Windows comparison
if runtime.GOOS == "windows" {
expected = filepath.ToSlash(expected)
}
assert.Equal(t, expected, result.String())
})
}
}
func TestPrepareRepoWithNormalization(t *testing.T) {
repoPath := setupTestRepo(t, "test-repo")
addTestFileAndCommit(t, repoPath, "test.txt", "test content")
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get current directory: %v", err)
}
defer func() {
if err := os.Chdir(originalDir); err != nil {
t.Fatalf("failed to restore original directory: %v", err)
}
}()
// Test absolute paths first (without changing directory)
absoluteTests := []struct {
name string
uri string
trustLocalConfig bool
}{
{
name: "absolute file URI - no trust",
uri: "file://" + repoPath,
trustLocalConfig: false,
},
{
name: "absolute file URI - with trust",
uri: "file://" + repoPath,
trustLocalConfig: true,
},
}
for _, tt := range absoluteTests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
path, _, _ := PrepareRepo(ctx, tt.uri, "", tt.trustLocalConfig, false)
if !tt.trustLocalConfig {
assert.NotEqual(t, repoPath, path, "Cloned repo should not use original path")
cmd := exec.Command("git", "-C", path, "status")
err := cmd.Run()
assert.NoError(t, err, "Cloned repo should be a valid git repository")
} else {
assert.Equal(t, repoPath, path, "Trusted repo should use original path")
}
if path != repoPath && path != "" {
_ = os.RemoveAll(path)
}
})
}
// Now change to temp directory for relative path tests
if err := os.Chdir(repoPath); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
relativeTests := []struct {
name string
uri string
trustLocalConfig bool
}{
{
name: "relative file URI with current directory - no trust",
uri: "file://.",
trustLocalConfig: false,
},
{
name: "relative file URI with current directory - with trust",
uri: "file://.",
trustLocalConfig: true,
},
}
for _, tt := range relativeTests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
path, _, _ := PrepareRepo(ctx, tt.uri, "", tt.trustLocalConfig, false)
if !tt.trustLocalConfig {
assert.NotEqual(t, tt.uri, "file://"+path, "Cloned repo should not use original path")
cmd := exec.Command("git", "-C", path, "status")
err := cmd.Run()
assert.NoError(t, err, "Cloned repo should be a valid git repository")
} else {
assert.Equal(t, tt.uri, "file://"+path, "Trusted repo should use original path")
}
if path != repoPath && path != "" {
_ = os.RemoveAll(path)
}
})
}
}
func TestPrepareRepoWithNormalizationBare(t *testing.T) {
t.Parallel()
workingRepoPath := setupTestRepo(t, "working-repo-original")
addTestFileAndCommit(t, workingRepoPath, "test.txt", "test content")
tempDir := t.TempDir()
bareRepoPath := filepath.Join(tempDir, "bare-repo")
assert.NoError(t, exec.Command("git", "clone", workingRepoPath, bareRepoPath, "--bare").Run())
_, err := os.Stat(filepath.Join(bareRepoPath, "refs"))
assert.NoError(t, err, "Bare repository should have refs directory")
_, err = os.Stat(filepath.Join(bareRepoPath, "HEAD"))
assert.NoError(t, err, "Bare repository should have HEAD file")
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get current directory: %v", err)
}
defer func() {
if err := os.Chdir(originalDir); err != nil {
t.Fatalf("failed to restore original directory: %v", err)
}
}()
// Test absolute paths first (without changing directory)
absoluteTests := []struct {
name string
uri string
trustLocalConfig bool
}{
{
name: "absolute file URI with bare repo - no trust",
uri: "file://" + bareRepoPath,
trustLocalConfig: false,
},
{
name: "absolute file URI with bare repo - with trust",
uri: "file://" + bareRepoPath,
trustLocalConfig: true,
},
}
for _, tt := range absoluteTests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
path, isRemote, err := PrepareRepo(ctx, tt.uri, "", tt.trustLocalConfig, true)
assert.NoError(t, err, "PrepareRepo should succeed")
assert.False(t, isRemote, "File URI should not be considered remote")
if tt.trustLocalConfig {
assert.Equal(t, bareRepoPath, path, "Trusted bare repo should use original path")
_, err = os.Stat(filepath.Join(path, "refs"))
assert.NoError(t, err, "Original bare repo should have refs directory")
_, err = os.Stat(filepath.Join(path, "HEAD"))
assert.NoError(t, err, "Original bare repo should have HEAD file")
} else {
assert.NotEqual(t, bareRepoPath, path, "Sanitized bare repo path should be different from original")
_, err = os.Stat(filepath.Join(path, "refs"))
assert.NoError(t, err, "Cloned bare repo should have refs directory")
_, err = os.Stat(filepath.Join(path, "HEAD"))
assert.NoError(t, err, "Cloned bare repo should have HEAD file")
_, err = os.Stat(filepath.Join(path, ".git"))
assert.True(t, os.IsNotExist(err), "Bare repo should not have .git subdirectory")
}
if path != bareRepoPath && path != "" {
_ = os.RemoveAll(path)
}
})
}
// Now change to temp directory for relative path tests
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("failed to change to temp directory: %v", err)
}
relativeTests := []struct {
name string
uri string
trustLocalConfig bool
}{
{
name: "relative file URI with bare repo - no trust",
uri: "file://./bare-repo",
trustLocalConfig: false,
},
{
name: "relative file URI with bare repo - with trust",
uri: "file://./bare-repo",
trustLocalConfig: true,
},
}
for _, tt := range relativeTests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
path, isRemote, err := PrepareRepo(ctx, tt.uri, "", tt.trustLocalConfig, true)
assert.NoError(t, err, "PrepareRepo should succeed")
assert.False(t, isRemote, "File URI should not be considered remote")
if tt.trustLocalConfig {
assert.Equal(t, tt.uri, "file://"+path, "Trusted bare repo should use relative path")
_, err = os.Stat(filepath.Join(path, "refs"))
assert.NoError(t, err, "Original bare repo should have refs directory")
_, err = os.Stat(filepath.Join(path, "HEAD"))
assert.NoError(t, err, "Original bare repo should have HEAD file")
} else {
assert.NotEqual(t, tt.uri, "file://"+path, "Sanitized bare repo path should be different from original")
_, err = os.Stat(filepath.Join(path, "refs"))
assert.NoError(t, err, "Cloned bare repo should have refs directory")
_, err = os.Stat(filepath.Join(path, "HEAD"))
assert.NoError(t, err, "Cloned bare repo should have HEAD file")
_, err = os.Stat(filepath.Join(path, ".git"))
assert.True(t, os.IsNotExist(err), "Bare repo should not have .git subdirectory")
}
if path != bareRepoPath && path != "" {
_ = os.RemoveAll(path)
}
})
}
}
// TestGitChunk_LongLine verifies that files containing lines longer than
// bufio's default 64 KB token limit are still scanned. Before the fix,
// bufio.Scanner would silently stop on the first oversized line and produce
// zero chunks for that file.
func TestGitChunk_LongLine(t *testing.T) {
t.Parallel()
ctx := context.Background()
repoPath := setupTestRepo(t, "long-line-repo")
// Build a single line that is 100 KB — well above the old 64 KB cap.
longLine := strings.Repeat("a", 100*1024)
addTestFileAndCommit(t, repoPath, "long_line.txt", longLine)
conn, err := anypb.New(&sourcespb.Git{
Credential: &sourcespb.Git_Unauthenticated{
Unauthenticated: &credentialspb.Unauthenticated{},
},
Repositories: []string{"file://" + repoPath},
})
assert.NoError(t, err)
s := Source{}
assert.NoError(t, s.Init(ctx, "test long line", 0, 0, false, conn, 1))
chunksCh := make(chan *sources.Chunk, 64)
var count int
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for range chunksCh {
count++
}
}()
assert.NoError(t, s.Chunks(ctx, chunksCh))
close(chunksCh)
wg.Wait()
// ensure the goroutine has finished writing to count before we read it
// one chunk for the commit/file metadata, and at least one chunk for the file content
assert.Equal(t, 2, count, "expected two chunks from a file with a 100 KB line")
}