Check committed file sizes

This commit is contained in:
2026-10-07 22:58:38 +00:00
parent eed95896f4
commit f80970a9d4
18 changed files with 460 additions and 6 deletions

View File

@@ -21,6 +21,8 @@ const (
WorkspaceFiles Check = "workspace-files"
// LocalReplacements identifies filesystem replacements in committed modules.
LocalReplacements Check = "local-replacements"
// FileSize identifies committed blobs that exceed a selected size limit.
FileSize Check = "file-size"
)
// Finding describes a validation observation at an optional source location.

View File

@@ -0,0 +1,52 @@
package validation
import (
"path"
"strings"
)
const defaultFileSizeLimit int64 = 1_000_000
func checkFileSize(tree *CandidateTree, settings *FileSizeSettings) error {
limit := defaultFileSizeLimit
if settings.LimitBytes != nil {
limit = *settings.LimitBytes
}
exclusions := normalizedExclusions(settings.Exclusions)
for _, entry := range tree.Entries() {
if entry.Type != "blob" || !entry.HasSize || excludedByPath(entry.Path, exclusions) {
continue
}
if entry.Size > limit {
return &Failure{
Check: FileSize,
Findings: []Finding{{
Path: entry.Path,
Message: "committed file exceeds the selected size limit",
}},
Measurement: &FileSizeMeasurement{Bytes: entry.Size, LimitBytes: limit},
}
}
}
return nil
}
func normalizedExclusions(exclusions []string) []string {
result := make([]string, 0, len(exclusions))
for _, exclusion := range exclusions {
if exclusion == "" {
continue
}
result = append(result, path.Clean(exclusion))
}
return result
}
func excludedByPath(filePath string, exclusions []string) bool {
for _, exclusion := range exclusions {
if exclusion == "." || filePath == exclusion || strings.HasPrefix(filePath, exclusion+"/") {
return true
}
}
return false
}

View File

