Files
trufflehog/pkg/gitparse/gitparse.go
meredith 4b7d1d3a68 Low-memory Git Scanning (#5257)
* Refactor gitparse for reduced peak memory use

Instead of one single `git log` that grows huge, get a commit list with
"log" then request `git show` groups of those commits.

This is currently slightly slower than main, and parallelizing this part
further doesn't improve things because of the diffChan consumer in the
caller.  It does pipeline just enough that one git show can run while
scanning consumes the prior set of diffs.

* Group commits while reading instead of slurp and split

* Make the low-memory scan mode a non-default option
2026-09-10 12:18:33 -05:00

1188 lines
34 KiB
Go

package gitparse
import (
"bufio"
"bytes"
"cmp"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"github.com/go-logr/logr"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
bufferwriter "github.com/trufflesecurity/trufflehog/v3/pkg/writers/buffer_writer"
bufferedfilewriter "github.com/trufflesecurity/trufflehog/v3/pkg/writers/buffered_file_writer"
)
const (
// defaultDateFormat is the standard date format for git.
// Uses ISO 8601 format to avoid locale-dependent weekday/month names.
defaultDateFormat = time.RFC3339
// defaultMaxDiffSize is the maximum size for a diff. Larger diffs will be cut off.
defaultMaxDiffSize int64 = 2 * 1024 * 1024 * 1024 // 2GB
// defaultMaxCommitSize is the maximum size for a commit. Larger commits will be cut off.
defaultMaxCommitSize int64 = 2 * 1024 * 1024 * 1024 // 2GB
// defaultWaitDelay is the default time to wait after context cancellation before forcefully killing git processes.
defaultWaitDelay = 5 * time.Second
// abbrevCommit is the git sha abbreviation length to use for `git show` invocations in the lower-memory scan mode.
abbrevCommit = 20
// showGroupSize is the number of commits per `git show` in the lower-memory scan mode.
//
// Windows has a command length limit of 32767, so at these values we should only be using a tiny part of of that for the commit list ((abbrevCommit + 1) * showGroupSize). We don't target any platforms with shorter limits.
showGroupSize = 75
)
// contentWriter defines a common interface for writing, reading, and managing diff content.
// It abstracts the underlying storage mechanism, allowing flexibility in how content is handled.
// This interface enables the use of different content storage strategies (e.g., in-memory buffer, file-based storage)
// based on performance needs or resource constraints, providing a unified way to interact with different content types.
type contentWriter interface { // Write appends data to the content storage.
// Write appends data to the content storage.
Write(data []byte) (int, error)
// ReadCloser provides a reader for accessing stored content.
ReadCloser() (io.ReadCloser, error)
// CloseForWriting closes the content storage for writing.
CloseForWriting() error
// Len returns the current size of the content.
Len() int
// String returns the content as a string or an error if the content cannot be converted to a string.
String() (string, error)
}
// Diff contains the information about a file diff in a commit.
// It abstracts the underlying content representation, allowing for flexible handling of diff content.
// The use of contentWriter enables the management of diff data either in memory or on disk,
// based on its size, optimizing resource usage and performance.
type Diff struct {
PathB string
LineStart int
IsBinary bool
Commit *Commit
contentWriter contentWriter
}
type diffOption func(*Diff)
// withPathB sets the PathB option.
func withPathB(pathB string) diffOption { return func(d *Diff) { d.PathB = pathB } }
// withCustomContentWriter sets the useCustomContentWriter option.
func withCustomContentWriter(cr contentWriter) diffOption {
return func(d *Diff) { d.contentWriter = cr }
}
// newDiff creates a new Diff with a threshold and an associated commit.
// All Diffs must have an associated commit.
// The contentWriter is used to manage the diff's content, allowing for flexible handling of diff data.
// By default, a buffer is used as the contentWriter, but this can be overridden with a custom contentWriter.
func newDiff(commit *Commit, opts ...diffOption) *Diff {
diff := &Diff{Commit: commit}
for _, opt := range opts {
opt(diff)
}
if diff.contentWriter == nil {
diff.contentWriter = bufferwriter.New()
}
return diff
}
// Len returns the length of the storage.
func (d *Diff) Len() int { return d.contentWriter.Len() }
// ReadCloser returns a ReadCloser for the contentWriter.
func (d *Diff) ReadCloser() (io.ReadCloser, error) { return d.contentWriter.ReadCloser() }
// write delegates to the contentWriter.
func (d *Diff) write(p []byte) error {
_, err := d.contentWriter.Write(p)
return err
}
// finalize ensures proper closure of resources associated with the Diff.
// handle the final flush in the finalize method, in case there's data remaining in the buffer.
// This method should be called to release resources, especially when writing to a file.
func (d *Diff) finalize() error { return d.contentWriter.CloseForWriting() }
// Commit contains commit header info and diffs.
type Commit struct {
Hash string
Author string
Committer string
Date time.Time
Message strings.Builder
Size int // in bytes
hasDiffs bool
}
// Parser sets values used in GitParse.
type Parser struct {
maxDiffSize int64
maxCommitSize int64
dateFormat string
waitDelay time.Duration
useCustomContentWriter bool
lowMemoryScan bool
}
type ParseState int
const (
Initial ParseState = iota
CommitLine
MergeLine
AuthorLine
AuthorDateLine
CommitterLine
CommitterDateLine
MessageStartLine
MessageLine
MessageEndLine
NotesStartLine
NotesLine
NotesEndLine
DiffLine
ModeLine
IndexLine
FromFileLine
ToFileLine
BinaryFileLine
HunkLineNumberLine
HunkContentLine
ParseFailure
)
func (state ParseState) String() string {
return [...]string{
"Initial",
"CommitLine",
"MergeLine",
"AuthorLine",
"AuthorDateLine",
"CommitterLine",
"CommitterDateLine",
"MessageStartLine",
"MessageLine",
"MessageEndLine",
"NotesStartLine",
"NotesLine",
"NotesEndLine",
"DiffLine",
"ModeLine",
"IndexLine",
"FromFileLine",
"ToFileLine",
"BinaryFileLine",
"HunkLineNumberLine",
"HunkContentLine",
"ParseFailure",
}[state]
}
// UseCustomContentWriter sets useCustomContentWriter option.
func UseCustomContentWriter() Option {
return func(parser *Parser) { parser.useCustomContentWriter = true }
}
// UseLowMemoryScan sets the scan to optimize for limited memory at the cost of speed (currently up to 9%)
func UseLowMemoryScan() Option {
return func(parser *Parser) {
parser.lowMemoryScan = true
}
}
// WithMaxDiffSize sets maxDiffSize option. Diffs larger than maxDiffSize will
// be truncated.
func WithMaxDiffSize(maxDiffSize int64) Option {
return func(parser *Parser) {
parser.maxDiffSize = maxDiffSize
}
}
// WithMaxCommitSize sets maxCommitSize option. Commits larger than maxCommitSize
// will be put in the commit channel and additional diffs will be added to a
// new commit.
func WithMaxCommitSize(maxCommitSize int64) Option {
return func(parser *Parser) {
parser.maxCommitSize = maxCommitSize
}
}
// WithWaitDelay sets the waitDelay option. This specifies how long to wait after
// context cancellation before forcefully killing git processes.
func WithWaitDelay(waitDelay time.Duration) Option {
return func(parser *Parser) {
parser.waitDelay = waitDelay
}
}
// Option is used for adding options to Config.
type Option func(*Parser)
// NewParser creates a GitParse config from options and sets defaults.
func NewParser(options ...Option) *Parser {
parser := &Parser{
dateFormat: defaultDateFormat,
maxDiffSize: defaultMaxDiffSize,
maxCommitSize: defaultMaxCommitSize,
waitDelay: defaultWaitDelay,
}
for _, option := range options {
option(parser)
}
return parser
}
type gitArgs struct {
env []string
global []string
log []string
show []string
paths []string
}
// RepoPath parses the output of the `git log` command for the `source` path.
// The Diff chan will return diffs in the order they are parsed from the log,
// though the diffs are generated using `git show` in groups.
func (c *Parser) RepoPath(
ctx context.Context,
source string,
head string,
abbreviatedLog bool,
excludedGlobs []string,
isBare bool,
) (chan *Diff, error) {
args := c.prepGitArgs(source, head, abbreviatedLog, excludedGlobs, isBare)
if c.lowMemoryScan {
return c.repoPathLowMemory(ctx, args)
}
showCmd := exec.CommandContext(ctx,
"git",
slices.Concat(args.global, []string{"log"}, args.show, args.log, args.paths)...,
)
showCmd.Env = args.env
return c.executeCommand(ctx, showCmd, false)
}
func (c *Parser) repoPathLowMemory(ctx context.Context, args gitArgs) (chan *Diff, error) {
commitGroups, err := c.gatherGitLog(ctx, args)
if err != nil {
return nil, err
}
// c.executeCommand returns a channel that is later closed by a
// different goroutine after the command finishes, but we're not
// running a single command anymore. we'll use a channel of channels to
// reduce back to one channel we return to our caller. Unbuffered so
// we have at most one git show running and one git show draining.
diffGroups := make(chan chan *Diff)
go func() {
defer common.RecoverWithExit(ctx)
defer close(diffGroups)
for group := range commitGroups {
if common.IsDone(ctx) {
return
}
showCmd := exec.CommandContext(ctx,
"git",
slices.Concat(args.global, []string{"show"}, args.show, group, args.paths)...,
)
showCmd.Env = args.env
diffGroup, err := c.executeCommand(ctx, showCmd, false)
if err != nil {
ctx.Logger().Error(err, "Error executing git show for commit group.")
return
}
err = common.CancellableWrite(ctx, diffGroups, diffGroup)
if err != nil {
ctx.Logger().Error(err, "git show interation cancelled")
return
}
}
}()
// and this is the single channel we're responsible for returning and
// closing.
diffChan := make(chan *Diff)
go func() {
defer common.RecoverWithExit(ctx)
defer close(diffChan)
var err error
for groupdiffs := range diffGroups {
for diff := range groupdiffs {
err = common.CancellableWrite(ctx, diffChan, diff)
if err != nil {
return // context cancel
}
}
}
}()
return diffChan, nil
}
// Ask git for a list of all relevant commit hashes but only hashes. Git takes
// on the work of linearizing history for us, then we work through the commit
// list. Returns a channel of groups of commit IDs, so scanning can start asap
// even if git log is taking a bit for large repos.
func (c *Parser) gatherGitLog(ctx context.Context, args gitArgs) (chan []string, error) {
cmd := exec.CommandContext(ctx,
"git", slices.Concat(
args.global, []string{"log"},
args.log, []string{
// https://git-scm.com/docs/git-log#_pretty_formats
"--pretty=format:%h",
// https://git-scm.com/docs/git-log#Documentation/git-log.txt---abbrevn
fmt.Sprintf("--abbrev=%d", abbrevCommit),
},
args.paths,
)...)
cmd.WaitDelay = c.waitDelay
cmd.Env = args.env
stdOut, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
err = cmd.Start()
if err != nil {
return nil, fmt.Errorf("failed to execute git log: %w", err)
}
commitGroups := make(chan []string)
go func() {
defer close(commitGroups)
defer func() {
err := cmd.Wait()
if err != nil {
ctx.Logger().Error(err, "git log exited with error", "stderr", cmd.Stderr)
}
}()
s := bufio.NewScanner(stdOut)
commitGroup := make([]string, 0, showGroupSize)
var err error
for s.Scan() && !common.IsDone(ctx) {
commitGroup = append(commitGroup, s.Text())
if len(commitGroup) == showGroupSize {
err = common.CancellableWrite(ctx, commitGroups, commitGroup)
if err != nil {
ctx.Logger().Error(err, "git log stopping early")
return
}
commitGroup = make([]string, 0, showGroupSize)
}
}
if len(commitGroup) != 0 {
err = common.CancellableWrite(ctx, commitGroups, commitGroup)
if err != nil {
ctx.Logger().Error(err, "failed to flush last git log group")
}
}
if err := s.Err(); err != nil {
ctx.Logger().Error(err, "error reading git log")
}
}()
return commitGroups, nil
}
func (c *Parser) prepGitArgs(source string, head string, abbreviatedLog bool, excludedGlobs []string, isBare bool) gitArgs {
args := gitArgs{
global: []string{
"-C", source,
},
log: []string{
// https://git-scm.com/docs/git-log#Documentation/git-log.txt---full-history
"--full-history",
},
show: []string{
// https://git-scm.com/docs/git-show#Documentation/git-show.txt---patch
"--patch",
// https://git-scm.com/docs/git-log#Documentation/git-log.txt---dateformat
"--date=iso-strict",
// https://git-scm.com/docs/git-show#_pretty_formats
"--pretty=fuller",
// https://git-scm.com/docs/git-show#Documentation/git-show.txt---notesref
"--notes",
},
paths: []string{},
}
if abbreviatedLog {
// https://git-scm.com/docs/git-show#Documentation/git-show.txt---diff-filterACDMRTUXB
args.log = append(args.log, "--diff-filter=AM")
args.show = append(args.show, "--diff-filter=AM")
}
// Keep head or all last, before the --, not required but sensible
// https://git-scm.com/docs/git-log#Documentation/git-log.txt---all
args.log = append(args.log, cmp.Or(head, "--all"))
// And then potentially add -- to args here
if len(excludedGlobs) != 0 {
args.paths = []string{"--", "."}
for _, glob := range excludedGlobs {
// This is not directly doc'd but added in git 1.9.0 and found in pathspec.c
args.paths = append(args.paths, ":(exclude)"+glob)
}
}
absPath, err := filepath.Abs(source)
if err == nil {
if !isBare {
args.env = append(args.env, "GIT_DIR="+filepath.Join(absPath, ".git"))
} else {
args.env = append(args.env, "GIT_DIR="+absPath)
// We need those variables to handle incoming commits
// while using trufflehog in pre-receive hooks
if dir := os.Getenv("GIT_OBJECT_DIRECTORY"); dir != "" {
args.env = append(args.env, "GIT_OBJECT_DIRECTORY="+dir)
}
if dir := os.Getenv("GIT_ALTERNATE_OBJECT_DIRECTORIES"); dir != "" {
args.env = append(args.env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+dir)
}
}
}
return args
}
// Staged parses the output of the `git diff` command for the `source` path.
func (c *Parser) Staged(ctx context.Context, source string) (chan *Diff, error) {
// Provide the --cached flag to diff to get the diff of the staged changes.
args := []string{"-C", source, "diff", "-p", "--cached", "--full-history", "--diff-filter=AM", "--date=iso-strict"}
cmd := exec.CommandContext(ctx, "git", args...)
absPath, err := filepath.Abs(source)
if err == nil {
cmd.Env = append(cmd.Env, "GIT_DIR="+filepath.Join(absPath, ".git"))
}
return c.executeCommand(ctx, cmd, true)
}
// executeCommand runs an exec.Cmd, reads stdout and stderr, and waits for the Cmd to complete.
// waitDelay specifies how long to wait after context cancellation before forcefully killing the process.
func (c *Parser) executeCommand(ctx context.Context, cmd *exec.Cmd, isStaged bool) (chan *Diff, error) {
diffChan := make(chan *Diff, 64)
stdOut, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stdErr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
// Set WaitDelay to allow the command additional time to exit after context cancellation
cmd.WaitDelay = c.waitDelay
err = cmd.Start()
if err != nil {
return nil, err
}
go func() {
scanner := bufio.NewScanner(stdErr)
for scanner.Scan() {
ctx.Logger().V(2).Info(scanner.Text())
}
}()
go func() {
defer func() {
if err := cmd.Wait(); err != nil {
ctx.Logger().V(2).Info("Error waiting for git command to complete.", "error", err)
}
}()
c.FromReader(ctx, stdOut, diffChan, isStaged)
if err := stdOut.Close(); err != nil {
ctx.Logger().V(2).Info("Error closing git stdout pipe.", "error", err)
}
}()
return diffChan, nil
}
func (c *Parser) FromReader(ctx context.Context, stdOut io.Reader, diffChan chan *Diff, isStaged bool) {
outReader := bufio.NewReader(stdOut)
var (
currentCommit *Commit
totalLogSize int
)
latestState := Initial
diff := func(c *Commit, opts ...diffOption) *Diff {
opts = append(opts, withCustomContentWriter(bufferwriter.New()))
return newDiff(c, opts...)
}
if c.useCustomContentWriter {
diff = func(c *Commit, opts ...diffOption) *Diff {
opts = append(opts, withCustomContentWriter(bufferedfilewriter.New()))
return newDiff(c, opts...)
}
}
currentDiff := diff(currentCommit)
defer common.RecoverWithExit(ctx)
defer close(diffChan)
for !common.IsDone(ctx) {
line, err := outReader.ReadBytes([]byte("\n")[0])
if err != nil && len(line) == 0 {
break
}
switch {
case isCommitLine(isStaged, latestState, line):
latestState = CommitLine
// If there is a currentDiff, add it to currentCommit.
if currentDiff.Len() > 0 || currentDiff.IsBinary {
if err := currentDiff.finalize(); err != nil {
ctx.Logger().Error(
err,
"failed to finalize diff",
"commit", currentCommit.Hash,
"diff", currentDiff.PathB,
"size", currentDiff.Len(),
"latest_state", latestState.String(),
)
}
diffChan <- currentDiff
currentCommit.Size += currentDiff.Len()
currentCommit.hasDiffs = true
}
// If there is a currentCommit, send it to the channel.
if currentCommit != nil {
totalLogSize += currentCommit.Size
if !currentCommit.hasDiffs {
// Initialize an empty Diff instance associated with the given commit.
// Since this diff represents "no changes", we only need to set the commit.
// This is required to ensure commits that have no diffs are still processed.
diffChan <- &Diff{Commit: currentCommit}
}
}
// Create a new currentDiff and currentCommit
currentCommit = &Commit{Message: strings.Builder{}}
currentDiff = diff(currentCommit)
// Check that the commit line contains a hash and set it.
if len(line) >= 47 {
currentCommit.Hash = string(line[7:47])
}
case isMergeLine(isStaged, latestState, line):
latestState = MergeLine
case isAuthorLine(isStaged, latestState, line):
latestState = AuthorLine
currentCommit.Author = strings.TrimSpace(string(line[8:]))
case isAuthorDateLine(isStaged, latestState, line):
latestState = AuthorDateLine
date, err := time.Parse(c.dateFormat, strings.TrimSpace(string(line[12:])))
if err != nil {
ctx.Logger().Error(err, "failed to parse commit date", "commit", currentCommit.Hash, "latestState", latestState.String())
latestState = ParseFailure
continue
}
currentCommit.Date = date
case isCommitterLine(isStaged, latestState, line):
latestState = CommitterLine
currentCommit.Committer = strings.TrimSpace(string(line[8:]))
case isCommitterDateLine(isStaged, latestState, line):
latestState = CommitterDateLine
// NoOp
case isMessageStartLine(isStaged, latestState, line):
latestState = MessageStartLine
// NoOp
case isMessageLine(isStaged, latestState, line):
latestState = MessageLine
currentCommit.Message.Write(line[4:]) // Messages are indented by 4 spaces.
case isMessageEndLine(isStaged, latestState, line):
latestState = MessageEndLine
// NoOp
case isNotesStartLine(isStaged, latestState, line):
latestState = NotesStartLine
currentCommit.Message.WriteString("\n")
currentCommit.Message.Write(line)
case isNotesLine(isStaged, latestState, line):
latestState = NotesLine
currentCommit.Message.Write(line[4:]) // Notes are indented by 4 spaces.
case isNotesEndLine(isStaged, latestState, line):
latestState = NotesEndLine
// NoOp
case isDiffLine(isStaged, latestState, line):
latestState = DiffLine
if currentDiff.Len() > 0 || currentDiff.IsBinary {
if err := currentDiff.finalize(); err != nil {
ctx.Logger().Error(err,
"failed to finalize diff",
"commit", currentCommit.Hash,
"diff", currentDiff.PathB,
"size", currentDiff.Len(),
"latest_state", latestState.String(),
)
}
diffChan <- currentDiff
currentCommit.hasDiffs = true
}
// This should never be nil, but check in case the stdin stream is messed up.
if currentCommit == nil {
currentCommit = &Commit{}
}
currentDiff = diff(currentCommit)
case isModeLine(latestState, line):
latestState = ModeLine
// NoOp
case isIndexLine(latestState, line):
latestState = IndexLine
// NoOp
case isBinaryLine(latestState, line):
latestState = BinaryFileLine
path, ok := pathFromBinaryLine(line)
if !ok {
err = fmt.Errorf(`expected line to match 'Binary files a/fileA and b/fileB differ', got "%s"`, line)
ctx.Logger().Error(err, "Failed to parse BinaryFileLine")
latestState = ParseFailure
continue
}
// Don't do anything if the file is deleted. (pathA has file path, pathB is /dev/null)
if path != "" {
currentDiff.PathB = path
currentDiff.IsBinary = true
}
case isFromFileLine(latestState, line):
latestState = FromFileLine
// NoOp
case isToFileLine(latestState, line):
latestState = ToFileLine
path, ok := pathFromToFileLine(line)
if !ok {
err = fmt.Errorf(`expected line to match format '+++ b/path/to/file.go', got '%s'`, line)
ctx.Logger().Error(err, "Failed to parse ToFileLine")
latestState = ParseFailure
continue
}
currentDiff.PathB = path
case isHunkLineNumberLine(latestState, line):
latestState = HunkLineNumberLine
if currentDiff.Len() > 0 || currentDiff.IsBinary {
if err := currentDiff.finalize(); err != nil {
ctx.Logger().Error(
err,
"failed to finalize diff",
"commit", currentCommit.Hash,
"diff", currentDiff.PathB,
"size", currentDiff.Len(),
"latest_state", latestState.String(),
)
}
diffChan <- currentDiff
}
currentDiff = diff(currentCommit, withPathB(currentDiff.PathB))
words := bytes.Split(line, []byte(" "))
if len(words) >= 3 {
startSlice := bytes.Split(words[2], []byte(","))
lineStart, err := strconv.Atoi(string(startSlice[0]))
if err == nil {
currentDiff.LineStart = lineStart
}
}
case isHunkContextLine(latestState, line):
if latestState != HunkContentLine {
latestState = HunkContentLine
}
// TODO: Why do we care about this? It creates empty lines in the diff. If there are no plusLines, it's just newlines.
if err := currentDiff.write([]byte("\n")); err != nil {
ctx.Logger().Error(err, "failed to write to diff")
}
case isHunkPlusLine(latestState, line):
if latestState != HunkContentLine {
latestState = HunkContentLine
}
if err := currentDiff.write(line[1:]); err != nil {
ctx.Logger().Error(err, "failed to write to diff")
}
// NoOp. We only care about additions.
case isHunkMinusLine(latestState, line),
isHunkNewlineWarningLine(latestState, line),
isHunkEmptyLine(latestState, line):
if latestState != HunkContentLine {
latestState = HunkContentLine
}
// NoOp
case isCommitSeparatorLine(latestState, line):
// NoOp
default:
// Skip ahead until we find the next diff or commit.
if latestState == ParseFailure {
continue
}
// Here be dragons...
// Build an informative error message.
err := fmt.Errorf(`invalid line "%s" after state "%s"`, line, latestState)
var logger logr.Logger
if currentCommit != nil && currentCommit.Hash != "" {
logger = ctx.Logger().WithValues("commit", currentCommit.Hash)
} else {
logger = ctx.Logger()
}
logger.Error(err, "failed to parse Git input. Recovering at the latest commit or diff...")
latestState = ParseFailure
}
if int64(currentDiff.Len()) > c.maxDiffSize {
ctx.Logger().V(2).Info(fmt.Sprintf(
"Diff for %s exceeded MaxDiffSize(%d)", currentDiff.PathB, c.maxDiffSize,
))
break
}
}
cleanupParse(ctx, currentCommit, currentDiff, diffChan, &totalLogSize)
ctx.Logger().V(2).Info("finished parsing git log.", "total_log_size", totalLogSize)
}
func isMergeLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || latestState != CommitLine {
return false
}
if len(line) > 6 && bytes.Equal(line[:6], []byte("Merge:")) {
return true
}
return false
}
// commit 7a95bbf0199e280a0e42dbb1d1a3f56cdd0f6e05
func isCommitLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || (latestState != Initial &&
latestState != MessageStartLine &&
latestState != MessageEndLine &&
latestState != ModeLine &&
latestState != IndexLine &&
latestState != BinaryFileLine &&
latestState != ToFileLine &&
latestState != HunkContentLine &&
latestState != ParseFailure) {
return false
}
if len(line) > 7 && bytes.Equal(line[:7], []byte("commit ")) {
return true
}
return false
}
// Author: Bill Rich <[email protected]>
func isAuthorLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || (latestState != CommitLine && latestState != MergeLine) {
return false
}
if len(line) > 8 && bytes.Equal(line[:7], []byte("Author:")) {
return true
}
return false
}
// AuthorDate: 2021-08-10T15:20:40+01:00
func isAuthorDateLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || latestState != AuthorLine {
return false
}
if len(line) > 10 && bytes.Equal(line[:11], []byte("AuthorDate:")) {
return true
}
return false
}
// Commit: Bill Rich <[email protected]>
func isCommitterLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || latestState != AuthorDateLine {
return false
}
if len(line) > 8 && bytes.Equal(line[:7], []byte("Commit:")) {
return true
}
return false
}
// CommitDate: Wed Apr 17 19:59:28 2024 -0400
func isCommitterDateLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || latestState != CommitterLine {
return false
}
if len(line) > 10 && bytes.Equal(line[:11], []byte("CommitDate:")) {
return true
}
return false
}
// Line directly after CommitterDate with only a newline.
func isMessageStartLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || latestState != CommitterDateLine {
return false
}
// TODO: Improve the implementation of this and isMessageEndLine
if len(strings.TrimRight(string(line[:]), "\r\n")) == 0 {
return true
}
return false
}
// Line that starts with 4 spaces
func isMessageLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || (latestState != MessageStartLine && latestState != MessageLine) {
return false
}
if len(line) > 4 && bytes.Equal(line[:4], []byte(" ")) {
return true
}
return false
}
// Line directly after MessageLine with only a newline.
func isMessageEndLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || latestState != MessageLine {
return false
}
if len(strings.TrimRight(string(line[:]), "\r\n")) == 0 {
return true
}
return false
}
// `Notes:` or `Notes (context):`
// See https://tylercipriani.com/blog/2022/11/19/git-notes-gits-coolest-most-unloved-feature/
func isNotesStartLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || latestState != MessageEndLine {
return false
}
if len(line) > 5 && bytes.Equal(line[:5], []byte("Notes")) {
return true
}
return false
}
// Line after NotesStartLine that starts with 4 spaces
func isNotesLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || (latestState != NotesStartLine && latestState != NotesLine) {
return false
}
if len(line) > 4 && bytes.Equal(line[:4], []byte(" ")) {
return true
}
return false
}
// Line directly after NotesLine with only a newline.
func isNotesEndLine(isStaged bool, latestState ParseState, line []byte) bool {
if isStaged || latestState != NotesLine {
return false
}
if len(strings.TrimRight(string(line[:]), "\r\n")) == 0 {
return true
}
return false
}
// diff --git a/internal/addrs/move_endpoint_module.go b/internal/addrs/move_endpoint_module.go
func isDiffLine(isStaged bool, latestState ParseState, line []byte) bool {
if latestState != MessageStartLine &&
latestState != MessageEndLine &&
latestState != NotesEndLine &&
latestState != BinaryFileLine &&
latestState != ModeLine &&
latestState != IndexLine &&
latestState != HunkContentLine &&
latestState != ParseFailure {
if !isStaged || latestState != Initial {
return false
}
}
if len(line) > 11 && bytes.Equal(line[:11], []byte("diff --git ")) {
return true
}
return false
}
// old mode 100644
// new mode 100755
// new file mode 100644
// similarity index 100%
// rename from old.txt
// rename to new.txt
// deleted file mode 100644
func isModeLine(latestState ParseState, line []byte) bool {
if latestState != DiffLine && latestState != ModeLine {
return false
}
// This could probably be better written.
if (len(line) > 17 && bytes.Equal(line[:17], []byte("deleted file mode"))) ||
(len(line) > 16 && bytes.Equal(line[:16], []byte("similarity index"))) ||
(len(line) > 13 && bytes.Equal(line[:13], []byte("new file mode"))) ||
(len(line) > 11 && bytes.Equal(line[:11], []byte("rename from"))) ||
(len(line) > 9 && bytes.Equal(line[:9], []byte("rename to"))) ||
(len(line) > 8 && bytes.Equal(line[:8], []byte("old mode"))) ||
(len(line) > 8 && bytes.Equal(line[:8], []byte("new mode"))) {
return true
}
return false
}
// index 1ed6fbee1..aea1e643a 100644
// index 00000000..e69de29b
func isIndexLine(latestState ParseState, line []byte) bool {
if latestState != DiffLine && latestState != ModeLine {
return false
}
if len(line) > 6 && bytes.Equal(line[:6], []byte("index ")) {
return true
}
return false
}
// Binary files /dev/null and b/plugin.sig differ
func isBinaryLine(latestState ParseState, line []byte) bool {
if latestState != IndexLine {
return false
}
if len(line) > 7 && bytes.Equal(line[:6], []byte("Binary")) {
return true
}
return false
}
// Get the b/ file path. Ignoring the edge case of files having `and /b` in the name for simplicity.
func pathFromBinaryLine(line []byte) (string, bool) {
if bytes.Contains(line, []byte("and /dev/null")) {
return "", true
}
var (
path string
err error
)
if _, after, ok := bytes.Cut(line, []byte(" and b/")); ok {
// drop the " differ\n"
path = string(after[:len(after)-8])
} else if _, after, ok = bytes.Cut(line, []byte(` and "b/`)); ok {
// Edge case where the path is quoted.
// https://github.com/trufflesecurity/trufflehog/issues/2384
// Drop the `" differ\n` and handle escaped characters in the path.
// e.g., "\342\200\224" instead of "—".
// See https://github.com/trufflesecurity/trufflehog/issues/2418
path, err = strconv.Unquote(`"` + string(after[:len(after)-9]) + `"`)
if err != nil {
return "", false
}
} else {
// Unknown format.
return "", false
}
return path, true
}
// --- a/internal/addrs/move_endpoint_module.go
// --- /dev/null
func isFromFileLine(latestState ParseState, line []byte) bool {
if latestState != IndexLine && latestState != ModeLine {
return false
}
if len(line) >= 6 && bytes.Equal(line[:4], []byte("--- ")) {
return true
}
return false
}
// +++ b/internal/addrs/move_endpoint_module.go
func isToFileLine(latestState ParseState, line []byte) bool {
if latestState != FromFileLine {
return false
}
if len(line) >= 6 && bytes.Equal(line[:4], []byte("+++ ")) {
return true
}
return false
}
// Get the b/ file path.
func pathFromToFileLine(line []byte) (string, bool) {
// Normalize paths, as they can end in `\n`, `\t\n`, etc.
// See https://github.com/trufflesecurity/trufflehog/issues/1060
line = bytes.TrimSpace(line)
// File was deleted.
if bytes.Equal(line, []byte("+++ /dev/null")) {
return "", true
}
var (
path string
err error
)
if _, after, ok := bytes.Cut(line, []byte("+++ b/")); ok {
path = string(after)
} else if _, after, ok = bytes.Cut(line, []byte(`+++ "b/`)); ok {
// Edge case where the path is quoted.
// e.g., `+++ "b/C++/1 \320\243\321\200\320\276\320\272/B.c"`
// Drop the trailing `"` and handle escaped characters in the path
// e.g., "\342\200\224" instead of "—".
// See https://github.com/trufflesecurity/trufflehog/issues/2418
path, err = strconv.Unquote(`"` + string(after[:len(after)-1]) + `"`)
if err != nil {
return "", false
}
} else {
// Unknown format.
return "", false
}
return path, true
}
// @@ -298 +298 @@ func maxRetryErrorHandler(resp *http.Response, err error, numTries int)
func isHunkLineNumberLine(latestState ParseState, line []byte) bool {
if latestState != ToFileLine && latestState != HunkContentLine {
return false
}
if len(line) >= 8 && bytes.Equal(line[:2], []byte("@@")) {
return true
}
return false
}
// fmt.Println("ok")
// (There's a space before `fmt` that gets removed by the formatter.)
func isHunkContextLine(latestState ParseState, line []byte) bool {
if latestState != HunkLineNumberLine && latestState != HunkContentLine {
return false
}
if len(line) >= 1 && bytes.Equal(line[:1], []byte(" ")) {
return true
}
return false
}
// +fmt.Println("ok")
func isHunkPlusLine(latestState ParseState, line []byte) bool {
if latestState != HunkLineNumberLine && latestState != HunkContentLine {
return false
}
if len(line) >= 1 && bytes.Equal(line[:1], []byte("+")) {
return true
}
return false
}
// -fmt.Println("ok")
func isHunkMinusLine(latestState ParseState, line []byte) bool {
if latestState != HunkLineNumberLine && latestState != HunkContentLine {
return false
}
if len(line) >= 1 && bytes.Equal(line[:1], []byte("-")) {
return true
}
return false
}
// \ No newline at end of file
func isHunkNewlineWarningLine(latestState ParseState, line []byte) bool {
if latestState != HunkContentLine {
return false
}
if len(line) >= 27 && bytes.Equal(line[:27], []byte("\\ No newline at end of file")) {
return true
}
return false
}
// Newline after hunk, or an empty line, e.g.
// +}
//
// commit 00920984e3435057f09cee5468850f7546dfa637 (tag: v3.42.0)
func isHunkEmptyLine(latestState ParseState, line []byte) bool {
if latestState != HunkLineNumberLine && latestState != HunkContentLine {
return false
}
// TODO: Can this also be `\n\r`?
if len(line) == 1 && bytes.Equal(line[:1], []byte("\n")) {
return true
}
return false
}
func isCommitSeparatorLine(latestState ParseState, line []byte) bool {
if (latestState == ModeLine || latestState == IndexLine || latestState == BinaryFileLine || latestState == ToFileLine) &&
len(line) == 1 && bytes.Equal(line[:1], []byte("\n")) {
return true
}
return false
}
func cleanupParse(ctx context.Context, currentCommit *Commit, currentDiff *Diff, diffChan chan *Diff, totalLogSize *int) {
if err := currentDiff.finalize(); err != nil {
ctx.Logger().Error(err, "failed to finalize diff")
return
}
// Ignore empty or binary diffs (this condition may be redundant).
if currentDiff != nil && (currentDiff.Len() > 0 || currentDiff.IsBinary) {
currentDiff.Commit = currentCommit
diffChan <- currentDiff
}
if currentCommit != nil {
if totalLogSize != nil {
*totalLogSize += currentCommit.Size
}
}
}