Check committed file sizes
This commit is contained in:
33
check.go
33
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user