@@ -0,0 +1,192 @@
package validation
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
func TestCheckFileSizeUsesStrictLimitsAndDefault(t *testing.T) {
tests := []struct {
name string
limit *int64
size int64
wantLimit int64
wantError bool
}{
{name: "below", limit: int64Pointer(10), size: 9, wantLimit: 10},
{name: "at", limit: int64Pointer(10), size: 10, wantLimit: 10},
{name: "above", limit: int64Pointer(10), size: 11, wantLimit: 10, wantError: true},
{name: "default at", size: 1_000_000, wantLimit: 1_000_000},
{name: "default above", size: 1_000_001, wantLimit: 1_000_000, wantError: true},
{name: "zero empty", limit: int64Pointer(0), size: 0},
{name: "zero nonempty", limit: int64Pointer(0), size: 1, wantError: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
tree := &CandidateTree{entries: []git.TreeEntry{{
Path: "file.bin", Type: "blob", Mode: "100644", Size: test.size, HasSize: true,
}}}
err := checkFileSize(tree, &FileSizeSettings{LimitBytes: test.limit})
if !test.wantError {
if err != nil {
t.Fatalf("checkFileSize() error = %v, want nil", err)
}
return
}
failure, ok := err.(*Failure)
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "file.bin" {
t.Fatalf("checkFileSize() error = %v, want file-size finding", err)
}
if failure.Measurement == nil || failure.Measurement.Bytes != test.size || failure.Measurement.LimitBytes != test.wantLimit {
t.Fatalf("measurement = %#v, want %d bytes against %d", failure.Measurement, test.size, test.wantLimit)
}
})
}
}
func TestCheckFileSizeAppliesLiteralPathExclusions(t *testing.T) {
tests := []struct {
name string
entries []git.TreeEntry
exclusions []string
wantPath string
}{
{
name: "directory boundary and normalized paths",
entries: []git.TreeEntry{
{Path: "assets/icon.png", Type: "blob", Size: 20, HasSize: true},
{Path: "assets-other/icon.png", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"./assets/"},
wantPath: "assets-other/icon.png",
},
{
name: "glob metacharacters are literal",
entries: []git.TreeEntry{
{Path: "[ab].dat", Type: "blob", Size: 20, HasSize: true},
{Path: "a.dat", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"[ab].dat", "literal[set]"},
wantPath: "a.dat",
},
{
name: "directory metacharacters are literal",
entries: []git.TreeEntry{
{Path: "literal[set]/file.dat", Type: "blob", Size: 20, HasSize: true},
{Path: "literalx/file.dat", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"literal[set]"},
wantPath: "literalx/file.dat",
},
{
name: "unmatched exclusion",
entries: []git.TreeEntry{
{Path: "selected.dat", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"not-present"},
wantPath: "selected.dat",
},
{
name: "exact file exclusion",
entries: []git.TreeEntry{
{Path: "selected.dat", Type: "blob", Size: 20, HasSize: true},
{Path: "other.dat", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"selected.dat"},
wantPath: "other.dat",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := checkFileSize(&CandidateTree{entries: test.entries}, &FileSizeSettings{
LimitBytes: int64Pointer(10),
Exclusions: test.exclusions,
})
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)
}
})
}
}
func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "small.bin", []byte{0, 1})
writeTreeFile(t, repository, "boundary.bin", []byte{0, 1, 2})
if err := os.Symlink("small.bin", filepath.Join(repository, "link")); err != nil {
t.Fatal(err)
}
submodule := newTreeRepository(t)
writeTreeFile(t, submodule, "submodule.txt", []byte("submodule"))
gitTree(t, submodule, "add", "--all")
gitTree(t, submodule, "commit", "-m", "create nested source")
submoduleCommit := strings.TrimSpace(gitTree(t, submodule, "rev-parse", "HEAD"))
gitTree(t, repository, "add", "--all")
gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleCommit+",submodule")
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkFileSize(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"}})
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)
}
if failure.Measurement == nil || failure.Measurement.Bytes != int64(len("small.bin")) {
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"}})
if err != nil {
t.Fatalf("checkFileSize() error = %v, want omitted gitlink size", err)
}
}
func TestCheckFileSizeUsesOnlyCandidateFilesIndividually(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "historical-large.dat", []byte(strings.Repeat("h", 20)))
commitCandidateTree(t, repository)
if err := os.Remove(filepath.Join(repository, "historical-large.dat")); err != nil {
t.Fatal(err)
}
writeTreeFile(t, repository, "one.dat", []byte(strings.Repeat("a", 6)))
writeTreeFile(t, repository, "two.dat", []byte(strings.Repeat("b", 6)))
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
if err := checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(10)}); err != nil {
t.Fatalf("checkFileSize() error = %v, want current individual files to pass", err)
}
}
func TestRunRejectsNegativeFileSizeLimitBeforeGoChecks(t *testing.T) {
negative := int64(-1)
err := Run(context.Background(), Request{Settings: Settings{
FileSize: &FileSizeSettings{LimitBytes: &negative},
}})
failure, ok := err.(*Failure)
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || !strings.Contains(failure.Findings[0].Message, "negative") {
t.Fatalf("Run() error = %v, want invalid file-size limit failure", err)
}
if errors.Unwrap(failure) != nil {
t.Fatalf("invalid limit failure cause = %v, want none", failure.Cause)
}
}
func int64Pointer(value int64) *int64 { return &value }

View File

@@ -21,6 +21,13 @@ type Settings struct {
CompileTargets []Target
GoFormat bool
ModuleDistribution ModuleDistributionSettings
FileSize *FileSizeSettings
}
// FileSizeSettings selects a per-blob limit and literal path exclusions.
type FileSizeSettings struct {
LimitBytes *int64
Exclusions []string
}
// ModuleDistributionSettings selects candidate distribution checks.
@@ -51,6 +58,12 @@ func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, e
// Run executes shared validation for the captured candidate.
func Run(ctx context.Context, request Request) error {
if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 {
return &Failure{
Check: FileSize,
Findings: []Finding{{Message: "file size limit must not be negative"}},
}
}
for _, target := range request.Settings.CompileTargets {
if strings.TrimSpace(target.OS) == "" || strings.TrimSpace(target.Arch) == "" || strings.Contains(target.OS, "/") || strings.Contains(target.Arch, "/") {
return &Failure{
@@ -99,10 +112,13 @@ func Run(ctx context.Context, request Request) error {
}
}
}
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements {
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil {
tree, err := request.ReadCandidateTree(ctx)
if err != nil {
check := LocalReplacements
check := FileSize
if request.Settings.ModuleDistribution.LocalReplacements {
check = LocalReplacements
}
if request.Settings.ModuleDistribution.WorkspaceFiles {
check = WorkspaceFiles
}
@@ -126,6 +142,11 @@ func Run(ctx context.Context, request Request) error {
return err
}
}
if request.Settings.FileSize != nil {
if err := checkFileSize(tree, request.Settings.FileSize); err != nil {
return err
}
}
}
return nil
}