From f80970a9d414f4279879b5d1b65ad9de7a65dcdf Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 22:58:38 +0000 Subject: [PATCH] Check committed file sizes --- check.go | 33 ++++ cmd/releasekit/internal/cli/config.go | 12 ++ cmd/releasekit/internal/cli/config_test.go | 46 ++++- cmd/releasekit/main.go | 11 ++ cmd/releasekit/main_test.go | 5 + docs/cli.md | 6 + docs/config.md | 19 ++ docs/consumers/check.md | 8 + docs/consumers/publish.md | 8 + docs/internal/overview.md | 2 +- docs/internal/validation.md | 4 + docs/roadmap/implementation.md | 7 + internal/validation/diagnostic.go | 2 + internal/validation/file_size.go | 52 ++++++ internal/validation/file_size_test.go | 192 +++++++++++++++++++++ internal/validation/validation.go | 25 ++- validation_error.go | 2 +- validation_test.go | 32 +++- 18 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 internal/validation/file_size.go create mode 100644 internal/validation/file_size_test.go diff --git a/check.go b/check.go index 749b1d6..fde89d9 100644 --- a/check.go +++ b/check.go @@ -55,6 +55,19 @@ type ValidationSpec struct { // ModuleDistribution selects checks for files that affect module-source // distribution. Its zero value disables those checks. ModuleDistribution ModuleDistributionValidationSpec + // FileSize selects a committed-file size check. Nil disables it; an empty + // object selects the 1,000,000-byte default. + FileSize *FileSizeValidationSpec +} + +// FileSizeValidationSpec selects a maximum size for each committed blob. +type FileSizeValidationSpec struct { + // LimitBytes is the maximum decimal byte count. Nil selects 1,000,000; + // zero is a zero-byte limit, and negative values are invalid. + LimitBytes *int64 + // Exclusions are literal repository-relative file or directory paths. + // A directory exclusion includes committed descendants. + Exclusions []string } // ModuleDistributionValidationSpec selects independent module-source @@ -99,6 +112,9 @@ type GoValidationSpec struct { // Validation.ModuleDistribution.LocalReplacements parses committed go.mod // declarations, including nested modules, and rejects filesystem replacements // after workspace-file inspection. Its zero value disables that check. +// Validation.FileSize checks each selected committed blob after module +// distribution checks. Its nil value disables size checks; an empty object +// enables the default limit. // The zero values of all selections preserve the ordinary suite. If // Build selects targets, Check builds them after validation in temporary // storage outside the worktree, then rechecks @@ -117,6 +133,7 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { } func candidateRequest(request ReleaseRequest) candidate.Request { + fileSize := internalFileSizeSettings(request.Validation.FileSize) result := candidate.Request{ RepositoryDir: request.RepositoryDir, Product: request.Product, @@ -130,6 +147,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request { WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles, LocalReplacements: request.Validation.ModuleDistribution.LocalReplacements, }, + FileSize: fileSize, }, AdditionalChecks: candidateChecks(request.AdditionalChecks), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), @@ -141,6 +159,21 @@ func candidateRequest(request ReleaseRequest) candidate.Request { return result } +func internalFileSizeSettings(spec *FileSizeValidationSpec) *validation.FileSizeSettings { + if spec == nil { + return nil + } + var limitBytes *int64 + if spec.LimitBytes != nil { + limit := *spec.LimitBytes + limitBytes = &limit + } + return &validation.FileSizeSettings{ + LimitBytes: limitBytes, + Exclusions: append([]string(nil), spec.Exclusions...), + } +} + func candidateChecks(checks func(context.Context, Candidate) error) func(context.Context, candidate.Candidate) error { if checks == nil { return nil diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 4e9d5b8..91fb6d7 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -131,6 +131,15 @@ type ValidationSettings struct { // Omitted or false disables the check. GoFormat bool `json:"go_format,omitempty"` ModuleDistribution ModuleDistributionSettings `json:"module_distribution,omitempty"` + // FileSize checks the size of each committed blob. Null or omission disables + // the check; an empty object enables the default limit. + FileSize *FileSizeSettings `json:"file_size,omitempty"` +} + +// FileSizeSettings selects the maximum committed-file size and literal exclusions. +type FileSizeSettings struct { + LimitBytes *int64 `json:"limit_bytes,omitempty"` + Exclusions []string `json:"exclusions,omitempty"` } // ModuleDistributionSettings selects committed module-source distribution checks. @@ -236,6 +245,9 @@ func LoadSettings(repoDir, configPath string) (Settings, error) { } } if settings.Validation != nil { + if fileSize := settings.Validation.FileSize; fileSize != nil && fileSize.LimitBytes != nil && *fileSize.LimitBytes < 0 { + return Settings{}, fmt.Errorf("profile %q: validation.file_size.limit_bytes must not be negative", configPath) + } for i, target := range settings.Validation.Go.CompileTargets { if strings.Count(target, "/") != 1 { return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i) diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index b085e7d..3b4c2d7 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -144,7 +144,7 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) { func TestLoadSettingsReadsGoValidation(t *testing.T) { repoDir := t.TempDir() profilePath := filepath.Join(repoDir, "releasekit.json") - if err := os.WriteFile(profilePath, []byte(`{"product":"Example","expected_push_url":"ssh://git.example/owner/repo.git","validation":{"go_format":true,"module_distribution":{"workspace_files":true,"local_replacements":true},"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil { + if err := os.WriteFile(profilePath, []byte(`{"product":"Example","expected_push_url":"ssh://git.example/owner/repo.git","validation":{"go_format":true,"module_distribution":{"workspace_files":true,"local_replacements":true},"file_size":{"limit_bytes":0,"exclusions":["assets"]},"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil { t.Fatal(err) } @@ -155,6 +155,9 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) { if settings.Validation == nil || !settings.Validation.GoFormat || !settings.Validation.ModuleDistribution.WorkspaceFiles || !settings.Validation.ModuleDistribution.LocalReplacements || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet { t.Fatalf("validation settings = %#v, want Go formatting, workspace inspection, race, and full vet enabled", settings.Validation) } + if settings.Validation.FileSize == nil || settings.Validation.FileSize.LimitBytes == nil || *settings.Validation.FileSize.LimitBytes != 0 || len(settings.Validation.FileSize.Exclusions) != 1 || settings.Validation.FileSize.Exclusions[0] != "assets" { + t.Fatalf("file size settings = %#v, want explicit zero limit and exclusions", settings.Validation.FileSize) + } if got, want := strings.Join(settings.Validation.Go.CompileTargets, ","), "linux/amd64,linux/arm64,darwin/arm64"; got != want { t.Fatalf("compile targets = %q, want %q", got, want) } @@ -163,6 +166,47 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) { } } +func TestLoadSettingsPreservesEmptyAndNullFileSize(t *testing.T) { + for _, test := range []struct { + name string + profile string + enabled bool + }{ + {name: "empty object", profile: `{"product":"Example","validation":{"file_size":{}}}`, enabled: true}, + {name: "default limit", profile: `{"product":"Example","validation":{"file_size":{"limit_bytes":null}}}`, enabled: true}, + {name: "null object", profile: `{"product":"Example","validation":{"file_size":null}}`}, + {name: "omitted object", profile: `{"product":"Example","validation":{}}`}, + } { + t.Run(test.name, func(t *testing.T) { + repoDir := t.TempDir() + profilePath := filepath.Join(repoDir, "releasekit.json") + if err := os.WriteFile(profilePath, []byte(test.profile), 0o600); err != nil { + t.Fatal(err) + } + settings, err := LoadSettings(repoDir, profilePath) + if err != nil { + t.Fatal(err) + } + got := settings.Validation != nil && settings.Validation.FileSize != nil + if got != test.enabled { + t.Fatalf("FileSize enabled = %t, want %t", got, test.enabled) + } + }) + } +} + +func TestLoadSettingsRejectsNegativeFileSizeLimit(t *testing.T) { + repoDir := t.TempDir() + profilePath := filepath.Join(repoDir, "releasekit.json") + profile := `{"product":"Example","validation":{"file_size":{"limit_bytes":-1}}}` + if err := os.WriteFile(profilePath, []byte(profile), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadSettings(repoDir, profilePath); err == nil || !strings.Contains(err.Error(), "validation.file_size.limit_bytes") { + t.Fatalf("LoadSettings() error = %v, want invalid negative file-size limit", err) + } +} + func TestExampleProfileLoads(t *testing.T) { repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) if err != nil { diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index d0f3c54..1fdbaac 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -172,6 +172,17 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec CompileTargets: make([]releasekit.Target, len(settings.Go.CompileTargets)), }, } + if settings.FileSize != nil { + var limitBytes *int64 + if settings.FileSize.LimitBytes != nil { + limit := *settings.FileSize.LimitBytes + limitBytes = &limit + } + result.FileSize = &releasekit.FileSizeValidationSpec{ + LimitBytes: limitBytes, + Exclusions: append([]string(nil), settings.FileSize.Exclusions...), + } + } for index, target := range settings.Go.CompileTargets { goos, goarch, _ := strings.Cut(target, "/") result.Go.CompileTargets[index] = releasekit.Target{OS: goos, Arch: goarch} diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 857b451..809bd41 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -113,6 +113,7 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { } func TestCandidateRequestMapsGoValidation(t *testing.T) { + limit := int64(2048) request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{ Product: "Example Product", ExpectedPushURL: "publication-only value", @@ -120,6 +121,7 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { GoFormat: true, Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}}, ModuleDistribution: cli.ModuleDistributionSettings{WorkspaceFiles: true, LocalReplacements: true}, + FileSize: &cli.FileSizeSettings{LimitBytes: &limit, Exclusions: []string{"cache"}}, }, }, nil) if !request.Validation.Go.Race || !request.Validation.Go.FullVet { @@ -131,6 +133,9 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { if !request.Validation.ModuleDistribution.WorkspaceFiles || !request.Validation.ModuleDistribution.LocalReplacements { t.Fatalf("candidate request module distribution = %#v, want both checks enabled", request.Validation.ModuleDistribution) } + if request.Validation.FileSize == nil || request.Validation.FileSize.LimitBytes == nil || *request.Validation.FileSize.LimitBytes != limit || len(request.Validation.FileSize.Exclusions) != 1 || request.Validation.FileSize.Exclusions[0] != "cache" { + t.Fatalf("candidate request file size = %#v, want configured limit and exclusions", request.Validation.FileSize) + } if request.ExpectedPushURL != "" { t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL) } diff --git a/docs/cli.md b/docs/cli.md index f6e981d..445b79f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -91,6 +91,12 @@ after workspace-file inspection. Filesystem replacements use the `local-replacements` identity and include the declaration path, source line, and target; versioned replacements are permitted. Committed module symlinks are resolved within the candidate, regardless of `GOWORK`. +When `validation.file_size` is a non-null object, the command checks each +committed blob after module-distribution checks. An omitted or null object +disables the check; an empty object enables the 1,000,000 decimal-byte default. +Oversized files use the `file-size` identity and report their path and measured +size against the limit. See the [configuration reference](config.md) for +literal exclusions and the current-candidate scope. The optional profile field `expected_push_url` applies only to `publish`. A nonempty value must exactly match the effective push URL resolved by Git; diff --git a/docs/config.md b/docs/config.md index 3dcd0de..a691f41 100644 --- a/docs/config.md +++ b/docs/config.md @@ -79,6 +79,22 @@ For example: } ``` +When `validation.file_size` is an object, ReleaseKit compares each committed +blob's size with `limit_bytes`. The limit uses decimal bytes and rejects only +sizes strictly greater than the limit. An omitted or null object disables the +check; `{}` enables it with a default of 1,000,000 bytes. A missing or null +`limit_bytes` selects that default, zero is a valid limit, and negative limits +are invalid. + +`validation.file_size.exclusions` contains literal repository-relative file or +directory paths. Directory exclusions include committed descendants, using path +boundaries (`assets` does not exclude `assets-old`). Ordinary relative path +normalization applies; metacharacters are literal and unmatched paths have no +effect. The check measures regular and symlink blobs, including small binaries; +symlink size is the committed link text. Gitlinks have no measured size. Only +the captured candidate is checked, one offending file is reported at a time, +and historical or aggregate repository size is not measured. + The `delivery` object contains `base_url`, `owner`, `repository`, and optional `token_env`. Its values are required when running `deliver`, but credentials are not required for `check`, `publish`, or `build`. `token_env` names the @@ -111,6 +127,9 @@ validation parses committed `go.mod` files after workspace inspection and report filesystem replacements as `local-replacements`, with path, directive line, and target. Versioned replacements are permitted; parse and inspection failures preserve their causes. +When `validation.file_size` is selected, shared validation checks committed +blob sizes after module-distribution checks and reports the first oversized +path with the `file-size` identity and measured and limit byte counts. Configured `checks` commands run afterward, once each in order, as additional product checks. Artifact builds selected by the separate `build` setting run after shared validation and product checks. diff --git a/docs/consumers/check.md b/docs/consumers/check.md index 12a02ba..a577e9a 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -54,6 +54,14 @@ path, line, and target as `local-replacements`; versioned replacements are permitted. Committed module symlinks are resolved within the candidate. Parse and inspection errors retain their causes, and the rule applies even when `GOWORK=off`. Its zero value disables the check. +Set `Validation.FileSize` to check each committed blob against a byte limit. +Nil disables the check; an empty object enables the default of 1,000,000 +decimal bytes. `LimitBytes` is strict greater-than, accepts zero, and rejects +negative values. `Exclusions` contains literal repository-relative files or +directories; excluded directories include descendants. Symlink blobs are +measured by link-text bytes, and gitlinks are omitted. The check applies to the +captured candidate and reports one oversized file with its measurement as +`file-size`; it does not measure historical or aggregate size. ReleaseKit calls `AdditionalChecks` once after shared validation when supplied. It does not download a different toolchain automatically. If race prerequisites are unavailable, the `go-test` check fails rather than being skipped; a selected diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index 558ec70..e55a276 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -69,6 +69,14 @@ permitted. Committed module symlinks are resolved within the candidate, and parse or inspection errors retain their causes. This check is independent of `GOWORK`; its zero value disables it, and a failure stops publication before product checks or tag creation. +Set `Validation.FileSize` to check each committed blob against a byte limit. +Nil disables the check; an empty object enables the default of 1,000,000 +decimal bytes. `LimitBytes` uses strict greater-than, accepts zero, and rejects +negative values. `Exclusions` contains literal repository-relative files or +directories, including committed descendants of excluded directories. Symlink +blob size is its link text; gitlinks are omitted. This check measures only the +captured candidate and reports one oversized file with its measurement as +`file-size`; it stops publication before product checks or tag creation. If `Build` selects targets, ReleaseKit verifies them after the standard suite and additional checks, before creating the tag. The operation then rechecks the diff --git a/docs/internal/overview.md b/docs/internal/overview.md index bbf5ed2..274761c 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -46,7 +46,7 @@ these implemented packages: | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | -| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, structured failures, and lazy committed-file access for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | +| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, structured failures, and lazy committed-file access for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | diff --git a/docs/internal/validation.md b/docs/internal/validation.md index 4ce86a0..392a518 100644 --- a/docs/internal/validation.md +++ b/docs/internal/validation.md @@ -37,6 +37,10 @@ with an inspection cause when their selected contents are unavailable. A replacement with no parsed version is reported with its go.mod path, line, directive, and target; versioned replacements are allowed. It runs after the workspace-file check using the same candidate inventory. +The optional file-size check uses each blob's captured Git size, including +symlink link-text bytes, and skips entries without a blob size such as gitlinks. +It applies normalized literal file/directory exclusions with path-boundary +matching, then returns the first oversized path with `FileSizeMeasurement`. `CandidateTree` gets metadata and bytes from Git objects, reads blob bytes lazily, and resolves destinations without consulting the worktree or neighboring files. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 64ec91c..b57b3b0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -386,6 +386,13 @@ The rule applies to candidate contents even when testing disables workspace use. ## Stage 8: Inspect individual committed file sizes +**Completion:** Complete. `Validation.FileSize` and `validation.file_size` enable +a per-blob decimal-byte limit with a 1,000,000-byte default, strict greater-than +comparison, and literal repository-relative file/directory exclusions. The check +uses captured blob sizes, counts symlink link text, omits gitlinks, and reports +one oversized file with its measurement. Nil disables the check, and historical +or aggregate repository size is outside its scope. + **Outcome:** Optional limits and intentional path exceptions are operational. 1. Add `FileSizeValidationSpec`, `ValidationSpec.FileSize`, and matching JSON diff --git a/internal/validation/diagnostic.go b/internal/validation/diagnostic.go index 834bf95..85dbdf1 100644 --- a/internal/validation/diagnostic.go +++ b/internal/validation/diagnostic.go @@ -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. diff --git a/internal/validation/file_size.go b/internal/validation/file_size.go new file mode 100644 index 0000000..045ac57 --- /dev/null +++ b/internal/validation/file_size.go @@ -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 +} diff --git a/internal/validation/file_size_test.go b/internal/validation/file_size_test.go new file mode 100644 index 0000000..4414d31 --- /dev/null +++ b/internal/validation/file_size_test.go @@ -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 } diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 6228fc2..23c91cd 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -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 } diff --git a/validation_error.go b/validation_error.go index ef27da0..8580189 100644 --- a/validation_error.go +++ b/validation_error.go @@ -9,7 +9,7 @@ import ( // ValidationCheck identifies a shared validation check that failed. // Current checks include "go-test", "go-vet", "package-compile", "go-format", -// "workspace-files", and "local-replacements". +// "workspace-files", "local-replacements", and "file-size". type ValidationCheck string // ValidationFinding describes a failed check observation at an optional diff --git a/validation_test.go b/validation_test.go index c8f920a..747ae44 100644 --- a/validation_test.go +++ b/validation_test.go @@ -8,15 +8,19 @@ import ( ) func TestCandidateRequestMapsGoValidation(t *testing.T) { + limit := int64(4096) request := candidateRequest(ReleaseRequest{ Validation: ValidationSpec{Go: GoValidationSpec{ Race: true, FullVet: true, CompileTargets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, - }, GoFormat: true, ModuleDistribution: ModuleDistributionValidationSpec{WorkspaceFiles: true, LocalReplacements: true}}, + }, GoFormat: true, ModuleDistribution: ModuleDistributionValidationSpec{WorkspaceFiles: true, LocalReplacements: true}, FileSize: &FileSizeValidationSpec{LimitBytes: &limit, Exclusions: []string{"assets"}}}, }) if !request.Validation.Race || !request.Validation.FullVet || !request.Validation.GoFormat || !request.Validation.ModuleDistribution.WorkspaceFiles || !request.Validation.ModuleDistribution.LocalReplacements { t.Fatalf("candidate request validation = %#v, want race, full vet, formatting, and distribution checks enabled", request.Validation) } + if request.Validation.FileSize == nil || request.Validation.FileSize.LimitBytes == nil || *request.Validation.FileSize.LimitBytes != limit || len(request.Validation.FileSize.Exclusions) != 1 || request.Validation.FileSize.Exclusions[0] != "assets" { + t.Fatalf("candidate request file size = %#v, want configured limit and exclusions", request.Validation.FileSize) + } want := []validation.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}} if len(request.Validation.CompileTargets) != len(want) { t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.CompileTargets, want) @@ -28,6 +32,17 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { } } +func TestCandidateRequestPreservesFileSizeObjectSelection(t *testing.T) { + disabled := candidateRequest(ReleaseRequest{}) + enabled := candidateRequest(ReleaseRequest{Validation: ValidationSpec{FileSize: &FileSizeValidationSpec{}}}) + if disabled.Validation.FileSize != nil { + t.Fatalf("omitted FileSize = %#v, want disabled", disabled.Validation.FileSize) + } + if enabled.Validation.FileSize == nil || enabled.Validation.FileSize.LimitBytes != nil { + t.Fatalf("empty FileSize = %#v, want enabled default selection", enabled.Validation.FileSize) + } +} + func TestValidationErrorPreservesLocalReplacementsIdentityAndFinding(t *testing.T) { cause := errors.New("module declaration could not be read") got := convertValidationError(&validation.Failure{ @@ -53,6 +68,21 @@ func TestValidationErrorPreservesLocalReplacementsIdentityAndFinding(t *testing. } } +func TestValidationErrorPreservesFileSizeMeasurement(t *testing.T) { + got := convertValidationError(&validation.Failure{ + Check: validation.FileSize, + Findings: []validation.Finding{{Path: "assets/bundle.bin", Message: "committed file exceeds the selected size limit"}}, + Measurement: &validation.FileSizeMeasurement{Bytes: 12, LimitBytes: 10}, + }) + var validationError *ValidationError + if !errors.As(got, &validationError) || validationError.Check != "file-size" { + t.Fatalf("converted error = %#v, want file-size identity", got) + } + if validationError.Measurement == nil || validationError.Measurement.Bytes != 12 || validationError.Measurement.LimitBytes != 10 { + t.Fatalf("converted measurement = %#v, want 12 bytes against 10", validationError.Measurement) + } +} + func TestValidationErrorPreservesGoVetIdentity(t *testing.T) { cause := errors.New("vet failed") got := convertValidationError(&validation.Failure{Check: validation.GoVet, Cause: cause})