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

@@ -55,6 +55,19 @@ type ValidationSpec struct {
// ModuleDistribution selects checks for files that affect module-source // ModuleDistribution selects checks for files that affect module-source
// distribution. Its zero value disables those checks. // distribution. Its zero value disables those checks.
ModuleDistribution ModuleDistributionValidationSpec 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 // ModuleDistributionValidationSpec selects independent module-source
@@ -99,6 +112,9 @@ type GoValidationSpec struct {
// Validation.ModuleDistribution.LocalReplacements parses committed go.mod // Validation.ModuleDistribution.LocalReplacements parses committed go.mod
// declarations, including nested modules, and rejects filesystem replacements // declarations, including nested modules, and rejects filesystem replacements
// after workspace-file inspection. Its zero value disables that check. // 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 // The zero values of all selections preserve the ordinary suite. If
// Build selects targets, Check builds them after validation in temporary // Build selects targets, Check builds them after validation in temporary
// storage outside the worktree, then rechecks // 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 { func candidateRequest(request ReleaseRequest) candidate.Request {
fileSize := internalFileSizeSettings(request.Validation.FileSize)
result := candidate.Request{ result := candidate.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Product: request.Product, Product: request.Product,
@@ -130,6 +147,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles, WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles,
LocalReplacements: request.Validation.ModuleDistribution.LocalReplacements, LocalReplacements: request.Validation.ModuleDistribution.LocalReplacements,
}, },
FileSize: fileSize,
}, },
AdditionalChecks: candidateChecks(request.AdditionalChecks), AdditionalChecks: candidateChecks(request.AdditionalChecks),
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
@@ -141,6 +159,21 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
return result 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 { func candidateChecks(checks func(context.Context, Candidate) error) func(context.Context, candidate.Candidate) error {
if checks == nil { if checks == nil {
return nil return nil

View File

@@ -131,6 +131,15 @@ type ValidationSettings struct {
// Omitted or false disables the check. // Omitted or false disables the check.
GoFormat bool `json:"go_format,omitempty"` GoFormat bool `json:"go_format,omitempty"`
ModuleDistribution ModuleDistributionSettings `json:"module_distribution,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. // ModuleDistributionSettings selects committed module-source distribution checks.
@@ -236,6 +245,9 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
} }
} }
if settings.Validation != nil { 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 { for i, target := range settings.Validation.Go.CompileTargets {
if strings.Count(target, "/") != 1 { if strings.Count(target, "/") != 1 {
return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i) return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i)

View File

@@ -144,7 +144,7 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) {
func TestLoadSettingsReadsGoValidation(t *testing.T) { func TestLoadSettingsReadsGoValidation(t *testing.T) {
repoDir := t.TempDir() repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json") 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) 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 { 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) 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 { 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) 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) { func TestExampleProfileLoads(t *testing.T) {
repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
if err != nil { if err != nil {

View File

@@ -172,6 +172,17 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
CompileTargets: make([]releasekit.Target, len(settings.Go.CompileTargets)), 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 { for index, target := range settings.Go.CompileTargets {
goos, goarch, _ := strings.Cut(target, "/") goos, goarch, _ := strings.Cut(target, "/")
result.Go.CompileTargets[index] = releasekit.Target{OS: goos, Arch: goarch} result.Go.CompileTargets[index] = releasekit.Target{OS: goos, Arch: goarch}

View File

@@ -113,6 +113,7 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
} }
func TestCandidateRequestMapsGoValidation(t *testing.T) { func TestCandidateRequestMapsGoValidation(t *testing.T) {
limit := int64(2048)
request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{ request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{
Product: "Example Product", Product: "Example Product",
ExpectedPushURL: "publication-only value", ExpectedPushURL: "publication-only value",
@@ -120,6 +121,7 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
GoFormat: true, GoFormat: true,
Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}}, Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}},
ModuleDistribution: cli.ModuleDistributionSettings{WorkspaceFiles: true, LocalReplacements: true}, ModuleDistribution: cli.ModuleDistributionSettings{WorkspaceFiles: true, LocalReplacements: true},
FileSize: &cli.FileSizeSettings{LimitBytes: &limit, Exclusions: []string{"cache"}},
}, },
}, nil) }, nil)
if !request.Validation.Go.Race || !request.Validation.Go.FullVet { 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 { if !request.Validation.ModuleDistribution.WorkspaceFiles || !request.Validation.ModuleDistribution.LocalReplacements {
t.Fatalf("candidate request module distribution = %#v, want both checks enabled", request.Validation.ModuleDistribution) 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 != "" { if request.ExpectedPushURL != "" {
t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL) t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL)
} }

View File

@@ -91,6 +91,12 @@ after workspace-file inspection. Filesystem replacements use the
`local-replacements` identity and include the declaration path, source line, `local-replacements` identity and include the declaration path, source line,
and target; versioned replacements are permitted. Committed module symlinks are and target; versioned replacements are permitted. Committed module symlinks are
resolved within the candidate, regardless of `GOWORK`. 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 The optional profile field `expected_push_url` applies only to `publish`. A
nonempty value must exactly match the effective push URL resolved by Git; nonempty value must exactly match the effective push URL resolved by Git;

View File

@@ -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 The `delivery` object contains `base_url`, `owner`, `repository`, and optional
`token_env`. Its values are required when running `deliver`, but credentials are `token_env`. Its values are required when running `deliver`, but credentials are
not required for `check`, `publish`, or `build`. `token_env` names the 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 filesystem replacements as `local-replacements`, with path, directive line, and
target. Versioned replacements are permitted; parse and inspection failures target. Versioned replacements are permitted; parse and inspection failures
preserve their causes. 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 Configured `checks` commands run afterward, once each in order, as additional
product checks. Artifact builds selected by the separate `build` setting run product checks. Artifact builds selected by the separate `build` setting run
after shared validation and product checks. after shared validation and product checks.

View File

@@ -54,6 +54,14 @@ path, line, and target as `local-replacements`; versioned replacements are
permitted. Committed module symlinks are resolved within the candidate. Parse permitted. Committed module symlinks are resolved within the candidate. Parse
and inspection errors retain their causes, and the rule applies even when and inspection errors retain their causes, and the rule applies even when
`GOWORK=off`. Its zero value disables the check. `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. ReleaseKit calls `AdditionalChecks` once after shared validation when supplied.
It does not download a different toolchain automatically. If race prerequisites It does not download a different toolchain automatically. If race prerequisites
are unavailable, the `go-test` check fails rather than being skipped; a selected are unavailable, the `go-test` check fails rather than being skipped; a selected

View File

@@ -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 parse or inspection errors retain their causes. This check is independent of
`GOWORK`; its zero value disables it, and a failure stops publication before `GOWORK`; its zero value disables it, and a failure stops publication before
product checks or tag creation. 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 If `Build` selects targets, ReleaseKit verifies them after the standard suite
and additional checks, before creating the tag. The operation then rechecks the and additional checks, before creating the tag. The operation then rechecks the

View File

@@ -46,7 +46,7 @@ these implemented packages:
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `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/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/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |

View File

@@ -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, replacement with no parsed version is reported with its go.mod path, line,
directive, and target; versioned replacements are allowed. It runs after the directive, and target; versioned replacements are allowed. It runs after the
workspace-file check using the same candidate inventory. 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 `CandidateTree` gets metadata and bytes from Git objects, reads blob bytes
lazily, and resolves destinations without consulting the worktree or lazily, and resolves destinations without consulting the worktree or
neighboring files. neighboring files.

View File

@@ -386,6 +386,13 @@ The rule applies to candidate contents even when testing disables workspace use.
## Stage 8: Inspect individual committed file sizes ## 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. **Outcome:** Optional limits and intentional path exceptions are operational.
1. Add `FileSizeValidationSpec`, `ValidationSpec.FileSize`, and matching JSON 1. Add `FileSizeValidationSpec`, `ValidationSpec.FileSize`, and matching JSON

View File

@@ -21,6 +21,8 @@ const (
WorkspaceFiles Check = "workspace-files" WorkspaceFiles Check = "workspace-files"
// LocalReplacements identifies filesystem replacements in committed modules. // LocalReplacements identifies filesystem replacements in committed modules.
LocalReplacements Check = "local-replacements" 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. // 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 CompileTargets []Target
GoFormat bool GoFormat bool
ModuleDistribution ModuleDistributionSettings 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. // 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. // Run executes shared validation for the captured candidate.
func Run(ctx context.Context, request Request) error { 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 { for _, target := range request.Settings.CompileTargets {
if strings.TrimSpace(target.OS) == "" || strings.TrimSpace(target.Arch) == "" || strings.Contains(target.OS, "/") || strings.Contains(target.Arch, "/") { if strings.TrimSpace(target.OS) == "" || strings.TrimSpace(target.Arch) == "" || strings.Contains(target.OS, "/") || strings.Contains(target.Arch, "/") {
return &Failure{ 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) tree, err := request.ReadCandidateTree(ctx)
if err != nil { if err != nil {
check := LocalReplacements check := FileSize
if request.Settings.ModuleDistribution.LocalReplacements {
check = LocalReplacements
}
if request.Settings.ModuleDistribution.WorkspaceFiles { if request.Settings.ModuleDistribution.WorkspaceFiles {
check = WorkspaceFiles check = WorkspaceFiles
} }
@@ -126,6 +142,11 @@ func Run(ctx context.Context, request Request) error {
return err return err
} }
} }
if request.Settings.FileSize != nil {
if err := checkFileSize(tree, request.Settings.FileSize); err != nil {
return err
}
}
} }
return nil return nil
} }

View File

@@ -9,7 +9,7 @@ import (
// ValidationCheck identifies a shared validation check that failed. // ValidationCheck identifies a shared validation check that failed.
// Current checks include "go-test", "go-vet", "package-compile", "go-format", // 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 type ValidationCheck string
// ValidationFinding describes a failed check observation at an optional // ValidationFinding describes a failed check observation at an optional

View File

@@ -8,15 +8,19 @@ import (
) )
func TestCandidateRequestMapsGoValidation(t *testing.T) { func TestCandidateRequestMapsGoValidation(t *testing.T) {
limit := int64(4096)
request := candidateRequest(ReleaseRequest{ request := candidateRequest(ReleaseRequest{
Validation: ValidationSpec{Go: GoValidationSpec{ Validation: ValidationSpec{Go: GoValidationSpec{
Race: true, FullVet: true, Race: true, FullVet: true,
CompileTargets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, 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 { 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) 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"}} want := []validation.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
if len(request.Validation.CompileTargets) != len(want) { if len(request.Validation.CompileTargets) != len(want) {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.CompileTargets, 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) { func TestValidationErrorPreservesLocalReplacementsIdentityAndFinding(t *testing.T) {
cause := errors.New("module declaration could not be read") cause := errors.New("module declaration could not be read")
got := convertValidationError(&validation.Failure{ 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) { func TestValidationErrorPreservesGoVetIdentity(t *testing.T) {
cause := errors.New("vet failed") cause := errors.New("vet failed")
got := convertValidationError(&validation.Failure{Check: validation.GoVet, Cause: cause}) got := convertValidationError(&validation.Failure{Check: validation.GoVet, Cause: cause})