Read blob sizes only for selected validation inputs

This commit is contained in:
2026-10-10 15:29:29 +00:00
parent 6c7b77970b
commit f27abcb53e
10 changed files with 129 additions and 40 deletions

View File

@@ -163,8 +163,9 @@ func CommittedPaths(repositoryDir, commit string) PathResolver {
}
}
// ReadTree enumerates all entries in a commit and records blob sizes. Paths
// are NUL-delimited by Git so whitespace in a filename is preserved.
// ReadTree enumerates path, type, mode, and object metadata for a commit.
// Blob sizes and contents are inspected separately. Paths are NUL-delimited
// by Git so whitespace in a filename is preserved.
func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) {
if !validObjectID(commit) {
return nil, fmt.Errorf("invalid commit object ID %q", commit)
@@ -177,9 +178,6 @@ func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, e
if err != nil {
return nil, fmt.Errorf("parse committed tree %s: %w", commit, err)
}
if err := populateBlobSizes(ctx, repositoryDir, entries); err != nil {
return nil, fmt.Errorf("read committed blob sizes for %s: %w", commit, err)
}
return entries, nil
}
@@ -223,11 +221,16 @@ func parseTreeEntries(output []byte) ([]TreeEntry, error) {
return entries, nil
}
func populateBlobSizes(ctx context.Context, repositoryDir string, entries []TreeEntry) error {
// PopulateBlobSizes fills unknown sizes for the supplied blob entries using
// local objects. Callers select the entries whose sizes their check needs.
func PopulateBlobSizes(ctx context.Context, repositoryDir string, entries []TreeEntry) error {
if err := ctx.Err(); err != nil {
return err
}
var blobIndexes []int
var input strings.Builder
for index := range entries {
if entries[index].Type != "blob" {
if entries[index].Type != "blob" || entries[index].HasSize {
continue
}
blobIndexes = append(blobIndexes, index)
@@ -250,7 +253,7 @@ func populateBlobSizes(ctx context.Context, repositoryDir string, entries []Tree
fields := strings.Fields(line)
entry := &entries[blobIndexes[index]]
if len(fields) == 2 && fields[0] == entry.ObjectID && fields[1] == "missing" {
return fmt.Errorf("committed blob %s is unavailable locally", entry.ObjectID)
return fmt.Errorf("committed blob %s for %q is unavailable locally", entry.ObjectID, entry.Path)
}
if len(fields) != 3 || fields[0] != entry.ObjectID || fields[1] != "blob" {
return fmt.Errorf("unexpected blob size record %q", line)

View File

@@ -85,8 +85,18 @@ func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) {
t.Setenv("RELEASEKIT_GIT_ATTEMPTS", attempts)
t.Setenv("GIT_NO_LAZY_FETCH", "0")
t.Setenv("GIT_ALLOW_PROTOCOL", "ssh")
if _, err := ReadTree(t.Context(), repository, fixture.commit); err == nil {
t.Fatal("tree inspection succeeded with unavailable objects")
entries, treeErr := ReadTree(t.Context(), repository, fixture.commit)
if filter == "tree:0" {
if treeErr == nil {
t.Fatal("tree inspection succeeded with unavailable trees")
}
} else {
if treeErr != nil {
t.Fatalf("metadata inspection required unavailable blobs: %v", treeErr)
}
if err := PopulateBlobSizes(t.Context(), repository, entries); err == nil {
t.Fatal("size inspection succeeded with unavailable blobs")
}
}
_, fileErr := ReadCommitFile(t.Context(), repository, fixture.commit, "source.txt")
var fileExitError *exec.ExitError

View File

@@ -1,21 +1,31 @@
package validation
import (
"context"
"fmt"
"path"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
const defaultFileSizeLimit int64 = 1_000_000
func checkFileSize(tree *CandidateTree, settings *FileSizeSettings) error {
func checkFileSize(ctx context.Context, tree *CandidateTree, settings *FileSizeSettings) error {
entries := make([]git.TreeEntry, 0, len(tree.inputEntries))
for _, entry := range tree.inputEntries {
if entry.Type == "blob" && !excludedByPath(entry.Path, settings.Exclusions) {
entries = append(entries, entry)
}
}
if err := git.PopulateBlobSizes(ctx, tree.repositoryDir, entries); err != nil {
return &Failure{Check: FileSize, Cause: fmt.Errorf("inspect selected committed file sizes: %w", err)}
}
limit := defaultFileSizeLimit
if settings.LimitBytes != nil {
limit = *settings.LimitBytes
}
for _, entry := range tree.inputEntries {
if entry.Type != "blob" || !entry.HasSize || excludedByPath(entry.Path, settings.Exclusions) {
continue
}
for _, entry := range entries {
if entry.Size > limit {
return &Failure{
Check: FileSize,

View File

@@ -33,7 +33,7 @@ func TestCheckFileSizeUsesStrictLimitsAndDefault(t *testing.T) {
Path: "file.bin", Type: "blob", Mode: "100644", Size: test.size, HasSize: true,
}}
tree := &CandidateTree{entries: entries, inputEntries: entries}
err := checkFileSize(tree, &FileSizeSettings{LimitBytes: test.limit})
err := checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: test.limit})
if !test.wantError {
if err != nil {
t.Fatalf("checkFileSize() error = %v, want nil", err)
@@ -109,7 +109,7 @@ func TestCheckFileSizeAppliesLiteralPathExclusions(t *testing.T) {
LimitBytes: int64Pointer(10),
Exclusions: test.exclusions,
}, ".")
err := checkFileSize(&CandidateTree{entries: test.entries, inputEntries: test.entries}, settings)
err := checkFileSize(t.Context(), &CandidateTree{entries: test.entries, inputEntries: test.entries}, settings)
failure, ok := err.(*Failure)
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != test.wantPath {
t.Fatalf("checkFileSize() error = %v, want only %q to exceed the limit", err, test.wantPath)
@@ -139,12 +139,12 @@ func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
}
cleanupCandidateTree(t, tree)
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"link"}})
err = checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"link"}})
if err != nil {
t.Fatalf("checkFileSize() error = %v, want small binary and exact-limit blob to pass", err)
}
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"small.bin", "boundary.bin"}})
err = checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"small.bin", "boundary.bin"}})
failure, ok := err.(*Failure)
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "link" {
t.Fatalf("checkFileSize() error = %v, want symlink blob to exceed the limit", err)
@@ -153,7 +153,7 @@ func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
t.Fatalf("symlink measurement = %#v, want link-text size %d", failure.Measurement, len("small.bin"))
}
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(0), Exclusions: []string{"small.bin", "boundary.bin", "link"}})
err = checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: int64Pointer(0), Exclusions: []string{"small.bin", "boundary.bin", "link"}})
if err != nil {
t.Fatalf("checkFileSize() error = %v, want omitted gitlink size", err)
}
@@ -174,7 +174,7 @@ func TestCheckFileSizeUsesOnlyCandidateFilesIndividually(t *testing.T) {
t.Fatal(err)
}
cleanupCandidateTree(t, tree)
if err := checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(10)}); err != nil {
if err := checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: int64Pointer(10)}); err != nil {
t.Fatalf("checkFileSize() error = %v, want current individual files to pass", err)
}
}

