diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9eaaa7..e7e3ddf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,9 +78,16 @@ jobs: - name: Check formatting shell: bash run: test -z "$(gofmt -l .)" + - run: go build ./... - run: go test ./... - run: go test -race ./... - run: go vet ./... + - name: Smoke test both command-line tools + shell: bash + run: | + go run ./cmd/worldcut-go --require-satisfied \ + ../../examples/coherent-deployment.json > /dev/null + go run ./cmd/worldcut-github-ci-go --help > /dev/null python: name: Python ${{ matrix.python }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b271bfa..ecfa73c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## Unreleased +- Added Go integrations to the `ports/go` module, closing the gap where the Go + port shipped a verifier but no adapters or integrations: + - `adapters.CaptureGitHead`, `adapters.CaptureHTTPObservation`, and + `adapters.CaptureKubernetesObservation`, behavior-compatible with the + TypeScript adapters, including exact branch-ref resolution, strong-ETag + promotion only, refused redirects, unread-and-closed response bodies, and + opaque Kubernetes `resourceVersion` handling; + - `githubactions.VerifyLatestWorkflow` and + `githubactions.InspectWorkflowEvidence`, the latest-completed-push + deployment gate and evidence coverage report, built on `net/http` with an + injectable client, API base URL, clock, and identifier source; + - `agenticdatakernel.ObservationFromResolution`, the structural Agentic Data + Kernel adapter, with no runtime kernel dependency; + - the `worldcut-github-ci-go` command, equivalent to `worldcut-github-ci`, + including stable JSON errors, exit status 2 unless the contract is + satisfied, and `verified_sha`/`workflow_run_id` in `GITHUB_OUTPUT`. +- Added a Go construction API so captured observations can be verified without + hand-assembling protocol JSON: `VerificationInput`, `ContractAssumptions`, + `SupportedAssumptions`, `ParseVerificationInput`, `VerifyDecisionContract`, + requirement constructors, `SnapshotJSONValue`, `ParseTimestamp`, and + `FormatTimestamp`. Constructed inputs are encoded and then validated by the + existing `ParseInput` path, so there is no second or weaker validation route, + and parsed snapshots and results remain mutation isolated. `ParseInput`, + `Verify`, and `VerifyJSON` are unchanged, and protocol 0.1 and engine 0.1.2 + outputs are unchanged. +- Added the stable Go integration error codes `WORLDCUT_GITHUB_API_ERROR`, + `WORLDCUT_GITHUB_RESPONSE_INVALID`, and `WORLDCUT_ADK_RESOLUTION_INVALID`, + plus `NewError`, `WrapError`, and cause unwrapping in `ErrorCode`. +- Added a Go GitHub Actions deployment-gate example workflow and documented the + Go integrations in the port README, root README, `docs/INTEGRATIONS.md`, + `docs/AGENTIC_DATA_KERNEL.md`, and `docs/VALIDATION.md`. The Go module keeps + its single `jcs` dependency and adds no GitHub, Kubernetes, or cloud SDK. - Added OIDC trusted-publishing workflows for Python `0.1.1` on PyPI and `WorldCut`/`WorldCut.Tool` `0.1.1` on NuGet.org, including protected tag validation, exact-artifact checks, and language-specific GitHub releases. diff --git a/README.md b/README.md index 3c1ceeb..55cd69e 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,13 @@ results, as described in | Port | Protocol / engine | Status | | --- | --- | --- | | TypeScript | 0.1 / 0.1.2 | Reference package with documented integrations | -| [Go](ports/go) | 0.1 / 0.1.2 | Independent conformant verifier and CLI; integrations not yet included | +| [Go](ports/go) | 0.1 / 0.1.2 | Independent conformant verifier, Go construction API, Git/HTTP/Kubernetes adapters, GitHub Actions gate, Agentic Data Kernel adapter, and two CLIs | | [Python](ports/python) | 0.1 / 0.1.2 | [`worldcut`](https://pypi.org/project/worldcut/0.1.1/) package and `worldcut-py` CLI for Python 3.11+ | | [.NET](ports/dotnet) | 0.1 / 0.1.2 | [`WorldCut`](https://www.nuget.org/packages/WorldCut/0.1.1) library and [`WorldCut.Tool`](https://www.nuget.org/packages/WorldCut.Tool/0.1.1) CLI for .NET 8 and .NET 10 | +Python and .NET currently implement the verifier and CLI only. The adapters +and integrations are available in TypeScript and Go. + Go is available through its public module tag, Python `0.1.1` is live on PyPI, and .NET `0.1.1` is live on NuGet.org. The Python release includes PEP 740 digital attestations bound to this repository and its protected release @@ -366,6 +369,9 @@ npm run feasibility Set `WORLDCUT_SAMPLE_GIT_REPO` to inspect another local Git repository. +The Go port implements the same three adapters in +[`ports/go/adapters`](ports/go/adapters). + ## GitHub Actions deployment gate The package includes a production-oriented gate for the latest completed @@ -391,6 +397,10 @@ In GitHub Actions the CLI writes `verified_sha` and `workflow_run_id` to [`examples/github-actions/deployment-gate.yml`](examples/github-actions/deployment-gate.yml) and [`docs/INTEGRATIONS.md`](docs/INTEGRATIONS.md). +The Go port provides the same gate as `worldcut-github-ci-go` and +`githubactions.VerifyLatestWorkflow`. See +[`examples/github-actions/deployment-gate-go.yml`](examples/github-actions/deployment-gate-go.yml). + ## Agentic Data Kernel `observationFromAgenticDataResolution` converts an eligible Agentic Data Kernel @@ -403,6 +413,9 @@ See [`docs/AGENTIC_DATA_KERNEL.md`](docs/AGENTIC_DATA_KERNEL.md) for the namespaced `basis.worldcut` contract and effect-gating guidance. +The Go equivalent is +`agenticdatakernel.ObservationFromResolution`. + ## JSON Schemas Immutable protocol 0.1 schemas are published with the package: diff --git a/docs/AGENTIC_DATA_KERNEL.md b/docs/AGENTIC_DATA_KERNEL.md index 10a4540..3dbe6e7 100644 --- a/docs/AGENTIC_DATA_KERNEL.md +++ b/docs/AGENTIC_DATA_KERNEL.md @@ -1,7 +1,9 @@ # Agentic Data Kernel integration WorldCut can consume resolved Agentic Data Kernel assertions without taking a -runtime dependency on the kernel package. +runtime dependency on the kernel package. The adapter is available in +TypeScript (`observationFromAgenticDataResolution`) and Go +(`agenticdatakernel.ObservationFromResolution`). The integration is structural by design: @@ -115,6 +117,38 @@ const observation = observationFromAgenticDataResolution(resolution, { The unresolved candidates remain relevant audit evidence and should be persisted with the verification record. +## Go + +The Go port provides the same structural adapter, with the same rejections and +the same `WORLDCUT_ADK_RESOLUTION_INVALID` error code: + +```go +import ( + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + adk "github.com/Jason-Doyle/WorldCut/ports/go/integrations/agenticdatakernel" +) + +head, err := adk.ObservationFromResolution(headResolution, adk.Options{}) +if err != nil { + return err +} +ci, err := adk.ObservationFromResolution(ciResolution, adk.Options{ + AllowResolvedWithConflict: true, +}) +if err != nil { + return err +} + +result, err := worldcut.VerifyDecisionContract(worldcut.VerificationInput{ + Contract: contract, + Observations: []worldcut.Observation{head, ci}, +}) +``` + +`Resolution` and `Assertion` are structural Go types. The kernel object and +`basis` values are supplied as ordinary Go values and are snapshotted, so the +returned observation never aliases caller state. + ## Persisting verification Persist the complete WorldCut input and result as an immutable kernel artifact, diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 2d954d3..3c717fa 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -1,5 +1,22 @@ # Integrations +Integrations are available in TypeScript and Go. Both implement the same +behavior; the tables below give the TypeScript name first and the Go name +second. + +| Integration | TypeScript | Go | +| --- | --- | --- | +| GitHub Actions gate | `verifyLatestGitHubWorkflow` | `githubactions.VerifyLatestWorkflow` | +| GitHub evidence coverage | `inspectGitHubWorkflowEvidence` | `githubactions.InspectWorkflowEvidence` | +| GitHub gate CLI | `worldcut-github-ci` | `worldcut-github-ci-go` | +| Git | `captureGitHead` | `adapters.CaptureGitHead` | +| HTTP | `captureHttpObservation` | `adapters.CaptureHTTPObservation` | +| Kubernetes | `captureKubernetesObservation` | `adapters.CaptureKubernetesObservation` | +| Agentic Data Kernel | `observationFromAgenticDataResolution` | `agenticdatakernel.ObservationFromResolution` | + +The Python and .NET ports implement the verifier only. There is no +TypeScript-only integration in the list above. + ## GitHub Actions deployment gate `verifyLatestGitHubWorkflow` checks the latest completed `push` run for an @@ -34,6 +51,29 @@ await deployCommit(verification.verifiedSha); Use a numeric workflow ID or a filename such as `ci.yml`. Display names are not accepted because they are not unambiguous identifiers. +In Go: + +```go +verification, err := githubactions.VerifyLatestWorkflow(ctx, githubactions.Options{ + Repository: "acme/payments", + Branch: "main", + Workflow: "ci.yml", + Token: os.Getenv("GITHUB_TOKEN"), +}) +if err != nil { + return err +} +if verification.VerifiedSHA == nil { + return fmt.Errorf("deployment blocked: %s", verification.Result.Verdict) +} +return deployCommit(ctx, *verification.VerifiedSHA) +``` + +The Go gate uses `net/http` only. `Options` accepts an injectable HTTP client, +API base URL, clock, and observation identifier source, so the gate is tested +without network access. Response bodies are bounded, redirects are refused, +and a token is never included in an error message. + The command-line gate is: ```sh @@ -43,6 +83,15 @@ worldcut-github-ci \ --workflow ci.yml ``` +The Go gate takes the same flags: + +```sh +worldcut-github-ci-go \ + --repository acme/payments \ + --branch main \ + --workflow ci.yml +``` + It exits with code `2` unless the contract is satisfied. In GitHub Actions it writes `verified_sha` and `workflow_run_id` to `GITHUB_OUTPUT`. @@ -57,28 +106,44 @@ Deploy `verifiedSha` or an immutable artifact built from that SHA. Never verify See [`examples/github-actions/deployment-gate.yml`](../examples/github-actions/deployment-gate.yml) -for a `workflow_run` example. +and +[`examples/github-actions/deployment-gate-go.yml`](../examples/github-actions/deployment-gate-go.yml) +for `workflow_run` examples. ## Native metadata adapters +Both implementations return an error rather than a success-shaped observation +when a provider call fails, and neither invents dependency or validity +metadata. + ### Git -`captureGitHead` validates and resolves an exact local branch ref and returns -its full commit SHA. Revision expressions such as `main~1` are rejected. +`captureGitHead` and `adapters.CaptureGitHead` validate and resolve an exact +local branch ref and return its full commit SHA. Revision expressions such as +`main~1` are rejected. ### HTTP -`captureHttpObservation` promotes only a syntactically valid strong ETag into -an exact version witness. Weak ETags and `Last-Modified` remain descriptive -metadata. +`captureHttpObservation` and `adapters.CaptureHTTPObservation` promote only a +syntactically valid strong ETag into an exact version witness. Weak ETags, the +wildcard, unquoted values, and `Last-Modified` remain descriptive metadata. +Redirects are never followed and the response body is never read. ### Kubernetes -`captureKubernetesObservation` records `metadata.resourceVersion` as an opaque -version token. Clients must not sort, parse, or treat it as a timestamp. +`captureKubernetesObservation` and `adapters.CaptureKubernetesObservation` +record `metadata.resourceVersion` as an opaque version token. Clients must not +sort, parse, or treat it as a timestamp. ## Agentic Data Kernel The structural adapter validates kernel resolution and assertion lifecycle semantics before producing a WorldCut observation. See [Agentic Data Kernel integration](AGENTIC_DATA_KERNEL.md). + +## Building a contract from captured observations + +The captured observations are ordinary protocol observations. In TypeScript +pass them to `verifyDecisionContract`; in Go pass them to +`worldcut.VerifyDecisionContract`, which applies exactly the same validation +and canonicalization as the JSON transport path. diff --git a/docs/PORT_RELEASES.md b/docs/PORT_RELEASES.md index 0e5b8a8..c522fcd 100644 --- a/docs/PORT_RELEASES.md +++ b/docs/PORT_RELEASES.md @@ -10,6 +10,12 @@ Python and .NET use registry-specific protected tag workflows: Current registry status: +- Go `ports/go/v0.2.0` is the published module tag. It adds the adapters, the + GitHub Actions gate, the + Agentic Data Kernel adapter, `worldcut-github-ci-go`, and the Go + construction API. Every change is backward compatible with `v0.1.0` — + `ParseInput`, `Verify`, and `VerifyJSON` keep their signatures and outputs — + so a minor version, not a major one, is correct. - .NET `0.1.1` is published as [`WorldCut`](https://www.nuget.org/packages/WorldCut/0.1.1) and [`WorldCut.Tool`](https://www.nuget.org/packages/WorldCut.Tool/0.1.1). diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index c8d1e87..9eb370b 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -31,6 +31,31 @@ This establishes that GitHub exposes the fields required by this integration for the inspected workflow history. It does not establish the same coverage for other providers or prove that an arbitrary workflow is trustworthy. +### Go implementation parity + +On 4 September 2026 the same public repository, branch, and workflow were +queried with both implementations. The TypeScript gate +(`node dist/github-ci-cli.js`) and the Go gate (`worldcut-github-ci-go`) +returned the same `branchSha`, the same `verifiedSha`, the same workflow run +identity, the same `CONTRACT_SATISFIED` verdict, and the same two satisfied +requirement summaries. The verification-record digests differ, as they must, +because each run carries its own observation timestamps and observation +identifiers. + +`inspectGitHubWorkflowEvidence` and `githubactions.InspectWorkflowEvidence` +returned identical coverage over the same 20 completed push runs: + +| Measurement | Result | +| --- | ---: | +| Completed `push` runs inspected | 20 | +| Runs with complete dependency and conclusion evidence | 20 | +| Evidence coverage | 100% | +| Conclusions | 19 `success`, 1 `cancelled` | + +This is a single live observation of one public repository. It is not a +continuous parity guarantee; the enforced cross-language guarantee remains the +verifier differential suite. + ## Package artifact `npm run test:package`: diff --git a/examples/github-actions/deployment-gate-go.yml b/examples/github-actions/deployment-gate-go.yml new file mode 100644 index 0000000..cb53fb0 --- /dev/null +++ b/examples/github-actions/deployment-gate-go.yml @@ -0,0 +1,41 @@ +name: Deploy verified main with the Go gate + +on: + workflow_run: + workflows: + - CI + branches: + - main + types: + - completed + +permissions: + actions: read + contents: read + +jobs: + gate: + if: github.event.workflow_run.event == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + + # The Go gate ships in the ports/go/v0.2.0 module tag. + - name: Install the WorldCut Go gate + run: go install github.com/Jason-Doyle/WorldCut/ports/go/cmd/worldcut-github-ci-go@v0.2.0 + + - name: Verify latest completed CI run + id: worldcut + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + worldcut-github-ci-go \ + --repository "$GITHUB_REPOSITORY" \ + --branch main \ + --workflow ci.yml + + - name: Deploy the immutable verified revision + run: | + echo "Deploy exactly ${{ steps.worldcut.outputs.verified_sha }}" diff --git a/ports/go/README.md b/ports/go/README.md index 088a28b..44e80ad 100644 --- a/ports/go/README.md +++ b/ports/go/README.md @@ -5,23 +5,43 @@ Independent Go implementation of WorldCut protocol **0.1** and engine ruleset acquisition planning, and verification-record digests without invoking Node.js or using generated TypeScript output. -The module requires Go 1.23 or newer. +It also ships the native Git, HTTP, and Kubernetes metadata adapters, the +GitHub Actions deployment gate, and the Agentic Data Kernel adapter, so a Go +program can capture evidence and verify a decision contract without leaving +the language. + +The module requires Go 1.23 or newer and depends only on the standard library +plus [`github.com/gowebpki/jcs`](https://github.com/gowebpki/jcs) for +RFC 8785 canonicalization. No GitHub, Kubernetes, or cloud SDK is used. ## Install ```sh go get github.com/Jason-Doyle/WorldCut/ports/go go install github.com/Jason-Doyle/WorldCut/ports/go/cmd/worldcut-go@latest +go install github.com/Jason-Doyle/WorldCut/ports/go/cmd/worldcut-github-ci-go@latest ``` -From a repository checkout: +The integrations and `worldcut-github-ci-go` ship in the `ports/go/v0.2.0` +module tag. From a repository checkout: ```sh cd ports/go -go build ./cmd/worldcut-go +go build ./cmd/... ``` -## Library use +## Packages + +| Import path | Contents | +| --- | --- | +| `github.com/Jason-Doyle/WorldCut/ports/go` | Protocol types, validation, verifier, canonicalization | +| `.../ports/go/adapters` | Git, HTTP, and Kubernetes metadata adapters | +| `.../ports/go/integrations/githubactions` | GitHub Actions deployment gate and evidence coverage | +| `.../ports/go/integrations/agenticdatakernel` | Structural Agentic Data Kernel adapter | +| `.../ports/go/cmd/worldcut-go` | Verification CLI | +| `.../ports/go/cmd/worldcut-github-ci-go` | GitHub Actions deployment gate CLI | + +## Verify transported JSON ```go source, err := os.ReadFile("verification.json") @@ -44,10 +64,157 @@ Import the module as: import worldcut "github.com/Jason-Doyle/WorldCut/ports/go" ``` +## Construct and verify in Go + +`VerifyDecisionContract` accepts a constructed `VerificationInput` and applies +exactly the same strict validation and canonicalization as `ParseInput`. There +is no second, weaker validation path: the document is encoded and parsed +through the same code the transport path uses. + +```go +result, err := worldcut.VerifyDecisionContract(worldcut.VerificationInput{ + Contract: worldcut.Contract{ + ID: "deploy-current-tested-head", + Version: "1", + DecisionTime: worldcut.FormatTimestamp(time.Now()), + Requirements: []worldcut.Requirement{ + worldcut.NewDependencyRequirement( + "ci-tested-current-head", + "The passing CI run tested the selected branch head", + "ci", + "head", + "tested_head", + ), + }, + }, + Observations: []worldcut.Observation{headObservation, ciObservation}, +}) +``` + +Use `ParseVerificationInput` when the same input is verified more than once; +it returns the immutable `*ParsedInput` snapshot that `Verify` consumes. + +Convenience defaults are limited to protocol constants: an empty +`ProtocolVersion` becomes `0.1` and a zero `ContractAssumptions` becomes +`SupportedAssumptions()`. Any other value must be supplied explicitly and is +validated normally. + +Supporting API: + +| Function | Purpose | +| --- | --- | +| `ParseInput`, `Verify`, `VerifyJSON` | Unchanged transport path | +| `ParseVerificationInput`, `VerifyDecisionContract` | Constructed inputs | +| `NewDependencyRequirement`, `NewCommonValidTimeRequirement`, `NewValueEqualsRequirement`, `Requirement.Advisory` | Requirement construction | +| `SnapshotJSONValue` | Validated, independent JSON snapshot of a Go value | +| `ParseTimestamp`, `FormatTimestamp` | Normalized UTC millisecond timestamps | +| `ErrorCode`, `NewError`, `WrapError` | Stable error codes | + +Snapshots and results are mutation isolated. A parsed input never aliases the +caller's maps or slices, and mutating a returned result cannot change a parsed +snapshot or a later verification. + +## Metadata adapters + +```go +head, err := adapters.CaptureGitHead(ctx, adapters.GitHeadOptions{ + RepositoryPath: ".", + RepositoryID: "payments", + Branch: "main", + Role: "head", +}) +``` + +| Adapter | Exact version witness | Behavior | +| --- | --- | --- | +| `CaptureGitHead` | Full commit SHA | Validates the branch with `git check-ref-format --branch` and resolves only `refs/heads/^{commit}`, so revision expressions such as `main~1` and missing refs are rejected | +| `CaptureHTTPObservation` | Syntactically valid strong `ETag` | Defaults to `HEAD`, never follows redirects, never reads the body, and always closes it; weak ETags, the wildcard, unquoted values, and `Last-Modified` stay descriptive | +| `CaptureKubernetesObservation` | Opaque `metadata.resourceVersion` | Structural input only; the value is never parsed, sorted, or treated as a timestamp, and no validity is inferred | + +Every adapter validates its options, bounds `AcquisitionCost` by +`worldcut.MaxAcquisitionCost`, accepts an injectable `Clock` and `NewID`, and +returns an error rather than a success-shaped observation when the provider +call fails. Identifiers are random version 4 UUIDs from `crypto/rand`. + +## GitHub Actions deployment gate + +```go +verification, err := githubactions.VerifyLatestWorkflow(ctx, githubactions.Options{ + Repository: "acme/payments", + Branch: "main", + Workflow: "ci.yml", + Token: os.Getenv("GITHUB_TOKEN"), +}) +if err != nil { + log.Fatal(err) +} +if verification.VerifiedSHA == nil { + log.Fatalf("deployment blocked: %s", verification.Result.Verdict) +} +deploy(*verification.VerifiedSHA) +``` + +The gate selects the latest completed `push` run, not the latest successful +run, validates the run's repository, branch, event, status, identifiers, and +full commit SHA, then reads the branch head and requires both a `success` +conclusion and an exact `tested_head` dependency match. `VerifiedSHA` is +non-nil only for `CONTRACT_SATISFIED`. + +`InspectWorkflowEvidence` reports evidence coverage across 1 to 100 recent +completed push runs. + +`Options` exposes `APIBaseURL`, `Client`, `Clock`, and `NewID` so the gate can +be tested deterministically. Response bodies are bounded, redirects are +refused, and the token is never included in an error message. + +CLI: + +```sh +worldcut-github-ci-go \ + --repository acme/payments \ + --branch main \ + --workflow ci.yml +``` + +It exits with code `2` unless the contract is satisfied, exits `1` with a +stable JSON error envelope on failure, and writes `verified_sha` and +`workflow_run_id` to `GITHUB_OUTPUT` on success. See +[`examples/github-actions/deployment-gate-go.yml`](../../examples/github-actions/deployment-gate-go.yml). + +## Agentic Data Kernel + +```go +observation, err := agenticdatakernel.ObservationFromResolution( + resolution, + agenticdatakernel.Options{}, +) +``` + +The adapter is structural and takes no runtime dependency on the kernel. It +rejects `unknown` and `conflicted` resolutions, `resolved_with_conflict` +unless `AllowResolvedWithConflict` is set, missing selections, non-`active` +assertions, assertions outside the resolved system or business interval, +missing or unsupported `basis.worldcut` metadata, unsupported fields, +provenance, or protocol versions, invalid acquisition costs and dependencies, +and cross-tenant resource or dependency claims. Every rejection uses +`WORLDCUT_ADK_RESOLUTION_INVALID`. + +## Errors + +| Code | Meaning | +| --- | --- | +| `WORLDCUT_INVALID_INPUT` | Input or constructed document failed protocol validation | +| `WORLDCUT_GITHUB_API_ERROR` | GitHub transport, status, or redirect failure | +| `WORLDCUT_GITHUB_RESPONSE_INVALID` | GitHub options or response content was unusable | +| `WORLDCUT_ADK_RESOLUTION_INVALID` | Kernel resolution or WorldCut metadata was unusable | + +`ErrorCode` unwraps wrapped causes. Native adapter failures wrap the +underlying operational error instead of inventing a code. + ## CLI -The CLI reads one verification input and prints the complete verification -result as JSON: +The verification CLI reads one verification input and prints the complete +verification result as JSON: ```sh worldcut-go verification.json @@ -68,13 +235,14 @@ Invalid JSON and invalid protocol input produce a JSON error with code Run from `ports/go`: ```sh -gofmt -w . +gofmt -l . go test ./... go test -race ./... go vet ./... ``` The tests consume every shared vector under `conformance/0.1`, including raw -Unicode rejection and exact canonical bytes and digests. This port currently -contains no cloud adapters, GitHub integration, or Agentic Data Kernel -integration. +Unicode rejection and exact canonical bytes and digests, and cover the +adapters, integrations, and both CLIs with adversarial provider responses, +mutation isolation, context cancellation, and exit-code checks. The race +detector requires cgo and a C toolchain. diff --git a/ports/go/adapters/adapters.go b/ports/go/adapters/adapters.go new file mode 100644 index 0000000..834e14a --- /dev/null +++ b/ports/go/adapters/adapters.go @@ -0,0 +1,75 @@ +// Package adapters captures native resource-version metadata from Git, HTTP, +// and Kubernetes as WorldCut observations. +// +// The adapters only record version material that a provider actually exposes. +// They never manufacture dependency or validity relationships, and every +// failure is returned as an error rather than as a success-shaped observation. +package adapters + +import ( + "errors" + "fmt" + "time" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + "github.com/Jason-Doyle/WorldCut/ports/go/internal/idgen" +) + +func observedAt(clock func() time.Time) string { + if clock == nil { + clock = time.Now + } + return worldcut.FormatTimestamp(clock()) +} + +func observationID(prefix string, source func() (string, error)) (string, error) { + if source == nil { + source = idgen.UUIDv4 + } + value, err := source() + if err != nil { + return "", fmt.Errorf("generate observation identifier: %w", err) + } + if value == "" { + return "", errors.New("observation identifier source returned an empty value") + } + return prefix + "-" + value, nil +} + +func acquisitionCost(cost *int64) (int64, error) { + if cost == nil { + return 1, nil + } + if *cost < 0 || *cost > worldcut.MaxAcquisitionCost { + return 0, fmt.Errorf( + "acquisitionCost must be an integer between 0 and %d", + worldcut.MaxAcquisitionCost, + ) + } + return *cost, nil +} + +func requireText(value, field string) error { + if value == "" { + return fmt.Errorf("%s must not be empty", field) + } + return nil +} + +func requireResource(resource worldcut.ResourceIdentity) error { + fields := []struct { + name string + value string + }{ + {"resource.provider", resource.Provider}, + {"resource.account", resource.Account}, + {"resource.kind", resource.Kind}, + {"resource.key", resource.Key}, + } + for _, field := range fields { + if err := requireText(field.value, field.name); err != nil { + return err + } + } + return nil +} diff --git a/ports/go/adapters/git.go b/ports/go/adapters/git.go new file mode 100644 index 0000000..35ae501 --- /dev/null +++ b/ports/go/adapters/git.go @@ -0,0 +1,151 @@ +package adapters + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" + "time" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" +) + +// GitHeadOptions describes one exact local branch head to capture. +type GitHeadOptions struct { + // RepositoryPath is the local working tree or repository directory. + RepositoryPath string + // RepositoryID names the repository inside the observation value and key. + RepositoryID string + // Branch is an exact local branch name. Revision expressions are rejected. + Branch string + // Role binds the observation to a contract role. + Role string + // Account defaults to "local". + Account string + // AcquisitionCost defaults to 1. + AcquisitionCost *int64 + // GitExecutable defaults to "git" resolved through PATH. + GitExecutable string + // Clock defaults to time.Now. + Clock func() time.Time + // NewID defaults to a random version 4 UUID. + NewID func() (string, error) +} + +var gitCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40,64}$`) + +// CaptureGitHead resolves an exact local branch head and records its commit +// SHA as an exact version witness. +// +// The branch name is validated with git check-ref-format --branch and then +// resolved only through refs/heads/^{commit}, so revision expressions +// such as main~1 and missing refs are rejected instead of silently resolving +// to another commit. +func CaptureGitHead(ctx context.Context, options GitHeadOptions) (worldcut.Observation, error) { + if ctx == nil { + ctx = context.Background() + } + fields := []struct { + name string + value string + }{ + {"repositoryPath", options.RepositoryPath}, + {"repositoryId", options.RepositoryID}, + {"branch", options.Branch}, + {"role", options.Role}, + } + for _, field := range fields { + if err := requireText(field.value, field.name); err != nil { + return worldcut.Observation{}, err + } + } + if strings.HasPrefix(options.Branch, "-") { + return worldcut.Observation{}, errors.New("branch must not start with a dash") + } + if strings.ContainsAny(options.Branch, "\x00\n") { + return worldcut.Observation{}, errors.New("branch must not contain control characters") + } + cost, err := acquisitionCost(options.AcquisitionCost) + if err != nil { + return worldcut.Observation{}, err + } + + executable := options.GitExecutable + if executable == "" { + executable = "git" + } + if _, err := runGit(ctx, executable, options.RepositoryPath, "check-ref-format", "--branch", options.Branch); err != nil { + return worldcut.Observation{}, err + } + stdout, err := runGit( + ctx, + executable, + options.RepositoryPath, + "rev-parse", + "--verify", + "refs/heads/"+options.Branch+"^{commit}", + ) + if err != nil { + return worldcut.Observation{}, err + } + commit := strings.TrimSpace(stdout) + if !gitCommitPattern.MatchString(commit) { + return worldcut.Observation{}, errors.New("git returned an invalid commit identifier") + } + + id, err := observationID("git", options.NewID) + if err != nil { + return worldcut.Observation{}, err + } + account := options.Account + if account == "" { + account = "local" + } + version := commit + return worldcut.Observation{ + ID: id, + Role: options.Role, + Resource: worldcut.ResourceIdentity{ + Provider: "git", + Account: account, + Kind: "branch_head", + Key: options.RepositoryID + "/" + options.Branch, + }, + Value: map[string]any{ + "repository": options.RepositoryID, + "branch": options.Branch, + "commit": commit, + }, + ObservedAt: observedAt(options.Clock), + AcquisitionCost: cost, + Witness: worldcut.ObservationWitness{ + Provenance: "client_observed", + Version: &version, + }, + }, nil +} + +func runGit(ctx context.Context, executable, repositoryPath string, arguments ...string) (string, error) { + command := exec.CommandContext( + ctx, + executable, + append([]string{"-C", repositoryPath}, arguments...)..., + ) + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + if ctx.Err() != nil { + return "", fmt.Errorf("git %s was cancelled: %w", arguments[0], ctx.Err()) + } + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + return "", fmt.Errorf("git %s failed: %w", arguments[0], err) + } + return "", fmt.Errorf("git %s failed: %s: %w", arguments[0], detail, err) + } + return stdout.String(), nil +} diff --git a/ports/go/adapters/git_test.go b/ports/go/adapters/git_test.go new file mode 100644 index 0000000..d2b7b40 --- /dev/null +++ b/ports/go/adapters/git_test.go @@ -0,0 +1,223 @@ +package adapters_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + "github.com/Jason-Doyle/WorldCut/ports/go/adapters" +) + +var commitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) + +func fixtureRepository(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not available on PATH") + } + directory := t.TempDir() + commands := [][]string{ + {"init", "--quiet", "--initial-branch=main", directory}, + {"-C", directory, "config", "user.email", "worldcut@example.invalid"}, + {"-C", directory, "config", "user.name", "WorldCut Test"}, + {"-C", directory, "config", "commit.gpgsign", "false"}, + {"-C", directory, "config", "core.autocrlf", "false"}, + } + for _, arguments := range commands { + if output, err := exec.Command("git", arguments...).CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v: %s", arguments, err, output) + } + } + if err := os.WriteFile(filepath.Join(directory, "sample.txt"), []byte("sample\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, arguments := range [][]string{ + {"-C", directory, "add", "sample.txt"}, + {"-C", directory, "commit", "--quiet", "-m", "init"}, + } { + if output, err := exec.Command("git", arguments...).CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v: %s", arguments, err, output) + } + } + return directory +} + +func TestCaptureGitHeadRecordsAnImmutableCommit(t *testing.T) { + directory := fixtureRepository(t) + observation, err := adapters.CaptureGitHead(context.Background(), adapters.GitHeadOptions{ + RepositoryPath: directory, + RepositoryID: "fixture", + Branch: "main", + Role: "head", + }) + if err != nil { + t.Fatal(err) + } + if observation.Witness.Version == nil || !commitPattern.MatchString(*observation.Witness.Version) { + t.Fatalf("version = %v", observation.Witness.Version) + } + if observation.Witness.Provenance != "client_observed" { + t.Fatalf("provenance = %s", observation.Witness.Provenance) + } + if observation.Witness.Validity != nil { + t.Fatal("the Git adapter must not invent a validity interval") + } + if observation.Resource.Account != "local" || observation.Resource.Kind != "branch_head" { + t.Fatalf("resource = %+v", observation.Resource) + } + if observation.Resource.Key != "fixture/main" { + t.Fatalf("resource key = %s", observation.Resource.Key) + } + if observation.AcquisitionCost != 1 { + t.Fatalf("acquisitionCost = %d", observation.AcquisitionCost) + } + value, ok := observation.Value.(map[string]any) + if !ok || value["commit"] != *observation.Witness.Version { + t.Fatalf("value = %#v", observation.Value) + } + + second, err := adapters.CaptureGitHead(context.Background(), adapters.GitHeadOptions{ + RepositoryPath: directory, + RepositoryID: "fixture", + Branch: "main", + Role: "second-head", + }) + if err != nil { + t.Fatal(err) + } + if observation.ID == second.ID { + t.Fatal("two captures produced the same observation identifier") + } +} + +func TestCaptureGitHeadRejectsRevisionExpressionsAndMissingRefs(t *testing.T) { + directory := fixtureRepository(t) + for name, branch := range map[string]string{ + "revision expression": "main~1", + "reflog expression": "main@{1}", + "missing branch": "does-not-exist", + "tag namespace": "refs/tags/v1", + "dash option": "-C", + } { + t.Run(name, func(t *testing.T) { + _, err := adapters.CaptureGitHead(context.Background(), adapters.GitHeadOptions{ + RepositoryPath: directory, + RepositoryID: "fixture", + Branch: branch, + Role: "head", + }) + if err == nil { + t.Fatalf("branch %q was accepted", branch) + } + }) + } +} + +func TestCaptureGitHeadValidatesOptions(t *testing.T) { + cases := map[string]adapters.GitHeadOptions{ + "missing path": {RepositoryID: "fixture", Branch: "main", Role: "head"}, + "missing repository": {RepositoryPath: ".", Branch: "main", Role: "head"}, + "missing branch": {RepositoryPath: ".", RepositoryID: "fixture", Role: "head"}, + "missing role": {RepositoryPath: ".", RepositoryID: "fixture", Branch: "main"}, + } + for name, options := range cases { + t.Run(name, func(t *testing.T) { + if _, err := adapters.CaptureGitHead(context.Background(), options); err == nil { + t.Fatal("expected a rejection") + } + }) + } + + cost := worldcut.MaxAcquisitionCost + 1 + _, err := adapters.CaptureGitHead(context.Background(), adapters.GitHeadOptions{ + RepositoryPath: ".", + RepositoryID: "fixture", + Branch: "main", + Role: "head", + AcquisitionCost: &cost, + }) + if err == nil || !strings.Contains(err.Error(), "acquisitionCost") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCaptureGitHeadHonorsContextCancellation(t *testing.T) { + directory := fixtureRepository(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := adapters.CaptureGitHead(ctx, adapters.GitHeadOptions{ + RepositoryPath: directory, + RepositoryID: "fixture", + Branch: "main", + Role: "head", + }) + if err == nil || !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCaptureGitHeadReportsMissingExecutable(t *testing.T) { + _, err := adapters.CaptureGitHead(context.Background(), adapters.GitHeadOptions{ + RepositoryPath: t.TempDir(), + RepositoryID: "fixture", + Branch: "main", + Role: "head", + GitExecutable: "worldcut-missing-git-executable", + }) + if err == nil || !strings.Contains(err.Error(), "git check-ref-format failed") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCaptureGitHeadObservationVerifies(t *testing.T) { + directory := fixtureRepository(t) + clock := func() time.Time { + return time.Date(2026, 9, 4, 18, 0, 0, 0, time.UTC) + } + observation, err := adapters.CaptureGitHead(context.Background(), adapters.GitHeadOptions{ + RepositoryPath: directory, + RepositoryID: "fixture", + Branch: "main", + Role: "head", + Clock: clock, + NewID: func() (string, error) { return "fixed", nil }, + }) + if err != nil { + t.Fatal(err) + } + if observation.ID != "git-fixed" { + t.Fatalf("identifier = %s", observation.ID) + } + if observation.ObservedAt != "2026-09-04T18:00:00.000Z" { + t.Fatalf("observedAt = %s", observation.ObservedAt) + } + result, err := worldcut.VerifyDecisionContract(worldcut.VerificationInput{ + Contract: worldcut.Contract{ + ID: "git-head", + Version: "1", + DecisionTime: "2026-09-04T18:00:00.000Z", + Requirements: []worldcut.Requirement{ + worldcut.NewValueEqualsRequirement( + "branch-is-main", + "The captured branch is main", + "head", + []string{"branch"}, + "main", + ), + }, + }, + Observations: []worldcut.Observation{observation}, + }) + if err != nil { + t.Fatal(err) + } + if result.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s", result.Verdict) + } +} diff --git a/ports/go/adapters/http.go b/ports/go/adapters/http.go new file mode 100644 index 0000000..2ff34f1 --- /dev/null +++ b/ports/go/adapters/http.go @@ -0,0 +1,204 @@ +package adapters + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + "unicode/utf8" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" +) + +// HTTPDoer performs a single HTTP request. *http.Client satisfies it. +type HTTPDoer interface { + Do(request *http.Request) (*http.Response, error) +} + +// HTTPObservationOptions describes one HTTP resource to capture. +type HTTPObservationOptions struct { + // URL is the absolute resource URL. + URL string + // Role binds the observation to a contract role. + Role string + // Resource is the caller-declared resource identity. + Resource worldcut.ResourceIdentity + // Method is "HEAD" (the default) or "GET". + Method string + // AcquisitionCost defaults to 1. + AcquisitionCost *int64 + // Client defaults to a client that refuses to follow redirects. + Client HTTPDoer + // Clock defaults to time.Now. + Clock func() time.Time + // NewID defaults to a random version 4 UUID. + NewID func() (string, error) +} + +// ErrRedirectNotFollowed reports that a response redirected. WorldCut never +// follows a redirect because the redirected resource is a different resource. +var ErrRedirectNotFollowed = errors.New("HTTP redirects are not followed") + +func defaultHTTPClient() *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return ErrRedirectNotFollowed + }, + } +} + +func redirectRefusingHTTPClient(client HTTPDoer) HTTPDoer { + if client == nil { + return defaultHTTPClient() + } + if standardClient, ok := client.(*http.Client); ok { + cloned := *standardClient + cloned.CheckRedirect = func(*http.Request, []*http.Request) error { + return ErrRedirectNotFollowed + } + return &cloned + } + return client +} + +func closeResponseBody(response *http.Response) { + if response != nil && response.Body != nil { + _ = response.Body.Close() + } +} + +// strongETag returns the ETag when it is a syntactically valid strong +// validator. Weak validators, the wildcard, and unquoted values are not exact +// versions and are never promoted. +func strongETag(value string) (string, bool) { + candidate := strings.TrimSpace(value) + if len(candidate) < 2 || candidate[0] != '"' || candidate[len(candidate)-1] != '"' { + return "", false + } + for _, character := range candidate[1 : len(candidate)-1] { + if character == 0x21 || + (character >= 0x23 && character <= 0x7e) || + (character >= 0x80 && character <= 0xff) { + continue + } + return "", false + } + return candidate, true +} + +func responseMatchesRequest(response *http.Response, request *http.Request) bool { + if response.Request == nil || response.Request.URL == nil { + return true + } + return response.Request.URL.String() == request.URL.String() +} + +// CaptureHTTPObservation records the status and validators of one HTTP +// resource. +// +// Only a syntactically valid strong ETag becomes an exact version witness. +// The status, ok flag, raw ETag, and Last-Modified header stay descriptive +// values; Last-Modified is never treated as an exact version. The response +// body is always closed and never read. +func CaptureHTTPObservation(ctx context.Context, options HTTPObservationOptions) (worldcut.Observation, error) { + if ctx == nil { + ctx = context.Background() + } + if err := requireText(options.URL, "url"); err != nil { + return worldcut.Observation{}, err + } + if err := requireText(options.Role, "role"); err != nil { + return worldcut.Observation{}, err + } + if err := requireResource(options.Resource); err != nil { + return worldcut.Observation{}, err + } + method := options.Method + if method == "" { + method = http.MethodHead + } + if method != http.MethodHead && method != http.MethodGet { + return worldcut.Observation{}, fmt.Errorf("method %q is not supported; use GET or HEAD", method) + } + cost, err := acquisitionCost(options.AcquisitionCost) + if err != nil { + return worldcut.Observation{}, err + } + id, err := observationID("http", options.NewID) + if err != nil { + return worldcut.Observation{}, err + } + + request, err := http.NewRequestWithContext(ctx, method, options.URL, nil) + if err != nil { + return worldcut.Observation{}, fmt.Errorf("build HTTP request for %s: %w", options.URL, err) + } + client := redirectRefusingHTTPClient(options.Client) + response, err := client.Do(request) + if err != nil { + closeResponseBody(response) + return worldcut.Observation{}, fmt.Errorf("HTTP request for %s failed: %w", options.URL, err) + } + if response == nil { + return worldcut.Observation{}, fmt.Errorf("HTTP client returned no response for %s", options.URL) + } + if !responseMatchesRequest(response, request) { + closeResponseBody(response) + return worldcut.Observation{}, fmt.Errorf( + "HTTP request for %s failed: %w", + options.URL, + ErrRedirectNotFollowed, + ) + } + // The body is deliberately never read; closing it releases the + // connection without consuming a resource representation. + if response.Body != nil { + defer closeResponseBody(response) + } + + etag, etagPresent := joinedHeader(response.Header, "ETag") + lastModified, lastModifiedPresent := joinedHeader(response.Header, "Last-Modified") + if !utf8.ValidString(etag) || !utf8.ValidString(lastModified) { + return worldcut.Observation{}, fmt.Errorf("HTTP response headers for %s are not valid UTF-8", options.URL) + } + + value := map[string]any{ + "status": response.StatusCode, + "ok": response.StatusCode >= 200 && response.StatusCode <= 299, + "etag": nullableText(etag, etagPresent), + "lastModified": nullableText(lastModified, lastModifiedPresent), + } + witness := worldcut.ObservationWitness{Provenance: "provider_asserted"} + if etagPresent { + if version, ok := strongETag(etag); ok { + witness.Version = &version + } + } + return worldcut.Observation{ + ID: id, + Role: options.Role, + Resource: options.Resource, + Value: value, + ObservedAt: observedAt(options.Clock), + AcquisitionCost: cost, + Witness: witness, + }, nil +} + +func joinedHeader(header http.Header, name string) (string, bool) { + values := header.Values(name) + if len(values) == 0 { + return "", false + } + return strings.Join(values, ", "), true +} + +func nullableText(value string, present bool) any { + if !present { + return nil + } + return value +} diff --git a/ports/go/adapters/http_test.go b/ports/go/adapters/http_test.go new file mode 100644 index 0000000..ff60400 --- /dev/null +++ b/ports/go/adapters/http_test.go @@ -0,0 +1,364 @@ +package adapters_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + "github.com/Jason-Doyle/WorldCut/ports/go/adapters" +) + +type stubBody struct { + closed *atomic.Bool + read *atomic.Bool +} + +func (b stubBody) Read(p []byte) (int, error) { + b.read.Store(true) + return 0, io.EOF +} + +func (b stubBody) Close() error { + b.closed.Store(true) + return nil +} + +type stubClient struct { + response *http.Response + err error + request *http.Request +} + +func (c *stubClient) Do(request *http.Request) (*http.Response, error) { + c.request = request + return c.response, c.err +} + +func headerResponse(status int, header http.Header, body io.ReadCloser) *http.Response { + if header == nil { + header = http.Header{} + } + return &http.Response{StatusCode: status, Header: header, Body: body} +} + +func fixtureResource() worldcut.ResourceIdentity { + return worldcut.ResourceIdentity{ + Provider: "fixture", + Account: "test", + Kind: "document", + Key: "one", + } +} + +func TestCaptureHTTPObservationPromotesStrongETag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodHead { + t.Errorf("method = %s", request.Method) + } + writer.Header().Set("ETag", `"fixture-v3"`) + writer.WriteHeader(http.StatusOK) + })) + defer server.Close() + + observation, err := adapters.CaptureHTTPObservation(context.Background(), adapters.HTTPObservationOptions{ + URL: server.URL + "/resource", + Role: "http-resource", + Resource: fixtureResource(), + }) + if err != nil { + t.Fatal(err) + } + if observation.Witness.Version == nil || *observation.Witness.Version != `"fixture-v3"` { + t.Fatalf("version = %v", observation.Witness.Version) + } + value, ok := observation.Value.(map[string]any) + if !ok { + t.Fatalf("value = %#v", observation.Value) + } + if value["status"] != 200 || value["ok"] != true || + value["etag"] != `"fixture-v3"` || value["lastModified"] != nil { + t.Fatalf("value = %#v", value) + } + if !strings.HasPrefix(observation.ID, "http-") { + t.Fatalf("identifier = %s", observation.ID) + } +} + +func TestCaptureHTTPObservationClosesUnreadBodies(t *testing.T) { + for _, method := range []string{"", http.MethodGet, http.MethodHead} { + closed := &atomic.Bool{} + read := &atomic.Bool{} + client := &stubClient{ + response: headerResponse( + http.StatusOK, + http.Header{"Etag": []string{`"stream-v1"`}}, + stubBody{closed: closed, read: read}, + ), + } + observation, err := adapters.CaptureHTTPObservation( + context.Background(), + adapters.HTTPObservationOptions{ + URL: "https://example.invalid/resource", + Method: method, + Role: "http-resource", + Resource: fixtureResource(), + Client: client, + }, + ) + if err != nil { + t.Fatal(err) + } + if observation.Witness.Version == nil || *observation.Witness.Version != `"stream-v1"` { + t.Fatalf("version = %v", observation.Witness.Version) + } + if !closed.Load() { + t.Fatalf("response body was not closed for method %q", method) + } + if read.Load() { + t.Fatalf("response body was read for method %q", method) + } + expectedMethod := method + if expectedMethod == "" { + expectedMethod = http.MethodHead + } + if client.request.Method != expectedMethod { + t.Fatalf("request method = %s", client.request.Method) + } + } +} + +func TestCaptureHTTPObservationDoesNotPromoteWeakValidators(t *testing.T) { + cases := map[string]http.Header{ + "last modified only": {"Last-Modified": []string{"Wed, 02 Sep 2026 20:00:00 GMT"}}, + "weak etag": {"Etag": []string{`W/"semantic-v1"`}}, + "wildcard etag": {"Etag": []string{"*"}}, + "unquoted etag": {"Etag": []string{"not-quoted"}}, + "empty etag": {"Etag": []string{""}}, + "quote only": {"Etag": []string{`"`}}, + "embedded quote": {"Etag": []string{`"a"b"`}}, + "control character": {"Etag": []string{"\"a\tb\""}}, + "above latin-1": {"Etag": []string{`"€"`}}, + "duplicate etag": {"Etag": []string{`"one"`, `"two"`}}, + } + for name, header := range cases { + t.Run(name, func(t *testing.T) { + closed := &atomic.Bool{} + read := &atomic.Bool{} + observation, err := adapters.CaptureHTTPObservation( + context.Background(), + adapters.HTTPObservationOptions{ + URL: "https://example.invalid/resource", + Role: "http-resource", + Resource: fixtureResource(), + Client: &stubClient{response: headerResponse( + http.StatusOK, + header, + stubBody{closed: closed, read: read}, + )}, + }, + ) + if err != nil { + t.Fatal(err) + } + if observation.Witness.Version != nil { + t.Fatalf("version = %q", *observation.Witness.Version) + } + value := observation.Value.(map[string]any) + if name == "duplicate etag" && value["etag"] != `"one", "two"` { + t.Fatalf("etag = %#v", value["etag"]) + } + if header.Get("Last-Modified") != "" && value["lastModified"] != header.Get("Last-Modified") { + t.Fatalf("lastModified = %#v", value["lastModified"]) + } + }) + } +} + +func TestCaptureHTTPObservationRecordsFailureStatus(t *testing.T) { + observation, err := adapters.CaptureHTTPObservation( + context.Background(), + adapters.HTTPObservationOptions{ + URL: "https://example.invalid/resource", + Role: "http-resource", + Resource: fixtureResource(), + Client: &stubClient{response: headerResponse( + http.StatusNotFound, + http.Header{}, + stubBody{closed: &atomic.Bool{}, read: &atomic.Bool{}}, + )}, + }, + ) + if err != nil { + t.Fatal(err) + } + value := observation.Value.(map[string]any) + if value["status"] != 404 || value["ok"] != false { + t.Fatalf("value = %#v", value) + } + if observation.Witness.Version != nil { + t.Fatal("a failed response must not carry an exact version") + } +} + +func TestCaptureHTTPObservationRefusesRedirects(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("ETag", `"redirected"`) + writer.WriteHeader(http.StatusOK) + })) + defer target.Close() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, target.URL, http.StatusFound) + })) + defer server.Close() + + _, err := adapters.CaptureHTTPObservation(context.Background(), adapters.HTTPObservationOptions{ + URL: server.URL + "/resource", + Role: "http-resource", + Resource: fixtureResource(), + }) + if err == nil || !errors.Is(err, adapters.ErrRedirectNotFollowed) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCaptureHTTPObservationRefusesRedirectsWithInjectedClient(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + })) + defer target.Close() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, target.URL, http.StatusFound) + })) + defer server.Close() + + _, err := adapters.CaptureHTTPObservation(context.Background(), adapters.HTTPObservationOptions{ + URL: server.URL + "/resource", + Role: "http-resource", + Resource: fixtureResource(), + Client: &http.Client{}, + }) + if err == nil || !errors.Is(err, adapters.ErrRedirectNotFollowed) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCaptureHTTPObservationValidatesOptions(t *testing.T) { + cases := map[string]adapters.HTTPObservationOptions{ + "missing url": {Role: "role", Resource: fixtureResource()}, + "missing role": {URL: "https://example.invalid", Resource: fixtureResource()}, + "missing resource": {URL: "https://example.invalid", Role: "role"}, + "unsupported method": { + URL: "https://example.invalid", + Role: "role", + Resource: fixtureResource(), + Method: "POST", + }, + } + for name, options := range cases { + t.Run(name, func(t *testing.T) { + if _, err := adapters.CaptureHTTPObservation(context.Background(), options); err == nil { + t.Fatal("expected a rejection") + } + }) + } +} + +func TestCaptureHTTPObservationReportsTransportFailures(t *testing.T) { + _, err := adapters.CaptureHTTPObservation(context.Background(), adapters.HTTPObservationOptions{ + URL: "https://example.invalid/resource", + Role: "http-resource", + Resource: fixtureResource(), + Client: &stubClient{err: errors.New("dial failed")}, + }) + if err == nil || !strings.Contains(err.Error(), "dial failed") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCaptureHTTPObservationClosesResponseReturnedWithError(t *testing.T) { + closed := &atomic.Bool{} + _, err := adapters.CaptureHTTPObservation(context.Background(), adapters.HTTPObservationOptions{ + URL: "https://example.invalid/resource", + Role: "http-resource", + Resource: fixtureResource(), + Client: &stubClient{ + response: headerResponse( + http.StatusTemporaryRedirect, + http.Header{}, + stubBody{closed: closed, read: &atomic.Bool{}}, + ), + err: errors.New("redirect refused"), + }, + }) + if err == nil || !strings.Contains(err.Error(), "redirect refused") { + t.Fatalf("unexpected error: %v", err) + } + if !closed.Load() { + t.Fatal("response body returned with an error was not closed") + } +} + +func TestCaptureHTTPObservationRejectsAChangedResponseURL(t *testing.T) { + closed := &atomic.Bool{} + response := headerResponse( + http.StatusOK, + http.Header{"Etag": []string{`"redirected"`}}, + stubBody{closed: closed, read: &atomic.Bool{}}, + ) + response.Request, _ = http.NewRequest( + http.MethodHead, + "https://other.example.invalid/resource", + nil, + ) + _, err := adapters.CaptureHTTPObservation(context.Background(), adapters.HTTPObservationOptions{ + URL: "https://example.invalid/resource", + Role: "http-resource", + Resource: fixtureResource(), + Client: &stubClient{response: response}, + }) + if err == nil || !errors.Is(err, adapters.ErrRedirectNotFollowed) { + t.Fatalf("unexpected error: %v", err) + } + if !closed.Load() { + t.Fatal("redirected response body was not closed") + } +} + +func TestCaptureHTTPObservationHonorsContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + })) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := adapters.CaptureHTTPObservation(ctx, adapters.HTTPObservationOptions{ + URL: server.URL, + Role: "http-resource", + Resource: fixtureResource(), + }) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCaptureHTTPObservationRejectsNonUTF8Headers(t *testing.T) { + _, err := adapters.CaptureHTTPObservation(context.Background(), adapters.HTTPObservationOptions{ + URL: "https://example.invalid/resource", + Role: "http-resource", + Resource: fixtureResource(), + Client: &stubClient{response: headerResponse( + http.StatusOK, + http.Header{"Etag": []string{"\"\xff\""}}, + stubBody{closed: &atomic.Bool{}, read: &atomic.Bool{}}, + )}, + }) + if err == nil || !strings.Contains(err.Error(), "valid UTF-8") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/ports/go/adapters/kubernetes.go b/ports/go/adapters/kubernetes.go new file mode 100644 index 0000000..c79221f --- /dev/null +++ b/ports/go/adapters/kubernetes.go @@ -0,0 +1,123 @@ +package adapters + +import ( + "time" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" +) + +// KubernetesObjectMetadata is the structural subset of Kubernetes object +// metadata WorldCut records. Empty Namespace defaults to "default" and an +// empty UID is recorded as null. +type KubernetesObjectMetadata struct { + Name string + Namespace string + UID string + ResourceVersion string +} + +// KubernetesObject is the structural subset of a Kubernetes object WorldCut +// records. No Kubernetes client library is required. +type KubernetesObject struct { + APIVersion string + Kind string + Metadata KubernetesObjectMetadata +} + +// KubernetesObservationOptions describes one Kubernetes object to capture. +type KubernetesObservationOptions struct { + // Cluster names the cluster inside the resource key. + Cluster string + // Account is the caller-declared tenant or context account. + Account string + // Role binds the observation to a contract role. + Role string + // Object is the observed object. + Object KubernetesObject + // Value overrides the recorded observation value. A nil Value records the + // object's structural identity. The supplied value is snapshotted, so + // later mutation of caller maps or slices cannot change the observation. + Value any + // AcquisitionCost defaults to 1. + AcquisitionCost *int64 + // Clock defaults to time.Now. + Clock func() time.Time + // NewID defaults to a random version 4 UUID. + NewID func() (string, error) +} + +// CaptureKubernetesObservation records metadata.resourceVersion as an opaque +// exact version token. +// +// The value is never parsed, sorted, compared for ordering, or interpreted as +// a timestamp, and no validity interval is inferred. +func CaptureKubernetesObservation(options KubernetesObservationOptions) (worldcut.Observation, error) { + fields := []struct { + name string + value string + }{ + {"cluster", options.Cluster}, + {"account", options.Account}, + {"role", options.Role}, + {"object.apiVersion", options.Object.APIVersion}, + {"object.kind", options.Object.Kind}, + {"object.metadata.name", options.Object.Metadata.Name}, + } + for _, field := range fields { + if err := requireText(field.value, field.name); err != nil { + return worldcut.Observation{}, err + } + } + cost, err := acquisitionCost(options.AcquisitionCost) + if err != nil { + return worldcut.Observation{}, err + } + id, err := observationID("kubernetes", options.NewID) + if err != nil { + return worldcut.Observation{}, err + } + + namespace := options.Object.Metadata.Namespace + if namespace == "" { + namespace = "default" + } + var value any + if options.Value == nil { + var uid any + if options.Object.Metadata.UID != "" { + uid = options.Object.Metadata.UID + } + value = map[string]any{ + "apiVersion": options.Object.APIVersion, + "kind": options.Object.Kind, + "name": options.Object.Metadata.Name, + "namespace": namespace, + "uid": uid, + } + } else { + value, err = worldcut.SnapshotJSONValue(options.Value) + if err != nil { + return worldcut.Observation{}, err + } + } + + witness := worldcut.ObservationWitness{Provenance: "provider_asserted"} + if options.Object.Metadata.ResourceVersion != "" { + version := options.Object.Metadata.ResourceVersion + witness.Version = &version + } + return worldcut.Observation{ + ID: id, + Role: options.Role, + Resource: worldcut.ResourceIdentity{ + Provider: "kubernetes", + Account: options.Account, + Kind: options.Object.APIVersion + "/" + options.Object.Kind, + Key: options.Cluster + "/" + namespace + "/" + options.Object.Metadata.Name, + }, + Value: value, + ObservedAt: observedAt(options.Clock), + AcquisitionCost: cost, + Witness: witness, + }, nil +} diff --git a/ports/go/adapters/kubernetes_test.go b/ports/go/adapters/kubernetes_test.go new file mode 100644 index 0000000..a0c21b2 --- /dev/null +++ b/ports/go/adapters/kubernetes_test.go @@ -0,0 +1,150 @@ +package adapters_test + +import ( + "testing" + "time" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + "github.com/Jason-Doyle/WorldCut/ports/go/adapters" +) + +func deploymentOptions() adapters.KubernetesObservationOptions { + return adapters.KubernetesObservationOptions{ + Cluster: "fixture", + Account: "test", + Role: "deployment", + Object: adapters.KubernetesObject{ + APIVersion: "apps/v1", + Kind: "Deployment", + Metadata: adapters.KubernetesObjectMetadata{ + Name: "payments", + Namespace: "production", + ResourceVersion: "9812", + }, + }, + } +} + +func TestCaptureKubernetesObservationKeepsResourceVersionOpaque(t *testing.T) { + observation, err := adapters.CaptureKubernetesObservation(deploymentOptions()) + if err != nil { + t.Fatal(err) + } + if observation.Witness.Version == nil || *observation.Witness.Version != "9812" { + t.Fatalf("version = %v", observation.Witness.Version) + } + if observation.Resource.Kind != "apps/v1/Deployment" { + t.Fatalf("resource kind = %s", observation.Resource.Kind) + } + if observation.Resource.Key != "fixture/production/payments" { + t.Fatalf("resource key = %s", observation.Resource.Key) + } + if observation.Witness.Validity != nil { + t.Fatal("the Kubernetes adapter must not infer a validity interval") + } + + second, err := adapters.CaptureKubernetesObservation(deploymentOptions()) + if err != nil { + t.Fatal(err) + } + if observation.ID == second.ID { + t.Fatal("two captures produced the same observation identifier") + } +} + +func TestCaptureKubernetesObservationDefaultsNamespaceAndUID(t *testing.T) { + options := deploymentOptions() + options.Object.Metadata.Namespace = "" + options.Object.Metadata.ResourceVersion = "" + observation, err := adapters.CaptureKubernetesObservation(options) + if err != nil { + t.Fatal(err) + } + if observation.Resource.Key != "fixture/default/payments" { + t.Fatalf("resource key = %s", observation.Resource.Key) + } + if observation.Witness.Version != nil { + t.Fatal("an absent resourceVersion must not produce a version witness") + } + value := observation.Value.(map[string]any) + if value["namespace"] != "default" || value["uid"] != nil { + t.Fatalf("value = %#v", value) + } +} + +func TestCaptureKubernetesObservationSnapshotsCustomValues(t *testing.T) { + custom := map[string]any{"replicas": 3, "labels": []any{"a"}} + options := deploymentOptions() + options.Value = custom + observation, err := adapters.CaptureKubernetesObservation(options) + if err != nil { + t.Fatal(err) + } + custom["replicas"] = 9 + custom["labels"].([]any)[0] = "mutated" + + value := observation.Value.(map[string]any) + if value["replicas"] != float64(3) { + t.Fatalf("custom value aliased the caller's map: %#v", value) + } + if value["labels"].([]any)[0] != "a" { + t.Fatalf("custom value aliased the caller's slice: %#v", value) + } +} + +func TestCaptureKubernetesObservationValidatesOptions(t *testing.T) { + cases := map[string]func(*adapters.KubernetesObservationOptions){ + "missing cluster": func(o *adapters.KubernetesObservationOptions) { o.Cluster = "" }, + "missing account": func(o *adapters.KubernetesObservationOptions) { o.Account = "" }, + "missing role": func(o *adapters.KubernetesObservationOptions) { o.Role = "" }, + "missing api version": func(o *adapters.KubernetesObservationOptions) { o.Object.APIVersion = "" }, + "missing kind": func(o *adapters.KubernetesObservationOptions) { o.Object.Kind = "" }, + "missing name": func(o *adapters.KubernetesObservationOptions) { o.Object.Metadata.Name = "" }, + "non-json value": func(o *adapters.KubernetesObservationOptions) { o.Value = func() {} }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + options := deploymentOptions() + mutate(&options) + if _, err := adapters.CaptureKubernetesObservation(options); err == nil { + t.Fatal("expected a rejection") + } + }) + } +} + +func TestCaptureKubernetesObservationVerifies(t *testing.T) { + options := deploymentOptions() + options.Clock = func() time.Time { return time.Date(2026, 9, 4, 18, 0, 0, 0, time.UTC) } + options.NewID = func() (string, error) { return "fixed", nil } + observation, err := adapters.CaptureKubernetesObservation(options) + if err != nil { + t.Fatal(err) + } + if observation.ID != "kubernetes-fixed" || observation.ObservedAt != "2026-09-04T18:00:00.000Z" { + t.Fatalf("observation = %+v", observation) + } + result, err := worldcut.VerifyDecisionContract(worldcut.VerificationInput{ + Contract: worldcut.Contract{ + ID: "kubernetes", + Version: "1", + DecisionTime: "2026-09-04T18:00:00.000Z", + Requirements: []worldcut.Requirement{ + worldcut.NewValueEqualsRequirement( + "namespace-is-production", + "The object is in production", + "deployment", + []string{"namespace"}, + "production", + ), + }, + }, + Observations: []worldcut.Observation{observation}, + }) + if err != nil { + t.Fatal(err) + } + if result.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s", result.Verdict) + } +} diff --git a/ports/go/cmd/worldcut-github-ci-go/main.go b/ports/go/cmd/worldcut-github-ci-go/main.go new file mode 100644 index 0000000..7e7027e --- /dev/null +++ b/ports/go/cmd/worldcut-github-ci-go/main.go @@ -0,0 +1,215 @@ +// Command worldcut-github-ci-go gates a deployment on the latest completed +// GitHub Actions push run for one exact workflow and branch. +// +// It exits with status 2 unless the decision contract is satisfied, writes +// verified_sha and workflow_run_id to GITHUB_OUTPUT on success, and reports +// failures as a stable JSON error envelope on stderr. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strconv" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + "github.com/Jason-Doyle/WorldCut/ports/go/integrations/githubactions" +) + +const usage = `Usage: worldcut-github-ci-go --repository owner/name --workflow ci.yml [options] + +Options: + --branch Branch to verify (default: main) + --token-env Environment variable containing a GitHub token + --full Include verification input and full result + --help Show this help` + +type dependencies struct { + getenv func(string) string + stdout io.Writer + stderr io.Writer + apiBaseURL string + client githubactions.HTTPDoer +} + +type requirementSummary struct { + ID string `json:"id"` + Status string `json:"status"` + Summary string `json:"summary"` +} + +type gateSummary struct { + Repository string `json:"repository"` + Branch string `json:"branch"` + Workflow string `json:"workflow"` + BranchSHA string `json:"branchSha"` + VerifiedSHA *string `json:"verifiedSha"` + WorkflowRun *githubactions.WorkflowRunEvidence `json:"workflowRun"` + Verdict string `json:"verdict"` + Requirements []requirementSummary `json:"requirements"` + VerificationRecordDigest string `json:"verificationRecordDigest"` +} + +func writeError(stderr io.Writer, code, message string) { + encoded, err := json.Marshal(map[string]any{ + "error": map[string]any{ + "code": code, + "message": message, + }, + }) + if err != nil { + fmt.Fprintln(stderr, `{"error":{"code":"WORLDCUT_RUNTIME_ERROR","message":"unable to encode error"}}`) + return + } + fmt.Fprintln(stderr, string(encoded)) +} + +func run(ctx context.Context, arguments []string, deps dependencies) int { + flags := flag.NewFlagSet("worldcut-github-ci-go", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.Usage = func() {} + repository := flags.String("repository", "", "repository in owner/name form") + branch := flags.String("branch", "main", "branch to verify") + workflow := flags.String("workflow", "", "numeric workflow ID or workflow filename") + tokenEnvironment := flags.String("token-env", "", "environment variable holding a GitHub token") + tokenEnvironmentAlias := flags.String("tokenEnv", "", "alias for --token-env") + full := flags.Bool("full", false, "include verification input and full result") + help := flags.Bool("help", false, "show help") + if err := flags.Parse(arguments); err != nil { + if errors.Is(err, flag.ErrHelp) { + fmt.Fprintln(deps.stdout, usage) + return 0 + } + writeError(deps.stderr, "WORLDCUT_INVALID_ARGUMENT", err.Error()) + return 1 + } + if *help { + fmt.Fprintln(deps.stdout, usage) + return 0 + } + if flags.NArg() != 0 { + writeError( + deps.stderr, + "WORLDCUT_INVALID_ARGUMENT", + "unexpected positional argument: "+flags.Arg(0), + ) + return 1 + } + if *repository == "" || *workflow == "" { + writeError( + deps.stderr, + "WORLDCUT_INVALID_ARGUMENT", + "--repository and --workflow are required", + ) + return 1 + } + if *tokenEnvironment != "" && *tokenEnvironmentAlias != "" && *tokenEnvironment != *tokenEnvironmentAlias { + writeError( + deps.stderr, + "WORLDCUT_INVALID_ARGUMENT", + "--token-env and --tokenEnv must not disagree", + ) + return 1 + } + tokenVariable := *tokenEnvironment + if tokenVariable == "" { + tokenVariable = *tokenEnvironmentAlias + } + + token := "" + if tokenVariable != "" { + token = deps.getenv(tokenVariable) + } else if value := deps.getenv("GITHUB_TOKEN"); value != "" { + token = value + } else { + token = deps.getenv("GH_TOKEN") + } + + verification, err := githubactions.VerifyLatestWorkflow(ctx, githubactions.Options{ + Repository: *repository, + Branch: *branch, + Workflow: *workflow, + Token: token, + APIBaseURL: deps.apiBaseURL, + Client: deps.client, + }) + if err != nil { + code := worldcut.ErrorCode(err) + if code == "" { + code = "WORLDCUT_RUNTIME_ERROR" + } + writeError(deps.stderr, code, err.Error()) + return 1 + } + + var payload any = verification + if !*full { + requirements := make([]requirementSummary, 0, len(verification.Result.RequirementResults)) + for _, requirement := range verification.Result.RequirementResults { + requirements = append(requirements, requirementSummary{ + ID: requirement.RequirementID, + Status: requirement.Status, + Summary: requirement.Summary, + }) + } + payload = gateSummary{ + Repository: verification.Repository, + Branch: verification.Branch, + Workflow: verification.Workflow, + BranchSHA: verification.BranchSHA, + VerifiedSHA: verification.VerifiedSHA, + WorkflowRun: verification.WorkflowRun, + Verdict: verification.Result.Verdict, + Requirements: requirements, + VerificationRecordDigest: verification.Result.VerificationRecordDigest, + } + } + encoder := json.NewEncoder(deps.stdout) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + if err := encoder.Encode(payload); err != nil { + writeError(deps.stderr, "WORLDCUT_RUNTIME_ERROR", err.Error()) + return 1 + } + + if verification.VerifiedSHA != nil { + if outputPath := deps.getenv("GITHUB_OUTPUT"); outputPath != "" { + runID := "" + if verification.WorkflowRun != nil { + runID = strconv.FormatInt(verification.WorkflowRun.ID, 10) + } + if err := appendOutput(outputPath, *verification.VerifiedSHA, runID); err != nil { + writeError(deps.stderr, "WORLDCUT_RUNTIME_ERROR", err.Error()) + return 1 + } + } + } + if verification.Result.Verdict != "CONTRACT_SATISFIED" { + return 2 + } + return 0 +} + +func appendOutput(path, verifiedSHA, runID string) error { + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer func() { + _ = file.Close() + }() + _, err = fmt.Fprintf(file, "verified_sha=%s\nworkflow_run_id=%s\n", verifiedSHA, runID) + return err +} + +func main() { + os.Exit(run(context.Background(), os.Args[1:], dependencies{ + getenv: os.Getenv, + stdout: os.Stdout, + stderr: os.Stderr, + })) +} diff --git a/ports/go/cmd/worldcut-github-ci-go/main_test.go b/ports/go/cmd/worldcut-github-ci-go/main_test.go new file mode 100644 index 0000000..03a2acc --- /dev/null +++ b/ports/go/cmd/worldcut-github-ci-go/main_test.go @@ -0,0 +1,365 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +const currentSHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +type gate struct { + conclusion string + headSHA string + empty bool + status int + requests []*http.Request +} + +func (g *gate) server(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + g.requests = append(g.requests, request) + if g.status != 0 { + writer.WriteHeader(g.status) + _, _ = writer.Write([]byte("failure")) + return + } + conclusion := g.conclusion + if conclusion == "" { + conclusion = "success" + } + headSHA := g.headSHA + if headSHA == "" { + headSHA = currentSHA + } + var payload any + switch { + case strings.Contains(request.URL.Path, "/actions/workflows/"): + branch := request.URL.Query().Get("branch") + runs := []any{} + if !g.empty { + runs = append(runs, map[string]any{ + "id": 81, + "workflow_id": 42, + "head_sha": headSHA, + "head_branch": branch, + "event": "push", + "status": "completed", + "conclusion": conclusion, + "html_url": "https://github.com/acme/service/actions/runs/81", + "head_repository": map[string]any{"full_name": "acme/service"}, + }) + } + payload = map[string]any{"workflow_runs": runs} + case strings.Contains(request.URL.Path, "/branches/"): + payload = map[string]any{"commit": map[string]any{"sha": currentSHA}} + default: + writer.WriteHeader(http.StatusNotFound) + return + } + encoded, err := json.Marshal(payload) + if err != nil { + t.Error(err) + return + } + _, _ = writer.Write(encoded) + })) + t.Cleanup(server.Close) + return server +} + +func execute(t *testing.T, arguments []string, environment map[string]string, baseURL string) (int, string, string) { + t.Helper() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + code := run(context.Background(), arguments, dependencies{ + getenv: func(name string) string { return environment[name] }, + stdout: stdout, + stderr: stderr, + apiBaseURL: baseURL, + }) + return code, stdout.String(), stderr.String() +} + +func TestCLIReportsSatisfiedGate(t *testing.T) { + state := &gate{} + server := state.server(t) + code, stdout, stderr := execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml"}, + nil, + server.URL, + ) + if code != 0 { + t.Fatalf("exit code = %d (%s)", code, stderr) + } + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatal(err) + } + if payload["verdict"] != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %v", payload["verdict"]) + } + if payload["verifiedSha"] != currentSHA { + t.Fatalf("verifiedSha = %v", payload["verifiedSha"]) + } + if payload["branch"] != "main" { + t.Fatalf("branch = %v", payload["branch"]) + } + if _, present := payload["input"]; present { + t.Fatal("the summary output must not include the verification input") + } + requirements, ok := payload["requirements"].([]any) + if !ok || len(requirements) != 2 { + t.Fatalf("requirements = %#v", payload["requirements"]) + } + if payload["verificationRecordDigest"] == "" { + t.Fatal("the summary must include the verification record digest") + } +} + +func TestCLIFullOutputIncludesInputAndResult(t *testing.T) { + state := &gate{} + code, stdout, stderr := execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml", "--full"}, + nil, + state.server(t).URL, + ) + if code != 0 { + t.Fatalf("exit code = %d (%s)", code, stderr) + } + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatal(err) + } + input, ok := payload["input"].(map[string]any) + if !ok || input["protocolVersion"] != "0.1" { + t.Fatalf("input = %#v", payload["input"]) + } + result, ok := payload["result"].(map[string]any) + if !ok || result["verdict"] != "CONTRACT_SATISFIED" { + t.Fatalf("result = %#v", payload["result"]) + } +} + +func TestCLIExitsWithTwoUnlessSatisfied(t *testing.T) { + for name, state := range map[string]*gate{ + "failed run": {conclusion: "failure"}, + "stale head": {headSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "no completed run": {empty: true}, + "failure and stale": {conclusion: "failure", headSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + } { + t.Run(name, func(t *testing.T) { + code, stdout, _ := execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml"}, + nil, + state.server(t).URL, + ) + if code != 2 { + t.Fatalf("exit code = %d", code) + } + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatal(err) + } + if payload["verifiedSha"] != nil { + t.Fatalf("verifiedSha = %v", payload["verifiedSha"]) + } + }) + } +} + +func TestCLIWritesGitHubOutputOnlyWhenVerified(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "github-output") + state := &gate{} + code, _, stderr := execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml"}, + map[string]string{"GITHUB_OUTPUT": outputPath}, + state.server(t).URL, + ) + if code != 0 { + t.Fatalf("exit code = %d (%s)", code, stderr) + } + contents, err := os.ReadFile(outputPath) + if err != nil { + t.Fatal(err) + } + expected := "verified_sha=" + currentSHA + "\nworkflow_run_id=81\n" + if string(contents) != expected { + t.Fatalf("GITHUB_OUTPUT = %q", string(contents)) + } + + failedPath := filepath.Join(t.TempDir(), "github-output") + failed := &gate{conclusion: "failure"} + code, _, _ = execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml"}, + map[string]string{"GITHUB_OUTPUT": failedPath}, + failed.server(t).URL, + ) + if code != 2 { + t.Fatalf("exit code = %d", code) + } + if _, err := os.Stat(failedPath); !os.IsNotExist(err) { + t.Fatalf("a violated gate wrote GITHUB_OUTPUT: %v", err) + } +} + +func TestCLIReportsErrorsAsStableJSON(t *testing.T) { + cases := map[string]struct { + arguments []string + state *gate + expectedKey string + }{ + "missing required flags": { + arguments: []string{"--repository", "acme/service"}, + state: &gate{}, + expectedKey: "WORLDCUT_INVALID_ARGUMENT", + }, + "unknown flag": { + arguments: []string{"--repository", "acme/service", "--workflow", "ci.yml", "--nope"}, + state: &gate{}, + expectedKey: "WORLDCUT_INVALID_ARGUMENT", + }, + "positional argument": { + arguments: []string{"--repository", "acme/service", "--workflow", "ci.yml", "extra"}, + state: &gate{}, + expectedKey: "WORLDCUT_INVALID_ARGUMENT", + }, + "invalid repository": { + arguments: []string{"--repository", "acme", "--workflow", "ci.yml"}, + state: &gate{}, + expectedKey: "WORLDCUT_GITHUB_RESPONSE_INVALID", + }, + "api failure": { + arguments: []string{"--repository", "acme/service", "--workflow", "ci.yml"}, + state: &gate{status: http.StatusForbidden}, + expectedKey: "WORLDCUT_GITHUB_API_ERROR", + }, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + code, stdout, stderr := execute( + t, + testCase.arguments, + nil, + testCase.state.server(t).URL, + ) + if code != 1 { + t.Fatalf("exit code = %d", code) + } + if stdout != "" { + t.Fatalf("stdout = %q", stdout) + } + var envelope struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stderr), &envelope); err != nil { + t.Fatalf("stderr is not a JSON envelope: %q", stderr) + } + if envelope.Error.Code != testCase.expectedKey { + t.Fatalf("error code = %s (%s)", envelope.Error.Code, envelope.Error.Message) + } + if envelope.Error.Message == "" { + t.Fatal("the error envelope has no message") + } + }) + } +} + +func TestCLIHelpExitsZero(t *testing.T) { + for _, argument := range []string{"--help", "-h"} { + code, stdout, stderr := execute(t, []string{argument}, nil, "") + if code != 0 { + t.Fatalf("%s exit code = %d (%s)", argument, code, stderr) + } + if !strings.Contains(stdout, "Usage: worldcut-github-ci-go") { + t.Fatalf("%s output = %q", argument, stdout) + } + } +} + +func TestCLIResolvesTokenEnvironment(t *testing.T) { + state := &gate{} + server := state.server(t) + execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml", "--token-env", "CUSTOM_TOKEN"}, + map[string]string{"CUSTOM_TOKEN": "custom", "GITHUB_TOKEN": "default"}, + server.URL, + ) + if authorization := state.requests[0].Header.Get("Authorization"); authorization != "Bearer custom" { + t.Fatalf("Authorization = %q", authorization) + } + + fallback := &gate{} + fallbackServer := fallback.server(t) + execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml"}, + map[string]string{"GH_TOKEN": "gh"}, + fallbackServer.URL, + ) + if authorization := fallback.requests[0].Header.Get("Authorization"); authorization != "Bearer gh" { + t.Fatalf("Authorization = %q", authorization) + } + + anonymous := &gate{} + anonymousServer := anonymous.server(t) + execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml"}, + nil, + anonymousServer.URL, + ) + if authorization := anonymous.requests[0].Header.Get("Authorization"); authorization != "" { + t.Fatalf("Authorization = %q", authorization) + } +} + +func TestCLIAcceptsCamelCaseTokenEnvironmentAlias(t *testing.T) { + state := &gate{} + server := state.server(t) + code, _, stderr := execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml", "--tokenEnv", "CUSTOM_TOKEN"}, + map[string]string{"CUSTOM_TOKEN": "custom"}, + server.URL, + ) + if code != 0 { + t.Fatalf("exit code = %d (%s)", code, stderr) + } + if authorization := state.requests[0].Header.Get("Authorization"); authorization != "Bearer custom" { + t.Fatalf("Authorization = %q", authorization) + } +} + +func TestCLIUsesExplicitBranch(t *testing.T) { + state := &gate{} + server := state.server(t) + code, _, _ := execute( + t, + []string{"--repository", "acme/service", "--workflow", "ci.yml", "--branch", "release"}, + nil, + server.URL, + ) + if code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(state.requests[0].URL.RawQuery, "branch=release") { + t.Fatalf("query = %s", state.requests[0].URL.RawQuery) + } +} diff --git a/ports/go/document.go b/ports/go/document.go new file mode 100644 index 0000000..15b85bb --- /dev/null +++ b/ports/go/document.go @@ -0,0 +1,149 @@ +package worldcut + +import ( + "encoding/json" + "reflect" + "time" +) + +// SupportedAssumptions returns the only clock, interval, and metadata models +// protocol 0.1 accepts. +func SupportedAssumptions() ContractAssumptions { + return ContractAssumptions{ + ClockModel: "trusted_normalized", + IntervalModel: "half_open", + MetadataModel: "honest_but_possibly_incomplete", + } +} + +// NewDependencyRequirement builds an exact dependency requirement. +func NewDependencyRequirement(id, description, dependentRole, targetRole, dependencyName string) Requirement { + return Requirement{ + ID: id, + Description: description, + Type: "dependency", + DependentRole: dependentRole, + TargetRole: targetRole, + DependencyName: dependencyName, + } +} + +// NewCommonValidTimeRequirement builds a scoped common-valid-time requirement. +func NewCommonValidTimeRequirement(id, description string, roles []string, within ValidityInterval) Requirement { + if within.Until != nil { + until := *within.Until + within.Until = &until + } + return Requirement{ + ID: id, + Description: description, + Type: "common_valid_time", + Roles: append([]string(nil), roles...), + Within: &within, + } +} + +// NewValueEqualsRequirement builds a deterministic value-path requirement. +// A nil expected value means the JSON value null. +func NewValueEqualsRequirement(id, description, role string, path []string, expected any) Requirement { + return Requirement{ + ID: id, + Description: description, + Type: "value_equals", + Role: role, + Path: append([]string(nil), path...), + Expected: expected, + } +} + +// Advisory marks the requirement as evaluated but not required for the +// aggregate verdict. +func (r Requirement) Advisory() Requirement { + required := false + r.Required = &required + return r +} + +func (input VerificationInput) withDefaults() VerificationInput { + if input.ProtocolVersion == "" { + input.ProtocolVersion = ProtocolVersion + } + if input.Contract.Assumptions == (ContractAssumptions{}) { + input.Contract.Assumptions = SupportedAssumptions() + } + return input +} + +// ParseVerificationInput validates a constructed verification input and +// returns an immutable parsed snapshot. The input is encoded and then run +// through the same strict validation and canonicalization as [ParseInput], so +// constructed and transported inputs are accepted on identical terms. +// +// An empty ProtocolVersion defaults to [ProtocolVersion] and a zero +// ContractAssumptions defaults to [SupportedAssumptions]. Every other field +// must be supplied. The returned snapshot shares no memory with the caller's +// maps or slices. +func ParseVerificationInput(input VerificationInput) (*ParsedInput, error) { + encoded, err := encodeDocument(input.withDefaults()) + if err != nil { + return nil, err + } + return ParseInput(encoded) +} + +// VerifyDecisionContract validates and verifies a constructed verification +// input. It is equivalent to [ParseVerificationInput] followed by [Verify]. +func VerifyDecisionContract(input VerificationInput) (*VerificationResult, error) { + parsed, err := ParseVerificationInput(input) + if err != nil { + return nil, err + } + return Verify(parsed) +} + +func encodeDocument(value any) ([]byte, error) { + // Go's JSON encoder silently replaces invalid Unicode and cannot encode + // cycles, so the canonical value rules run before encoding and every + // rejection stays a WorldCut input error. + if err := validateCanonicalValue(reflect.ValueOf(value), "input", map[canonicalVisit]bool{}); err != nil { + return nil, invalidInput("%v", err) + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, invalidInput("%v", err) + } + return encoded, nil +} + +// SnapshotJSONValue validates a Go value against the WorldCut canonical JSON +// data rules and returns an independent snapshot built only from JSON types: +// nil, bool, float64, string, []any, and map[string]any. +// +// Use it when adapting provider payloads so that later mutation of the +// caller's maps or slices cannot change a captured observation. +func SnapshotJSONValue(value any) (any, error) { + encoded, err := encodeDocument(value) + if err != nil { + return nil, err + } + snapshot, err := decodeJSON(encoded) + if err != nil { + return nil, invalidInput("%v", err) + } + return snapshot, nil +} + +// ParseTimestamp validates a normalized ISO-8601 UTC timestamp with +// milliseconds, the only timestamp form protocol 0.1 accepts. +func ParseTimestamp(value string) (time.Time, error) { + _, parsed, err := parseTimestamp(value, "timestamp") + if err != nil { + return time.Time{}, invalidInput("%v", err) + } + return parsed, nil +} + +// FormatTimestamp renders an instant as normalized UTC milliseconds. +func FormatTimestamp(instant time.Time) string { + return instant.UTC().Format(timestampLayout) +} diff --git a/ports/go/document_test.go b/ports/go/document_test.go new file mode 100644 index 0000000..4a8ba75 --- /dev/null +++ b/ports/go/document_test.go @@ -0,0 +1,504 @@ +package worldcut + +import ( + "encoding/json" + "math" + "strings" + "testing" + "time" +) + +func sampleContractInput() VerificationInput { + return VerificationInput{ + Contract: Contract{ + ID: "deploy", + Version: "1", + DecisionTime: "2026-09-04T18:00:00.000Z", + Requirements: []Requirement{ + NewDependencyRequirement( + "ci-tested-current-head", + "The passing CI run tested the selected branch head", + "ci", + "head", + "tested_head", + ), + NewValueEqualsRequirement( + "ci-passed", + "CI passed", + "ci", + []string{"status"}, + "passed", + ), + }, + }, + Observations: []Observation{ + { + ID: "head-1", + Role: "head", + Resource: ResourceIdentity{ + Provider: "git", + Account: "acme", + Kind: "branch_head", + Key: "payments/main", + }, + Value: map[string]any{"commit": "commit-B"}, + ObservedAt: "2026-09-04T17:59:00.000Z", + AcquisitionCost: 1, + Witness: ObservationWitness{ + Provenance: "provider_asserted", + Version: stringPointer("commit-B"), + }, + }, + { + ID: "ci-1", + Role: "ci", + Resource: ResourceIdentity{ + Provider: "github-actions", + Account: "acme", + Kind: "workflow_run", + Key: "ci.yml/2041", + }, + Value: map[string]any{"status": "passed"}, + ObservedAt: "2026-09-04T17:59:30.000Z", + AcquisitionCost: 2, + Witness: ObservationWitness{ + Provenance: "provider_asserted", + Version: stringPointer("2041"), + Dependencies: []DependencyWitness{{ + Name: "tested_head", + Resource: ResourceIdentity{ + Provider: "git", + Account: "acme", + Kind: "branch_head", + Key: "payments/main", + }, + Relation: "exact", + Version: stringPointer("commit-B"), + Provenance: "provider_asserted", + }}, + }, + }, + }, + } +} + +func stringPointer(value string) *string { + return &value +} + +const sampleContractJSON = `{ + "protocolVersion": "0.1", + "contract": { + "id": "deploy", + "version": "1", + "decisionTime": "2026-09-04T18:00:00.000Z", + "assumptions": { + "clockModel": "trusted_normalized", + "intervalModel": "half_open", + "metadataModel": "honest_but_possibly_incomplete" + }, + "requirements": [ + { + "id": "ci-tested-current-head", + "description": "The passing CI run tested the selected branch head", + "type": "dependency", + "dependentRole": "ci", + "targetRole": "head", + "dependencyName": "tested_head" + }, + { + "id": "ci-passed", + "description": "CI passed", + "type": "value_equals", + "role": "ci", + "path": ["status"], + "expected": "passed" + } + ] + }, + "observations": [ + { + "id": "head-1", + "role": "head", + "resource": { + "provider": "git", + "account": "acme", + "kind": "branch_head", + "key": "payments/main" + }, + "value": {"commit": "commit-B"}, + "observedAt": "2026-09-04T17:59:00.000Z", + "acquisitionCost": 1, + "witness": {"provenance": "provider_asserted", "version": "commit-B"} + }, + { + "id": "ci-1", + "role": "ci", + "resource": { + "provider": "github-actions", + "account": "acme", + "kind": "workflow_run", + "key": "ci.yml/2041" + }, + "value": {"status": "passed"}, + "observedAt": "2026-09-04T17:59:30.000Z", + "acquisitionCost": 2, + "witness": { + "provenance": "provider_asserted", + "version": "2041", + "dependencies": [ + { + "name": "tested_head", + "resource": { + "provider": "git", + "account": "acme", + "kind": "branch_head", + "key": "payments/main" + }, + "relation": "exact", + "version": "commit-B", + "provenance": "provider_asserted" + } + ] + } + } + ] +}` + +func TestConstructedInputMatchesTransportedInput(t *testing.T) { + constructed, err := VerifyDecisionContract(sampleContractInput()) + if err != nil { + t.Fatal(err) + } + transported, err := VerifyJSON([]byte(sampleContractJSON)) + if err != nil { + t.Fatal(err) + } + if constructed.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s", constructed.Verdict) + } + constructedJSON, err := json.Marshal(constructed) + if err != nil { + t.Fatal(err) + } + transportedJSON, err := json.Marshal(transported) + if err != nil { + t.Fatal(err) + } + if string(constructedJSON) != string(transportedJSON) { + t.Fatalf("constructed result differs from transported result:\n%s\n%s", constructedJSON, transportedJSON) + } + if constructed.VerificationRecordDigest != transported.VerificationRecordDigest { + t.Fatalf( + "digest mismatch: %s != %s", + constructed.VerificationRecordDigest, + transported.VerificationRecordDigest, + ) + } +} + +func TestConstructedInputAppliesProtocolDefaults(t *testing.T) { + input := sampleContractInput() + input.ProtocolVersion = "" + input.Contract.Assumptions = ContractAssumptions{} + if _, err := VerifyDecisionContract(input); err != nil { + t.Fatal(err) + } + + input.ProtocolVersion = "0.2" + _, err := VerifyDecisionContract(input) + if err == nil || ErrorCode(err) != InvalidInputCode { + t.Fatalf("unexpected protocol version error: %v", err) + } + + input = sampleContractInput() + input.Contract.Assumptions = ContractAssumptions{ClockModel: "wall_clock"} + _, err = VerifyDecisionContract(input) + if err == nil || !strings.Contains(err.Error(), "assumptions are not supported") { + t.Fatalf("unexpected assumptions error: %v", err) + } +} + +func TestConstructedInputIsMutationIsolated(t *testing.T) { + value := map[string]any{"status": "passed"} + nested := []any{map[string]any{"note": "original"}} + input := sampleContractInput() + input.Observations[1].Value = map[string]any{ + "status": value["status"], + "nested": nested, + } + parsed, err := ParseVerificationInput(input) + if err != nil { + t.Fatal(err) + } + + value["status"] = "failed" + nested[0].(map[string]any)["note"] = "mutated" + input.Observations[1].Witness.Dependencies[0].Name = "other" + input.Contract.Requirements[1].Expected = "failed" + + result, err := Verify(parsed) + if err != nil { + t.Fatal(err) + } + if result.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s after caller mutation", result.Verdict) + } + second, err := Verify(parsed) + if err != nil { + t.Fatal(err) + } + if second.VerificationRecordDigest != result.VerificationRecordDigest { + t.Fatal("repeated verification of a parsed snapshot is not deterministic") + } + result.RequirementResults[0].Status = "MUTATED" + third, err := Verify(parsed) + if err != nil { + t.Fatal(err) + } + if third.RequirementResults[0].Status == "MUTATED" { + t.Fatal("mutating a result changed the parsed snapshot") + } +} + +func TestConstructedInputRejectsInvalidCanonicalData(t *testing.T) { + cyclic := map[string]any{} + cyclic["self"] = cyclic + + cases := map[string]any{ + "invalid unicode": string([]byte{0xed, 0xa0, 0x80}), + "non-finite": math.Inf(1), + "cycle": cyclic, + "binary": []byte("bytes"), + "function": func() {}, + } + for name, value := range cases { + t.Run(name, func(t *testing.T) { + input := sampleContractInput() + input.Observations[0].Value = map[string]any{"field": value} + _, err := VerifyDecisionContract(input) + if err == nil { + t.Fatal("expected a rejection") + } + if ErrorCode(err) != InvalidInputCode { + t.Fatalf("error code = %q (%v)", ErrorCode(err), err) + } + }) + } +} + +func TestConstructedRequirementRejectsForeignVariantFields(t *testing.T) { + input := sampleContractInput() + input.Contract.Requirements[0].Role = "ci" + _, err := VerifyDecisionContract(input) + if err == nil || !strings.Contains(err.Error(), "unsupported field(s)") { + t.Fatalf("cross-variant field was not rejected: %v", err) + } +} + +func TestConstructedRequirementSupportsNullExpectedAndAdvisory(t *testing.T) { + input := sampleContractInput() + input.Contract.Requirements[1] = NewValueEqualsRequirement( + "ci-note-absent", + "CI exposes a null note", + "ci", + []string{"note"}, + nil, + ).Advisory() + input.Observations[1].Value = map[string]any{"status": "passed", "note": nil} + result, err := VerifyDecisionContract(input) + if err != nil { + t.Fatal(err) + } + if result.Coverage.Advisory != 1 || result.Coverage.Required != 1 { + t.Fatalf("coverage = %+v", result.Coverage) + } + for _, requirement := range result.RequirementResults { + if requirement.RequirementID == "ci-note-absent" && requirement.Status != "SATISFIED" { + t.Fatalf("null expected value was not matched: %s", requirement.Status) + } + } +} + +func TestConstructedInputRejectsProtocolViolations(t *testing.T) { + t.Run("duplicate role", func(t *testing.T) { + input := sampleContractInput() + input.Observations[1].Role = "head" + if _, err := VerifyDecisionContract(input); err == nil || + !strings.Contains(err.Error(), "duplicate observation role") { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("acquisition cost bound", func(t *testing.T) { + input := sampleContractInput() + input.Observations[0].AcquisitionCost = MaxAcquisitionCost + 1 + if _, err := VerifyDecisionContract(input); err == nil || + !strings.Contains(err.Error(), "acquisitionCost") { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("observation after decision time", func(t *testing.T) { + input := sampleContractInput() + input.Observations[0].ObservedAt = "2026-09-04T18:00:00.001Z" + if _, err := VerifyDecisionContract(input); err == nil || + !strings.Contains(err.Error(), "must not be after contract.decisionTime") { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("unnormalized timestamp", func(t *testing.T) { + input := sampleContractInput() + input.Observations[0].ObservedAt = "2026-09-04T17:59:00Z" + if _, err := VerifyDecisionContract(input); err == nil || + !strings.Contains(err.Error(), "normalized ISO-8601 UTC") { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("unsupported requirement type", func(t *testing.T) { + input := sampleContractInput() + input.Contract.Requirements[0].Type = "value_greater_than" + if _, err := VerifyDecisionContract(input); err == nil || + !strings.Contains(err.Error(), "unsupported requirement type") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestCommonValidTimeRequirementConstruction(t *testing.T) { + roles := []string{"head", "ci"} + requirement := NewCommonValidTimeRequirement( + "shared-window", + "Both roles were valid together", + roles, + ValidityInterval{From: "2026-09-04T17:00:00.000Z", Until: stringPointer("2026-09-04T19:00:00.000Z")}, + ) + roles[0] = "mutated" + if requirement.Roles[0] != "head" { + t.Fatal("requirement retained an alias of the caller's roles slice") + } + + input := sampleContractInput() + input.Contract.Requirements = []Requirement{requirement} + interval := ValidityInterval{ + From: "2026-09-04T17:30:00.000Z", + Until: stringPointer("2026-09-04T18:30:00.000Z"), + } + input.Observations[0].Witness.Validity = &interval + input.Observations[1].Witness.Validity = &interval + result, err := VerifyDecisionContract(input) + if err != nil { + t.Fatal(err) + } + if result.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s", result.Verdict) + } +} + +func TestSnapshotJSONValueIsIndependentAndNormalized(t *testing.T) { + source := map[string]any{ + "list": []any{1, 2}, + "nested": map[string]any{"flag": true}, + } + snapshot, err := SnapshotJSONValue(source) + if err != nil { + t.Fatal(err) + } + source["nested"].(map[string]any)["flag"] = false + source["list"].([]any)[0] = 99 + + record, ok := snapshot.(map[string]any) + if !ok { + t.Fatalf("snapshot type = %T", snapshot) + } + if record["nested"].(map[string]any)["flag"] != true { + t.Fatal("snapshot aliased the caller's nested map") + } + if record["list"].([]any)[0] != float64(1) { + t.Fatalf("snapshot did not normalize numbers: %#v", record["list"]) + } + + if _, err := SnapshotJSONValue(math.NaN()); err == nil { + t.Fatal("NaN was accepted") + } +} + +func TestTimestampHelpers(t *testing.T) { + instant := time.Date(2026, 9, 4, 18, 0, 0, 123_456_789, time.UTC) + formatted := FormatTimestamp(instant) + if formatted != "2026-09-04T18:00:00.123Z" { + t.Fatalf("formatted = %s", formatted) + } + parsed, err := ParseTimestamp(formatted) + if err != nil { + t.Fatal(err) + } + if FormatTimestamp(parsed) != formatted { + t.Fatal("timestamp round trip failed") + } + offset := time.Date(2026, 9, 4, 18, 0, 0, 0, time.FixedZone("east", 3600)) + if FormatTimestamp(offset) != "2026-09-04T17:00:00.000Z" { + t.Fatalf("offset was not normalized to UTC: %s", FormatTimestamp(offset)) + } + for _, value := range []string{ + "2026-09-04T18:00:00Z", + "2026-09-04T18:00:00.123+01:00", + "2026-09-04 18:00:00.123Z", + "", + } { + if _, err := ParseTimestamp(value); err == nil { + t.Fatalf("accepted unnormalized timestamp %q", value) + } + } + if _, err := ParseTimestamp("0000-01-01T00:00:00.000Z"); err != nil { + t.Fatalf("year zero timestamp was rejected: %v", err) + } +} + +func TestErrorCodeUnwrapsWrappedIntegrationErrors(t *testing.T) { + cause := &Error{Code: InvalidInputCode, Message: "inner"} + wrapped := WrapError(GitHubResponseInvalidCode, "outer", cause) + if ErrorCode(wrapped) != GitHubResponseInvalidCode { + t.Fatalf("code = %s", ErrorCode(wrapped)) + } + if wrapped.Error() != "outer: inner" { + t.Fatalf("message = %s", wrapped.Error()) + } + if ErrorCode(nil) != "" { + t.Fatal("nil error reported a code") + } +} + +func FuzzVerifyDecisionContractNoPanic(f *testing.F) { + f.Add("head", "commit-B", int64(1), "2026-09-04T18:00:00.000Z") + f.Add("", "", int64(-1), "") + f.Fuzz(func(t *testing.T, role, version string, cost int64, observedAt string) { + input := VerificationInput{ + Contract: Contract{ + ID: "fuzz", + Version: "1", + DecisionTime: "2026-09-04T18:00:00.000Z", + Requirements: []Requirement{ + NewValueEqualsRequirement("expect", "Expect", role, []string{"status"}, version), + }, + }, + Observations: []Observation{{ + ID: "observation", + Role: role, + Resource: ResourceIdentity{Provider: "p", Account: "a", Kind: "k", Key: "x"}, + Value: map[string]any{"status": version}, + ObservedAt: observedAt, + AcquisitionCost: cost, + Witness: ObservationWitness{Provenance: "provider_asserted"}, + }}, + } + result, err := VerifyDecisionContract(input) + if err != nil { + if ErrorCode(err) != InvalidInputCode { + t.Fatalf("unexpected error code %q for %v", ErrorCode(err), err) + } + return + } + if result.ProtocolVersion != ProtocolVersion || result.EngineVersion != EngineVersion { + t.Fatalf("unexpected result envelope: %+v", result) + } + }) +} diff --git a/ports/go/errors.go b/ports/go/errors.go index 0f6a525..61e58bc 100644 --- a/ports/go/errors.go +++ b/ports/go/errors.go @@ -1,16 +1,45 @@ package worldcut -import "fmt" +import ( + "errors" + "fmt" +) -const InvalidInputCode = "WORLDCUT_INVALID_INPUT" +// Stable error codes shared with the other WorldCut implementations. +const ( + InvalidInputCode = "WORLDCUT_INVALID_INPUT" + GitHubAPIErrorCode = "WORLDCUT_GITHUB_API_ERROR" + GitHubResponseInvalidCode = "WORLDCUT_GITHUB_RESPONSE_INVALID" + ADKResolutionInvalidCode = "WORLDCUT_ADK_RESOLUTION_INVALID" +) +// Error is a WorldCut error carrying a stable machine-readable code. type Error struct { Code string Message string + Cause error } func (e *Error) Error() string { - return e.Message + if e.Cause == nil { + return e.Message + } + return e.Message + ": " + e.Cause.Error() +} + +// Unwrap exposes the underlying operational error, when one exists. +func (e *Error) Unwrap() error { + return e.Cause +} + +// NewError builds a WorldCut error with the supplied stable code. +func NewError(code, message string) *Error { + return &Error{Code: code, Message: message} +} + +// WrapError builds a WorldCut error that preserves an underlying cause. +func WrapError(code, message string, cause error) *Error { + return &Error{Code: code, Message: message, Cause: cause} } func invalidInput(format string, args ...any) error { @@ -20,8 +49,11 @@ func invalidInput(format string, args ...any) error { } } +// ErrorCode reports the stable WorldCut code for err, or an empty string when +// err was not produced by this module. func ErrorCode(err error) string { - if worldCutError, ok := err.(*Error); ok { + var worldCutError *Error + if errors.As(err, &worldCutError) { return worldCutError.Code } return "" diff --git a/ports/go/example_integration_test.go b/ports/go/example_integration_test.go new file mode 100644 index 0000000..f328b0f --- /dev/null +++ b/ports/go/example_integration_test.go @@ -0,0 +1,225 @@ +package worldcut_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + "github.com/Jason-Doyle/WorldCut/ports/go/adapters" + adk "github.com/Jason-Doyle/WorldCut/ports/go/integrations/agenticdatakernel" + "github.com/Jason-Doyle/WorldCut/ports/go/integrations/githubactions" +) + +const releaseSHA = "cccccccccccccccccccccccccccccccccccccccc" + +// Example_captureAndVerify captures provider metadata with the bundled +// adapters and verifies a decision contract built from the captured +// observations, without assembling protocol JSON by hand. +func Example_captureAndVerify() { + decisionTime := time.Date(2026, 9, 4, 18, 0, 0, 0, time.UTC) + clock := func() time.Time { return decisionTime } + + deployment, err := adapters.CaptureKubernetesObservation(adapters.KubernetesObservationOptions{ + Cluster: "eu-west", + Account: "acme", + Role: "deployment", + Object: adapters.KubernetesObject{ + APIVersion: "apps/v1", + Kind: "Deployment", + Metadata: adapters.KubernetesObjectMetadata{ + Name: "payments", + Namespace: "production", + ResourceVersion: "9812", + }, + }, + Value: map[string]any{ + "image": "registry.example/payments@sha256:" + releaseSHA, + }, + Clock: clock, + }) + if err != nil { + fmt.Println("capture failed:", err) + return + } + + release, err := adk.ObservationFromResolution(adk.Resolution{ + Status: "known", + ValidAt: "2026-09-04T18:00:00.000Z", + SystemAt: "2026-09-04T18:00:00.000Z", + Selected: &adk.Assertion{ + TenantID: "acme", + AssertionID: "release-2041", + Object: map[string]any{ + "image": "registry.example/payments@sha256:" + releaseSHA, + }, + ValidFrom: "2026-09-04T17:00:00.000Z", + SystemFrom: "2026-09-04T17:30:00.000Z", + Status: "active", + Basis: map[string]any{ + "worldcut": map[string]any{ + "protocolVersion": "0.1", + "role": "release", + "resource": map[string]any{ + "provider": "agentic-data-kernel", + "account": "acme", + "kind": "release", + "key": "payments/2041", + }, + "provenance": "provider_asserted", + "version": "2041", + }, + }, + }, + }, adk.Options{}) + if err != nil { + fmt.Println("adapt failed:", err) + return + } + + result, err := worldcut.VerifyDecisionContract(worldcut.VerificationInput{ + Contract: worldcut.Contract{ + ID: "deploy-approved-release", + Version: "1", + DecisionTime: worldcut.FormatTimestamp(decisionTime), + Requirements: []worldcut.Requirement{ + worldcut.NewValueEqualsRequirement( + "deployment-runs-approved-image", + "The running deployment uses the approved release image", + "deployment", + []string{"image"}, + "registry.example/payments@sha256:"+releaseSHA, + ), + worldcut.NewValueEqualsRequirement( + "release-is-approved-image", + "The kernel selected the approved release image", + "release", + []string{"image"}, + "registry.example/payments@sha256:"+releaseSHA, + ), + }, + }, + Observations: []worldcut.Observation{deployment, release}, + }) + if err != nil { + fmt.Println("verification failed:", err) + return + } + + fmt.Println(result.Verdict) + fmt.Println(result.Coverage.Required, result.Coverage.Satisfied) + // Output: + // CONTRACT_SATISFIED + // 2 2 +} + +// Example_gitHubDeploymentGate verifies that the latest completed push run of +// one workflow tested the current branch head and returns the immutable SHA +// that deployment code must consume. +func Example_gitHubDeploymentGate() { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var payload any + if strings.Contains(request.URL.Path, "/actions/workflows/") { + payload = map[string]any{"workflow_runs": []any{map[string]any{ + "id": 2041, + "workflow_id": 42, + "head_sha": releaseSHA, + "head_branch": "main", + "event": "push", + "status": "completed", + "conclusion": "success", + "html_url": "https://github.com/acme/payments/actions/runs/2041", + "head_repository": map[string]any{"full_name": "acme/payments"}, + }}} + } else { + payload = map[string]any{"commit": map[string]any{"sha": releaseSHA}} + } + encoded, _ := json.Marshal(payload) + _, _ = writer.Write(encoded) + })) + defer server.Close() + + verification, err := githubactions.VerifyLatestWorkflow(context.Background(), githubactions.Options{ + Repository: "acme/payments", + Branch: "main", + Workflow: "ci.yml", + APIBaseURL: server.URL, + }) + if err != nil { + fmt.Println("gate failed:", err) + return + } + fmt.Println(verification.Result.Verdict) + if verification.VerifiedSHA != nil { + fmt.Println("deploy", *verification.VerifiedSHA) + } + // Output: + // CONTRACT_SATISFIED + // deploy cccccccccccccccccccccccccccccccccccccccc +} + +// TestCapturedObservationsShareNoStateWithCallers proves that observations +// returned by the integrations can be combined and reverified without the +// caller's original data influencing the outcome. +func TestCapturedObservationsShareNoStateWithCallers(t *testing.T) { + custom := map[string]any{"image": "registry.example/payments:1"} + observation, err := adapters.CaptureKubernetesObservation(adapters.KubernetesObservationOptions{ + Cluster: "eu-west", + Account: "acme", + Role: "deployment", + Object: adapters.KubernetesObject{ + APIVersion: "apps/v1", + Kind: "Deployment", + Metadata: adapters.KubernetesObjectMetadata{Name: "payments"}, + }, + Value: custom, + Clock: func() time.Time { return time.Date(2026, 9, 4, 18, 0, 0, 0, time.UTC) }, + }) + if err != nil { + t.Fatal(err) + } + custom["image"] = "registry.example/payments:2" + + input := worldcut.VerificationInput{ + Contract: worldcut.Contract{ + ID: "image-pinned", + Version: "1", + DecisionTime: "2026-09-04T18:00:00.000Z", + Requirements: []worldcut.Requirement{ + worldcut.NewValueEqualsRequirement( + "image-is-pinned", + "The deployment runs the pinned image", + "deployment", + []string{"image"}, + "registry.example/payments:1", + ), + }, + }, + Observations: []worldcut.Observation{observation}, + } + first, err := worldcut.VerifyDecisionContract(input) + if err != nil { + t.Fatal(err) + } + if first.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s", first.Verdict) + } + + parsed, err := worldcut.ParseVerificationInput(input) + if err != nil { + t.Fatal(err) + } + observation.Value.(map[string]any)["image"] = "registry.example/payments:3" + second, err := worldcut.Verify(parsed) + if err != nil { + t.Fatal(err) + } + if second.VerificationRecordDigest != first.VerificationRecordDigest { + t.Fatal("mutating a captured observation changed a parsed snapshot") + } +} diff --git a/ports/go/integrations/agenticdatakernel/agenticdatakernel.go b/ports/go/integrations/agenticdatakernel/agenticdatakernel.go new file mode 100644 index 0000000..5bfbb1f --- /dev/null +++ b/ports/go/integrations/agenticdatakernel/agenticdatakernel.go @@ -0,0 +1,493 @@ +// Package agenticdatakernel adapts resolved Agentic Data Kernel assertions +// into WorldCut observations. +// +// The adapter is structural: it declares only the fields WorldCut needs and +// takes no runtime dependency on the kernel. Every rejection uses the stable +// WORLDCUT_ADK_RESOLUTION_INVALID code and fails closed. +package agenticdatakernel + +import ( + "fmt" + "sort" + "time" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" +) + +// Assertion is the structural subset of a kernel assertion WorldCut consumes. +type Assertion struct { + // TenantID owns the assertion. Every WorldCut resource account and + // dependency resource account must equal it. + TenantID string + // AssertionID identifies the assertion and the derived observation. + AssertionID string + // Object is the asserted JSON value. + Object any + // ValidFrom and ValidTo bound business validity. A nil ValidTo is open. + ValidFrom string + ValidTo *string + // SystemFrom and SystemTo bound system validity. A nil SystemTo is open. + SystemFrom string + SystemTo *string + // Status is the assertion lifecycle state. Only "active" is eligible. + Status string + // Basis carries the namespaced basis.worldcut metadata. + Basis any +} + +// Resolution is the structural subset of a kernel resolution result. +type Resolution struct { + // Status is one of known, unknown, conflicted, or resolved_with_conflict. + Status string + // Selected is the assertion the kernel selected, when one exists. + Selected *Assertion + // ValidAt and SystemAt are the bitemporal coordinates of the resolution. + ValidAt string + SystemAt string +} + +// Options carries application policy for adapting a resolution. +type Options struct { + // AllowResolvedWithConflict permits a resolution that preserved an + // unresolved conflict. This is an explicit application policy decision. + AllowResolvedWithConflict bool +} + +var ( + resolutionStatuses = map[string]bool{ + "known": true, + "unknown": true, + "conflicted": true, + "resolved_with_conflict": true, + } + provenanceValues = map[string]bool{ + "provider_asserted": true, + "client_observed": true, + "derived": true, + "operator_supplied": true, + } + basisFields = map[string]bool{ + "protocolVersion": true, + "role": true, + "resource": true, + "provenance": true, + "version": true, + "dependencies": true, + "acquisitionCost": true, + } + resourceFields = []string{"provider", "account", "kind", "key"} +) + +func invalid(format string, arguments ...any) error { + return worldcut.NewError( + worldcut.ADKResolutionInvalidCode, + fmt.Sprintf(format, arguments...), + ) +} + +// copyText detaches an optional caller-owned string pointer so later +// assignment through that pointer cannot change a returned observation. +func copyText(value *string) *string { + if value == nil { + return nil + } + copied := *value + return &copied +} + +func wrap(message string, cause error) error { + return worldcut.WrapError(worldcut.ADKResolutionInvalidCode, message, cause) +} + +type worldCutBasis struct { + role string + resource worldcut.ResourceIdentity + provenance string + version *string + dependencies []worldcut.DependencyWitness + acquisitionCost int64 +} + +// ObservationFromResolution converts an eligible kernel resolution into a +// validated WorldCut observation. +// +// The returned observation shares no memory with the caller's assertion +// object or basis metadata, and it has already passed the same strict +// protocol validation as a transported verification input. +func ObservationFromResolution(resolution Resolution, options Options) (worldcut.Observation, error) { + observation, err := adapt(resolution, options) + if err != nil { + if worldcut.ErrorCode(err) == worldcut.ADKResolutionInvalidCode { + return worldcut.Observation{}, err + } + return worldcut.Observation{}, wrap( + "Agentic Data Kernel resolution metadata is invalid", + err, + ) + } + return observation, nil +} + +func adapt(resolution Resolution, options Options) (worldcut.Observation, error) { + if !resolutionStatuses[resolution.Status] { + return worldcut.Observation{}, invalid( + "Agentic Data Kernel resolution status %s is unsupported", + resolution.Status, + ) + } + if resolution.Status == "unknown" || resolution.Status == "conflicted" { + return worldcut.Observation{}, invalid( + "Agentic Data Kernel resolution status %s cannot authorize an observation", + resolution.Status, + ) + } + if resolution.Status == "resolved_with_conflict" && !options.AllowResolvedWithConflict { + return worldcut.Observation{}, invalid( + "resolved_with_conflict requires AllowResolvedWithConflict: true", + ) + } + if resolution.Selected == nil { + return worldcut.Observation{}, invalid( + "Agentic Data Kernel resolution has no selected assertion", + ) + } + assertion := *resolution.Selected + if assertion.Status != "active" { + return worldcut.Observation{}, invalid( + "Agentic Data Kernel assertion status %s is not eligible", + assertion.Status, + ) + } + + systemValid, err := activeAt( + assertion.SystemFrom, + assertion.SystemTo, + resolution.SystemAt, + "assertion.systemTime", + ) + if err != nil { + return worldcut.Observation{}, err + } + if !systemValid { + return worldcut.Observation{}, invalid( + "Agentic Data Kernel assertion is not system-valid at resolution.systemAt", + ) + } + businessValid, err := activeAt( + assertion.ValidFrom, + assertion.ValidTo, + resolution.ValidAt, + "assertion.validTime", + ) + if err != nil { + return worldcut.Observation{}, err + } + if !businessValid { + return worldcut.Observation{}, invalid( + "Agentic Data Kernel assertion is not business-valid at resolution.validAt", + ) + } + + object, err := worldcut.SnapshotJSONValue(assertion.Object) + if err != nil { + return worldcut.Observation{}, wrap("assertion.object is not JSON data", err) + } + basis, err := worldCutBasisFrom(assertion) + if err != nil { + return worldcut.Observation{}, err + } + if basis.resource.Account != assertion.TenantID { + return worldcut.Observation{}, invalid( + "WorldCut resource account must equal the Agentic Data Kernel tenantId", + ) + } + + observation := worldcut.Observation{ + ID: "adk-" + assertion.AssertionID, + Role: basis.role, + Resource: basis.resource, + Value: object, + ObservedAt: assertion.SystemFrom, + AcquisitionCost: basis.acquisitionCost, + Witness: worldcut.ObservationWitness{ + Provenance: basis.provenance, + Version: basis.version, + Validity: &worldcut.ValidityInterval{ + From: assertion.ValidFrom, + Until: copyText(assertion.ValidTo), + }, + Dependencies: basis.dependencies, + }, + } + if err := validateObservation(observation, resolution.SystemAt); err != nil { + return worldcut.Observation{}, wrap( + "Agentic Data Kernel WorldCut metadata is invalid", + err, + ) + } + return observation, nil +} + +// validateObservation submits the adapted observation to the WorldCut engine +// so that construction cannot bypass protocol validation. +func validateObservation(observation worldcut.Observation, systemAt string) error { + probe := observation + probe.Value = map[string]any{"selected": observation.Value} + _, err := worldcut.VerifyDecisionContract(worldcut.VerificationInput{ + ProtocolVersion: worldcut.ProtocolVersion, + Contract: worldcut.Contract{ + ID: "agentic-data-kernel-observation-validation", + Version: "1", + DecisionTime: systemAt, + Assumptions: worldcut.SupportedAssumptions(), + Requirements: []worldcut.Requirement{ + worldcut.NewValueEqualsRequirement( + "selected-value-is-preserved", + "The selected kernel value is preserved", + observation.Role, + []string{"selected"}, + observation.Value, + ), + }, + }, + Observations: []worldcut.Observation{probe}, + }) + return err +} + +func timestamp(value, field string) (time.Time, error) { + parsed, err := worldcut.ParseTimestamp(value) + if err != nil { + return time.Time{}, invalid("%s must be normalized ISO-8601 UTC", field) + } + return parsed, nil +} + +func activeAt(start string, end *string, at, field string) (bool, error) { + startTime, err := timestamp(start, field+".from") + if err != nil { + return false, err + } + atTime, err := timestamp(at, field+".at") + if err != nil { + return false, err + } + if end == nil { + return !atTime.Before(startTime), nil + } + endTime, err := timestamp(*end, field+".to") + if err != nil { + return false, err + } + return !atTime.Before(startTime) && atTime.Before(endTime), nil +} + +func worldCutBasisFrom(assertion Assertion) (worldCutBasis, error) { + snapshot, err := worldcut.SnapshotJSONValue(assertion.Basis) + if err != nil { + return worldCutBasis{}, wrap("assertion.basis is not JSON data", err) + } + basisRecord, ok := snapshot.(map[string]any) + if !ok { + return worldCutBasis{}, invalid("assertion.basis must be an object") + } + candidate, ok := basisRecord["worldcut"].(map[string]any) + if !ok { + return worldCutBasis{}, invalid("assertion.basis.worldcut must be an object") + } + unknown := unsupportedFields(candidate, basisFields) + if len(unknown) != 0 { + return worldCutBasis{}, invalid( + "assertion.basis.worldcut contains unsupported field(s): %v", + unknown, + ) + } + if candidate["protocolVersion"] != worldcut.ProtocolVersion { + return worldCutBasis{}, invalid( + "assertion.basis.worldcut.protocolVersion must equal %s", + worldcut.ProtocolVersion, + ) + } + role, ok := candidate["role"].(string) + if !ok || role == "" { + return worldCutBasis{}, invalid("assertion.basis.worldcut.role must be a non-empty string") + } + resource, err := resourceFrom(candidate["resource"], "assertion.basis.worldcut.resource") + if err != nil { + return worldCutBasis{}, err + } + provenance, ok := candidate["provenance"].(string) + if !ok || !provenanceValues[provenance] { + return worldCutBasis{}, invalid("assertion.basis.worldcut.provenance is unsupported") + } + var version *string + if rawVersion, exists := candidate["version"]; exists { + text, ok := rawVersion.(string) + if !ok || text == "" { + return worldCutBasis{}, invalid( + "assertion.basis.worldcut.version must be a non-empty string", + ) + } + version = &text + } + acquisitionCost := int64(1) + if rawCost, exists := candidate["acquisitionCost"]; exists { + number, ok := rawCost.(float64) + if !ok || number != float64(int64(number)) || number < 0 || + number > float64(worldcut.MaxAcquisitionCost) { + return worldCutBasis{}, invalid( + "assertion.basis.worldcut.acquisitionCost must be an integer between 0 and %d", + worldcut.MaxAcquisitionCost, + ) + } + acquisitionCost = int64(number) + } + dependencies, err := dependenciesFrom(candidate, assertion.TenantID) + if err != nil { + return worldCutBasis{}, err + } + return worldCutBasis{ + role: role, + resource: resource, + provenance: provenance, + version: version, + dependencies: dependencies, + acquisitionCost: acquisitionCost, + }, nil +} + +func dependenciesFrom(candidate map[string]any, tenantID string) ([]worldcut.DependencyWitness, error) { + rawDependencies, exists := candidate["dependencies"] + if !exists { + return nil, nil + } + values, ok := rawDependencies.([]any) + if !ok { + return nil, invalid("assertion.basis.worldcut.dependencies must be an array") + } + dependencies := make([]worldcut.DependencyWitness, 0, len(values)) + for _, value := range values { + record, ok := value.(map[string]any) + if !ok { + return nil, invalid("assertion.basis.worldcut.dependencies[] must be an object") + } + resourceRecord, ok := record["resource"].(map[string]any) + if !ok { + return nil, invalid( + "assertion.basis.worldcut.dependencies[].resource must be an object", + ) + } + if resourceRecord["account"] != tenantID { + return nil, invalid( + "Every WorldCut dependency resource account must equal the Agentic Data Kernel tenantId", + ) + } + unknown := unsupportedFields(record, map[string]bool{ + "name": true, + "resource": true, + "relation": true, + "version": true, + "provenance": true, + }) + if len(unknown) != 0 { + return nil, invalid( + "assertion.basis.worldcut.dependencies[] contains unsupported field(s): %v", + unknown, + ) + } + name, ok := record["name"].(string) + if !ok || name == "" { + return nil, invalid( + "assertion.basis.worldcut.dependencies[].name must be a non-empty string", + ) + } + resource, err := resourceFrom( + resourceRecord, + "assertion.basis.worldcut.dependencies["+name+"].resource", + ) + if err != nil { + return nil, err + } + relation, ok := record["relation"].(string) + if !ok || relation != "exact" { + return nil, invalid( + "assertion.basis.worldcut.dependencies[%s].relation is unsupported", + name, + ) + } + provenance, ok := record["provenance"].(string) + if !ok || !provenanceValues[provenance] { + return nil, invalid( + "assertion.basis.worldcut.dependencies[%s].provenance is unsupported", + name, + ) + } + var version *string + if rawVersion, exists := record["version"]; exists { + text, ok := rawVersion.(string) + if !ok || text == "" { + return nil, invalid( + "assertion.basis.worldcut.dependencies[%s].version must be a non-empty string", + name, + ) + } + version = &text + } + dependencies = append(dependencies, worldcut.DependencyWitness{ + Name: name, + Resource: resource, + Relation: relation, + Version: version, + Provenance: provenance, + }) + } + return dependencies, nil +} + +func resourceFrom(value any, field string) (worldcut.ResourceIdentity, error) { + record, ok := value.(map[string]any) + if !ok { + return worldcut.ResourceIdentity{}, invalid("%s must be an object", field) + } + allowed := map[string]bool{} + for _, name := range resourceFields { + allowed[name] = true + } + unknown := unsupportedFields(record, allowed) + if len(unknown) != 0 { + return worldcut.ResourceIdentity{}, invalid( + "%s contains unsupported field(s): %v", + field, + unknown, + ) + } + values := make([]string, 0, len(resourceFields)) + for _, name := range resourceFields { + text, ok := record[name].(string) + if !ok || text == "" { + return worldcut.ResourceIdentity{}, invalid( + "%s.%s must be a non-empty string", + field, + name, + ) + } + values = append(values, text) + } + return worldcut.ResourceIdentity{ + Provider: values[0], + Account: values[1], + Kind: values[2], + Key: values[3], + }, nil +} + +func unsupportedFields(record map[string]any, allowed map[string]bool) []string { + var unknown []string + for key := range record { + if !allowed[key] { + unknown = append(unknown, key) + } + } + sort.Strings(unknown) + return unknown +} diff --git a/ports/go/integrations/agenticdatakernel/agenticdatakernel_test.go b/ports/go/integrations/agenticdatakernel/agenticdatakernel_test.go new file mode 100644 index 0000000..84d1620 --- /dev/null +++ b/ports/go/integrations/agenticdatakernel/agenticdatakernel_test.go @@ -0,0 +1,423 @@ +package agenticdatakernel_test + +import ( + "strings" + "testing" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + adk "github.com/Jason-Doyle/WorldCut/ports/go/integrations/agenticdatakernel" +) + +func basis() map[string]any { + return map[string]any{ + "worldcut": map[string]any{ + "protocolVersion": "0.1", + "role": "head", + "resource": map[string]any{ + "provider": "github", + "account": "tenant-a", + "kind": "branch_head", + "key": "service/main", + }, + "provenance": "provider_asserted", + "version": "commit-B", + "acquisitionCost": 2, + }, + } +} + +func text(value string) *string { + return &value +} + +func resolution() adk.Resolution { + return adk.Resolution{ + Status: "known", + ValidAt: "2026-09-02T12:00:00.000Z", + SystemAt: "2026-09-02T12:00:00.000Z", + Selected: &adk.Assertion{ + TenantID: "tenant-a", + AssertionID: "assertion-1", + Object: map[string]any{"type": "string", "value": "commit-B"}, + ValidFrom: "2026-09-02T11:00:00.000Z", + ValidTo: nil, + SystemFrom: "2026-09-02T11:30:00.000Z", + SystemTo: nil, + Status: "active", + Basis: basis(), + }, + } +} + +func worldCutBasis(input adk.Resolution) map[string]any { + return input.Selected.Basis.(map[string]any)["worldcut"].(map[string]any) +} + +func TestAdaptsEligibleResolution(t *testing.T) { + observation, err := adk.ObservationFromResolution(resolution(), adk.Options{}) + if err != nil { + t.Fatal(err) + } + if observation.ID != "adk-assertion-1" { + t.Fatalf("id = %s", observation.ID) + } + if observation.Role != "head" { + t.Fatalf("role = %s", observation.Role) + } + if observation.Resource.Account != "tenant-a" { + t.Fatalf("resource account = %s", observation.Resource.Account) + } + if observation.Witness.Version == nil || *observation.Witness.Version != "commit-B" { + t.Fatalf("version = %v", observation.Witness.Version) + } + if observation.AcquisitionCost != 2 { + t.Fatalf("acquisitionCost = %d", observation.AcquisitionCost) + } + if observation.ObservedAt != "2026-09-02T11:30:00.000Z" { + t.Fatalf("observedAt = %s", observation.ObservedAt) + } + validity := observation.Witness.Validity + if validity == nil || validity.From != "2026-09-02T11:00:00.000Z" || validity.Until != nil { + t.Fatalf("validity = %+v", validity) + } + value, ok := observation.Value.(map[string]any) + if !ok || value["value"] != "commit-B" { + t.Fatalf("value = %#v", observation.Value) + } +} + +func TestAdaptedObservationVerifies(t *testing.T) { + observation, err := adk.ObservationFromResolution(resolution(), adk.Options{}) + if err != nil { + t.Fatal(err) + } + result, err := worldcut.VerifyDecisionContract(worldcut.VerificationInput{ + Contract: worldcut.Contract{ + ID: "kernel-decision", + Version: "1", + DecisionTime: "2026-09-02T12:00:00.000Z", + Requirements: []worldcut.Requirement{ + worldcut.NewValueEqualsRequirement( + "selected-commit", + "The kernel selected commit-B", + "head", + []string{"value"}, + "commit-B", + ), + }, + }, + Observations: []worldcut.Observation{observation}, + }) + if err != nil { + t.Fatal(err) + } + if result.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s", result.Verdict) + } +} + +func TestAdaptedObservationIsMutationIsolated(t *testing.T) { + input := resolution() + object := input.Selected.Object.(map[string]any) + validTo := "2026-09-02T13:00:00.000Z" + input.Selected.ValidTo = &validTo + observation, err := adk.ObservationFromResolution(input, adk.Options{}) + if err != nil { + t.Fatal(err) + } + object["value"] = "commit-C" + worldCutBasis(input)["role"] = "mutated" + worldCutBasis(input)["resource"].(map[string]any)["account"] = "tenant-b" + validTo = "9999-01-01T00:00:00.000Z" + + value := observation.Value.(map[string]any) + if value["value"] != "commit-B" { + t.Fatalf("observation value aliased the caller's object: %#v", value) + } + if observation.Role != "head" || observation.Resource.Account != "tenant-a" { + t.Fatalf("observation aliased the caller's basis: %+v", observation) + } + if observation.Witness.Validity.Until == nil || + *observation.Witness.Validity.Until != "2026-09-02T13:00:00.000Z" { + t.Fatalf("observation aliased the caller's validTo pointer: %+v", observation.Witness.Validity) + } +} + +func TestRejectsUnusableResolutionStatuses(t *testing.T) { + for _, status := range []string{"unknown", "conflicted"} { + input := resolution() + input.Status = status + input.Selected = nil + _, err := adk.ObservationFromResolution(input, adk.Options{}) + if worldcut.ErrorCode(err) != worldcut.ADKResolutionInvalidCode { + t.Fatalf("status %s error = %v", status, err) + } + } + + input := resolution() + input.Status = "invented" + if _, err := adk.ObservationFromResolution(input, adk.Options{}); err == nil || + !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("unexpected error: %v", err) + } + + input = resolution() + input.Status = "resolved_with_conflict" + if _, err := adk.ObservationFromResolution(input, adk.Options{}); err == nil || + !strings.Contains(err.Error(), "AllowResolvedWithConflict") { + t.Fatalf("unexpected error: %v", err) + } + observation, err := adk.ObservationFromResolution( + input, + adk.Options{AllowResolvedWithConflict: true}, + ) + if err != nil { + t.Fatal(err) + } + if observation.Role != "head" { + t.Fatalf("role = %s", observation.Role) + } + + input = resolution() + input.Selected = nil + if _, err := adk.ObservationFromResolution(input, adk.Options{}); err == nil || + !strings.Contains(err.Error(), "no selected assertion") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRejectsIneligibleLifecycleAndTemporalStates(t *testing.T) { + disputed := resolution() + disputed.Selected.Status = "disputed" + if _, err := adk.ObservationFromResolution(disputed, adk.Options{}); err == nil || + !strings.Contains(err.Error(), "not eligible") { + t.Fatalf("unexpected error: %v", err) + } + + systemClosed := resolution() + systemClosed.Selected.SystemTo = text("2026-09-02T11:45:00.000Z") + if _, err := adk.ObservationFromResolution(systemClosed, adk.Options{}); err == nil || + !strings.Contains(err.Error(), "not system-valid") { + t.Fatalf("unexpected error: %v", err) + } + + systemEarly := resolution() + systemEarly.Selected.SystemFrom = "2026-09-02T12:30:00.000Z" + if _, err := adk.ObservationFromResolution(systemEarly, adk.Options{}); err == nil || + !strings.Contains(err.Error(), "not system-valid") { + t.Fatalf("unexpected error: %v", err) + } + + businessExpired := resolution() + businessExpired.Selected.ValidTo = text("2026-09-02T11:45:00.000Z") + if _, err := adk.ObservationFromResolution(businessExpired, adk.Options{}); err == nil || + !strings.Contains(err.Error(), "not business-valid") { + t.Fatalf("unexpected error: %v", err) + } + + for name, mutate := range map[string]func(*adk.Resolution){ + "unnormalized systemAt": func(r *adk.Resolution) { r.SystemAt = "2026-09-02T12:00:00Z" }, + "unnormalized validAt": func(r *adk.Resolution) { r.ValidAt = "" }, + "unnormalized validFrom": func(r *adk.Resolution) { + r.Selected.ValidFrom = "2026-09-02" + }, + "unnormalized systemTo": func(r *adk.Resolution) { + r.Selected.SystemTo = text("not-a-timestamp") + }, + } { + t.Run(name, func(t *testing.T) { + input := resolution() + mutate(&input) + _, err := adk.ObservationFromResolution(input, adk.Options{}) + if worldcut.ErrorCode(err) != worldcut.ADKResolutionInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + }) + } +} + +func TestBindsResourceAccountsToTheKernelTenant(t *testing.T) { + mismatched := resolution() + worldCutBasis(mismatched)["resource"].(map[string]any)["account"] = "tenant-b" + if _, err := adk.ObservationFromResolution(mismatched, adk.Options{}); err == nil || + !strings.Contains(err.Error(), "tenantId") { + t.Fatalf("unexpected error: %v", err) + } + + dependencyTenant := resolution() + worldCutBasis(dependencyTenant)["dependencies"] = []any{ + map[string]any{"resource": map[string]any{"account": "tenant-b"}}, + } + _, err := adk.ObservationFromResolution(dependencyTenant, adk.Options{}) + if err == nil || !strings.Contains(err.Error(), "Every WorldCut dependency resource account") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAcceptsDependencyWitnesses(t *testing.T) { + input := resolution() + worldCutBasis(input)["role"] = "ci" + worldCutBasis(input)["dependencies"] = []any{ + map[string]any{ + "name": "tested_head", + "resource": map[string]any{ + "provider": "github", + "account": "tenant-a", + "kind": "branch_head", + "key": "service/main", + }, + "relation": "exact", + "version": "commit-B", + "provenance": "provider_asserted", + }, + } + observation, err := adk.ObservationFromResolution(input, adk.Options{}) + if err != nil { + t.Fatal(err) + } + if len(observation.Witness.Dependencies) != 1 { + t.Fatalf("dependencies = %+v", observation.Witness.Dependencies) + } + dependency := observation.Witness.Dependencies[0] + if dependency.Name != "tested_head" || dependency.Relation != "exact" || + dependency.Version == nil || *dependency.Version != "commit-B" { + t.Fatalf("dependency = %+v", dependency) + } +} + +func TestRejectsMalformedWorldCutMetadata(t *testing.T) { + cases := map[string]func(map[string]any){ + "dependencies is an object": func(b map[string]any) { + b["dependencies"] = map[string]any{} + }, + "dependency is not an object": func(b map[string]any) { + b["dependencies"] = []any{"tested_head"} + }, + "dependency resource missing": func(b map[string]any) { + b["dependencies"] = []any{map[string]any{"name": "tested_head"}} + }, + "dependency relation unsupported": func(b map[string]any) { + b["dependencies"] = []any{map[string]any{ + "name": "tested_head", + "resource": map[string]any{ + "provider": "github", + "account": "tenant-a", + "kind": "branch_head", + "key": "service/main", + }, + "relation": "compatible", + "provenance": "provider_asserted", + }} + }, + "dependency field unsupported": func(b map[string]any) { + b["dependencies"] = []any{map[string]any{ + "name": "tested_head", + "resource": map[string]any{ + "provider": "github", + "account": "tenant-a", + "kind": "branch_head", + "key": "service/main", + }, + "relation": "exact", + "provenance": "provider_asserted", + "expiresAt": "2026-09-02T12:00:00.000Z", + }} + }, + "unsupported basis field": func(b map[string]any) { b["scope"] = "global" }, + "wrong protocol version": func(b map[string]any) { b["protocolVersion"] = "0.2" }, + "missing protocol version": func(b map[string]any) { delete(b, "protocolVersion") }, + "empty role": func(b map[string]any) { b["role"] = "" }, + "missing role": func(b map[string]any) { delete(b, "role") }, + "role is not a string": func(b map[string]any) { b["role"] = 4 }, + "missing resource": func(b map[string]any) { delete(b, "resource") }, + "resource extra field": func(b map[string]any) { + b["resource"].(map[string]any)["region"] = "eu" + }, + "resource field empty": func(b map[string]any) { + b["resource"].(map[string]any)["kind"] = "" + }, + "unsupported provenance": func(b map[string]any) { b["provenance"] = "guessed" }, + "missing provenance": func(b map[string]any) { delete(b, "provenance") }, + "empty version": func(b map[string]any) { b["version"] = "" }, + "negative cost": func(b map[string]any) { b["acquisitionCost"] = -1 }, + "fractional cost": func(b map[string]any) { b["acquisitionCost"] = 1.5 }, + "unbounded cost": func(b map[string]any) { + b["acquisitionCost"] = float64(worldcut.MaxAcquisitionCost) + 1 + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + input := resolution() + mutate(worldCutBasis(input)) + _, err := adk.ObservationFromResolution(input, adk.Options{}) + if worldcut.ErrorCode(err) != worldcut.ADKResolutionInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + }) + } +} + +func TestRejectsMalformedBasisContainers(t *testing.T) { + cases := map[string]any{ + "basis is nil": nil, + "basis is a string": "worldcut", + "basis is an array": []any{}, + "basis has no worldcut entry": map[string]any{"other": map[string]any{}}, + "worldcut is not an object": map[string]any{"worldcut": "0.1"}, + } + for name, value := range cases { + t.Run(name, func(t *testing.T) { + input := resolution() + input.Selected.Basis = value + _, err := adk.ObservationFromResolution(input, adk.Options{}) + if worldcut.ErrorCode(err) != worldcut.ADKResolutionInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + }) + } +} + +func TestRejectsNonJSONAssertionObjects(t *testing.T) { + input := resolution() + input.Selected.Object = func() {} + _, err := adk.ObservationFromResolution(input, adk.Options{}) + if worldcut.ErrorCode(err) != worldcut.ADKResolutionInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + + cyclic := map[string]any{} + cyclic["self"] = cyclic + input = resolution() + input.Selected.Object = cyclic + _, err = adk.ObservationFromResolution(input, adk.Options{}) + if worldcut.ErrorCode(err) != worldcut.ADKResolutionInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } +} + +func TestRejectsAssertionIdentitiesTheEngineWouldReject(t *testing.T) { + input := resolution() + input.Selected.AssertionID = string([]byte{0xed, 0xa0, 0x80}) + _, err := adk.ObservationFromResolution(input, adk.Options{}) + if worldcut.ErrorCode(err) != worldcut.ADKResolutionInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + if !strings.Contains(err.Error(), "WorldCut metadata is invalid") { + t.Fatalf("error message = %s", err.Error()) + } +} + +func TestPreservesYearZeroCompatibleTimestamps(t *testing.T) { + input := resolution() + input.Selected.ValidFrom = "0000-01-01T00:00:00.000Z" + input.Selected.SystemFrom = "0000-01-01T00:00:00.000Z" + input.ValidAt = "0000-01-01T00:00:00.000Z" + input.SystemAt = "0000-01-01T00:00:00.000Z" + observation, err := adk.ObservationFromResolution(input, adk.Options{}) + if err != nil { + t.Fatal(err) + } + if observation.ObservedAt != "0000-01-01T00:00:00.000Z" { + t.Fatalf("observedAt = %s", observation.ObservedAt) + } +} diff --git a/ports/go/integrations/githubactions/githubactions.go b/ports/go/integrations/githubactions/githubactions.go new file mode 100644 index 0000000..8d51202 --- /dev/null +++ b/ports/go/integrations/githubactions/githubactions.go @@ -0,0 +1,670 @@ +// Package githubactions verifies that the latest completed push run of an +// exact GitHub Actions workflow tested the current branch head. +// +// It uses only the standard library. The HTTP client, API base URL, clock, +// and observation identifier source are injectable so the gate can be tested +// deterministically without network access. +package githubactions + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + "github.com/Jason-Doyle/WorldCut/ports/go/internal/idgen" +) + +// DefaultAPIBaseURL is the public GitHub REST API root. +const DefaultAPIBaseURL = "https://api.github.com" + +// maxResponseBytes bounds every response body this package reads. +const maxResponseBytes = 8 << 20 + +// maxSafeInteger matches the JavaScript safe-integer bound the reference +// implementation enforces on GitHub identifiers. +const maxSafeInteger = int64(9007199254740991) + +// HTTPDoer performs a single HTTP request. *http.Client satisfies it. +type HTTPDoer interface { + Do(request *http.Request) (*http.Response, error) +} + +// Options selects the workflow gate target and its injectable dependencies. +type Options struct { + // Repository is an owner/name pair. + Repository string + // Branch is the branch whose head must have been tested. + Branch string + // Workflow is a numeric workflow ID or a .yml/.yaml workflow filename. + Workflow string + // Token is an optional GitHub token. It is never included in an error. + Token string + // APIBaseURL defaults to DefaultAPIBaseURL. + APIBaseURL string + // Client defaults to a client that refuses to follow redirects. + Client HTTPDoer + // Clock defaults to time.Now. + Clock func() time.Time + // NewID defaults to a random version 4 UUID. + NewID func() (string, error) +} + +// WorkflowRunEvidence is the validated subset of a workflow run WorldCut uses. +type WorkflowRunEvidence struct { + ID int64 `json:"id"` + WorkflowID int64 `json:"workflowId"` + HeadSHA string `json:"headSha"` + HeadBranch string `json:"headBranch"` + Event string `json:"event"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` +} + +// Verification is the complete result of one deployment gate evaluation. +type Verification struct { + Repository string `json:"repository"` + Branch string `json:"branch"` + Workflow string `json:"workflow"` + // BranchSHA is the branch head observed after the run lookup. + BranchSHA string `json:"branchSha"` + // VerifiedSHA is non-nil only when the contract is satisfied. Deployments + // must consume this exact SHA, never the branch name. + VerifiedSHA *string `json:"verifiedSha"` + WorkflowRun *WorkflowRunEvidence `json:"workflowRun"` + Input worldcut.VerificationInput `json:"input"` + Result *worldcut.VerificationResult `json:"result"` +} + +// EvidenceCoverage summarizes how much of the required evidence GitHub +// actually exposes across recent completed push runs. +type EvidenceCoverage struct { + Repository string `json:"repository"` + Branch string `json:"branch"` + Workflow string `json:"workflow"` + InspectedRuns int `json:"inspectedRuns"` + DependencyEvidenceAvailable int `json:"dependencyEvidenceAvailable"` + ConclusionEvidenceAvailable int `json:"conclusionEvidenceAvailable"` + CompleteEvidenceRuns int `json:"completeEvidenceRuns"` + EvidenceCoverage float64 `json:"evidenceCoverage"` + Conclusions map[string]int `json:"conclusions"` +} + +var ( + repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) + workflowIDPattern = regexp.MustCompile(`^[0-9]+$`) + workflowFile = regexp.MustCompile(`(?i)^[A-Za-z0-9_.-]+\.ya?ml$`) + shaPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) +) + +// ErrRedirectNotFollowed reports that the GitHub API redirected. WorldCut +// never follows a redirect while collecting evidence. +var ErrRedirectNotFollowed = errors.New("GitHub API redirects are not followed") + +func responseInvalid(format string, arguments ...any) error { + return worldcut.NewError( + worldcut.GitHubResponseInvalidCode, + fmt.Sprintf(format, arguments...), + ) +} + +func apiError(message string, cause error) error { + if cause == nil { + return worldcut.NewError(worldcut.GitHubAPIErrorCode, message) + } + return worldcut.WrapError(worldcut.GitHubAPIErrorCode, message, cause) +} + +type client struct { + repository string + repositoryPath string + branch string + workflow string + token string + apiBaseURL string + http HTTPDoer + clock func() time.Time + newID func() (string, error) +} + +func (options Options) resolve() (*client, error) { + if !repositoryPattern.MatchString(options.Repository) { + return nil, responseInvalid("repository must use owner/name form") + } + if strings.TrimSpace(options.Branch) == "" { + return nil, responseInvalid("branch must not be empty") + } + if !workflowIDPattern.MatchString(options.Workflow) && !workflowFile.MatchString(options.Workflow) { + return nil, responseInvalid("workflow must be a numeric workflow ID or workflow filename") + } + owner, name, found := strings.Cut(options.Repository, "/") + if !found || owner == "" || name == "" { + return nil, responseInvalid("repository must use owner/name form") + } + apiBaseURL := options.APIBaseURL + if apiBaseURL == "" { + apiBaseURL = DefaultAPIBaseURL + } + apiBaseURL = strings.TrimRight(apiBaseURL, "/") + if apiBaseURL == "" { + return nil, responseInvalid("apiBaseUrl must not be empty") + } + parsedBaseURL, err := url.Parse(apiBaseURL) + if err != nil || + (parsedBaseURL.Scheme != "http" && parsedBaseURL.Scheme != "https") || + parsedBaseURL.Host == "" || + parsedBaseURL.User != nil || + parsedBaseURL.RawQuery != "" || + parsedBaseURL.Fragment != "" { + return nil, responseInvalid( + "apiBaseUrl must be an absolute HTTP(S) URL without user information, query, or fragment", + ) + } + httpClient := options.Client + if standardClient, ok := httpClient.(*http.Client); ok { + cloned := *standardClient + cloned.CheckRedirect = func(*http.Request, []*http.Request) error { + return ErrRedirectNotFollowed + } + httpClient = &cloned + } else if httpClient == nil { + httpClient = &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return ErrRedirectNotFollowed + }, + } + } + clock := options.Clock + if clock == nil { + clock = time.Now + } + newID := options.NewID + if newID == nil { + newID = idgen.UUIDv4 + } + return &client{ + repository: options.Repository, + repositoryPath: url.PathEscape(owner) + "/" + url.PathEscape(name), + branch: options.Branch, + workflow: options.Workflow, + token: options.Token, + apiBaseURL: apiBaseURL, + http: httpClient, + clock: clock, + newID: newID, + }, nil +} + +func (c *client) now() string { + return worldcut.FormatTimestamp(c.clock()) +} + +func (c *client) observationID(prefix string) (string, error) { + value, err := c.newID() + if err != nil { + return "", responseInvalid("generate observation identifier: %v", err) + } + if value == "" { + return "", responseInvalid("observation identifier source returned an empty value") + } + return prefix + "-" + value, nil +} + +func (c *client) workflowRunsURL(perPage int) string { + query := url.Values{} + query.Set("branch", c.branch) + query.Set("event", "push") + query.Set("status", "completed") + query.Set("exclude_pull_requests", "true") + query.Set("per_page", strconv.Itoa(perPage)) + return c.apiBaseURL + "/repos/" + c.repositoryPath + "/actions/workflows/" + + url.PathEscape(c.workflow) + "/runs?" + query.Encode() +} + +func (c *client) branchURL() string { + return c.apiBaseURL + "/repos/" + c.repositoryPath + "/branches/" + url.PathEscape(c.branch) +} + +func (c *client) getJSON(ctx context.Context, target string) (any, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, apiError("GitHub request could not be built for "+target, err) + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "worldcut") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if c.token != "" { + request.Header.Set("Authorization", "Bearer "+c.token) + } + response, err := c.http.Do(request) + if err != nil { + if response != nil && response.Body != nil { + _ = response.Body.Close() + } + return nil, apiError("GitHub request failed for "+target, err) + } + if response == nil { + return nil, apiError("GitHub client returned no response for "+target, nil) + } + if response.Request != nil && + response.Request.URL != nil && + response.Request.URL.String() != request.URL.String() { + if response.Body != nil { + _ = response.Body.Close() + } + return nil, apiError( + "GitHub request changed resource URL for "+target, + ErrRedirectNotFollowed, + ) + } + var body []byte + if response.Body != nil { + defer func() { + _ = response.Body.Close() + }() + body, err = io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1)) + if err != nil { + return nil, apiError("GitHub response could not be read for "+target, err) + } + if len(body) > maxResponseBytes { + return nil, responseInvalid( + "GitHub response for %s exceeded %d bytes", + target, + maxResponseBytes, + ) + } + } + if response.StatusCode < 200 || response.StatusCode > 299 { + return nil, apiError(fmt.Sprintf( + "GitHub request returned %d: %s", + response.StatusCode, + truncate(string(body), 500), + ), nil) + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, worldcut.WrapError( + worldcut.GitHubResponseInvalidCode, + "GitHub returned invalid JSON for "+target, + err, + ) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return nil, responseInvalid("GitHub returned trailing content for %s", target) + } + return value, nil +} + +func truncate(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func requireRecord(value any, field string) (map[string]any, error) { + record, ok := value.(map[string]any) + if !ok { + return nil, responseInvalid("%s must be an object", field) + } + return record, nil +} + +func requireString(record map[string]any, field string) (string, error) { + value, ok := record[field].(string) + if !ok || value == "" { + return "", responseInvalid("GitHub response field %s must be a non-empty string", field) + } + return value, nil +} + +func requireSafeInteger(record map[string]any, field string) (int64, error) { + number, ok := record[field].(json.Number) + if !ok { + return 0, responseInvalid("GitHub response field %s must be a safe integer", field) + } + value, err := number.Int64() + if err != nil { + float, floatErr := number.Float64() + if floatErr != nil || float != float64(int64(float)) { + return 0, responseInvalid("GitHub response field %s must be a safe integer", field) + } + value = int64(float) + } + if value > maxSafeInteger || value < -maxSafeInteger { + return 0, responseInvalid("GitHub response field %s must be a safe integer", field) + } + return value, nil +} + +func requireSHA(value, field string) (string, error) { + if !shaPattern.MatchString(value) { + return "", responseInvalid("%s must be a full Git commit SHA", field) + } + return value, nil +} + +func workflowRunFromResponse(value any, repository, branch string) (*WorkflowRunEvidence, error) { + run, err := requireRecord(value, "workflow run") + if err != nil { + return nil, err + } + headRepository, err := requireRecord(run["head_repository"], "workflow run head_repository") + if err != nil { + return nil, err + } + fullName, err := requireString(headRepository, "full_name") + if err != nil { + return nil, err + } + headBranch, err := requireString(run, "head_branch") + if err != nil { + return nil, err + } + event, err := requireString(run, "event") + if err != nil { + return nil, err + } + status, err := requireString(run, "status") + if err != nil { + return nil, err + } + if !strings.EqualFold(fullName, repository) { + return nil, responseInvalid("workflow run belongs to %s, not %s", fullName, repository) + } + if headBranch != branch || event != "push" || status != "completed" { + return nil, responseInvalid( + "workflow run does not match the requested branch, push event, and completed status", + ) + } + id, err := requireSafeInteger(run, "id") + if err != nil { + return nil, err + } + workflowID, err := requireSafeInteger(run, "workflow_id") + if err != nil { + return nil, err + } + rawHeadSHA, err := requireString(run, "head_sha") + if err != nil { + return nil, err + } + headSHA, err := requireSHA(rawHeadSHA, "workflow head_sha") + if err != nil { + return nil, err + } + conclusion, err := requireString(run, "conclusion") + if err != nil { + return nil, err + } + runURL, err := requireString(run, "html_url") + if err != nil { + return nil, err + } + return &WorkflowRunEvidence{ + ID: id, + WorkflowID: workflowID, + HeadSHA: headSHA, + HeadBranch: headBranch, + Event: "push", + Status: "completed", + Conclusion: conclusion, + URL: runURL, + }, nil +} + +func workflowRuns(payload any) ([]any, error) { + record, err := requireRecord(payload, "workflow runs response") + if err != nil { + return nil, err + } + runs, ok := record["workflow_runs"].([]any) + if !ok { + return nil, responseInvalid("GitHub workflow_runs must be an array") + } + return runs, nil +} + +func branchSHAFromResponse(payload any) (string, error) { + branch, err := requireRecord(payload, "branch") + if err != nil { + return "", err + } + commit, err := requireRecord(branch["commit"], "branch commit") + if err != nil { + return "", err + } + sha, err := requireString(commit, "sha") + if err != nil { + return "", err + } + return requireSHA(sha, "branch commit sha") +} + +// VerifyLatestWorkflow gates a deployment on the latest completed push run of +// one exact workflow. +// +// It deliberately selects the latest completed run rather than the latest +// successful run, so a newer failure is a known violation instead of being +// hidden by an older success. It returns a verified SHA only when the +// contract is satisfied. +func VerifyLatestWorkflow(ctx context.Context, options Options) (*Verification, error) { + if ctx == nil { + ctx = context.Background() + } + gate, err := options.resolve() + if err != nil { + return nil, err + } + + payload, err := gate.getJSON(ctx, gate.workflowRunsURL(1)) + if err != nil { + return nil, err + } + runs, err := workflowRuns(payload) + if err != nil { + return nil, err + } + var run *WorkflowRunEvidence + if len(runs) > 0 { + run, err = workflowRunFromResponse(runs[0], gate.repository, gate.branch) + if err != nil { + return nil, err + } + } + runObservedAt := gate.now() + + branchPayload, err := gate.getJSON(ctx, gate.branchURL()) + if err != nil { + return nil, err + } + branchSHA, err := branchSHAFromResponse(branchPayload) + if err != nil { + return nil, err + } + branchObservedAt := gate.now() + decisionTime := gate.now() + + branchResource := worldcut.ResourceIdentity{ + Provider: "github", + Account: gate.repository, + Kind: "branch_head", + Key: gate.branch, + } + headID, err := gate.observationID("github-head") + if err != nil { + return nil, err + } + headVersion := branchSHA + observations := []worldcut.Observation{{ + ID: headID, + Role: "head", + Resource: branchResource, + Value: map[string]any{ + "repository": gate.repository, + "branch": gate.branch, + "sha": branchSHA, + }, + ObservedAt: branchObservedAt, + AcquisitionCost: 1, + Witness: worldcut.ObservationWitness{ + Provenance: "provider_asserted", + Version: &headVersion, + }, + }} + if run != nil { + runID, err := gate.observationID("github-run") + if err != nil { + return nil, err + } + runVersion := strconv.FormatInt(run.ID, 10) + testedHead := run.HeadSHA + observations = append(observations, worldcut.Observation{ + ID: runID, + Role: "ci", + Resource: worldcut.ResourceIdentity{ + Provider: "github-actions", + Account: gate.repository, + Kind: "workflow_run", + Key: gate.workflow + "/" + runVersion, + }, + Value: map[string]any{ + "conclusion": run.Conclusion, + "event": run.Event, + "headSha": run.HeadSHA, + "runId": run.ID, + "status": run.Status, + "url": run.URL, + "workflowId": run.WorkflowID, + }, + ObservedAt: runObservedAt, + AcquisitionCost: 2, + Witness: worldcut.ObservationWitness{ + Provenance: "provider_asserted", + Version: &runVersion, + Dependencies: []worldcut.DependencyWitness{{ + Name: "tested_head", + Resource: branchResource, + Relation: "exact", + Version: &testedHead, + Provenance: "provider_asserted", + }}, + }, + }) + } + + input := worldcut.VerificationInput{ + ProtocolVersion: worldcut.ProtocolVersion, + Contract: worldcut.Contract{ + ID: "github-latest-completed-push", + Version: "1", + DecisionTime: decisionTime, + Assumptions: worldcut.SupportedAssumptions(), + Requirements: []worldcut.Requirement{ + worldcut.NewValueEqualsRequirement( + "workflow-conclusion-success", + "The latest completed push workflow concluded successfully", + "ci", + []string{"conclusion"}, + "success", + ), + worldcut.NewDependencyRequirement( + "workflow-tested-current-head", + "The workflow run tested the selected branch head", + "ci", + "head", + "tested_head", + ), + }, + }, + Observations: observations, + } + result, err := worldcut.VerifyDecisionContract(input) + if err != nil { + return nil, worldcut.WrapError( + worldcut.GitHubResponseInvalidCode, + "GitHub evidence did not form a valid WorldCut verification input", + err, + ) + } + verification := &Verification{ + Repository: gate.repository, + Branch: gate.branch, + Workflow: gate.workflow, + BranchSHA: branchSHA, + WorkflowRun: run, + Input: input, + Result: result, + } + if result.Verdict == "CONTRACT_SATISFIED" { + verified := branchSHA + verification.VerifiedSHA = &verified + } + return verification, nil +} + +// InspectWorkflowEvidence reports how consistently GitHub exposes the +// dependency and conclusion evidence the gate requires, over the latest +// completed push runs. The limit must be between 1 and 100. +func InspectWorkflowEvidence(ctx context.Context, options Options, limit int) (*EvidenceCoverage, error) { + if ctx == nil { + ctx = context.Background() + } + gate, err := options.resolve() + if err != nil { + return nil, err + } + if limit < 1 || limit > 100 { + return nil, responseInvalid("history limit must be an integer from 1 through 100") + } + payload, err := gate.getJSON(ctx, gate.workflowRunsURL(limit)) + if err != nil { + return nil, err + } + rawRuns, err := workflowRuns(payload) + if err != nil { + return nil, err + } + conclusions := map[string]int{} + completeEvidenceRuns := 0 + for _, rawRun := range rawRuns { + run, err := workflowRunFromResponse(rawRun, gate.repository, gate.branch) + if err != nil { + return nil, err + } + conclusions[run.Conclusion]++ + if len(run.HeadSHA) == 40 && run.Conclusion != "" { + completeEvidenceRuns++ + } + } + coverage := 0.0 + if len(rawRuns) > 0 { + coverage = float64(completeEvidenceRuns) / float64(len(rawRuns)) + } + return &EvidenceCoverage{ + Repository: gate.repository, + Branch: gate.branch, + Workflow: gate.workflow, + InspectedRuns: len(rawRuns), + DependencyEvidenceAvailable: len(rawRuns), + ConclusionEvidenceAvailable: len(rawRuns), + CompleteEvidenceRuns: completeEvidenceRuns, + EvidenceCoverage: coverage, + Conclusions: conclusions, + }, nil +} diff --git a/ports/go/integrations/githubactions/githubactions_test.go b/ports/go/integrations/githubactions/githubactions_test.go new file mode 100644 index 0000000..12dd413 --- /dev/null +++ b/ports/go/integrations/githubactions/githubactions_test.go @@ -0,0 +1,637 @@ +package githubactions_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + worldcut "github.com/Jason-Doyle/WorldCut/ports/go" + "github.com/Jason-Doyle/WorldCut/ports/go/integrations/githubactions" +) + +const ( + currentSHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + staleSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +type recordedRequest struct { + method string + path string + escapedPath string + query string + authorization string + accept string + userAgent string + apiVersion string +} + +type responseErrorClient struct { + response *http.Response + err error +} + +func (c responseErrorClient) Do(*http.Request) (*http.Response, error) { + return c.response, c.err +} + +type trackedBody struct { + closed bool +} + +func (b *trackedBody) Read([]byte) (int, error) { + return 0, io.EOF +} + +func (b *trackedBody) Close() error { + b.closed = true + return nil +} + +type fixture struct { + status int + responseBody string + runs []any + runsOverride any + branchPayload any + oversized bool + requests []recordedRequest +} + +func run(overrides map[string]any, removed ...string) map[string]any { + value := map[string]any{ + "id": 81, + "workflow_id": 42, + "head_sha": currentSHA, + "head_branch": "main", + "event": "push", + "status": "completed", + "conclusion": "success", + "html_url": "https://github.com/acme/service/actions/runs/81", + "head_repository": map[string]any{"full_name": "acme/service"}, + } + for key, override := range overrides { + value[key] = override + } + for _, key := range removed { + delete(value, key) + } + return value +} + +func (f *fixture) server(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + f.requests = append(f.requests, recordedRequest{ + method: request.Method, + path: request.URL.Path, + escapedPath: request.URL.EscapedPath(), + query: request.URL.RawQuery, + authorization: request.Header.Get("Authorization"), + accept: request.Header.Get("Accept"), + userAgent: request.Header.Get("User-Agent"), + apiVersion: request.Header.Get("X-GitHub-Api-Version"), + }) + if f.status != 0 { + writer.WriteHeader(f.status) + body := f.responseBody + if body == "" { + body = "failure" + } + _, _ = writer.Write([]byte(body)) + return + } + writer.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(request.URL.Path, "/actions/workflows/"): + if f.oversized { + _, _ = writer.Write([]byte(`"` + strings.Repeat("a", 9<<20) + `"`)) + return + } + var payload any = map[string]any{"workflow_runs": f.runs} + if f.runsOverride != nil { + payload = f.runsOverride + } + if f.runs == nil && f.runsOverride == nil { + payload = map[string]any{"workflow_runs": []any{run(nil)}} + } + writeJSON(t, writer, payload) + case strings.Contains(request.URL.Path, "/branches/"): + payload := f.branchPayload + if payload == nil { + payload = map[string]any{"commit": map[string]any{"sha": currentSHA}} + } + writeJSON(t, writer, payload) + default: + t.Errorf("unexpected GitHub path: %s", request.URL.Path) + writer.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server +} + +func writeJSON(t *testing.T, writer http.ResponseWriter, payload any) { + t.Helper() + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + _, _ = writer.Write(encoded) +} + +func options(server *httptest.Server) githubactions.Options { + return githubactions.Options{ + Repository: "acme/service", + Branch: "main", + Workflow: "ci.yml", + APIBaseURL: server.URL, + } +} + +func TestGateReturnsImmutableSHAForCurrentSuccessfulRun(t *testing.T) { + state := &fixture{} + server := state.server(t) + verification, err := githubactions.VerifyLatestWorkflow(context.Background(), options(server)) + if err != nil { + t.Fatal(err) + } + if verification.Result.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s", verification.Result.Verdict) + } + if verification.VerifiedSHA == nil || *verification.VerifiedSHA != currentSHA { + t.Fatalf("verifiedSha = %v", verification.VerifiedSHA) + } + if verification.WorkflowRun == nil || verification.WorkflowRun.HeadSHA != currentSHA { + t.Fatalf("workflowRun = %+v", verification.WorkflowRun) + } + if verification.WorkflowRun.ID != 81 || verification.WorkflowRun.WorkflowID != 42 { + t.Fatalf("workflowRun identifiers = %+v", verification.WorkflowRun) + } + if len(verification.Input.Observations) != 2 { + t.Fatalf("observations = %d", len(verification.Input.Observations)) + } + if verification.Input.Observations[0].ID == verification.Input.Observations[1].ID { + t.Fatal("observation identifiers are not unique") + } + + if len(state.requests) != 2 { + t.Fatalf("requests = %d", len(state.requests)) + } + runsRequest := state.requests[0] + for _, expected := range []string{ + "branch=main", + "event=push", + "status=completed", + "exclude_pull_requests=true", + "per_page=1", + } { + if !strings.Contains(runsRequest.query, expected) { + t.Fatalf("query %q is missing %q", runsRequest.query, expected) + } + } + if runsRequest.accept != "application/vnd.github+json" || + runsRequest.userAgent != "worldcut" || + runsRequest.apiVersion != "2022-11-28" { + t.Fatalf("request headers = %+v", runsRequest) + } + if runsRequest.authorization != "" { + t.Fatalf("unauthenticated request sent an Authorization header: %q", runsRequest.authorization) + } + if !strings.HasSuffix(state.requests[1].path, "/branches/main") { + t.Fatalf("branch path = %s", state.requests[1].path) + } +} + +func TestGateRejectsRunForOlderBranchHead(t *testing.T) { + state := &fixture{runs: []any{run(map[string]any{"head_sha": staleSHA})}} + verification, err := githubactions.VerifyLatestWorkflow(context.Background(), options(state.server(t))) + if err != nil { + t.Fatal(err) + } + if verification.Result.Verdict != "CONTRACT_VIOLATED" { + t.Fatalf("verdict = %s", verification.Result.Verdict) + } + if verification.VerifiedSHA != nil { + t.Fatalf("verifiedSha = %v", *verification.VerifiedSHA) + } +} + +func TestGateRejectsLatestCompletedFailedRun(t *testing.T) { + state := &fixture{runs: []any{run(map[string]any{"conclusion": "failure"})}} + verification, err := githubactions.VerifyLatestWorkflow(context.Background(), options(state.server(t))) + if err != nil { + t.Fatal(err) + } + if verification.Result.Verdict != "CONTRACT_VIOLATED" { + t.Fatalf("verdict = %s", verification.Result.Verdict) + } + found := false + for _, requirement := range verification.Result.RequirementResults { + if requirement.RequirementID == "workflow-conclusion-success" { + found = true + if requirement.Status != "VIOLATED" { + t.Fatalf("conclusion requirement status = %s", requirement.Status) + } + } + } + if !found { + t.Fatal("the conclusion requirement was not evaluated") + } +} + +func TestGateTreatsNoCompletedPushRunAsInsufficientEvidence(t *testing.T) { + state := &fixture{runsOverride: map[string]any{"workflow_runs": []any{}}} + verification, err := githubactions.VerifyLatestWorkflow(context.Background(), options(state.server(t))) + if err != nil { + t.Fatal(err) + } + if verification.Result.Verdict != "INSUFFICIENT_EVIDENCE" { + t.Fatalf("verdict = %s", verification.Result.Verdict) + } + if verification.WorkflowRun != nil { + t.Fatalf("workflowRun = %+v", verification.WorkflowRun) + } + if verification.VerifiedSHA != nil { + t.Fatal("insufficient evidence produced a verified SHA") + } +} + +func TestGateReportsAPIErrors(t *testing.T) { + state := &fixture{status: http.StatusForbidden, responseBody: "rate limited"} + _, err := githubactions.VerifyLatestWorkflow(context.Background(), options(state.server(t))) + if worldcut.ErrorCode(err) != worldcut.GitHubAPIErrorCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "rate limited") { + t.Fatalf("error message = %s", err.Error()) + } +} + +func TestGateNeverLeaksTheToken(t *testing.T) { + const token = "ghp_supersecrettokenvalue" + state := &fixture{status: http.StatusUnauthorized, responseBody: "bad credentials"} + server := state.server(t) + gateOptions := options(server) + gateOptions.Token = token + _, err := githubactions.VerifyLatestWorkflow(context.Background(), gateOptions) + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), token) { + t.Fatalf("token leaked into the error: %s", err.Error()) + } + if state.requests[0].authorization != "Bearer "+token { + t.Fatalf("Authorization header = %q", state.requests[0].authorization) + } +} + +func TestGateRejectsAdversarialResponses(t *testing.T) { + cases := map[string]*fixture{ + "foreign repository": {runs: []any{run(map[string]any{ + "head_repository": map[string]any{"full_name": "fork/service"}, + })}}, + "missing head repository": {runs: []any{run(nil, "head_repository")}}, + "wrong branch": {runs: []any{run(map[string]any{"head_branch": "release"})}}, + "wrong event": {runs: []any{run(map[string]any{"event": "schedule"})}}, + "incomplete status": {runs: []any{run(map[string]any{"status": "in_progress"})}}, + "short head sha": {runs: []any{run(map[string]any{"head_sha": "abc123"})}}, + "non-hex head sha": {runs: []any{run(map[string]any{ + "head_sha": strings.Repeat("z", 40), + })}}, + "unsafe run id": {runs: []any{run(map[string]any{"id": json.Number("9007199254740992")})}}, + "fractional run id": {runs: []any{run(map[string]any{"id": 1.5})}}, + "string run id": {runs: []any{run(map[string]any{"id": "81"})}}, + "missing conclusion": {runs: []any{run(nil, "conclusion")}}, + "empty html url": {runs: []any{run(map[string]any{"html_url": ""})}}, + "run is not an object": {runsOverride: map[string]any{ + "workflow_runs": []any{"not-a-run"}, + }}, + "workflow runs is not an array": {runsOverride: map[string]any{"workflow_runs": 5}}, + "response is not an object": {runsOverride: []any{}}, + "branch is not an object": {branchPayload: "not-a-branch"}, + "branch commit missing": {branchPayload: map[string]any{}}, + "branch sha is short": {branchPayload: map[string]any{ + "commit": map[string]any{"sha": "abc"}, + }}, + } + for name, state := range cases { + t.Run(name, func(t *testing.T) { + _, err := githubactions.VerifyLatestWorkflow(context.Background(), options(state.server(t))) + if worldcut.ErrorCode(err) != worldcut.GitHubResponseInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + }) + } +} + +func TestGateRejectsInvalidJSON(t *testing.T) { + for name, body := range map[string]string{ + "truncated object": "{not json", + "trailing content": `{"workflow_runs":[]} {"workflow_runs":[]}`, + "empty body": "", + "html error page": "rate limited", + } { + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(body)) + })) + defer server.Close() + _, err := githubactions.VerifyLatestWorkflow(context.Background(), options(server)) + if worldcut.ErrorCode(err) != worldcut.GitHubResponseInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + }) + } +} + +func TestGateBoundsResponseBodies(t *testing.T) { + state := &fixture{oversized: true} + _, err := githubactions.VerifyLatestWorkflow(context.Background(), options(state.server(t))) + if worldcut.ErrorCode(err) != worldcut.GitHubResponseInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + if !strings.Contains(err.Error(), "exceeded") { + t.Fatalf("error message = %s", err.Error()) + } +} + +func TestGateValidatesOptions(t *testing.T) { + cases := map[string]githubactions.Options{ + "empty repository": {Repository: "", Branch: "main", Workflow: "ci.yml"}, + "repository path": {Repository: "acme/service/extra", Branch: "main", Workflow: "ci.yml"}, + "repository space": {Repository: "acme /service", Branch: "main", Workflow: "ci.yml"}, + "empty branch": {Repository: "acme/service", Branch: " ", Workflow: "ci.yml"}, + "display name": {Repository: "acme/service", Branch: "main", Workflow: "CI"}, + "workflow path": {Repository: "acme/service", Branch: "main", Workflow: "../ci.yml"}, + "empty workflow": {Repository: "acme/service", Branch: "main", Workflow: ""}, + "empty api base url": {Repository: "acme/service", Branch: "main", Workflow: "ci.yml", APIBaseURL: "///"}, + "relative api base url": {Repository: "acme/service", Branch: "main", Workflow: "ci.yml", APIBaseURL: "/api/v3"}, + "api base user info": {Repository: "acme/service", Branch: "main", Workflow: "ci.yml", APIBaseURL: "https://user:secret@example.invalid"}, + "api base query": {Repository: "acme/service", Branch: "main", Workflow: "ci.yml", APIBaseURL: "https://example.invalid?token=secret"}, + "non-numeric workflow": {Repository: "acme/service", Branch: "main", Workflow: "12a"}, + } + for name, gateOptions := range cases { + t.Run(name, func(t *testing.T) { + _, err := githubactions.VerifyLatestWorkflow(context.Background(), gateOptions) + if worldcut.ErrorCode(err) != worldcut.GitHubResponseInvalidCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + }) + } +} + +func TestGateAcceptsNumericWorkflowIdentifiers(t *testing.T) { + state := &fixture{} + server := state.server(t) + gateOptions := options(server) + gateOptions.Workflow = "42" + verification, err := githubactions.VerifyLatestWorkflow(context.Background(), gateOptions) + if err != nil { + t.Fatal(err) + } + if verification.Result.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s", verification.Result.Verdict) + } + if !strings.Contains(state.requests[0].path, "/actions/workflows/42/runs") { + t.Fatalf("path = %s", state.requests[0].path) + } +} + +func TestGateEscapesBranchNames(t *testing.T) { + state := &fixture{ + runs: []any{run(map[string]any{"head_branch": "release/1.0"})}, + branchPayload: map[string]any{"commit": map[string]any{"sha": currentSHA}}, + } + server := state.server(t) + gateOptions := options(server) + gateOptions.Branch = "release/1.0" + verification, err := githubactions.VerifyLatestWorkflow(context.Background(), gateOptions) + if err != nil { + t.Fatal(err) + } + if verification.Result.Verdict != "CONTRACT_SATISFIED" { + t.Fatalf("verdict = %s", verification.Result.Verdict) + } + if !strings.Contains(state.requests[0].query, "branch=release%2F1.0") { + t.Fatalf("query = %s", state.requests[0].query) + } + if state.requests[1].escapedPath != "/repos/acme/service/branches/release%2F1.0" { + t.Fatalf("branch path = %s", state.requests[1].escapedPath) + } +} + +func TestGateHonorsContextCancellation(t *testing.T) { + state := &fixture{} + server := state.server(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := githubactions.VerifyLatestWorkflow(ctx, options(server)) + if worldcut.ErrorCode(err) != worldcut.GitHubAPIErrorCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation was not preserved: %v", err) + } +} + +func TestGateRefusesRedirects(t *testing.T) { + state := &fixture{} + target := state.server(t) + redirect := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, target.URL+request.URL.Path, http.StatusFound) + })) + defer redirect.Close() + _, err := githubactions.VerifyLatestWorkflow(context.Background(), options(redirect)) + if worldcut.ErrorCode(err) != worldcut.GitHubAPIErrorCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + if !errors.Is(err, githubactions.ErrRedirectNotFollowed) { + t.Fatalf("redirect error was not preserved: %v", err) + } +} + +func TestGateRefusesRedirectsWithInjectedClient(t *testing.T) { + state := &fixture{} + target := state.server(t) + redirect := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, target.URL+request.URL.Path, http.StatusFound) + })) + defer redirect.Close() + gateOptions := options(redirect) + gateOptions.Client = &http.Client{} + _, err := githubactions.VerifyLatestWorkflow(context.Background(), gateOptions) + if worldcut.ErrorCode(err) != worldcut.GitHubAPIErrorCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + if !errors.Is(err, githubactions.ErrRedirectNotFollowed) { + t.Fatalf("redirect error was not preserved: %v", err) + } +} + +func TestGateClosesResponseReturnedWithError(t *testing.T) { + body := &trackedBody{} + gateOptions := githubactions.Options{ + Repository: "acme/service", + Branch: "main", + Workflow: "ci.yml", + Client: responseErrorClient{ + response: &http.Response{ + StatusCode: http.StatusTemporaryRedirect, + Header: http.Header{}, + Body: body, + }, + err: errors.New("redirect refused"), + }, + } + _, err := githubactions.VerifyLatestWorkflow(context.Background(), gateOptions) + if worldcut.ErrorCode(err) != worldcut.GitHubAPIErrorCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + if !body.closed { + t.Fatal("response body returned with an error was not closed") + } +} + +func TestGateRejectsAChangedResponseURL(t *testing.T) { + body := &trackedBody{} + finalRequest, err := http.NewRequest( + http.MethodGet, + "https://other.example.invalid/runs", + nil, + ) + if err != nil { + t.Fatal(err) + } + gateOptions := githubactions.Options{ + Repository: "acme/service", + Branch: "main", + Workflow: "ci.yml", + Client: responseErrorClient{ + response: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: body, + Request: finalRequest, + }, + }, + } + _, err = githubactions.VerifyLatestWorkflow(context.Background(), gateOptions) + if worldcut.ErrorCode(err) != worldcut.GitHubAPIErrorCode { + t.Fatalf("error = %v (code %q)", err, worldcut.ErrorCode(err)) + } + if !errors.Is(err, githubactions.ErrRedirectNotFollowed) { + t.Fatalf("redirect error was not preserved: %v", err) + } + if !body.closed { + t.Fatal("redirected response body was not closed") + } +} + +func TestGateUsesInjectedClockAndIdentifiers(t *testing.T) { + state := &fixture{} + server := state.server(t) + gateOptions := options(server) + instant := time.Date(2026, 9, 4, 18, 0, 0, 0, time.UTC) + gateOptions.Clock = func() time.Time { return instant } + counter := 0 + gateOptions.NewID = func() (string, error) { + counter++ + return "fixed-" + string(rune('0'+counter)), nil + } + verification, err := githubactions.VerifyLatestWorkflow(context.Background(), gateOptions) + if err != nil { + t.Fatal(err) + } + if verification.Input.Contract.DecisionTime != "2026-09-04T18:00:00.000Z" { + t.Fatalf("decisionTime = %s", verification.Input.Contract.DecisionTime) + } + if verification.Input.Observations[0].ID != "github-head-fixed-1" { + t.Fatalf("head observation id = %s", verification.Input.Observations[0].ID) + } + if verification.Input.Observations[1].ID != "github-run-fixed-2" { + t.Fatalf("run observation id = %s", verification.Input.Observations[1].ID) + } + + gateOptions.NewID = func() (string, error) { return "", errors.New("no entropy") } + if _, err := githubactions.VerifyLatestWorkflow(context.Background(), gateOptions); err == nil { + t.Fatal("a failing identifier source was accepted") + } +} + +func TestGateVerificationInputIsIndependentlyVerifiable(t *testing.T) { + state := &fixture{} + verification, err := githubactions.VerifyLatestWorkflow( + context.Background(), + options(state.server(t)), + ) + if err != nil { + t.Fatal(err) + } + replayed, err := worldcut.VerifyDecisionContract(verification.Input) + if err != nil { + t.Fatal(err) + } + if replayed.VerificationRecordDigest != verification.Result.VerificationRecordDigest { + t.Fatal("the returned input does not reproduce the returned result") + } +} + +func TestEvidenceCoverageSummarizesHistory(t *testing.T) { + state := &fixture{runs: []any{ + run(nil), + run(map[string]any{"id": 80, "conclusion": "failure"}), + run(map[string]any{"id": 79, "conclusion": "success"}), + }} + server := state.server(t) + coverage, err := githubactions.InspectWorkflowEvidence(context.Background(), options(server), 20) + if err != nil { + t.Fatal(err) + } + if coverage.InspectedRuns != 3 || coverage.CompleteEvidenceRuns != 3 { + t.Fatalf("coverage = %+v", coverage) + } + if coverage.EvidenceCoverage != 1 { + t.Fatalf("evidenceCoverage = %v", coverage.EvidenceCoverage) + } + if coverage.Conclusions["success"] != 2 || coverage.Conclusions["failure"] != 1 { + t.Fatalf("conclusions = %+v", coverage.Conclusions) + } + if !strings.Contains(state.requests[0].query, "per_page=20") { + t.Fatalf("query = %s", state.requests[0].query) + } +} + +func TestEvidenceCoverageHandlesNoRuns(t *testing.T) { + state := &fixture{runsOverride: map[string]any{"workflow_runs": []any{}}} + coverage, err := githubactions.InspectWorkflowEvidence( + context.Background(), + options(state.server(t)), + 5, + ) + if err != nil { + t.Fatal(err) + } + if coverage.InspectedRuns != 0 || coverage.EvidenceCoverage != 0 { + t.Fatalf("coverage = %+v", coverage) + } + if len(coverage.Conclusions) != 0 { + t.Fatalf("conclusions = %+v", coverage.Conclusions) + } +} + +func TestEvidenceCoverageBoundsHistoryLimit(t *testing.T) { + state := &fixture{} + server := state.server(t) + for _, limit := range []int{0, -1, 101} { + _, err := githubactions.InspectWorkflowEvidence(context.Background(), options(server), limit) + if worldcut.ErrorCode(err) != worldcut.GitHubResponseInvalidCode { + t.Fatalf("limit %d error = %v (code %q)", limit, err, worldcut.ErrorCode(err)) + } + } +} diff --git a/ports/go/internal/idgen/idgen.go b/ports/go/internal/idgen/idgen.go new file mode 100644 index 0000000..2c230bc --- /dev/null +++ b/ports/go/internal/idgen/idgen.go @@ -0,0 +1,31 @@ +// Package idgen produces collision-resistant identifiers for captured +// observations. +package idgen + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +// UUIDv4 returns a random RFC 4122 version 4 UUID in canonical text form. +func UUIDv4() (string, error) { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", fmt.Errorf("read random bytes: %w", err) + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + + encoded := make([]byte, 36) + hex.Encode(encoded[0:8], bytes[0:4]) + encoded[8] = '-' + hex.Encode(encoded[9:13], bytes[4:6]) + encoded[13] = '-' + hex.Encode(encoded[14:18], bytes[6:8]) + encoded[18] = '-' + hex.Encode(encoded[19:23], bytes[8:10]) + encoded[23] = '-' + hex.Encode(encoded[24:36], bytes[10:16]) + return string(encoded), nil +} diff --git a/ports/go/internal/idgen/idgen_test.go b/ports/go/internal/idgen/idgen_test.go new file mode 100644 index 0000000..62cc20c --- /dev/null +++ b/ports/go/internal/idgen/idgen_test.go @@ -0,0 +1,25 @@ +package idgen + +import ( + "regexp" + "testing" +) + +var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +func TestUUIDv4IsCanonicalAndUnique(t *testing.T) { + seen := make(map[string]bool, 512) + for i := 0; i < 512; i++ { + value, err := UUIDv4() + if err != nil { + t.Fatal(err) + } + if !uuidPattern.MatchString(value) { + t.Fatalf("identifier %q is not a canonical version 4 UUID", value) + } + if seen[value] { + t.Fatalf("identifier %q was generated twice", value) + } + seen[value] = true + } +} diff --git a/ports/go/models.go b/ports/go/models.go index ac182ba..433187d 100644 --- a/ports/go/models.go +++ b/ports/go/models.go @@ -1,5 +1,7 @@ package worldcut +import "encoding/json" + const ( ProtocolVersion = "0.1" EngineVersion = "0.1.2" @@ -19,31 +21,34 @@ type ValidityInterval struct { } type DependencyWitness struct { - Name string - Resource ResourceIdentity - Relation string - Version *string - Provenance string + Name string `json:"name"` + Resource ResourceIdentity `json:"resource"` + Relation string `json:"relation"` + Version *string `json:"version,omitempty"` + Provenance string `json:"provenance"` } type ObservationWitness struct { - Provenance string - Version *string - Validity *ValidityInterval - Dependencies []DependencyWitness + Provenance string `json:"provenance"` + Version *string `json:"version,omitempty"` + Validity *ValidityInterval `json:"validity,omitempty"` + Dependencies []DependencyWitness `json:"dependencies,omitempty"` } type Observation struct { - ID string - Role string - Resource ResourceIdentity - Value any - ObservedAt string - AcquisitionCost int64 - Witness ObservationWitness + ID string `json:"id"` + Role string `json:"role"` + Resource ResourceIdentity `json:"resource"` + Value any `json:"value"` + ObservedAt string `json:"observedAt"` + AcquisitionCost int64 `json:"acquisitionCost"` + Witness ObservationWitness `json:"witness"` raw map[string]any } +// Requirement holds the union of every protocol 0.1 requirement shape. Only +// the fields that belong to Type are part of a well-formed requirement; see +// [Requirement.MarshalJSON]. type Requirement struct { ID string Description string @@ -64,14 +69,63 @@ func (r Requirement) isRequired() bool { return r.Required == nil || *r.Required } +// MarshalJSON emits the protocol form of the requirement variant named by +// Type. Fields belonging to another variant are still emitted when they hold +// a value so that strict validation rejects them instead of silently dropping +// evidence the caller supplied. +func (r Requirement) MarshalJSON() ([]byte, error) { + document := map[string]any{ + "id": r.ID, + "description": r.Description, + "type": r.Type, + } + if r.Required != nil { + document["required"] = *r.Required + } + if r.Type == "dependency" || r.DependentRole != "" || r.TargetRole != "" || r.DependencyName != "" { + document["dependentRole"] = r.DependentRole + document["targetRole"] = r.TargetRole + document["dependencyName"] = r.DependencyName + } + if r.Type == "common_valid_time" || len(r.Roles) != 0 || r.Within != nil { + document["roles"] = r.Roles + document["within"] = r.Within + } + if r.Type == "value_equals" || r.Role != "" || len(r.Path) != 0 || r.Expected != nil { + document["role"] = r.Role + document["path"] = r.Path + document["expected"] = r.Expected + } + return json.Marshal(document) +} + +// ContractAssumptions names the clock, interval, and metadata models a +// contract relies on. Protocol 0.1 supports exactly one combination, returned +// by [SupportedAssumptions]. +type ContractAssumptions struct { + ClockModel string `json:"clockModel"` + IntervalModel string `json:"intervalModel"` + MetadataModel string `json:"metadataModel"` +} + type Contract struct { - ID string - Version string - DecisionTime string - Requirements []Requirement + ID string `json:"id"` + Version string `json:"version"` + DecisionTime string `json:"decisionTime"` + Assumptions ContractAssumptions `json:"assumptions"` + Requirements []Requirement `json:"requirements"` raw map[string]any } +// VerificationInput is the constructible form of a WorldCut verification +// input. Use [VerifyDecisionContract] or [ParseVerificationInput] to submit +// one; both apply the same strict validation as [ParseInput]. +type VerificationInput struct { + ProtocolVersion string `json:"protocolVersion"` + Contract Contract `json:"contract"` + Observations []Observation `json:"observations"` +} + type verificationInput struct { ProtocolVersion string Contract Contract diff --git a/ports/go/planning.go b/ports/go/planning.go index 5e11596..e01f157 100644 --- a/ports/go/planning.go +++ b/ports/go/planning.go @@ -88,8 +88,8 @@ func addOption(state planState, candidate AcquisitionOption) (planState, error) } cost := state.cost for _, newAction := range candidate.Actions { - if newAction.Cost < 0 || newAction.Cost > maxAcquisitionCost { - return planState{}, invalidInput("acquisition action %s cost must be between 0 and %d", newAction.ID, maxAcquisitionCost) + if newAction.Cost < 0 || newAction.Cost > MaxAcquisitionCost { + return planState{}, invalidInput("acquisition action %s cost must be between 0 and %d", newAction.ID, MaxAcquisitionCost) } if existing, ok := actions[newAction.ID]; ok { if existing.Cost != newAction.Cost { diff --git a/ports/go/validation.go b/ports/go/validation.go index 4baf268..279f260 100644 --- a/ports/go/validation.go +++ b/ports/go/validation.go @@ -14,8 +14,11 @@ import ( ) const ( - maxAcquisitionCost = int64(1_000_000_000) - timestampLayout = "2006-01-02T15:04:05.000Z" + // MaxAcquisitionCost is the inclusive protocol bound on an integer + // acquisition cost. + MaxAcquisitionCost = int64(1_000_000_000) + + timestampLayout = "2006-01-02T15:04:05.000Z" ) var provenanceValues = map[string]bool{ @@ -381,6 +384,7 @@ func parseContract(value any) (Contract, time.Time, error) { ID: id, Version: version, DecisionTime: decisionText, + Assumptions: SupportedAssumptions(), Requirements: requirements, raw: record, }, decisionTime, nil @@ -415,8 +419,8 @@ func parseObservation(value any) (Observation, time.Time, error) { return Observation{}, time.Time{}, err } costNumber, ok := record["acquisitionCost"].(float64) - if !ok || math.Trunc(costNumber) != costNumber || costNumber < 0 || costNumber > float64(maxAcquisitionCost) { - return Observation{}, time.Time{}, fmt.Errorf("%s.acquisitionCost must be an integer between 0 and %d", role, maxAcquisitionCost) + if !ok || math.Trunc(costNumber) != costNumber || costNumber < 0 || costNumber > float64(MaxAcquisitionCost) { + return Observation{}, time.Time{}, fmt.Errorf("%s.acquisitionCost must be an integer between 0 and %d", role, MaxAcquisitionCost) } witness, err := parseWitness(record["witness"], role) if err != nil {