diff --git a/.github/ci/candidate-suite.sh b/.github/ci/candidate-suite.sh index 3c410ac..546a3bd 100755 --- a/.github/ci/candidate-suite.sh +++ b/.github/ci/candidate-suite.sh @@ -140,8 +140,7 @@ record() { echo '# candidate protocol suite. It is NOT a published release, NOT a release' echo '# claim and NOT a conformance claim, and it must not be cited as any of' echo '# them. The committed default pin is unchanged by this run; promoting it' - echo '# is owned by TASK-260720-38l1sy after TASK-260720-25d05o qualifies the' - echo '# release.' + echo '# is owned solely by TASK-260728-d8ktna after rc.5 release qualification.' echo '#' echo "candidate_revision $ref" echo "candidate_root $root" @@ -150,6 +149,7 @@ record() { echo "tree_sha256 sha256:$tree_sha" echo "file_count $files" echo "committed_released_pin ${SPEC_PIN:-}" + echo 'pin_promotion_owner TASK-260728-d8ktna' echo "runner_goos $(go env GOOS 2>/dev/null || echo '')" echo "runner_goarch $(go env GOARCH 2>/dev/null || echo '')" echo 'evidence_class candidate-only' diff --git a/.github/ci/gate-selftest.sh b/.github/ci/gate-selftest.sh index 08d3b91..7b203d2 100755 --- a/.github/ci/gate-selftest.sh +++ b/.github/ci/gate-selftest.sh @@ -28,6 +28,7 @@ PASS=0; FAIL=0; SKIPPED=0 # these cases also assert that the pin CI actually uses is itself a full, # immutable, lowercase 40-hex revision -- the same shape a candidate must have. PIN="$(awk '/^[ \t]*SPEC_PIN:[ \t]*/{print $2; exit}' .github/workflows/ci.yml)" +QUALIFIED_PIN='00b1688a9b2457ca397a0bb550acf47cad8ee967' ok() { PASS=$((PASS + 1)); printf 'ok %s\n' "$1"; } bad() { FAIL=$((FAIL + 1)); printf 'FAIL %s\n %s\n' "$1" "$2"; } @@ -51,6 +52,15 @@ assert_contains() { fi } +if [ "$PIN" = "$QUALIFIED_PIN" ]; then + ok 'the default SPEC_PIN remains on the currently qualified release' +else + bad 'the default SPEC_PIN remains on the currently qualified release' "got $PIN, want $QUALIFIED_PIN; TASK-260728-d8ktna owns promotion" +fi +assert_contains 'the candidate revision remains an explicit workflow input' 'ref: ${{ inputs.candidate_ref }}' .github/workflows/ci.yml +assert_contains 'the candidate root remains an explicit workflow input' 'CANDIDATE_ROOT_INPUT: ${{ inputs.candidate_root }}' .github/workflows/ci.yml +assert_contains 'the candidate lane remains non-default' "github.event_name == 'workflow_dispatch'" .github/workflows/ci.yml + echo '=== candidate-suite.sh verify-ref: only a full immutable revision is a candidate ===' CS="$HERE/candidate-suite.sh" assert 'candidate inputs reject revision plus root' 1 bash "$CS" verify-inputs '1234567890abcdef1234567890abcdef12345678' '/candidate/root' @@ -120,6 +130,7 @@ if [ -f "$EV" ]; then assert_contains 'evidence records the file count' 'file_count 2' "$EV" assert_contains 'evidence records the protocol version' '1.0.0-rc.5' "$EV" assert_contains 'evidence records the committed pin' "committed_released_pin $PIN" "$EV" + assert_contains 'evidence records the sole pin-promotion owner' 'pin_promotion_owner TASK-260728-d8ktna' "$EV" else bad 'record wrote its evidence file' "missing: $EV" fi diff --git a/.github/ci/platform-cases.tsv b/.github/ci/platform-cases.tsv index a9d1428..6123d80 100644 --- a/.github/ci/platform-cases.tsv +++ b/.github/ci/platform-cases.tsv @@ -47,6 +47,14 @@ internal/buildcache TestWindowsProtectedStateMatrix windows - - Windows DACL protected-state matrix for the build cache internal/buildcache TestWindowsProtectedStateMatrix/* - windows host-capability a subtest needing a Windows symlink may skip where the host forbids it; the matrix itself must still pass internal/buildcache TestValidateWindowsSecurityPolicy windows - - validates the Windows security-descriptor policy on cache state +internal/buildrepo TestWindowsProtectedStoreReusesOwnerPrivateArtifact windows - - trusted owner-controlled protected external artifacts are reusable +internal/buildrepo TestWindowsProtectedStoreReusesOwnerPrivateSnapshot windows - - trusted owner-controlled protected external snapshots are reusable +internal/buildrepo TestWindowsProtectedSecurityDescriptorRejectsWrongOwnerAndDACL windows - - owner and exact protected-DACL policy rejects adversarial descriptors +internal/buildrepo TestWindowsProtectedArtifactAdversarialStateQuarantines windows - - hard-link DACL and reparse attacks force protected external artifact quarantine +internal/buildrepo TestWindowsProtectedArtifactAdversarialStateQuarantines/* - windows host-capability a reparse-point subtest may skip where the host forbids symlink creation +internal/buildrepo TestWindowsProtectedArtifactPathSwapCannotReturnBytes windows - - path replacement during retained-handle validation cannot return cache bytes +internal/buildrepo TestWindowsProtectedArtifactEntrySwapCannotReturnBytes windows - - entry-directory replacement during guarded validation cannot return cache bytes +internal/buildrepo TestWindowsProtectedSnapshotHardLinkQuarantines windows - - hard-linked protected snapshot files force quarantine # =========================================================================== # Windows reparse points diff --git a/.github/ci/skip-classes.tsv b/.github/ci/skip-classes.tsv index daf9338..58a4398 100644 --- a/.github/ci/skip-classes.tsv +++ b/.github/ci/skip-classes.tsv @@ -42,6 +42,7 @@ # --- the conformance root --------------------------------------------------- root-unset CURATOR_CONFORMANCE_ROOT is not set deferred-only the package was deferred by suite-plan.sh +external-root-unset CURATOR_EXTERNAL_REPOSITORY_CORPUS_ROOT is not set allow the separately released rc5 external-repository corpus is exercised by rc5-external-test root-content publishes no allow the supplied root does not publish this vector group root-content is a pre-revision root allow the supplied root predates this artefact diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a336a81..a7b3ff3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,10 +32,8 @@ permissions: env: # The one immutable committed protocol-suite pin, referenced by every job. # It stays on the currently qualified released revision; promoting it is - # owned by TASK-260720-38l1sy, after TASK-260720-25d05o qualifies the - # release. This pin publishes protocol 1.0.0-rc.3 and claims no later - # revision. The schema v6 candidate never appears here -- it enters through - # the non-default `candidate-conformance` job below and nowhere else. + # owned solely by TASK-260728-d8ktna after rc.5 release qualification. The + # rc.5 candidate enters only through explicit non-default candidate paths. SPEC_PIN: 00b1688a9b2457ca397a0bb550acf47cad8ee967 # actions/setup-go only began forcing GOTOOLCHAIN=local in v6.0.0; this # workflow pins @v5, so without these two lines a job may download a diff --git a/Makefile b/Makefile index 0bfe221..9cc46bd 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ CANDIDATE := .github/ci/candidate-suite.sh .PHONY: build test fmt lint vet check \ require-pin-root ci-test race race-full check-ci \ gate-selftest ledger-check no-broad-suppression \ - candidate-verify-ref candidate-record candidate-test + candidate-verify-ref candidate-record candidate-test rc5-external-test build: $(GO) build -ldflags '$(LDFLAGS)' -o bin/curator ./cmd/curator @@ -86,6 +86,17 @@ check-ci: require-pin-root $(MAKE) ledger-check $(MAKE) ci-test +# Authenticates and consumes the separately released rc.5 external-repository +# corpus. It is explicit because the protocol conformance root and the interop +# corpus are independently versioned inputs. +rc5-external-test: require-pin-root + @test -n "$(CURATOR_EXTERNAL_REPOSITORY_CORPUS_ROOT)" || { \ + echo 'CURATOR_EXTERNAL_REPOSITORY_CORPUS_ROOT is required by this gate.'; \ + exit 1; } + $(GO) test ./internal/conformanceconsumer ./internal/rc5interop ./cmd/curator \ + -run 'TestAcceptedRC5ExternalRepositoryCorpus|TestEveryAcceptedRC5CaseHasACuratorBinding|TestNativeBlackBoxProjectGlobalLifecycle' \ + -count=1 + # --- Candidate protocol suite ---------------------------------------------- # # Non-default by construction: nothing here reads or writes the committed pin. diff --git a/cmd/curator/native_blackbox_test.go b/cmd/curator/native_blackbox_test.go new file mode 100644 index 0000000..0484ffb --- /dev/null +++ b/cmd/curator/native_blackbox_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +// TestNativeBlackBoxProjectGlobalLifecycle exercises only the built command's +// public surface. It is the native project/global activation and uninstall +// spine used by rc.5 qualification; external-repository source, cache, failure, +// and rollback cases are bound separately by internal/rc5interop. +func TestNativeBlackBoxProjectGlobalLifecycle(t *testing.T) { + root := t.TempDir() + binary := filepath.Join(root, "bin", "curator") + if runtime.GOOS == "windows" { + binary += ".exe" + } + if err := os.MkdirAll(filepath.Dir(binary), 0o755); err != nil { + t.Fatal(err) + } + build := exec.Command("go", "build", "-o", binary, ".") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build black-box curator: %v\n%s", err, output) + } + + home := filepath.Join(root, "manager-home") + configPath := filepath.Join(home, "config.json") + skillsRoot := filepath.Join(root, "skills") + project := filepath.Join(root, "project") + fixture := filepath.Join(skillsRoot, "native-fixture") + writeNativeFixture(t, fixture) + runNativeGit(t, project, "init", "-q", "-b", "main") + + env := append(os.Environ(), "CURATOR_CONFIG="+configPath, "HOME="+root, "USERPROFILE="+root) + runNativeCurator(t, binary, env, "bootstrap", "--non-interactive", "--skills-root", skillsRoot, "--default-agents", "codex_cli") + runNativeCurator(t, binary, env, "init", project) + runNativeCurator(t, binary, env, "add", "native-fixture", "--source", "native-fixture", "--tag", "v1", "--project", project) + runNativeCurator(t, binary, env, "status", project, "--json", "--check") + runNativeCurator(t, binary, env, "shell-init", "bash", "--install", "--no-global") + if _, err := os.Stat(filepath.Join(project, ".agents", "skills", "native-fixture", "SKILL.md")); err != nil { + t.Fatalf("project activation missing: %v", err) + } + + runNativeCurator(t, binary, env, "global", "init") + runNativeCurator(t, binary, env, "global", "add", "native-fixture", "--source", "native-fixture", "--tag", "v1") + runNativeCurator(t, binary, env, "global", "status", "--json", "--check") + if _, err := os.Stat(filepath.Join(home, "global", "skills", "native-fixture", "SKILL.md")); err != nil { + t.Fatalf("global activation missing: %v", err) + } + + runNativeCurator(t, binary, env, "remove", "native-fixture", "--project", project) + runNativeCurator(t, binary, env, "install", project) + if _, err := os.Stat(filepath.Join(project, ".agents", "skills", "native-fixture")); !os.IsNotExist(err) { + t.Fatalf("project uninstall left installed state: %v", err) + } + runNativeCurator(t, binary, env, "global", "remove", "native-fixture") + runNativeCurator(t, binary, env, "global", "install") + if _, err := os.Stat(filepath.Join(home, "global", "skills", "native-fixture")); !os.IsNotExist(err) { + t.Fatalf("global uninstall left installed state: %v", err) + } +} + +func writeNativeFixture(t *testing.T, root string) { + t.Helper() + write := func(relative, content string, mode os.FileMode) { + path := filepath.Join(root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } + } + write("SKILL.md", "---\nname: native-fixture\ndescription: native qualification fixture\n---\n# Native fixture\n", 0o644) + write("scripts/native-fixture", "#!/bin/sh\necho native-fixture\n", 0o755) + manifest, err := json.Marshal(map[string]any{ + "schema_version": 4, + "capabilities": map[string]any{}, + "runtime_roots": []string{"scripts"}, + "commands": map[string]any{ + "native-fixture": map[string]any{"type": "script", "unix_path": "scripts/native-fixture", "win_path": "scripts/native-fixture"}, + }, + }) + if err != nil { + t.Fatal(err) + } + write("agent-skill.json", string(manifest), 0o644) + runNativeGit(t, root, "init", "-q", "-b", "main") + runNativeGit(t, root, "add", ".") + runNativeGit(t, root, "commit", "-qm", "native fixture") + runNativeGit(t, root, "tag", "v1") +} + +func runNativeGit(t *testing.T, dir string, args ...string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + gitArgs := append([]string{"-c", "commit.gpgsign=false", "-c", "tag.gpgSign=false"}, args...) + command := exec.Command("git", gitArgs...) + command.Dir = dir + command.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Curator Test", "GIT_AUTHOR_EMAIL=curator@example.test", + "GIT_COMMITTER_NAME=Curator Test", "GIT_COMMITTER_EMAIL=curator@example.test", + ) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } +} + +func runNativeCurator(t *testing.T, binary string, env []string, args ...string) { + t.Helper() + command := exec.Command(binary, args...) + command.Env = env + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("curator %v: %v\n%s", args, err, output) + } +} diff --git a/docs/external-build-repositories.md b/docs/external-build-repositories.md new file mode 100644 index 0000000..d12831c --- /dev/null +++ b/docs/external-build-repositories.md @@ -0,0 +1,339 @@ +# External build repositories: author and operator guide + +This guide describes Curator's protocol 1.0.0-rc.5 support for repository-backed +CLI dependencies. The only supported external driver is the closed +`go-repository-v1` driver. An external Git repository is a source and audit +subject, not a generic build-script host. + +## Ownership model + +Three files have deliberately separate responsibilities: + +| Owner | File | May select | +| --- | --- | --- | +| Skill author | `agent-skill.json` schema 7 | Repository identity and immutable lock; command key; repository key; logical target | +| Repository author | repository-root `skill-build.json` schema 1 | Closed driver revision; one explicit build root; one contained source directory | +| Operator | `Skillfile.dev.json` schema 2 and manager policy | Development source substitution; credentials and host verification; admitted Git and toolchain; audit, cache, and platform policy | + +The command key is the executable identity. For command key `golden-tool`, the +manager derives the single artifact path as `bin/golden-tool` or +`bin/golden-tool.exe`. Repository metadata cannot choose binary names, output +paths, argv, environment, credentials, signing, hooks, plugins, generators, +fallbacks, or secondary artifacts. A package cannot delegate those choices to +a wrapper or arbitrary command. Receipts and install markers record the +manager's result; repository data cannot use them to make any of those choices. + +The canonical manifest filename is `agent-skill.json`. Curator continues to +read an equal `csk-skill.json` compatibility copy, but authors should not ship +two different manifests: conflicting copies fail closed. + +## Author a repository-backed command + +The consuming skill declares a repository and selects a logical target. The +full lowercase object ID is the lock; `tag` is an optional additional +assertion. + + +```json +{ + "schema_version": 7, + "capabilities": {}, + "build_repositories": { + "golden-tools": { + "git": "https://github.com/example/golden-tools.git", + "locked_commit": { + "object_format": "sha1", + "hex": "0123456789abcdef0123456789abcdef01234567" + }, + "tag": "v1.4.0" + } + }, + "commands": { + "golden-tool": { + "type": "build", + "driver": "go-repository-v1", + "repository": "golden-tools", + "target": "golden-tool" + } + } +} +``` + +At the root of `golden-tools`, the repository author publishes the logical +target. This monorepo example selects the module under `tools/admin`; it does +not ask the manager to discover a module or executable. + + +```json +{ + "schema_version": 1, + "targets": { + "golden-tool": { + "driver": "go-repository-v1", + "build_root": "tools/admin", + "source_dir": "tools/admin/cmd/golden-tool" + } + } +} +``` + +`source_dir` must equal `build_root` or be contained below it. `build_root` +must contain `go.mod` directly, and that file must be the nearest ancestor +`go.mod` of `source_dir`. Select each nested module with its own logical target. +The whole admitted repository remains the identity and audit subject, while +only the selected build root is compiler-visible. External repository files do +not enter agent context or the consuming skill's runtime copy. + +Use SHA-1 only with a 40-character object ID and SHA-256 only with a +64-character object ID. Do not use a branch, `HEAD`, abbreviated ID, revision +expression, or range in the declaration. For an untagged declaration the +manager acquires exactly the locked object. For a tagged declaration it +acquires exactly `refs/tags/`, peels the complete tag chain, and requires +the terminal commit to equal the lock. There is no direct-object fallback when +the tag is missing or moved. + +Repository access is HTTPS or SSH under operator policy. TLS verification, +known-hosts state, authentication material, credential brokering, proxy policy, +timeouts, and the admitted Git/SSH binaries remain outside package data. A +repository that needs a package-provided credential helper or transport command +is unsupported. + +## Development substitutions + +Substitutions are non-committed operator input. They replace acquisition for +one declared repository; they do not change its repository key, target, driver, +command name, output, compiler policy, credentials, or signing policy. + +A local worktree substitution is project-relative: + + +```json +{ + "schema_version": 2, + "substitutions": {}, + "build_repository_substitutions": { + "golden-skill": { + "golden-tools": { + "path": "../golden-tools" + } + } + } +} +``` + +A network substitution uses the same restricted repository grammar and an +explicit ref. Branches are allowed here because this file is operator-owned +development state, never package provenance. + + +```json +{ + "schema_version": 2, + "substitutions": {}, + "build_repository_substitutions": { + "golden-skill": { + "golden-tools": { + "git": "ssh://git@github.com/example/golden-tools.git", + "ref": { + "kind": "branch", + "value": "driver-development" + } + } + } + } +} +``` + +Local input must be an ordinary non-bare worktree with a direct, link-free +`.git` directory, files-format refs, admitted configuration, and complete +SHA-1 or SHA-256 loose objects or supported pack/index pairs. Gitfiles, linked +worktrees, bare repositories, reftable, alternates, replace refs, grafts, +shallow or promisor state, optional pack sidecars, Git LFS, links, and special +files fail closed. Curator parses Git administration and inertly copies object +bytes; it does not execute source hooks, filters, helpers, upload-pack, LFS, or +maintenance. + +Receipts and markers retain both declared and effective identities. Strict +audit rejects substitutions. Advisory audit discloses the substitution and +audits the exact effective snapshot independently from the consuming skill. + +## What an operator must provide + +Before enabling `go-repository-v1`, establish all of the following: + +- an admitted, fingerprinted Git release family and the fixed Go toolchain and + sysroot covered by the rc.5 qualification evidence; +- HTTPS or SSH authentication and host-verification policy owned by the + operator, never by the package; +- resource limits covering object count and size, expanded bytes, file count, + path size, tree/tag depth, and time; +- an audit policy that treats the skill and each effective external repository + as separate subjects; and +- owner-protected, contained, link-safe storage for frozen snapshots, receipts, + artifacts, journals, markers, and command shims. + +The required order is fixed: acquire the exact source, recompute raw-object +identities and prove the complete graph, scan every blob for Git LFS pointers, +materialize exact regular files, validate the whole snapshot, compute its +build-source digest, validate the descriptor and target, audit the external +subject, and only then look in the artifact cache. A cache miss may proceed to +the compiler. A claimed cache hit does not skip source proof or audit. + +Cache keys and receipt hashes are consistency identifiers, not signatures or +trust claims. Curator recomputes a receipt cache key from its complete canonical +input and recomputes the marker receipt hash from the complete canonical +receipt. Unreadable or unprovable protected state is not current and is never +made trusted by changing permissions or adopting candidate bytes. + +Command shims are manager-created PATH entries. They forward the user's +arguments, preserve the inherited PATH and child exit status, and resolve only +to the marker-selected protected artifact. They never point into a checkout, +Git object store, source snapshot, staging directory, or script runtime. Curator +does not execute the artifact during install, validation, status, repair, +rollback, or garbage collection. + +## Project and global operator procedure + +Start from an operator-controlled Curator configuration and a checked-in +`Skillfile.json`. The package declaration supplies the immutable repository +lock; operators must not replace it with a branch, a locally chosen object, or +an arbitrary build command. + +```bash +# One-time machine configuration. Use an operator-owned directory containing +# the skill repositories named by Skillfile.json. +curator bootstrap --non-interactive --skills-root /absolute/operator/skills + +# Project scope: validate the complete plan without writes, install it, and +# then require both installed and compiled state to be current. +curator install /absolute/project --dry-run --audit=strict --strict-tags +curator install /absolute/project --audit=strict --strict-tags +curator status /absolute/project --json --check +``` + +On Windows, pass an absolute Windows project path and skills root (for example +`C:\\Operator\\skills` and `C:\\src\\project`). Do not translate a Windows +path through a POSIX compatibility layer: repository and protected-store path +proof is native to the host. + +The machine-wide scope has its own declaration and lifecycle: + +```bash +curator global init +curator global install --dry-run --audit=strict --strict-tags +curator global install --audit=strict --strict-tags +curator global status --json --check +``` + +`status` is read-only. A non-current code is evidence to run the same declared +install again; install is Curator's repair operation and reacquires or rebuilds +only after source, audit, and protected-cache proof. Do not manually edit a +marker, receipt, cache entry, shim, snapshot, or its permissions. If an install +or repair fails, Curator retains the previously committed installation and +reports the rollback or recovery action. Re-run `status --check` after the +failure before deciding whether the old generation remains usable. + +Uninstallation is declaration-first so dependency and shared-consumer state is +recomputed transactionally: + +```bash +# Project scope. +curator remove golden-skill --project /absolute/project +curator install /absolute/project --audit=strict --strict-tags +curator status /absolute/project --json --check + +# Global scope. +curator global remove golden-skill +curator global install --audit=strict --strict-tags +curator global status --json --check + +# Reclaim only entries no live marker or journal still references. +curator gc +``` + +For interactive bare command names, cache the optional manager-generated shell +hook and follow the printed shell-specific activation instruction: + +```bash +curator shell-init --install +``` + +Project and global shims remain directly invocable without modifying a shell +profile. PATH activation never points at a repository checkout or protected +cache and does not authorize a package-selected hook. + +## Offline and read-only behavior + +Offline outcomes are intentionally different: + +| Operation | Exact protected evidence available | Required outcome | +| --- | --- | --- | +| Syntax-only validation | No | Warning `build_repository_unverified_offline`; no source, audit, cache, receipt, marker, or installation claim | +| Install/update/repair or coverage-claiming audit | No | Error `build_repository_source_unavailable` before cache lookup, compilation, or mutation | +| Untagged install with an exact protected snapshot | Yes | Revalidate the protected boundary and snapshot, repeat independent audit, then allow normal cache lookup/build processing | +| Tagged install or repair while remote tag proof is unavailable | Even if an old snapshot exists | Error `build_repository_source_unavailable`; a protected object cannot replace the operation's exact-tag assertion | +| Read-only status | Exact snapshot, receipt, artifact, marker, and shim relationship | Report current without contacting the remote merely to retest tag movement | +| Read-only status | Evidence missing or unreadable | Report non-current or currentness unknown; do not fetch, repair, adopt, sign, or execute | + +The warning from syntax-only validation is not an installation failure, and it +is not proof that installation would succeed. Protected offline reuse is a +narrow manager-owned optimization for an exact untagged snapshot, not a +package-controlled fallback. + +## Signing boundary + +`go-repository-v1` performs no post-build signing, timestamping, or +notarization. A package signing request fails +`build_repository_package_signing_forbidden`. If the platform requires local +signing, the build fails `build_repository_signer_policy_unsupported` until a +separately reviewed operator signer profile defines the fixed signer, process +graph, network policy, identity handling, cache input, publication, and +rollback. Release-pipeline signing remains an operator concern; repository +metadata cannot request or select it. + +## Troubleshooting + +| Symptom or code | Meaning | Safe response | +| --- | --- | --- | +| Schema rejects `output`, `argv`, `env`, `credentials`, `signing`, hooks, plugins, generators, or fallback | Package data crossed a closed ownership boundary | Remove the field; do not move it into a script, wrapper, or alternate descriptor | +| `build_repository_ref_moved` | The exact tag no longer peels to the locked commit | Restore the tag or intentionally publish a reviewed manifest update with a new full lock | +| `build_repository_source_unavailable` | Exact source or required tag proof is unavailable | Restore operator network/access policy or the exact source; do not broaden the refspec or use an arbitrary command | +| `build_repository_unverified_offline` | Syntax is valid but source coverage was impossible | Treat it only as a warning from syntax validation; run a source-covering operation when evidence is available | +| `build_repository_incomplete_source` | The raw object graph or a bounded object is missing | Repair the source repository under operator control; do not hydrate content during admission | +| `build_repository_git_object_semantics_invalid` | A commit, tree, tag, mode, or object identity failed proof | Correct and republish the repository; do not bypass raw-object verification | +| `build_repository_git_lfs_unsupported` | An admitted blob is an LFS pointer | Commit complete ordinary Git blob bytes; LFS hydration is not supported | +| `build_repository_local_gitfile_unsupported` or `build_repository_local_linked_worktree_unsupported` | The local substitution is indirect or linked | Use a direct ordinary worktree clone | +| `build_repository_local_bare_unsupported` | The local substitution is bare | Use a non-bare ordinary worktree | +| `build_repository_local_layout_unsafe` or `build_repository_local_format_unsupported` | Links, special files, extensions, alternates, partial-clone state, or other administration are outside the admitted format | Create a clean ordinary worktree; do not patch Curator's protected state | +| `build_repository_audit_blocked` | The effective repository snapshot failed its independent policy | Resolve the audit record or policy decision; skill audit evidence cannot substitute for repository evidence | +| `build_repository_protected_boundary_untrusted` | Cache or snapshot storage cannot be proved owner-protected and link-safe | Rebuild into a new manager-created protected boundary; never adopt or chmod candidate bytes into trust | +| `build_repository_receipt_invalid` or `build_repository_artifact_invalid` | Cached evidence is corrupt or mismatched | Quarantine it and rebuild from an exact revalidated, audited snapshot | + +## Future-driver admission + +The external Git envelope is not a generic frontend and unsupported languages +are not equivalents to Go. Each language below remains unsupported until it +has a separately versioned closed driver, independent threat review, immutable +schema and vectors, and native qualification evidence. A future driver must +fix the entire process graph and compiler-visible input; it cannot expose a +package recipe or arbitrary command. + + +| Language | Admission | Build scripts and plugins | Macros and generators | Dependencies and network | Native inputs | Deterministic artifact | +| --- | --- | --- | --- | --- | --- | --- | +| Rust | Unsupported; requires a separately versioned closed driver | Reject or fully model `build.rs`, Cargo aliases, config, and compiler/plugin loading | Bound procedural macros and generated source without executing repository-selected tools | Prove vendored crates, lockfile, features, registry identity, and offline Cargo behavior; deny undeclared fetches | Pin linker, sysroot, target specs, C toolchain, and native libraries | Define one manager-derived binary plus normalized metadata and reproducibility vectors | +| Swift | Unsupported; requires a separately versioned closed driver | Reject or fully model SwiftPM command/build-tool plugins and package scripts | Bound attached/freestanding macros and generated sources | Prove resolved packages and artifacts; deny undeclared registries, Git access, and downloads | Pin SDK, target triple, Clang importer/module maps, C-family libraries, and linker | Define one manager-derived executable with stable module/debug metadata handling and native vectors | +| Kotlin/JVM | Unsupported; requires a separately versioned closed driver | Reject Gradle/Maven scripts, tasks, init scripts, and plugins unless the driver fixes them completely | Bound KSP, kapt, annotation processors, compiler plugins, and generated classes/resources | Prove dependency graph, repositories, checksums, and offline cache; deny dynamic versions and fetches | Pin JDK, JVM target, JNI libraries, resource transforms, and platform tools | Define one manager-derived JAR or native artifact with normalized archives and reproducibility vectors | +| C/C++ | Unsupported; requires a separately versioned closed driver | Reject CMake/Meson/Autoconf recipes and package scripts unless replaced by a fixed manager graph | Bound preprocessor inputs, code generators, response files, and compiler/linker plugins | Prove headers, libraries, package-manager state, and offline dependency closure; deny configure-time network | Pin compiler, assembler, linker, sysroot, ABI, target, system headers/libraries, and runtime | Define one manager-derived artifact with normalized debug/build IDs and cross-platform vectors | +| .NET | Unsupported; requires a separately versioned closed driver | Reject or fully model MSBuild targets/tasks, SDK resolvers, props, and plugins | Bound source generators, analyzers, weaving, generated resources, and AOT steps | Prove NuGet lock graph, feeds, package hashes, and offline restore; deny undeclared downloads | Pin SDK/runtime packs, RID, native/AOT toolchain, P/Invoke and COM inputs, and platform libraries | Define one manager-derived assembly or native artifact with deterministic archive/metadata and native vectors | + + +Every proposal must additionally define toolchain and sysroot identity, fixed +arguments and environment, offline link policy, audit-before-cache ordering, +receipt/marker/cache identity, signer boundary, dry-run semantics, status, +repair, rollback, garbage collection, resource limits, and platform-specific +failure vectors. It must reject produced-program execution during admission, +build validation, installation, or lifecycle operations. + +See the [project overview](../README.md) for Curator's broader security and +interoperability model. diff --git a/docs/external-repository-corpus-consumer.md b/docs/external-repository-corpus-consumer.md new file mode 100644 index 0000000..6e1d9b8 --- /dev/null +++ b/docs/external-repository-corpus-consumer.md @@ -0,0 +1,80 @@ +# External repository corpus consumer + +This guide describes Curator's consumer for the accepted 60-case rc.5 +external-repository corpus. The consumer authenticates externally supplied +bytes and binds them to Curator tests; it does not copy the normative corpus or +turn a local run into a release or cross-manager parity claim. + +## Versioned corpus boundary + +Call `conformanceconsumer.OpenCorpus(root, conformanceconsumer.RC5Boundary)` +with an externally supplied `interop/rc5/external-repository` root. The boundary requires +`curator-conformance-corpus/v1` and protocol `1.0.0-rc.5`. The consumer reads +only paths listed in `manifest.json` and verifies each selected file against its +declared SHA-256 digest. The root is supplied as +`CURATOR_EXTERNAL_REPOSITORY_CORPUS_ROOT`; no corpus bytes or repository-local +golden values are embedded here. + +## Black-box adapter contract + +Implement `conformanceconsumer.Runner`, or configure `ProcessRunner` with the +manager executable and arguments. `ProcessRunner` links no manager package. It +sends one `RunRequest` JSON object on standard input and captures exit status, +standard output, and standard error as observations. A non-zero manager exit is +data, while launch, transport, or context failures are runner errors. + +The process environment is caller supplied. Harness authors should provide +only the environment required by the chosen case and should isolate the +fixture, manager home, cache, and temporary directories per run. + +## Deterministic fixture materialization + +Use `MaterializeCorpusFiles` with an empty, task-owned destination. Every source +must be manifest-listed; every target must be a clean relative slash path; file +mode is explicit; targets are sorted before creation; duplicate targets, +existing targets, path traversal, and symlink parents are rejected. The helper +returns source/target/SHA-256 provenance for the run report. Generated fixture +trees are transient inputs and must not be checked in or described as normative +release evidence. + +## Machine-readable report + +`ReportJSON` emits deterministic JSON identified by +`urn:relux-works:curator:cross-manager-report:v1`. The JSON Schema is +stored with the package as `report.schema.json` and exposed by `ReportSchema`. +Case states are deliberately observational (`observed`, `mismatch`, `error`, +or `not-run`); the report format contains no qualification, parity, platform, +merge, or release claim. + +## Corpus update checklist + +- Replace the caller-supplied corpus root; do not copy corpus files into this + repository. +- Record the accepted corpus commit and recompute the exact manifest SHA-256. +- Confirm the accepted manifest still declares protocol `1.0.0-rc.5` and that + the `curator-conformance-corpus/v1` parser remains compatible. +- Diff every changed manifest entry and review all case additions, removals, + expected outcomes, sources, threat coverage, and lifecycle coverage. +- Re-run `internal/conformanceconsumer` and `internal/rc5interop` with + `CURATOR_EXTERNAL_REPOSITORY_CORPUS_ROOT` set and update only reviewed Curator + bindings. +- Revalidate `report.schema.json` consumers and fixture provenance output. +- Keep qualification, native platform execution, conformance claims, release + pins, tags, and promotion decisions in their separately authorized tasks. + +## Accepted rc.5 input evidence + +- Consumer code base: `74fe162415d800cd0a6975313827f9dc8594d299`. +- Protocol tag commit: + `f5d7673039226ab81de2f4f87e2155ae995c4df3`. +- `conformance/v1` tree: + `0ea6b7166482cfe951fdf62d72dbcbe3b5d8b8e4`. +- `conformance/v1/manifest.json` SHA-256: + `b6f56aacc0e37dcc6692f73f641bff761e89b645adfe20a47a06d81c6fda204c`. +- Accepted external-repository corpus SHA-256: + `7652fa628812dbd9e72367b6aebc853a0db6178babbc11737fc05a515afbf771`. +- The accepted corpus has 60 cases, 18 architecture-v6 threat rows, and 12 + lifecycle boundaries. Candidate platform claims are empty; platform evidence + is recorded by native qualification, not inferred from corpus presence. + +These values identify inputs only. They do not claim Linux support. diff --git a/internal/buildrepo/guide_test.go b/internal/buildrepo/guide_test.go new file mode 100644 index 0000000..11b63d1 --- /dev/null +++ b/internal/buildrepo/guide_test.go @@ -0,0 +1,122 @@ +package buildrepo_test + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/relux-works/curator/internal/buildrepo" + "github.com/relux-works/curator/internal/devsub" + "github.com/relux-works/curator/internal/skillspec" +) + +const guideRelativePath = "../../docs/external-build-repositories.md" + +func readGuide(t *testing.T) string { + t.Helper() + payload, err := os.ReadFile(guideRelativePath) + if err != nil { + t.Fatal(err) + } + return string(payload) +} + +func guideExamples(t *testing.T, guide string) map[string][]string { + t.Helper() + pattern := regexp.MustCompile(`(?s)\s*` + "```json\\n" + `(.*?)\n` + "```") + examples := map[string][]string{} + for _, match := range pattern.FindAllStringSubmatch(guide, -1) { + examples[match[1]] = append(examples[match[1]], match[2]) + } + return examples +} + +func TestExternalRepositoryGuideExamplesValidate(t *testing.T) { + examples := guideExamples(t, readGuide(t)) + if len(examples["manifest"]) != 1 || len(examples["descriptor"]) != 1 || len(examples["substitution"]) != 2 { + t.Fatalf("guide example inventory = %#v", examples) + } + + for _, manifest := range examples["manifest"] { + directory := t.TempDir() + if err := os.WriteFile(filepath.Join(directory, skillspec.CanonicalManifestName), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + spec, err := skillspec.Load(directory) + if err != nil { + t.Fatalf("schema-7 manifest example is invalid: %v", err) + } + if spec.SchemaVersion != 7 { + t.Fatalf("manifest schema = %d, want 7", spec.SchemaVersion) + } + } + + for _, descriptor := range examples["descriptor"] { + if _, err := buildrepo.ParseDescriptor([]byte(descriptor)); err != nil { + t.Fatalf("skill-build.json example is invalid: %v", err) + } + } + + for _, substitution := range examples["substitution"] { + if _, err := devsub.ParseManifestBytes([]byte(substitution), t.TempDir()); err != nil { + t.Fatalf("Skillfile.dev.json example is invalid: %v", err) + } + } +} + +func TestExternalRepositoryGuideFutureDriverAdmissionTable(t *testing.T) { + guide := readGuide(t) + start := strings.Index(guide, "") + end := strings.Index(guide, "") + if start < 0 || end <= start { + t.Fatal("future-driver admission table markers are missing") + } + lines := strings.Split(guide[start:end], "\n") + rows := map[string][]string{} + for _, line := range lines { + if !strings.HasPrefix(line, "| ") || strings.HasPrefix(line, "| ---") || strings.HasPrefix(line, "| Language ") { + continue + } + cells := strings.Split(strings.Trim(line, "| "), " | ") + if len(cells) != 7 { + t.Fatalf("future-driver row has %d cells, want 7: %s", len(cells), line) + } + rows[cells[0]] = cells + } + + for _, language := range []string{"Rust", "Swift", "Kotlin/JVM", "C/C++", ".NET"} { + cells, ok := rows[language] + if !ok { + t.Errorf("future-driver table is missing %s", language) + continue + } + if !strings.Contains(cells[1], "Unsupported") || !strings.Contains(cells[1], "separately versioned closed driver") { + t.Errorf("%s admission does not remain closed and unsupported: %q", language, cells[1]) + } + for column, cell := range cells[2:] { + if strings.TrimSpace(cell) == "" { + t.Errorf("%s threat-review column %d is empty", language, column+2) + } + } + } + if len(rows) != 5 { + t.Fatalf("future-driver table has %d language rows, want 5", len(rows)) + } +} + +func TestExternalRepositoryGuideLocalLinksResolve(t *testing.T) { + guide := readGuide(t) + linkPattern := regexp.MustCompile(`\[[^]]+\]\(([^)]+)\)`) + guideDirectory := filepath.Dir(guideRelativePath) + for _, match := range linkPattern.FindAllStringSubmatch(guide, -1) { + target := strings.SplitN(match[1], "#", 2)[0] + if target == "" || strings.Contains(target, "://") { + continue + } + if _, err := os.Stat(filepath.Clean(filepath.Join(guideDirectory, filepath.FromSlash(target)))); err != nil { + t.Errorf("guide link %q does not resolve: %v", match[1], err) + } + } +} diff --git a/internal/conformanceconsumer/blackbox.go b/internal/conformanceconsumer/blackbox.go new file mode 100644 index 0000000..4d1376a --- /dev/null +++ b/internal/conformanceconsumer/blackbox.go @@ -0,0 +1,434 @@ +package conformanceconsumer + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +// RuntimeObservation is supplied by a platform observer while the manager is +// alive. Processes and network endpoints are logical evidence strings; the +// observer owns platform-specific collection and must not mutate the run. +type RuntimeObservation struct { + Processes []string `json:"processes,omitempty"` + Network []string `json:"network,omitempty"` +} + +// RuntimeObserver samples process and network activity until ctx is canceled. +// Native macOS and Windows harnesses can implement this without importing a +// manager. Tests can inject deterministic observations. +type RuntimeObserver interface { + Observe(ctx context.Context, managerPID int) (RuntimeObservation, error) +} + +// FileObservation is a stable logical snapshot entry. Absolute implementation +// paths never enter comparison or report bytes. +type FileObservation struct { + Path string `json:"path"` + Kind string `json:"kind"` + SHA256 string `json:"sha256,omitempty"` + Size int64 `json:"size,omitempty"` +} + +// FileChange describes one observable filesystem effect. +type FileChange struct { + Path string `json:"path"` + Change string `json:"change"` + Before string `json:"before_sha256,omitempty"` + After string `json:"after_sha256,omitempty"` +} + +// CommandSpec is one released-manager black-box invocation. +type CommandSpec struct { + Manager string + Executable string + Args []string + Dir string + Env []string + WatchedRoots map[string]string + MutableRoots []string + AllowedWrites []string + AllowedProcesses []string + NormalizePaths map[string]string + Observer RuntimeObserver + ObserverTimeout time.Duration +} + +// CommandObservation is deterministic evidence from one independent process. +type CommandObservation struct { + Manager string `json:"manager"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Files []FileObservation `json:"files"` + Changes []FileChange `json:"changes"` + Processes []string `json:"processes,omitempty"` + Network []string `json:"network,omitempty"` + UnexpectedProcesses []string `json:"unexpected_processes,omitempty"` + UnexpectedNetwork []string `json:"unexpected_network,omitempty"` + UnexpectedWrites []string `json:"unexpected_writes,omitempty"` + MutationOnFailure bool `json:"mutation_on_failure,omitempty"` + ObservationSHA256 string `json:"observation_sha256"` +} + +// RunCommand executes one manager as an independent process with exactly the +// supplied environment, snapshots all declared roots, and records forbidden +// activity reported by the platform observer. +func RunCommand(ctx context.Context, spec CommandSpec) (CommandObservation, error) { + if spec.Manager == "" || spec.Executable == "" { + return CommandObservation{}, fmt.Errorf("manager and executable are required") + } + executable, err := exec.LookPath(spec.Executable) + if err != nil { + return CommandObservation{}, fmt.Errorf("resolve manager executable: %w", err) + } + before, err := snapshotRoots(spec.WatchedRoots) + if err != nil { + return CommandObservation{}, fmt.Errorf("snapshot before manager run: %w", err) + } + command := exec.CommandContext(ctx, executable, spec.Args...) + command.Dir = spec.Dir + command.Env = append([]string(nil), spec.Env...) + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Start(); err != nil { + return CommandObservation{}, fmt.Errorf("start manager process: %w", err) + } + + observerCtx, cancelObserver := context.WithCancel(context.Background()) + type observerResult struct { + observation RuntimeObservation + err error + } + observerDone := make(chan observerResult, 1) + if spec.Observer == nil { + observerDone <- observerResult{} + } else { + go func() { + observation, observeErr := spec.Observer.Observe(observerCtx, command.Process.Pid) + observerDone <- observerResult{observation: observation, err: observeErr} + }() + } + waitErr := command.Wait() + cancelObserver() + timeout := spec.ObserverTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + var observed observerResult + select { + case observed = <-observerDone: + case <-time.After(timeout): + return CommandObservation{}, fmt.Errorf("runtime observer did not stop within %s", timeout) + } + if observed.err != nil { + return CommandObservation{}, fmt.Errorf("runtime observer: %w", observed.err) + } + + after, err := snapshotRoots(spec.WatchedRoots) + if err != nil { + return CommandObservation{}, fmt.Errorf("snapshot after manager run: %w", err) + } + exitCode := 0 + if waitErr != nil { + if ctx.Err() != nil { + return CommandObservation{}, ctx.Err() + } + var exitError *exec.ExitError + if !errors.As(waitErr, &exitError) { + return CommandObservation{}, fmt.Errorf("wait for manager process: %w", waitErr) + } + exitCode = exitError.ExitCode() + } + + changes := diffFiles(before, after) + result := CommandObservation{ + Manager: spec.Manager, + ExitCode: exitCode, + Stdout: normalizeOutput(stdout.String(), spec.NormalizePaths), + Stderr: normalizeOutput(stderr.String(), spec.NormalizePaths), + Files: after, + Changes: changes, + Processes: sortedUnique(observed.observation.Processes), + Network: sortedUnique(observed.observation.Network), + } + result.UnexpectedProcesses = unexpectedProcesses(result.Processes, executable, spec.AllowedProcesses) + result.UnexpectedNetwork = append([]string(nil), result.Network...) + result.UnexpectedWrites = unexpectedWrites(changes, spec.AllowedWrites) + if exitCode != 0 { + result.MutationOnFailure = changedRoot(changes, spec.MutableRoots) + } + digest, err := observationDigest(result) + if err != nil { + return CommandObservation{}, err + } + result.ObservationSHA256 = digest + return result, nil +} + +func snapshotRoots(roots map[string]string) ([]FileObservation, error) { + labels := make([]string, 0, len(roots)) + for label := range roots { + if err := validateCorpusPath(label); err != nil { + return nil, fmt.Errorf("watched root %q: %w", label, err) + } + labels = append(labels, label) + } + sort.Strings(labels) + var result []FileObservation + for _, label := range labels { + root := roots[label] + info, err := os.Lstat(root) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return nil, fmt.Errorf("watched root %q must be a real directory", label) + } + err = filepath.WalkDir(root, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if name == root { + return nil + } + relative, err := filepath.Rel(root, name) + if err != nil { + return err + } + logical := label + "/" + filepath.ToSlash(relative) + entryInfo, err := entry.Info() + if err != nil { + return err + } + observation := FileObservation{Path: logical, Size: entryInfo.Size()} + switch { + case entry.Type()&os.ModeSymlink != 0: + observation.Kind = "symlink" + target, err := os.Readlink(name) + if err != nil { + return err + } + observation.SHA256 = digestBytes([]byte(filepath.ToSlash(target))) + case entry.IsDir(): + observation.Kind = "directory" + observation.Size = 0 + case entry.Type().IsRegular(): + observation.Kind = "file" + payload, err := os.ReadFile(name) + if err != nil { + return err + } + observation.SHA256 = digestBytes(payload) + default: + observation.Kind = "special" + } + result = append(result, observation) + return nil + }) + if err != nil { + return nil, err + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Path < result[j].Path }) + return result, nil +} + +func diffFiles(before, after []FileObservation) []FileChange { + old := make(map[string]FileObservation, len(before)) + current := make(map[string]FileObservation, len(after)) + for _, file := range before { + old[file.Path] = file + } + for _, file := range after { + current[file.Path] = file + } + paths := make([]string, 0, len(old)+len(current)) + seen := map[string]struct{}{} + for path := range old { + seen[path] = struct{}{} + paths = append(paths, path) + } + for path := range current { + if _, exists := seen[path]; !exists { + paths = append(paths, path) + } + } + sort.Strings(paths) + changes := make([]FileChange, 0) + for _, path := range paths { + beforeFile, hadBefore := old[path] + afterFile, hasAfter := current[path] + change := FileChange{Path: path} + switch { + case !hadBefore: + change.Change, change.After = "added", afterFile.SHA256 + case !hasAfter: + change.Change, change.Before = "removed", beforeFile.SHA256 + case beforeFile != afterFile: + change.Change, change.Before, change.After = "modified", beforeFile.SHA256, afterFile.SHA256 + default: + continue + } + changes = append(changes, change) + } + return changes +} + +func normalizeOutput(value string, replacements map[string]string) string { + value = strings.ReplaceAll(value, "\r\n", "\n") + type replacement struct{ from, to string } + ordered := make([]replacement, 0, len(replacements)) + for from, to := range replacements { + if from != "" { + ordered = append(ordered, replacement{from, to}) + } + } + sort.Slice(ordered, func(i, j int) bool { return len(ordered[i].from) > len(ordered[j].from) }) + for _, item := range ordered { + value = strings.ReplaceAll(value, item.from, item.to) + value = strings.ReplaceAll(value, filepath.ToSlash(item.from), item.to) + } + return value +} + +func unexpectedProcesses(processes []string, executable string, allowed []string) []string { + allow := map[string]struct{}{strings.ToLower(filepath.Base(executable)): {}} + for _, name := range allowed { + allow[strings.ToLower(filepath.Base(name))] = struct{}{} + } + var unexpected []string + for _, process := range processes { + if _, ok := allow[strings.ToLower(filepath.Base(process))]; !ok { + unexpected = append(unexpected, process) + } + } + return sortedUnique(unexpected) +} + +func unexpectedWrites(changes []FileChange, allowed []string) []string { + var unexpected []string + for _, change := range changes { + permitted := false + for _, prefix := range allowed { + prefix = strings.TrimSuffix(prefix, "/") + if change.Path == prefix || strings.HasPrefix(change.Path, prefix+"/") { + permitted = true + break + } + } + if !permitted { + unexpected = append(unexpected, change.Path) + } + } + return sortedUnique(unexpected) +} + +func changedRoot(changes []FileChange, mutable []string) bool { + for _, change := range changes { + for _, root := range mutable { + root = strings.TrimSuffix(root, "/") + if change.Path == root || strings.HasPrefix(change.Path, root+"/") { + return true + } + } + } + return false +} + +func observationDigest(observation CommandObservation) (string, error) { + copyObservation := observation + copyObservation.ObservationSHA256 = "" + payload, err := json.Marshal(copyObservation) + if err != nil { + return "", fmt.Errorf("encode command observation: %w", err) + } + return digestBytes(payload), nil +} + +func digestBytes(payload []byte) string { + digest := sha256.Sum256(payload) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func sortedUnique(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + sort.Strings(result) + return result +} + +// InspectBinary pins a released binary by content and its black-box version +// output. Source revision remains explicit and cannot be guessed from a file. +func InspectBinary(ctx context.Context, manager, executable, revision, specRevision, toolchain, operatingSystem, architecture string, versionArgs, env []string) (RevisionEvidence, error) { + if manager == "" || revision == "" || specRevision == "" || toolchain == "" || operatingSystem == "" || architecture == "" { + return RevisionEvidence{}, fmt.Errorf("complete binary revision metadata is required") + } + resolved, err := exec.LookPath(executable) + if err != nil { + return RevisionEvidence{}, err + } + payload, err := os.ReadFile(resolved) + if err != nil { + return RevisionEvidence{}, err + } + command := exec.CommandContext(ctx, resolved, versionArgs...) + command.Env = append([]string(nil), env...) + output, err := command.CombinedOutput() + if err != nil { + return RevisionEvidence{}, fmt.Errorf("read %s version: %w: %s", manager, err, strings.TrimSpace(string(output))) + } + version := strings.TrimSpace(strings.ReplaceAll(string(output), "\r\n", "\n")) + if version == "" { + return RevisionEvidence{}, fmt.Errorf("%s returned an empty version", manager) + } + return RevisionEvidence{Manager: manager, Version: version, Revision: revision, BinarySHA256: digestBytes(payload), SpecRevision: specRevision, Toolchain: toolchain, OperatingSystem: operatingSystem, Architecture: architecture}, nil +} + +// WriteFailureArtifacts preserves raw output and the complete normalized +// observation under a caller-owned task directory. +func WriteFailureArtifacts(root, caseID string, observation CommandObservation) (string, error) { + if err := validateCorpusPath(caseID); err != nil || strings.Contains(caseID, "/") { + return "", fmt.Errorf("failure case id must be one safe path component") + } + directory := filepath.Join(root, caseID) + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", err + } + payload, err := json.MarshalIndent(observation, "", " ") + if err != nil { + return "", err + } + files := map[string][]byte{"observation.json": append(payload, '\n'), "stdout.log": []byte(observation.Stdout), "stderr.log": []byte(observation.Stderr)} + for name, content := range files { + if err := os.WriteFile(filepath.Join(directory, name), content, 0o600); err != nil { + return "", err + } + } + return filepath.ToSlash(caseID), nil +} diff --git a/internal/conformanceconsumer/boundary.go b/internal/conformanceconsumer/boundary.go new file mode 100644 index 0000000..c27beec --- /dev/null +++ b/internal/conformanceconsumer/boundary.go @@ -0,0 +1,174 @@ +// Package conformanceconsumer provides implementation-neutral scaffolding for +// consuming an external Curator Protocol corpus through black-box runners. +package conformanceconsumer + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +const ( + // CorpusBoundaryV1 identifies the consumer-side manifest contract. It is + // independent of a particular candidate checkout and can be rebound to an + // accepted corpus with the same protocol version. + CorpusBoundaryV1 = "curator-conformance-corpus/v1" + ProtocolRC5 = "1.0.0-rc.5" +) + +// Boundary pins the protocol and consumer contract expected at a corpus root. +type Boundary struct { + Version string `json:"version"` + ProtocolVersion string `json:"protocol_version"` +} + +// RC5Boundary is the explicit boundary used by the provisional rc.5 consumer. +var RC5Boundary = Boundary{Version: CorpusBoundaryV1, ProtocolVersion: ProtocolRC5} + +type manifest struct { + SchemaVersion int `json:"schema_version,omitempty"` + CorpusVersion string `json:"corpus_version,omitempty"` + ProtocolVersion string `json:"protocol_version"` + GeneratedAt string `json:"generated_at"` + Generator string `json:"generator"` + Files []manifestEntry `json:"files"` +} + +type manifestEntry struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size,omitempty"` +} + +// Corpus is a read-only view of files authenticated by manifest.json. +type Corpus struct { + root string + boundary Boundary + manifestSHA256 string + files map[string]string +} + +// OpenCorpus validates a corpus manifest against the explicit boundary. +func OpenCorpus(root string, boundary Boundary) (*Corpus, error) { + if boundary.Version != CorpusBoundaryV1 { + return nil, fmt.Errorf("unsupported corpus boundary %q", boundary.Version) + } + if boundary.ProtocolVersion == "" { + return nil, fmt.Errorf("corpus protocol version is required") + } + absolute, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve corpus root: %w", err) + } + payload, err := os.ReadFile(filepath.Join(absolute, "manifest.json")) + if err != nil { + return nil, fmt.Errorf("read corpus manifest: %w", err) + } + var parsed manifest + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&parsed); err != nil { + return nil, fmt.Errorf("decode corpus manifest: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("decode corpus manifest: trailing JSON value") + } + if parsed.ProtocolVersion != boundary.ProtocolVersion { + return nil, fmt.Errorf("corpus protocol version %q does not match boundary %q", parsed.ProtocolVersion, boundary.ProtocolVersion) + } + if parsed.CorpusVersion != "" && parsed.CorpusVersion != "rc5-external-repository-interop-v1" { + return nil, fmt.Errorf("unsupported external repository corpus version %q", parsed.CorpusVersion) + } + files := make(map[string]string, len(parsed.Files)) + for _, entry := range parsed.Files { + if err := validateCorpusPath(entry.Path); err != nil { + return nil, fmt.Errorf("manifest file %q: %w", entry.Path, err) + } + if _, exists := files[entry.Path]; exists { + return nil, fmt.Errorf("manifest contains duplicate file %q", entry.Path) + } + if _, err := parseDigest(entry.SHA256); err != nil { + return nil, fmt.Errorf("manifest file %q: %w", entry.Path, err) + } + files[entry.Path] = entry.SHA256 + } + digest := sha256.Sum256(payload) + return &Corpus{ + root: absolute, + boundary: boundary, + manifestSHA256: "sha256:" + hex.EncodeToString(digest[:]), + files: files, + }, nil +} + +// Evidence returns the replaceable corpus identity used by reports. +func (c *Corpus) Evidence() CorpusEvidence { + return CorpusEvidence{ + Boundary: c.boundary.Version, + ProtocolVersion: c.boundary.ProtocolVersion, + ManifestSHA256: c.manifestSHA256, + } +} + +// Entries returns sorted manifest paths under prefix without reading bytes. +func (c *Corpus) Entries(prefix string) []string { + entries := make([]string, 0) + for name := range c.files { + if strings.HasPrefix(name, prefix) { + entries = append(entries, name) + } + } + sort.Strings(entries) + return entries +} + +// Read verifies and returns one manifest-listed file. +func (c *Corpus) Read(name string) ([]byte, string, error) { + if err := validateCorpusPath(name); err != nil { + return nil, "", err + } + want, ok := c.files[name] + if !ok { + return nil, "", fmt.Errorf("corpus file %q is not listed in manifest.json", name) + } + payload, err := os.ReadFile(filepath.Join(c.root, filepath.FromSlash(name))) + if err != nil { + return nil, "", fmt.Errorf("read corpus file %q: %w", name, err) + } + digest := sha256.Sum256(payload) + got := "sha256:" + hex.EncodeToString(digest[:]) + if got != want { + return nil, "", fmt.Errorf("corpus file %q digest %s does not match manifest %s", name, got, want) + } + return payload, got, nil +} + +func validateCorpusPath(name string) error { + if name == "" || strings.Contains(name, "\\") || path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.HasPrefix(name, "../") { + return fmt.Errorf("path must be a clean relative slash path") + } + return nil +} + +func parseDigest(value string) ([]byte, error) { + encoded, ok := strings.CutPrefix(value, "sha256:") + if !ok { + return nil, fmt.Errorf("digest must use sha256 prefix") + } + digest, err := hex.DecodeString(encoded) + if err != nil || len(digest) != sha256.Size { + return nil, fmt.Errorf("digest must contain 64 lowercase hexadecimal characters") + } + if encoded != strings.ToLower(encoded) { + return nil, fmt.Errorf("digest must contain 64 lowercase hexadecimal characters") + } + return digest, nil +} diff --git a/internal/conformanceconsumer/cases.go b/internal/conformanceconsumer/cases.go new file mode 100644 index 0000000..7dc6dbf --- /dev/null +++ b/internal/conformanceconsumer/cases.go @@ -0,0 +1,43 @@ +package conformanceconsumer + +import ( + "encoding/json" + "fmt" +) + +// Case is one implementation-neutral external-repository observation from the +// shared rc.5 case manifest. Expected remains raw JSON so the protocol corpus, +// rather than this consumer, owns the outcome vocabulary. +type Case struct { + ID string `json:"id"` + Category string `json:"category"` + Source string `json:"source"` + Expected json.RawMessage `json:"expected"` +} + +type caseManifest struct { + Cases []Case `json:"cases"` +} + +// Cases authenticates and decodes the shared case manifest. +func (c *Corpus) Cases() ([]Case, error) { + payload, _, err := c.Read("case-manifest.json") + if err != nil { + return nil, err + } + var document caseManifest + if err := json.Unmarshal(payload, &document); err != nil { + return nil, fmt.Errorf("decode case manifest: %w", err) + } + seen := make(map[string]struct{}, len(document.Cases)) + for _, item := range document.Cases { + if item.ID == "" || item.Category == "" || item.Source == "" || len(item.Expected) == 0 { + return nil, fmt.Errorf("case manifest contains an incomplete case") + } + if _, ok := seen[item.ID]; ok { + return nil, fmt.Errorf("case manifest contains duplicate case %q", item.ID) + } + seen[item.ID] = struct{}{} + } + return append([]Case(nil), document.Cases...), nil +} diff --git a/internal/conformanceconsumer/compare.go b/internal/conformanceconsumer/compare.go new file mode 100644 index 0000000..68c82b8 --- /dev/null +++ b/internal/conformanceconsumer/compare.go @@ -0,0 +1,90 @@ +package conformanceconsumer + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" +) + +// ProtocolObservation contains only normative, implementation-neutral facts. +// Adapters must translate private manager paths into logical names before +// constructing this value. +type ProtocolObservation struct { + CaseID string `json:"case_id"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + TypedError string `json:"typed_error,omitempty"` + Identities map[string]string `json:"identities,omitempty"` + Digests map[string]string `json:"digests,omitempty"` + Receipts map[string]string `json:"receipts,omitempty"` + Markers map[string]string `json:"markers,omitempty"` + States map[string]string `json:"states,omitempty"` + Files []FileObservation `json:"files,omitempty"` + UnexpectedProcess []string `json:"unexpected_processes,omitempty"` + UnexpectedNetwork []string `json:"unexpected_network,omitempty"` + UnexpectedWrites []string `json:"unexpected_writes,omitempty"` + MutationOnFailure bool `json:"mutation_on_failure,omitempty"` +} + +// Comparison is a deterministic parity result. Mismatch names are stable and +// intentionally exclude implementation-private physical paths. +type Comparison struct { + CaseID string `json:"case_id"` + Equal bool `json:"equal"` + Mismatches []string `json:"mismatches,omitempty"` + LeftSHA256 string `json:"left_sha256"` + RightSHA256 string `json:"right_sha256"` +} + +// CompareProtocol compares all protocol-required observations exactly. +func CompareProtocol(left, right ProtocolObservation) (Comparison, error) { + if left.CaseID == "" || right.CaseID == "" || left.CaseID != right.CaseID { + return Comparison{}, fmt.Errorf("matching non-empty case ids are required") + } + normalizeProtocolObservation(&left) + normalizeProtocolObservation(&right) + leftPayload, err := json.Marshal(left) + if err != nil { + return Comparison{}, err + } + rightPayload, err := json.Marshal(right) + if err != nil { + return Comparison{}, err + } + comparison := Comparison{CaseID: left.CaseID, LeftSHA256: digestBytes(leftPayload), RightSHA256: digestBytes(rightPayload)} + fields := []struct { + name string + left, right any + }{ + {"exit_code", left.ExitCode, right.ExitCode}, + {"stdout", left.Stdout, right.Stdout}, + {"stderr", left.Stderr, right.Stderr}, + {"typed_error", left.TypedError, right.TypedError}, + {"identities", left.Identities, right.Identities}, + {"digests", left.Digests, right.Digests}, + {"receipts", left.Receipts, right.Receipts}, + {"markers", left.Markers, right.Markers}, + {"states", left.States, right.States}, + {"files", left.Files, right.Files}, + {"unexpected_processes", left.UnexpectedProcess, right.UnexpectedProcess}, + {"unexpected_network", left.UnexpectedNetwork, right.UnexpectedNetwork}, + {"unexpected_writes", left.UnexpectedWrites, right.UnexpectedWrites}, + {"mutation_on_failure", left.MutationOnFailure, right.MutationOnFailure}, + } + for _, field := range fields { + if !reflect.DeepEqual(field.left, field.right) { + comparison.Mismatches = append(comparison.Mismatches, field.name) + } + } + comparison.Equal = len(comparison.Mismatches) == 0 + return comparison, nil +} + +func normalizeProtocolObservation(observation *ProtocolObservation) { + sort.Slice(observation.Files, func(i, j int) bool { return observation.Files[i].Path < observation.Files[j].Path }) + observation.UnexpectedProcess = sortedUnique(observation.UnexpectedProcess) + observation.UnexpectedNetwork = sortedUnique(observation.UnexpectedNetwork) + observation.UnexpectedWrites = sortedUnique(observation.UnexpectedWrites) +} diff --git a/internal/conformanceconsumer/conformanceconsumer_test.go b/internal/conformanceconsumer/conformanceconsumer_test.go new file mode 100644 index 0000000..8f55566 --- /dev/null +++ b/internal/conformanceconsumer/conformanceconsumer_test.go @@ -0,0 +1,335 @@ +package conformanceconsumer + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "runtime" + "sort" + "strings" + "testing" +) + +func writeCorpus(t *testing.T, protocol string, files map[string][]byte) string { + t.Helper() + root := t.TempDir() + type entry struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + } + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Sort(sort.Reverse(sort.StringSlice(names))) + manifestFiles := make([]entry, 0, len(files)) + for _, name := range names { + payload := files[name] + digest := sha256.Sum256(payload) + manifestFiles = append(manifestFiles, entry{name, "sha256:" + hex.EncodeToString(digest[:])}) + target := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, payload, 0o644); err != nil { + t.Fatal(err) + } + } + manifest := struct { + ProtocolVersion string `json:"protocol_version"` + GeneratedAt string `json:"generated_at"` + Generator string `json:"generator"` + Files []entry `json:"files"` + }{protocol, "2000-01-01T00:00:00Z", "test", manifestFiles} + payload, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "manifest.json"), payload, 0o644); err != nil { + t.Fatal(err) + } + return root +} + +func TestCorpusBoundaryAuthenticatesReplaceableInput(t *testing.T) { + root := writeCorpus(t, ProtocolRC5, map[string][]byte{ + "fixtures/external-repository/a.json": []byte("a"), + "vectors/external-repository.json": []byte("vector"), + }) + corpus, err := OpenCorpus(root, RC5Boundary) + if err != nil { + t.Fatal(err) + } + wantEntries := []string{"fixtures/external-repository/a.json", "vectors/external-repository.json"} + if got := corpus.Entries(""); !reflect.DeepEqual(got, wantEntries) { + t.Fatalf("entries = %v", got) + } + payload, _, err := corpus.Read("vectors/external-repository.json") + if err != nil || string(payload) != "vector" { + t.Fatalf("read authenticated vector = %q, %v", payload, err) + } + if _, _, err := corpus.Read("not-listed.json"); err == nil { + t.Fatal("unlisted corpus file must be rejected") + } + if err := os.WriteFile(filepath.Join(root, "vectors", "external-repository.json"), []byte("tampered"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := corpus.Read("vectors/external-repository.json"); err == nil || !strings.Contains(err.Error(), "does not match manifest") { + t.Fatalf("tampered corpus read error = %v", err) + } +} + +func TestCorpusBoundaryRejectsProtocolDrift(t *testing.T) { + root := writeCorpus(t, "1.0.0-rc.6", map[string][]byte{"vector.json": []byte("x")}) + if _, err := OpenCorpus(root, RC5Boundary); err == nil || !strings.Contains(err.Error(), "does not match boundary") { + t.Fatalf("protocol drift error = %v", err) + } +} + +func TestCorpusBoundaryRejectsMalformedContract(t *testing.T) { + root := writeCorpus(t, ProtocolRC5, map[string][]byte{"vector.json": []byte("x")}) + for _, boundary := range []Boundary{ + {Version: "future/v2", ProtocolVersion: ProtocolRC5}, + {Version: CorpusBoundaryV1}, + } { + if _, err := OpenCorpus(root, boundary); err == nil { + t.Fatalf("boundary %#v must be rejected", boundary) + } + } + if err := validateCorpusPath("../escape"); err == nil { + t.Fatal("escaping corpus path must be rejected") + } + if _, err := parseDigest("sha256:not-a-digest"); err == nil { + t.Fatal("malformed corpus digest must be rejected") + } +} + +func TestMaterializeCorpusFilesIsDeterministicAndProvenanced(t *testing.T) { + root := writeCorpus(t, ProtocolRC5, map[string][]byte{"fixtures/a": []byte("alpha"), "fixtures/b": []byte("beta")}) + corpus, err := OpenCorpus(root, RC5Boundary) + if err != nil { + t.Fatal(err) + } + destination := filepath.Join(t.TempDir(), "fixture") + got, err := MaterializeCorpusFiles(corpus, destination, []CorpusFile{ + {Source: "fixtures/b", Target: "z/b", Mode: 0o600}, + {Source: "fixtures/a", Target: "a", Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + if got[0].Target != "a" || got[1].Target != "z/b" { + t.Fatalf("materialization order = %#v", got) + } + payload, err := os.ReadFile(filepath.Join(destination, "z", "b")) + if err != nil || string(payload) != "beta" { + t.Fatalf("materialized payload = %q, %v", payload, err) + } + if got[1].Source != "fixtures/b" || !strings.HasPrefix(got[1].SHA256, "sha256:") { + t.Fatalf("provenance = %#v", got[1]) + } + if _, err := MaterializeCorpusFiles(corpus, t.TempDir(), []CorpusFile{{Source: "fixtures/a", Target: "../escape", Mode: 0o644}}); err == nil { + t.Fatal("escaping fixture target must be rejected") + } +} + +func TestMaterializeCorpusFilesRejectsSymlinkParent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation may require elevated privileges") + } + root := writeCorpus(t, ProtocolRC5, map[string][]byte{"fixtures/a": []byte("alpha")}) + corpus, err := OpenCorpus(root, RC5Boundary) + if err != nil { + t.Fatal(err) + } + destination := t.TempDir() + if err := os.Symlink(t.TempDir(), filepath.Join(destination, "linked")); err != nil { + t.Fatal(err) + } + _, err = MaterializeCorpusFiles(corpus, destination, []CorpusFile{{Source: "fixtures/a", Target: "linked/a", Mode: 0o644}}) + if err == nil || !strings.Contains(err.Error(), "not a real directory") { + t.Fatalf("symlink parent error = %v", err) + } +} + +func TestProcessRunnerUsesJSONBlackBoxContract(t *testing.T) { + runner := ProcessRunner{Executable: os.Args[0], Args: []string{"-test.run=TestProcessRunnerHelper", "--"}, Env: []string{"GO_WANT_CONFORMANCE_HELPER=1"}} + request := RunRequest{Boundary: CorpusBoundaryV1, CorpusRoot: "/candidate", FixtureRoot: "/fixture", Case: "raw-object"} + result, err := runner.Run(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if result.ExitCode != 7 || string(result.Stdout) != "raw-object\n" || !strings.HasPrefix(string(result.Stderr), "helper observation\n") { + t.Fatalf("runner result = %#v", result) + } +} + +func TestProcessRunnerRejectsInvalidConfiguration(t *testing.T) { + request := RunRequest{Boundary: CorpusBoundaryV1} + if _, err := (ProcessRunner{}).Run(context.Background(), request); err == nil || !strings.Contains(err.Error(), "executable") { + t.Fatalf("missing executable error = %v", err) + } + request.Boundary = "future/v2" + if _, err := (ProcessRunner{Executable: os.Args[0]}).Run(context.Background(), request); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("future boundary error = %v", err) + } +} + +func TestProcessRunnerHelper(t *testing.T) { + if os.Getenv("GO_WANT_CONFORMANCE_HELPER") != "1" { + return + } + payload, err := io.ReadAll(os.Stdin) + if err != nil { + os.Exit(90) + } + var request RunRequest + if err := json.Unmarshal(payload, &request); err != nil { + os.Exit(91) + } + fmt.Fprintln(os.Stdout, request.Case) + fmt.Fprintln(os.Stderr, "helper observation") + os.Exit(7) +} + +func TestReportJSONIsDeterministicAndSchemaIsMachineReadable(t *testing.T) { + report := Report{ + Schema: ReportSchemaV1, + Corpus: CorpusEvidence{Boundary: CorpusBoundaryV1, ProtocolVersion: ProtocolRC5, ManifestSHA256: "sha256:" + strings.Repeat("a", 64)}, + Adapter: "fixture-adapter/v1", + Revisions: []RevisionEvidence{reportRevisionFixture()}, + Cases: []CaseResult{{ID: "z", Status: CaseNotRun, ExitCode: -1}, {ID: "a", Status: CaseObserved, ExitCode: 0}}, + } + payload, err := ReportJSON(report) + if err != nil { + t.Fatal(err) + } + if strings.Index(string(payload), `"id": "a"`) > strings.Index(string(payload), `"id": "z"`) { + t.Fatalf("report cases are not sorted: %s", payload) + } + var schema map[string]any + if err := json.Unmarshal(ReportSchema(), &schema); err != nil { + t.Fatalf("report schema JSON: %v", err) + } + if schema["$id"] != ReportSchemaV1 { + t.Fatalf("report schema id = %v", schema["$id"]) + } +} + +func TestReportJSONRejectsAmbiguousResults(t *testing.T) { + valid := Report{ + Schema: ReportSchemaV1, + Corpus: CorpusEvidence{Boundary: CorpusBoundaryV1, ProtocolVersion: ProtocolRC5, ManifestSHA256: "sha256:" + strings.Repeat("a", 64)}, + Adapter: "fixture-adapter/v1", + Revisions: []RevisionEvidence{reportRevisionFixture()}, + Cases: []CaseResult{{ID: "same", Status: CaseObserved}}, + } + duplicate := valid + duplicate.Cases = append(duplicate.Cases, duplicate.Cases[0]) + if _, err := ReportJSON(duplicate); err == nil || !strings.Contains(err.Error(), "duplicate case") { + t.Fatalf("duplicate report case error = %v", err) + } + invalidStatus := valid + invalidStatus.Cases[0].Status = "passed" + if _, err := ReportJSON(invalidStatus); err == nil || !strings.Contains(err.Error(), "invalid status") { + t.Fatalf("qualification-like status error = %v", err) + } +} + +func reportRevisionFixture() RevisionEvidence { + return RevisionEvidence{ + Manager: "curator", + Version: "test", + Revision: strings.Repeat("b", 40), + BinarySHA256: "sha256:" + strings.Repeat("c", 64), + SpecRevision: strings.Repeat("d", 40), + Toolchain: "go-test", + OperatingSystem: "test-os", + Architecture: "test-arch", + } +} + +func TestMaterializeCorpusFilesRejectsDuplicateAndExistingTargets(t *testing.T) { + root := writeCorpus(t, ProtocolRC5, map[string][]byte{"fixtures/a": []byte("alpha")}) + corpus, err := OpenCorpus(root, RC5Boundary) + if err != nil { + t.Fatal(err) + } + duplicate := []CorpusFile{ + {Source: "fixtures/a", Target: "same", Mode: 0o644}, + {Source: "fixtures/a", Target: "same", Mode: 0o644}, + } + if _, err := MaterializeCorpusFiles(corpus, t.TempDir(), duplicate); err == nil || !strings.Contains(err.Error(), "duplicate fixture target") { + t.Fatalf("duplicate fixture error = %v", err) + } + destination := t.TempDir() + if err := os.WriteFile(filepath.Join(destination, "existing"), []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := MaterializeCorpusFiles(corpus, destination, []CorpusFile{{Source: "fixtures/a", Target: "existing", Mode: 0o644}}); err == nil { + t.Fatal("existing fixture target must be rejected") + } + if _, err := MaterializeCorpusFiles(nil, destination, nil); err == nil || !strings.Contains(err.Error(), "corpus is required") { + t.Fatalf("nil corpus error = %v", err) + } + if _, err := MaterializeCorpusFiles(corpus, t.TempDir(), []CorpusFile{{Source: "fixtures/a", Target: "bad-mode", Mode: 0}}); err == nil { + t.Fatal("zero fixture mode must be rejected") + } +} + +func TestReportJSONRejectsInvalidIdentity(t *testing.T) { + report := Report{} + if _, err := ReportJSON(report); err == nil || !strings.Contains(err.Error(), "report schema") { + t.Fatalf("missing schema error = %v", err) + } + report.Schema = ReportSchemaV1 + report.Corpus = CorpusEvidence{Boundary: CorpusBoundaryV1, ProtocolVersion: ProtocolRC5, ManifestSHA256: "bad"} + if _, err := ReportJSON(report); err == nil || !strings.Contains(err.Error(), "manifest digest") { + t.Fatalf("bad manifest digest error = %v", err) + } + report.Corpus.ManifestSHA256 = "sha256:" + strings.Repeat("a", 64) + if _, err := ReportJSON(report); err == nil || !strings.Contains(err.Error(), "adapter is required") { + t.Fatalf("missing adapter error = %v", err) + } +} + +func TestRC5CandidateBoundary(t *testing.T) { + root := os.Getenv("CURATOR_CONFORMANCE_ROOT") + if root == "" { + t.Skip("CURATOR_CONFORMANCE_ROOT is not set") + } + corpus, err := OpenCorpus(root, RC5Boundary) + if err != nil { + t.Fatal(err) + } + if got := len(corpus.Entries("fixtures/external-repository/")); got != 4 { + t.Fatalf("external repository fixture file count = %d, want 4", got) + } + if _, _, err := corpus.Read("vectors/external-repository-acquisition.json"); err != nil { + t.Fatal(err) + } +} + +func TestAcceptedRC5ExternalRepositoryCorpus(t *testing.T) { + root := os.Getenv("CURATOR_EXTERNAL_REPOSITORY_CORPUS_ROOT") + if root == "" { + t.Skip("CURATOR_EXTERNAL_REPOSITORY_CORPUS_ROOT is not set") + } + corpus, err := OpenCorpus(root, RC5Boundary) + if err != nil { + t.Fatal(err) + } + cases, err := corpus.Cases() + if err != nil { + t.Fatal(err) + } + if len(cases) != 60 { + t.Fatalf("external repository cases = %d, want exact accepted rc.5 inventory 60", len(cases)) + } +} diff --git a/internal/conformanceconsumer/materialize.go b/internal/conformanceconsumer/materialize.go new file mode 100644 index 0000000..8784442 --- /dev/null +++ b/internal/conformanceconsumer/materialize.go @@ -0,0 +1,130 @@ +package conformanceconsumer + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" +) + +// CorpusFile maps one authenticated corpus file into an isolated fixture tree. +type CorpusFile struct { + Source string + Target string + Mode fs.FileMode +} + +// MaterializedFile records the source and digest of a generated fixture file. +// It is provenance metadata, not normative release evidence. +type MaterializedFile struct { + Source string `json:"source"` + Target string `json:"target"` + SHA256 string `json:"sha256"` +} + +// MaterializeCorpusFiles deterministically creates a fixture tree from +// manifest-authenticated corpus inputs. Existing targets and symlinks are +// rejected so a run cannot overwrite or escape its isolated workspace. +func MaterializeCorpusFiles(corpus *Corpus, root string, files []CorpusFile) ([]MaterializedFile, error) { + if corpus == nil { + return nil, fmt.Errorf("corpus is required") + } + absolute, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve fixture root: %w", err) + } + ordered := append([]CorpusFile(nil), files...) + sort.Slice(ordered, func(i, j int) bool { return ordered[i].Target < ordered[j].Target }) + seen := make(map[string]struct{}, len(ordered)) + for _, file := range ordered { + if err := validateCorpusPath(file.Target); err != nil { + return nil, fmt.Errorf("fixture target %q: %w", file.Target, err) + } + if _, duplicate := seen[file.Target]; duplicate { + return nil, fmt.Errorf("duplicate fixture target %q", file.Target) + } + seen[file.Target] = struct{}{} + if file.Mode&^fs.FileMode(0o777) != 0 || file.Mode.Perm() == 0 { + return nil, fmt.Errorf("fixture target %q has invalid mode %04o", file.Target, file.Mode) + } + } + if err := os.MkdirAll(absolute, 0o755); err != nil { + return nil, fmt.Errorf("create fixture root: %w", err) + } + rootInfo, err := os.Lstat(absolute) + if err != nil { + return nil, fmt.Errorf("inspect fixture root: %w", err) + } + if rootInfo.Mode()&os.ModeSymlink != 0 || !rootInfo.IsDir() { + return nil, fmt.Errorf("fixture root must be a real directory") + } + result := make([]MaterializedFile, 0, len(ordered)) + for _, file := range ordered { + payload, digest, err := corpus.Read(file.Source) + if err != nil { + return nil, err + } + target := filepath.Join(absolute, filepath.FromSlash(file.Target)) + if err := ensureSafeParents(absolute, filepath.Dir(target)); err != nil { + return nil, fmt.Errorf("fixture target %q: %w", file.Target, err) + } + handle, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, file.Mode.Perm()) + if err != nil { + return nil, fmt.Errorf("create fixture target %q: %w", file.Target, err) + } + _, writeErr := handle.Write(payload) + closeErr := handle.Close() + if writeErr != nil { + return nil, fmt.Errorf("write fixture target %q: %w", file.Target, writeErr) + } + if closeErr != nil { + return nil, fmt.Errorf("close fixture target %q: %w", file.Target, closeErr) + } + if err := os.Chmod(target, file.Mode.Perm()); err != nil { + return nil, fmt.Errorf("set fixture target %q mode: %w", file.Target, err) + } + result = append(result, MaterializedFile{Source: file.Source, Target: file.Target, SHA256: digest}) + } + return result, nil +} + +func ensureSafeParents(root, parent string) error { + relative, err := filepath.Rel(root, parent) + if err != nil || relative == ".." || filepath.IsAbs(relative) { + return fmt.Errorf("target escapes fixture root") + } + cursor := root + if relative == "." { + return nil + } + for _, part := range splitPath(relative) { + cursor = filepath.Join(cursor, part) + info, statErr := os.Lstat(cursor) + if statErr == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("parent %q is not a real directory", cursor) + } + continue + } + if !os.IsNotExist(statErr) { + return statErr + } + if err := os.Mkdir(cursor, 0o755); err != nil { + return err + } + } + return nil +} + +func splitPath(value string) []string { + parts := make([]string, 0) + for value != "." && value != "" { + dir, base := filepath.Split(value) + if base != "" { + parts = append([]string{base}, parts...) + } + value = filepath.Clean(dir) + } + return parts +} diff --git a/internal/conformanceconsumer/report.go b/internal/conformanceconsumer/report.go new file mode 100644 index 0000000..db5125f --- /dev/null +++ b/internal/conformanceconsumer/report.go @@ -0,0 +1,129 @@ +package conformanceconsumer + +import ( + _ "embed" + "encoding/json" + "fmt" + "sort" +) + +const ReportSchemaV1 = "urn:relux-works:curator:cross-manager-report:v1" + +//go:embed report.schema.json +var reportSchema []byte + +// CorpusEvidence identifies input bytes without promoting them to a release. +type CorpusEvidence struct { + Boundary string `json:"boundary"` + ProtocolVersion string `json:"protocol_version"` + ManifestSHA256 string `json:"manifest_sha256"` +} + +// RevisionEvidence pins every independently supplied input that can affect a +// native black-box result. Revision values are caller-provided because a +// released binary cannot reliably recover its source revision from argv. +type RevisionEvidence struct { + Manager string `json:"manager"` + Version string `json:"version"` + Revision string `json:"revision"` + BinarySHA256 string `json:"binary_sha256"` + SpecRevision string `json:"spec_revision"` + Toolchain string `json:"toolchain"` + OperatingSystem string `json:"operating_system"` + Architecture string `json:"architecture"` +} + +// CaseStatus is an observation state, not a conformance verdict. +type CaseStatus string + +const ( + CaseObserved CaseStatus = "observed" + CaseMismatch CaseStatus = "mismatch" + CaseError CaseStatus = "error" + CaseNotRun CaseStatus = "not-run" +) + +// CaseResult is one deterministic black-box observation. +type CaseResult struct { + ID string `json:"id"` + Status CaseStatus `json:"status"` + ExitCode int `json:"exit_code"` + Detail string `json:"detail,omitempty"` + ObservationSHA256 string `json:"observation_sha256,omitempty"` + FailureArtifact string `json:"failure_artifact,omitempty"` + UnexpectedProcesses []string `json:"unexpected_processes,omitempty"` + UnexpectedNetwork []string `json:"unexpected_network,omitempty"` + UnexpectedWrites []string `json:"unexpected_writes,omitempty"` + MutationOnFailure bool `json:"mutation_on_failure,omitempty"` +} + +// Report is the machine-readable provisional consumer report. +type Report struct { + Schema string `json:"schema"` + Corpus CorpusEvidence `json:"corpus"` + Adapter string `json:"adapter"` + Revisions []RevisionEvidence `json:"revisions"` + Cases []CaseResult `json:"cases"` +} + +// ReportJSON validates and deterministically encodes a report. Cases are +// sorted by ID so process scheduling cannot affect output bytes. +func ReportJSON(report Report) ([]byte, error) { + if report.Schema != ReportSchemaV1 { + return nil, fmt.Errorf("report schema must be %q", ReportSchemaV1) + } + if report.Corpus.Boundary != CorpusBoundaryV1 || report.Corpus.ProtocolVersion == "" { + return nil, fmt.Errorf("report corpus boundary is invalid") + } + if _, err := parseDigest(report.Corpus.ManifestSHA256); err != nil { + return nil, fmt.Errorf("report manifest digest: %w", err) + } + if report.Adapter == "" { + return nil, fmt.Errorf("report adapter is required") + } + if len(report.Revisions) == 0 { + return nil, fmt.Errorf("report revision evidence is required") + } + copyReport := report + copyReport.Revisions = append([]RevisionEvidence(nil), report.Revisions...) + sort.Slice(copyReport.Revisions, func(i, j int) bool { return copyReport.Revisions[i].Manager < copyReport.Revisions[j].Manager }) + for index, revision := range copyReport.Revisions { + if revision.Manager == "" || revision.Version == "" || revision.Revision == "" || revision.SpecRevision == "" || revision.Toolchain == "" || revision.OperatingSystem == "" || revision.Architecture == "" { + return nil, fmt.Errorf("report revision %d is incomplete", index) + } + if _, err := parseDigest(revision.BinarySHA256); err != nil { + return nil, fmt.Errorf("report revision %q binary digest: %w", revision.Manager, err) + } + if index > 0 && copyReport.Revisions[index-1].Manager == revision.Manager { + return nil, fmt.Errorf("report contains duplicate manager revision %q", revision.Manager) + } + } + copyReport.Cases = append([]CaseResult(nil), report.Cases...) + sort.Slice(copyReport.Cases, func(i, j int) bool { return copyReport.Cases[i].ID < copyReport.Cases[j].ID }) + for index, result := range copyReport.Cases { + if result.ID == "" { + return nil, fmt.Errorf("report case %d has no id", index) + } + if index > 0 && copyReport.Cases[index-1].ID == result.ID { + return nil, fmt.Errorf("report contains duplicate case %q", result.ID) + } + switch result.Status { + case CaseObserved, CaseMismatch, CaseError, CaseNotRun: + default: + return nil, fmt.Errorf("report case %q has invalid status %q", result.ID, result.Status) + } + if result.ObservationSHA256 != "" { + if _, err := parseDigest(result.ObservationSHA256); err != nil { + return nil, fmt.Errorf("report case %q observation digest: %w", result.ID, err) + } + } + } + payload, err := json.MarshalIndent(copyReport, "", " ") + if err != nil { + return nil, err + } + return append(payload, '\n'), nil +} + +// ReportSchema returns a defensive copy of the embedded JSON Schema. +func ReportSchema() []byte { return append([]byte(nil), reportSchema...) } diff --git a/internal/conformanceconsumer/report.schema.json b/internal/conformanceconsumer/report.schema.json new file mode 100644 index 0000000..e740ecb --- /dev/null +++ b/internal/conformanceconsumer/report.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:relux-works:curator:cross-manager-report:v1", + "title": "Curator cross-manager black-box report v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "corpus", "adapter", "revisions", "cases"], + "properties": { + "schema": { "const": "urn:relux-works:curator:cross-manager-report:v1" }, + "corpus": { + "type": "object", + "additionalProperties": false, + "required": ["boundary", "protocol_version", "manifest_sha256"], + "properties": { + "boundary": { "const": "curator-conformance-corpus/v1" }, + "protocol_version": { "type": "string", "minLength": 1 }, + "manifest_sha256": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" } + } + }, + "adapter": { "type": "string", "minLength": 1 }, + "revisions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["manager", "version", "revision", "binary_sha256", "spec_revision", "toolchain", "operating_system", "architecture"], + "properties": { + "manager": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "minLength": 1 }, + "binary_sha256": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "spec_revision": { "type": "string", "minLength": 1 }, + "toolchain": { "type": "string", "minLength": 1 }, + "operating_system": { "type": "string", "minLength": 1 }, + "architecture": { "type": "string", "minLength": 1 } + } + } + }, + "cases": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "status", "exit_code"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "status": { "enum": ["observed", "mismatch", "error", "not-run"] }, + "exit_code": { "type": "integer" }, + "detail": { "type": "string" }, + "observation_sha256": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "failure_artifact": { "type": "string" }, + "unexpected_processes": { "type": "array", "items": { "type": "string" } }, + "unexpected_network": { "type": "array", "items": { "type": "string" } }, + "unexpected_writes": { "type": "array", "items": { "type": "string" } }, + "mutation_on_failure": { "type": "boolean" } + } + } + } + } +} diff --git a/internal/conformanceconsumer/runner.go b/internal/conformanceconsumer/runner.go new file mode 100644 index 0000000..5b69381 --- /dev/null +++ b/internal/conformanceconsumer/runner.go @@ -0,0 +1,74 @@ +package conformanceconsumer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" +) + +// RunRequest is the versioned JSON request delivered to a black-box adapter. +type RunRequest struct { + Boundary string `json:"boundary"` + CorpusRoot string `json:"corpus_root"` + FixtureRoot string `json:"fixture_root"` + Case string `json:"case"` +} + +// RunResult contains raw process observations without interpreting manager +// behavior or making a qualification decision. +type RunResult struct { + ExitCode int `json:"exit_code"` + Stdout []byte `json:"stdout"` + Stderr []byte `json:"stderr"` +} + +// Runner is the implementation-neutral adapter seam used by corpus consumers. +type Runner interface { + Run(context.Context, RunRequest) (RunResult, error) +} + +// ProcessRunner invokes an injected executable. The request is sent as JSON on +// stdin; no manager implementation package is linked into this package. +type ProcessRunner struct { + Executable string + Args []string + Env []string +} + +// Run executes the configured black-box process. Non-zero process exits are +// returned as observations; launch, transport, and context failures are errors. +func (r ProcessRunner) Run(ctx context.Context, request RunRequest) (RunResult, error) { + if r.Executable == "" { + return RunResult{}, fmt.Errorf("runner executable is required") + } + if request.Boundary != CorpusBoundaryV1 { + return RunResult{}, fmt.Errorf("run request boundary %q is unsupported", request.Boundary) + } + input, err := json.Marshal(request) + if err != nil { + return RunResult{}, fmt.Errorf("encode run request: %w", err) + } + command := exec.CommandContext(ctx, r.Executable, r.Args...) + command.Env = append([]string(nil), r.Env...) + command.Stdin = bytes.NewReader(append(input, '\n')) + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + err = command.Run() + result := RunResult{ExitCode: 0, Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} + if err == nil { + return result, nil + } + if ctx.Err() != nil { + return result, ctx.Err() + } + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + return result, fmt.Errorf("run black-box adapter: %w", err) + } + result.ExitCode = exitError.ExitCode() + return result, nil +} diff --git a/internal/rc5interop/bindings.go b/internal/rc5interop/bindings.go new file mode 100644 index 0000000..ec23469 --- /dev/null +++ b/internal/rc5interop/bindings.go @@ -0,0 +1,74 @@ +// Package rc5interop binds every shared rc.5 external-repository case to the +// Curator black-box or native regression that exercises the behavior. +package rc5interop + +// Binding names the native Go package and focused test used by qualification. +// The shared corpus owns inputs and expected outcomes; these names are only the +// Curator adapter layer and contain no copied normative values. +type Binding struct { + Package string + Test string +} + +var Bindings = map[string]Binding{ + "sha1-tag-match-https": {"./internal/buildrepo", "TestNetworkAndLocalSHA1SHA256RawObjectParity"}, + "sha1-tag-match-ssh": {"./internal/buildrepo", "TestPackIndexConformanceAndExactSSHWrapper"}, + "sha1-tag-moved": {"./internal/buildrepo", "TestTaggedAcquisitionUsesOnlyExactTagAndChecksTerminalCommit"}, + "sha1-tag-missing": {"./internal/buildrepo", "TestTaggedAcquisitionUsesOnlyExactTagAndChecksTerminalCommit"}, + "sha256-untagged": {"./internal/buildrepo", "TestNetworkAndLocalSHA1SHA256RawObjectParity"}, + "untagged-object-missing": {"./internal/buildrepo", "TestAdmissionFailuresPrecedeAuditCacheAndCompiler"}, + "canonical-https-ssh-scp": {"./internal/buildrepo", "TestCanonicalRepositorySourceVectors"}, + "operator-local-identity": {"./internal/buildrepo", "TestLocalIdentityDoesNotExposeHostPath"}, + "clean-git-session": {"./internal/buildrepo", "TestAdmissionFailuresPrecedeAuditCacheAndCompiler"}, + "exact-fetch-closed-shape": {"./internal/buildrepo", "TestTaggedAcquisitionUsesOnlyExactTagAndChecksTerminalCommit"}, + "ssh-wrapper-closed-shape": {"./internal/buildrepo", "TestPackIndexConformanceAndExactSSHWrapper"}, + "raw-object-reader-closed-shape": {"./internal/buildrepo", "TestRawObjectAndLFSPinnedConformanceFixtures"}, + "monorepo-root-target": {"./internal/buildrepo", "TestExternalPipelineCompilerSeesOnlySelectedBuildRoot"}, + "monorepo-nested-target": {"./internal/buildrepo", "TestExternalPipelineCompilerSeesOnlySelectedBuildRoot"}, + "local-substitution": {"./internal/devsub", "TestSchema2BuildRepositorySubstitutions"}, + "network-substitution-revision": {"./internal/devsub", "TestSchema2BuildRepositorySubstitutions"}, + "network-substitution-tag": {"./internal/devsub", "TestSchema2BuildRepositorySubstitutions"}, + "network-substitution-branch": {"./internal/devsub", "TestSchema2BuildRepositorySubstitutions"}, + "raw-object-malformed": {"./internal/buildrepo", "TestRawObjectAndLFSPinnedConformanceFixtures"}, + "lfs-pointer": {"./internal/buildrepo", "TestRawObjectAndLFSPinnedConformanceFixtures"}, + "submodule-gitlink": {"./internal/buildrepo", "TestRawObjectAndLFSPinnedConformanceFixtures"}, + "symbolic-link": {"./internal/buildrepo", "TestRawObjectAndLFSPinnedConformanceFixtures"}, + "special-file-mode": {"./internal/buildrepo", "TestRawObjectAndLFSPinnedConformanceFixtures"}, + "alternate-object-store": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "replace-ref": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "graft": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "promisor-pack": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "partial-clone": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "gitfile": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "linked-worktree": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "bare-repository": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "reftable": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "object-link": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "filter-config-inert": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "credential-helper-inert": {"./internal/buildrepo", "TestLocalConfigAndAdministrationAdversarialBoundaries"}, + "pack-v2-sha1": {"./internal/buildrepo", "TestPackIndexConformanceAndExactSSHWrapper"}, + "pack-v3-sha1": {"./internal/buildrepo", "TestPackIndexConformanceAndExactSSHWrapper"}, + "pack-v2-sha256": {"./internal/buildrepo", "TestPackIndexConformanceAndExactSSHWrapper"}, + "pack-index-checksum-mismatch": {"./internal/buildrepo", "TestPackIndexConformanceAndExactSSHWrapper"}, + "audit-order-cache-hit": {"./internal/buildrepo", "TestExternalPipelineCacheHitRepeatsAdmissionValidationAndAudit"}, + "audit-order-cache-miss": {"./internal/buildrepo", "TestExternalPipelineOrderingAcrossOperations"}, + "cache-corrupt-receipt": {"./internal/install", "TestCorruptCacheEntryIsRebuiltAndNeverReused"}, + "cache-corrupt-artifact": {"./internal/install", "TestCorruptCacheEntryIsRebuiltAndNeverReused"}, + "protected-offline-reuse": {"./internal/buildrepo", "TestExternalPipelineOfflineProtectedSnapshotAndTagFailure"}, + "offline-syntax-only": {"./internal/buildrepo", "TestExternalPipelineOfflineProtectedSnapshotAndTagFailure"}, + "offline-install-without-snapshot": {"./internal/buildrepo", "TestExternalPipelineOfflineProtectedSnapshotAndTagFailure"}, + "mixed-receipt-v1-v2-marker-v3": {"./internal/marker", "TestMarkerV3StructurallyRepresentsLocalExternalAndMixed"}, + "external-receipt-v2-exact-bytes": {"./internal/buildrepo", "TestExternalReceiptV2CacheKeyVector"}, + "status-current": {"./cmd/curator", "TestCompiledProjectStatusRepairRollbackRecovery"}, + "status-corrupt": {"./cmd/curator", "TestCompiledProjectStatusRepairRollbackRecovery"}, + "repair-reacquires": {"./cmd/curator", "TestCompiledProjectStatusRepairRollbackRecovery"}, + "gc-retains-marker-and-journal-roots": {"./internal/buildrepo", "TestExternalGCUsesMarkerAndJournalKeysAsOnlyRoots"}, + "shim-path-structural": {"./internal/runtimestore", "TestCompiledShimsStageWithoutLaunchThenPostInstallForwardExactly"}, + "path-collision": {"./internal/runtimestore", "TestStagingRejectsLiveOverlapAndUnsafePathEntries"}, + "package-argv-forbidden": {"./internal/skillspec", "TestSchema7RepositoryCommandAndDeclarationStayClosed"}, + "shim-collision-rollback": {"./internal/install", "TestSecondBuildFailurePreservesPriorInstallationAndLiveCache"}, + "consumer-last-rollback": {"./internal/install", "TestConsumerLedgerIsAbsentAfterAFailedFirstInstallAndCommitsLastOnSuccess"}, + "package-signing-request": {"./internal/buildrepo", "TestExternalPipelineOrderingAcrossOperations"}, + "platform-requires-signing": {"./internal/buildrepo", "TestExternalPipelineOrderingAcrossOperations"}, + "truthful-platform-claims": {"./internal/godriver", "TestNativeControlInventoryIsExhaustiveAndClosed"}, +} diff --git a/internal/rc5interop/bindings_test.go b/internal/rc5interop/bindings_test.go new file mode 100644 index 0000000..e6469d7 --- /dev/null +++ b/internal/rc5interop/bindings_test.go @@ -0,0 +1,43 @@ +package rc5interop + +import ( + "os" + "testing" + + "github.com/relux-works/curator/internal/conformanceconsumer" +) + +func TestEveryAcceptedRC5CaseHasACuratorBinding(t *testing.T) { + root := os.Getenv("CURATOR_EXTERNAL_REPOSITORY_CORPUS_ROOT") + if root == "" { + t.Skip("CURATOR_EXTERNAL_REPOSITORY_CORPUS_ROOT is not set") + } + corpus, err := conformanceconsumer.OpenCorpus(root, conformanceconsumer.RC5Boundary) + if err != nil { + t.Fatal(err) + } + cases, err := corpus.Cases() + if err != nil { + t.Fatal(err) + } + if len(cases) != 60 || len(Bindings) != 60 { + t.Fatalf("case/binding inventory = %d/%d, want 60/60", len(cases), len(Bindings)) + } + seen := make(map[string]struct{}, len(cases)) + for _, item := range cases { + seen[item.ID] = struct{}{} + binding, ok := Bindings[item.ID] + if !ok { + t.Errorf("shared case %q has no Curator binding", item.ID) + continue + } + if binding.Package == "" || binding.Test == "" { + t.Errorf("shared case %q has an incomplete Curator binding", item.ID) + } + } + for id := range Bindings { + if _, ok := seen[id]; !ok { + t.Errorf("Curator binding %q is not present in the accepted corpus", id) + } + } +}