View File

@@ -98,7 +98,7 @@ func TestFileSizeExclusionsAreModuleRelative(t *testing.T) {
}
cleanupCandidateTree(t, tree)
settings := fileSizeSettingsForModule(&FileSizeSettings{LimitBytes: int64Pointer(10), Exclusions: []string{"./dist/"}}, tree.modulePath)
err = checkFileSize(tree, settings)
err = checkFileSize(t.Context(), tree, settings)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "nested/dist-old/output.bin" {
t.Fatalf("module-relative file-size exclusion error = %v, want only nested/dist-old/output.bin", err)

View File

@@ -48,10 +48,10 @@ func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T
t.Errorf("committed tree is missing %q", path)
}
}
if got := entries["binary.dat"]; got.size != 4 || !got.hasSize {
t.Errorf("binary metadata = %#v, want size 4", got)
if got := entries["binary.dat"]; got.mode != "100644" || got.kind != "blob" {
t.Errorf("binary metadata = %#v", got)
}
if got := entries["readme-link"]; got.mode != "120000" || got.kind != "blob" || !got.hasSize {
if got := entries["readme-link"]; got.mode != "120000" || got.kind != "blob" {
t.Errorf("symlink metadata = %#v", got)
}
if got := entries["submodule"]; got.mode != "160000" || got.kind != "commit" || got.hasSize {

View File

@@ -190,7 +190,7 @@ func Run(ctx context.Context, request Request) (result error) {
}
}
if request.Settings.FileSize != nil {
if err := checkFileSize(tree, fileSizeSettings); err != nil {
if err := checkFileSize(ctx, tree, fileSizeSettings); err != nil {
return err
}
}