diff --git a/cmd/releasekit/internal/cli/receipt.go b/cmd/releasekit/internal/cli/receipt.go index 9f50b46..a751a3d 100644 --- a/cmd/releasekit/internal/cli/receipt.go +++ b/cmd/releasekit/internal/cli/receipt.go @@ -2,7 +2,6 @@ package cli import ( "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -12,10 +11,12 @@ import ( "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" ) const BuildReceiptName = ".releasekit-build.json" +const BuildReceiptSchemaVersion = 1 type ReceiptTarget struct { OS string `json:"os"` @@ -31,6 +32,7 @@ type ReceiptArtifact struct { // BuildReceipt is private local coordination data for generated binaries. type BuildReceipt struct { + SchemaVersion int `json:"schema_version"` RepositoryDir string `json:"repository_dir"` ModuleDir string `json:"module_dir"` ModulePath string `json:"module_path"` @@ -52,7 +54,7 @@ func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) { return "", fmt.Errorf("resolve build receipt directory: %w", err) } receipt.OutputDir = resolvedDir - if err := validateBuildReceipt(receipt, true); err != nil { + if err := validateBuildReceipt(receipt); err != nil { return "", err } contents, err := json.MarshalIndent(receipt, "", " ") @@ -114,7 +116,10 @@ func LoadBuildReceipt(path string) (BuildReceipt, error) { } return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err) } - if err := validateBuildReceipt(receipt, true); err != nil { + if err := validateBuildReceipt(receipt); err != nil { + if receipt.SchemaVersion != BuildReceiptSchemaVersion { + return BuildReceipt{}, fmt.Errorf("%w; regenerate it with the updated build command", err) + } return BuildReceipt{}, err } if filepath.Clean(receipt.OutputDir) != filepath.Dir(resolvedPath) { @@ -128,22 +133,26 @@ func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, if err != nil || filepath.Clean(receipt.RepositoryDir) != filepath.Clean(resolvedRepo) { return false } - if receipt.CandidateCommit != commit || receipt.Version != version || receipt.MainPackage != settings.MainPackage || receipt.BinaryName != settings.BinaryName { + if receipt.CandidateCommit != commit || receipt.Version != version { return false } - want := normalizeReceiptTargets(settings.Targets) - if len(want) != len(receipt.Targets) { + want := artifact.NormalizeSettings(settings.MainPackage, settings.BinaryName, parseReceiptTargets(settings.Targets)) + got := artifact.NormalizeSettings(receipt.MainPackage, receipt.BinaryName, parseStoredTargets(receipt.Targets)) + if want.MainPackage != got.MainPackage || want.BinaryName != got.BinaryName || len(want.Targets) != len(got.Targets) { return false } - for i, target := range want { - if target != receipt.Targets[i] { + for i, target := range want.Targets { + if target != got.Targets[i] { return false } } return true } -func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error { +func validateBuildReceipt(receipt BuildReceipt) error { + if receipt.SchemaVersion != BuildReceiptSchemaVersion { + return fmt.Errorf("unsupported or missing build receipt schema version %d", receipt.SchemaVersion) + } if !versioncontract.ValidStableVersion(receipt.Version) { return fmt.Errorf("build receipt has invalid version %q", receipt.Version) } @@ -153,6 +162,9 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error { if strings.TrimSpace(receipt.ModulePath) == "" || strings.TrimSpace(receipt.Tag) == "" { return errors.New("build receipt is missing module path or full tag") } + if err := validateRecordedIdentity(receipt); err != nil { + return fmt.Errorf("build receipt has inconsistent module identity: %w", err) + } if !validCommitID(receipt.CandidateCommit) { return errors.New("build receipt has invalid candidate commit") } @@ -197,39 +209,38 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error { if !validSHA256(artifact.SHA256) { return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename) } - if verifyFiles { - if err := verifyReceiptArtifact(artifact); err != nil { - return err - } - } } return nil } -func verifyReceiptArtifact(artifact ReceiptArtifact) error { - info, err := os.Lstat(artifact.Path) +func validateRecordedIdentity(receipt BuildReceipt) error { + repositoryDir := filepath.Clean(receipt.RepositoryDir) + moduleDir := filepath.Clean(receipt.ModuleDir) + relative, err := filepath.Rel(repositoryDir, moduleDir) if err != nil { - return fmt.Errorf("inspect build artifact %q: %w", artifact.Path, err) + return fmt.Errorf("relate module directory to repository: %w", err) } - if !info.Mode().IsRegular() { - return fmt.Errorf("build artifact %q is not a regular file", artifact.Path) + if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + return errors.New("module directory is outside repository") } - file, err := os.Open(artifact.Path) + if relative == "" { + relative = "." + } + identity, err := releaseidentity.Derive(releaseidentity.Selection{ + RepositoryDir: repositoryDir, + ModuleDir: moduleDir, + ModuleSubdir: filepath.ToSlash(relative), + }, []byte("module "+receipt.ModulePath+"\n"), receipt.Version) if err != nil { - return fmt.Errorf("open build artifact %q: %w", artifact.Path, err) + return err } - defer file.Close() - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return fmt.Errorf("hash build artifact %q: %w", artifact.Path, err) - } - if actual := hex.EncodeToString(hash.Sum(nil)); actual != artifact.SHA256 { - return fmt.Errorf("build artifact %q hash changed: got %s, expected %s", artifact.Path, actual, artifact.SHA256) + if identity.Tag != receipt.Tag { + return fmt.Errorf("full tag %q does not match module selection and version; want %q", receipt.Tag, identity.Tag) } return nil } -func normalizeReceiptTargets(targets []string) []ReceiptTarget { +func parseReceiptTargets(targets []string) []artifact.Target { parsed := make([]artifact.Target, 0, len(targets)) for _, value := range targets { goos, goarch, ok := strings.Cut(value, "/") @@ -238,12 +249,15 @@ func normalizeReceiptTargets(targets []string) []ReceiptTarget { } parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch}) } - normalized := artifact.NormalizeTargets(parsed) - result := make([]ReceiptTarget, 0, len(normalized)) - for _, target := range normalized { - result = append(result, ReceiptTarget{OS: target.OS, Arch: target.Arch}) + return parsed +} + +func parseStoredTargets(targets []ReceiptTarget) []artifact.Target { + parsed := make([]artifact.Target, 0, len(targets)) + for _, target := range targets { + parsed = append(parsed, artifact.Target{OS: target.OS, Arch: target.Arch}) } - return result + return parsed } func validCommitID(value string) bool { diff --git a/cmd/releasekit/internal/cli/receipt_test.go b/cmd/releasekit/internal/cli/receipt_test.go index e6e1c43..47a07e7 100644 --- a/cmd/releasekit/internal/cli/receipt_test.go +++ b/cmd/releasekit/internal/cli/receipt_test.go @@ -3,65 +3,86 @@ package cli import ( "crypto/sha256" "encoding/hex" + "encoding/json" "os" "path/filepath" "strings" "testing" ) -func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) { - outputDir := t.TempDir() - repositoryDir := t.TempDir() - filename := "example-v1.2.3-linux-amd64" - artifactPath := filepath.Join(outputDir, filename) - contents := []byte("built application") - if err := os.WriteFile(artifactPath, contents, 0o755); err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(contents) - receipt := BuildReceipt{ - RepositoryDir: repositoryDir, - ModuleDir: repositoryDir, - ModulePath: "example.com/receipt", - Tag: "v1.2.3", - CandidateCommit: strings.Repeat("a", 40), - Version: "v1.2.3", - MainPackage: "./cmd/example", - BinaryName: "example", - Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, - Compiler: "go version go1.25.0 linux/amd64", - Artifacts: []ReceiptArtifact{{ - Path: artifactPath, - Filename: filename, - Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, - SHA256: hex.EncodeToString(digest[:]), - }}, - } - path, err := WriteBuildReceipt(outputDir, receipt) - if err != nil { - t.Fatal(err) - } - loaded, err := LoadBuildReceipt(path) - if err != nil { - t.Fatal(err) - } - settings := BuildSettings{ - MainPackage: "./cmd/example", - BinaryName: "example", - Targets: []string{"linux/amd64", "linux/amd64"}, - } - if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { - t.Fatalf("receipt does not match normalized build settings: %#v", loaded) - } - settings.BinaryName = "other" - if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { - t.Fatal("receipt matched changed build settings") - } - if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil { - t.Fatal(err) - } - if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "hash changed") { - t.Fatalf("LoadBuildReceipt() error = %v, want changed-file hash error", err) +func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) { + for _, test := range []struct { + name string + moduleDir string + modulePath string + tag string + }{ + {name: "root", moduleDir: ".", modulePath: "example.com/receipt", tag: "v1.2.3"}, + {name: "nested", moduleDir: "integrations/ckconfig", modulePath: "example.com/ckconfig", tag: "integrations/ckconfig/v1.2.3"}, + } { + t.Run(test.name, func(t *testing.T) { + outputDir := t.TempDir() + repositoryDir := t.TempDir() + moduleDir := repositoryDir + if test.moduleDir != "." { + moduleDir = filepath.Join(repositoryDir, filepath.FromSlash(test.moduleDir)) + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + t.Fatal(err) + } + } + filename := "example-v1.2.3-linux-amd64" + artifactPath := filepath.Join(outputDir, filename) + contents := []byte("built application") + if err := os.WriteFile(artifactPath, contents, 0o755); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(contents) + receipt := BuildReceipt{ + SchemaVersion: BuildReceiptSchemaVersion, + RepositoryDir: repositoryDir, + ModuleDir: moduleDir, + ModulePath: test.modulePath, + Tag: test.tag, + CandidateCommit: strings.Repeat("a", 40), + Version: "v1.2.3", + MainPackage: "./cmd/example", + BinaryName: "example", + Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, + Compiler: "go version go1.25.0 linux/amd64", + Artifacts: []ReceiptArtifact{{ + Path: artifactPath, Filename: filename, + Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: hex.EncodeToString(digest[:]), + }}, + } + path, err := WriteBuildReceipt(outputDir, receipt) + if err != nil { + t.Fatal(err) + } + loaded, err := LoadBuildReceipt(path) + if err != nil { + t.Fatal(err) + } + settings := BuildSettings{MainPackage: "./cmd/example", BinaryName: "example", Targets: []string{"linux/amd64", "linux/amd64"}} + if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { + t.Fatalf("receipt does not match normalized build settings: %#v", loaded) + } + settings.BinaryName = "other" + if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { + t.Fatal("receipt matched changed build settings") + } + if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := LoadBuildReceipt(path); err != nil { + t.Fatalf("receipt loading verified changed artifact bytes: %v", err) + } + if err := os.Remove(artifactPath); err != nil { + t.Fatal(err) + } + if _, err := LoadBuildReceipt(path); err != nil { + t.Fatalf("receipt loading required artifact bytes: %v", err) + } + }) } } @@ -71,7 +92,9 @@ func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) { body string want string }{ - {name: "unknown field", body: `{"unexpected":true}`, want: "unknown field"}, + {name: "unknown field", body: `{"schema_version":1,"unexpected":true}`, want: "unknown field"}, + {name: "unversioned receipt", body: `{}`, want: "regenerate it with the updated build command"}, + {name: "unknown schema", body: `{"schema_version":2}`, want: "regenerate it with the updated build command"}, {name: "multiple values", body: `{} {}`, want: "multiple JSON values"}, } { t.Run(test.name, func(t *testing.T) { @@ -85,3 +108,66 @@ func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) { }) } } + +func TestWriteBuildReceiptRejectsInconsistentRecordedIdentity(t *testing.T) { + repositoryDir := t.TempDir() + moduleDir := filepath.Join(repositoryDir, "integrations", "ckconfig") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + t.Fatal(err) + } + outputDir := t.TempDir() + receipt := BuildReceipt{ + SchemaVersion: BuildReceiptSchemaVersion, + RepositoryDir: repositoryDir, ModuleDir: moduleDir, ModulePath: "example.com/ckconfig", Tag: "v1.2.3", + CandidateCommit: strings.Repeat("a", 40), Version: "v1.2.3", MainPackage: ".", BinaryName: "helper", + OutputDir: outputDir, Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, Compiler: "go version fixture", + Artifacts: []ReceiptArtifact{{ + Path: filepath.Join(outputDir, "helper-v1.2.3-linux-amd64"), Filename: "helper-v1.2.3-linux-amd64", + Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: strings.Repeat("a", 64), + }}, + } + if _, err := WriteBuildReceipt(outputDir, receipt); err == nil || !strings.Contains(err.Error(), "full tag") { + t.Fatalf("WriteBuildReceipt() error = %v, want inconsistent full tag", err) + } +} + +func TestLoadBuildReceiptRejectsMismatchedModuleIdentity(t *testing.T) { + repositoryDir := t.TempDir() + outputDir := t.TempDir() + artifactPath := filepath.Join(outputDir, "helper-v1.2.3-linux-amd64") + contents := []byte("fixture") + if err := os.WriteFile(artifactPath, contents, 0o600); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(contents) + receipt := BuildReceipt{ + SchemaVersion: BuildReceiptSchemaVersion, + RepositoryDir: repositoryDir, ModuleDir: repositoryDir, ModulePath: "example.com/receipt", Tag: "v1.2.3", + CandidateCommit: strings.Repeat("a", 40), Version: "v1.2.3", MainPackage: ".", BinaryName: "helper", + OutputDir: outputDir, Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, Compiler: "go version fixture", + Artifacts: []ReceiptArtifact{{ + Path: artifactPath, Filename: filepath.Base(artifactPath), + Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: hex.EncodeToString(digest[:]), + }}, + } + contents, err := json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(contents, &fields); err != nil { + t.Fatal(err) + } + fields["module_dir"] = filepath.Join(repositoryDir, "integrations", "ckconfig") + contents, err = json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(outputDir, BuildReceiptName) + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "inconsistent module identity") { + t.Fatalf("LoadBuildReceipt() error = %v, want inconsistent module identity", err) + } +} diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 7b31613..8742674 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -394,6 +394,7 @@ func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec { func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt { receipt := cli.BuildReceipt{ + SchemaVersion: cli.BuildReceiptSchemaVersion, RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index ed2539a..b365256 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -381,7 +381,7 @@ func main() { BinaryName: "fixture", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}, } - if !receipt.MatchesBuild(repository, commit, "v1.2.3", settings) || len(receipt.Artifacts) != 1 { + if receipt.SchemaVersion != cli.BuildReceiptSchemaVersion || !receipt.MatchesBuild(repository, commit, "v1.2.3", settings) || len(receipt.Artifacts) != 1 { t.Fatalf("build receipt = %#v", receipt) } if !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].Filename)) || !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].SHA256)) { @@ -881,7 +881,7 @@ func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) { } func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testing.T) { - for _, mode := range []string{"valid receipt", "tampered", "stale settings"} { + for _, mode := range []string{"valid receipt", "tampered", "missing artifact", "stale settings"} { t.Run(mode, func(t *testing.T) { repository := newCLIRepository(t) state := &deliveryServerState{} @@ -902,6 +902,16 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin t.Fatal(err) } } + if mode == "missing artifact" { + if err := os.Remove(artifactPath); err != nil { + t.Fatal(err) + } + } + if mode == "tampered" || mode == "missing artifact" { + if _, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName)); err != nil { + t.Fatalf("receipt loading performed artifact verification: %v", err) + } + } t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") var stdout, stderr bytes.Buffer status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr) @@ -917,6 +927,11 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 { t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.apiRequests, stdout.String(), stderr.String()) } + if mode == "tampered" || mode == "missing artifact" { + if strings.Contains(stderr.String(), "load verified build receipt") { + t.Fatalf("%s artifact was rejected while loading its receipt: %q", mode, stderr.String()) + } + } }) } } @@ -937,6 +952,7 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet digest := sha256.Sum256(content) target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"} receipt := cli.BuildReceipt{ + SchemaVersion: cli.BuildReceiptSchemaVersion, RepositoryDir: resolvedRepository, ModuleDir: resolvedRepository, ModulePath: "example.com/cli-candidate", Tag: "v1.2.3", CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"), SourceVerified: true, diff --git a/docs/consumers/build.md b/docs/consumers/build.md index 66f0af6..a15a426 100644 --- a/docs/consumers/build.md +++ b/docs/consumers/build.md @@ -83,6 +83,8 @@ points inside the source tree. For delivery from a tagged checkout, `deliver` builds selected tagged-source targets into temporary storage by default. Use `--assets DIR` to deliver a -separate build output; ReleaseKit requires its receipt and verifies the source, -build settings, and artifact hashes before the first Gitea write. See the +separate build output; ReleaseKit requires the current module-bound receipt and +checks its source identity and normalized build settings. It verifies artifact +bytes and sizes during delivery before the first Gitea write. Older receipts +must be regenerated with the updated `build` command. See the [delivery consumer guide](delivery.md) for page-only delivery and retry states. diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md index acf3dea..96b369f 100644 --- a/docs/consumers/delivery.md +++ b/docs/consumers/delivery.md @@ -89,9 +89,11 @@ RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver --assets dist v1.2.3 The build command does not need delivery credentials. The `--assets DIR` directory must contain the private `.releasekit-build.json` receipt created by -`build`. ReleaseKit checks the receipt's repository, commit, version, resolved -module directory and path, full tag, build settings, and file hashes before -making a remote request; caller-owned files remain available for retries. +`build`. ReleaseKit checks its schema, repository, commit, version, resolved +module directory and path, full tag, normalized build settings, and artifact +declarations. Old receipts must be regenerated with the updated `build` +command. Delivery verifies the artifact bytes and sizes before making a hosted +request; caller-owned files remain available for retries. Ordinary `deliver` does not download application dependencies or change the checkout. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f7d9790..20d5a1b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -499,6 +499,15 @@ changed or missing artifact bytes fails during delivery before hosted requests. Protect verification ownership through an observable read boundary rather than tests tied to private helper choreography. Run receipt and CLI prebuilt tests. +**Completed:** Replaced the unversioned receipt with schema version 1 and pure +recorded-identity checks for module path, physical directory, version, and full +tag. Receipt loading now validates structure and normalized settings without +opening artifacts; delivery remains responsible for verifying artifact bytes +and sizes before hosted requests. Old and unknown schemas require regeneration. +Verified root and nested receipts, malformed identity and settings, changed and +missing artifacts, `go test ./...`, `go run ./internal/apicheck`, and +`git diff --check`. + ## Stage 10: Map profiles and product commands to module operations **Outcome:** Local check, publication, and build commands express the new API with