Check committed file sizes
This commit is contained in:
@@ -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.
|
||||
|
||||
52
internal/validation/file_size.go
Normal file
52
internal/validation/file_size.go
Normal 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
|
||||
}
|
||||
192
internal/validation/file_size_test.go
Normal file
192
internal/validation/file_size_test.go
Normal 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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user