diff --git a/AGENTS.md b/AGENTS.md index 790f582..9c42a55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,9 @@ This repository owns the user-controlled MALT local data runtime: CLI, local daemon, accepted-root policy, UnixFS application semantics, Merkle DAG import compatibility, pluggable transport capabilities, and payload-byte verification. -It also keeps the runtime-owned executable adapters needed to measure those exact behaviors; -the evaluator that plans and interprets campaigns lives in `malt-evaluation`. +This is the edge node implementation. Reproducible measurement drivers, replay +workers, experiment protocols, and accounting policy live in `malt-evaluation` +and consume public node/runtime capabilities. ## Boundaries @@ -28,9 +29,9 @@ the evaluator that plans and interprets campaigns lives in `malt-evaluation`. - Keep IPFS-style Merkle DAG import and compatibility policy here. A Merkle DAG root is a compatibility output, not a MALT-authenticated root or ProofList. - Keep benchmark suites, run plans, comparison policy, result schemas, and - result provenance in `malt-evaluation`. Code under `tools/evaluation` is a - pinned process adapter, not a supported runtime command or an evaluator - source of truth. + result provenance in `malt-evaluation`. Do not place experiment workers or + experiment-specific accounting in this node repository. Expose only reusable + node/runtime capabilities and storage observation needed by external callers. ## Package Ownership @@ -163,13 +164,6 @@ the evaluator that plans and interprets campaigns lives in `malt-evaluation`. select configured runtime services, call `application/` use cases, and format results, not become a second UnixFS, trust, or plan-composition implementation. `cmd/` contains only the supported `malt` product binary. -- `tools/evaluation/cmd/` owns evaluator-launched runtime process adapters. - Preserve their external binary names and wire contracts, but do not treat - their Go packages as a compatibility surface. -- `internal/evaluation/` owns shared implementation for those adapters, - including disposable-Gateway instance authentication, bootstrap, raw-CAS, - and selective-CAR transport. Production packages and `cmd/malt` must not - import it. - Preserve `internal/architecture` dependency tests when adding packages or moving responsibilities. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9bedd25..6b8ada7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,10 +19,11 @@ It owns: - application-level payload-byte verification. - runtime-side encrypted snapshot creation, restore, and automatic scheduling. -Runtime-specific benchmark process adapters also live in this repository under -`tools/evaluation`. They exercise this implementation at a pinned commit, but -do not own benchmark plans, suites, comparison policy, result schemas, or -result provenance; those belong in `malt-evaluation`. +Benchmark process adapters, replay workers, experiment fixtures, accounting, +plans, suites and provenance belong in `malt-evaluation`. Its adapters exercise +this node through public capabilities at a pinned module version. This runtime +and Gateway provide edge and cloud node implementations; measurements remain +outside the node implementation. It depends on `github.com/dewebprotocol/malt-core` for canonical graph types, typed query/candidate/batch contracts, local verification, CID rules, and @@ -124,7 +125,7 @@ merkledag -> CID/link replay + fixed profile transport trust -> accepted/candidate root persistence transport -> Gateway/local/hybrid adapters; never imports unixfs, merkledag, or trust bucketsync -> nodeapi.DatasetBranch + independent durable synchronization metadata -tools/evaluation/cmd -> internal/evaluation + public runtime capabilities +malt-evaluation adapters -> public nodeapi / runtime capabilities (external dependency) ``` `transport.Client` is one reusable HTTP connection, but consumers depend on @@ -134,8 +135,8 @@ the fixed `PostMerkleDAGResolve` and `PostMerkleDAGRead` capabilities; there is no arbitrary profile-route escape hatch. Transport results remain untrusted. The public transport also does not expose evaluation instance credentials, bootstrap control, unchecked raw-CAS reads, or the selective-CAR route. Those -disposable-Gateway capabilities live under `internal/evaluation` and can only -be composed by evaluator adapters. +disposable-Gateway capabilities live under `malt-evaluation/internal/nodeeval` +and can only be composed by evaluator adapters. `transport/local` is a bounded, atomic, owner-private durable CAS and verifies the complete body on both `Get` and `Has`. `transport/hybrid` is transport-level @@ -287,8 +288,6 @@ profile; it does not change MALT Core or the existing plaintext semantics of ## Packages - `cmd/malt`: CLI and daemon process lifecycle. -- `tools/evaluation/cmd`: evaluator-launched process adapters. Their executable - names and wire contracts are campaign inputs, not supported user CLI. - `application`: reusable trusted-root, UnixFS, and Merkle DAG use cases shared by command and daemon adapters. - `application/backup`: encrypted MALT-native filesystem, local fingerprint, Bucket @@ -394,8 +393,6 @@ profile; it does not change MALT Core or the existing plaintext semantics of - `internal/securefile`: owner-only Unix file modes and protected owner-and-SYSTEM Windows file DACLs. - `internal/cas`: runtime-side CAS helpers and byte verification. -- `internal/evaluation`: private adapter support, including evaluation Gateway - authentication/control transport and shared workload machinery. - `unixfs/model`: UnixFS application values and path rules. - `unixfs`: verified UnixFS reader/writer facade, staging, materialization, and payload verification. diff --git a/README.md b/README.md index af8739c..de74514 100644 --- a/README.md +++ b/README.md @@ -532,20 +532,17 @@ and cannot fall back to the public CAS namespace. ## Evaluation adapters -`cmd/` contains only the supported `malt` product binary. Runtime-specific -benchmark process adapters live under `tools/evaluation/cmd`, with shared -private support under `internal/evaluation`. They preserve their external -`malt-eval-*` executable and wire contracts, but are not a public Go API or -supported user CLI. - -Evaluation instance authentication, bootstrap control, unchecked raw-CAS -fetches, and selective-CAR transport are deliberately confined to that private -boundary. The public `transport` package cannot invoke them. Benchmark suites, -campaign plans, comparison policy, result schemas, and provenance remain owned -by the separate -[malt-evaluation](https://github.com/DeWebProtocol/malt-evaluation) repository. -See [tools/evaluation/README.md](./tools/evaluation/README.md) for the adapter -inventory and build boundary. +`cmd/` contains only the supported `malt` product binary. Replay workers, +benchmark drivers, experimental transports, fixtures, accounting and result +schemas live in the separate +[malt-evaluation](https://github.com/DeWebProtocol/malt-evaluation) repository, +under `adapters/node/cmd` and `internal/nodeeval`. + +The evaluator calls the public `nodeapi`, UnixFS, Merkle DAG and transport +capabilities. The node provides storage and authentication behavior; the +evaluator defines workloads and measurement boundaries. Gateway and this +runtime are cloud and edge implementations of node capabilities, respectively. +Evaluation builds pin the node module separately from the evaluator revision. The transport exposes bounded ordered CAS `PutBatch`/`HasBatch` and a typed diagnostic metrics snapshot. Merkle DAG import and verified link replay @@ -627,7 +624,8 @@ for completion. None of these operations accepts a candidate automatically. The candidate API is a complete-view workflow, not a stateless transition proof. Evaluation tooling can export bounded file snapshots with -`go run ./tools/evaluation/cmd/malt-eval-rooted-file-trace --input snapshots.json`. +`go run ./adapters/node/cmd/malt-eval-rooted-file-trace --input snapshots.json` +from a `malt-evaluation` checkout. The input is an array of complete versions, each containing `files` (canonical relative paths mapped to base64 bytes) and `queries` (canonical paths). `--backend ipa` and `--chunk-size` select the commitment and file chunk geometry. diff --git a/cmd/README.md b/cmd/README.md index c224676..5c2f9c4 100644 --- a/cmd/README.md +++ b/cmd/README.md @@ -6,6 +6,6 @@ This namespace contains only the supported `malt` runtime binary: go build -o bin/malt ./cmd/malt ``` -Benchmark workers are not product commands. They live under -[`tools/evaluation/cmd`](../tools/evaluation/cmd) so packaging and API reviews -cannot confuse evaluator process adapters with the user-facing CLI. +Benchmark workers are not product commands. They live in +[`malt-evaluation/adapters/node/cmd`](https://github.com/DeWebProtocol/malt-evaluation/tree/main/adapters/node/cmd) +and call public node/runtime capabilities. diff --git a/docs/evaluation-local-files.md b/docs/evaluation-local-files.md deleted file mode 100644 index bb7047e..0000000 --- a/docs/evaluation-local-files.md +++ /dev/null @@ -1,41 +0,0 @@ -# Verified local-file evaluation leaf - -`tools/evaluation/cmd/malt-eval-local-files` exercises the actual rooted-v1 -UnixFS writer, payload CAS and verified client reader. It accepts one bounded -file fixture and reuses it for full reads, byte ranges and authenticated missing -paths. The process adapter is evaluator tooling, not a supported product command. -The request/result contract is maintained by `malt-evaluation` in -`docs/local-files-worker-v1.md`. - -The fixture contains canonical regular-file paths and actual source bytes. -Every chunk and directory manifest is stored in a real in-memory CAS, while -authentication candidates are materialized through the public Core SDK. -Initialization reads every source file through the verified UnixFS facade and -compares its bytes with the independent fixture before measured queries begin. - -Reads start from the caller-selected entry root and original path. The client -fetches, decodes and verifies serialized authentication results, then binds each -required CAS body to authenticated CIDs. Ranges include actual Positional -metadata and chunk verification, including partial terminal chunks. A missing -path succeeds only after the reader's locally verified absence result; corrupt -CAS bytes or missing backend objects cannot be classified as authenticated -absence. The process never promotes a root into the runtime trust store. - -Counters describe actual calls to the authentication and payload capabilities. -Proof bytes count serialized `AuthenticationResult` envelopes; CAS bytes count -all returned block bodies, including manifests and repeated accesses. They are -not network wire bytes, persistent writes or internal materializer lookups. -The response separately identifies returned range/file length and its digest. -Fixture construction and complete readback remain outside read latency. - -The fixture is bounded by 32 MiB of source payload and 100,000 regular files; -chunks are between one byte and 4 MiB. The JSONL line bound is 64 MiB. Canonical -paths, collisions, operation-specific fields, duplicate keys/IDs, response -identity and explicit terminal close are checked. stdout is JSONL only. -The fixture implements no modes, symlinks, mutations, durability, HTTP, CAR, -cold-reset, checkpoint or resource-budget capability. - -Runtime tests cover KZG/IPA full reads, empty files, real side objects, shallow -and deep missing paths, ranges spanning chunks, partial terminal chunks, changed -entry roots, corrupted bodies and invalid protocol/lifecycle input. Linux -full-module test/vet/build and whitespace validation passed for this leaf. diff --git a/docs/go-api.md b/docs/go-api.md index 61f0488..9b8b743 100644 --- a/docs/go-api.md +++ b/docs/go-api.md @@ -185,8 +185,8 @@ defines no network codec, discovery rule, or wire identifier. The public transport does not expose evaluation instance tokens, bootstrap control, unchecked raw-CAS reads, or the selective-CAR route. Those -capabilities are private to the pinned process adapters under -`tools/evaluation`; they are not a supported integration surface. +capabilities are private to the process adapters in the separate +`malt-evaluation` repository; they are not a supported node integration surface. `Metrics` returns inexpensive monotonic counters. `MetricsWithStorage` also requests Gateway's O(live KV entries) logical scan and should be used only by diff --git a/docs/runtime-migration.md b/docs/runtime-migration.md index 00e8b55..df43175 100644 --- a/docs/runtime-migration.md +++ b/docs/runtime-migration.md @@ -19,7 +19,7 @@ not a current build contract. The original plan is available in | `internal/runtime` | Reusable composition of local services and transports | | `cmd/malt` | CLI and daemon entrypoints | | `merkledag` | Isolated IPFS-compatible import and CID/link replay | -| `tools/evaluation` | Private measured workers, separate from production policy | +| Evaluation workers | Moved to `malt-evaluation/adapters/node/cmd`; consume public node/runtime capabilities | The runtime's Go module namespace remains `github.com/dewebprotocol/malt-client` until its separately governed module migration. The module pins published Core diff --git a/docs/v0.0.5-parity.md b/docs/v0.0.5-parity.md index d4e1296..0d56174 100644 --- a/docs/v0.0.5-parity.md +++ b/docs/v0.0.5-parity.md @@ -20,7 +20,7 @@ repository at v0.0.5. | ArcTable, KV, and CAS persistence | gateway | migrated; not imported by runtime or core | | standalone CAS process | none | deliberately removed; gateway owns product CAS | | evaluator and benchmark suites | [malt-evaluation](https://github.com/DeWebProtocol/malt-evaluation) | former v0.0.5 evaluator retired from the active tree and recoverable from the [immutable standalone import commit](https://github.com/DeWebProtocol/malt-evaluation/commit/774854b5d90c1ba5d57c3d74619dd97a78fd9dcf) and Git history; active workloads live at the repository root | -| current runtime measurement adapters | malt | evaluator-launched `malt-eval-*` process boundaries live under `tools/evaluation/cmd`; shared implementation is private under `internal/evaluation`, while plans, suites, schemas, and provenance remain in `malt-evaluation` | +| current runtime measurement adapters | malt-evaluation | `adapters/node/cmd` and `internal/nodeeval` own workers and experimental support; the runtime exports only public node/application capabilities | | Merkle DAG gateway compatibility reads | gateway + malt | gateway traverses UnixFS and returns CID-bound blocks; the runtime independently hashes and replays them under a distinct non-MALT profile | | profile-specific transport escape hatch | none | arbitrary route/body transport was removed; public Merkle DAG compatibility uses fixed resolve/read capabilities, while evaluation-only bootstrap/raw-CAS/selective-CAR routes are private adapter implementation | | generic IPLD `ParseBlock`/link helpers | malt | retired: unused generic parser removed; supported import and verified DAG-PB/raw/DAG-CBOR/DAG-JSON replay remain in `merkledag` | diff --git a/go.mod b/go.mod index 5808027..5af3045 100644 --- a/go.mod +++ b/go.mod @@ -15,9 +15,7 @@ require ( github.com/ipld/go-ipld-prime v0.24.0 github.com/multiformats/go-multibase v0.3.0 github.com/multiformats/go-multihash v0.2.3 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 - github.com/syndtr/goleveldb v1.0.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 @@ -33,7 +31,6 @@ require ( github.com/gammazero/deque v1.2.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -71,7 +68,6 @@ require ( go.uber.org/zap v1.28.0 // indirect golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect golang.org/x/sync v0.23.0 // indirect - golang.org/x/text v0.42.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/protobuf v1.36.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/go.sum b/go.sum index 5bd3ba8..17b459c 100644 --- a/go.sum +++ b/go.sum @@ -25,13 +25,10 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3h github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dewebprotocol/malt-core v0.0.11-rc.1 h1:V1PC7myStsHDeAjy6DkNwkOGyWlJF4vXYFQSwgAbb4A= github.com/dewebprotocol/malt-core v0.0.11-rc.1/go.mod h1:Ewf3s5XYFlgsX2PhcOKY2BLqv9jcYXYEFaYhDHjO/Y0= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= github.com/filecoin-project/go-clock v0.1.0/go.mod h1:4uB/O4PvOjlx1VCMdZ9MyDZXRm//gkj1ELEbxfI1AZs= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gammazero/chanqueue v1.1.2 h1:dZEsxlyANZMyeTRemABqZF8QM9BnE4NBI43Oh3y5fIU= github.com/gammazero/chanqueue v1.1.2/go.mod h1:XDN1X/jjAbmSceNFOQbtKToeSkxtdVdpKu90LiEdBEE= github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= @@ -41,7 +38,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -54,8 +50,6 @@ github.com/hanwen/go-fuse/v2 v2.11.0 h1:CGVkJh9gRz0pTRMADNcqdFl3ec/5QbE/Vx1Gl7ES github.com/hanwen/go-fuse/v2 v2.11.0/go.mod h1:aU7NkGYZUmuJrZapoI3mEcNve7PZTySUOLBuch/vR6U= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -170,11 +164,6 @@ github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOo github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk= github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -191,8 +180,6 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= @@ -247,20 +234,14 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= @@ -268,11 +249,6 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6f google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/architecture/dependencies_test.go b/internal/architecture/dependencies_test.go index 4a5f07b..e2a1226 100644 --- a/internal/architecture/dependencies_test.go +++ b/internal/architecture/dependencies_test.go @@ -62,20 +62,17 @@ func TestRuntimeModuleNamespaceCutoverRemainsExplicit(t *testing.T) { func TestProductionPackagesDoNotImportEvaluation(t *testing.T) { root := moduleRoot(t) + for _, directory := range []string{"tools/evaluation", "internal/evaluation"} { + if _, err := os.Stat(filepath.Join(root, directory)); !os.IsNotExist(err) { + t.Fatalf("node runtime must not contain evaluation drivers at %s: %v", directory, err) + } + } set := token.NewFileSet() err := filepath.WalkDir(root, func(filePath string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - relative, err := filepath.Rel(root, filePath) if err != nil { return err } if entry.IsDir() { - if relative == filepath.Join("internal", "evaluation") || - relative == filepath.Join("tools", "evaluation") { - return filepath.SkipDir - } return nil } if filepath.Ext(filePath) != ".go" || strings.HasSuffix(filePath, "_test.go") { @@ -90,7 +87,9 @@ func TestProductionPackagesDoNotImportEvaluation(t *testing.T) { if err != nil { return err } - if value == "github.com/dewebprotocol/malt-client/internal/evaluation" || + if value == "github.com/dewebprotocol/malt-evaluation" || + strings.HasPrefix(value, "github.com/dewebprotocol/malt-evaluation/") || + value == "github.com/dewebprotocol/malt-client/internal/evaluation" || strings.HasPrefix(value, "github.com/dewebprotocol/malt-client/internal/evaluation/") { t.Errorf("%s imports forbidden production dependency %s", filePath, value) } @@ -102,7 +101,7 @@ func TestProductionPackagesDoNotImportEvaluation(t *testing.T) { } } -func TestCommandNamespacesSeparateProductAndEvaluationBinaries(t *testing.T) { +func TestCommandNamespaceContainsOnlyProductBinary(t *testing.T) { root := moduleRoot(t) productEntries, err := os.ReadDir(filepath.Join(root, "cmd")) if err != nil { @@ -118,18 +117,6 @@ func TestCommandNamespacesSeparateProductAndEvaluationBinaries(t *testing.T) { t.Fatalf("cmd must contain only the production malt command, got %v", productCommands) } - evaluationEntries, err := os.ReadDir(filepath.Join(root, "tools", "evaluation", "cmd")) - if err != nil { - t.Fatal(err) - } - if len(evaluationEntries) == 0 { - t.Fatal("evaluation command namespace is empty") - } - for _, entry := range evaluationEntries { - if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "malt-eval-") { - t.Errorf("tools/evaluation/cmd contains non-evaluation entry %q", entry.Name()) - } - } } func TestPublicTransportContainsNoEvaluationControlPlane(t *testing.T) { diff --git a/internal/cas/cas.go b/internal/cas/cas.go index 70de145..572781e 100644 --- a/internal/cas/cas.go +++ b/internal/cas/cas.go @@ -7,7 +7,6 @@ import ( "github.com/dewebprotocol/malt-client/nodeapi" cid "github.com/ipfs/go-cid" - mh "github.com/multiformats/go-multihash" ) // ErrNotFound reports that a requested CAS block is absent. Remote and local @@ -78,11 +77,7 @@ func NormalizeCodec(codec uint64) uint64 { // CIDForBlock computes the CID for block bytes and codec. func CIDForBlock(block Block) (cid.Cid, error) { - mhash, err := mh.Sum(block.Data, mh.SHA2_256, -1) - if err != nil { - return cid.Undef, err - } - return cid.NewCidV1(NormalizeCodec(block.Codec), mhash), nil + return nodeapi.CIDForBlock(block) } // PutBlocks stores blocks, using batch writes when supported. diff --git a/internal/evaluation/authenticationgraph/graph.go b/internal/evaluation/authenticationgraph/graph.go deleted file mode 100644 index 82d9017..0000000 --- a/internal/evaluation/authenticationgraph/graph.go +++ /dev/null @@ -1,404 +0,0 @@ -// Package authenticationgraph composes retained Core Writers for bounded, -// disposable-Gateway evaluations. It owns no protocol or accepted-root policy. -package authenticationgraph - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "time" - - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/protocol" - "github.com/dewebprotocol/malt-core/sdk/authentication" - cid "github.com/ipfs/go-cid" -) - -const ( - MaxObjects = 4096 - MaxEntries = 65536 - MaxDepth = 256 - MaxBytes = 64 << 20 -) - -type Remote interface { - AuthenticationCandidate(context.Context, cid.Cid) (gatewaytransport.CandidateResponse, error) - SubmitAuthenticationBatch(context.Context, protocol.AuthenticationBatch) (gatewaytransport.BatchResponse, error) -} - -// View contains original typed inputs, without copying complete node vectors. -// It is an independent snapshot; changing it cannot mutate retained Writers. -type View struct { - Root cid.Cid - States map[string]engine.State -} - -func (v View) State(root cid.Cid) (engine.State, error) { - state, ok := v.States[root.String()] - if !ok { - return engine.State{}, fmt.Errorf("authentication graph omits Root %s", root) - } - return state, nil -} - -type object struct { - writer *authentication.Writer - state engine.State - bytes uint64 -} -type graph struct { - root cid.Cid - objects map[string]object -} - -type LoadMetrics struct{ FetchNS, ImportNS, WireBytes, Objects uint64 } -type EditMetrics struct{ ApplyNS, ExportNS, Candidates, CandidateBytes uint64 } -type Result struct { - Base, Root cid.Cid - Receipt protocol.AuthenticationReceipt - Idempotent bool - Computation EditMetrics - Submission gatewaytransport.BatchResponse - SubmitRoundTripNS uint64 -} - -// Session advances only after a receipt binds the exact submitted batch and -// evaluator durable boundary. This is materialization acknowledgement only. -type Session struct { - mu sync.Mutex - remote Remote - engine *engine.Engine - current *graph -} - -func New(remote Remote, e *engine.Engine) (*Session, error) { - if remote == nil || e == nil { - return nil, errors.New("authentication graph needs a remote and engine") - } - return &Session{remote: remote, engine: e}, nil -} -func (s *Session) Load(ctx context.Context, root cid.Cid) (LoadMetrics, error) { - s.mu.Lock() - defer s.mu.Unlock() - next, metrics, err := s.fetch(ctx, root) - if err == nil { - s.current = next - } - return metrics, err -} -func (s *Session) fetch(ctx context.Context, root cid.Cid) (*graph, LoadMetrics, error) { - var metrics LoadMetrics - if err := s.engine.CheckRoot(root); err != nil { - return nil, metrics, err - } - next := &graph{root: root, objects: map[string]object{}} - visiting := map[string]bool{} - heights := map[string]int{} - var entries uint64 - var visit func(cid.Cid, int) error - visit = func(root cid.Cid, depth int) error { - if err := ctx.Err(); err != nil { - return err - } - if depth > MaxDepth { - return errors.New("authentication graph exceeds depth bound") - } - key := root.String() - if visiting[key] { - return errors.New("authentication graph contains a cycle") - } - if _, ok := next.objects[key]; ok { - if depth+heights[key] > MaxDepth { - return errors.New("shared subtree exceeds depth bound") - } - return nil - } - if len(next.objects)+len(visiting) >= MaxObjects { - return errors.New("authentication graph exceeds object bound") - } - visiting[key] = true - started := time.Now() - response, err := s.remote.AuthenticationCandidate(ctx, root) - metrics.FetchNS += elapsed(started) - if err != nil { - return err - } - candidate := response.Candidate - if candidate.Root != key { - return errors.New("candidate response substitutes the selected Root") - } - // Count normalized bytes as well as wire bytes; an in-process Remote is not - // allowed to bypass the same memory bound as the HTTP adapter. - encoded, err := json.Marshal(candidate) - if err != nil { - return err - } - size := max(response.WireBytes, uint64(len(encoded))) - if size > MaxBytes-metrics.WireBytes { - return errors.New("authentication graph exceeds byte bound") - } - metrics.WireBytes += size - if uint64(len(candidate.State.Entries)) > MaxEntries-entries { - return errors.New("authentication graph exceeds entry bound") - } - entries += uint64(len(candidate.State.Entries)) - started = time.Now() - writer, err := authentication.NewWriter(ctx, s.engine, candidate) - metrics.ImportNS += elapsed(started) - if err != nil { - return fmt.Errorf("authenticate candidate %s: %w", key, err) - } - for _, target := range graphTargets(candidate.State) { - if _, _, err := maltcid.ParseRoot(target); err == nil { - if err := visit(target, depth+1); err != nil { - return err - } - heights[key] = max(heights[key], 1+heights[target.String()]) - } - } - next.objects[key] = object{writer: writer, state: cloneState(candidate.State), bytes: size} - delete(visiting, key) - metrics.Objects++ - return nil - } - if err := visit(root, 0); err != nil { - return nil, metrics, err - } - return next, metrics, nil -} -func (s *Session) Snapshot() (View, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.current == nil { - return View{}, errors.New("authentication graph is not loaded") - } - return snapshot(s.current), nil -} -func snapshot(g *graph) View { - v := View{Root: g.root, States: make(map[string]engine.State, len(g.objects))} - for key, object := range g.objects { - v.States[key] = cloneState(object.state) - } - return v -} - -// Audit reimports the durable graph independently, without changing the session. -func (s *Session) Audit(ctx context.Context) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.current == nil { - return errors.New("authentication graph is not loaded") - } - _, _, err := s.fetch(ctx, s.current.root) - return err -} - -// Edit is private local computation against one immutable graph. Callers apply -// children before explicitly replacing the selected parent edge; aliased roots -// therefore retain ordinary copy-on-write behavior. -type Edit struct { - owner *Session - base *graph - pending map[string]object - candidates []protocol.AuthenticationCandidate - metrics EditMetrics -} - -func (s *Session) Begin() (*Edit, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.current == nil { - return nil, errors.New("authentication graph is not loaded") - } - return &Edit{owner: s, base: s.current, pending: map[string]object{}}, nil -} -func (e *Edit) get(root cid.Cid) (object, error) { - if o, ok := e.pending[root.String()]; ok { - return o, nil - } - if o, ok := e.base.objects[root.String()]; ok { - return o, nil - } - return object{}, fmt.Errorf("edit refers to an unavailable Root %s", root) -} -func (e *Edit) State(root cid.Cid) (engine.State, error) { - o, err := e.get(root) - return cloneState(o.state), err -} -func (e *Edit) Metrics() EditMetrics { return e.metrics } -func (e *Edit) Apply(ctx context.Context, root cid.Cid, delta authentication.Delta) (cid.Cid, error) { - o, err := e.get(root) - if err != nil { - return cid.Undef, err - } - started := time.Now() - writer, err := o.writer.Apply(ctx, delta) - e.metrics.ApplyNS += elapsed(started) - if err != nil { - return cid.Undef, err - } - if writer.Root().Equals(root) { - return root, nil - } - return e.retain(ctx, writer) -} -func (e *Edit) Build(ctx context.Context, state engine.State) (cid.Cid, error) { - if len(state.Entries) > MaxEntries { - return cid.Undef, errors.New("new state exceeds entry bound") - } - started := time.Now() - writer, err := authentication.BuildWriter(ctx, e.owner.engine, state) - e.metrics.ApplyNS += elapsed(started) - if err != nil { - return cid.Undef, err - } - return e.retain(ctx, writer) -} -func (e *Edit) retain(ctx context.Context, writer *authentication.Writer) (cid.Cid, error) { - root := writer.Root() - if _, ok := e.pending[root.String()]; ok { - return root, nil - } - if _, ok := e.base.objects[root.String()]; ok { - return root, nil - } - if len(e.pending) >= MaxObjects { - return cid.Undef, errors.New("edit exceeds candidate bound") - } - started := time.Now() - candidate, err := writer.Export(ctx) - e.metrics.ExportNS += elapsed(started) - if err != nil { - return cid.Undef, err - } - data, err := json.Marshal(candidate) - if err != nil { - return cid.Undef, err - } - size := uint64(len(data)) - if size > MaxBytes-e.metrics.CandidateBytes { - return cid.Undef, errors.New("edit exceeds candidate byte bound") - } - e.metrics.CandidateBytes += size - e.metrics.Candidates++ - e.pending[root.String()] = object{writer: writer, state: candidate.State, bytes: size} - e.candidates = append(e.candidates, candidate) - return root, nil -} -func (e *Edit) graph(ctx context.Context, root cid.Cid) (*graph, error) { - next := &graph{root: root, objects: map[string]object{}} - visiting := map[string]bool{} - heights := map[string]int{} - var entries, size uint64 - var visit func(cid.Cid, int) error - visit = func(root cid.Cid, depth int) error { - if err := ctx.Err(); err != nil { - return err - } - if depth > MaxDepth { - return errors.New("new graph exceeds depth bound") - } - key := root.String() - if visiting[key] { - return errors.New("new graph contains a cycle") - } - if _, ok := next.objects[key]; ok { - if depth+heights[key] > MaxDepth { - return errors.New("shared subtree exceeds depth bound") - } - return nil - } - if len(next.objects)+len(visiting) >= MaxObjects { - return errors.New("new graph exceeds object bound") - } - o, err := e.get(root) - if err != nil { - return err - } - if uint64(len(o.state.Entries)) > MaxEntries-entries || o.bytes > MaxBytes-size { - return errors.New("new graph exceeds entry or byte bound") - } - entries += uint64(len(o.state.Entries)) - size += o.bytes - visiting[key] = true - for _, target := range graphTargets(o.state) { - if _, _, err := maltcid.ParseRoot(target); err == nil { - if err := visit(target, depth+1); err != nil { - return err - } - heights[key] = max(heights[key], 1+heights[target.String()]) - } - } - next.objects[key] = o - delete(visiting, key) - return nil - } - if err := visit(root, 0); err != nil { - return nil, err - } - return next, nil -} - -// Preview runs closure and bounds checks before submission. It is local state, -// not evidence of persistence, publication, or trusted-root acceptance. -func (e *Edit) Preview(ctx context.Context, root cid.Cid) (View, error) { - g, err := e.graph(ctx, root) - if err != nil { - return View{}, err - } - return snapshot(g), nil -} -func (s *Session) Submit(ctx context.Context, transaction string, edit *Edit, root cid.Cid) (Result, error) { - s.mu.Lock() - defer s.mu.Unlock() - if edit == nil || edit.owner != s || edit.base != s.current { - return Result{}, errors.New("edit does not match current session state") - } - next, err := edit.graph(ctx, root) - if err != nil { - return Result{}, err - } - batch := protocol.AuthenticationBatch{Profile: protocol.AuthenticationBatchProfile, TransactionID: transaction, Base: s.current.root.String(), Root: root.String(), Candidates: edit.candidates} - if err := batch.Validate(); err != nil { - return Result{}, err - } - started := time.Now() - response, err := s.remote.SubmitAuthenticationBatch(ctx, batch) - roundTrip := elapsed(started) - if err != nil { - return Result{}, err - } - if err := response.Receipt.Validate(batch); err != nil { - return Result{}, err - } - if response.Receipt.DurableBoundary != gatewaytransport.AuthenticationDurableBoundary { - return Result{}, errors.New("receipt has an unexpected durable boundary") - } - result := Result{Base: s.current.root, Root: root, Receipt: response.Receipt, Idempotent: response.Idempotent, Computation: edit.metrics, Submission: response, SubmitRoundTripNS: roundTrip} - s.current = next - return result, nil -} -func cloneState(state engine.State) engine.State { - state.Entries = append([]engine.Entry{}, state.Entries...) - for i := range state.Entries { - state.Entries[i].Label = bytes.Clone(state.Entries[i].Label) - } - return state -} -func elapsed(started time.Time) uint64 { return uint64(max(time.Since(started), 0)) } - -func graphTargets(state engine.State) []cid.Cid { - targets := make([]cid.Cid, 0, len(state.Entries)+1) - if state.PayloadCID.Defined() { - targets = append(targets, state.PayloadCID) - } - for _, entry := range state.Entries { - targets = append(targets, entry.Target) - } - return targets -} diff --git a/internal/evaluation/authenticationgraph/graph_test.go b/internal/evaluation/authenticationgraph/graph_test.go deleted file mode 100644 index b747cde..0000000 --- a/internal/evaluation/authenticationgraph/graph_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package authenticationgraph - -import ( - "context" - "encoding/json" - "errors" - "testing" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - "github.com/dewebprotocol/malt-core/auth/commitment" - "github.com/dewebprotocol/malt-core/auth/commitment/kzg" - "github.com/dewebprotocol/malt-core/auth/coordinate" - "github.com/dewebprotocol/malt-core/derivation" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/protocol" - "github.com/dewebprotocol/malt-core/sdk/authentication" - cid "github.com/ipfs/go-cid" -) - -type countedScheme struct { - *kzg.Scheme - verifies int -} - -func (s *countedScheme) BatchVerify(root commitment.Value, indices []uint64, values []commitment.Cell, proof []byte) (bool, error) { - s.verifies++ - return s.Scheme.BatchVerify(root, indices, values, proof) -} -func (s *countedScheme) VerifyIndex(root commitment.Value, index uint64, value commitment.Cell, proof []byte) (bool, error) { - s.verifies++ - return s.Scheme.VerifyIndex(root, index, value, proof) -} - -type fixtureRemote struct { - candidates map[string]protocol.AuthenticationCandidate - fetches, submits int - wrong string - last protocol.AuthenticationBatch -} - -func (r *fixtureRemote) AuthenticationCandidate(_ context.Context, root cid.Cid) (gatewaytransport.CandidateResponse, error) { - r.fetches++ - c, ok := r.candidates[root.String()] - if !ok { - return gatewaytransport.CandidateResponse{}, errors.New("missing candidate") - } - data, _ := json.Marshal(c) - c, _ = protocol.DecodeAuthenticationCandidate(data) - return gatewaytransport.CandidateResponse{Candidate: c, WireBytes: uint64(len(data))}, nil -} -func (r *fixtureRemote) SubmitAuthenticationBatch(_ context.Context, b protocol.AuthenticationBatch) (gatewaytransport.BatchResponse, error) { - r.submits++ - r.last = b - digest, err := b.Digest() - if err != nil { - return gatewaytransport.BatchResponse{}, err - } - receipt := protocol.AuthenticationReceipt{Profile: protocol.AuthenticationReceiptProfile, TransactionID: b.TransactionID, Base: b.Base, Root: b.Root, Digest: digest, DurableBoundary: gatewaytransport.AuthenticationDurableBoundary} - switch r.wrong { - case "root": - receipt.Root = b.Base - case "transaction": - receipt.TransactionID = "other" - case "digest": - receipt.Digest = "different" - case "boundary": - receipt.DurableBoundary = "different" - case "profile": - receipt.Profile = "old" - } - if r.wrong == "" { - for _, c := range b.Candidates { - r.candidates[c.Root] = c - } - } - return gatewaytransport.BatchResponse{Receipt: receipt}, nil -} -func graphFixture(t *testing.T) (*Session, *fixtureRemote, *countedScheme, cid.Cid, cid.Cid, cid.Cid) { - t.Helper() - ctx := context.Background() - base, err := kzg.NewScheme() - if err != nil { - t.Fatal(err) - } - counted := &countedScheme{Scheme: base} - registry := engine.NewRegistry() - if err := registry.Register(counted); err != nil { - t.Fatal(err) - } - e := engine.New(registry) - payload := raw(t, "first chunk") - child, err := authentication.Prepare(ctx, e, engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: maltcid.KZG4096}, Entries: []engine.Entry{{Label: coordinate.EncodeIndex(0), Target: payload}}}) - if err != nil { - t.Fatal(err) - } - childRoot, _ := cid.Parse(child.Root) - parent, err := authentication.Prepare(ctx, e, engine.State{Descriptor: maltcid.RootDescriptor{Layout: maltcid.Prefix, DerivationProfile: uint8(derivation.SHA256), Profile: maltcid.KZG4096}, Entries: []engine.Entry{{Label: []byte("first"), Target: childRoot}, {Label: []byte("alias"), Target: childRoot}}}) - if err != nil { - t.Fatal(err) - } - root, _ := cid.Parse(parent.Root) - remote := &fixtureRemote{candidates: map[string]protocol.AuthenticationCandidate{child.Root: child, parent.Root: parent}} - session, err := New(remote, e) - if err != nil { - t.Fatal(err) - } - return session, remote, counted, root, childRoot, payload -} -func raw(t *testing.T, s string) cid.Cid { - t.Helper() - v, err := clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: []byte(s)}) - if err != nil { - t.Fatal(err) - } - return v -} -func TestRetainedWritersCopyOnWriteAndExactReceipt(t *testing.T) { - s, r, scheme, root, child, payload := graphFixture(t) - ctx := context.Background() - load, err := s.Load(ctx, root) - if err != nil { - t.Fatal(err) - } - if load.Objects != 2 || r.fetches != 2 || scheme.verifies == 0 { - t.Fatalf("initial import not observed: %+v fetches=%d verifies=%d", load, r.fetches, scheme.verifies) - } - scheme.verifies = 0 - stale, _ := s.Begin() - edit, _ := s.Begin() - after := raw(t, "second data") - nextChild, err := edit.Apply(ctx, child, authentication.Delta{Changes: []engine.Change{{Label: coordinate.EncodeIndex(0), Before: payload, After: after}}}) - if err != nil { - t.Fatal(err) - } - next, err := edit.Apply(ctx, root, authentication.Delta{Changes: []engine.Change{{Label: []byte("first"), Before: child, After: nextChild}}}) - if err != nil { - t.Fatal(err) - } - preview, err := edit.Preview(ctx, next) - if err != nil { - t.Fatal(err) - } - if len(preview.States) != 3 { - t.Fatal("aliased child was not preserved") - } - state, _ := preview.State(next) - for _, entry := range state.Entries { - if string(entry.Label) == "alias" && !entry.Target.Equals(child) { - t.Fatal("alias was rebound") - } - } - // A snapshot is caller-owned; changing its input or target must not poison a writer. - old, _ := s.Snapshot() - oldState := old.States[root.String()] - oldState.Entries[0].Label[0] ^= 1 - oldState.Entries[0].Target = after - for _, bad := range []string{"root", "transaction", "digest", "boundary", "profile"} { - r.wrong = bad - if _, err := s.Submit(ctx, "edit-one", edit, next); err == nil { - t.Fatalf("accepted %s substitution", bad) - } - unchanged, _ := s.Snapshot() - if !unchanged.Root.Equals(root) { - t.Fatal("failed receipt advanced session") - } - } - r.wrong = "" - result, err := s.Submit(ctx, "edit-one", edit, next) - if err != nil { - t.Fatal(err) - } - if !result.Root.Equals(next) || r.last.Candidates[0].Root != nextChild.String() || r.last.Candidates[1].Root != next.String() { - t.Fatal("dependency order or receipt Root differs") - } - if r.fetches != 2 || scheme.verifies != 0 { - t.Fatalf("incremental writer refetched or reverified state: fetches=%d verifies=%d", r.fetches, scheme.verifies) - } - if _, err := s.Submit(ctx, "stale-edit", stale, root); err == nil { - t.Fatal("stale edit accepted") - } - if err := s.Audit(ctx); err != nil { - t.Fatal(err) - } - if r.fetches != 5 || scheme.verifies == 0 { - t.Fatal("durable audit did not independently import full closure") - } -} -func TestFailedImportAndMissingClosureLeavePriorSessionUsable(t *testing.T) { - s, r, _, root, child, _ := graphFixture(t) - ctx := context.Background() - if _, err := s.Load(ctx, root); err != nil { - t.Fatal(err) - } - c := r.candidates[child.String()] - delete(r.candidates, child.String()) - if _, err := s.Load(ctx, root); err == nil { - t.Fatal("missing child accepted") - } - state, err := s.Snapshot() - if err != nil || !state.Root.Equals(root) { - t.Fatal("failed load discarded prior state") - } - r.candidates[child.String()] = c - bad := r.candidates[root.String()] - bad.State.Entries = append([]engine.Entry{}, bad.State.Entries...) - bad.State.Entries[0].Target = raw(t, "wrong") - r.candidates[root.String()] = bad - if _, err := s.Load(ctx, root); err == nil { - t.Fatal("unbound inputs accepted") - } - edit, _ := s.Begin() - unknown := raw(t, "not a Root") - if _, err := edit.Apply(ctx, unknown, authentication.Delta{}); err == nil { - t.Fatal("unknown edit base accepted") - } -} diff --git a/internal/evaluation/e0selftest/e0selftest.go b/internal/evaluation/e0selftest/e0selftest.go deleted file mode 100644 index 829e54e..0000000 --- a/internal/evaluation/e0selftest/e0selftest.go +++ /dev/null @@ -1,614 +0,0 @@ -// Package e0selftest implements the cross-repository side of the evaluator's -// formal-E0 invocation/receipt contract. Evaluation commands use it only when -// the reserved invocation environment is present; normal product paths never -// depend on this package. -package e0selftest - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "slices" - "strings" -) - -const ( - InvocationEnvironment = "MALT_EVAL_E0_SELF_TEST_INVOCATION" - InvocationSchema = "malt-eval-e0-self-test-invocation/v1" - ReceiptSchema = "malt-eval-e0-self-test-receipt/v1" - maximumInvocationSize = 1 << 20 -) - -var ( - idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`) - hex64 = regexp.MustCompile(`^[0-9a-f]{64}$`) -) - -type Profile struct { - ProfileID string `json:"profile_id"` - PositiveCases []string `json:"positive_cases"` - HostileCases []string `json:"hostile_cases"` -} - -type Contract struct { - ProfileID string `json:"profile_id"` - ProfileSHA256 string `json:"profile_sha256"` - PositiveCases uint32 `json:"positive_cases"` - HostileCases uint32 `json:"hostile_cases"` -} - -type CaseResult struct { - ID string - Passed bool -} - -type Counts struct { - Expected uint32 `json:"expected"` - Executed uint32 `json:"executed"` - Passed uint32 `json:"passed"` -} - -type TestedFilePin struct { - FileID string `json:"file_id"` - SHA256 string `json:"sha256"` - Bytes int64 `json:"bytes"` -} - -type Receipt struct { - SchemaVersion string `json:"schema_version"` - CapabilityID string `json:"capability_id"` - ProfileID string `json:"profile_id"` - ProfileSHA256 string `json:"profile_sha256"` - CorpusSHA256 string `json:"corpus_sha256"` - Positive Counts `json:"positive"` - Hostile Counts `json:"hostile"` - TestedExecutable TestedFilePin `json:"tested_executable"` - TestedInputs []TestedFilePin `json:"tested_inputs"` -} - -type invocation struct { - SchemaVersion string `json:"schema_version"` - CapabilityID string `json:"capability_id"` - Contract Contract `json:"contract"` - CorpusSHA256 string `json:"corpus_sha256"` - Executable invocationFile `json:"executable"` - Inputs []invocationFile `json:"inputs"` -} - -type invocationFile struct { - FileID string `json:"file_id"` - Path string `json:"path"` - SHA256 string `json:"sha256"` - Bytes int64 `json:"bytes"` -} - -type InputFile struct { - FileID string - Path string -} - -// InputPaths returns the exact canonical input paths in the evaluator-authored -// invocation. Self-test commands use it to prove that their typed file flags -// consume the complete argument set, rather than relying on Issue to bind -// inputs they never opened or interpreted. -func InputPaths() ([]string, error) { - invocation, err := readInvocation() - if err != nil { - return nil, err - } - paths := make([]string, len(invocation.Inputs)) - for index, input := range invocation.Inputs { - paths[index] = input.Path - } - slices.Sort(paths) - return paths, nil -} - -// Issue verifies the evaluator-authored invocation, the compiled capability -// and profile, every executed case, the running binary, and every file input. -// It returns no receipt if any positive or hostile case is missing or fails. -func Issue(expectedCapabilityID string, profile Profile, results []CaseResult) (Receipt, error) { - if !idPattern.MatchString(expectedCapabilityID) { - return Receipt{}, fmt.Errorf("invalid compiled self-test capability ID %q", expectedCapabilityID) - } - profileContract, err := profile.Contract() - if err != nil { - return Receipt{}, err - } - invocation, err := readInvocation() - if err != nil { - return Receipt{}, err - } - if invocation.CapabilityID != expectedCapabilityID { - return Receipt{}, fmt.Errorf("compiled self-test capability %q does not match E0 invocation %q", expectedCapabilityID, invocation.CapabilityID) - } - if invocation.Contract != profileContract { - return Receipt{}, fmt.Errorf("compiled self-test profile %+v does not match E0 contract %+v", profileContract, invocation.Contract) - } - positive, hostile, err := countCases(profile, results) - if err != nil { - return Receipt{}, err - } - executable, err := describeExecutable(invocation.Executable) - if err != nil { - return Receipt{}, err - } - inputs := make([]TestedFilePin, len(invocation.Inputs)) - for index, input := range invocation.Inputs { - pin, err := describeInvocationFile(input) - if err != nil { - return Receipt{}, fmt.Errorf("self-test input %q: %w", input.FileID, err) - } - inputs[index] = pin - } - slices.SortFunc(inputs, func(left, right TestedFilePin) int { return strings.Compare(left.FileID, right.FileID) }) - receipt := Receipt{ - SchemaVersion: ReceiptSchema, CapabilityID: expectedCapabilityID, - ProfileID: profileContract.ProfileID, ProfileSHA256: profileContract.ProfileSHA256, - CorpusSHA256: invocation.CorpusSHA256, Positive: positive, Hostile: hostile, - TestedExecutable: executable, TestedInputs: inputs, - } - if err := receipt.Validate(); err != nil { - return Receipt{}, err - } - return receipt, nil -} - -func (p Profile) Contract() (Contract, error) { - if !idPattern.MatchString(p.ProfileID) { - return Contract{}, fmt.Errorf("invalid self-test profile ID %q", p.ProfileID) - } - if err := validateCaseIDs("positive", p.PositiveCases); err != nil { - return Contract{}, err - } - if err := validateCaseIDs("hostile", p.HostileCases); err != nil { - return Contract{}, err - } - seen := make(map[string]struct{}, len(p.PositiveCases)+len(p.HostileCases)) - for _, id := range append(append([]string(nil), p.PositiveCases...), p.HostileCases...) { - if _, duplicate := seen[id]; duplicate { - return Contract{}, fmt.Errorf("self-test case %q appears in both profiles or repeats", id) - } - seen[id] = struct{}{} - } - digest, err := canonicalDigest(p) - if err != nil { - return Contract{}, err - } - return Contract{ProfileID: p.ProfileID, ProfileSHA256: digest, PositiveCases: uint32(len(p.PositiveCases)), HostileCases: uint32(len(p.HostileCases))}, nil -} - -func (r Receipt) Validate() error { - if r.SchemaVersion != ReceiptSchema || !idPattern.MatchString(r.CapabilityID) || !idPattern.MatchString(r.ProfileID) || !hex64.MatchString(r.ProfileSHA256) || !hex64.MatchString(r.CorpusSHA256) { - return errors.New("invalid E0 self-test receipt identity") - } - if err := validateCounts("positive", r.Positive); err != nil { - return err - } - if err := validateCounts("hostile", r.Hostile); err != nil { - return err - } - if err := r.TestedExecutable.validate(); err != nil { - return fmt.Errorf("invalid tested executable: %w", err) - } - if len(r.TestedInputs) == 0 { - return errors.New("self-test receipt must bind at least one input") - } - for index, input := range r.TestedInputs { - if err := input.validate(); err != nil { - return fmt.Errorf("invalid tested input: %w", err) - } - if input.FileID == r.TestedExecutable.FileID { - return errors.New("self-test receipt repeats executable as an input") - } - if index > 0 && r.TestedInputs[index-1].FileID >= input.FileID { - return errors.New("self-test receipt inputs must be canonical sorted unique file IDs") - } - } - return nil -} - -// BuildInvocationEnvironment is a test/interop helper. It derives all digests -// from live files and returns the exact reserved environment value used by the -// evaluator; callers cannot provide claimed file hashes. -func BuildInvocationEnvironment(capabilityID string, profile Profile, executable InputFile, inputs []InputFile, corpusFileIDs []string) (string, error) { - contract, err := profile.Contract() - if err != nil { - return "", err - } - executablePin, err := describeInputFile(executable) - if err != nil { - return "", err - } - inputPins := make([]invocationFile, len(inputs)) - byID := make(map[string]invocationFile, len(inputs)) - for index, input := range inputs { - pin, err := describeInputFile(input) - if err != nil { - return "", err - } - if _, duplicate := byID[pin.FileID]; duplicate { - return "", fmt.Errorf("repeated self-test input file %q", pin.FileID) - } - byID[pin.FileID] = pin - inputPins[index] = pin - } - slices.SortFunc(inputPins, func(left, right invocationFile) int { return strings.Compare(left.FileID, right.FileID) }) - corpusPins := make([]TestedFilePin, len(corpusFileIDs)) - seenCorpus := make(map[string]struct{}, len(corpusFileIDs)) - for index, id := range corpusFileIDs { - pin, exists := byID[id] - if !exists { - return "", fmt.Errorf("corpus file %q is not a self-test input", id) - } - if _, duplicate := seenCorpus[id]; duplicate { - return "", fmt.Errorf("repeated self-test corpus file %q", id) - } - seenCorpus[id] = struct{}{} - corpusPins[index] = TestedFilePin{FileID: pin.FileID, SHA256: pin.SHA256, Bytes: pin.Bytes} - } - if len(corpusPins) == 0 { - return "", errors.New("self-test corpus set is empty") - } - slices.SortFunc(corpusPins, func(left, right TestedFilePin) int { return strings.Compare(left.FileID, right.FileID) }) - corpusDigest, err := canonicalDigest(corpusPins) - if err != nil { - return "", err - } - invocation := invocation{ - SchemaVersion: InvocationSchema, CapabilityID: capabilityID, Contract: contract, CorpusSHA256: corpusDigest, - Executable: executablePin, Inputs: inputPins, - } - if err := invocation.validate(); err != nil { - return "", err - } - encoded, err := marshalCanonical(invocation) - if err != nil { - return "", err - } - return string(encoded), nil -} - -func readInvocation() (invocation, error) { - raw := os.Getenv(InvocationEnvironment) - if raw == "" { - return invocation{}, fmt.Errorf("%s is required for a formal E0 self-test", InvocationEnvironment) - } - if len(raw) > maximumInvocationSize { - return invocation{}, errors.New("E0 self-test invocation exceeds the bounded profile") - } - var value invocation - if err := decodeStrict([]byte(raw), &value); err != nil { - return invocation{}, fmt.Errorf("decode E0 self-test invocation: %w", err) - } - if err := value.validate(); err != nil { - return invocation{}, err - } - return value, nil -} - -func (i invocation) validate() error { - if i.SchemaVersion != InvocationSchema || !idPattern.MatchString(i.CapabilityID) || !hex64.MatchString(i.CorpusSHA256) { - return errors.New("invalid E0 self-test invocation identity") - } - if err := i.Contract.validate(); err != nil { - return err - } - if err := i.Executable.validate(); err != nil { - return fmt.Errorf("invalid E0 self-test executable: %w", err) - } - if len(i.Inputs) == 0 { - return errors.New("E0 external self-test invocation requires input pins") - } - for index, input := range i.Inputs { - if err := input.validate(); err != nil { - return fmt.Errorf("invalid E0 self-test input: %w", err) - } - if input.FileID == i.Executable.FileID { - return errors.New("E0 self-test invocation repeats executable as an input") - } - if index > 0 && i.Inputs[index-1].FileID >= input.FileID { - return errors.New("E0 self-test inputs must be canonical sorted unique file IDs") - } - } - return nil -} - -func (c Contract) validate() error { - if !idPattern.MatchString(c.ProfileID) || !hex64.MatchString(c.ProfileSHA256) || c.PositiveCases == 0 || c.HostileCases == 0 { - return errors.New("self-test contract requires a canonical profile, digest, and nonzero positive/hostile counts") - } - return nil -} - -func (f invocationFile) validate() error { - if !idPattern.MatchString(f.FileID) || !filepath.IsAbs(f.Path) || filepath.Clean(f.Path) != f.Path || !hex64.MatchString(f.SHA256) || f.Bytes < 0 { - return fmt.Errorf("invalid invocation file %q", f.FileID) - } - return nil -} - -func (f TestedFilePin) validate() error { - if !idPattern.MatchString(f.FileID) || !hex64.MatchString(f.SHA256) || f.Bytes < 0 { - return fmt.Errorf("invalid tested file %q", f.FileID) - } - return nil -} - -func validateCounts(label string, counts Counts) error { - if counts.Expected == 0 || counts.Executed != counts.Expected || counts.Passed != counts.Expected { - return fmt.Errorf("self-test %s counts must be nonzero and satisfy expected=executed=passed", label) - } - return nil -} - -func validateCaseIDs(label string, values []string) error { - if len(values) == 0 { - return fmt.Errorf("self-test %s profile must contain at least one case", label) - } - seen := make(map[string]struct{}, len(values)) - for _, id := range values { - if !idPattern.MatchString(id) { - return fmt.Errorf("invalid self-test %s case ID %q", label, id) - } - if _, duplicate := seen[id]; duplicate { - return fmt.Errorf("repeated self-test %s case ID %q", label, id) - } - seen[id] = struct{}{} - } - return nil -} - -func countCases(profile Profile, results []CaseResult) (Counts, Counts, error) { - positiveIDs := make(map[string]struct{}, len(profile.PositiveCases)) - hostileIDs := make(map[string]struct{}, len(profile.HostileCases)) - for _, id := range profile.PositiveCases { - positiveIDs[id] = struct{}{} - } - for _, id := range profile.HostileCases { - hostileIDs[id] = struct{}{} - } - positive := Counts{Expected: uint32(len(profile.PositiveCases))} - hostile := Counts{Expected: uint32(len(profile.HostileCases))} - seen := make(map[string]struct{}, len(results)) - for _, result := range results { - if _, duplicate := seen[result.ID]; duplicate { - return Counts{}, Counts{}, fmt.Errorf("self-test case %q executed more than once", result.ID) - } - seen[result.ID] = struct{}{} - switch { - case contains(positiveIDs, result.ID): - positive.Executed++ - if result.Passed { - positive.Passed++ - } - case contains(hostileIDs, result.ID): - hostile.Executed++ - if result.Passed { - hostile.Passed++ - } - default: - return Counts{}, Counts{}, fmt.Errorf("self-test executed unknown case %q", result.ID) - } - } - if positive.Executed != positive.Expected || hostile.Executed != hostile.Expected { - return Counts{}, Counts{}, fmt.Errorf("self-test executed positive=%d/%d hostile=%d/%d", positive.Executed, positive.Expected, hostile.Executed, hostile.Expected) - } - if positive.Passed != positive.Expected || hostile.Passed != hostile.Expected { - return Counts{}, Counts{}, fmt.Errorf("self-test passed positive=%d/%d hostile=%d/%d", positive.Passed, positive.Expected, hostile.Passed, hostile.Expected) - } - return positive, hostile, nil -} - -func contains(values map[string]struct{}, id string) bool { - _, ok := values[id] - return ok -} - -func describeExecutable(expected invocationFile) (TestedFilePin, error) { - path, err := os.Executable() - if err != nil { - return TestedFilePin{}, fmt.Errorf("locate self-test executable: %w", err) - } - actualInfo, err := os.Stat(path) - if err != nil { - return TestedFilePin{}, fmt.Errorf("inspect running self-test executable: %w", err) - } - expectedInfo, err := os.Stat(expected.Path) - if err != nil { - return TestedFilePin{}, fmt.Errorf("inspect E0 executable pin: %w", err) - } - if !os.SameFile(actualInfo, expectedInfo) { - return TestedFilePin{}, errors.New("running self-test executable is not the E0-pinned file") - } - return describeInvocationFile(expected) -} - -func describeInputFile(file InputFile) (invocationFile, error) { - if !idPattern.MatchString(file.FileID) || !filepath.IsAbs(file.Path) || filepath.Clean(file.Path) != file.Path { - return invocationFile{}, fmt.Errorf("invalid self-test input file %q", file.FileID) - } - digest, size, err := describeRegularFile(file.Path) - if err != nil { - return invocationFile{}, err - } - return invocationFile{FileID: file.FileID, Path: file.Path, SHA256: digest, Bytes: size}, nil -} - -func describeInvocationFile(expected invocationFile) (TestedFilePin, error) { - digest, size, err := describeRegularFile(expected.Path) - if err != nil { - return TestedFilePin{}, err - } - if digest != expected.SHA256 || size != expected.Bytes { - return TestedFilePin{}, fmt.Errorf("live descriptor %s/%d does not match E0 pin %s/%d", digest, size, expected.SHA256, expected.Bytes) - } - return TestedFilePin{FileID: expected.FileID, SHA256: digest, Bytes: size}, nil -} - -func describeRegularFile(path string) (string, int64, error) { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return "", 0, errors.New("self-test file path must be canonical and absolute") - } - info, err := os.Lstat(path) - if err != nil { - return "", 0, err - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return "", 0, errors.New("self-test file must be a regular non-symlink") - } - resolved, err := filepath.EvalSymlinks(path) - if err != nil { - return "", 0, err - } - if resolved != path { - return "", 0, errors.New("self-test file or one of its parents is symlinked") - } - file, err := os.Open(path) - if err != nil { - return "", 0, err - } - defer file.Close() - opened, err := file.Stat() - if err != nil { - return "", 0, err - } - if !os.SameFile(info, opened) || !opened.Mode().IsRegular() { - return "", 0, errors.New("self-test file changed while opening") - } - hash := sha256.New() - written, err := io.Copy(hash, file) - if err != nil { - return "", 0, err - } - if written != opened.Size() { - return "", 0, errors.New("self-test file size changed while hashing") - } - return hex.EncodeToString(hash.Sum(nil)), written, nil -} - -func canonicalDigest(value any) (string, error) { - encoded, err := marshalCanonical(value) - if err != nil { - return "", err - } - digest := sha256.Sum256(encoded) - return hex.EncodeToString(digest[:]), nil -} - -func marshalCanonical(value any) ([]byte, error) { - var initial bytes.Buffer - encoder := json.NewEncoder(&initial) - encoder.SetEscapeHTML(false) - if err := encoder.Encode(value); err != nil { - return nil, err - } - decoder := json.NewDecoder(bytes.NewReader(bytes.TrimSuffix(initial.Bytes(), []byte{'\n'}))) - decoder.UseNumber() - var normalized any - if err := decoder.Decode(&normalized); err != nil { - return nil, err - } - if err := requireEOF(decoder); err != nil { - return nil, err - } - var output bytes.Buffer - encoder = json.NewEncoder(&output) - encoder.SetEscapeHTML(false) - if err := encoder.Encode(normalized); err != nil { - return nil, err - } - return bytes.TrimSuffix(output.Bytes(), []byte{'\n'}), nil -} - -func decodeStrict(data []byte, target any) error { - trimmed := bytes.TrimSpace(data) - if len(trimmed) == 0 || trimmed[0] != '{' { - return errors.New("top-level JSON value must be an object") - } - if err := rejectDuplicateKeys(data); err != nil { - return err - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - return requireEOF(decoder) -} - -func rejectDuplicateKeys(data []byte) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.UseNumber() - if err := scanValue(decoder, "$"); err != nil { - return err - } - return requireEOF(decoder) -} - -func scanValue(decoder *json.Decoder, path string) error { - token, err := decoder.Token() - if err != nil { - return err - } - delimiter, ok := token.(json.Delim) - if !ok { - return nil - } - switch delimiter { - case '{': - seen := make(map[string]struct{}) - for decoder.More() { - keyToken, err := decoder.Token() - if err != nil { - return err - } - key, ok := keyToken.(string) - if !ok { - return fmt.Errorf("object key at %s is not a string", path) - } - if _, duplicate := seen[key]; duplicate { - return fmt.Errorf("duplicate JSON object key %q at %s", key, path) - } - seen[key] = struct{}{} - if err := scanValue(decoder, path+"."+key); err != nil { - return err - } - } - closing, err := decoder.Token() - if err != nil || closing != json.Delim('}') { - return errors.New("invalid JSON object closing delimiter") - } - case '[': - for index := 0; decoder.More(); index++ { - if err := scanValue(decoder, fmt.Sprintf("%s[%d]", path, index)); err != nil { - return err - } - } - closing, err := decoder.Token() - if err != nil || closing != json.Delim(']') { - return errors.New("invalid JSON array closing delimiter") - } - default: - return fmt.Errorf("unexpected JSON delimiter %q", delimiter) - } - return nil -} - -func requireEOF(decoder *json.Decoder) error { - var trailing json.RawMessage - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - if err == nil { - return errors.New("trailing JSON value") - } - return err - } - return nil -} diff --git a/internal/evaluation/e0selftest/e0selftest_test.go b/internal/evaluation/e0selftest/e0selftest_test.go deleted file mode 100644 index 697c1c1..0000000 --- a/internal/evaluation/e0selftest/e0selftest_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package e0selftest - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestIssueBindsEvaluatorInvocationCasesAndFiles(t *testing.T) { - profile := Profile{ProfileID: "profile", PositiveCases: []string{"accept"}, HostileCases: []string{"reject"}} - contract, err := profile.Contract() - if err != nil { - t.Fatal(err) - } - if contract.ProfileSHA256 != "254f4b991b9ae62cc380181d4d0daba8d7019b312c620c42340e2bbea1451ad8" { - t.Fatalf("profile digest = %s", contract.ProfileSHA256) - } - input := filepath.Join(t.TempDir(), "corpus.json") - if err := os.WriteFile(input, []byte(`{"case":"fixed"}`), 0o600); err != nil { - t.Fatal(err) - } - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - environment, err := BuildInvocationEnvironment( - "capability", profile, - InputFile{FileID: "worker", Path: executable}, - []InputFile{{FileID: "corpus", Path: input}}, - []string{"corpus"}, - ) - if err != nil { - t.Fatal(err) - } - t.Setenv(InvocationEnvironment, environment) - receipt, err := Issue("capability", profile, []CaseResult{{ID: "accept", Passed: true}, {ID: "reject", Passed: true}}) - if err != nil { - t.Fatal(err) - } - if err := receipt.Validate(); err != nil { - t.Fatal(err) - } - if receipt.CapabilityID != "capability" || receipt.Positive.Executed != 1 || receipt.Hostile.Executed != 1 || len(receipt.TestedInputs) != 1 || receipt.TestedInputs[0].FileID != "corpus" { - t.Fatalf("receipt = %+v", receipt) - } -} - -func TestIssueRejectsRelabelMissingFailedAndDuplicateCases(t *testing.T) { - profile := Profile{ProfileID: "profile", PositiveCases: []string{"accept"}, HostileCases: []string{"reject"}} - input := filepath.Join(t.TempDir(), "corpus.json") - if err := os.WriteFile(input, []byte(`{"case":"fixed"}`), 0o600); err != nil { - t.Fatal(err) - } - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - environment, err := BuildInvocationEnvironment( - "capability", profile, - InputFile{FileID: "worker", Path: executable}, - []InputFile{{FileID: "corpus", Path: input}}, - []string{"corpus"}, - ) - if err != nil { - t.Fatal(err) - } - t.Setenv(InvocationEnvironment, environment) - for name, test := range map[string]struct { - capability string - results []CaseResult - }{ - "relabel": {capability: "other", results: []CaseResult{{ID: "accept", Passed: true}, {ID: "reject", Passed: true}}}, - "missing": {capability: "capability", results: []CaseResult{{ID: "accept", Passed: true}}}, - "failed": {capability: "capability", results: []CaseResult{{ID: "accept", Passed: true}, {ID: "reject", Passed: false}}}, - "duplicate": {capability: "capability", results: []CaseResult{{ID: "accept", Passed: true}, {ID: "accept", Passed: true}, {ID: "reject", Passed: true}}}, - } { - t.Run(name, func(t *testing.T) { - if _, err := Issue(test.capability, profile, test.results); err == nil { - t.Fatal("invalid self-test issued a receipt") - } - }) - } -} - -func TestIssueRejectsMutatedInputAndMalformedInvocation(t *testing.T) { - profile := Profile{ProfileID: "profile", PositiveCases: []string{"accept"}, HostileCases: []string{"reject"}} - input := filepath.Join(t.TempDir(), "corpus.json") - if err := os.WriteFile(input, []byte(`{"case":"fixed"}`), 0o600); err != nil { - t.Fatal(err) - } - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - environment, err := BuildInvocationEnvironment( - "capability", profile, - InputFile{FileID: "worker", Path: executable}, - []InputFile{{FileID: "corpus", Path: input}}, - []string{"corpus"}, - ) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(input, []byte(`{"case":"mutated"}`), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv(InvocationEnvironment, environment) - if _, err := Issue("capability", profile, []CaseResult{{ID: "accept", Passed: true}, {ID: "reject", Passed: true}}); err == nil || !strings.Contains(err.Error(), "does not match E0 pin") { - t.Fatalf("mutated input error = %v", err) - } - t.Setenv(InvocationEnvironment, `{"schema_version":"one","schema_version":"two"}`) - if _, err := Issue("capability", profile, []CaseResult{{ID: "accept", Passed: true}, {ID: "reject", Passed: true}}); err == nil || !strings.Contains(err.Error(), "duplicate") { - t.Fatalf("duplicate invocation error = %v", err) - } -} diff --git a/internal/evaluation/gatewaytransport/accounting.go b/internal/evaluation/gatewaytransport/accounting.go deleted file mode 100644 index 0a8cf35..0000000 --- a/internal/evaluation/gatewaytransport/accounting.go +++ /dev/null @@ -1,52 +0,0 @@ -package gatewaytransport - -import "math" - -// WriteAccounting is untrusted Gateway diagnostic accounting for -// the atomic staging boundary. It excludes payload CAS and physical backend -// bytes, which evaluation workers must account for separately. -type WriteAccounting struct { - Profile string `json:"profile"` - Available bool `json:"available"` - UnavailableReason string `json:"unavailable_reason,omitempty"` - ByteMethod string `json:"byte_method"` - ObjectLedgerSHA256 string `json:"object_ledger_sha256,omitempty"` - Categories []WriteCategoryAccounting `json:"categories"` -} - -type WriteCategoryAccounting struct { - Category string `json:"category"` - AttemptedWrites uint64 `json:"attempted_writes"` - AttemptedBytes uint64 `json:"attempted_bytes"` - AttemptedNewWrites uint64 `json:"attempted_new_writes"` - AttemptedNewBytes uint64 `json:"attempted_new_bytes"` - AttemptedReplacementWrites uint64 `json:"attempted_replacement_writes"` - AttemptedReplacementBytes uint64 `json:"attempted_replacement_bytes"` - AttemptedSameValueWrites uint64 `json:"attempted_same_value_writes"` - AttemptedSameValueBytes uint64 `json:"attempted_same_value_bytes"` - AttemptedDeleteWrites uint64 `json:"attempted_delete_writes"` - AttemptedDeleteBytes uint64 `json:"attempted_delete_bytes"` - NewlyPersistedWrites uint64 `json:"newly_persisted_writes"` - GrossNewBytes uint64 `json:"gross_new_bytes"` - NewWrites uint64 `json:"new_writes"` - NewBytes uint64 `json:"new_bytes"` - ReplacedWrites uint64 `json:"replaced_writes"` - ReplacementNewBytes uint64 `json:"replacement_new_bytes"` - ReplacementReclaimedBytes uint64 `json:"replacement_reclaimed_bytes"` - SameValueWrites uint64 `json:"same_value_writes"` - DeletedWrites uint64 `json:"deleted_writes"` - DeletedReclaimedBytes uint64 `json:"deleted_reclaimed_bytes"` - ReclaimedBytes uint64 `json:"reclaimed_bytes"` - NetBytes int64 `json:"net_bytes"` -} - -func sumAmounts(want uint64, values ...uint64) bool { - var total uint64 - for _, value := range values { - if value > math.MaxUint64-total { - return false - } - total += value - } - return want == total -} diff --git a/internal/evaluation/gatewaytransport/authentication.go b/internal/evaluation/gatewaytransport/authentication.go deleted file mode 100644 index e6bff90..0000000 --- a/internal/evaluation/gatewaytransport/authentication.go +++ /dev/null @@ -1,206 +0,0 @@ -package gatewaytransport - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/protocol" - cid "github.com/ipfs/go-cid" -) - -const ( - AuthenticationPhaseProfile = "gateway.authentication-phases/0" - AuthenticationDurableBoundary = "gateway.evaluation-authentication-kv-atomic/0" - authenticationHeaderPrefix = "X-Malt-Authentication-" -) - -// These observations describe one evaluation request. They are neither -// authentication evidence nor a root-publication or local trust decision. -type PhaseMetrics struct { - ValidationAndStageNS uint64 - PersistNS uint64 - ReceiptNS uint64 -} -type CandidateResponse struct { - Candidate protocol.AuthenticationCandidate - WireBytes uint64 -} -type BatchResponse struct { - Receipt protocol.AuthenticationReceipt - RequestWireBytes uint64 - ResponseWireBytes uint64 - RequestEncodingNS uint64 - ResponseVerifyNS uint64 - Idempotent bool - Gateway PhaseMetrics - WriteAccounting WriteAccounting - WriteAccountingWireBytes uint64 -} - -func (c *Client) AuthenticationCandidate(ctx context.Context, root cid.Cid) (CandidateResponse, error) { - if _, _, err := maltcid.ParseRoot(root); err != nil { - return CandidateResponse{}, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("/v1/evaluation/authentication/candidates/"+url.PathEscape(root.String())), nil) - if err != nil { - return CandidateResponse{}, err - } - response, err := c.instanceHTTP.Do(req) - if err != nil { - return CandidateResponse{}, err - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return CandidateResponse{}, c.responseError(response) - } - if err := requireJSONNoStore(response); err != nil { - return CandidateResponse{}, err - } - raw, err := readBounded(response.Body, min(c.maxJSONResponseBytes, int64(protocol.MaxVerificationJSONBytes)), "evaluation authentication candidate") - if err != nil { - return CandidateResponse{}, err - } - candidate, err := protocol.DecodeAuthenticationCandidate(raw) - if err != nil { - return CandidateResponse{}, err - } - if candidate.Root != root.String() { - return CandidateResponse{}, fmt.Errorf("candidate differs from caller-selected Root") - } - return CandidateResponse{Candidate: candidate, WireBytes: uint64(len(raw))}, nil -} - -// SubmitAuthenticationBatch validates the operational receipt against the -// exact submitted bytes/Roots/transaction. Local acceptance remains separate. -func (c *Client) SubmitAuthenticationBatch(ctx context.Context, batch protocol.AuthenticationBatch) (BatchResponse, error) { - started := time.Now() - if err := batch.Validate(); err != nil { - return BatchResponse{}, err - } - raw, err := json.Marshal(batch) - if err != nil { - return BatchResponse{}, err - } - if len(raw) > protocol.MaxVerificationJSONBytes { - return BatchResponse{}, fmt.Errorf("authentication batch exceeds protocol limit") - } - encodingNS := uint64(time.Since(started).Nanoseconds()) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/v1/evaluation/authentication/batches"), bytes.NewReader(raw)) - if err != nil { - return BatchResponse{}, err - } - request.Header.Set("Content-Type", "application/json") - response, err := c.instanceHTTP.Do(request) - if err != nil { - return BatchResponse{}, err - } - defer response.Body.Close() - if response.StatusCode != http.StatusCreated { - return BatchResponse{}, c.responseError(response) - } - if err := requireJSONNoStore(response); err != nil { - return BatchResponse{}, err - } - for name, want := range map[string]string{"Phase-Profile": AuthenticationPhaseProfile, "Publication": "separate", "Trust": "client-owned", "Durable-Boundary": AuthenticationDurableBoundary} { - if value, err := oneHeader(response.Header, authenticationHeaderPrefix+name); err != nil || value != want { - return BatchResponse{}, fmt.Errorf("unexpected authentication %s boundary", name) - } - } - phases, err := parseAuthenticationPhases(response.Header) - if err != nil { - return BatchResponse{}, err - } - accounting, accountingBytes, err := parseAuthenticationAccounting(response.Header) - if err != nil { - return BatchResponse{}, err - } - idempotent, err := oneHeader(response.Header, authenticationHeaderPrefix+"Idempotent") - if err != nil || idempotent != "true" && idempotent != "false" { - return BatchResponse{}, fmt.Errorf("missing canonical authentication idempotency classification") - } - encodedReceipt, err := readBounded(response.Body, min(c.maxJSONResponseBytes, int64(protocol.MaxVerificationJSONBytes)), "evaluation authentication receipt") - if err != nil { - return BatchResponse{}, err - } - verifyStarted := time.Now() - receipt, err := protocol.DecodeAuthenticationReceipt(encodedReceipt) - if err != nil { - return BatchResponse{}, err - } - if err := receipt.Validate(batch); err != nil { - return BatchResponse{}, err - } - if receipt.DurableBoundary != AuthenticationDurableBoundary { - return BatchResponse{}, fmt.Errorf("receipt differs from evaluation durable boundary") - } - return BatchResponse{Receipt: receipt, RequestWireBytes: uint64(len(raw)), ResponseWireBytes: uint64(len(encodedReceipt)), RequestEncodingNS: encodingNS, ResponseVerifyNS: uint64(time.Since(verifyStarted).Nanoseconds()), Idempotent: idempotent == "true", Gateway: phases, WriteAccounting: accounting, WriteAccountingWireBytes: accountingBytes}, nil -} - -func oneHeader(header http.Header, name string) (string, error) { - values := header.Values(name) - if len(values) != 1 || values[0] == "" || strings.TrimSpace(values[0]) != values[0] { - return "", fmt.Errorf("missing single canonical %s header", name) - } - return values[0], nil -} -func parseAuthenticationPhases(header http.Header) (PhaseMetrics, error) { - var result PhaseMetrics - for name, out := range map[string]*uint64{"Validation-And-Stage-Nanos": &result.ValidationAndStageNS, "Persist-Nanos": &result.PersistNS, "Receipt-Nanos": &result.ReceiptNS} { - raw, err := oneHeader(header, authenticationHeaderPrefix+name) - if err != nil { - return result, err - } - n, err := strconv.ParseUint(raw, 10, 64) - if err != nil || strconv.FormatUint(n, 10) != raw { - return result, fmt.Errorf("invalid canonical %s", name) - } - *out = n - } - names := map[string]bool{} - for _, value := range header.Values("Server-Timing") { - for _, item := range strings.Split(value, ",") { - names[strings.TrimSpace(strings.SplitN(item, ";", 2)[0])] = true - } - } - for _, name := range []string{"authentication-validate-stage", "persist", "receipt"} { - if !names[name] { - return result, fmt.Errorf("Server-Timing omits %s", name) - } - } - return result, nil -} -func parseAuthenticationAccounting(header http.Header) (WriteAccounting, uint64, error) { - var value WriteAccounting - encoded, err := oneHeader(header, authenticationHeaderPrefix+"Write-Accounting") - if err != nil { - return value, 0, err - } - if len(encoded) > base64.RawURLEncoding.EncodedLen(4096) { - return value, 0, fmt.Errorf("authentication accounting header exceeds limit") - } - raw, err := base64.RawURLEncoding.Strict().DecodeString(encoded) - if err != nil || len(raw) == 0 || len(raw) > 4096 { - return value, 0, fmt.Errorf("accounting is not bounded raw URL base64") - } - if err := rejectDuplicateJSONKeys(raw); err != nil { - return value, 0, err - } - if err := decodeStrict(raw, &value); err != nil { - return value, 0, err - } - if err := validateWriteAccounting(value); err != nil { - return value, 0, err - } - // Availability is explicit. A measurement consumer must require it; the - // writer correctness oracle intentionally makes no durable byte claim. - return value, uint64(len(raw)), nil -} diff --git a/internal/evaluation/gatewaytransport/authentication_test.go b/internal/evaluation/gatewaytransport/authentication_test.go deleted file mode 100644 index 5aaa2c0..0000000 --- a/internal/evaluation/gatewaytransport/authentication_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package gatewaytransport_test - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "io" - "math" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - "github.com/dewebprotocol/malt-core/protocol" - cid "github.com/ipfs/go-cid" -) - -func TestEvaluationAuthenticationCandidateBindsSelectedRootAndCurrentWire(t *testing.T) { - candidate := validBootstrapCandidate(t).Candidate - for _, mode := range []string{"valid", "wrong-root", "old-profile", "duplicate-key", "cacheable"} { - t.Run(mode, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.URL.Path != "/v1/evaluation/authentication/candidates/"+candidate.Root || r.Header.Get(gatewaytransport.InstanceTokenHeader) != testInstanceToken || r.Header.Get(gatewaytransport.BootstrapAuthorizationTokenHeader) != "" { - t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - value := candidate - switch mode { - case "wrong-root": - value.Root = mustRawCID(t, "wrong-root").String() - case "old-profile": - value.Profile = "malt.update-view/v2" - case "cacheable": - w.Header().Set("Cache-Control", "public, max-age=3600") - } - raw, err := json.Marshal(value) - if err != nil { - t.Error(err) - return - } - if mode == "duplicate-key" { - raw = bytes.Replace(raw, []byte(`"profile":`), []byte(`"profile":"malt.authentication/2","profile":`), 1) - } - _, _ = w.Write(raw) - })) - defer server.Close() - result, err := newEvaluationClient(t, server.URL, 0).AuthenticationCandidate(t.Context(), cid.MustParse(candidate.Root)) - if mode == "valid" { - if err != nil || result.Candidate.Root != candidate.Root || result.WireBytes == 0 { - t.Fatalf("result=%+v err=%v", result, err) - } - } else if err == nil { - t.Fatal("hostile candidate accepted") - } - }) - } -} - -func TestEvaluationAuthenticationBatchRequiresExactReceiptAndDiagnostics(t *testing.T) { - candidate := validBootstrapCandidate(t).Candidate - batch := protocol.AuthenticationBatch{Profile: protocol.AuthenticationBatchProfile, TransactionID: "typed-write-1", Base: candidate.Root, Root: candidate.Root, Candidates: []protocol.AuthenticationCandidate{candidate}} - digest, err := batch.Digest() - if err != nil { - t.Fatal(err) - } - baseReceipt := protocol.AuthenticationReceipt{Profile: protocol.AuthenticationReceiptProfile, TransactionID: batch.TransactionID, Base: batch.Base, Root: batch.Root, Digest: digest, DurableBoundary: gatewaytransport.AuthenticationDurableBoundary} - for _, mode := range []string{"valid", "retry", "receipt-root", "receipt-base", "receipt-id", "receipt-digest", "receipt-profile", "receipt-boundary", "duplicate-receipt", "header-boundary", "missing-phase", "duplicate-phase", "noncanonical-nanos", "missing-timing", "accounting-profile", "accounting-overflow", "duplicate-accounting", "cacheable"} { - t.Run(mode, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/v1/evaluation/authentication/batches" || r.Header.Get(gatewaytransport.InstanceTokenHeader) != testInstanceToken || r.Header.Get(gatewaytransport.BootstrapAuthorizationTokenHeader) != "" { - t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) - } - raw, err := io.ReadAll(r.Body) - if err != nil { - t.Error(err) - return - } - submitted, err := protocol.DecodeAuthenticationBatch(raw) - if err != nil { - t.Error(err) - return - } - if submittedDigest, err := submitted.Digest(); err != nil || submittedDigest != digest { - t.Errorf("changed batch digest=%s err=%v", submittedDigest, err) - } - receipt := baseReceipt - accounting := validWriteAccounting() - prefix := "X-Malt-Authentication-" - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - for k, v := range map[string]string{"Phase-Profile": gatewaytransport.AuthenticationPhaseProfile, "Publication": "separate", "Trust": "client-owned", "Durable-Boundary": gatewaytransport.AuthenticationDurableBoundary, "Validation-And-Stage-Nanos": "11", "Persist-Nanos": "12", "Receipt-Nanos": "13", "Idempotent": "false"} { - w.Header().Set(prefix+k, v) - } - w.Header().Set("Server-Timing", "authentication-validate-stage;dur=0.001, persist;dur=0.001, receipt;dur=0.001") - switch mode { - case "retry": - w.Header().Set(prefix+"Idempotent", "true") - case "receipt-root": - receipt.Root = mustRawCID(t, "root").String() - case "receipt-base": - receipt.Base = mustRawCID(t, "base").String() - case "receipt-id": - receipt.TransactionID = "another-write" - case "receipt-digest": - receipt.Digest = strings.Repeat("0", 64) - case "receipt-profile": - receipt.Profile = "malt.materialization-receipt/v2" - case "receipt-boundary": - receipt.DurableBoundary = "volatile" - case "header-boundary": - w.Header().Set(prefix+"Durable-Boundary", "volatile") - case "missing-phase": - w.Header().Del(prefix + "Persist-Nanos") - case "duplicate-phase": - w.Header().Add(prefix+"Persist-Nanos", "12") - case "noncanonical-nanos": - w.Header().Set(prefix+"Persist-Nanos", "012") - case "missing-timing": - w.Header().Del("Server-Timing") - case "accounting-profile": - accounting.Profile = "gateway.client-root-write-accounting/v2" - case "accounting-overflow": - accounting.Categories[0].AttemptedReplacementWrites = math.MaxUint64 - accounting.Categories[0].AttemptedWrites = 0 - case "cacheable": - w.Header().Set("Cache-Control", "public") - } - accountingJSON, err := json.Marshal(accounting) - if err != nil { - t.Error(err) - return - } - if mode == "duplicate-accounting" { - accountingJSON = bytes.Replace(accountingJSON, []byte(`"available":`), []byte(`"available":true,"available":`), 1) - } - w.Header().Set(prefix+"Write-Accounting", base64.RawURLEncoding.EncodeToString(accountingJSON)) - receiptJSON, err := json.Marshal(receipt) - if err != nil { - t.Error(err) - return - } - if mode == "duplicate-receipt" { - receiptJSON = bytes.Replace(receiptJSON, []byte(`"root":`), []byte(`"root":"ignored","root":`), 1) - } - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(receiptJSON) - })) - defer server.Close() - result, err := newEvaluationClient(t, server.URL, 0).SubmitAuthenticationBatch(t.Context(), batch) - if mode == "valid" || mode == "retry" { - if err != nil || result.Receipt != baseReceipt || result.Gateway.ValidationAndStageNS != 11 || result.Gateway.PersistNS != 12 || result.Gateway.ReceiptNS != 13 || result.WriteAccountingWireBytes == 0 || result.RequestWireBytes == 0 || result.ResponseWireBytes == 0 || result.Idempotent != (mode == "retry") { - t.Fatalf("result=%+v err=%v", result, err) - } - } else if err == nil { - t.Fatal("hostile receipt/diagnostics accepted") - } - }) - } -} diff --git a/internal/evaluation/gatewaytransport/client.go b/internal/evaluation/gatewaytransport/client.go deleted file mode 100644 index 445df16..0000000 --- a/internal/evaluation/gatewaytransport/client.go +++ /dev/null @@ -1,562 +0,0 @@ -// Package gatewaytransport owns evaluator-only Gateway HTTP capabilities. -// Its instance client injects the disposable evaluation identity into every -// request, while bootstrap authorization uses a separate client so the -// controller-only secret is never combined with the instance token. -package gatewaytransport - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "net" - "net/http" - "net/url" - "path" - "strings" - "time" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - "github.com/dewebprotocol/malt-client/merkledag" - "github.com/dewebprotocol/malt-client/transport" - "github.com/dewebprotocol/malt-core/protocol" - cid "github.com/ipfs/go-cid" -) - -const ( - InstanceTokenHeader = "X-Malt-Evaluation-Instance-Token" - BootstrapAuthorizationTokenHeader = "X-Malt-Evaluation-Bootstrap-Authorization" - BootstrapProfile = "gateway.evaluation-authentication-bootstrap/0" - - defaultMaxJSONResponseBytes int64 = 96 << 20 - defaultMaxBlobResponseBytes int64 = 64 << 20 - defaultMaxErrorResponseBytes int64 = 1 << 20 - - WriteAccountingProfile = "gateway.authentication-write-accounting/0" - WriteByteMethod = "durable-kv-key-plus-value-bytes/v2" -) - -var writeCategories = []string{"arctable-arcset-records", "arctable-lineage-metadata", "root-version-metadata"} - -type Options struct { - BaseURL string - InstanceToken string - HTTPClient *http.Client - MaxJSONResponseBytes int64 - MaxBlobResponseBytes int64 - MaxErrorResponseBytes int64 -} - -// Client is an evaluator-only control and data-plane transport. Product code -// must use package transport instead. -type Client struct { - baseURL *url.URL - plainHTTP *http.Client - instanceHTTP *http.Client - maxJSONResponseBytes int64 - maxBlobResponseBytes int64 - maxErrorResponseBytes int64 -} - -// Health is the strict evaluation projection of Gateway health. The public -// transport health value deliberately omits these evaluator-only capabilities. -type Health struct { - Status string `json:"status"` - EvaluationInstanceToken string `json:"evaluation_instance_token,omitempty"` - KVBackend string `json:"kv_backend,omitempty"` - BlobBackend string `json:"blob_backend,omitempty"` - ArcTableMode string `json:"arctable_mode,omitempty"` - CommitmentProfile string `json:"default_commitment_backend,omitempty"` - CommitmentBackends string `json:"commitment_backends,omitempty"` - EvaluationCASWriteAccounting string `json:"evaluation_cas_write_accounting,omitempty"` - EvaluationCASWriteIsolation string `json:"evaluation_cas_write_isolation,omitempty"` - AuthenticationWriteAccounting string `json:"evaluation_authentication_write_accounting,omitempty"` - AuthenticationExactAcceptance string `json:"evaluation_authentication_exact_acceptance,omitempty"` - EvaluationAuthenticationBootstrap string `json:"evaluation_authentication_bootstrap,omitempty"` - EvaluationRQ3FlatPrefix string `json:"evaluation_rq3_flat_prefix,omitempty"` - EvaluationRQ3FlatPrefixLayout string `json:"evaluation_rq3_flat_prefix_layout,omitempty"` - EvaluationRQ3FlatPrefixStorageScope string `json:"evaluation_rq3_flat_prefix_storage_scope,omitempty"` - EvaluationRQ3FlatPrefixLookupIndex string `json:"evaluation_rq3_flat_prefix_lookup_index,omitempty"` - EvaluationRQ3FlatPrefixCommitmentTreatment string `json:"evaluation_rq3_flat_prefix_commitment_treatment,omitempty"` - EvaluationRQ3FlatPrefixFSKVMode string `json:"evaluation_rq3_flat_prefix_fskv_mode,omitempty"` - EvaluationRQ3FlatPrefixCheckpoint string `json:"evaluation_rq3_flat_prefix_checkpoint,omitempty"` - EvaluationRQ3FlatPrefixMaterializationCache string `json:"evaluation_rq3_flat_prefix_materialization_cache,omitempty"` -} - -type BootstrapObject struct { - OperationID string - Candidate protocol.AuthenticationCandidate -} -type BootstrapResult struct { - Root cid.Cid - ValidationAndStageNanos uint64 - PersistNanos uint64 - WriteAccounting WriteAccounting -} - -// RawCASGetter adapts the evaluator-only raw endpoint to merkledag.BlockGetter. -// The Merkle DAG verifier, not this transport, binds returned bytes to the CID. -type RawCASGetter struct { - Client *Client -} - -func (g RawCASGetter) Get(ctx context.Context, key cid.Cid) ([]byte, error) { - if g.Client == nil { - return nil, fmt.Errorf("evaluation raw CAS client is nil") - } - return g.Client.GetRawCAS(ctx, key) -} - -func New(options Options) (*Client, error) { - baseURL, err := parseBaseURL(options.BaseURL) - if err != nil { - return nil, err - } - if !canonicalLowerSHA256(options.InstanceToken) { - return nil, fmt.Errorf("evaluation instance token must be a canonical SHA-256") - } - if baseURL.Scheme != "https" && !(baseURL.Scheme == "http" && isLoopbackHost(baseURL.Hostname())) { - return nil, fmt.Errorf("evaluation instance token requires HTTPS or a loopback HTTP Gateway origin") - } - maxJSON, err := responseLimit(options.MaxJSONResponseBytes, defaultMaxJSONResponseBytes, "JSON") - if err != nil { - return nil, err - } - maxBlob, err := responseLimit(options.MaxBlobResponseBytes, defaultMaxBlobResponseBytes, "blob") - if err != nil { - return nil, err - } - maxError, err := responseLimit(options.MaxErrorResponseBytes, defaultMaxErrorResponseBytes, "error") - if err != nil { - return nil, err - } - baseHTTP := options.HTTPClient - if baseHTTP == nil { - baseHTTP = &http.Client{Timeout: 5 * time.Minute} - } - plainHTTP := cloneWithoutRedirects(baseHTTP, "evaluation Gateway") - instanceHTTP := cloneWithoutRedirects(baseHTTP, "evaluation instance") - roundTripper := instanceHTTP.Transport - if roundTripper == nil { - roundTripper = http.DefaultTransport - } - instanceHTTP.Transport = instanceTokenTransport{base: roundTripper, token: options.InstanceToken, allowedBase: baseURL} - return &Client{ - baseURL: baseURL, plainHTTP: plainHTTP, instanceHTTP: instanceHTTP, - maxJSONResponseBytes: maxJSON, maxBlobResponseBytes: maxBlob, maxErrorResponseBytes: maxError, - }, nil -} - -// InstanceHTTPClient returns a copy of the no-redirect HTTP client that injects -// the evaluation instance token. Evaluation adapters pass it to the ordinary -// untrusted transport so current authentication and CAS requests are bound -// to the same disposable Gateway instance. -func (c *Client) InstanceHTTPClient() *http.Client { - if c == nil || c.instanceHTTP == nil { - return nil - } - copyClient := *c.instanceHTTP - return ©Client -} - -func (c *Client) Health(ctx context.Context) (*Health, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("/healthz"), nil) - if err != nil { - return nil, err - } - var health Health - if err := c.executeJSON(c.instanceHTTP, request, &health); err != nil { - return nil, err - } - return &health, nil -} - -// BootstrapEvaluationObject installs one first-campaign typed candidate with -// the controller-only bootstrap secret. It intentionally uses plainHTTP, so -// the instance token is not attached to this distinct authorization boundary. -func (c *Client) BootstrapEvaluationObject(ctx context.Context, bootstrapAuthorizationToken string, value BootstrapObject) (BootstrapResult, error) { - if !canonicalLowerSHA256(bootstrapAuthorizationToken) { - return BootstrapResult{}, fmt.Errorf("evaluation bootstrap authorization token must be a canonical SHA-256") - } - if value.OperationID == "" || value.Candidate.Previous != "" { - return BootstrapResult{}, fmt.Errorf("bootstrap requires a fresh candidate and operation ID") - } - if err := value.Candidate.Validate(); err != nil { - return BootstrapResult{}, err - } - expectedRoot, err := cid.Decode(value.Candidate.Root) - if err != nil { - return BootstrapResult{}, err - } - requestBody := struct { - Profile string `json:"profile"` - OperationID string `json:"operation_id"` - Candidate protocol.AuthenticationCandidate `json:"candidate"` - }{BootstrapProfile, value.OperationID, value.Candidate} - encoded, err := json.Marshal(requestBody) - if err != nil { - return BootstrapResult{}, err - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/v1/evaluation/authentication/bootstrap"), bytes.NewReader(encoded)) - if err != nil { - return BootstrapResult{}, err - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set(BootstrapAuthorizationTokenHeader, bootstrapAuthorizationToken) - response, err := c.plainHTTP.Do(request) - if err != nil { - return BootstrapResult{}, err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return BootstrapResult{}, c.responseError(response) - } - if err := requireJSONNoStore(response); err != nil { - return BootstrapResult{}, err - } - raw, err := readBounded(response.Body, c.maxJSONResponseBytes, "Gateway evaluation bootstrap response") - if err != nil { - return BootstrapResult{}, err - } - if err := rejectDuplicateJSONKeys(raw); err != nil { - return BootstrapResult{}, fmt.Errorf("Gateway evaluation bootstrap response: %w", err) - } - var wire struct { - Profile string `json:"profile"` - Root string `json:"root"` - ValidationAndStageNanos uint64 `json:"validation_and_stage_nanos"` - PersistNanos uint64 `json:"persist_nanos"` - WriteAccounting WriteAccounting `json:"write_accounting"` - } - if err := decodeStrict(raw, &wire); err != nil { - return BootstrapResult{}, fmt.Errorf("decode Gateway evaluation bootstrap response: %w", err) - } - root, err := cid.Parse(wire.Root) - if err != nil || !root.Equals(expectedRoot) || wire.Profile != BootstrapProfile { - return BootstrapResult{}, fmt.Errorf("Gateway evaluation bootstrap returned a mismatched exact Root") - } - if err := validateWriteAccounting(wire.WriteAccounting); err != nil || !wire.WriteAccounting.Available || - strings.TrimSpace(wire.WriteAccounting.UnavailableReason) != "" { - return BootstrapResult{}, fmt.Errorf("Gateway evaluation bootstrap returned invalid exact write accounting") - } - return BootstrapResult{ - Root: root, ValidationAndStageNanos: wire.ValidationAndStageNanos, PersistNanos: wire.PersistNanos, WriteAccounting: wire.WriteAccounting, - }, nil -} - -// GetRawCAS returns bounded bytes without hashing them. Only the Direct-CAS -// evaluator uses this method so its local verifier can own and time the single -// CID hash. -func (c *Client) GetRawCAS(ctx context.Context, key cid.Cid) ([]byte, error) { - if !key.Defined() { - return nil, fmt.Errorf("Gateway CAS key is undefined") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("/v1/cas/"+url.PathEscape(key.String())), nil) - if err != nil { - return nil, err - } - response, err := c.instanceHTTP.Do(request) - if err != nil { - return nil, err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - responseErr := c.responseError(response) - if response.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("%w: %w", clientcas.ErrNotFound, responseErr) - } - return nil, responseErr - } - return readBounded(response.Body, c.maxBlobResponseBytes, "Gateway evaluation CAS body") -} - -// ReadCAR invokes the evaluator-only selective-CAR route. Callers must pass the -// returned bytes to merkledag.VerifyMerkleDAGCARRead before using any result. -func (c *Client) ReadCAR(ctx context.Context, encodedRequest []byte) ([]byte, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/v1/compat/merkledag/car/read"), bytes.NewReader(encodedRequest)) - if err != nil { - return nil, err - } - request.Header.Set("Content-Type", "application/json") - response, err := c.instanceHTTP.Do(request) - if err != nil { - return nil, err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return nil, c.responseError(response) - } - if got := response.Header.Get("Content-Type"); got != merkledag.MerkleDAGCARReadMediaType { - return nil, fmt.Errorf("unsupported Gateway Merkle-DAG CAR content type %q", got) - } - return readBounded(response.Body, c.maxBlobResponseBytes, "Gateway Merkle-DAG CAR response") -} - -type instanceTokenTransport struct { - base http.RoundTripper - token string - allowedBase *url.URL -} - -func (t instanceTokenTransport) RoundTrip(request *http.Request) (*http.Response, error) { - if !withinBaseURL(request.URL, t.allowedBase) || - request.Host != "" && !strings.EqualFold(request.Host, t.allowedBase.Host) { - return nil, fmt.Errorf("refusing to send evaluation instance token outside configured Gateway base URL") - } - copyRequest := request.Clone(request.Context()) - copyRequest.Header = request.Header.Clone() - copyRequest.Header.Set(InstanceTokenHeader, t.token) - return t.base.RoundTrip(copyRequest) -} - -func cloneWithoutRedirects(source *http.Client, label string) *http.Client { - copyClient := *source - copyClient.CheckRedirect = func(next *http.Request, _ []*http.Request) error { - return fmt.Errorf("refusing %s redirect to %s", label, next.URL.Redacted()) - } - return ©Client -} - -func parseBaseURL(raw string) (*url.URL, error) { - parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(raw), "/")) - if err != nil || parsed.Scheme != "http" && parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || - parsed.Opaque != "" || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" || - parsed.Path != "" && (parsed.Path[0] != '/' || path.Clean(parsed.Path) != parsed.Path) { - return nil, fmt.Errorf("evaluation Gateway base URL must be an absolute HTTP(S) URL without query or fragment") - } - return parsed, nil -} - -func withinBaseURL(candidate, base *url.URL) bool { - if candidate == nil || base == nil || !strings.EqualFold(candidate.Scheme, base.Scheme) || - !strings.EqualFold(candidate.Host, base.Host) || candidate.User != nil || - candidate.Opaque != "" || candidate.RawPath != "" || - candidate.Path != "" && (candidate.Path[0] != '/' || path.Clean(candidate.Path) != candidate.Path) { - return false - } - basePath := strings.TrimSuffix(base.Path, "/") - return basePath == "" || candidate.Path == basePath || strings.HasPrefix(candidate.Path, basePath+"/") -} - -func (c *Client) endpoint(route string) string { - copyURL := *c.baseURL - copyURL.Path = path.Join(copyURL.Path, route) - if strings.HasSuffix(route, "/") && !strings.HasSuffix(copyURL.Path, "/") { - copyURL.Path += "/" - } - return copyURL.String() -} - -func (c *Client) executeJSON(client *http.Client, request *http.Request, output any) error { - response, err := client.Do(request) - if err != nil { - return err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return c.responseError(response) - } - data, err := readBounded(response.Body, c.maxJSONResponseBytes, "Gateway evaluation JSON response") - if err != nil { - return err - } - if err := rejectDuplicateJSONKeys(data); err != nil { - return fmt.Errorf("decode Gateway evaluation JSON response: %w", err) - } - decoder := json.NewDecoder(bytes.NewReader(data)) - if err := decoder.Decode(output); err != nil { - return fmt.Errorf("decode Gateway evaluation JSON response: %w", err) - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - if err == nil { - return fmt.Errorf("decode Gateway evaluation JSON response: trailing JSON") - } - return fmt.Errorf("decode Gateway evaluation JSON response: %w", err) - } - return nil -} - -func (c *Client) responseError(response *http.Response) error { - data, err := readBounded(response.Body, c.maxErrorResponseBytes, "Gateway evaluation error response") - if err != nil { - return err - } - var body struct { - Error string `json:"error,omitempty"` - Message string `json:"message,omitempty"` - } - message := strings.TrimSpace(string(data)) - if json.Unmarshal(data, &body) == nil { - if body.Message != "" { - message = body.Message - } else if body.Error != "" { - message = body.Error - } - } - if message == "" { - message = http.StatusText(response.StatusCode) - } - return &transport.Error{StatusCode: response.StatusCode, Message: message} -} - -func requireJSONNoStore(response *http.Response) error { - mediaType := strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0]) - if mediaType != "application/json" { - return fmt.Errorf("Gateway evaluation bootstrap response has unsupported Content-Type %q", response.Header.Get("Content-Type")) - } - for _, directive := range strings.Split(strings.ToLower(response.Header.Get("Cache-Control")), ",") { - if strings.TrimSpace(directive) == "no-store" { - return nil - } - } - return fmt.Errorf("Gateway evaluation bootstrap response is missing Cache-Control: no-store") -} - -func validateWriteAccounting(accounting WriteAccounting) error { - if accounting.Profile != WriteAccountingProfile || accounting.ByteMethod != WriteByteMethod { - return fmt.Errorf("Gateway authentication write accounting has unsupported profile/method") - } - if !accounting.Available { - if accounting.UnavailableReason == "" || accounting.ObjectLedgerSHA256 != "" || len(accounting.Categories) != 0 { - return fmt.Errorf("unavailable Gateway authentication write accounting carries measurements") - } - return nil - } - if accounting.UnavailableReason != "" || !canonicalLowerSHA256(accounting.ObjectLedgerSHA256) || - len(accounting.Categories) != len(writeCategories) { - return fmt.Errorf("available Gateway authentication write accounting is incomplete") - } - for index, category := range accounting.Categories { - if category.Category != writeCategories[index] || !sumAmounts(category.AttemptedWrites, category.AttemptedNewWrites, category.AttemptedReplacementWrites, category.AttemptedSameValueWrites, category.AttemptedDeleteWrites) || - !sumAmounts(category.AttemptedBytes, category.AttemptedNewBytes, category.AttemptedReplacementBytes, category.AttemptedSameValueBytes, category.AttemptedDeleteBytes) || category.GrossNewBytes > math.MaxInt64 || category.ReclaimedBytes > math.MaxInt64 || category.NetBytes != int64(category.GrossNewBytes)-int64(category.ReclaimedBytes) || - !sumAmounts(category.NewlyPersistedWrites, category.NewWrites, category.ReplacedWrites) || - !sumAmounts(category.GrossNewBytes, category.NewBytes, category.ReplacementNewBytes) || - !sumAmounts(category.ReclaimedBytes, category.ReplacementReclaimedBytes, category.DeletedReclaimedBytes) { - return fmt.Errorf("Gateway authentication write accounting category %d is inconsistent", index) - } - } - return nil -} - -func canonicalLowerSHA256(value string) bool { - if len(value) != 64 { - return false - } - for _, character := range value { - if (character < '0' || character > '9') && (character < 'a' || character > 'f') { - return false - } - } - return true -} - -func isLoopbackHost(host string) bool { - if strings.EqualFold(strings.TrimSpace(host), "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - -func responseLimit(value, fallback int64, kind string) (int64, error) { - if value < 0 { - return 0, fmt.Errorf("maximum %s response size must not be negative", kind) - } - if value == 0 { - return fallback, nil - } - return value, nil -} - -func readBounded(reader io.Reader, limit int64, description string) ([]byte, error) { - if limit <= 0 { - return nil, fmt.Errorf("%s limit is invalid", description) - } - readLimit := limit - if limit < int64(^uint64(0)>>1) { - readLimit++ - } - data, err := io.ReadAll(io.LimitReader(reader, readLimit)) - if err != nil { - return nil, err - } - if int64(len(data)) > limit { - return nil, fmt.Errorf("%s exceeds %d-byte limit", description, limit) - } - return data, nil -} - -func decodeStrict(data []byte, output any) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(output); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - if err == nil { - return fmt.Errorf("trailing JSON") - } - return err - } - return nil -} - -func rejectDuplicateJSONKeys(data []byte) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.UseNumber() - if err := scanJSONValue(decoder, "$"); err != nil { - return err - } - if token, err := decoder.Token(); !errors.Is(err, io.EOF) { - if err == nil { - return fmt.Errorf("trailing token %v", token) - } - return err - } - return nil -} - -func scanJSONValue(decoder *json.Decoder, location string) error { - token, err := decoder.Token() - if err != nil { - return err - } - delimiter, ok := token.(json.Delim) - if !ok { - return nil - } - switch delimiter { - case '{': - seen := make(map[string]struct{}) - for decoder.More() { - rawKey, err := decoder.Token() - if err != nil { - return err - } - key, ok := rawKey.(string) - if !ok { - return fmt.Errorf("%s has a non-string object key", location) - } - if _, duplicate := seen[key]; duplicate { - return fmt.Errorf("%s has duplicate key %q", location, key) - } - seen[key] = struct{}{} - if err := scanJSONValue(decoder, location+"."+key); err != nil { - return err - } - } - case '[': - for index := 0; decoder.More(); index++ { - if err := scanJSONValue(decoder, fmt.Sprintf("%s[%d]", location, index)); err != nil { - return err - } - } - default: - return fmt.Errorf("%s has unexpected delimiter %q", location, delimiter) - } - _, err = decoder.Token() - return err -} diff --git a/internal/evaluation/gatewaytransport/client_test.go b/internal/evaluation/gatewaytransport/client_test.go deleted file mode 100644 index ba75dff..0000000 --- a/internal/evaluation/gatewaytransport/client_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package gatewaytransport_test - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - "github.com/dewebprotocol/malt-client/merkledag" - "github.com/dewebprotocol/malt-client/transport" - "github.com/dewebprotocol/malt-core/auth/commitment/kzg" - "github.com/dewebprotocol/malt-core/derivation" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/protocol" - "github.com/dewebprotocol/malt-core/sdk/authentication" - cid "github.com/ipfs/go-cid" -) - -const testInstanceToken = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - -func TestNewRequiresCanonicalTokenAndProtectedOrigin(t *testing.T) { - for _, test := range []struct { - name string - baseURL string - token string - want string - }{ - {name: "invalid token", baseURL: "https://gateway.example", token: "not-a-token", want: "canonical SHA-256"}, - {name: "plaintext remote", baseURL: "http://192.0.2.1:8080", token: testInstanceToken, want: "HTTPS or a loopback"}, - {name: "query is forbidden", baseURL: "https://gateway.example/prefix?secret=true", token: testInstanceToken, want: "without query"}, - } { - t.Run(test.name, func(t *testing.T) { - if _, err := gatewaytransport.New(gatewaytransport.Options{BaseURL: test.baseURL, InstanceToken: test.token}); err == nil || - !strings.Contains(err.Error(), test.want) { - t.Fatalf("New error = %v, want substring %q", err, test.want) - } - }) - } -} - -func TestInstanceClientSupportsGatewayPrefixAndRejectsOtherTargets(t *testing.T) { - var prefixRequests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.URL.Path != "/gateway/healthz" { - http.NotFound(response, request) - return - } - prefixRequests.Add(1) - if request.Header.Get(gatewaytransport.InstanceTokenHeader) != testInstanceToken { - t.Errorf("instance token = %q", request.Header.Get(gatewaytransport.InstanceTokenHeader)) - } - _, _ = io.WriteString(response, `{"status":"ok"}`) - })) - defer server.Close() - evaluation := newEvaluationClient(t, server.URL+"/gateway", 0) - if _, err := evaluation.Health(t.Context()); err != nil { - t.Fatal(err) - } - - other := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.Header.Get(gatewaytransport.InstanceTokenHeader) != "" { - t.Errorf("other origin received instance token") - } - response.WriteHeader(http.StatusNoContent) - })) - defer other.Close() - request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, other.URL, nil) - if err != nil { - t.Fatal(err) - } - if _, err := evaluation.InstanceHTTPClient().Do(request); err == nil || !strings.Contains(err.Error(), "outside configured Gateway base URL") { - t.Fatalf("cross-origin request error = %v", err) - } - if prefixRequests.Load() != 1 { - t.Fatalf("prefix requests = %d", prefixRequests.Load()) - } -} - -func TestInstanceClientRejectsPrefixTraversalAndHostOverride(t *testing.T) { - var received atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - received.Add(1) - if request.Header.Get(gatewaytransport.InstanceTokenHeader) != "" { - t.Errorf("escaped request leaked instance token") - } - response.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - evaluation := newEvaluationClient(t, server.URL+"/gateway", 0) - - tests := []struct { - name string - url string - host string - }{ - {name: "dot traversal", url: server.URL + "/gateway/../outside"}, - {name: "encoded traversal", url: server.URL + "/gateway/%2e%2e/outside"}, - {name: "encoded slash", url: server.URL + "/gateway/%2Foutside"}, - {name: "duplicate slash", url: server.URL + "/gateway//outside"}, - {name: "host override", url: server.URL + "/gateway/healthz", host: "other.example"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, test.url, nil) - if err != nil { - t.Fatal(err) - } - request.Host = test.host - if _, err := evaluation.InstanceHTTPClient().Do(request); err == nil || - !strings.Contains(err.Error(), "outside configured Gateway base URL") { - t.Fatalf("request error = %v", err) - } - }) - } - if received.Load() != 0 { - t.Fatalf("prefix escape reached Gateway %d times", received.Load()) - } -} - -func TestInstanceClientAndHealthBindEveryRequestToToken(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - requests.Add(1) - if got := request.Header.Get(gatewaytransport.InstanceTokenHeader); got != testInstanceToken { - t.Errorf("instance token = %q", got) - } - switch request.URL.Path { - case "/healthz": - _, _ = io.WriteString(response, `{"status":"ok","evaluation_instance_token":"`+testInstanceToken+`"}`) - case "/v1/client-roots": - response.WriteHeader(http.StatusNoContent) - default: - http.NotFound(response, request) - } - })) - defer server.Close() - - evaluation := newEvaluationClient(t, server.URL, 0) - health, err := evaluation.Health(t.Context()) - if err != nil { - t.Fatal(err) - } - if health.Status != "ok" || health.EvaluationInstanceToken != testInstanceToken { - t.Fatalf("health = %#v", health) - } - request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/v1/client-roots", nil) - if err != nil { - t.Fatal(err) - } - response, err := evaluation.InstanceHTTPClient().Do(request) - if err != nil { - t.Fatal(err) - } - _ = response.Body.Close() - if response.StatusCode != http.StatusNoContent || requests.Load() != 2 { - t.Fatalf("generic instance request = status %d requests %d", response.StatusCode, requests.Load()) - } -} - -func TestHealthRejectsDuplicateOrTrailingFields(t *testing.T) { - for _, body := range []string{ - `{"status":"ok","status":"bad"}`, - `{"status":"ok"}{"status":"bad"}`, - } { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(response, body) - })) - evaluation := newEvaluationClient(t, server.URL, 0) - _, err := evaluation.Health(t.Context()) - server.Close() - if err == nil { - t.Fatalf("Health accepted %s", body) - } - } -} - -func TestHealthAllowsUnrelatedGatewayCapabilities(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(response, `{"service":"malt-gateway","status":"ok","future_capability":"value"}`) - })) - defer server.Close() - health, err := newEvaluationClient(t, server.URL, 0).Health(t.Context()) - if err != nil || health.Status != "ok" { - t.Fatalf("Health = %#v err=%v", health, err) - } -} - -func TestBootstrapUsesDistinctAuthorizationAndBindsRootAccounting(t *testing.T) { - object := validBootstrapCandidate(t) - bootstrapToken := strings.Repeat("b", 64) - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodPost || request.URL.Path != "/v1/evaluation/authentication/bootstrap" { - t.Fatalf("request = %s %s", request.Method, request.URL.Path) - } - if request.Header.Get(gatewaytransport.BootstrapAuthorizationTokenHeader) != bootstrapToken || - request.Header.Get(gatewaytransport.InstanceTokenHeader) != "" || - request.Header.Get("Content-Type") != "application/json" { - t.Fatalf("request headers = %#v", request.Header) - } - var body struct { - Profile string `json:"profile"` - OperationID string `json:"operation_id"` - Candidate protocol.AuthenticationCandidate `json:"candidate"` - } - decoder := json.NewDecoder(request.Body) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&body); err != nil { - t.Fatal(err) - } - if body.Profile != gatewaytransport.BootstrapProfile || body.OperationID != object.OperationID || body.Candidate.Root != object.Candidate.Root || len(body.Candidate.State.Entries) != 1 || string(body.Candidate.State.Entries[0].Label) != "payload" { - t.Fatalf("bootstrap request = %#v", body) - } - response.Header().Set("Content-Type", "application/json") - response.Header().Set("Cache-Control", "private, no-store") - _ = json.NewEncoder(response).Encode(map[string]any{ - "profile": gatewaytransport.BootstrapProfile, "root": object.Candidate.Root, - "validation_and_stage_nanos": uint64(11), "persist_nanos": uint64(22), - "write_accounting": validWriteAccounting(), - }) - })) - defer server.Close() - - result, err := newEvaluationClient(t, server.URL, 0).BootstrapEvaluationObject(t.Context(), bootstrapToken, object) - if err != nil { - t.Fatal(err) - } - if !result.Root.Equals(cid.MustParse(object.Candidate.Root)) || result.ValidationAndStageNanos != 11 || result.PersistNanos != 22 || - !result.WriteAccounting.Available { - t.Fatalf("bootstrap result = %#v", result) - } -} - -func TestBootstrapRejectsRedirectWithoutLeakingAuthorization(t *testing.T) { - object := validBootstrapCandidate(t) - bootstrapToken := strings.Repeat("b", 64) - var targetRequests atomic.Int32 - target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - targetRequests.Add(1) - if got := request.Header.Get(gatewaytransport.BootstrapAuthorizationTokenHeader); got != "" { - t.Errorf("redirect target received bootstrap authorization %q", got) - } - response.WriteHeader(http.StatusNoContent) - })) - defer target.Close() - source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.Header.Get(gatewaytransport.BootstrapAuthorizationTokenHeader) != bootstrapToken { - t.Errorf("source did not receive bootstrap authorization") - } - http.Redirect(response, request, target.URL, http.StatusTemporaryRedirect) - })) - defer source.Close() - - _, err := newEvaluationClient(t, source.URL, 0).BootstrapEvaluationObject(t.Context(), bootstrapToken, object) - if err == nil || !strings.Contains(err.Error(), "refusing evaluation Gateway redirect") { - t.Fatalf("bootstrap redirect error = %v", err) - } - if targetRequests.Load() != 0 { - t.Fatalf("redirect target received %d requests", targetRequests.Load()) - } -} - -func TestRawCASDefersCIDVerificationButKeepsTokenAndBounds(t *testing.T) { - requested := mustRawCID(t, "expected") - hostile := []byte("wrong") - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet || request.URL.Path != "/v1/cas/"+requested.String() { - http.NotFound(response, request) - return - } - if request.Header.Get(gatewaytransport.InstanceTokenHeader) != testInstanceToken { - http.Error(response, "missing token", http.StatusUnauthorized) - return - } - _, _ = response.Write(hostile) - })) - defer server.Close() - - raw, err := newEvaluationClient(t, server.URL, int64(len(hostile))).GetRawCAS(t.Context(), requested) - if err != nil || string(raw) != string(hostile) { - t.Fatalf("raw CAS = %q err=%v", raw, err) - } - _, err = newEvaluationClient(t, server.URL, int64(len(hostile)-1)).GetRawCAS(t.Context(), requested) - if err == nil || !strings.Contains(err.Error(), "exceeds") { - t.Fatalf("bounded raw CAS error = %v", err) - } -} - -func TestRawCASClassifiesOnlyNotFound(t *testing.T) { - requested := mustRawCID(t, "missing") - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { - http.Error(response, "missing", http.StatusNotFound) - })) - defer server.Close() - - _, err := newEvaluationClient(t, server.URL, 0).GetRawCAS(t.Context(), requested) - if !errors.Is(err, clientcas.ErrNotFound) { - t.Fatalf("raw CAS error = %v, want cas.ErrNotFound", err) - } - var apiErr *transport.Error - if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound { - t.Fatalf("raw CAS error = %T %v, want structured 404", err, err) - } -} - -func TestReadCARRequiresTokenExactMediaTypeAndBound(t *testing.T) { - const requestBody = `{"profile":"merkledag.read/v0alpha1"}` - responseBody := []byte("untrusted CAR bytes") - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodPost || request.URL.Path != "/v1/compat/merkledag/car/read" || - request.Header.Get("Content-Type") != "application/json" || - request.Header.Get(gatewaytransport.InstanceTokenHeader) != testInstanceToken { - http.Error(response, "bad request", http.StatusBadRequest) - return - } - body, _ := io.ReadAll(request.Body) - if string(body) != requestBody { - t.Errorf("CAR request = %q", body) - } - response.Header().Set("Content-Type", merkledag.MerkleDAGCARReadMediaType) - _, _ = response.Write(responseBody) - })) - defer server.Close() - - got, err := newEvaluationClient(t, server.URL, int64(len(responseBody))).ReadCAR(t.Context(), []byte(requestBody)) - if err != nil || string(got) != string(responseBody) { - t.Fatalf("CAR response = %q err=%v", got, err) - } - _, err = newEvaluationClient(t, server.URL, int64(len(responseBody)-1)).ReadCAR(t.Context(), []byte(requestBody)) - if err == nil || !strings.Contains(err.Error(), "exceeds") { - t.Fatalf("bounded CAR error = %v", err) - } -} - -func TestReadCARRejectsWrongMediaType(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { - response.Header().Set("Content-Type", "application/octet-stream") - _, _ = response.Write([]byte("not a CAR")) - })) - defer server.Close() - _, err := newEvaluationClient(t, server.URL, 0).ReadCAR(t.Context(), []byte(`{}`)) - if err == nil || !strings.Contains(err.Error(), "content type") { - t.Fatalf("wrong media type error = %v", err) - } -} - -func newEvaluationClient(t *testing.T, baseURL string, maxBlob int64) *gatewaytransport.Client { - t.Helper() - client, err := gatewaytransport.New(gatewaytransport.Options{ - BaseURL: baseURL, InstanceToken: testInstanceToken, MaxBlobResponseBytes: maxBlob, - }) - if err != nil { - t.Fatal(err) - } - return client -} - -func validBootstrapCandidate(t *testing.T) gatewaytransport.BootstrapObject { - t.Helper() - scheme, err := kzg.NewScheme() - if err != nil { - t.Fatal(err) - } - profiles := engine.NewRegistry() - if err := profiles.Register(scheme); err != nil { - t.Fatal(err) - } - e := engine.New(profiles) - candidate, err := authentication.Prepare(t.Context(), e, engine.State{Descriptor: maltcid.RootDescriptor{Layout: maltcid.Prefix, DerivationProfile: uint8(derivation.SHA256), Profile: maltcid.KZG4096}, Entries: []engine.Entry{{Label: []byte("payload"), Target: mustRawCID(t, "bootstrap-target")}}}) - if err != nil { - t.Fatal(err) - } - return gatewaytransport.BootstrapObject{OperationID: "bootstrap-1", Candidate: candidate} -} - -func validWriteAccounting() gatewaytransport.WriteAccounting { - categories := make([]gatewaytransport.WriteCategoryAccounting, 0, 3) - for _, category := range []string{"arctable-arcset-records", "arctable-lineage-metadata", "root-version-metadata"} { - categories = append(categories, gatewaytransport.WriteCategoryAccounting{ - Category: category, AttemptedWrites: 1, AttemptedBytes: 2, - AttemptedNewWrites: 1, AttemptedNewBytes: 2, - NewlyPersistedWrites: 1, GrossNewBytes: 2, - NewWrites: 1, NewBytes: 2, NetBytes: 2, - }) - } - return gatewaytransport.WriteAccounting{ - Profile: "gateway.authentication-write-accounting/0", Available: true, - ByteMethod: "durable-kv-key-plus-value-bytes/v2", - ObjectLedgerSHA256: strings.Repeat("c", 64), Categories: categories, - } -} - -func mustRawCID(t *testing.T, value string) cid.Cid { - t.Helper() - key, err := clientcas.CIDForBlock(clientcas.Block{Data: []byte(value), Codec: cid.Raw}) - if err != nil { - t.Fatal(err) - } - return key -} diff --git a/internal/evaluation/gatewaytransport/flatmap_test.go b/internal/evaluation/gatewaytransport/flatmap_test.go deleted file mode 100644 index 5a86f5f..0000000 --- a/internal/evaluation/gatewaytransport/flatmap_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package gatewaytransport_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - cid "github.com/ipfs/go-cid" -) - -func TestApplyEvaluationFlatPrefixUsesSecretOnlyAndRequiresArcSetOnlyAccounting(t *testing.T) { - root := cid.MustParse(validBootstrapCandidate(t).Candidate.Root) - target := mustRawCID(t, "flat-target") - secret := strings.Repeat("b", 64) - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodPost || request.URL.Path != "/v1/evaluation/rq3/flat-prefix" { - t.Fatalf("request = %s %s", request.Method, request.URL.Path) - } - if request.Header.Get(gatewaytransport.BootstrapAuthorizationTokenHeader) != secret || - request.Header.Get(gatewaytransport.InstanceTokenHeader) != "" { - t.Fatalf("flat-prefix authorization headers = %#v", request.Header) - } - var body struct { - Profile string `json:"profile"` - OperationID string `json:"operation_id"` - Initial bool `json:"initial"` - Changes []struct { - Label []byte `json:"label"` - After string `json:"after"` - } `json:"changes"` - } - decoder := json.NewDecoder(request.Body) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&body); err != nil { - t.Fatal(err) - } - if body.Profile != gatewaytransport.FlatPrefixProfile || body.OperationID != "snapshot" || - !body.Initial || len(body.Changes) != 1 || string(body.Changes[0].Label) != "rq3/file-sha256/abc" || - body.Changes[0].After != target.String() { - t.Fatalf("flat-prefix request = %#v", body) - } - response.Header().Set("Content-Type", "application/json") - response.Header().Set("Cache-Control", "private, no-store") - _ = json.NewEncoder(response).Encode(map[string]any{ - "profile": gatewaytransport.FlatPrefixProfile, "root": root.String(), - "validation_and_stage_nanos": uint64(11), "persist_nanos": uint64(13), - "write_accounting": flatWriteAccounting(), - }) - })) - defer server.Close() - - result, err := newEvaluationClient(t, server.URL, 0).ApplyEvaluationFlatPrefix(t.Context(), secret, gatewaytransport.FlatPrefixMutation{ - OperationID: "snapshot", Initial: true, - Changes: []gatewaytransport.FlatPrefixChange{{Label: []byte("rq3/file-sha256/abc"), After: target}}, - }) - if err != nil { - t.Fatal(err) - } - if !result.Root.Equals(root) || result.ValidationAndStageNanos != 11 || result.PersistNanos != 13 || - result.WriteAccounting.Categories[0].GrossNewBytes == 0 { - t.Fatalf("flat-prefix result = %#v", result) - } -} - -func flatWriteAccounting() gatewaytransport.WriteAccounting { - value := validWriteAccounting() - value.Categories[1] = gatewaytransport.WriteCategoryAccounting{Category: "arctable-lineage-metadata"} - value.Categories[2] = gatewaytransport.WriteCategoryAccounting{Category: "root-version-metadata"} - return value -} - -func TestApplyEvaluationFlatPrefixRejectsNonzeroDisabledCategoryCounter(t *testing.T) { - root := cid.MustParse(validBootstrapCandidate(t).Candidate.Root) - target := mustRawCID(t, "flat-target") - secret := strings.Repeat("c", 64) - accounting := flatWriteAccounting() - accounting.Categories[1].AttemptedWrites = 1 - accounting.Categories[1].AttemptedSameValueWrites = 1 - accounting.Categories[1].AttemptedBytes = 17 - accounting.Categories[1].AttemptedSameValueBytes = 17 - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { - response.Header().Set("Content-Type", "application/json") - response.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(response).Encode(map[string]any{ - "profile": gatewaytransport.FlatPrefixProfile, "root": root.String(), - "validation_and_stage_nanos": uint64(1), "persist_nanos": uint64(1), "write_accounting": accounting, - }) - })) - defer server.Close() - - _, err := newEvaluationClient(t, server.URL, 0).ApplyEvaluationFlatPrefix(t.Context(), secret, gatewaytransport.FlatPrefixMutation{ - OperationID: "snapshot", Initial: true, - Changes: []gatewaytransport.FlatPrefixChange{{Label: []byte("rq3/file-sha256/abc"), After: target}}, - }) - if err == nil || !strings.Contains(err.Error(), "non-ArcSet metadata") { - t.Fatalf("disabled-category attempted same-value counter error = %v", err) - } -} diff --git a/internal/evaluation/gatewaytransport/flatprefix.go b/internal/evaluation/gatewaytransport/flatprefix.go deleted file mode 100644 index 83c22fb..0000000 --- a/internal/evaluation/gatewaytransport/flatprefix.go +++ /dev/null @@ -1,169 +0,0 @@ -package gatewaytransport - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "slices" - "strings" - - "github.com/dewebprotocol/malt-core/derivation" - "github.com/dewebprotocol/malt-core/maltcid" - cid "github.com/ipfs/go-cid" -) - -const FlatPrefixProfile = "gateway.evaluation-flat-prefix-mutation/1" - -type FlatPrefixMutation struct { - OperationID string - BaseRoot cid.Cid - Initial bool - Changes []FlatPrefixChange -} - -type FlatPrefixChange struct { - Label []byte - Before cid.Cid - After cid.Cid -} - -type FlatPrefixResult struct { - Root cid.Cid - ValidationAndStageNanos uint64 - PersistNanos uint64 - WriteAccounting WriteAccounting -} - -func (c *Client) ApplyEvaluationFlatPrefix(ctx context.Context, authorizationToken string, value FlatPrefixMutation) (FlatPrefixResult, error) { - if !canonicalLowerSHA256(authorizationToken) { - return FlatPrefixResult{}, fmt.Errorf("evaluation flat-prefix authorization token must be a canonical SHA-256") - } - if !validFlatPrefixOperationID(value.OperationID) || len(value.Changes) == 0 || len(value.Changes) > 65_536 { - return FlatPrefixResult{}, fmt.Errorf("evaluation flat-prefix mutation is incomplete") - } - if value.Initial == value.BaseRoot.Defined() { - return FlatPrefixResult{}, fmt.Errorf("evaluation flat-prefix base/initial relationship is invalid") - } - if value.BaseRoot.Defined() && (!currentFlatPrefixRoot(value.BaseRoot)) { - return FlatPrefixResult{}, fmt.Errorf("evaluation flat-prefix base is not a KZG Prefix/AA=1 Root") - } - type wireChange struct { - Label []byte `json:"label"` - Before string `json:"before,omitempty"` - After string `json:"after,omitempty"` - } - body := struct { - Profile string `json:"profile"` - OperationID string `json:"operation_id"` - BaseRoot string `json:"base_root,omitempty"` - Initial bool `json:"initial"` - Changes []wireChange `json:"changes"` - }{ - Profile: FlatPrefixProfile, OperationID: value.OperationID, Initial: value.Initial, - Changes: make([]wireChange, len(value.Changes)), - } - if value.BaseRoot.Defined() { - body.BaseRoot = value.BaseRoot.String() - } - paths := make([]string, len(value.Changes)) - for index, change := range value.Changes { - if len(change.Label) == 0 || (!change.Before.Defined() && !change.After.Defined()) || - (change.Before.Defined() && change.After.Defined() && change.Before.Equals(change.After)) || - value.Initial && (change.Before.Defined() || !change.After.Defined()) { - return FlatPrefixResult{}, fmt.Errorf("evaluation flat-prefix change %d is invalid", index) - } - paths[index] = string(change.Label) - body.Changes[index].Label = change.Label - if change.Before.Defined() { - body.Changes[index].Before = change.Before.String() - } - if change.After.Defined() { - body.Changes[index].After = change.After.String() - } - } - if !slices.IsSorted(paths) { - return FlatPrefixResult{}, fmt.Errorf("evaluation flat-prefix changes are not sorted") - } - for index := 1; index < len(paths); index++ { - if paths[index] == paths[index-1] { - return FlatPrefixResult{}, fmt.Errorf("evaluation flat-prefix changes repeat a path") - } - } - encoded, err := json.Marshal(body) - if err != nil { - return FlatPrefixResult{}, err - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/v1/evaluation/rq3/flat-prefix"), bytes.NewReader(encoded)) - if err != nil { - return FlatPrefixResult{}, err - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set(BootstrapAuthorizationTokenHeader, authorizationToken) - response, err := c.plainHTTP.Do(request) - if err != nil { - return FlatPrefixResult{}, err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return FlatPrefixResult{}, c.responseError(response) - } - if err := requireJSONNoStore(response); err != nil { - return FlatPrefixResult{}, err - } - raw, err := readBounded(response.Body, c.maxJSONResponseBytes, "Gateway evaluation flat-prefix response") - if err != nil { - return FlatPrefixResult{}, err - } - if err := rejectDuplicateJSONKeys(raw); err != nil { - return FlatPrefixResult{}, fmt.Errorf("Gateway evaluation flat-prefix response: %w", err) - } - var wire struct { - Profile string `json:"profile"` - Root string `json:"root"` - ValidationAndStageNanos uint64 `json:"validation_and_stage_nanos"` - PersistNanos uint64 `json:"persist_nanos"` - WriteAccounting WriteAccounting `json:"write_accounting"` - } - if err := decodeStrict(raw, &wire); err != nil { - return FlatPrefixResult{}, fmt.Errorf("decode Gateway evaluation flat-prefix response: %w", err) - } - root, err := cid.Parse(wire.Root) - if err != nil || wire.Profile != FlatPrefixProfile || !root.Defined() || - !currentFlatPrefixRoot(root) { - return FlatPrefixResult{}, fmt.Errorf("Gateway evaluation flat-prefix returned an invalid root") - } - if err := validateWriteAccounting(wire.WriteAccounting); err != nil || !wire.WriteAccounting.Available || - strings.TrimSpace(wire.WriteAccounting.UnavailableReason) != "" { - return FlatPrefixResult{}, fmt.Errorf("Gateway evaluation flat-prefix returned invalid exact write accounting") - } - if len(wire.WriteAccounting.Categories) != 3 || - wire.WriteAccounting.Categories[1] != (WriteCategoryAccounting{Category: "arctable-lineage-metadata"}) || - wire.WriteAccounting.Categories[2] != (WriteCategoryAccounting{Category: "root-version-metadata"}) { - return FlatPrefixResult{}, fmt.Errorf("Gateway evaluation flat-prefix persisted non-ArcSet metadata") - } - return FlatPrefixResult{ - Root: root, ValidationAndStageNanos: wire.ValidationAndStageNanos, PersistNanos: wire.PersistNanos, - WriteAccounting: wire.WriteAccounting, - }, nil -} - -func validFlatPrefixOperationID(value string) bool { - if value == "" || len(value) > 128 { - return false - } - for index, character := range value { - if character >= 0x61 && character <= 0x7a || character >= 0x30 && character <= 0x39 || - index > 0 && (character == 0x2e || character == 0x5f || character == 0x2d) { - continue - } - return false - } - return true -} - -func currentFlatPrefixRoot(root cid.Cid) bool { - d, _, err := maltcid.ParseRoot(root) - return err == nil && d.Layout == maltcid.Prefix && d.DerivationProfile == uint8(derivation.SHA256) && d.Profile == maltcid.KZG4096 -} diff --git a/internal/evaluation/machine/machine-descriptor.schema.json b/internal/evaluation/machine/machine-descriptor.schema.json deleted file mode 100644 index ffa3698..0000000 --- a/internal/evaluation/machine/machine-descriptor.schema.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://dewebprotocol.github.io/malt/schemas/malt-evaluation-machine-descriptor-v1.json", - "title": "MALT paper evaluation machine descriptor", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "id", "identity", "classification", "classification_evidence_source"], - "properties": { - "schema_version": {"const": "malt-evaluation-machine-descriptor/v1"}, - "id": {"type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$"}, - "identity": { - "type": "object", - "additionalProperties": false, - "required": ["os", "architecture", "cpu_model", "cpu_model_source", "board_model", "board_model_source", "logical_cores", "logical_cores_source", "memory_bytes", "memory_source"], - "properties": { - "os": {"type": "string", "minLength": 1, "maxLength": 512}, - "architecture": {"type": "string", "minLength": 1, "maxLength": 512}, - "cpu_model": {"type": "string", "minLength": 1, "maxLength": 512}, - "cpu_model_source": {"type": "string", "minLength": 1, "maxLength": 512}, - "board_model": {"type": "string", "minLength": 1, "maxLength": 512}, - "board_model_source": {"type": "string", "minLength": 1, "maxLength": 512}, - "logical_cores": {"type": "integer", "minimum": 1, "maximum": 4096}, - "logical_cores_source": {"const": "go-runtime:NumCPU"}, - "memory_bytes": {"type": "integer", "minimum": 67108864}, - "memory_source": {"const": "linux:/proc/meminfo:MemTotal"} - } - }, - "classification": {"enum": ["general-purpose", "low-power-arm"]}, - "classification_evidence_source": {"type": "string", "minLength": 1, "maxLength": 1024} - } -} diff --git a/internal/evaluation/machine/machine.go b/internal/evaluation/machine/machine.go deleted file mode 100644 index ba23a21..0000000 --- a/internal/evaluation/machine/machine.go +++ /dev/null @@ -1,250 +0,0 @@ -// Package machine pins and verifies the physical machine identity used by the -// paper evaluator. A low-power classification is accepted only after the -// descriptor's actual OS, architecture, CPU, board, cores, and memory match a -// local probe. -package machine - -import ( - "bytes" - "crypto/sha256" - _ "embed" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "regexp" - "strings" -) - -const ( - SchemaVersion = "malt-evaluation-machine-descriptor/v1" - ClassGeneral = "general-purpose" - ClassLowPower = "low-power-arm" - - MaxDescriptorBytes = 64 << 10 -) - -//go:embed machine-descriptor.schema.json -var SchemaJSON []byte - -var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$`) - -type Identity struct { - OS string `json:"os"` - Architecture string `json:"architecture"` - CPUModel string `json:"cpu_model"` - CPUModelSource string `json:"cpu_model_source"` - BoardModel string `json:"board_model"` - BoardModelSource string `json:"board_model_source"` - LogicalCores uint32 `json:"logical_cores"` - LogicalCoresSource string `json:"logical_cores_source"` - MemoryBytes uint64 `json:"memory_bytes"` - MemorySource string `json:"memory_source"` -} - -type Descriptor struct { - SchemaVersion string `json:"schema_version"` - ID string `json:"id"` - Identity Identity `json:"identity"` - Classification string `json:"classification"` - ClassificationEvidenceSource string `json:"classification_evidence_source"` -} - -type Evidence struct { - Descriptor *Descriptor - SHA256 string - Bytes uint64 - Probe Identity -} - -func Decode(data []byte) (*Descriptor, error) { - if err := rejectDuplicateKeys(data); err != nil { - return nil, fmt.Errorf("decode machine descriptor: %w", err) - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var descriptor Descriptor - if err := decoder.Decode(&descriptor); err != nil { - return nil, fmt.Errorf("decode machine descriptor: %w", err) - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return nil, fmt.Errorf("machine descriptor must contain exactly one JSON object") - } - if err := descriptor.Validate(); err != nil { - return nil, err - } - return &descriptor, nil -} - -func rejectDuplicateKeys(data []byte) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - var value func() error - value = func() error { - token, err := decoder.Token() - if err != nil { - return err - } - delimiter, ok := token.(json.Delim) - if !ok { - return nil - } - if delimiter == '[' { - for decoder.More() { - if err := value(); err != nil { - return err - } - } - _, err := decoder.Token() - return err - } - if delimiter != '{' { - return fmt.Errorf("unexpected JSON delimiter") - } - keys := make(map[string]struct{}) - for decoder.More() { - keyToken, err := decoder.Token() - if err != nil { - return err - } - key, ok := keyToken.(string) - if !ok { - return fmt.Errorf("object key is not a string") - } - if _, duplicate := keys[key]; duplicate { - return fmt.Errorf("duplicate JSON field %q", key) - } - keys[key] = struct{}{} - if err := value(); err != nil { - return err - } - } - _, err = decoder.Token() - return err - } - if err := value(); err != nil { - return err - } - if _, err := decoder.Token(); !errors.Is(err, io.EOF) { - return fmt.Errorf("JSON contains trailing data") - } - return nil -} - -func (d Descriptor) Validate() error { - if d.SchemaVersion != SchemaVersion || !idPattern.MatchString(d.ID) { - return fmt.Errorf("machine descriptor schema or ID is invalid") - } - identity := d.Identity - for name, value := range map[string]string{ - "os": identity.OS, "architecture": identity.Architecture, - "cpu_model": identity.CPUModel, "cpu_model_source": identity.CPUModelSource, - "board_model": identity.BoardModel, "board_model_source": identity.BoardModelSource, - "logical_cores_source": identity.LogicalCoresSource, "memory_source": identity.MemorySource, - } { - if value != normalize(value) || len(value) == 0 || len(value) > 512 { - return fmt.Errorf("machine descriptor %s is empty, non-canonical, or too long", name) - } - } - if identity.LogicalCores == 0 || identity.LogicalCores > 4096 || identity.MemoryBytes < 64<<20 { - return fmt.Errorf("machine descriptor cores or memory is outside evaluator bounds") - } - if d.Classification != ClassGeneral && d.Classification != ClassLowPower || normalize(d.ClassificationEvidenceSource) != d.ClassificationEvidenceSource || len(d.ClassificationEvidenceSource) == 0 || len(d.ClassificationEvidenceSource) > 1024 { - return fmt.Errorf("machine descriptor classification evidence is invalid") - } - if d.Classification == ClassLowPower && identity.Architecture != "arm" && identity.Architecture != "arm64" { - return fmt.Errorf("low-power-arm classification requires an ARM architecture") - } - return nil -} - -func (d Descriptor) LowPowerARM() bool { return d.Classification == ClassLowPower } - -func Match(descriptor *Descriptor, actual Identity) error { - if descriptor == nil { - return fmt.Errorf("machine descriptor is nil") - } - if err := descriptor.Validate(); err != nil { - return err - } - expected := descriptor.Identity - if actual != expected { - return fmt.Errorf("actual machine identity does not match descriptor %q: actual=%+v expected=%+v", descriptor.ID, actual, expected) - } - return nil -} - -// LoadPinned rejects symlinks and file replacement, verifies the registered -// bytes/digest, strictly decodes the descriptor, probes the host, and matches -// the complete physical identity before returning evidence. -func LoadPinned(path, expectedSHA256 string, expectedBytes int64) (*Evidence, error) { - if path == "" || !canonicalSHA256(expectedSHA256) || expectedBytes <= 0 || expectedBytes > MaxDescriptorBytes { - return nil, fmt.Errorf("machine descriptor artifact pin is incomplete") - } - lstat, err := os.Lstat(path) - if err != nil || lstat.Mode()&os.ModeSymlink != 0 || !lstat.Mode().IsRegular() || lstat.Size() != expectedBytes { - return nil, fmt.Errorf("machine descriptor is not the pinned regular file: %v", err) - } - file, err := os.Open(path) - if err != nil { - return nil, err - } - opened, err := file.Stat() - if err != nil || !opened.Mode().IsRegular() || !os.SameFile(lstat, opened) { - _ = file.Close() - return nil, fmt.Errorf("machine descriptor changed before it was opened") - } - data, readErr := io.ReadAll(io.LimitReader(file, MaxDescriptorBytes+1)) - closeErr := file.Close() - if readErr != nil { - return nil, readErr - } - if closeErr != nil { - return nil, closeErr - } - post, err := os.Lstat(path) - if err != nil || !os.SameFile(lstat, post) || post.Mode()&os.ModeSymlink != 0 || int64(len(data)) != expectedBytes { - return nil, fmt.Errorf("machine descriptor changed while it was read") - } - digest := sha256.Sum256(data) - encodedDigest := hex.EncodeToString(digest[:]) - if encodedDigest != expectedSHA256 { - return nil, fmt.Errorf("machine descriptor SHA-256 does not match its pin") - } - descriptor, err := Decode(data) - if err != nil { - return nil, err - } - actual, err := Probe() - if err != nil { - return nil, err - } - if err := Match(descriptor, actual); err != nil { - return nil, err - } - return &Evidence{Descriptor: descriptor, SHA256: encodedDigest, Bytes: uint64(len(data)), Probe: actual}, nil -} - -func NewDescriptor(id, classification, evidenceSource string, identity Identity) (*Descriptor, error) { - descriptor := &Descriptor{ - SchemaVersion: SchemaVersion, ID: id, Identity: identity, - Classification: classification, ClassificationEvidenceSource: normalize(evidenceSource), - } - if err := descriptor.Validate(); err != nil { - return nil, err - } - return descriptor, nil -} - -func normalize(value string) string { - return strings.Join(strings.Fields(strings.TrimSpace(strings.Trim(value, "\x00"))), " ") -} - -func canonicalSHA256(value string) bool { - if len(value) != sha256.Size*2 || strings.ToLower(value) != value { - return false - } - _, err := hex.DecodeString(value) - return err == nil -} diff --git a/internal/evaluation/machine/machine_test.go b/internal/evaluation/machine/machine_test.go deleted file mode 100644 index 7f9ca55..0000000 --- a/internal/evaluation/machine/machine_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package machine - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestPinnedDescriptorMatchesActualMachine(t *testing.T) { - identity, err := Probe() - if err != nil { - t.Fatal(err) - } - descriptor, err := NewDescriptor("host-under-test", ClassGeneral, "test-suite:registered-platform-evidence", identity) - if err != nil { - t.Fatal(err) - } - raw, err := json.Marshal(descriptor) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "machine.json") - if err := os.WriteFile(path, raw, 0o600); err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(raw) - evidence, err := LoadPinned(path, hex.EncodeToString(digest[:]), int64(len(raw))) - if err != nil { - t.Fatal(err) - } - if evidence.Descriptor.ID != descriptor.ID || evidence.Probe != identity { - t.Fatalf("machine evidence = %#v", evidence) - } - - hostile := *descriptor - hostile.Identity.CPUModel += " hostile" - if err := Match(&hostile, identity); err == nil { - t.Fatal("mismatched CPU model was accepted") - } -} - -func TestDescriptorDecodeIsStrict(t *testing.T) { - if _, err := Decode([]byte(`{"schema_version":"malt-evaluation-machine-descriptor/v1","schema_version":"malt-evaluation-machine-descriptor/v1"}`)); err == nil { - t.Fatal("duplicate descriptor field was accepted") - } - identity, err := Probe() - if err != nil { - t.Fatal(err) - } - descriptor, err := NewDescriptor("host", ClassGeneral, "test:evidence", identity) - if err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(descriptor) - raw = append(append([]byte(nil), raw[:len(raw)-1]...), []byte(`,"unknown":true}`)...) - if _, err := Decode(raw); err == nil { - t.Fatal("unknown descriptor field was accepted") - } -} diff --git a/internal/evaluation/machine/probe_linux.go b/internal/evaluation/machine/probe_linux.go deleted file mode 100644 index 5251785..0000000 --- a/internal/evaluation/machine/probe_linux.go +++ /dev/null @@ -1,102 +0,0 @@ -//go:build linux - -package machine - -import ( - "bufio" - "fmt" - "os" - "runtime" - "strconv" - "strings" -) - -func Probe() (Identity, error) { - cpuModel, cpuSource, err := probeCPUModel() - if err != nil { - return Identity{}, err - } - boardModel, boardSource, err := probeBoardModel() - if err != nil { - return Identity{}, err - } - memory, err := probeMemory() - if err != nil { - return Identity{}, err - } - cores := runtime.NumCPU() - if cores <= 0 || cores > 4096 { - return Identity{}, fmt.Errorf("runtime logical core count is outside evaluator bounds") - } - return Identity{ - OS: runtime.GOOS, Architecture: runtime.GOARCH, - CPUModel: cpuModel, CPUModelSource: cpuSource, - BoardModel: boardModel, BoardModelSource: boardSource, - LogicalCores: uint32(cores), LogicalCoresSource: "go-runtime:NumCPU", - MemoryBytes: memory, MemorySource: "linux:/proc/meminfo:MemTotal", - }, nil -} - -func probeCPUModel() (string, string, error) { - data, err := os.ReadFile("/proc/cpuinfo") - if err != nil { - return "", "", fmt.Errorf("read CPU identity: %w", err) - } - values := make(map[string]string) - scanner := bufio.NewScanner(strings.NewReader(string(data))) - for scanner.Scan() { - key, value, found := strings.Cut(scanner.Text(), ":") - if !found { - continue - } - key, value = strings.ToLower(strings.TrimSpace(key)), normalize(value) - if value != "" && values[key] == "" { - values[key] = value - } - } - if err := scanner.Err(); err != nil { - return "", "", err - } - for _, key := range []string{"model name", "hardware", "model"} { - if values[key] != "" { - return values[key], "linux:/proc/cpuinfo:" + key, nil - } - } - return "", "", fmt.Errorf("/proc/cpuinfo has no bounded CPU model field") -} - -func probeBoardModel() (string, string, error) { - for _, path := range []string{"/proc/device-tree/model", "/sys/firmware/devicetree/base/model", "/sys/class/dmi/id/product_name"} { - data, err := os.ReadFile(path) - if err != nil { - continue - } - if value := normalize(string(data)); value != "" { - return value, "linux:" + path, nil - } - } - return "", "", fmt.Errorf("no device-tree or DMI board model is available") -} - -func probeMemory() (uint64, error) { - file, err := os.Open("/proc/meminfo") - if err != nil { - return 0, err - } - defer file.Close() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - fields := strings.Fields(scanner.Text()) - if len(fields) == 3 && fields[0] == "MemTotal:" && fields[2] == "kB" { - value, err := strconv.ParseUint(fields[1], 10, 64) - if err != nil || value > ^uint64(0)/1024 { - return 0, fmt.Errorf("parse /proc/meminfo MemTotal") - } - return value * 1024, nil - } - } - if err := scanner.Err(); err != nil { - return 0, err - } - return 0, fmt.Errorf("/proc/meminfo omits MemTotal") -} diff --git a/internal/evaluation/machine/probe_other.go b/internal/evaluation/machine/probe_other.go deleted file mode 100644 index ec0c784..0000000 --- a/internal/evaluation/machine/probe_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !linux - -package machine - -import "fmt" - -func Probe() (Identity, error) { - return Identity{}, fmt.Errorf("paper machine identity probing is unavailable on this operating system") -} diff --git a/internal/evaluation/rq2e0/file.go b/internal/evaluation/rq2e0/file.go deleted file mode 100644 index be4bf3e..0000000 --- a/internal/evaluation/rq2e0/file.go +++ /dev/null @@ -1,70 +0,0 @@ -package rq2e0 - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "regexp" -) - -var fileIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`) - -// FilePin is a live descriptor for one formal-E0 regular-file input. -type FilePin struct { - FileID string `json:"e0_file_id"` - Path string `json:"path"` - SHA256 string `json:"sha256"` - Bytes int64 `json:"bytes"` -} - -// DescribeRegularFile rejects symlinks and replacement races while deriving -// the exact digest and size consumed by a production worker. -func DescribeRegularFile(fileID, path string) (FilePin, error) { - if !fileIDPattern.MatchString(fileID) || !filepath.IsAbs(path) || filepath.Clean(path) != path { - return FilePin{}, errors.New("invalid RQ2 E0 regular-file identity or path") - } - info, err := os.Lstat(path) - if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() <= 0 { - return FilePin{}, fmt.Errorf("RQ2 E0 input is not a nonempty regular non-symlink file: %v", err) - } - file, err := os.Open(path) - if err != nil { - return FilePin{}, err - } - opened, err := file.Stat() - if err != nil || !opened.Mode().IsRegular() || !os.SameFile(info, opened) { - _ = file.Close() - return FilePin{}, errors.New("RQ2 E0 input changed before it was opened") - } - hash := sha256.New() - written, copyErr := io.Copy(hash, file) - closeErr := file.Close() - if copyErr != nil { - return FilePin{}, copyErr - } - if closeErr != nil { - return FilePin{}, closeErr - } - post, err := os.Lstat(path) - if err != nil || post.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, post) || written != info.Size() { - return FilePin{}, errors.New("RQ2 E0 input changed while it was read") - } - return FilePin{FileID: fileID, Path: path, SHA256: hex.EncodeToString(hash.Sum(nil)), Bytes: written}, nil -} - -// ValidateFilePin verifies that a declared pin still identifies the same -// regular file at the point where it is consumed. -func ValidateFilePin(pin FilePin) error { - live, err := DescribeRegularFile(pin.FileID, pin.Path) - if err != nil { - return err - } - if live.SHA256 != pin.SHA256 || live.Bytes != pin.Bytes { - return errors.New("RQ2 E0 input does not match its SHA-256/size pin") - } - return nil -} diff --git a/internal/evaluation/rq2e0/fixture.go b/internal/evaluation/rq2e0/fixture.go deleted file mode 100644 index d0813a9..0000000 --- a/internal/evaluation/rq2e0/fixture.go +++ /dev/null @@ -1,70 +0,0 @@ -package rq2e0 - -import ( - "context" - "fmt" - - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2fixture" - "github.com/dewebprotocol/malt-core/auth/commitment/ipa" - "github.com/dewebprotocol/malt-core/auth/commitment/kzg" - "github.com/dewebprotocol/malt-core/engine" - cid "github.com/ipfs/go-cid" -) - -// BuildFixture computes exact typed KZG and IPA roots from declared source -// bytes, then independently checks the full source oracle for each graph. -func BuildFixture(ctx context.Context, source *rq2fixture.SourceDefinition) (*rq2fixture.Fixture, error) { - if ctx == nil || source == nil { - return nil, fmt.Errorf("RQ2 E0 fixture source or context is nil") - } - roots := make([]rq2fixture.RootBinding, 0, 2) - for _, backend := range []string{"kzg", "ipa"} { - e, err := fixtureEngine(backend) - if err != nil { - return nil, err - } - candidates, err := source.Candidates(ctx, e, backend) - if err != nil { - return nil, err - } - root, err := cid.Parse(candidates[len(candidates)-1].Root) - if err != nil { - return nil, err - } - roots = append(roots, rq2fixture.RootBinding{Backend: backend, CID: root.String()}) - } - fixture, err := source.Fixture(roots) - if err != nil { - return nil, err - } - for _, backend := range []string{"kzg", "ipa"} { - e, err := fixtureEngine(backend) - if err != nil { - return nil, err - } - if _, err := fixture.Candidates(ctx, e, backend); err != nil { - return nil, err - } - } - return fixture, nil -} -func fixtureEngine(backend string) (*engine.Engine, error) { - var scheme engine.Profile - var err error - switch backend { - case "kzg": - scheme, err = kzg.NewScheme() - case "ipa": - scheme, err = ipa.NewScheme() - default: - return nil, fmt.Errorf("unsupported backend %q", backend) - } - if err != nil { - return nil, err - } - profiles := engine.NewRegistry() - if err := profiles.Register(scheme); err != nil { - return nil, err - } - return engine.New(profiles), nil -} diff --git a/internal/evaluation/rq2e0/gateway.go b/internal/evaluation/rq2e0/gateway.go deleted file mode 100644 index c29d74e..0000000 --- a/internal/evaluation/rq2e0/gateway.go +++ /dev/null @@ -1,230 +0,0 @@ -package rq2e0 - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "sync" - "time" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2fixture" - clienttransport "github.com/dewebprotocol/malt-client/transport" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/protocol" - "github.com/dewebprotocol/malt-core/sdk/authentication" - cid "github.com/ipfs/go-cid" -) - -// ConformanceGateway is an in-memory HTTP oracle for writer correctness only. -// Actual durability, byte accounting and snapshot E0 use the separately tested -// Gateway executable. This oracle emits unavailable write accounting and may -// never be used as a paper performance or persistence measurement source. -type ConformanceGateway struct { - mu sync.Mutex - token string - candidates map[string]protocol.AuthenticationCandidate - payloads map[string]bool - receipts map[string]protocol.AuthenticationReceipt - engine *engine.Engine - server *httptest.Server - operations uint64 -} - -func NewConformanceGateway(fixture *rq2fixture.Fixture, backend, token string) (*ConformanceGateway, cid.Cid, error) { - if fixture == nil || len(token) != 64 { - return nil, cid.Undef, errors.New("invalid RQ2 writer conformance input") - } - e, err := fixtureEngine(backend) - if err != nil { - return nil, cid.Undef, err - } - candidates, err := fixture.Candidates(context.Background(), e, backend) - if err != nil { - return nil, cid.Undef, err - } - g := &ConformanceGateway{token: token, engine: e, candidates: map[string]protocol.AuthenticationCandidate{}, payloads: map[string]bool{}, receipts: map[string]protocol.AuthenticationReceipt{}} - for _, c := range candidates { - g.candidates[c.Root] = c - } - for _, f := range fixture.DirectFiles { - g.payloads[f.CID] = true - } - for _, f := range fixture.ListFiles { - for _, chunk := range f.Chunks { - g.payloads[chunk.CID] = true - } - } - g.server = httptest.NewServer(g) - root, _ := cid.Parse(candidates[len(candidates)-1].Root) - return g, root, nil -} -func (g *ConformanceGateway) URL() string { return g.server.URL } - -func (g *ConformanceGateway) Close() { - if g != nil && g.server != nil { - g.server.Close() - g.server = nil - } -} - -func (g *ConformanceGateway) Operations() uint64 { - g.mu.Lock() - defer g.mu.Unlock() - return g.operations -} - -func (g *ConformanceGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "private, no-store") - if r.Header.Get(gatewaytransport.InstanceTokenHeader) != g.token { - http.Error(w, "incorrect instance token", http.StatusUnauthorized) - return - } - switch { - case r.URL.Path == "/healthz" && r.Method == http.MethodGet: - _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "evaluation_instance_token": g.token}) - case strings.HasPrefix(r.URL.Path, "/v1/evaluation/authentication/candidates/") && r.Method == http.MethodGet: - root := strings.TrimPrefix(r.URL.Path, "/v1/evaluation/authentication/candidates/") - g.mu.Lock() - candidate, ok := g.candidates[root] - g.mu.Unlock() - if !ok { - http.Error(w, "unknown Root", http.StatusNotFound) - return - } - _ = json.NewEncoder(w).Encode(candidate) - case r.URL.Path == "/v1/cas/batch" && r.Method == http.MethodPost: - g.handleCASBatch(w, r) - case r.URL.Path == "/v1/evaluation/authentication/batches" && r.Method == http.MethodPost: - g.handleBatch(w, r) - default: - http.NotFound(w, r) - } -} -func (g *ConformanceGateway) handleCASBatch(w http.ResponseWriter, r *http.Request) { - var submitted struct { - Profile string `json:"profile"` - Blocks []struct { - Codec uint64 `json:"codec"` - Data []byte `json:"data"` - } `json:"blocks"` - } - decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, protocol.MaxVerificationJSONBytes)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&submitted); err != nil || submitted.Profile != clienttransport.CASPutBatchProfile || len(submitted.Blocks) == 0 || len(submitted.Blocks) > 64 { - http.Error(w, "invalid CAS batch", http.StatusBadRequest) - return - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - http.Error(w, "trailing JSON", http.StatusBadRequest) - return - } - keys := make([]cid.Cid, len(submitted.Blocks)) - results := make([]map[string]string, len(keys)) - for i, b := range submitted.Blocks { - key, err := clientcas.CIDForBlock(clientcas.Block{Codec: b.Codec, Data: b.Data}) - if err != nil || b.Codec != cid.Raw { - http.Error(w, "invalid raw payload", http.StatusBadRequest) - return - } - keys[i] = key - results[i] = map[string]string{"cid": key.String(), "status": "stored"} - } - g.mu.Lock() - for _, key := range keys { - g.payloads[key.String()] = true - } - g.mu.Unlock() - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"profile": clienttransport.CASPutBatchProfile, "results": results}) -} -func (g *ConformanceGateway) handleBatch(w http.ResponseWriter, r *http.Request) { - started := time.Now() - raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, protocol.MaxVerificationJSONBytes)) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - batch, err := protocol.DecodeAuthenticationBatch(raw) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - digest, err := batch.Digest() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - g.mu.Lock() - defer g.mu.Unlock() - if receipt, exists := g.receipts[batch.TransactionID]; exists { - if receipt.Digest != digest { - http.Error(w, "transaction substitution", http.StatusConflict) - return - } - g.writeReceipt(w, receipt, true, uint64(time.Since(started)), 0, 0) - return - } - if _, ok := g.candidates[batch.Base]; !ok { - http.Error(w, "unknown batch base", http.StatusConflict) - return - } - if err := authentication.ValidateBatch(r.Context(), g.engine, batch); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - staged := map[string]bool{} - for _, candidate := range batch.Candidates { - if candidate.Previous != "" { - if _, ok := g.candidates[candidate.Previous]; !ok && !staged[candidate.Previous] { - http.Error(w, "missing previous Root", http.StatusBadRequest) - return - } - } - for _, entry := range candidate.State.Entries { - key := entry.Target.String() - if _, _, err := maltcid.ParseRoot(entry.Target); err == nil { - if _, ok := g.candidates[key]; !ok && !staged[key] { - http.Error(w, "missing child Root", http.StatusBadRequest) - return - } - } else if !g.payloads[key] { - http.Error(w, "missing payload", http.StatusBadRequest) - return - } - } - staged[candidate.Root] = true - } - validation := uint64(time.Since(started)) - started = time.Now() - for _, candidate := range batch.Candidates { - g.candidates[candidate.Root] = candidate - } - g.operations++ - persist := uint64(time.Since(started)) - started = time.Now() - receipt := protocol.AuthenticationReceipt{Profile: protocol.AuthenticationReceiptProfile, TransactionID: batch.TransactionID, Base: batch.Base, Root: batch.Root, Digest: digest, DurableBoundary: gatewaytransport.AuthenticationDurableBoundary} - g.receipts[batch.TransactionID] = receipt - g.writeReceipt(w, receipt, false, validation, persist, uint64(time.Since(started))) -} -func (g *ConformanceGateway) writeReceipt(w http.ResponseWriter, receipt protocol.AuthenticationReceipt, retry bool, validation, persist, receiptNS uint64) { - prefix := "X-Malt-Authentication-" - for name, value := range map[string]string{"Phase-Profile": gatewaytransport.AuthenticationPhaseProfile, "Publication": "separate", "Trust": "client-owned", "Durable-Boundary": receipt.DurableBoundary, "Idempotent": strconv.FormatBool(retry), "Validation-And-Stage-Nanos": strconv.FormatUint(validation, 10), "Persist-Nanos": strconv.FormatUint(persist, 10), "Receipt-Nanos": strconv.FormatUint(receiptNS, 10)} { - w.Header().Set(prefix+name, value) - } - w.Header().Set("Server-Timing", fmt.Sprintf("authentication-validate-stage;dur=%.6f, persist;dur=%.6f, receipt;dur=%.6f", float64(validation)/1e6, float64(persist)/1e6, float64(receiptNS)/1e6)) - accounting, _ := json.Marshal(gatewaytransport.WriteAccounting{Profile: gatewaytransport.WriteAccountingProfile, Available: false, UnavailableReason: "writer-conformance-e0-no-durable-accounting", ByteMethod: gatewaytransport.WriteByteMethod, Categories: []gatewaytransport.WriteCategoryAccounting{}}) - w.Header().Set(prefix+"Write-Accounting", base64.RawURLEncoding.EncodeToString(accounting)) - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(receipt) -} diff --git a/internal/evaluation/rq2e0/harness.go b/internal/evaluation/rq2e0/harness.go deleted file mode 100644 index e7cfa56..0000000 --- a/internal/evaluation/rq2e0/harness.go +++ /dev/null @@ -1,163 +0,0 @@ -package rq2e0 - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "sync" - "time" -) - -const maximumWorkerLine = 1 << 20 - -// Harness runs the production worker function over its real JSONL process -// boundary while allowing a formal self-test to advance the accepted root one -// response at a time. -type Harness struct { - input *io.PipeWriter - lines <-chan harnessLine - done <-chan error - timeout time.Duration - once sync.Once -} - -type harnessLine struct { - data []byte - err error -} - -// StartHarness starts one production worker callback. The callback must use -// input/output exactly as its normal stdin/stdout path does. -func StartHarness(timeout time.Duration, run func(io.Reader, io.Writer) error) (*Harness, error) { - if timeout <= 0 || timeout > 10*time.Minute || run == nil { - return nil, errors.New("invalid RQ2 E0 worker harness") - } - inputReader, inputWriter := io.Pipe() - outputReader, outputWriter := io.Pipe() - done := make(chan error, 1) - go func() { - err := run(inputReader, outputWriter) - _ = outputWriter.CloseWithError(err) - _ = inputReader.CloseWithError(err) - done <- err - }() - lines := make(chan harnessLine, 1) - go func() { - defer close(lines) - scanner := bufio.NewScanner(outputReader) - scanner.Buffer(make([]byte, 64<<10), maximumWorkerLine) - for scanner.Scan() { - lines <- harnessLine{data: append([]byte(nil), scanner.Bytes()...)} - } - if err := scanner.Err(); err != nil { - lines <- harnessLine{err: err} - } - _ = outputReader.Close() - }() - return &Harness{input: inputWriter, lines: lines, done: done, timeout: timeout}, nil -} - -// Exchange sends one strict JSON object and decodes one strict JSON response. -func (h *Harness) Exchange(request, response any) error { - encoded, err := json.Marshal(request) - if err != nil { - return err - } - if _, err := h.input.Write(append(encoded, '\n')); err != nil { - return err - } - select { - case line, ok := <-h.lines: - if !ok { - return errors.New("RQ2 E0 worker closed stdout before its response") - } - if line.err != nil { - return line.err - } - decoder := json.NewDecoder(bytes.NewReader(line.data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(response); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return errors.New("RQ2 E0 worker response contains trailing JSON") - } - return nil - case <-time.After(h.timeout): - return errors.New("RQ2 E0 worker response timeout") - } -} - -// WriteRaw writes one raw JSONL request for malformed-input hostile cases. -func (h *Harness) WriteRaw(value []byte) error { - if len(value) == 0 || bytes.IndexByte(value, '\n') >= 0 { - return errors.New("invalid raw RQ2 E0 request") - } - _, err := h.input.Write(append(append([]byte(nil), value...), '\n')) - return err -} - -// WaitError closes stdin and requires the worker to fail. It is used only for -// hostile cases that are rejected before a valid response can be emitted. -func (h *Harness) WaitError() error { - _ = h.input.Close() - select { - case err := <-h.done: - h.once.Do(func() {}) - if err == nil { - return errors.New("RQ2 E0 hostile worker unexpectedly succeeded") - } - return nil - case <-time.After(h.timeout): - h.Abort() - return errors.New("RQ2 E0 hostile worker did not terminate") - } -} - -// Close completes a successful worker after its session-end response and -// rejects any trailing output. -func (h *Harness) Close() error { - if h == nil || h.input == nil { - return errors.New("RQ2 E0 worker harness is closed") - } - _ = h.input.Close() - select { - case err := <-h.done: - h.input = nil - h.once.Do(func() {}) - if err != nil { - return err - } - for line := range h.lines { - if line.err != nil { - return line.err - } - return fmt.Errorf("RQ2 E0 worker emitted trailing output %q", line.data) - } - return nil - case <-time.After(h.timeout): - h.Abort() - return errors.New("RQ2 E0 worker did not exit") - } -} - -// Abort stops an incomplete/hostile harness without claiming a successful -// production session. -func (h *Harness) Abort() { - if h == nil { - return - } - h.once.Do(func() { - if h.input != nil { - _ = h.input.CloseWithError(errors.New("formal E0 hostile abort")) - } - select { - case <-h.done: - case <-time.After(5 * time.Second): - } - h.input = nil - }) -} diff --git a/internal/evaluation/rq2fixture/authentication.go b/internal/evaluation/rq2fixture/authentication.go deleted file mode 100644 index 0db5cf2..0000000 --- a/internal/evaluation/rq2fixture/authentication.go +++ /dev/null @@ -1,100 +0,0 @@ -package rq2fixture - -import ( - "context" - "fmt" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" - unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" - "github.com/dewebprotocol/malt-core/auth/coordinate" - "github.com/dewebprotocol/malt-core/derivation" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/protocol" - "github.com/dewebprotocol/malt-core/sdk/authentication" - cid "github.com/ipfs/go-cid" -) - -// Candidates builds measured Positional file objects before their Prefix AA1 -// directory root. Source paths are explicit opaque labels at the Core boundary. -func (s *SourceDefinition) Candidates(ctx context.Context, e *engine.Engine, backend string) ([]protocol.AuthenticationCandidate, error) { - if err := s.Validate(); err != nil { - return nil, err - } - profile, err := BackendProfile(backend) - if err != nil { - return nil, err - } - candidates := make([]protocol.AuthenticationCandidate, 0, len(s.ListFiles)+1) - seen := map[string]bool{} - bindings := make([]engine.Entry, 0, len(s.DirectFiles)+len(s.ListFiles)) - for _, file := range s.DirectFiles { - target, err := clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: file.Bytes}) - if err != nil { - return nil, err - } - bindings = append(bindings, engine.Entry{Label: []byte(file.Path), Target: target}) - } - for _, file := range s.ListFiles { - state := engine.State{Descriptor: maltcid.RootDescriptor{DerivationProfile: uint8(derivation.Direct), Layout: maltcid.Positional, Profile: profile}, Entries: make([]engine.Entry, len(file.Chunks))} - state.PayloadCID, err = unixfsmodel.InlineChunkMetadata(file.TotalSize, file.ChunkSize, uint64(len(file.Chunks))) - if err != nil { - return nil, err - } - for i, chunk := range file.Chunks { - target, err := clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: chunk.Bytes}) - if err != nil { - return nil, err - } - state.Entries[i] = engine.Entry{Label: coordinate.EncodeIndex(chunk.Index), Target: target} - } - candidate, err := authentication.Prepare(ctx, e, state) - if err != nil { - return nil, err - } - if !seen[candidate.Root] { - candidates = append(candidates, candidate) - seen[candidate.Root] = true - } - target, _ := cid.Parse(candidate.Root) - bindings = append(bindings, engine.Entry{Label: []byte(file.Path), Target: target}) - } - candidate, err := authentication.Prepare(ctx, e, engine.State{Descriptor: maltcid.RootDescriptor{Layout: maltcid.Prefix, DerivationProfile: uint8(derivation.SHA256), Profile: profile}, Entries: bindings}) - if err != nil { - return nil, err - } - return append(candidates, candidate), nil -} - -// Candidates independently reconstructs the fixture's exact pinned Root and -// checks the complete source oracle before returning bootstrap candidates. -func (f *Fixture) Candidates(ctx context.Context, e *engine.Engine, backend string) ([]protocol.AuthenticationCandidate, error) { - if err := f.Validate(); err != nil { - return nil, err - } - source := SourceDefinition{SchemaVersion: SourceSchemaVersion, FixtureID: f.FixtureID, MutationSeedSHA256: f.MutationSeedSHA256, Operations: f.Operations} - for _, file := range f.DirectFiles { - source.DirectFiles = append(source.DirectFiles, SourceDirectFile{Path: file.Path, Coordinate: file.Coordinate, Bytes: file.Bytes}) - } - for _, file := range f.ListFiles { - out := SourceListFile{Path: file.Path, Coordinate: file.Coordinate, ChunkSize: file.ChunkSize, TotalSize: file.TotalSize} - for _, chunk := range file.Chunks { - out.Chunks = append(out.Chunks, SourceListChunk{Index: chunk.Index, Bytes: chunk.Bytes}) - } - source.ListFiles = append(source.ListFiles, out) - } - candidates, err := source.Candidates(ctx, e, backend) - if err != nil { - return nil, err - } - root, _ := cid.Parse(candidates[len(candidates)-1].Root) - view := authenticationgraph.View{Root: root, States: make(map[string]engine.State, len(candidates))} - for _, candidate := range candidates { - view.States[candidate.Root] = candidate.State - } - if err := f.ValidateInitialGraph(view, backend); err != nil { - return nil, fmt.Errorf("reconstructed fixture: %w", err) - } - return candidates, nil -} diff --git a/internal/evaluation/rq2fixture/fixture.go b/internal/evaluation/rq2fixture/fixture.go deleted file mode 100644 index 1294abc..0000000 --- a/internal/evaluation/rq2fixture/fixture.go +++ /dev/null @@ -1,617 +0,0 @@ -// Package rq2fixture defines the bounded source fixture used by the paper RQ2 -// native and browser writers. The fixture binds the initial accepted root to -// exact source bytes and declares every operation target before measurement. -package rq2fixture - -import ( - "bytes" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "regexp" - "slices" - "strings" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" - "github.com/dewebprotocol/malt-client/unixfs" - "github.com/dewebprotocol/malt-core/derivation" - "github.com/dewebprotocol/malt-core/maltcid" - cid "github.com/ipfs/go-cid" -) - -const ( - SchemaVersion = "malt-rq2-source-fixture/v1" - - KindDirectReplace = "direct-replace" - KindDirectInsert = "direct-insert" - KindDirectDelete = "direct-delete" - KindDirectMove = "direct-move" - KindListAppend = "list-append" - KindListReplace = "list-replace" - KindBatchInsert = "batch-insert" - KindDocumentEdit = "document-edit" - - maxFiles = 4096 - maxOperations = 256 - maxChunks = 65536 - maxDecodedBytes = 64 << 20 -) - -var identityPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$`) - -type RootBinding struct { - Backend string `json:"backend"` - CID string `json:"cid"` -} - -type DirectFile struct { - Path string `json:"path"` - Coordinate string `json:"coordinate"` - Bytes []byte `json:"bytes"` - CID string `json:"cid"` -} - -type ListChunk struct { - Index uint64 `json:"index"` - Bytes []byte `json:"bytes"` - CID string `json:"cid"` -} - -type ListFile struct { - Path string `json:"path"` - Coordinate string `json:"coordinate"` - ChunkSize uint64 `json:"chunk_size"` - TotalSize uint64 `json:"total_size"` - Chunks []ListChunk `json:"chunks"` -} - -type BatchTarget struct { - Path string `json:"path"` - Coordinate string `json:"coordinate"` - PayloadBytes uint64 `json:"payload_bytes"` -} - -// Operation fixes the source path and authenticated coordinate affected by an -// evaluator operation. Coordinates are deliberately explicit even though the -// v1 source profile requires them to equal their canonical UnixFS paths. -type Operation struct { - Name string `json:"name"` - Kind string `json:"kind"` - SourcePath string `json:"source_path,omitempty"` - SourceCoordinate string `json:"source_coordinate,omitempty"` - DestinationPath string `json:"destination_path,omitempty"` - DestinationCoordinate string `json:"destination_coordinate,omitempty"` - PayloadBytes uint64 `json:"payload_bytes,omitempty"` - ListIndex *uint64 `json:"list_index,omitempty"` - Batch []BatchTarget `json:"batch,omitempty"` -} - -type Fixture struct { - SchemaVersion string `json:"schema_version"` - FixtureID string `json:"fixture_id"` - MutationSeedSHA256 string `json:"mutation_seed_sha256"` - InitialRoots []RootBinding `json:"initial_roots"` - DirectFiles []DirectFile `json:"direct_files"` - ListFiles []ListFile `json:"list_files"` - Operations []Operation `json:"operations"` -} - -// Decode accepts exactly one JSON object, rejects unknown fields, and verifies -// all roots, CIDs, source bytes, descriptors, paths, and operation targets. -func Decode(data []byte) (*Fixture, error) { - if err := rejectDuplicateKeys(data); err != nil { - return nil, fmt.Errorf("decode RQ2 source fixture: %w", err) - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var fixture Fixture - if err := decoder.Decode(&fixture); err != nil { - return nil, fmt.Errorf("decode RQ2 source fixture: %w", err) - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return nil, fmt.Errorf("RQ2 source fixture must contain exactly one JSON object") - } - if err := fixture.Validate(); err != nil { - return nil, err - } - return &fixture, nil -} - -func rejectDuplicateKeys(data []byte) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.UseNumber() - var value func() error - value = func() error { - token, err := decoder.Token() - if err != nil { - return err - } - delimiter, ok := token.(json.Delim) - if !ok { - return nil - } - switch delimiter { - case '{': - keys := make(map[string]struct{}) - for decoder.More() { - keyToken, err := decoder.Token() - if err != nil { - return err - } - key, ok := keyToken.(string) - if !ok { - return fmt.Errorf("object key is not a string") - } - if _, duplicate := keys[key]; duplicate { - return fmt.Errorf("duplicate JSON field %q", key) - } - keys[key] = struct{}{} - if err := value(); err != nil { - return err - } - } - closing, err := decoder.Token() - if err != nil || closing != json.Delim('}') { - return fmt.Errorf("unterminated JSON object") - } - case '[': - for decoder.More() { - if err := value(); err != nil { - return err - } - } - closing, err := decoder.Token() - if err != nil || closing != json.Delim(']') { - return fmt.Errorf("unterminated JSON array") - } - default: - return fmt.Errorf("unexpected JSON delimiter %q", delimiter) - } - return nil - } - if err := value(); err != nil { - return err - } - if _, err := decoder.Token(); !errors.Is(err, io.EOF) { - return fmt.Errorf("JSON contains trailing data") - } - return nil -} - -func (f *Fixture) Validate() error { - if f == nil || f.SchemaVersion != SchemaVersion || !identityPattern.MatchString(f.FixtureID) || !canonicalSHA256(f.MutationSeedSHA256) { - return fmt.Errorf("RQ2 source fixture identity or schema is invalid") - } - if len(f.InitialRoots) == 0 || len(f.InitialRoots) > 2 || len(f.DirectFiles)+len(f.ListFiles) == 0 || len(f.DirectFiles)+len(f.ListFiles) > maxFiles { - return fmt.Errorf("RQ2 source fixture root or file count is outside evaluator bounds") - } - roots := make(map[string]cid.Cid, len(f.InitialRoots)) - for _, binding := range f.InitialRoots { - if binding.Backend != "kzg" && binding.Backend != "ipa" || roots[binding.Backend].Defined() { - return fmt.Errorf("RQ2 source fixture has an invalid or duplicate backend root") - } - root, err := cid.Parse(binding.CID) - descriptor, _, rootErr := maltcid.ParseRoot(root) - profile, _ := BackendProfile(binding.Backend) - if err != nil || rootErr != nil || descriptor.Profile != profile || descriptor.Layout != maltcid.Prefix || descriptor.DerivationProfile != uint8(derivation.SHA256) || root.String() != binding.CID { - return fmt.Errorf("RQ2 source fixture %s root is not a canonical typed MALT CID", binding.Backend) - } - roots[binding.Backend] = root - } - paths := make(map[string]string, len(f.DirectFiles)+len(f.ListFiles)) - var decodedBytes uint64 - for _, file := range f.DirectFiles { - if err := validatePathCoordinate(file.Path, file.Coordinate); err != nil { - return fmt.Errorf("direct file: %w", err) - } - if _, exists := paths[file.Path]; exists { - return fmt.Errorf("RQ2 source fixture repeats path %q", file.Path) - } - if err := validateRawCID(file.Bytes, file.CID); err != nil { - return fmt.Errorf("direct file %q: %w", file.Path, err) - } - paths[file.Path] = "direct" - decodedBytes += uint64(len(file.Bytes)) - } - chunkCount := 0 - for _, file := range f.ListFiles { - if err := validatePathCoordinate(file.Path, file.Coordinate); err != nil { - return fmt.Errorf("list file: %w", err) - } - if _, exists := paths[file.Path]; exists { - return fmt.Errorf("RQ2 source fixture repeats path %q", file.Path) - } - if file.ChunkSize == 0 || file.TotalSize == 0 || len(file.Chunks) == 0 { - return fmt.Errorf("list file %q has an empty fixed-list descriptor", file.Path) - } - var total uint64 - for index, chunk := range file.Chunks { - if chunk.Index != uint64(index) || len(chunk.Bytes) == 0 || uint64(len(chunk.Bytes)) > file.ChunkSize { - return fmt.Errorf("list file %q chunk %d is not a bounded contiguous chunk", file.Path, index) - } - if index != len(file.Chunks)-1 && uint64(len(chunk.Bytes)) != file.ChunkSize { - return fmt.Errorf("list file %q has a partial non-final chunk", file.Path) - } - if err := validateRawCID(chunk.Bytes, chunk.CID); err != nil { - return fmt.Errorf("list file %q chunk %d: %w", file.Path, index, err) - } - total += uint64(len(chunk.Bytes)) - decodedBytes += uint64(len(chunk.Bytes)) - } - if total != file.TotalSize { - return fmt.Errorf("list file %q bytes do not match total_size", file.Path) - } - paths[file.Path] = "list" - chunkCount += len(file.Chunks) - } - if chunkCount > maxChunks || decodedBytes == 0 || decodedBytes > maxDecodedBytes { - return fmt.Errorf("RQ2 source fixture decoded bytes or chunk count is outside evaluator bounds") - } - if len(f.Operations) == 0 || len(f.Operations) > maxOperations { - return fmt.Errorf("RQ2 source fixture operation count is outside evaluator bounds") - } - operations := make(map[string]struct{}, len(f.Operations)) - for _, operation := range f.Operations { - if !identityPattern.MatchString(operation.Name) { - return fmt.Errorf("RQ2 source fixture operation name %q is invalid", operation.Name) - } - if _, exists := operations[operation.Name]; exists { - return fmt.Errorf("RQ2 source fixture repeats operation %q", operation.Name) - } - operations[operation.Name] = struct{}{} - if err := validateOperation(operation, paths); err != nil { - return fmt.Errorf("operation %q: %w", operation.Name, err) - } - if operation.Kind == KindListAppend || operation.Kind == KindListReplace { - list, ok := f.List(operation.SourcePath) - if !ok { - return fmt.Errorf("operation %q: list source is not declared", operation.Name) - } - if operation.Kind == KindListAppend && operation.PayloadBytes != list.ChunkSize { - return fmt.Errorf("operation %q: fixed-list append payload must equal chunk_size", operation.Name) - } - if operation.Kind == KindListReplace { - if operation.ListIndex == nil || *operation.ListIndex >= uint64(len(list.Chunks)) || operation.PayloadBytes != uint64(len(list.Chunks[*operation.ListIndex].Bytes)) { - return fmt.Errorf("operation %q: replacement must target one declared chunk with the same byte length", operation.Name) - } - } - } - } - return nil -} - -func validateOperation(operation Operation, initialPaths map[string]string) error { - source := func(kind string) error { - if err := validatePathCoordinate(operation.SourcePath, operation.SourceCoordinate); err != nil { - return err - } - if initialPaths[operation.SourcePath] != kind { - return fmt.Errorf("source %q is not an initial %s file", operation.SourcePath, kind) - } - return nil - } - destination := func() error { - if err := validatePathCoordinate(operation.DestinationPath, operation.DestinationCoordinate); err != nil { - return err - } - if _, exists := initialPaths[operation.DestinationPath]; exists { - return fmt.Errorf("destination %q already exists initially", operation.DestinationPath) - } - return nil - } - switch operation.Kind { - case KindDirectReplace: - if err := source("direct"); err != nil { - return err - } - return requirePayload(operation.PayloadBytes) - case KindDocumentEdit: - if err := source("direct"); err != nil { - return err - } - return requirePayload(operation.PayloadBytes) - case KindDirectInsert: - if err := destination(); err != nil { - return err - } - return requirePayload(operation.PayloadBytes) - case KindDirectDelete: - if err := source("direct"); err != nil { - return err - } - if operation.PayloadBytes != 0 { - return fmt.Errorf("delete declares payload bytes") - } - case KindDirectMove: - if err := source("direct"); err != nil { - return err - } - if err := destination(); err != nil { - return err - } - if operation.PayloadBytes != 0 { - return fmt.Errorf("move declares payload bytes") - } - case KindListAppend: - if err := source("list"); err != nil { - return err - } - if operation.ListIndex != nil { - return fmt.Errorf("append declares a list index") - } - return requirePayload(operation.PayloadBytes) - case KindListReplace: - if err := source("list"); err != nil { - return err - } - if operation.ListIndex == nil { - return fmt.Errorf("list replacement omits its exact index") - } - return requirePayload(operation.PayloadBytes) - case KindBatchInsert: - if len(operation.Batch) == 0 || len(operation.Batch) > 64 || operation.PayloadBytes != 0 { - return fmt.Errorf("batch target count or aggregate payload declaration is invalid") - } - seen := make(map[string]struct{}, len(operation.Batch)) - for _, target := range operation.Batch { - if err := validatePathCoordinate(target.Path, target.Coordinate); err != nil { - return err - } - if _, exists := initialPaths[target.Path]; exists { - return fmt.Errorf("batch destination %q already exists initially", target.Path) - } - if _, exists := seen[target.Path]; exists { - return fmt.Errorf("batch repeats destination %q", target.Path) - } - seen[target.Path] = struct{}{} - if err := requirePayload(target.PayloadBytes); err != nil { - return err - } - } - default: - return fmt.Errorf("unsupported operation kind %q", operation.Kind) - } - return nil -} - -func requirePayload(size uint64) error { - if size == 0 || size > maxDecodedBytes { - return fmt.Errorf("payload size is outside evaluator bounds") - } - return nil -} - -func validatePathCoordinate(path, coordinate string) error { - if path == "" || path != coordinate { - return fmt.Errorf("source path and authenticated coordinate must be identical and non-empty") - } - _, err := unixfs.ParseCanonicalStagedPath(path) - if err != nil { - return fmt.Errorf("path %q is not a canonical relative UnixFS path", path) - } - return nil -} - -func validateRawCID(data []byte, encoded string) error { - parsed, err := cid.Parse(encoded) - if err != nil || parsed.Type() != cid.Raw || parsed.String() != encoded { - return fmt.Errorf("CID is not one canonical raw CID") - } - expected, err := clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: data}) - if err != nil || !expected.Equals(parsed) { - return fmt.Errorf("CID does not bind the declared bytes") - } - return nil -} - -func canonicalSHA256(value string) bool { - if len(value) != sha256.Size*2 || strings.ToLower(value) != value { - return false - } - _, err := hex.DecodeString(value) - return err == nil -} - -func (f *Fixture) Root(backend string) (cid.Cid, error) { - for _, binding := range f.InitialRoots { - if binding.Backend == backend { - root, _ := cid.Parse(binding.CID) - return root, nil - } - } - return cid.Undef, fmt.Errorf("RQ2 source fixture has no %s initial root", backend) -} - -func (f *Fixture) Operation(name string) (Operation, error) { - for _, operation := range f.Operations { - if operation.Name == name { - return operation, nil - } - } - return Operation{}, fmt.Errorf("RQ2 source fixture does not declare operation %q", name) -} - -func (f *Fixture) RequireOperations(names []string) error { - want := append([]string(nil), names...) - slices.Sort(want) - want = slices.Compact(want) - for _, name := range want { - if _, err := f.Operation(name); err != nil { - return err - } - } - return nil -} - -func (f *Fixture) InitialSource() map[string][]byte { - result := make(map[string][]byte, len(f.DirectFiles)+len(f.ListFiles)) - for _, file := range f.DirectFiles { - result[file.Path] = append([]byte(nil), file.Bytes...) - } - for _, file := range f.ListFiles { - for _, chunk := range file.Chunks { - result[file.Path] = append(result[file.Path], chunk.Bytes...) - } - } - return result -} - -func (f *Fixture) List(path string) (ListFile, bool) { - for _, file := range f.ListFiles { - if file.Path == path { - return file, true - } - } - return ListFile{}, false -} - -// Payload derives the exact operation payload from the pinned mutation seed. -// This keeps repeated browser sessions bounded without letting a worker invent -// target paths or unregistered source material. -func (f *Fixture) Payload(operation string, ordinal uint64, target int, size uint64) ([]byte, error) { - if size == 0 || size > maxDecodedBytes || target < 0 || target > 64 { - return nil, fmt.Errorf("RQ2 derived payload request is outside evaluator bounds") - } - seed, err := hex.DecodeString(f.MutationSeedSHA256) - if err != nil { - return nil, err - } - label := fmt.Sprintf("%s\x00%d\x00%d", operation, ordinal, target) - base := sha256.Sum256(append(seed, []byte(label)...)) - output := make([]byte, int(size)) - for offset, counter := 0, uint64(0); offset < len(output); counter++ { - var raw [8]byte - binary.BigEndian.PutUint64(raw[:], counter) - block := sha256.Sum256(append(base[:], raw[:]...)) - offset += copy(output[offset:], block[:]) - } - return output, nil -} - -func CloneSource(source map[string][]byte) map[string][]byte { - clone := make(map[string][]byte, len(source)) - for path, data := range source { - clone[path] = append([]byte(nil), data...) - } - return clone -} - -// ApplySourceOperation returns the exact post-image and the changed payloads -// in the same order in which the operation's intent must consume their CIDs. -func (f *Fixture) ApplySourceOperation(source map[string][]byte, operation Operation, ordinal uint64) (map[string][]byte, [][]byte, error) { - post := CloneSource(source) - payload := func(index int, size uint64) ([]byte, error) { return f.Payload(operation.Name, ordinal, index, size) } - switch operation.Kind { - case KindDirectReplace: - if _, exists := post[operation.SourcePath]; !exists { - return nil, nil, fmt.Errorf("source %q does not exist", operation.SourcePath) - } - data, err := payload(0, operation.PayloadBytes) - if err != nil { - return nil, nil, err - } - post[operation.SourcePath] = data - return post, [][]byte{data}, nil - case KindDocumentEdit: - old, exists := post[operation.SourcePath] - if !exists { - return nil, nil, fmt.Errorf("document source %q does not exist", operation.SourcePath) - } - edit, err := payload(0, operation.PayloadBytes) - if err != nil { - return nil, nil, err - } - data := append(append([]byte(nil), old...), edit...) - post[operation.SourcePath] = data - return post, [][]byte{data}, nil - case KindDirectInsert: - if _, exists := post[operation.DestinationPath]; exists { - return nil, nil, fmt.Errorf("destination %q already exists", operation.DestinationPath) - } - data, err := payload(0, operation.PayloadBytes) - if err != nil { - return nil, nil, err - } - post[operation.DestinationPath] = data - return post, [][]byte{data}, nil - case KindDirectDelete: - if _, exists := post[operation.SourcePath]; !exists { - return nil, nil, fmt.Errorf("delete source %q does not exist", operation.SourcePath) - } - delete(post, operation.SourcePath) - return post, nil, nil - case KindDirectMove: - data, exists := post[operation.SourcePath] - if !exists { - return nil, nil, fmt.Errorf("move source %q does not exist", operation.SourcePath) - } - if _, exists := post[operation.DestinationPath]; exists { - return nil, nil, fmt.Errorf("move destination %q already exists", operation.DestinationPath) - } - delete(post, operation.SourcePath) - post[operation.DestinationPath] = data - return post, nil, nil - case KindListAppend: - old, exists := post[operation.SourcePath] - if !exists { - return nil, nil, fmt.Errorf("list source %q does not exist", operation.SourcePath) - } - data, err := payload(0, operation.PayloadBytes) - if err != nil { - return nil, nil, err - } - post[operation.SourcePath] = append(append([]byte(nil), old...), data...) - return post, [][]byte{data}, nil - case KindListReplace: - old, exists := post[operation.SourcePath] - list, listExists := f.List(operation.SourcePath) - if !exists || !listExists || operation.ListIndex == nil { - return nil, nil, fmt.Errorf("list replacement source is unavailable") - } - data, err := payload(0, operation.PayloadBytes) - if err != nil { - return nil, nil, err - } - offset := *operation.ListIndex * list.ChunkSize - if offset+uint64(len(data)) > uint64(len(old)) { - return nil, nil, fmt.Errorf("list replacement exceeds current source bytes") - } - old = append([]byte(nil), old...) - copy(old[offset:offset+uint64(len(data))], data) - post[operation.SourcePath] = old - return post, [][]byte{data}, nil - case KindBatchInsert: - values := make([][]byte, len(operation.Batch)) - for index, target := range operation.Batch { - if _, exists := post[target.Path]; exists { - return nil, nil, fmt.Errorf("batch destination %q already exists", target.Path) - } - data, err := payload(index, target.PayloadBytes) - if err != nil { - return nil, nil, err - } - post[target.Path], values[index] = data, data - } - return post, values, nil - default: - return nil, nil, fmt.Errorf("unsupported RQ2 source operation kind %q", operation.Kind) - } -} - -// ValidateInitialGraph proves that the fixture's declared source bytes map to -// the campaign's exact initial accepted root for the selected backend. -func (f *Fixture) ValidateInitialGraph(view authenticationgraph.View, backend string) error { - expected, err := f.Root(backend) - if err != nil { - return err - } - if !view.Root.Equals(expected) { - return fmt.Errorf("authentication graph Root %s does not equal fixture %s root %s", view.Root, backend, expected) - } - return f.ValidateGraphAgainstSource(view, backend, f.InitialSource()) -} diff --git a/internal/evaluation/rq2fixture/fixture_test.go b/internal/evaluation/rq2fixture/fixture_test.go deleted file mode 100644 index 9542425..0000000 --- a/internal/evaluation/rq2fixture/fixture_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package rq2fixture - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "testing" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" - "github.com/dewebprotocol/malt-core/auth/commitment/kzg" - "github.com/dewebprotocol/malt-core/derivation" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/sdk/authentication" - cid "github.com/ipfs/go-cid" -) - -func TestDecodeAndOracleBindExactSource(t *testing.T) { - fixture, view, raw := testFixture(t) - if err := fixture.ValidateInitialGraph(view, "kzg"); err != nil { - t.Fatal(err) - } - hostile := fixture.InitialSource() - hostile["document.txt"][0] ^= 0xff - if err := fixture.ValidateGraphAgainstSource(view, "kzg", hostile); err == nil { - t.Fatal("source bytes unrelated to the authenticated CID passed the oracle") - } - - unknown := append(append([]byte(nil), raw[:len(raw)-1]...), []byte(`,"unknown":true}`)...) - if _, err := Decode(unknown); err == nil { - t.Fatal("unknown fixture field was accepted") - } - duplicate := []byte(`{"schema_version":"malt-rq2-source-fixture/v1","schema_version":"malt-rq2-source-fixture/v1"}`) - if _, err := Decode(duplicate); err == nil { - t.Fatal("duplicate fixture field was accepted") - } -} - -func TestDocumentEditUsesRetainedExactPostImage(t *testing.T) { - fixture, _, _ := testFixture(t) - operation, err := fixture.Operation("document-edit-cid-binding-submit") - if err != nil { - t.Fatal(err) - } - initial := fixture.InitialSource() - first, payloads, err := fixture.ApplySourceOperation(initial, operation, 0) - if err != nil || len(payloads) != 1 { - t.Fatal(err) - } - second, _, err := fixture.ApplySourceOperation(first, operation, 1) - if err != nil { - t.Fatal(err) - } - if got, want := len(second["document.txt"]), len(initial["document.txt"])+2*int(operation.PayloadBytes); got != want { - t.Fatalf("retained document bytes = %d, want %d", got, want) - } -} - -func testFixture(t *testing.T) (*Fixture, authenticationgraph.View, []byte) { - t.Helper() - data := []byte("document bytes") - key, err := clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: data}) - if err != nil { - t.Fatal(err) - } - scheme, err := kzg.NewScheme() - if err != nil { - t.Fatal(err) - } - profiles := engine.NewRegistry() - if err := profiles.Register(scheme); err != nil { - t.Fatal(err) - } - candidate, err := authentication.Prepare(context.Background(), engine.New(profiles), engine.State{Descriptor: maltcid.RootDescriptor{Layout: maltcid.Prefix, DerivationProfile: uint8(derivation.SHA256), Profile: maltcid.KZG4096}, Entries: []engine.Entry{{Label: []byte("document.txt"), Target: key}}}) - if err != nil { - t.Fatal(err) - } - root, _ := cid.Parse(candidate.Root) - view := authenticationgraph.View{Root: root, States: map[string]engine.State{root.String(): candidate.State}} - seed := sha256.Sum256([]byte("fixture seed")) - value := Fixture{ - SchemaVersion: SchemaVersion, FixtureID: "fixture", MutationSeedSHA256: hex.EncodeToString(seed[:]), - InitialRoots: []RootBinding{{Backend: "kzg", CID: root.String()}}, - DirectFiles: []DirectFile{{Path: "document.txt", Coordinate: "document.txt", Bytes: data, CID: key.String()}}, - Operations: []Operation{{ - Name: "document-edit-cid-binding-submit", Kind: KindDocumentEdit, - SourcePath: "document.txt", SourceCoordinate: "document.txt", PayloadBytes: 32, - }}, - } - raw, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - fixture, err := Decode(raw) - if err != nil { - t.Fatal(err) - } - return fixture, view, raw -} diff --git a/internal/evaluation/rq2fixture/oracle.go b/internal/evaluation/rq2fixture/oracle.go deleted file mode 100644 index bc596c2..0000000 --- a/internal/evaluation/rq2fixture/oracle.go +++ /dev/null @@ -1,137 +0,0 @@ -package rq2fixture - -import ( - "fmt" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" - unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" - "github.com/dewebprotocol/malt-core/auth/coordinate" - "github.com/dewebprotocol/malt-core/derivation" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - cid "github.com/ipfs/go-cid" -) - -// ValidateGraphAgainstSource is an independent full post-image oracle. It -// checks every typed binding, measured sequence descriptor, chunk CID and -// reachable object against source bytes, without using the operation delta. -// The caller must first authenticate the graph using the Core SDK. -func (f *Fixture) ValidateGraphAgainstSource(view authenticationgraph.View, backend string, source map[string][]byte) error { - if f == nil { - return fmt.Errorf("RQ2 source fixture is nil") - } - profile, err := BackendProfile(backend) - if err != nil { - return err - } - descriptor, _, err := maltcid.ParseRoot(view.Root) - if err != nil || descriptor.Profile != profile || descriptor.Layout != maltcid.Prefix || descriptor.DerivationProfile != uint8(derivation.SHA256) { - return fmt.Errorf("source root requires Prefix AA1 for backend %s", backend) - } - root, err := view.State(view.Root) - if err != nil { - return err - } - if root.Descriptor != descriptor || root.PayloadCID.Defined() || len(root.Entries) != len(source) { - return fmt.Errorf("root metadata or binding count differs from source") - } - entries := make(map[string]engine.Entry, len(root.Entries)) - for _, entry := range root.Entries { - if entry.Label == nil { - return fmt.Errorf("root contains a non-label input") - } - path := string(entry.Label) - if err := validatePathCoordinate(path, path); err != nil { - return err - } - if _, duplicate := entries[path]; duplicate { - return fmt.Errorf("root repeats label %q", path) - } - entries[path] = entry - } - used := map[string]bool{view.Root.String(): true} - for path, data := range source { - entry, exists := entries[path] - if !exists { - return fmt.Errorf("source file %q is absent from authenticated root", path) - } - file, measured := f.List(path) - if !measured { - expected, err := clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: data}) - if err != nil || !entry.Target.Equals(expected) { - return fmt.Errorf("direct source bytes for %q do not bind authenticated CID", path) - } - continue - } - descriptor, _, err := maltcid.ParseRoot(entry.Target) - if err != nil || descriptor.Profile != profile || descriptor.Layout != maltcid.Positional || descriptor.DerivationProfile != uint8(derivation.Direct) { - return fmt.Errorf("source file %q requires a Positional AA0 Root for backend %s", path, backend) - } - object, err := view.State(entry.Target) - if err != nil { - return err - } - geometry, err := unixfsmodel.ParseInlineChunkMetadata(object.PayloadCID, uint64(len(object.Entries))) - if err != nil { - return err - } - if object.Descriptor != descriptor || geometry.ChunkSize != file.ChunkSize || geometry.TotalSize != uint64(len(data)) { - return fmt.Errorf("source file %q measured metadata does not match post-image", path) - } - if err := validateChunkEntries(object, data); err != nil { - return fmt.Errorf("source file %q: %w", path, err) - } - used[entry.Target.String()] = true - } - if len(view.States) != len(used) { - return fmt.Errorf("authentication graph contains %d objects, full source closure uses %d", len(view.States), len(used)) - } - for key := range view.States { - if !used[key] { - return fmt.Errorf("authentication graph contains an unrelated Root %s", key) - } - } - return nil -} -func validateChunkEntries(state engine.State, data []byte) error { - geometry, err := unixfsmodel.ParseInlineChunkMetadata(state.PayloadCID, uint64(len(state.Entries))) - if err != nil { - return err - } - count := uint64(len(data)) / geometry.ChunkSize - if uint64(len(data))%geometry.ChunkSize != 0 { - count++ - } - if uint64(len(state.Entries)) != count { - return fmt.Errorf("object has %d chunks, source bytes require %d", len(state.Entries), count) - } - // Match by explicit index; input slice order is not an authenticated property. - seen := make(map[uint64]bool, len(state.Entries)) - for _, entry := range state.Entries { - coord, decodeErr := coordinate.Parse(entry.Label) - index := coord.Index - if decodeErr != nil || coord.Kind != coordinate.Index || index >= count || seen[index] { - return fmt.Errorf("chunk input is duplicate, invalid or outside the measured sequence") - } - seen[index] = true - start := index * geometry.ChunkSize - end := start + min(uint64(len(data))-start, geometry.ChunkSize) - expected, err := clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: data[start:end]}) - if err != nil || !entry.Target.Equals(expected) { - return fmt.Errorf("chunk %d CID does not bind its source bytes", index) - } - } - return nil -} - -func BackendProfile(backend string) (maltcid.ProfileID, error) { - switch backend { - case "kzg": - return maltcid.KZG4096, nil - case "ipa": - return maltcid.IPA256, nil - default: - return 0, fmt.Errorf("unsupported backend %q", backend) - } -} diff --git a/internal/evaluation/rq2fixture/source-definition.schema.json b/internal/evaluation/rq2fixture/source-definition.schema.json deleted file mode 100644 index 2af11b8..0000000 --- a/internal/evaluation/rq2fixture/source-definition.schema.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://dewebprotocol.github.io/malt/schemas/malt-rq2-source-definition-v1.json", - "title": "MALT RQ2 source definition", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "fixture_id", "mutation_seed_sha256", "direct_files", "list_files", "operations"], - "properties": { - "schema_version": {"const": "malt-rq2-source-definition/v1"}, - "fixture_id": {"type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$"}, - "mutation_seed_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "direct_files": {"type": "array", "maxItems": 4096, "items": {"$ref": "#/$defs/direct"}}, - "list_files": {"type": "array", "maxItems": 4096, "items": {"$ref": "#/$defs/list"}}, - "operations": {"type": "array", "minItems": 1, "maxItems": 256, "items": {"oneOf": [ - {"$ref": "#/$defs/direct_replace"}, {"$ref": "#/$defs/direct_insert"}, {"$ref": "#/$defs/direct_delete"}, - {"$ref": "#/$defs/direct_move"}, {"$ref": "#/$defs/list_append"}, {"$ref": "#/$defs/list_replace"}, - {"$ref": "#/$defs/batch_insert"}, {"$ref": "#/$defs/document_edit"} - ]}} - }, - "$defs": { - "direct": { - "type": "object", "additionalProperties": false, "required": ["path", "coordinate", "bytes"], - "properties": {"path": {"type": "string", "minLength": 1}, "coordinate": {"type": "string", "minLength": 1}, "bytes": {"type": "string", "contentEncoding": "base64"}} - }, - "chunk": { - "type": "object", "additionalProperties": false, "required": ["index", "bytes"], - "properties": {"index": {"type": "integer", "minimum": 0}, "bytes": {"type": "string", "contentEncoding": "base64"}} - }, - "list": { - "type": "object", "additionalProperties": false, "required": ["path", "coordinate", "chunk_size", "total_size", "chunks"], - "properties": {"path": {"type": "string", "minLength": 1}, "coordinate": {"type": "string", "minLength": 1}, "chunk_size": {"type": "integer", "minimum": 1}, "total_size": {"type": "integer", "minimum": 1}, "chunks": {"type": "array", "minItems": 1, "maxItems": 65536, "items": {"$ref": "#/$defs/chunk"}}} - }, - "name": {"type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$"}, - "path": {"type": "string", "minLength": 1}, - "payload_bytes": {"type": "integer", "minimum": 1, "maximum": 67108864}, - "batch_target": { - "type": "object", "additionalProperties": false, "required": ["path", "coordinate", "payload_bytes"], - "properties": {"path": {"$ref": "#/$defs/path"}, "coordinate": {"$ref": "#/$defs/path"}, "payload_bytes": {"$ref": "#/$defs/payload_bytes"}} - }, - "direct_replace": { - "type": "object", "additionalProperties": false, "required": ["name", "kind", "source_path", "source_coordinate", "payload_bytes"], - "properties": {"name": {"$ref": "#/$defs/name"}, "kind": {"const": "direct-replace"}, "source_path": {"$ref": "#/$defs/path"}, "source_coordinate": {"$ref": "#/$defs/path"}, "payload_bytes": {"$ref": "#/$defs/payload_bytes"}} - }, - "direct_insert": { - "type": "object", "additionalProperties": false, "required": ["name", "kind", "destination_path", "destination_coordinate", "payload_bytes"], - "properties": {"name": {"$ref": "#/$defs/name"}, "kind": {"const": "direct-insert"}, "destination_path": {"$ref": "#/$defs/path"}, "destination_coordinate": {"$ref": "#/$defs/path"}, "payload_bytes": {"$ref": "#/$defs/payload_bytes"}} - }, - "direct_delete": { - "type": "object", "additionalProperties": false, "required": ["name", "kind", "source_path", "source_coordinate"], - "properties": {"name": {"$ref": "#/$defs/name"}, "kind": {"const": "direct-delete"}, "source_path": {"$ref": "#/$defs/path"}, "source_coordinate": {"$ref": "#/$defs/path"}} - }, - "direct_move": { - "type": "object", "additionalProperties": false, "required": ["name", "kind", "source_path", "source_coordinate", "destination_path", "destination_coordinate"], - "properties": {"name": {"$ref": "#/$defs/name"}, "kind": {"const": "direct-move"}, "source_path": {"$ref": "#/$defs/path"}, "source_coordinate": {"$ref": "#/$defs/path"}, "destination_path": {"$ref": "#/$defs/path"}, "destination_coordinate": {"$ref": "#/$defs/path"}} - }, - "list_append": { - "type": "object", "additionalProperties": false, "required": ["name", "kind", "source_path", "source_coordinate", "payload_bytes"], - "properties": {"name": {"$ref": "#/$defs/name"}, "kind": {"const": "list-append"}, "source_path": {"$ref": "#/$defs/path"}, "source_coordinate": {"$ref": "#/$defs/path"}, "payload_bytes": {"$ref": "#/$defs/payload_bytes"}} - }, - "list_replace": { - "type": "object", "additionalProperties": false, "required": ["name", "kind", "source_path", "source_coordinate", "payload_bytes", "list_index"], - "properties": {"name": {"$ref": "#/$defs/name"}, "kind": {"const": "list-replace"}, "source_path": {"$ref": "#/$defs/path"}, "source_coordinate": {"$ref": "#/$defs/path"}, "payload_bytes": {"$ref": "#/$defs/payload_bytes"}, "list_index": {"type": "integer", "minimum": 0}} - }, - "batch_insert": { - "type": "object", "additionalProperties": false, "required": ["name", "kind", "batch"], - "properties": {"name": {"$ref": "#/$defs/name"}, "kind": {"const": "batch-insert"}, "batch": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"$ref": "#/$defs/batch_target"}}} - }, - "document_edit": { - "type": "object", "additionalProperties": false, "required": ["name", "kind", "source_path", "source_coordinate", "payload_bytes"], - "properties": {"name": {"$ref": "#/$defs/name"}, "kind": {"const": "document-edit"}, "source_path": {"$ref": "#/$defs/path"}, "source_coordinate": {"$ref": "#/$defs/path"}, "payload_bytes": {"$ref": "#/$defs/payload_bytes"}} - } - } -} diff --git a/internal/evaluation/rq2fixture/source.go b/internal/evaluation/rq2fixture/source.go deleted file mode 100644 index 6436a21..0000000 --- a/internal/evaluation/rq2fixture/source.go +++ /dev/null @@ -1,183 +0,0 @@ -package rq2fixture - -import ( - "bytes" - _ "embed" - "encoding/json" - "errors" - "fmt" - "io" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - cid "github.com/ipfs/go-cid" -) - -const SourceSchemaVersion = "malt-rq2-source-definition/v1" - -//go:embed source-definition.schema.json -var SourceSchemaJSON []byte - -type SourceDirectFile struct { - Path string `json:"path"` - Coordinate string `json:"coordinate"` - Bytes []byte `json:"bytes"` -} - -type SourceListChunk struct { - Index uint64 `json:"index"` - Bytes []byte `json:"bytes"` -} - -type SourceListFile struct { - Path string `json:"path"` - Coordinate string `json:"coordinate"` - ChunkSize uint64 `json:"chunk_size"` - TotalSize uint64 `json:"total_size"` - Chunks []SourceListChunk `json:"chunks"` -} - -// SourceDefinition is the human-authored input to the production fixture -// builder. It contains source bytes and operation targets, but never roots or -// CIDs: those are independently computed for KZG and IPA by the builder. -type SourceDefinition struct { - SchemaVersion string `json:"schema_version"` - FixtureID string `json:"fixture_id"` - MutationSeedSHA256 string `json:"mutation_seed_sha256"` - DirectFiles []SourceDirectFile `json:"direct_files"` - ListFiles []SourceListFile `json:"list_files"` - Operations []Operation `json:"operations"` -} - -func DecodeSource(data []byte) (*SourceDefinition, error) { - if err := rejectDuplicateKeys(data); err != nil { - return nil, fmt.Errorf("decode RQ2 source definition: %w", err) - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var source SourceDefinition - if err := decoder.Decode(&source); err != nil { - return nil, fmt.Errorf("decode RQ2 source definition: %w", err) - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return nil, fmt.Errorf("RQ2 source definition must contain exactly one JSON object") - } - if err := source.Validate(); err != nil { - return nil, err - } - return &source, nil -} - -func (s *SourceDefinition) Validate() error { - if s == nil || s.SchemaVersion != SourceSchemaVersion || !identityPattern.MatchString(s.FixtureID) || !canonicalSHA256(s.MutationSeedSHA256) { - return fmt.Errorf("RQ2 source definition identity or schema is invalid") - } - if len(s.DirectFiles)+len(s.ListFiles) == 0 || len(s.DirectFiles)+len(s.ListFiles) > maxFiles || len(s.Operations) == 0 || len(s.Operations) > maxOperations { - return fmt.Errorf("RQ2 source definition file or operation count is outside evaluator bounds") - } - paths := make(map[string]string, len(s.DirectFiles)+len(s.ListFiles)) - var decodedBytes uint64 - for _, file := range s.DirectFiles { - if err := validatePathCoordinate(file.Path, file.Coordinate); err != nil { - return fmt.Errorf("direct file: %w", err) - } - if _, duplicate := paths[file.Path]; duplicate { - return fmt.Errorf("RQ2 source definition repeats path %q", file.Path) - } - paths[file.Path] = "direct" - decodedBytes += uint64(len(file.Bytes)) - } - chunkCount := 0 - for _, file := range s.ListFiles { - if err := validatePathCoordinate(file.Path, file.Coordinate); err != nil { - return fmt.Errorf("list file: %w", err) - } - if _, duplicate := paths[file.Path]; duplicate || file.ChunkSize == 0 || file.TotalSize == 0 || len(file.Chunks) == 0 { - return fmt.Errorf("list file %q identity or descriptor is invalid", file.Path) - } - var total uint64 - for index, chunk := range file.Chunks { - if chunk.Index != uint64(index) || len(chunk.Bytes) == 0 || uint64(len(chunk.Bytes)) > file.ChunkSize || index != len(file.Chunks)-1 && uint64(len(chunk.Bytes)) != file.ChunkSize { - return fmt.Errorf("list file %q chunk %d is not a bounded contiguous chunk", file.Path, index) - } - total += uint64(len(chunk.Bytes)) - decodedBytes += uint64(len(chunk.Bytes)) - } - if total != file.TotalSize { - return fmt.Errorf("list file %q bytes do not match total_size", file.Path) - } - paths[file.Path] = "list" - chunkCount += len(file.Chunks) - } - if decodedBytes == 0 || decodedBytes > maxDecodedBytes || chunkCount > maxChunks { - return fmt.Errorf("RQ2 source definition decoded bytes or chunks exceed evaluator bounds") - } - operations := make(map[string]struct{}, len(s.Operations)) - for _, operation := range s.Operations { - if !identityPattern.MatchString(operation.Name) { - return fmt.Errorf("RQ2 source definition operation name %q is invalid", operation.Name) - } - if _, duplicate := operations[operation.Name]; duplicate { - return fmt.Errorf("RQ2 source definition repeats operation %q", operation.Name) - } - operations[operation.Name] = struct{}{} - if err := validateOperation(operation, paths); err != nil { - return fmt.Errorf("operation %q: %w", operation.Name, err) - } - if operation.Kind == KindListAppend || operation.Kind == KindListReplace { - list, ok := s.list(operation.SourcePath) - if !ok || operation.Kind == KindListAppend && operation.PayloadBytes != list.ChunkSize { - return fmt.Errorf("operation %q does not match its fixed-list source", operation.Name) - } - if operation.Kind == KindListReplace && (operation.ListIndex == nil || *operation.ListIndex >= uint64(len(list.Chunks)) || operation.PayloadBytes != uint64(len(list.Chunks[*operation.ListIndex].Bytes))) { - return fmt.Errorf("operation %q replacement does not match its declared chunk", operation.Name) - } - } - } - return nil -} - -func (s *SourceDefinition) list(path string) (SourceListFile, bool) { - for _, file := range s.ListFiles { - if file.Path == path { - return file, true - } - } - return SourceListFile{}, false -} - -func (s *SourceDefinition) Fixture(roots []RootBinding) (*Fixture, error) { - if err := s.Validate(); err != nil { - return nil, err - } - fixture := &Fixture{ - SchemaVersion: SchemaVersion, FixtureID: s.FixtureID, MutationSeedSHA256: s.MutationSeedSHA256, - InitialRoots: append([]RootBinding(nil), roots...), Operations: append([]Operation(nil), s.Operations...), - DirectFiles: make([]DirectFile, len(s.DirectFiles)), ListFiles: make([]ListFile, len(s.ListFiles)), - } - for index, file := range s.DirectFiles { - key, err := rawCID(file.Bytes) - if err != nil { - return nil, err - } - fixture.DirectFiles[index] = DirectFile{Path: file.Path, Coordinate: file.Coordinate, Bytes: append([]byte(nil), file.Bytes...), CID: key.String()} - } - for index, file := range s.ListFiles { - result := ListFile{Path: file.Path, Coordinate: file.Coordinate, ChunkSize: file.ChunkSize, TotalSize: file.TotalSize, Chunks: make([]ListChunk, len(file.Chunks))} - for chunkIndex, chunk := range file.Chunks { - key, err := rawCID(chunk.Bytes) - if err != nil { - return nil, err - } - result.Chunks[chunkIndex] = ListChunk{Index: chunk.Index, Bytes: append([]byte(nil), chunk.Bytes...), CID: key.String()} - } - fixture.ListFiles[index] = result - } - if err := fixture.Validate(); err != nil { - return nil, err - } - return fixture, nil -} - -func rawCID(data []byte) (cid.Cid, error) { - return clientcas.CIDForBlock(clientcas.Block{Codec: cid.Raw, Data: data}) -} diff --git a/internal/evaluation/rq2fixture/source_schema_test.go b/internal/evaluation/rq2fixture/source_schema_test.go deleted file mode 100644 index bd48519..0000000 --- a/internal/evaluation/rq2fixture/source_schema_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package rq2fixture - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "testing" - - "github.com/santhosh-tekuri/jsonschema/v6" -) - -func TestSourceDefinitionSchemaMatchesStrictOperationDecoder(t *testing.T) { - schema := compileSourceSchema(t) - valid := testSourceDefinition() - raw, err := json.Marshal(valid) - if err != nil { - t.Fatal(err) - } - if _, err := DecodeSource(raw); err != nil { - t.Fatalf("Go decoder rejected valid all-kind source: %v", err) - } - if err := validateSourceSchema(schema, raw); err != nil { - t.Fatalf("schema rejected valid all-kind source: %v", err) - } - - hostile := []struct { - name string - mutate func(map[string]any) - }{ - {name: "unknown operation field", mutate: func(value map[string]any) { - operations(value)[0]["invented"] = true - }}, - {name: "missing required payload", mutate: func(value map[string]any) { - delete(operations(value)[0], "payload_bytes") - }}, - {name: "forbidden append index", mutate: func(value map[string]any) { - operations(value)[4]["list_index"] = float64(0) - }}, - {name: "unknown batch target field", mutate: func(value map[string]any) { - batch := operations(value)[6]["batch"].([]any) - batch[0].(map[string]any)["source_path"] = "wrong" - }}, - } - for _, test := range hostile { - t.Run(test.name, func(t *testing.T) { - var value map[string]any - if err := json.Unmarshal(raw, &value); err != nil { - t.Fatal(err) - } - test.mutate(value) - hostileRaw, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - if _, err := DecodeSource(hostileRaw); err == nil { - t.Fatal("Go strict decoder accepted hostile operation shape") - } - if err := validateSourceSchema(schema, hostileRaw); err == nil { - t.Fatal("JSON schema accepted hostile operation shape") - } - }) - } -} - -func compileSourceSchema(t *testing.T) *jsonschema.Schema { - t.Helper() - compiler := jsonschema.NewCompiler() - compiler.DefaultDraft(jsonschema.Draft2020) - document, err := jsonschema.UnmarshalJSON(bytes.NewReader(SourceSchemaJSON)) - if err != nil { - t.Fatal(err) - } - if err := compiler.AddResource("source-definition.schema.json", document); err != nil { - t.Fatal(err) - } - schema, err := compiler.Compile("source-definition.schema.json") - if err != nil { - t.Fatal(err) - } - return schema -} - -func validateSourceSchema(schema *jsonschema.Schema, raw []byte) error { - instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) - if err != nil { - return err - } - return schema.Validate(instance) -} - -func operations(value map[string]any) []map[string]any { - raw := value["operations"].([]any) - result := make([]map[string]any, len(raw)) - for index := range raw { - result[index] = raw[index].(map[string]any) - } - return result -} - -func testSourceDefinition() SourceDefinition { - chunkA := make([]byte, 32) - chunkB := make([]byte, 32) - copy(chunkA, "chunk-a") - copy(chunkB, "chunk-b") - seed := sha256.Sum256([]byte("source schema parity seed")) - index := uint64(0) - return SourceDefinition{ - SchemaVersion: SourceSchemaVersion, FixtureID: "fixture", MutationSeedSHA256: hex.EncodeToString(seed[:]), - DirectFiles: []SourceDirectFile{ - {Path: "document.txt", Coordinate: "document.txt", Bytes: []byte("document")}, - {Path: "delete.txt", Coordinate: "delete.txt", Bytes: []byte("delete")}, - {Path: "move.txt", Coordinate: "move.txt", Bytes: []byte("move")}, - }, - ListFiles: []SourceListFile{{ - Path: "list.bin", Coordinate: "list.bin", ChunkSize: 32, TotalSize: 64, - Chunks: []SourceListChunk{{Index: 0, Bytes: chunkA}, {Index: 1, Bytes: chunkB}}, - }}, - Operations: []Operation{ - {Name: "direct-replace", Kind: KindDirectReplace, SourcePath: "document.txt", SourceCoordinate: "document.txt", PayloadBytes: 16}, - {Name: "direct-insert", Kind: KindDirectInsert, DestinationPath: "insert.txt", DestinationCoordinate: "insert.txt", PayloadBytes: 16}, - {Name: "direct-delete", Kind: KindDirectDelete, SourcePath: "delete.txt", SourceCoordinate: "delete.txt"}, - {Name: "direct-move", Kind: KindDirectMove, SourcePath: "move.txt", SourceCoordinate: "move.txt", DestinationPath: "moved.txt", DestinationCoordinate: "moved.txt"}, - {Name: "list-append", Kind: KindListAppend, SourcePath: "list.bin", SourceCoordinate: "list.bin", PayloadBytes: 32}, - {Name: "list-replace", Kind: KindListReplace, SourcePath: "list.bin", SourceCoordinate: "list.bin", PayloadBytes: 32, ListIndex: &index}, - {Name: "batch-insert", Kind: KindBatchInsert, Batch: []BatchTarget{{Path: "batch.txt", Coordinate: "batch.txt", PayloadBytes: 16}}}, - {Name: "document-edit", Kind: KindDocumentEdit, SourcePath: "document.txt", SourceCoordinate: "document.txt", PayloadBytes: 32}, - }, - } -} - -func ExampleSourceDefinition() { - value := testSourceDefinition() - fmt.Println(value.SchemaVersion, len(value.Operations)) - // Output: malt-rq2-source-definition/v1 8 -} diff --git a/internal/evaluation/rq2metrics/contract.go b/internal/evaluation/rq2metrics/contract.go deleted file mode 100644 index 60a5c4a..0000000 --- a/internal/evaluation/rq2metrics/contract.go +++ /dev/null @@ -1,180 +0,0 @@ -// Package rq2metrics freezes the aggregation semantics of the RQ2 mutation -// metric wire contract. It deliberately treats duration taxonomy separately -// from bytes and counts: the latter are field-specific resource observations -// and are never an additive decomposition. -package rq2metrics - -import ( - "fmt" - "math" - "slices" -) - -const ( - // TaxonomyProfile is emitted by every successful RQ2 mutation record. - TaxonomyProfile = "malt-rq2-metric-taxonomy/v2" - // ResourceAggregationRule prevents reports from stacking byte/count values - // merely because their phase durations are in one exclusive wall-time set. - ResourceAggregationRule = "bytes-and-counts-field-specific-non-additive/v1" -) - -// Taxonomy is the frozen reporting contract for duration_ns. Inclusive totals -// and nested diagnostics must never be stacked with their children/parents. -// ExclusiveMutationPhases may be stacked under mutation_total only; the -// residual is intentionally unclassified operation work. BrowserBoundaryPhases -// are exclusive only under the browser host call, outside mutation_total. -// OrthogonalResources use a different resource domain (CPU or memory). -type Taxonomy struct { - InclusiveTotals []string - ExclusiveMutationPhases []string - NestedDiagnostics []string - ColdStartupPhases []string - BrowserBoundaryPhases []string - OrthogonalResources []string - ResourceAggregationProfile string -} - -var contract = Taxonomy{ - InclusiveTotals: []string{"mutation_total", "candidate_generation", "first_mutation"}, - ExclusiveMutationPhases: []string{ - "scan", "chunk", "hash", "graph_snapshot", - "candidate_apply", "candidate_export", "batch_encoding", "upload", "receipt_check", - }, - NestedDiagnostics: []string{"gateway_validate_stage", "gateway_persist"}, - ColdStartupPhases: []string{"wasm_download", "wasm_instantiate", "parameter_load"}, - BrowserBoundaryPhases: []string{"js_wasm_boundary"}, - OrthogonalResources: []string{"cpu_total", "peak_memory"}, - ResourceAggregationProfile: ResourceAggregationRule, -} - -// Contract returns an independent copy so tests and report adapters can bind -// the exact classification without mutating the process-global definition. -func Contract() Taxonomy { - return Taxonomy{ - InclusiveTotals: slices.Clone(contract.InclusiveTotals), - ExclusiveMutationPhases: slices.Clone(contract.ExclusiveMutationPhases), - NestedDiagnostics: slices.Clone(contract.NestedDiagnostics), - ColdStartupPhases: slices.Clone(contract.ColdStartupPhases), - BrowserBoundaryPhases: slices.Clone(contract.BrowserBoundaryPhases), - OrthogonalResources: slices.Clone(contract.OrthogonalResources), - ResourceAggregationProfile: contract.ResourceAggregationProfile, - } -} - -// Observation contains only the wall-duration attributes needed for -// reconciliation. Applicability is included so operation-specific omitted -// phases (for example delete payload upload) contribute no synthetic zero. -type Observation struct { - Applicable bool - DurationNS uint64 -} - -// Validate reconciles the overlapping duration domains. It is intentionally -// fail-closed: overflow or a child/subset exceeding its containing measurement -// invalidates the record instead of being hidden in a paper residual. -func Validate(profile string, values map[string]Observation, browser, coldMutation bool) error { - if profile != TaxonomyProfile { - return fmt.Errorf("RQ2 metric taxonomy profile %q is not %q", profile, TaxonomyProfile) - } - for _, name := range allMetricNames() { - if _, ok := values[name]; !ok { - return fmt.Errorf("RQ2 metric taxonomy is missing %q", name) - } - } - total := values["mutation_total"] - clientRoot := values["candidate_generation"] - if !total.Applicable || !clientRoot.Applicable || total.DurationNS == 0 || clientRoot.DurationNS == 0 { - return fmt.Errorf("RQ2 inclusive mutation/client-root totals are absent") - } - if clientRoot.DurationNS > total.DurationNS { - return fmt.Errorf("candidate_generation exceeds inclusive mutation_total") - } - exclusive, err := sumApplicable(values, contract.ExclusiveMutationPhases) - if err != nil { - return err - } - if exclusive > total.DurationNS { - return fmt.Errorf("exclusive mutation phases exceed inclusive mutation_total") - } - sdkExclusive, err := sumApplicable(values, []string{"graph_snapshot", "candidate_apply", "candidate_export"}) - if err != nil { - return err - } - if sdkExclusive > clientRoot.DurationNS { - return fmt.Errorf("exclusive SDK phases exceed candidate_generation") - } - gateway, err := sumApplicable(values, []string{"gateway_validate_stage", "gateway_persist"}) - if err != nil { - return err - } - if gateway > total.DurationNS { - return fmt.Errorf("nested Gateway diagnostics exceed inclusive mutation_total") - } - first := values["first_mutation"] - boundary := values["js_wasm_boundary"] - if browser { - if !boundary.Applicable { - return fmt.Errorf("browser metric taxonomy omits js_wasm_boundary") - } - if first.Applicable != coldMutation { - return fmt.Errorf("browser first_mutation applicability=%t, want %t", first.Applicable, coldMutation) - } - if coldMutation { - combined, err := add(total.DurationNS, boundary.DurationNS) - if err != nil { - return err - } - if combined > first.DurationNS { - return fmt.Errorf("mutation_total plus JS/WASM boundary exceeds first_mutation host call") - } - } - } else if first.Applicable || boundary.Applicable { - return fmt.Errorf("native metric taxonomy contains browser host-call durations") - } - return nil -} - -func allMetricNames() []string { - result := make([]string, 0, len(contract.InclusiveTotals)+len(contract.ExclusiveMutationPhases)+len(contract.NestedDiagnostics)+len(contract.ColdStartupPhases)+len(contract.BrowserBoundaryPhases)+len(contract.OrthogonalResources)) - for _, names := range [][]string{ - contract.InclusiveTotals, contract.ExclusiveMutationPhases, contract.NestedDiagnostics, - contract.ColdStartupPhases, contract.BrowserBoundaryPhases, contract.OrthogonalResources, - } { - result = append(result, names...) - } - return result -} - -func sumApplicable(values map[string]Observation, names []string) (uint64, error) { - var total uint64 - for _, name := range names { - value := values[name] - if !value.Applicable { - continue - } - var err error - total, err = add(total, value.DurationNS) - if err != nil { - return 0, fmt.Errorf("RQ2 duration reconciliation for %q: %w", name, err) - } - } - return total, nil -} - -func add(left, right uint64) (uint64, error) { - return AddDurations(left, right) -} - -// AddDurations performs the same checked aggregation used by the executable -// taxonomy contract. Worker adapters use it when extending an inclusive SDK -// subtotal with caller-owned canonicalization phases. -func AddDurations(values ...uint64) (uint64, error) { - var total uint64 - for _, value := range values { - if math.MaxUint64-total < value { - return 0, fmt.Errorf("duration sum overflows uint64") - } - total += value - } - return total, nil -} diff --git a/internal/evaluation/rq2metrics/contract_test.go b/internal/evaluation/rq2metrics/contract_test.go deleted file mode 100644 index 14ffdce..0000000 --- a/internal/evaluation/rq2metrics/contract_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package rq2metrics - -import ( - "math" - "reflect" - "strings" - "testing" -) - -func TestTaxonomyContractIsFrozenAndDefensivelyCopied(t *testing.T) { - got := Contract() - want := Taxonomy{ - InclusiveTotals: []string{"mutation_total", "candidate_generation", "first_mutation"}, - ExclusiveMutationPhases: []string{ - "scan", "chunk", "hash", "graph_snapshot", - "candidate_apply", "candidate_export", "batch_encoding", "upload", "receipt_check", - }, - NestedDiagnostics: []string{"gateway_validate_stage", "gateway_persist"}, - ColdStartupPhases: []string{"wasm_download", "wasm_instantiate", "parameter_load"}, - BrowserBoundaryPhases: []string{"js_wasm_boundary"}, - OrthogonalResources: []string{"cpu_total", "peak_memory"}, - ResourceAggregationProfile: ResourceAggregationRule, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("taxonomy = %#v, want %#v", got, want) - } - got.InclusiveTotals[0] = "changed" - if Contract().InclusiveTotals[0] != "mutation_total" { - t.Fatal("caller mutated the frozen taxonomy") - } -} - -func TestValidateReconcilesDurationDomains(t *testing.T) { - values := validObservations(true, true) - if err := Validate(TaxonomyProfile, values, true, true); err != nil { - t.Fatal(err) - } - tests := map[string]func(map[string]Observation){ - "exclusive exceeds mutation total": func(value map[string]Observation) { - value["scan"] = Observation{Applicable: true, DurationNS: 101} - }, - "sdk phases exceed subtotal": func(value map[string]Observation) { - value["candidate_generation"] = Observation{Applicable: true, DurationNS: 5} - }, - "gateway diagnostics exceed total": func(value map[string]Observation) { - value["gateway_validate_stage"] = Observation{Applicable: true, DurationNS: 60} - value["gateway_persist"] = Observation{Applicable: true, DurationNS: 60} - }, - "browser call cannot contain mutation and boundary": func(value map[string]Observation) { - value["first_mutation"] = Observation{Applicable: true, DurationNS: 100} - value["js_wasm_boundary"] = Observation{Applicable: true, DurationNS: 1} - }, - "sum overflow": func(value map[string]Observation) { - value["mutation_total"] = Observation{Applicable: true, DurationNS: math.MaxUint64} - value["scan"] = Observation{Applicable: true, DurationNS: math.MaxUint64} - value["chunk"] = Observation{Applicable: true, DurationNS: 1} - }, - } - for name, mutate := range tests { - t.Run(name, func(t *testing.T) { - copy := cloneObservations(values) - mutate(copy) - if err := Validate(TaxonomyProfile, copy, true, true); err == nil { - t.Fatal("invalid overlapping durations were accepted") - } - }) - } -} - -func TestValidateRejectsProfileApplicabilityAndMissingMetricDrift(t *testing.T) { - values := validObservations(false, false) - if err := Validate(TaxonomyProfile, values, false, false); err != nil { - t.Fatal(err) - } - if err := Validate("stale", values, false, false); err == nil || !strings.Contains(err.Error(), "profile") { - t.Fatalf("profile error = %v", err) - } - delete(values, "peak_memory") - if err := Validate(TaxonomyProfile, values, false, false); err == nil || !strings.Contains(err.Error(), "missing") { - t.Fatalf("missing-metric error = %v", err) - } -} - -func TestAddDurationsIsExactAndFailsClosedOnOverflow(t *testing.T) { - if total, err := AddDurations(1, 2, 3, 4); err != nil || total != 10 { - t.Fatalf("AddDurations = %d, %v; want 10", total, err) - } - if _, err := AddDurations(math.MaxUint64-1, 1, 1); err == nil { - t.Fatal("duration aggregation overflow was accepted") - } -} - -func validObservations(browser, cold bool) map[string]Observation { - values := make(map[string]Observation) - for _, name := range allMetricNames() { - values[name] = Observation{} - } - values["mutation_total"] = Observation{Applicable: true, DurationNS: 100} - values["candidate_generation"] = Observation{Applicable: true, DurationNS: 30} - for _, name := range []string{"scan", "chunk", "hash", "graph_snapshot", "candidate_apply", "candidate_export", "batch_encoding", "upload", "receipt_check"} { - values[name] = Observation{Applicable: true, DurationNS: 2} - } - values["gateway_validate_stage"] = Observation{Applicable: true, DurationNS: 2} - values["gateway_persist"] = Observation{Applicable: true, DurationNS: 2} - values["cpu_total"] = Observation{Applicable: true, DurationNS: 150} - values["peak_memory"] = Observation{Applicable: true, DurationNS: 100} - if browser { - values["js_wasm_boundary"] = Observation{Applicable: true, DurationNS: 5} - if cold { - values["first_mutation"] = Observation{Applicable: true, DurationNS: 110} - for _, name := range []string{"wasm_download", "wasm_instantiate", "parameter_load"} { - values[name] = Observation{Applicable: true, DurationNS: 2} - } - } - } - return values -} - -func cloneObservations(source map[string]Observation) map[string]Observation { - result := make(map[string]Observation, len(source)) - for name, value := range source { - result[name] = value - } - return result -} diff --git a/internal/evaluation/rq2wire/wire.go b/internal/evaluation/rq2wire/wire.go deleted file mode 100644 index 4b60c4e..0000000 --- a/internal/evaluation/rq2wire/wire.go +++ /dev/null @@ -1,465 +0,0 @@ -// Package rq2wire mirrors the evaluator-owned RQ2 JSONL contract without -// importing malt-evaluation. It is shared only by the browser host adapter and -// the real Go/WASM writer artifact built from this repository. -package rq2wire - -import ( - "encoding/hex" - "fmt" - "regexp" - "slices" - "strings" - - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2metrics" - "github.com/dewebprotocol/malt-core/maltcid" - cid "github.com/ipfs/go-cid" -) - -const ( - WorkerRequestSchema = "malt-rq2-worker-request/v1" - WorkerRecordSchema = "malt-rq2-worker-record/v2" - - ClientNative = "native" - ClientBrowserWASM = "browser-wasm" - - LifecycleNativeLong = "native-long-lived" - LifecycleBrowserCold = "browser-cold" - LifecycleBrowserSteady = "browser-steady" - LifecycleBrowserShort = "browser-short-session" - - RecordPreflight = "preflight" - RecordSessionStart = "session-start" - RecordMutation = "mutation" - RecordSessionEnd = "session-end" - - PhaseObserved = "observed" - PhaseNotApplicable = "not-applicable" -) - -var IDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$`) - -type WorkerRequest struct { - SchemaVersion string `json:"schema_version"` - WorkerID string `json:"worker_id"` - RequestID string `json:"request_id"` - RecordKind string `json:"record_kind"` - SessionID string `json:"session_id"` - ClientKind string `json:"client_kind"` - PlatformID string `json:"platform_id"` - Backend string `json:"backend"` - Lifecycle string `json:"lifecycle"` - FixtureID string `json:"fixture_id"` - Operation string `json:"operation,omitempty"` - Measured bool `json:"measured"` - ExpectedAcceptedRoot string `json:"expected_accepted_root,omitempty"` -} - -func (r WorkerRequest) Validate() error { - if r.SchemaVersion != WorkerRequestSchema || !IDPattern.MatchString(r.WorkerID) || !IDPattern.MatchString(r.RequestID) || - !IDPattern.MatchString(r.SessionID) || !IDPattern.MatchString(r.PlatformID) || !IDPattern.MatchString(r.FixtureID) { - return fmt.Errorf("invalid RQ2 worker request identity") - } - if err := ValidateCoordinate(r.ClientKind, r.Backend, r.Lifecycle); err != nil { - return err - } - switch r.RecordKind { - case RecordPreflight: - if r.Operation != "" || r.Measured || r.ExpectedAcceptedRoot != "" { - return fmt.Errorf("preflight request contains mutation/session state") - } - case RecordSessionStart, RecordSessionEnd: - if r.Operation != "" || r.Measured || !ValidTypedRoot(r.ExpectedAcceptedRoot, r.Backend) { - return fmt.Errorf("session request must bind one typed accepted root") - } - case RecordMutation: - if !IDPattern.MatchString(r.Operation) || !ValidTypedRoot(r.ExpectedAcceptedRoot, r.Backend) { - return fmt.Errorf("mutation request is incomplete") - } - default: - return fmt.Errorf("unsupported RQ2 worker request kind %q", r.RecordKind) - } - return nil -} - -type RuntimeEvidence struct { - OS string `json:"os"` - Architecture string `json:"architecture"` - LowPowerARM bool `json:"low_power_arm"` - MachineDescriptorID string `json:"machine_descriptor_id"` - MachineDescriptorSHA256 string `json:"machine_descriptor_sha256"` - BrowserEngine string `json:"browser_engine,omitempty"` - WASMSHA256 string `json:"wasm_sha256,omitempty"` - ParameterProfile string `json:"parameter_profile,omitempty"` - ParameterSHA256 string `json:"parameter_sha256,omitempty"` - ParameterInputBytes uint64 `json:"parameter_input_bytes,omitempty"` -} - -func ParameterEvidence(backend string) (profile, digest string, inputBytes uint64, ok bool) { - switch backend { - case "kzg": - // Core NewScheme consumes its 192-byte opening key and 393216-byte - // Lagrange writer key. Digest order is opening key, then writer key. - return "malt-kzg4096-binary-opening-then-writer-key/v1", "5ce50c7a06499dee8b17987d594e5552232d9bd7a5ea8c5f1b29e951717f0c9d", 393408, true - case "ipa": - // go-ipa GenerateRandomPoints consumes this exact 19-byte seed; - // VectorLength=256 is fixed code/configuration rather than loaded bytes. - return "go-ipa-53bbb0ceb27a-seed-eth-verkle-oct-2021", "08c26364c812dffb5ba046479f25ca9afdc8ab39b0c89f37506cab988a96ac21", 19, true - default: - return "", "", 0, false - } -} - -type SessionEvidence struct { - GraphFetch PhaseMeasurement `json:"graph_fetch"` - GraphImport PhaseMeasurement `json:"graph_import"` - AcceptedRoot string `json:"accepted_root"` - ReceiptCount uint64 `json:"receipt_count"` - AuditPassed bool `json:"audit_passed"` -} - -type PhaseMeasurement struct { - Applicable bool `json:"applicable"` - Status string `json:"status"` - DurationNS uint64 `json:"duration_ns"` - Bytes uint64 `json:"bytes"` - Count uint64 `json:"count"` -} - -func ObservedPhase(duration, bytes, count uint64) PhaseMeasurement { - return PhaseMeasurement{Applicable: true, Status: PhaseObserved, DurationNS: duration, Bytes: bytes, Count: count} -} - -func NotApplicablePhase() PhaseMeasurement { - return PhaseMeasurement{Status: PhaseNotApplicable} -} - -func (p PhaseMeasurement) validate(name string, required bool) error { - if p.Applicable != required { - return fmt.Errorf("phase %s applicable=%t, want %t", name, p.Applicable, required) - } - if !p.Applicable { - if p.Status != PhaseNotApplicable { - return fmt.Errorf("non-applicable phase %s has invalid status %q", name, p.Status) - } - if p.DurationNS != 0 || p.Bytes != 0 || p.Count != 0 { - return fmt.Errorf("non-applicable phase %s has measurements", name) - } - return nil - } - if p.Status != PhaseObserved { - return fmt.Errorf("applicable phase %s has status %q", name, p.Status) - } - if p.Count == 0 { - return fmt.Errorf("applicable phase %s has no observation count", name) - } - return nil -} - -type MutationMetrics struct { - // TaxonomyProfile binds the executable aggregation contract. Duration - // classes are frozen in rq2metrics; bytes/counts remain field-specific, - // non-additive resource observations regardless of duration class. - TaxonomyProfile string `json:"taxonomy_profile"` - MutationTotal PhaseMeasurement `json:"mutation_total"` - Scan PhaseMeasurement `json:"scan"` - Chunk PhaseMeasurement `json:"chunk"` - Hash PhaseMeasurement `json:"hash"` - - GraphSnapshot PhaseMeasurement `json:"graph_snapshot"` - - CandidateExport PhaseMeasurement `json:"candidate_export"` - CandidateApply PhaseMeasurement `json:"candidate_apply"` - CandidateGeneration PhaseMeasurement `json:"candidate_generation"` - BatchEncoding PhaseMeasurement `json:"batch_encoding"` - Upload PhaseMeasurement `json:"upload"` - GatewayValidateStage PhaseMeasurement `json:"gateway_validate_stage"` - GatewayPersist PhaseMeasurement `json:"gateway_persist"` - ReceiptCheck PhaseMeasurement `json:"receipt_check"` - CPUTotal PhaseMeasurement `json:"cpu_total"` - PeakMemory PhaseMeasurement `json:"peak_memory"` - WASMDownload PhaseMeasurement `json:"wasm_download"` - WASMInstantiate PhaseMeasurement `json:"wasm_instantiate"` - ParameterLoad PhaseMeasurement `json:"parameter_load"` - FirstMutation PhaseMeasurement `json:"first_mutation"` - JSWASMBoundary PhaseMeasurement `json:"js_wasm_boundary"` -} - -func (m MutationMetrics) Validate(clientKind, backend, lifecycle, operation string) error { - if m.TaxonomyProfile != rq2metrics.TaxonomyProfile { - return fmt.Errorf("invalid RQ2 metric taxonomy profile %q", m.TaxonomyProfile) - } - scanChunkRequired, hashRequired := pipelineApplicability(clientKind, operation) - for _, phase := range []struct { - name string - value PhaseMeasurement - required bool - }{{"scan", m.Scan, scanChunkRequired}, {"chunk", m.Chunk, scanChunkRequired}, {"hash", m.Hash, hashRequired}} { - if err := phase.value.validate(phase.name, phase.required); err != nil { - return err - } - } - for _, phase := range []struct { - name string - value PhaseMeasurement - }{ - {"mutation_total", m.MutationTotal}, {"graph_snapshot", m.GraphSnapshot}, - {"candidate_export", m.CandidateExport}, {"candidate_apply", m.CandidateApply}, - {"candidate_generation", m.CandidateGeneration}, {"batch_encoding", m.BatchEncoding}, - {"gateway_validate_stage", m.GatewayValidateStage}, {"gateway_persist", m.GatewayPersist}, {"receipt_check", m.ReceiptCheck}, - {"cpu_total", m.CPUTotal}, {"peak_memory", m.PeakMemory}, - } { - if err := phase.value.validate(phase.name, true); err != nil { - return err - } - } - uploadRequired := clientKind == ClientBrowserWASM || clientKind == ClientNative && - operation != "delete-directory-entry" && operation != "move" && operation != "rename" - if err := m.Upload.validate("upload", uploadRequired); err != nil { - return err - } - if m.MutationTotal.DurationNS == 0 || m.BatchEncoding.Bytes == 0 || - m.ReceiptCheck.Bytes == 0 || m.PeakMemory.Bytes == 0 || uploadRequired && m.Upload.Bytes == 0 { - return fmt.Errorf("total latency and directional batch/upload/receipt/memory accounting must be nonzero") - } - browser := clientKind == ClientBrowserWASM - cold := lifecycle == LifecycleBrowserCold - short := lifecycle == LifecycleBrowserShort - coldObserved := cold || short && m.WASMDownload.Applicable - for _, phase := range []struct { - name string - value PhaseMeasurement - required bool - }{ - {"wasm_download", m.WASMDownload, coldObserved}, {"wasm_instantiate", m.WASMInstantiate, coldObserved}, - {"parameter_load", m.ParameterLoad, coldObserved}, {"first_mutation", m.FirstMutation, coldObserved}, - {"js_wasm_boundary", m.JSWASMBoundary, browser}, - } { - if err := phase.value.validate(phase.name, phase.required); err != nil { - return err - } - } - if coldObserved && (m.WASMDownload.Bytes == 0 || m.ParameterLoad.Bytes == 0) { - return fmt.Errorf("cold WASM download and parameter byte accounting must be nonzero") - } - if coldObserved { - _, _, inputBytes, ok := ParameterEvidence(backend) - if !ok || m.ParameterLoad.Bytes != inputBytes { - return fmt.Errorf("cold parameter-load bytes do not match the exact backend initialization input") - } - } - return rq2metrics.Validate(m.TaxonomyProfile, m.durationObservations(), browser, coldObserved) -} - -func pipelineApplicability(clientKind, operation string) (scanChunk, hash bool) { - nativePayload := clientKind == ClientNative && operation != "delete-directory-entry" && operation != "move" && operation != "rename" - return operation == "document-edit-cid-binding-submit" || nativePayload, clientKind == ClientBrowserWASM || nativePayload -} - -func (m MutationMetrics) durationObservations() map[string]rq2metrics.Observation { - result := make(map[string]rq2metrics.Observation, 23) - for _, metric := range []struct { - name string - value PhaseMeasurement - }{ - {"mutation_total", m.MutationTotal}, {"scan", m.Scan}, {"chunk", m.Chunk}, {"hash", m.Hash}, - {"graph_snapshot", m.GraphSnapshot}, - {"candidate_export", m.CandidateExport}, - {"candidate_apply", m.CandidateApply}, {"candidate_generation", m.CandidateGeneration}, - {"batch_encoding", m.BatchEncoding}, {"upload", m.Upload}, {"gateway_validate_stage", m.GatewayValidateStage}, - {"gateway_persist", m.GatewayPersist}, {"receipt_check", m.ReceiptCheck}, {"cpu_total", m.CPUTotal}, - {"peak_memory", m.PeakMemory}, {"wasm_download", m.WASMDownload}, {"wasm_instantiate", m.WASMInstantiate}, - {"parameter_load", m.ParameterLoad}, {"first_mutation", m.FirstMutation}, {"js_wasm_boundary", m.JSWASMBoundary}, - } { - result[metric.name] = rq2metrics.Observation{Applicable: metric.value.Applicable, DurationNS: metric.value.DurationNS} - } - return result -} - -type MutationEvidence struct { - Operation string `json:"operation"` - PriorRoot string `json:"prior_root"` - CandidateRoot string `json:"candidate_root"` - ReceiptRoot string `json:"receipt_root"` - ReceiptAccepted bool `json:"receipt_accepted"` - - BatchSHA256 string `json:"batch_sha256"` - Metrics MutationMetrics `json:"metrics"` -} - -type WorkerRecord struct { - SchemaVersion string `json:"schema_version"` - WorkerID string `json:"worker_id"` - RequestID string `json:"request_id"` - RecordKind string `json:"record_kind"` - SessionID string `json:"session_id"` - ClientKind string `json:"client_kind"` - PlatformID string `json:"platform_id"` - Backend string `json:"backend"` - Lifecycle string `json:"lifecycle"` - FixtureID string `json:"fixture_id"` - Measured bool `json:"measured"` - Success bool `json:"success"` - FailureClass string `json:"failure_class,omitempty"` - Error string `json:"error,omitempty"` - Capabilities []string `json:"capabilities,omitempty"` - Runtime *RuntimeEvidence `json:"runtime,omitempty"` - Session *SessionEvidence `json:"session,omitempty"` - Mutation *MutationEvidence `json:"mutation,omitempty"` -} - -func BaseRecord(request WorkerRequest) WorkerRecord { - return WorkerRecord{ - SchemaVersion: WorkerRecordSchema, WorkerID: request.WorkerID, RequestID: request.RequestID, - RecordKind: request.RecordKind, SessionID: request.SessionID, ClientKind: request.ClientKind, - PlatformID: request.PlatformID, Backend: request.Backend, Lifecycle: request.Lifecycle, - FixtureID: request.FixtureID, Measured: request.Measured, - } -} - -func FailedRecord(request WorkerRequest, class string, err error) WorkerRecord { - record := BaseRecord(request) - record.FailureClass, record.Error = class, err.Error() - return record -} - -func (r WorkerRecord) Validate() error { - if !slices.Contains([]string{RecordPreflight, RecordSessionStart, RecordMutation, RecordSessionEnd}, r.RecordKind) { - return fmt.Errorf("unsupported RQ2 worker record kind %q", r.RecordKind) - } - if !IDPattern.MatchString(r.WorkerID) || !IDPattern.MatchString(r.RequestID) || !IDPattern.MatchString(r.SessionID) || - !IDPattern.MatchString(r.PlatformID) || !IDPattern.MatchString(r.FixtureID) || r.SchemaVersion != WorkerRecordSchema { - return fmt.Errorf("invalid RQ2 worker record identity") - } - if err := ValidateCoordinate(r.ClientKind, r.Backend, r.Lifecycle); err != nil { - return err - } - if !r.Success { - if r.FailureClass == "" || r.Error == "" || r.Capabilities != nil || r.Runtime != nil || r.Session != nil || r.Mutation != nil { - return fmt.Errorf("failed RQ2 worker record contains invalid evidence") - } - return nil - } - if r.FailureClass != "" || r.Error != "" { - return fmt.Errorf("successful RQ2 worker record has failure metadata") - } - switch r.RecordKind { - case RecordPreflight: - if r.Measured || r.Runtime == nil || r.Session != nil || r.Mutation != nil || !slices.Equal(r.Capabilities, RequiredCapabilities(r.ClientKind, r.Backend)) { - return fmt.Errorf("invalid RQ2 preflight evidence") - } - if !RuntimeIdentityValid(*r.Runtime) { - return fmt.Errorf("RQ2 preflight machine descriptor provenance is incomplete") - } - if r.ClientKind == ClientBrowserWASM { - profile, digest, inputBytes, ok := ParameterEvidence(r.Backend) - if !ok || r.Runtime.ParameterProfile != profile || r.Runtime.ParameterSHA256 != digest || r.Runtime.ParameterInputBytes != inputBytes { - return fmt.Errorf("browser preflight parameter provenance is not exact") - } - } else if r.Runtime.ParameterProfile != "" || r.Runtime.ParameterSHA256 != "" || r.Runtime.ParameterInputBytes != 0 { - return fmt.Errorf("native preflight must not claim browser parameter-load provenance") - } - case RecordSessionStart: - if r.Session == nil || r.Session.GraphFetch.validate("graph_fetch", true) != nil || r.Session.GraphImport.validate("graph_import", true) != nil || r.Session.GraphFetch.Bytes == 0 || r.Session.GraphImport.Count == 0 { - return fmt.Errorf("session-start omits authenticated graph import") - } - if r.Measured || r.Session == nil || r.Mutation != nil || r.Runtime != nil || r.Capabilities != nil || - !ValidTypedRoot(r.Session.AcceptedRoot, r.Backend) || r.Session.ReceiptCount != 0 || r.Session.AuditPassed { - return fmt.Errorf("invalid RQ2 session-start evidence") - } - case RecordMutation: - if r.Mutation == nil || r.Session != nil || r.Runtime != nil || r.Capabilities != nil { - return fmt.Errorf("invalid RQ2 mutation evidence") - } - if !IDPattern.MatchString(r.Mutation.Operation) || !ValidTypedRoot(r.Mutation.PriorRoot, r.Backend) || - !ValidTypedRoot(r.Mutation.CandidateRoot, r.Backend) || r.Mutation.CandidateRoot != r.Mutation.ReceiptRoot || !r.Mutation.ReceiptAccepted { - return fmt.Errorf("invalid RQ2 mutation roots") - } - for _, digest := range []string{r.Mutation.BatchSHA256} { - if !CanonicalSHA256(digest) { - return fmt.Errorf("invalid RQ2 mutation digest") - } - } - return r.Mutation.Metrics.Validate(r.ClientKind, r.Backend, r.Lifecycle, r.Mutation.Operation) - case RecordSessionEnd: - if r.Session == nil || r.Session.GraphFetch.validate("graph_fetch", false) != nil || r.Session.GraphImport.validate("graph_import", false) != nil { - return fmt.Errorf("session-end carries startup graph import") - } - if r.Measured || r.Session == nil || r.Mutation != nil || r.Runtime != nil || r.Capabilities != nil || - !ValidTypedRoot(r.Session.AcceptedRoot, r.Backend) || !r.Session.AuditPassed { - return fmt.Errorf("invalid RQ2 session-end evidence") - } - default: - return fmt.Errorf("unsupported RQ2 record kind %q", r.RecordKind) - } - return nil -} - -func RequiredCapabilities(clientKind, backend string) []string { - values := []string{"authentication-batch-v0", "authentication-candidate-v0", "exact-authentication-receipt-v0", "phase-metrics-v2", "retained-authentication-writer-v0"} - if clientKind == ClientNative { - values = append(values, "long-lived-session-v1", "native-writer-v1") - } else if clientKind == ClientBrowserWASM { - values = append(values, "wasm-cold-start-v1", "wasm-steady-session-v1", "wasm-writer-v1") - } - values = append(values, "commitment-"+backend+"-v1") - slices.Sort(values) - return values -} - -func ValidateCoordinate(clientKind, backend, lifecycle string) error { - if backend != "kzg" && backend != "ipa" { - return fmt.Errorf("unsupported RQ2 backend %q", backend) - } - switch clientKind { - case ClientNative: - if lifecycle != LifecycleNativeLong { - return fmt.Errorf("native client requires long-lived lifecycle") - } - case ClientBrowserWASM: - if lifecycle != LifecycleBrowserCold && lifecycle != LifecycleBrowserSteady && lifecycle != LifecycleBrowserShort { - return fmt.Errorf("browser client requires cold, steady, or short-session lifecycle") - } - default: - return fmt.Errorf("unsupported RQ2 client kind %q", clientKind) - } - return nil -} - -func ValidTypedRoot(value, backend string) bool { - parsed, err := cid.Parse(value) - descriptor, _, rootErr := maltcid.ParseRoot(parsed) - profile := maltcid.KZG4096 - if backend == "ipa" { - profile = maltcid.IPA256 - } - return err == nil && rootErr == nil && parsed.String() == value && descriptor.Profile == profile && (backend == "kzg" || backend == "ipa") -} - -func CanonicalSHA256(value string) bool { - if len(value) != 64 { - return false - } - decoded, err := hex.DecodeString(value) - return err == nil && hex.EncodeToString(decoded) == value -} - -func Bind(record WorkerRecord, request WorkerRequest) error { - if record.WorkerID != request.WorkerID || record.RequestID != request.RequestID || record.RecordKind != request.RecordKind || - record.SessionID != request.SessionID || record.ClientKind != request.ClientKind || record.PlatformID != request.PlatformID || - record.Backend != request.Backend || record.Lifecycle != request.Lifecycle || record.FixtureID != request.FixtureID || record.Measured != request.Measured { - return fmt.Errorf("RQ2 worker response does not bind request %q", request.RequestID) - } - if record.Success && request.RecordKind == RecordMutation && (record.Mutation == nil || record.Mutation.Operation != request.Operation || record.Mutation.PriorRoot != request.ExpectedAcceptedRoot) { - return fmt.Errorf("RQ2 mutation response does not bind operation/prior root") - } - if record.Success && (request.RecordKind == RecordSessionStart || request.RecordKind == RecordSessionEnd) && - (record.Session == nil || record.Session.AcceptedRoot != request.ExpectedAcceptedRoot) { - return fmt.Errorf("RQ2 session response does not bind expected root") - } - return nil -} - -func RuntimeIdentityValid(runtime RuntimeEvidence) bool { - return strings.TrimSpace(runtime.OS) != "" && strings.TrimSpace(runtime.Architecture) != "" && - IDPattern.MatchString(runtime.MachineDescriptorID) && CanonicalSHA256(runtime.MachineDescriptorSHA256) -} diff --git a/internal/evaluation/rq2wire/wire_test.go b/internal/evaluation/rq2wire/wire_test.go deleted file mode 100644 index dce0074..0000000 --- a/internal/evaluation/rq2wire/wire_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package rq2wire - -import "testing" - -func TestPipelineApplicabilityMatchesExecutedPayloadWork(t *testing.T) { - tests := []struct { - name, client, operation string - scanChunk, hash bool - }{ - {"browser document edit", ClientBrowserWASM, "document-edit-cid-binding-submit", true, true}, - {"browser map replace", ClientBrowserWASM, "map-replace", false, true}, - {"browser list append", ClientBrowserWASM, "list-append", false, true}, - {"native payload", ClientNative, "modify-small-file", true, true}, - {"native rename", ClientNative, "rename", false, false}, - {"native move", ClientNative, "move", false, false}, - {"native delete", ClientNative, "delete-directory-entry", false, false}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - scanChunk, hash := pipelineApplicability(test.client, test.operation) - if scanChunk != test.scanChunk || hash != test.hash { - t.Fatalf("pipelineApplicability(%q, %q) = %t/%t, want %t/%t", test.client, test.operation, scanChunk, hash, test.scanChunk, test.hash) - } - }) - } -} diff --git a/internal/evaluation/rq2write/project.go b/internal/evaluation/rq2write/project.go deleted file mode 100644 index 4988a0d..0000000 --- a/internal/evaluation/rq2write/project.go +++ /dev/null @@ -1,166 +0,0 @@ -// Package rq2write projects the source fixture's file operations directly into -// typed Core deltas. Native filesystem and browser source preparation remain -// separately measured callers of this shared application projection. -package rq2write - -import ( - "bytes" - "context" - "fmt" - "math" - - "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2fixture" - unixfsmodel "github.com/dewebprotocol/malt-client/unixfs/model" - "github.com/dewebprotocol/malt-core/auth/coordinate" - "github.com/dewebprotocol/malt-core/derivation" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/sdk/authentication" - cid "github.com/ipfs/go-cid" -) - -func Apply(ctx context.Context, edit *authenticationgraph.Edit, root cid.Cid, operation rq2fixture.Operation, payloads []cid.Cid) (cid.Cid, error) { - state, err := edit.State(root) - if err != nil { - return cid.Undef, err - } - if state.Descriptor.Layout != maltcid.Prefix || state.Descriptor.DerivationProfile != uint8(derivation.SHA256) { - return cid.Undef, fmt.Errorf("RQ2 file root requires Prefix AA1") - } - for _, payload := range payloads { - if !payload.Defined() || payload.Type() != cid.Raw { - return cid.Undef, fmt.Errorf("file operation requires raw payload CIDs") - } - } - if operation.Kind == rq2fixture.KindListAppend || operation.Kind == rq2fixture.KindListReplace { - edge, err := entryAt(state, operation.SourcePath) - if err != nil { - return cid.Undef, err - } - child, err := edit.State(edge.Target) - if err != nil { - return cid.Undef, err - } - if child.Descriptor.Layout != maltcid.Positional || child.Descriptor.DerivationProfile != uint8(derivation.Direct) || child.Descriptor.Profile != state.Descriptor.Profile || !child.PayloadCID.Defined() { - return cid.Undef, fmt.Errorf("file requires a measured Positional Root with the parent's VC profile") - } - if len(payloads) != 1 { - return cid.Undef, fmt.Errorf("chunk operation requires one payload") - } - geometry, err := unixfsmodel.ParseInlineChunkMetadata(child.PayloadCID, uint64(len(child.Entries))) - if err != nil { - return cid.Undef, err - } - var delta authentication.Delta - if operation.Kind == rq2fixture.KindListAppend { - if geometry.TotalSize%geometry.ChunkSize != 0 || operation.PayloadBytes != geometry.ChunkSize || geometry.TotalSize > math.MaxUint64-geometry.ChunkSize { - return cid.Undef, fmt.Errorf("append requires a full final chunk and one same-width new chunk") - } - count := uint64(len(child.Entries)) + 1 - total := geometry.TotalSize + geometry.ChunkSize - payload, err := unixfsmodel.InlineChunkMetadata(total, geometry.ChunkSize, count) - if err != nil { - return cid.Undef, err - } - delta = authentication.Delta{Changes: []engine.Change{{Label: coordinate.EncodeIndex(count - 1), After: payloads[0]}}, Count: &count, PayloadCID: &payload} - } else { - if operation.ListIndex == nil || *operation.ListIndex >= uint64(len(child.Entries)) { - return cid.Undef, fmt.Errorf("replacement index is outside measured file") - } - index := *operation.ListIndex - size := min(geometry.ChunkSize, geometry.TotalSize-index*geometry.ChunkSize) - if operation.PayloadBytes != size { - return cid.Undef, fmt.Errorf("replacement must preserve the authenticated chunk length") - } - var before cid.Cid - for _, entry := range child.Entries { - if bytes.Equal(entry.Label, coordinate.EncodeIndex(index)) { - before = entry.Target - break - } - } - if !before.Defined() { - return cid.Undef, fmt.Errorf("replacement index is absent") - } - delta.Changes = []engine.Change{{Label: coordinate.EncodeIndex(index), Before: before, After: payloads[0]}} - } - nextChild, err := edit.Apply(ctx, edge.Target, delta) - if err != nil { - return cid.Undef, err - } - // Select the caller's exact file edge; never rewrite every parent of an - // aliased immutable child or reject a valid copy-on-write graph. - return edit.Apply(ctx, root, authentication.Delta{Changes: []engine.Change{{Label: edge.Label, Before: edge.Target, After: nextChild}}}) - } - insert := func(path string, payload cid.Cid) (engine.Change, error) { - if path == "" { - return engine.Change{}, fmt.Errorf("insert path is empty") - } - for _, entry := range state.Entries { - if bytes.Equal(entry.Label, []byte(path)) { - return engine.Change{}, fmt.Errorf("destination %q already exists", path) - } - } - return engine.Change{Label: []byte(path), After: payload}, nil - } - var changes []engine.Change - switch operation.Kind { - case rq2fixture.KindBatchInsert: - if len(payloads) != len(operation.Batch) || len(payloads) == 0 { - return cid.Undef, fmt.Errorf("batch payload count differs from declared destinations") - } - for i, payload := range payloads { - change, err := insert(operation.Batch[i].Path, payload) - if err != nil { - return cid.Undef, err - } - changes = append(changes, change) - } - case rq2fixture.KindDirectInsert: - if len(payloads) != 1 { - return cid.Undef, fmt.Errorf("insert requires one payload") - } - change, err := insert(operation.DestinationPath, payloads[0]) - if err != nil { - return cid.Undef, err - } - changes = []engine.Change{change} - case rq2fixture.KindDirectReplace, rq2fixture.KindDocumentEdit, rq2fixture.KindDirectDelete, rq2fixture.KindDirectMove: - entry, err := entryAt(state, operation.SourcePath) - if err != nil { - return cid.Undef, err - } - if entry.Target.Type() != cid.Raw { - return cid.Undef, fmt.Errorf("source %q is not a direct file", operation.SourcePath) - } - change := engine.Change{Label: entry.Label, Before: entry.Target} - if operation.Kind == rq2fixture.KindDirectReplace || operation.Kind == rq2fixture.KindDocumentEdit { - if len(payloads) != 1 { - return cid.Undef, fmt.Errorf("replacement requires one payload") - } - change.After = payloads[0] - } else if len(payloads) != 0 { - return cid.Undef, fmt.Errorf("metadata operation cannot carry payloads") - } - changes = []engine.Change{change} - if operation.Kind == rq2fixture.KindDirectMove { - destination, err := insert(operation.DestinationPath, entry.Target) - if err != nil { - return cid.Undef, err - } - changes = append(changes, destination) - } - default: - return cid.Undef, fmt.Errorf("unsupported source operation %q", operation.Kind) - } - return edit.Apply(ctx, root, authentication.Delta{Changes: changes}) -} -func entryAt(state engine.State, path string) (engine.Entry, error) { - for _, entry := range state.Entries { - if bytes.Equal(entry.Label, []byte(path)) { - return entry, nil - } - } - return engine.Entry{}, fmt.Errorf("source %q is absent", path) -} diff --git a/internal/evaluation/rq2write/project_test.go b/internal/evaluation/rq2write/project_test.go deleted file mode 100644 index f5f51fa..0000000 --- a/internal/evaluation/rq2write/project_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package rq2write_test - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "strings" - "testing" - - "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2e0" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2fixture" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2write" - "github.com/dewebprotocol/malt-client/transport" - "github.com/dewebprotocol/malt-core/auth/commitment/ipa" - "github.com/dewebprotocol/malt-core/auth/commitment/kzg" - "github.com/dewebprotocol/malt-core/engine" - cid "github.com/ipfs/go-cid" -) - -func TestCurrentProjectionAllFileOperationsAndSharedMeasuredChildren(t *testing.T) { - ctx := context.Background() - seed := sha256.Sum256([]byte("typed RQ2 fixture")) - zero := uint64(0) - source := &rq2fixture.SourceDefinition{SchemaVersion: rq2fixture.SourceSchemaVersion, FixtureID: "typed-projection", MutationSeedSHA256: hex.EncodeToString(seed[:]), DirectFiles: []rq2fixture.SourceDirectFile{}, Operations: []rq2fixture.Operation{ - {Name: "append", Kind: rq2fixture.KindListAppend, SourcePath: "large.bin", SourceCoordinate: "large.bin", PayloadBytes: 4}, - {Name: "replace-large-file-chunk", Kind: rq2fixture.KindListReplace, SourcePath: "large.bin", SourceCoordinate: "large.bin", PayloadBytes: 4, ListIndex: &zero}, - {Name: "batch-sync", Kind: rq2fixture.KindBatchInsert, Batch: []rq2fixture.BatchTarget{{Path: "batch/one", Coordinate: "batch/one", PayloadBytes: 4}, {Path: "batch/two", Coordinate: "batch/two", PayloadBytes: 4}}}, - {Name: "create-small-file", Kind: rq2fixture.KindDirectInsert, DestinationPath: "created", DestinationCoordinate: "created", PayloadBytes: 4}, - {Name: "insert-directory-entry", Kind: rq2fixture.KindDirectInsert, DestinationPath: "inserted", DestinationCoordinate: "inserted", PayloadBytes: 4}, - {Name: "modify-small-file", Kind: rq2fixture.KindDirectReplace, SourcePath: "modify", SourceCoordinate: "modify", PayloadBytes: 4}, - {Name: "delete-directory-entry", Kind: rq2fixture.KindDirectDelete, SourcePath: "delete", SourceCoordinate: "delete"}, - {Name: "rename", Kind: rq2fixture.KindDirectMove, SourcePath: "rename", SourceCoordinate: "rename", DestinationPath: "renamed", DestinationCoordinate: "renamed"}, - {Name: "move", Kind: rq2fixture.KindDirectMove, SourcePath: "move", SourceCoordinate: "move", DestinationPath: "directory/moved", DestinationCoordinate: "directory/moved"}, - {Name: "document-edit-cid-binding-submit", Kind: rq2fixture.KindDocumentEdit, SourcePath: "document", SourceCoordinate: "document", PayloadBytes: 4}, - }} - for _, path := range []string{"modify", "delete", "rename", "move", "document"} { - source.DirectFiles = append(source.DirectFiles, rq2fixture.SourceDirectFile{Path: path, Coordinate: path, Bytes: []byte("old " + path)}) - } - for _, path := range []string{"large.bin", "alias.bin"} { - source.ListFiles = append(source.ListFiles, rq2fixture.SourceListFile{Path: path, Coordinate: path, ChunkSize: 4, TotalSize: 8, Chunks: []rq2fixture.SourceListChunk{{Index: 0, Bytes: []byte("one!")}, {Index: 1, Bytes: []byte("two!")}}}) - } - fixture, err := rq2e0.BuildFixture(ctx, source) - if err != nil { - t.Fatal(err) - } - for _, backend := range []string{"kzg", "ipa"} { - t.Run(backend, func(t *testing.T) { - gateway, root, err := rq2e0.NewConformanceGateway(fixture, backend, strings.Repeat("a", 64)) - if err != nil { - t.Fatal(err) - } - defer gateway.Close() - remote, err := gatewaytransport.New(gatewaytransport.Options{BaseURL: gateway.URL(), InstanceToken: strings.Repeat("a", 64)}) - if err != nil { - t.Fatal(err) - } - payloads, err := transport.New(transport.Options{BaseURL: gateway.URL(), HTTPClient: remote.InstanceHTTPClient()}) - if err != nil { - t.Fatal(err) - } - var scheme engine.Profile - if backend == "kzg" { - scheme, err = kzg.NewScheme() - } else { - scheme, err = ipa.NewScheme() - } - if err != nil { - t.Fatal(err) - } - registry := engine.NewRegistry() - if err := registry.Register(scheme); err != nil { - t.Fatal(err) - } - session, err := authenticationgraph.New(remote, engine.New(registry)) - if err != nil { - t.Fatal(err) - } - load, err := session.Load(ctx, root) - if err != nil { - t.Fatal(err) - } - if load.Objects != 2 { - t.Fatal("shared child imported twice") - } - initial, err := session.Snapshot() - if err != nil { - t.Fatal(err) - } - if err := fixture.ValidateInitialGraph(initial, backend); err != nil { - t.Fatal(err) - } - currentSource := fixture.InitialSource() - for i, operation := range source.Operations { - nextSource, bodies, err := fixture.ApplySourceOperation(currentSource, operation, uint64(i)) - if err != nil { - t.Fatal(err) - } - targets := make([]cid.Cid, len(bodies)) - if len(bodies) > 0 { - blocks := make([]transport.Block, len(bodies)) - for j, body := range bodies { - blocks[j] = transport.Block{Codec: cid.Raw, Data: body} - } - upload, err := payloads.PutBatchMeasured(ctx, blocks) - if err != nil { - t.Fatal(err) - } - for j, result := range upload.Results { - targets[j] = result.CID - } - } - edit, err := session.Begin() - if err != nil { - t.Fatal(err) - } - next, err := rq2write.Apply(ctx, edit, root, operation, targets) - if err != nil { - t.Fatalf("%s: %v", operation.Name, err) - } - preview, err := edit.Preview(ctx, next) - if err != nil { - t.Fatal(err) - } - if err := fixture.ValidateGraphAgainstSource(preview, backend, nextSource); err != nil { - t.Fatalf("%s full oracle: %v", operation.Name, err) - } - result, err := session.Submit(ctx, fmt.Sprintf("operation-%d", i), edit, next) - if err != nil { - t.Fatal(err) - } - if result.Idempotent || result.Submission.WriteAccounting.Available { - t.Fatal("correctness oracle claimed production accounting or returned replay") - } - currentSource = nextSource - root = next - } - if err := session.Audit(ctx); err != nil { - t.Fatal(err) - } - if got := gateway.Operations(); got != uint64(len(source.Operations)) { - t.Fatalf("operations = %d", got) - } - }) - } -} diff --git a/internal/evaluation/rq3baseline/archive_index.go b/internal/evaluation/rq3baseline/archive_index.go deleted file mode 100644 index 3b9fcbd..0000000 --- a/internal/evaluation/rq3baseline/archive_index.go +++ /dev/null @@ -1,44 +0,0 @@ -package rq3baseline - -import ( - "sync" - - "github.com/syndtr/goleveldb/leveldb/storage" -) - -// Meter the public LevelDB storage writer, including WAL/table/manifest writes -// and compaction. CURRENT and diagnostic logging bypass this interface; their -// retained sizes are inventoried, and write coverage is explicitly incomplete. -type meteredIndexStorage struct { - storage.Storage - mu sync.Mutex - bytes, calls int64 -} - -func (s *meteredIndexStorage) Create(fd storage.FileDesc) (storage.Writer, error) { - w, err := s.Storage.Create(fd) - if err != nil { - return nil, err - } - return &meteredIndexWriter{Writer: w, owner: s}, nil -} - -func (s *meteredIndexStorage) snapshot() (int64, int64) { - s.mu.Lock() - defer s.mu.Unlock() - return s.bytes, s.calls -} - -type meteredIndexWriter struct { - storage.Writer - owner *meteredIndexStorage -} - -func (w *meteredIndexWriter) Write(p []byte) (int, error) { - n, err := w.Writer.Write(p) - w.owner.mu.Lock() - w.owner.bytes += int64(n) - w.owner.calls++ - w.owner.mu.Unlock() - return n, err -} diff --git a/internal/evaluation/rq3baseline/archive_store.go b/internal/evaluation/rq3baseline/archive_store.go deleted file mode 100644 index 842fb58..0000000 --- a/internal/evaluation/rq3baseline/archive_store.go +++ /dev/null @@ -1,461 +0,0 @@ -package rq3baseline - -import ( - "bufio" - "bytes" - "container/list" - "encoding/binary" - "errors" - "fmt" - "io" - "os" - "path/filepath" - - cid "github.com/ipfs/go-cid" - car "github.com/ipld/go-car/v2" - carstorage "github.com/ipld/go-car/v2/storage" - "github.com/syndtr/goleveldb/leveldb" - "github.com/syndtr/goleveldb/leveldb/filter" - "github.com/syndtr/goleveldb/leveldb/opt" - "github.com/syndtr/goleveldb/leveldb/storage" -) - -const ReplayStorageBackend = "segmented-carv1-leveldb/v1" - -const ( - archiveSegmentBytes = 1 << 30 - archiveCacheBytes = 32 << 20 - archiveWriteBufferBytes = 8 << 20 - archiveReadHandles = 8 - archiveIndexHandles = 32 - archiveMaxSectionBytes = MaximumJSONLRecordBytes -) - -// ReplayStorage is a terminal, CAS-only observation after independent CAR/index -// reconciliation and index close. File sizes are logical lengths, not allocated -// sectors. Storage.Writer counters exclude LevelDB's internal CURRENT/LOG writes. -// Source checkout staging and evaluator output files are outside this scope. -type ReplayStorage struct { - Schema string `json:"schema_version"` - Backend string `json:"backend"` - SegmentLimit int64 `json:"segment_limit_bytes,string"` - IndexCache int64 `json:"index_block_cache_bytes,string"` - IndexBuffer int64 `json:"index_write_buffer_bytes,string"` - IndexHandles int `json:"index_open_files_cache"` - ReadHandles int `json:"car_read_handles"` - Objects int64 `json:"cas_objects,string"` - BodyBytes int64 `json:"cas_body_bytes,string"` - CARFiles int64 `json:"car_files,string"` - CARBytes int64 `json:"car_file_bytes,string"` - FramingBytes int64 `json:"car_framing_bytes,string"` - CARWriteBytes int64 `json:"car_write_bytes,string"` - IndexFiles int64 `json:"index_files,string"` - IndexBytes int64 `json:"index_file_bytes,string"` - IndexWriterBytes int64 `json:"index_storage_writer_bytes,string"` - IndexWriterCalls int64 `json:"index_storage_writer_calls,string"` - IndexWriterComplete bool `json:"index_writer_coverage_complete"` - IndexWriterExclusions []string `json:"index_writer_exclusions"` - Entries int64 `json:"cas_filesystem_entries,string"` - Reconciled bool `json:"car_index_reconciled"` -} - -type archiveLocation struct { - segment uint32 - offset, length uint64 -} - -func (p archiveLocation) encode() []byte { - b := make([]byte, 20) - binary.LittleEndian.PutUint32(b, p.segment) - binary.LittleEndian.PutUint64(b[4:], p.offset) - binary.LittleEndian.PutUint64(b[12:], p.length) - return b -} - -func decodeArchiveLocation(b []byte) (archiveLocation, error) { - if len(b) != 20 { - return archiveLocation{}, fmt.Errorf("invalid CAR index location") - } - p := archiveLocation{binary.LittleEndian.Uint32(b), binary.LittleEndian.Uint64(b[4:]), binary.LittleEndian.Uint64(b[12:])} - if p.segment == 0 || p.offset > uint64(^uint64(0)>>1) || p.length == 0 || p.length > archiveMaxSectionBytes+10 || p.offset+p.length < p.offset { - return archiveLocation{}, fmt.Errorf("CAR index location exceeds bounds") - } - return p, nil -} - -type archiveReader struct { - segment uint32 - file *os.File -} - -// archiveCAS is owned by accountingStore.mu. Only LevelDB's metered storage is -// concurrently accessed. No in-memory per-object index is retained here. -type archiveCAS struct { - root string - index *leveldb.DB - indexStorage *meteredIndexStorage - header []byte - segmentLimit int64 - segments uint32 - active *os.File - activeBytes int64 - carWritten int64 - readers *list.List - failed error - sealed bool - handlesClosed bool - closeErr error -} - -func newArchiveCAS(root string, segmentLimit int64) (*archiveCAS, error) { - // Use the standard writer only for an empty rootless CARv1 header. Blocks - // are appended below; go-car's growing in-memory insertion index is unused. - var header bytes.Buffer - w, err := carstorage.NewWritable(&header, []cid.Cid{}, car.WriteAsCarV1(true)) - if err != nil { - return nil, err - } - if err := w.Finalize(); err != nil { - return nil, err - } - if segmentLimit <= int64(header.Len()) { - return nil, fmt.Errorf("CAR segment limit too small") - } - if err := os.Mkdir(filepath.Join(root, "data"), 0o700); err != nil { - return nil, err - } - fs, err := storage.OpenFile(filepath.Join(root, "index"), false) - if err != nil { - return nil, err - } - meter := &meteredIndexStorage{Storage: fs} - db, err := leveldb.Open(meter, &opt.Options{ - ErrorIfExist: true, Compression: opt.NoCompression, Strict: opt.StrictAll, - BlockCacheCapacity: archiveCacheBytes, WriteBuffer: archiveWriteBufferBytes, - OpenFilesCacheCapacity: archiveIndexHandles, Filter: filter.NewBloomFilter(10), - }) - if err != nil { - return nil, errors.Join(err, fs.Close()) - } - return &archiveCAS{root: root, index: db, indexStorage: meter, header: append([]byte(nil), header.Bytes()...), segmentLimit: segmentLimit, readers: list.New()}, nil -} - -func (s *archiveCAS) path(segment uint32) string { - return filepath.Join(s.root, "data", fmt.Sprintf("%08d.car", segment)) -} - -func (s *archiveCAS) ready() error { - if s.failed != nil { - return s.failed - } - if s.sealed || s.index == nil { - return fmt.Errorf("replay CAR store is sealed or closed") - } - return nil -} - -func (s *archiveCAS) get(key cid.Cid) ([]byte, error) { - if err := s.ready(); err != nil { - return nil, err - } - value, err := s.index.Get(key.Bytes(), nil) - if errors.Is(err, leveldb.ErrNotFound) { - return nil, os.ErrNotExist - } - if err != nil { - return nil, err - } - p, err := decodeArchiveLocation(value) - if err != nil { - return nil, err - } - if p.segment > s.segments || p.offset < uint64(len(s.header)) { - return nil, fmt.Errorf("unknown CAR segment or header offset") - } - f, err := s.reader(p.segment) - if err != nil { - return nil, fmt.Errorf("indexed CAR segment is unavailable: %v", err) - } - info, err := f.Stat() - if err != nil { - return nil, err - } - if !info.Mode().IsRegular() || p.offset > uint64(info.Size()) || p.length > uint64(info.Size())-p.offset { - return nil, fmt.Errorf("CAR index points outside a regular segment") - } - frame := make([]byte, int(p.length)) - if _, err := f.ReadAt(frame, int64(p.offset)); err != nil { - return nil, err - } - length, n := binary.Uvarint(frame) - if n <= 0 || length != uint64(len(frame)-n) || length > archiveMaxSectionBytes { - return nil, fmt.Errorf("invalid CAR block framing") - } - if !bytes.Equal(binary.AppendUvarint(nil, length), frame[:n]) { - return nil, fmt.Errorf("noncanonical CAR section length") - } - cidBytes, actual, err := cid.CidFromBytes(frame[n:]) - if err != nil || !actual.Equals(key) { - return nil, fmt.Errorf("CAR index does not bind complete CID %s", key) - } - data := frame[n+cidBytes:] - computed, err := key.Prefix().Sum(data) - if err != nil || !computed.Equals(key) { - return nil, fmt.Errorf("CAR block does not match CID %s", key) - } - return data, nil -} - -func (s *archiveCAS) reader(segment uint32) (*os.File, error) { - if segment == s.segments && s.active != nil { - return s.active, nil - } - for e := s.readers.Front(); e != nil; e = e.Next() { - r := e.Value.(archiveReader) - if r.segment == segment { - s.readers.MoveToFront(e) - return r.file, nil - } - } - if s.readers.Len() == archiveReadHandles { - e := s.readers.Back() - s.readers.Remove(e) - if err := e.Value.(archiveReader).file.Close(); err != nil { - s.failed = err - return nil, err - } - } - f, err := os.Open(s.path(segment)) - if err != nil { - return nil, err - } - s.readers.PushFront(archiveReader{segment, f}) - return f, nil -} - -func (s *archiveCAS) appendNew(key cid.Cid, data []byte) (err error) { - if err := s.ready(); err != nil { - return err - } - defer func() { - if err != nil { - s.failed = err - } - }() - section := uint64(key.ByteLen() + len(data)) - if section > archiveMaxSectionBytes { - return fmt.Errorf("CAR section exceeds replay record limit") - } - prefix := append(binary.AppendUvarint(nil, section), key.Bytes()...) - frameBytes := int64(len(prefix) + len(data)) - if frameBytes+int64(len(s.header)) > s.segmentLimit { - return fmt.Errorf("CAR block exceeds segment limit") - } - if s.active == nil || s.activeBytes+frameBytes > s.segmentLimit { - if s.active != nil { - f := s.active - s.active = nil - if err := f.Close(); err != nil { - return err - } - } - if s.segments == ^uint32(0) { - return fmt.Errorf("too many CAR segments") - } - s.segments++ - s.active, err = os.OpenFile(s.path(s.segments), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return err - } - s.activeBytes = 0 - if err := s.write(s.header); err != nil { - return err - } - } - p := archiveLocation{s.segments, uint64(s.activeBytes), uint64(frameBytes)} - if err := s.write(prefix); err != nil { - return err - } - if err := s.write(data); err != nil { - return err - } - // Publication follows the complete frame. Any failure poisons this run; - // partially written data are never accepted as a resumable measurement. - return s.index.Put(key.Bytes(), p.encode(), &opt.WriteOptions{Sync: false}) -} - -func (s *archiveCAS) write(data []byte) error { - n, err := s.active.Write(data) - s.activeBytes += int64(n) - s.carWritten += int64(n) - if err == nil && n != len(data) { - err = io.ErrShortWrite - } - return err -} - -type countedReader struct { - io.Reader - n uint64 -} - -func (r *countedReader) Read(p []byte) (int, error) { - n, err := r.Reader.Read(p) - r.n += uint64(n) - return n, err -} - -// inventory scans every physical CAR frame with go-car's CID validation, then -// binds its exact location to the disk index. Duplicate frames, orphan frames, -// missing/extra index entries and truncated sections fail reconciliation. -func (s *archiveCAS) inventory() (report ReplayStorage, err error) { - if err := s.ready(); err != nil { - return report, err - } - entries, err := os.ReadDir(filepath.Join(s.root, "data")) - if err != nil { - return report, err - } - if len(entries) != int(s.segments) { - return report, fmt.Errorf("unexpected CAR segment set") - } - for segment := uint32(1); segment <= s.segments; segment++ { - if err := s.scanSegment(segment, &report); err != nil { - return report, err - } - } - iter := s.index.NewIterator(nil, &opt.ReadOptions{DontFillCache: true, Strict: opt.StrictAll}) - var indexed int64 - for iter.Next() { - indexed++ - } - err = iter.Error() - iter.Release() - if err != nil { - return report, err - } - if indexed != report.Objects { - return report, fmt.Errorf("CAR/index object count mismatch") - } - if report.CARBytes != s.carWritten { - return report, fmt.Errorf("CAR retained bytes differ from completed writes") - } - report.Schema, report.Backend = "malt-replay-cas-storage/v1", ReplayStorageBackend - report.SegmentLimit, report.IndexCache, report.IndexBuffer = s.segmentLimit, archiveCacheBytes, archiveWriteBufferBytes - report.ReadHandles, report.IndexHandles = archiveReadHandles, archiveIndexHandles - report.FramingBytes = report.CARBytes - report.BodyBytes - report.CARWriteBytes = s.carWritten - report.Reconciled = true - return report, nil -} - -func (s *archiveCAS) scanSegment(segment uint32, report *ReplayStorage) (returnErr error) { - f, err := os.Open(s.path(segment)) - if err != nil { - return err - } - defer func() { returnErr = errors.Join(returnErr, f.Close()) }() - info, err := f.Stat() - if err != nil { - return err - } - if !info.Mode().IsRegular() || info.Size() > s.segmentLimit { - return fmt.Errorf("invalid CAR segment") - } - reader := &countedReader{Reader: bufio.NewReaderSize(f, 256<<10)} - blocks, err := car.NewBlockReader(reader, car.MaxAllowedSectionSize(archiveMaxSectionBytes)) - if err != nil { - return err - } - if blocks.Version != 1 || len(blocks.Roots) != 0 || reader.n != uint64(len(s.header)) { - return fmt.Errorf("invalid replay CAR header") - } - for { - offset := reader.n - block, err := blocks.Next() - if errors.Is(err, io.EOF) { - if reader.n != offset || reader.n != uint64(info.Size()) { - return fmt.Errorf("truncated CAR frame") - } - break - } - if err != nil { - return err - } - value, err := s.index.Get(block.Cid().Bytes(), &opt.ReadOptions{DontFillCache: true}) - if err != nil { - return fmt.Errorf("CAR frame lacks index entry: %w", err) - } - want := archiveLocation{segment, offset, reader.n - offset} - if !bytes.Equal(value, want.encode()) { - return fmt.Errorf("CAR frame/index location mismatch") - } - report.Objects++ - report.BodyBytes += int64(len(block.RawData())) - } - report.CARFiles++ - report.CARBytes += info.Size() - return nil -} - -func (s *archiveCAS) finish() (ReplayStorage, error) { - report, err := s.inventory() - if err != nil { - return report, err - } - if err := s.closeHandles(); err != nil { - return report, err - } - report.IndexWriterBytes, report.IndexWriterCalls = s.indexStorage.snapshot() - report.IndexWriterExclusions = []string{"LevelDB CURRENT metadata", "LevelDB diagnostic LOG"} - err = filepath.WalkDir(s.root, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - report.Entries++ - if entry.IsDir() { - return nil - } - info, err := entry.Info() - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return fmt.Errorf("non-regular replay CAS entry") - } - if filepath.Dir(path) == filepath.Join(s.root, "index") { - report.IndexFiles++ - report.IndexBytes += info.Size() - } - return nil - }) - return report, err -} - -func (s *archiveCAS) closeHandles() error { - if s.handlesClosed { - return s.closeErr - } - s.handlesClosed = true - s.sealed = true - var errs []error - if s.active != nil { - f := s.active - s.active = nil - errs = append(errs, f.Close()) - } - for e := s.readers.Front(); e != nil; e = e.Next() { - errs = append(errs, e.Value.(archiveReader).file.Close()) - } - s.readers.Init() - if s.index != nil { - db := s.index - s.index = nil - errs = append(errs, db.Close()) - } - if s.indexStorage != nil { - errs = append(errs, s.indexStorage.Close()) - } - s.closeErr = errors.Join(errs...) - return s.closeErr -} diff --git a/internal/evaluation/rq3baseline/archive_store_test.go b/internal/evaluation/rq3baseline/archive_store_test.go deleted file mode 100644 index d69c5c6..0000000 --- a/internal/evaluation/rq3baseline/archive_store_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package rq3baseline - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "testing" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - merkledag "github.com/ipfs/boxo/ipld/merkledag" - unixfs "github.com/ipfs/boxo/ipld/unixfs" - cid "github.com/ipfs/go-cid" -) - -func testArchive(t *testing.T, segmentBytes int64) *archiveCAS { - t.Helper() - s, err := newArchiveCAS(t.TempDir(), segmentBytes) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := s.closeHandles(); err != nil { - t.Error(err) - } - }) - return s -} - -func archiveKey(t *testing.T, data []byte, codec uint64) cid.Cid { - t.Helper() - key, err := clientcas.CIDForBlock(clientcas.Block{Data: data, Codec: codec}) - if err != nil { - t.Fatal(err) - } - return key -} - -func TestReplayArchiveMatchesFileStoreAcrossAllProfiles(t *testing.T) { - for _, profile := range []string{"merkledag-nested", "hamt-nested", "hamt-flat"} { - t.Run(profile, func(t *testing.T) { - system, directory, fanout := SystemHAMTUnixFS, "hamt", 256 - if profile == "merkledag-nested" { - system, directory, fanout = SystemMerkleDAGUnixFS, "basic", 0 - } - spec := baseSpec(system, directory, fanout, []FrozenFile{frozenFile("a/b", []byte("initial")), frozenFile("stable", []byte("unchanged"))}) - packed := func() *accountingStore { - s := newAccountingStore() - if s.initErr == nil { - s.archive, s.initErr = newArchiveCAS(s.root, 16<<10) - } - return s - } - var sessions []*StreamSession - var previous *CommitRecord - var expectedObjects, expectedBytes int64 - compare := func(a, b CommitRecord) { - t.Helper() - a.ClientPhases, b.ClientPhases = ClientPhases{}, ClientPhases{} - if !reflect.DeepEqual(a, b) { - t.Fatalf("logical record differs at %s", a.CommitID) - } - } - for _, factory := range []func() *accountingStore{newAccountingStore, packed} { - s, initial, err := startStreamWithStore(t.Context(), spec, profile == "hamt-flat", true, factory) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := s.Close(); err != nil { - t.Error(err) - } - }) - sessions = append(sessions, s) - if previous != nil { - compare(*previous, initial) - } else { - previous = &initial - } - } - expectedObjects += int64(previous.CAS.Total.NewlyPersistedObjects) - expectedBytes += previous.CAS.Total.NewlyPersistedBytes - body := []byte("initial") - for i := 0; i < 80; i++ { - next := []byte(fmt.Sprintf("version-%d-%s", i%7, bytes.Repeat([]byte("x"), i%11))) - if i%9 == 0 { - next = body - } // no-op and cross-commit reuse - commit := Commit{CommitID: fmt.Sprintf("archive-update-%d", i), Mutations: []Mutation{replaceMutation("a/b", body, next)}} - var records []CommitRecord - for _, session := range sessions { - got, err := session.ApplyChunkWithReadback(t.Context(), []Commit{commit}) - if err != nil { - t.Fatal(err) - } - records = append(records, got[0]) - if err := session.VerifyAll(t.Context()); err != nil { - t.Fatal(err) - } - } - compare(records[0], records[1]) - expectedObjects += int64(records[0].CAS.Total.NewlyPersistedObjects) - expectedBytes += records[0].CAS.Total.NewlyPersistedBytes - body = next - } - for _, session := range sessions { - objects, size, err := session.RetainedCAS() - if err != nil || objects != expectedObjects || size != expectedBytes { - t.Fatalf("inventory = %d/%d, want %d/%d: %v", objects, size, expectedObjects, expectedBytes, err) - } - } - report, err := sessions[1].FinishReplayStorage() - if err != nil { - t.Fatal(err) - } - if !report.Reconciled || report.CARWriteBytes != report.CARBytes || report.CARBytes-report.FramingBytes != expectedBytes || report.IndexFiles == 0 || report.IndexWriterBytes == 0 || report.IndexWriterComplete { - t.Fatalf("invalid backend report: %+v", report) - } - if _, err := sessions[1].store.Get(t.Context(), archiveKey(t, body, cid.Raw)); err == nil { - t.Fatal("sealed store accepted reads") - } - }) - } -} - -func TestReplayArchiveUsesWholeCIDAndPreservesDedup(t *testing.T) { - s := newReplayAccountingStore() - t.Cleanup(func() { - if err := s.close(); err != nil { - t.Error(err) - } - }) - data, err := merkledag.NodeWithData(unixfs.FolderPBData()).EncodeProtobuf(false) - if err != nil { - t.Fatal(err) - } - s.beginPhase() - for _, codec := range []uint64{cid.Raw, cid.DagProtobuf, cid.Raw, cid.DagProtobuf} { - key, err := s.PutWithCodec(t.Context(), data, codec) - if err != nil { - t.Fatal(err) - } - got, err := s.Get(t.Context(), key) - if err != nil || !bytes.Equal(data, got) { - t.Fatalf("whole CID lookup failed: %v", err) - } - } - first, _, _ := s.finishPhase() - if first.Total.NewlyPersistedObjects != 2 || first.Total.DuplicateObjects != 2 { - t.Fatalf("dedup = %+v", first.Total) - } - s.beginPhase() - if _, err := s.PutWithCodec(t.Context(), data, cid.Raw); err != nil { - t.Fatal(err) - } - second, _, _ := s.finishPhase() - if second.Total.AlreadyPresentObjects != 1 || second.Total.NewlyPersistedObjects != 0 { - t.Fatal("cross-phase dedup changed") - } - report, err := s.archive.inventory() - if err != nil || report.Objects != 2 { - t.Fatalf("whole CID inventory: %+v, %v", report, err) - } -} - -func TestReplayArchiveManyObjectsUseFewFiles(t *testing.T) { - s := testArchive(t, 64<<10) - const count = 20000 - var total int64 - for i := 0; i < count; i++ { - data := binary.LittleEndian.AppendUint64(nil, uint64(i)) - if err := s.appendNew(archiveKey(t, data, cid.Raw), data); err != nil { - t.Fatal(err) - } - total += int64(len(data)) - } - // Reverse-order lookups span many sealed shards and exercise eviction. - for i := count - 1; i >= 0; i -= 101 { - data := binary.LittleEndian.AppendUint64(nil, uint64(i)) - got, err := s.get(archiveKey(t, data, cid.Raw)) - if err != nil || !bytes.Equal(got, data) { - t.Fatalf("read %d: %v", i, err) - } - if s.readers.Len() > archiveReadHandles { - t.Fatal("unbounded reader handles") - } - } - report, err := s.finish() - if err != nil { - t.Fatal(err) - } - if report.Objects != count || report.BodyBytes != total || report.CARFiles < 2 || report.Entries >= 100 { - t.Fatalf("inode reduction failed: %+v", report) - } - t.Logf("%d objects, %d CAR segments, %d CAS filesystem entries, %d body bytes, %d CAR bytes, %d index bytes", count, report.CARFiles, report.Entries, report.BodyBytes, report.CARBytes, report.IndexBytes) -} - -func TestReplayArchiveRejectsCorruptOrIncompleteStorage(t *testing.T) { - for _, scenario := range []string{"body", "index-offset", "missing-index", "extra-index", "truncated-frame", "truncated-varint", "duplicate-frame", "missing-segment"} { - t.Run(scenario, func(t *testing.T) { - s := testArchive(t, 4096) - data := []byte("checked payload") - key := archiveKey(t, data, cid.Raw) - if err := s.appendNew(key, data); err != nil { - t.Fatal(err) - } - location, err := s.index.Get(key.Bytes(), nil) - if err != nil { - t.Fatal(err) - } - p, err := decodeArchiveLocation(location) - if err != nil { - t.Fatal(err) - } - switch scenario { - case "body": - _, err = s.active.WriteAt([]byte("!"), int64(p.offset+p.length-1)) - case "index-offset": - p.offset++ - err = s.index.Put(key.Bytes(), p.encode(), nil) - case "missing-index": - err = s.index.Delete(key.Bytes(), nil) - case "extra-index": - err = s.index.Put(archiveKey(t, []byte("phantom"), cid.Raw).Bytes(), location, nil) - case "truncated-frame": - err = s.active.Truncate(s.activeBytes - 1) - case "truncated-varint": - err = s.write([]byte{0x80}) - case "duplicate-frame": - err = s.appendNew(key, data) - case "missing-segment": - f := s.active - s.active = nil - err = f.Close() - if err == nil { - err = os.Remove(s.path(1)) - } - } - if err != nil { - t.Fatal(err) - } - if scenario == "body" || scenario == "index-offset" || scenario == "missing-segment" || scenario == "truncated-frame" { - if _, err := s.get(key); err == nil || os.IsNotExist(err) { - t.Fatalf("corruption treated as valid/missing block: %v", err) - } - } - if _, err := s.inventory(); err == nil { - t.Fatal("damaged CAR/index accepted") - } - }) - } -} - -func TestReplayArchiveFailurePoisonsAppend(t *testing.T) { - s := testArchive(t, 4096) - data := []byte("first") - if err := s.appendNew(archiveKey(t, data, cid.Raw), data); err != nil { - t.Fatal(err) - } - // Read-only replacement deterministically injects a failed filesystem write. - if err := s.active.Close(); err != nil { - t.Fatal(err) - } - var err error - s.active, err = os.Open(s.path(1)) - if err != nil { - t.Fatal(err) - } - for _, text := range []string{"second", "third"} { - body := []byte(text) - if err := s.appendNew(archiveKey(t, body, cid.Raw), body); err == nil { - t.Fatal("failed archive continued accepting writes") - } - } - if _, err := s.inventory(); err == nil { - t.Fatal("failed run became a complete inventory") - } - missing := archiveKey(t, []byte("absent"), cid.Raw) - if _, err := s.get(missing); errors.Is(err, os.ErrNotExist) { - t.Fatal("poisoned store treated as normal absence") - } -} - -func TestReplayArchiveIndexFailureRetainsUnpublishedFrame(t *testing.T) { - s := testArchive(t, 4096) - data := []byte("first") - if err := s.appendNew(archiveKey(t, data, cid.Raw), data); err != nil { - t.Fatal(err) - } - before := s.carWritten - if err := s.index.SetReadOnly(); err != nil { - t.Fatal(err) - } - data = []byte("index publication fails after CAR append") - if err := s.appendNew(archiveKey(t, data, cid.Raw), data); err == nil { - t.Fatal("read-only index accepted append") - } - if s.carWritten <= before { - t.Fatal("partial execution's actual CAR write was erased") - } - if _, err := s.inventory(); err == nil { - t.Fatal("unpublished frame accepted as completed replay") - } -} - -func TestReplayArchiveCreationRejectsExistingIndex(t *testing.T) { - directory := t.TempDir() - s, err := newArchiveCAS(directory, 4096) - if err != nil { - t.Fatal(err) - } - if err := s.closeHandles(); err != nil { - t.Fatal(err) - } - if _, err := newArchiveCAS(directory, 4096); err == nil { - t.Fatal("existing run reused") - } - if _, err := os.Stat(filepath.Join(directory, "index")); err != nil { - t.Fatal(err) - } -} diff --git a/internal/evaluation/rq3baseline/payload_store.go b/internal/evaluation/rq3baseline/payload_store.go deleted file mode 100644 index bc306fe..0000000 --- a/internal/evaluation/rq3baseline/payload_store.go +++ /dev/null @@ -1,150 +0,0 @@ -package rq3baseline - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "os" - "path/filepath" -) - -// payloadStore keeps live checkout content out of the worker heap. Files are -// immutable and addressed by the already-validated payload SHA-256. -type payloadStore struct { - directory string - refs map[string]uint64 -} - -func newPayloadStore() (*payloadStore, error) { - directory, err := os.MkdirTemp("", "malt-rq3-payloads-") - if err != nil { - return nil, err - } - return &payloadStore{directory: directory, refs: make(map[string]uint64)}, nil -} - -func (s *payloadStore) put(digest string, data []byte) error { - if s == nil || s.directory == "" { - return fmt.Errorf("RQ3 payload store is closed") - } - actual := sha256.Sum256(data) - if hex.EncodeToString(actual[:]) != digest { - return fmt.Errorf("RQ3 payload store object %q has a mismatched digest", digest) - } - if s.refs[digest] > 0 { - s.refs[digest]++ - return nil - } - path := filepath.Join(s.directory, digest) - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if os.IsExist(err) { - existing, readErr := os.ReadFile(path) - if readErr != nil { - return readErr - } - if !bytes.Equal(existing, data) { - return fmt.Errorf("RQ3 payload store object %q differs from its existing content", digest) - } - s.refs[digest]++ - return nil - } - if err != nil { - return err - } - if _, err := file.Write(data); err != nil { - return cleanupFailedPayloadFile(path, err, file.Close(), os.Remove) - } - if err := file.Close(); err != nil { - return cleanupFailedPayloadFile(path, err, nil, os.Remove) - } - s.refs[digest]++ - return nil -} - -func cleanupFailedPayloadFile(path string, primary, closeErr error, remove func(string) error) error { - removeErr := remove(path) - if os.IsNotExist(removeErr) { - removeErr = nil - } - return errors.Join(primary, closeErr, removeErr) -} - -func (s *payloadStore) read(file logicalFile) ([]byte, error) { - if file.data != nil { - return file.data, nil - } - if s == nil || s.directory == "" { - return nil, fmt.Errorf("RQ3 payload store is closed") - } - data, err := os.ReadFile(filepath.Join(s.directory, file.hash)) - if err != nil { - return nil, err - } - digest := sha256.Sum256(data) - if len(data) != file.size || hex.EncodeToString(digest[:]) != file.hash { - return nil, fmt.Errorf("RQ3 payload store object %q failed validation", file.hash) - } - return data, nil -} - -// reconcile retains exactly the immutable payloads referenced by the supplied -// live semantic and execution states. Replaced and deleted content is removed -// between chunks so disk use follows the live checkout rather than history. -func (s *payloadStore) reconcile(states ...map[string]logicalFile) error { - if s == nil || s.directory == "" { - return fmt.Errorf("RQ3 payload store is closed") - } - live := make(map[string]uint64) - for _, state := range states { - for _, file := range state { - if file.hash == "" { - return fmt.Errorf("RQ3 live payload has an empty digest") - } - live[file.hash]++ - } - } - var reconcileErr error - for digest := range s.refs { - if live[digest] != 0 { - continue - } - if err := os.Remove(filepath.Join(s.directory, digest)); err != nil && !os.IsNotExist(err) { - reconcileErr = errors.Join(reconcileErr, fmt.Errorf("remove unreferenced RQ3 payload %q: %w", digest, err)) - continue - } - delete(s.refs, digest) - } - for digest, count := range live { - if _, err := os.Stat(filepath.Join(s.directory, digest)); err != nil { - reconcileErr = errors.Join(reconcileErr, fmt.Errorf("stat live RQ3 payload %q: %w", digest, err)) - continue - } - s.refs[digest] = count - } - return reconcileErr -} - -func (s *payloadStore) close() error { - if s == nil || s.directory == "" { - return nil - } - directory := s.directory - if err := os.RemoveAll(directory); err != nil { - return err - } - s.directory = "" - s.refs = nil - return nil -} - -func retainLogicalFile(data []byte, mode uint32, digest string, store *payloadStore) (logicalFile, error) { - if store == nil { - return logicalFile{data: cloneBytes(data), mode: mode, hash: digest, size: len(data)}, nil - } - if err := store.put(digest, data); err != nil { - return logicalFile{}, err - } - return logicalFile{mode: mode, hash: digest, size: len(data)}, nil -} diff --git a/internal/evaluation/rq3baseline/readback.go b/internal/evaluation/rq3baseline/readback.go deleted file mode 100644 index ba45279..0000000 --- a/internal/evaluation/rq3baseline/readback.go +++ /dev/null @@ -1,267 +0,0 @@ -package rq3baseline - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - - merkledagimport "github.com/dewebprotocol/malt-client/merkledag/importer" - merkledag "github.com/ipfs/boxo/ipld/merkledag" - unixfs "github.com/ipfs/boxo/ipld/unixfs" - unixfsio "github.com/ipfs/boxo/ipld/unixfs/io" - cid "github.com/ipfs/go-cid" - ipld "github.com/ipfs/go-ipld-format" -) - -// VerifyAll checks every final source file and the complete directory key set. -// Per-commit checks must not hide a lost untouched file or an extra binding. -func (s *StreamSession) VerifyAll(ctx context.Context) error { - if s == nil || s.failed || s.editor == nil { - return fmt.Errorf("invalid stream") - } - dag := merkledagimport.NewDAGService(s.store) - key, err := cid.Parse(s.editor.Root()) - if err != nil { - return err - } - node, err := dag.Get(ctx, key) - if err != nil { - return err - } - var paths []string - var walk func(ipld.Node, string) error - walk = func(node ipld.Node, prefix string) error { - dir, err := unixfsio.NewDirectoryFromNode(dag, node) - if err != nil { - return err - } - links, err := dir.Links(ctx) - if err != nil { - return err - } - for _, link := range links { - name := link.Name - if prefix != "" { - name = prefix + "/" + name - } - child, err := dag.Get(ctx, link.Cid) - if err != nil { - return err - } - isDirectory := false - if proto, ok := child.(*merkledag.ProtoNode); ok { - info, err := unixfs.FSNodeFromBytes(proto.Data()) - if err != nil { - return err - } - isDirectory = info.Type() == unixfs.TDirectory || info.Type() == unixfs.THAMTShard - } - if isDirectory && !s.flat { - if err := walk(child, name); err != nil { - return err - } - continue - } - if _, exists := s.state[name]; !exists { - return fmt.Errorf("unexpected final binding %q", name) - } - paths = append(paths, name) - } - return nil - } - if err := walk(node, ""); err != nil { - return err - } - sort.Strings(paths) - if len(paths) != len(s.state) { - return fmt.Errorf("final directory key set differs from frozen source") - } - for i := 1; i < len(paths); i++ { - if paths[i] == paths[i-1] { - return fmt.Errorf("duplicate final binding") - } - } - return s.VerifyPaths(ctx, paths) -} - -// RetainedCAS independently enumerates the actual CAS objects before cleanup. -// It measures serialized object bodies, excluding filesystem allocation and -// inode metadata; no physical-device write amplification is implied. -func (s *StreamSession) RetainedCAS() (objects, bytes int64, err error) { - if s == nil || s.failed || s.store == nil { - return 0, 0, fmt.Errorf("invalid stream") - } - s.store.mu.Lock() - defer s.store.mu.Unlock() - if s.store.archive != nil { - report, err := s.store.archive.inventory() - return report.Objects, report.BodyBytes, err - } - err = filepath.WalkDir(s.store.root, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - return nil - } - info, statErr := entry.Info() - if statErr != nil { - return statErr - } - if !info.Mode().IsRegular() { - return fmt.Errorf("non-regular CAS entry %q", path) - } - objects++ - bytes += info.Size() - return nil - }) - return -} - -// FinishReplayStorage reconciles all CAR frames with the disk index, closes -// the backend, and inventories its files before cleanup. This is terminal: -// further reads/mutations are rejected. It does not resume a failed replay. -func (s *StreamSession) FinishReplayStorage() (*ReplayStorage, error) { - if s == nil || s.failed || s.store == nil || s.store.archive == nil { - return nil, fmt.Errorf("invalid archive replay stream") - } - s.store.mu.Lock() - defer s.store.mu.Unlock() - report, err := s.store.archive.finish() - if err != nil { - s.failed = true - return nil, err - } - return &report, nil -} - -// VerifyPaths checks the active CAS root against the independently validated -// source state. It includes absence and verifies every traversed CID through -// the accounting store. It is not a MALT proof or a durable-write receipt. -func (s *StreamSession) VerifyPaths(ctx context.Context, paths []string) error { - if s == nil || s.failed || s.editor == nil { - return fmt.Errorf("readback requires an active, valid stream") - } - return s.verifyPaths(ctx, paths) -} - -func (s *StreamSession) verifyPaths(ctx context.Context, paths []string) error { - dag := merkledagimport.NewDAGService(s.store) - root, err := cid.Parse(s.editor.Root()) - if err != nil { - return err - } - for _, path := range paths { - if err := validateCanonicalPath("readback path", path); err != nil { - return err - } - node, err := dag.Get(ctx, root) - if err != nil { - return err - } - parts := strings.Split(path, "/") - want, exists := s.state[path] - if s.flat { - parts = []string{path} - } - for _, part := range parts { - dir, directoryErr := unixfsio.NewDirectoryFromNode(dag, node) - if directoryErr != nil { - // Replacing a directory with a regular file removes all former - // descendants. A CID-checked regular-file ancestor establishes - // absence; malformed or missing DAG nodes remain errors. - if !exists && !s.flat { - regular, kindErr := regularFileNode(node) - if kindErr != nil { - return kindErr - } - if regular { - err = os.ErrNotExist - break - } - } - return fmt.Errorf("readback %q: %w", path, directoryErr) - } - node, err = dir.Find(ctx, part) - if err != nil { - break - } - } - if !exists { - // Git can delete file p and add p/child in the same commit. The - // regular-file binding p is absent even though the derived directory - // p exists. Flat HAMT keys remain opaque and cannot use this rule. - if err == nil && !s.flat && s.hasSourceDescendant(path) { - if _, directoryErr := unixfsio.NewDirectoryFromNode(dag, node); directoryErr == nil { - continue - } - } - if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("readback deleted path %q returned %v", path, err) - } - continue - } - if err != nil { - return fmt.Errorf("readback present path %q: %w", path, err) - } - if s.preserveMode { - proto, ok := node.(*merkledag.ProtoNode) - if !ok { - return fmt.Errorf("file %q lacks its declared permission metadata", path) - } - info, err := unixfs.FSNodeFromBytes(proto.Data()) - if err != nil { - return err - } - if uint32(info.Mode().Perm()) != want.mode { - return fmt.Errorf("readback permissions for %q differ from frozen source", path) - } - } - reader, err := unixfsio.NewDagReader(ctx, node, dag) - if err != nil { - return fmt.Errorf("readback file %q: %w", path, err) - } - hash := sha256.New() - n, readErr := io.Copy(hash, io.LimitReader(reader, int64(want.size)+1)) - closeErr := reader.Close() - if err := errors.Join(readErr, closeErr); err != nil { - return err - } - if n != int64(want.size) || hex.EncodeToString(hash.Sum(nil)) != want.hash { - return fmt.Errorf("readback %q does not match the frozen post-image", path) - } - } - return nil -} - -func (s *StreamSession) hasSourceDescendant(path string) bool { - prefix := path + "/" - for sourcePath := range s.state { - if strings.HasPrefix(sourcePath, prefix) { - return true - } - } - return false -} - -func regularFileNode(node ipld.Node) (bool, error) { - if _, ok := node.(*merkledag.RawNode); ok { - return true, nil - } - proto, ok := node.(*merkledag.ProtoNode) - if !ok { - return false, nil - } - info, err := unixfs.FSNodeFromBytes(proto.Data()) - if err != nil { - return false, err - } - return info.Type() == unixfs.TFile || info.Type() == unixfs.TRaw, nil -} diff --git a/internal/evaluation/rq3baseline/readback_test.go b/internal/evaluation/rq3baseline/readback_test.go deleted file mode 100644 index 453b59f..0000000 --- a/internal/evaluation/rq3baseline/readback_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package rq3baseline - -import ( - "context" - "testing" -) - -func TestFlatAndNestedHAMTUseDifferentPathIndexes(t *testing.T) { - ctx := context.Background() - mode, raw := uint32(0o644), true - file := frozenFile - spec := RunSpec{System: SystemHAMTUnixFS, Layout: LayoutSpec{Model: "unixfs", FileLayout: "balanced", DirectoryLayout: "hamt", Chunking: ChunkingSpec{Algorithm: "fixed", SizeBytes: 4}, HAMTFanout: 256, RawFileLeaf: &raw}, Snapshot: Snapshot{CommitID: "initial", Files: []FrozenFile{file("a/b.txt", []byte("one")), file("c.txt", []byte("two"))}}, Commits: []Commit{}} - nested, n, err := StartStream(ctx, spec) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := nested.Close(); err != nil { - t.Error(err) - } - }) - flat, f, err := StartFlatHAMTStream(ctx, spec) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := flat.Close(); err != nil { - t.Error(err) - } - }) - if n.Root == f.Root { - t.Fatal("flat and nested HAMT produced the same root") - } - for _, session := range []*StreamSession{nested, flat} { - if err := session.VerifyPaths(ctx, []string{"a/b.txt", "c.txt", "missing.txt"}); err != nil { - t.Fatal(err) - } - commit := Commit{CommitID: "delete", Mutations: []Mutation{{Kind: MutationDelete, FileKind: FileKindRegular, Path: "a/b.txt", ExpectedOldSHA256: digest([]byte("one")), ExpectedOldMode: &mode}}} - if _, err := session.ApplyChunkWithReadback(ctx, []Commit{commit}); err != nil { - t.Fatal(err) - } - if err := session.VerifyPaths(ctx, []string{"a/b.txt", "c.txt"}); err != nil { - t.Fatal(err) - } - if err := session.VerifyAll(ctx); err != nil { - t.Fatal(err) - } - } - var sum int64 - for _, event := range f.CAS.Events { - if event.ObjectRole == "" { - t.Fatal("CAS event lacks object ownership") - } - if event.Bytes != event.PayloadBytes+event.StructuralMetadataBytes { - t.Fatal("byte components do not partition object") - } - if event.Status == statusNewlyPersisted { - sum += event.Bytes - } - } - if sum != f.CAS.Total.NewlyPersistedBytes { - t.Fatal("event ledger does not reconcile") - } -} - -func TestReplayPreservesPermissionsForRawLeafFiles(t *testing.T) { - ctx := context.Background() - for _, flat := range []bool{false, true} { - spec := baseSpec(SystemHAMTUnixFS, "hamt", 256, []FrozenFile{frozenFile("a/b", []byte("one"))}) - session, initial, err := StartReplayStream(ctx, spec, flat) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := session.Close(); err != nil { - t.Error(err) - } - }) - if err := session.VerifyPaths(ctx, []string{"a/b"}); err != nil { - t.Fatal(err) - } - mode := uint32(0o755) - oldMode := uint32(0o644) - mutation := Mutation{Kind: MutationModeChange, Path: "a/b", FileKind: FileKindRegular, ExpectedOldSHA256: digest([]byte("one")), ExpectedOldMode: &oldMode, Mode: &mode} - records, err := session.ApplyChunkWithReadback(ctx, []Commit{{CommitID: "chmod", Mutations: []Mutation{mutation}}}) - if err != nil { - t.Fatal(err) - } - if records[0].Root == initial.Root || records[0].AdapterPayloadInputBytes != 0 || records[0].CAS.Total.NewlyPersistedBytes == 0 { - t.Fatal("mode-only update lost authenticated metadata or source boundary") - } - } -} - -func TestReplayReadbackDistinguishesFileAndDirectoryReplacements(t *testing.T) { - for _, profile := range []string{"merkledag-nested", "hamt-nested", "hamt-flat"} { - t.Run(profile, func(t *testing.T) { - mode := uint32(0o644) - system, directory, fanout := SystemHAMTUnixFS, "hamt", 256 - if profile == "merkledag-nested" { - system, directory, fanout = SystemMerkleDAGUnixFS, "basic", 0 - } - spec := baseSpec(system, directory, fanout, []FrozenFile{frozenFile("a", []byte("old-a")), frozenFile("b/c", []byte("old-c"))}) - session, _, err := StartReplayStream(t.Context(), spec, profile == "hamt-flat") - if err != nil { - t.Fatal(err) - } - defer session.Close() - insert := func(path, body string) Mutation { - file := frozenFile(path, []byte(body)) - return Mutation{Kind: MutationInsert, FileKind: FileKindRegular, Path: path, PayloadBase64: file.PayloadBase64, PayloadSHA256: file.PayloadSHA256, Mode: &mode} - } - remove := func(path, body string) Mutation { - return Mutation{Kind: MutationDelete, FileKind: FileKindRegular, Path: path, ExpectedOldSHA256: digest([]byte(body)), ExpectedOldMode: &mode} - } - commits := []Commit{ - {CommitID: "swap", Mutations: []Mutation{remove("a", "old-a"), insert("a/x", "new-x"), remove("b/c", "old-c"), insert("b", "new-b")}}, - {CommitID: "reverse", Mutations: []Mutation{remove("a/x", "new-x"), insert("a", "final-a"), remove("b", "new-b"), insert("b/c", "final-c")}}, - } - for _, commit := range commits { - if _, err := session.ApplyChunkWithReadback(t.Context(), []Commit{commit}); err != nil { - t.Fatal(err) - } - if err := session.VerifyAll(t.Context()); err != nil { - t.Fatal(err) - } - } - // Actual directory bindings still cannot stand in for expected files. - actual := session.state["b/c"] - session.state["b"] = actual - if err := session.VerifyPaths(t.Context(), []string{"b"}); err == nil { - t.Fatal("a directory was accepted as a source regular file") - } - delete(session.state, "b") - if err := session.VerifyPaths(t.Context(), []string{"a"}); err != nil { - t.Fatal(err) - } - }) - } -} diff --git a/internal/evaluation/rq3baseline/run.go b/internal/evaluation/rq3baseline/run.go deleted file mode 100644 index 3ba4715..0000000 --- a/internal/evaluation/rq3baseline/run.go +++ /dev/null @@ -1,247 +0,0 @@ -package rq3baseline - -import ( - "context" - "errors" - "fmt" - "io/fs" - "time" - - merkledagimport "github.com/dewebprotocol/malt-client/merkledag/importer" -) - -// Run validates the complete frozen workload before creating any CAS state, -// then executes it through the current incremental UnixFS Editor. -func Run(ctx context.Context, spec RunSpec) (_ *RunResult, returnErr error) { - prepared, err := prepare(spec) - if err != nil { - return nil, err - } - sourceAccounting := accountPreparedSource(prepared) - store := newAccountingStore() - defer func() { returnErr = errors.Join(returnErr, retryCleanup(store.close)) }() - editor, err := merkledagimport.NewEditor(store, importerOptions(prepared.spec)) - if err != nil { - return nil, fmt.Errorf("create UnixFS editor: %w", err) - } - - result := &RunResult{ - SchemaVersion: RunResultSchema, - CapabilityID: Capability().CapabilityID, - System: prepared.spec.System, - WorkloadSHA256: prepared.digest, - Layout: prepared.spec.Layout, - Records: make([]CommitRecord, 0, 1+len(prepared.commits)), - } - state := make(map[string]logicalFile, len(prepared.snapshot)) - - store.beginPhase() - started := time.Now() - snapshotMutations := make([]MutationExecution, 0, len(prepared.snapshot)) - var snapshotPayloadBytes int64 - for index, file := range prepared.snapshot { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := editor.PutFile(ctx, file.path, file.data, fs.FileMode(file.mode)); err != nil { - return nil, fmt.Errorf("snapshot commit %q put %q: %w", prepared.spec.Snapshot.CommitID, file.path, err) - } - state[file.path] = logicalFile{data: cloneBytes(file.data), mode: file.mode, hash: file.hash, size: len(file.data)} - snapshotPayloadBytes += int64(len(file.data)) - snapshotMutations = append(snapshotMutations, MutationExecution{ - Index: index, - Kind: MutationInsert, - Path: file.path, - Translation: "snapshot_put_file", - LogicalObjectsChanged: 1, - LogicalBindingsChanged: 1, - LogicalPayloadBytes: int64(len(file.data)), - }) - } - snapshotElapsed := time.Since(started).Nanoseconds() - snapshotRoot := editor.Root() - if snapshotRoot == "" { - return nil, fmt.Errorf("snapshot commit %q produced an empty root", prepared.spec.Snapshot.CommitID) - } - snapshotCAS, snapshotPutNanos, snapshotGetNanos := store.finishPhase() - result.Records = append(result.Records, CommitRecord{ - CommitID: prepared.spec.Snapshot.CommitID, - Root: snapshotRoot, - Snapshot: true, - LogicalObjectsChanged: len(prepared.snapshot), - LogicalBindingsChanged: len(prepared.snapshot), - LogicalPayloadBytes: snapshotPayloadBytes, - AdapterPayloadInputBytes: sourceAccounting[0].AdapterPayloadInputBytes, - Mutations: snapshotMutations, - CAS: snapshotCAS, - ClientPhases: phaseMetrics(snapshotElapsed, snapshotPutNanos, snapshotGetNanos), - }) - - parentRoot := snapshotRoot - for commitIndex, commit := range prepared.commits { - store.beginPhase() - started = time.Now() - executions := make([]MutationExecution, 0, len(commit.mutations)) - logicalObjects := 0 - logicalBindings := 0 - var logicalPayload int64 - for index, mutation := range commit.mutations { - if err := ctx.Err(); err != nil { - return nil, err - } - execution, err := executeMutation(ctx, editor, state, index, mutation, nil) - if err != nil { - return nil, fmt.Errorf("commit %q mutation[%d]: %w", commit.id, index, err) - } - executions = append(executions, execution) - logicalObjects += execution.LogicalObjectsChanged - logicalBindings += execution.LogicalBindingsChanged - logicalPayload += execution.LogicalPayloadBytes - } - elapsed := time.Since(started).Nanoseconds() - root := editor.Root() - if root == "" { - return nil, fmt.Errorf("commit %q produced an empty root", commit.id) - } - casAccounting, putNanos, getNanos := store.finishPhase() - result.Records = append(result.Records, CommitRecord{ - CommitID: commit.id, - ParentRoot: parentRoot, - Root: root, - LogicalObjectsChanged: logicalObjects, - LogicalBindingsChanged: logicalBindings, - LogicalPayloadBytes: logicalPayload, - AdapterPayloadInputBytes: sourceAccounting[commitIndex+1].AdapterPayloadInputBytes, - Mutations: executions, - CAS: casAccounting, - ClientPhases: phaseMetrics(elapsed, putNanos, getNanos), - }) - parentRoot = root - } - return result, nil -} - -func importerOptions(spec RunSpec) merkledagimport.Options { - return merkledagimport.Options{ - Model: spec.Layout.Model, - FileLayout: spec.Layout.FileLayout, - DirLayout: spec.Layout.DirectoryLayout, - ChunkSize: spec.Layout.Chunking.SizeBytes, - HAMTFanout: spec.Layout.HAMTFanout, - RawFileLeaf: *spec.Layout.RawFileLeaf, - } -} - -func executeMutation(ctx context.Context, editor *merkledagimport.Editor, state map[string]logicalFile, index int, prepared preparedMutation, payloads *payloadStore) (MutationExecution, error) { - mutation := prepared.mutation - execution := MutationExecution{ - Index: index, - Kind: mutation.Kind, - Path: mutation.Path, - Destination: mutation.Destination, - LogicalObjectsChanged: 1, - } - switch mutation.Kind { - case MutationInsert: - if err := editor.PutFile(ctx, mutation.Path, prepared.data, fs.FileMode(*mutation.Mode)); err != nil { - return MutationExecution{}, err - } - retained, err := retainLogicalFile(prepared.data, *mutation.Mode, mutation.PayloadSHA256, payloads) - if err != nil { - return MutationExecution{}, err - } - state[mutation.Path] = retained - execution.Translation = "put_file" - execution.LogicalBindingsChanged = 1 - execution.LogicalPayloadBytes = int64(len(prepared.data)) - - case MutationReplace: - if err := editor.PutFile(ctx, mutation.Path, prepared.data, fs.FileMode(*mutation.Mode)); err != nil { - return MutationExecution{}, err - } - retained, err := retainLogicalFile(prepared.data, *mutation.Mode, mutation.PayloadSHA256, payloads) - if err != nil { - return MutationExecution{}, err - } - state[mutation.Path] = retained - execution.Translation = "put_file_replace" - execution.LogicalBindingsChanged = 1 - execution.LogicalPayloadBytes = int64(len(prepared.data)) - - case MutationAppend: - old := state[mutation.Path] - if err := editor.PutFile(ctx, mutation.Path, prepared.data, fs.FileMode(*mutation.Mode)); err != nil { - return MutationExecution{}, err - } - retained, err := retainLogicalFile(prepared.data, *mutation.Mode, mutation.PayloadSHA256, payloads) - if err != nil { - return MutationExecution{}, err - } - state[mutation.Path] = retained - execution.Translation = "put_file_full_result" - execution.LogicalBindingsChanged = 1 - execution.LogicalPayloadBytes = int64(len(prepared.data) - old.size) - - case MutationModeChange: - old := state[mutation.Path] - oldData, err := payloads.read(old) - if payloads == nil { - oldData, err = old.data, nil - } - if err != nil { - return MutationExecution{}, err - } - if err := editor.PutFile(ctx, mutation.Path, oldData, fs.FileMode(*mutation.Mode)); err != nil { - return MutationExecution{}, err - } - old.mode = *mutation.Mode - state[mutation.Path] = old - execution.Translation = "put_file_mode_change" - execution.LogicalBindingsChanged = 1 - - case MutationDelete: - if err := editor.RemoveFile(ctx, mutation.Path); err != nil { - return MutationExecution{}, err - } - delete(state, mutation.Path) - execution.Translation = "remove_file" - execution.LogicalBindingsChanged = 1 - - case MutationRename, MutationMove: - old := state[mutation.Path] - oldData, err := payloads.read(old) - if payloads == nil { - oldData, err = old.data, nil - } - if err != nil { - return MutationExecution{}, err - } - if err := editor.RemoveFile(ctx, mutation.Path); err != nil { - return MutationExecution{}, err - } - if err := editor.PutFile(ctx, mutation.Destination, oldData, fs.FileMode(old.mode)); err != nil { - return MutationExecution{}, err - } - delete(state, mutation.Path) - state[mutation.Destination] = old - execution.Translation = "remove_file_then_put_file" - execution.LogicalBindingsChanged = 2 - - default: - return MutationExecution{}, fmt.Errorf("unsupported mutation kind %q after validation", mutation.Kind) - } - return execution, nil -} - -func phaseMetrics(total, put, get int64) ClientPhases { - compute := total - put - get - if compute < 0 { - compute = 0 - } - return ClientPhases{ - ClientComputeWallNanos: total, - CASPutWallNanos: put, - CASGetWallNanos: get, - EditorOverheadExcludingCASWallNanos: compute, - } -} diff --git a/internal/evaluation/rq3baseline/run_test.go b/internal/evaluation/rq3baseline/run_test.go deleted file mode 100644 index f927163..0000000 --- a/internal/evaluation/rq3baseline/run_test.go +++ /dev/null @@ -1,629 +0,0 @@ -package rq3baseline - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "errors" - "fmt" - "os" - "reflect" - "sort" - "strings" - "testing" - - casmemory "github.com/dewebprotocol/malt-client/internal/cas/memory" - merkledagimport "github.com/dewebprotocol/malt-client/merkledag/importer" - cid "github.com/ipfs/go-cid" -) - -func TestCleanupFailedPayloadFileReturnsCloseAndRemoveFailures(t *testing.T) { - primary := errors.New("write failed") - closeErr := errors.New("close failed") - removeErr := errors.New("remove failed") - got := cleanupFailedPayloadFile("payload", primary, closeErr, func(path string) error { - if path != "payload" { - t.Fatalf("remove path = %q", path) - } - return removeErr - }) - for _, want := range []error{primary, closeErr, removeErr} { - if !errors.Is(got, want) { - t.Fatalf("cleanup error %v does not include %v", got, want) - } - } - if got := cleanupFailedPayloadFile("payload", primary, nil, func(string) error { return os.ErrNotExist }); !errors.Is(got, primary) || errors.Is(got, os.ErrNotExist) { - t.Fatalf("not-exist cleanup should retain only the primary failure: %v", got) - } -} - -func TestPayloadStoreReconcileRetainsOnlyLiveCheckout(t *testing.T) { - store, err := newPayloadStore() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := store.close(); err != nil { - t.Errorf("close payload store: %v", err) - } - }) - oldData, liveData := []byte("historical"), []byte("live") - oldSum, liveSum := sha256.Sum256(oldData), sha256.Sum256(liveData) - oldDigest, liveDigest := hex.EncodeToString(oldSum[:]), hex.EncodeToString(liveSum[:]) - if err := store.put(oldDigest, oldData); err != nil { - t.Fatal(err) - } - if err := store.put(liveDigest, liveData); err != nil { - t.Fatal(err) - } - live := map[string]logicalFile{"file": {hash: liveDigest, size: len(liveData)}} - if err := store.reconcile(live); err != nil { - t.Fatal(err) - } - if _, err := store.read(logicalFile{hash: oldDigest, size: len(oldData)}); !os.IsNotExist(err) { - t.Fatalf("historical payload read error = %v, want not-exist", err) - } - if got, err := store.read(live["file"]); err != nil || string(got) != string(liveData) { - t.Fatalf("live payload = %q, %v", got, err) - } -} - -func TestRunBasicUnixFSEmitsRootPerCommitAndExactDedupAccounting(t *testing.T) { - old := []byte("same-data") - appended := []byte("same-data-more") - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{ - frozenFile("docs/a.txt", old), - frozenFile("docs/b.txt", old), - }) - spec.Commits = []Commit{ - {CommitID: "c2-same-value", Mutations: []Mutation{replaceMutation("docs/a.txt", old, old)}}, - {CommitID: "c3-append", Mutations: []Mutation{appendMutation("docs/a.txt", old, appended)}}, - {CommitID: "c4-rename", Mutations: []Mutation{pathMutation(MutationRename, "docs/b.txt", "docs/c.txt", old)}}, - {CommitID: "c5-move", Mutations: []Mutation{pathMutation(MutationMove, "docs/c.txt", "archive/c.txt", old)}}, - {CommitID: "c6-delete", Mutations: []Mutation{pathMutation(MutationDelete, "archive/c.txt", "", old)}}, - } - - result, err := Run(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - if len(result.Records) != 1+len(spec.Commits) { - t.Fatalf("records = %d, want %d", len(result.Records), 1+len(spec.Commits)) - } - if result.WorkloadSHA256 == "" || result.CapabilityID != Capability().CapabilityID { - t.Fatalf("missing provenance: %#v", result) - } - for i, record := range result.Records { - if _, err := cid.Parse(record.Root); err != nil { - t.Fatalf("record[%d] root %q: %v", i, record.Root, err) - } - if i > 0 && record.ParentRoot != result.Records[i-1].Root { - t.Fatalf("record[%d] parent = %q, want %q", i, record.ParentRoot, result.Records[i-1].Root) - } - if record.ClientPhases.ClientComputeWallNanos <= 0 { - t.Fatalf("record[%d] omitted client compute phase: %#v", i, record.ClientPhases) - } - assertAccountingAddsUp(t, record.CAS) - } - - snapshot := result.Records[0] - if got, want := snapshot.AdapterPayloadInputBytes, int64(len(old)*2); got != want { - t.Fatalf("snapshot adapter input bytes = %d, want full source snapshot %d", got, want) - } - if snapshot.CAS.PayloadChunks.NewlyPersistedBytes == 0 || snapshot.CAS.StructuralMetadata.NewlyPersistedBytes == 0 { - t.Fatalf("snapshot did not separate payload and structure: %#v", snapshot.CAS) - } - if snapshot.CAS.PayloadChunks.DuplicateObjects == 0 { - t.Fatalf("identical payload chunks were not identified as same-commit duplicates: %#v", snapshot.CAS.PayloadChunks) - } - - sameValue := result.Records[1] - if got, want := sameValue.AdapterPayloadInputBytes, int64(len(old)); got != want { - t.Fatalf("same-value replace adapter input bytes = %d, want full post-image %d", got, want) - } - if sameValue.Root != sameValue.ParentRoot { - t.Fatalf("same-value replace changed content-addressed root: parent=%s root=%s", sameValue.ParentRoot, sameValue.Root) - } - if sameValue.CAS.Total.NewlyPersistedObjects != 0 || sameValue.CAS.Total.AlreadyPresentObjects == 0 { - t.Fatalf("same-value replace accounting = %#v", sameValue.CAS.Total) - } - - appendRecord := result.Records[2] - if got, want := appendRecord.LogicalPayloadBytes, int64(len(appended)-len(old)); got != want { - t.Fatalf("append logical bytes = %d, want %d", got, want) - } - if appendRecord.CAS.Total.NewlyPersistedObjects == 0 { - t.Fatal("append persisted no new CAS objects") - } - if got, want := appendRecord.AdapterPayloadInputBytes, int64(len(appended)); got != want { - t.Fatalf("append adapter input bytes = %d, want full post-image %d", got, want) - } - - renameRecord := result.Records[3] - if renameRecord.LogicalPayloadBytes != 0 || renameRecord.LogicalBindingsChanged != 2 || renameRecord.Mutations[0].Translation != "remove_file_then_put_file" { - t.Fatalf("rename semantics = %#v", renameRecord) - } - if renameRecord.AdapterPayloadInputBytes != 0 || result.Records[4].AdapterPayloadInputBytes != 0 || result.Records[5].AdapterPayloadInputBytes != 0 { - t.Fatalf("path-only/delete adapter input bytes must be zero: rename=%d move=%d delete=%d", renameRecord.AdapterPayloadInputBytes, result.Records[4].AdapterPayloadInputBytes, result.Records[5].AdapterPayloadInputBytes) - } - if renameRecord.CAS.Total.AttemptedObjects == 0 { - t.Fatal("rename did not include intermediate Editor writes") - } - - second, err := Run(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - if second.WorkloadSHA256 != result.WorkloadSHA256 { - t.Fatalf("workload digest changed: %s != %s", second.WorkloadSHA256, result.WorkloadSHA256) - } - for i := range result.Records { - if result.Records[i].Root != second.Records[i].Root || !reflect.DeepEqual(result.Records[i].CAS, second.Records[i].CAS) { - t.Fatalf("deterministic result mismatch at record %d", i) - } - } -} - -func TestStreamSessionMatchesSingleRunAcrossChunks(t *testing.T) { - payload := []byte("same-data") - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{ - frozenFile("docs/a.txt", payload), - frozenFile("docs/b.txt", payload), - }) - spec.Commits = []Commit{ - {CommitID: "c2-replace", Mutations: []Mutation{replaceMutation("docs/a.txt", payload, []byte("next"))}}, - {CommitID: "c3-rename", Mutations: []Mutation{pathMutation(MutationRename, "docs/b.txt", "docs/c.txt", payload)}}, - {CommitID: "c4-delete", Mutations: []Mutation{pathMutation(MutationDelete, "docs/c.txt", "", payload)}}, - } - - want, err := Run(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - streamSpec := spec - streamSpec.Commits = []Commit{} - session, snapshot, err := StartStream(t.Context(), streamSpec) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := session.Close(); err != nil { - t.Errorf("close stream session: %v", err) - } - }) - for _, path := range []string{"docs/a.txt", "docs/b.txt"} { - if session.state[path].data != nil || session.validationState[path].data != nil { - t.Fatalf("stream retained %q payload bytes in live heap state", path) - } - stored, err := session.payloads.read(session.state[path]) - if err != nil || !reflect.DeepEqual(stored, payload) { - t.Fatalf("stream disk payload %q = %q, %v", path, stored, err) - } - } - first, err := session.ApplyChunk(t.Context(), spec.Commits[:1]) - if err != nil { - t.Fatal(err) - } - second, err := session.ApplyChunk(t.Context(), spec.Commits[1:]) - if err != nil { - t.Fatal(err) - } - got := append([]CommitRecord{snapshot}, first...) - got = append(got, second...) - if len(got) != len(want.Records) { - t.Fatalf("stream records = %d, want %d", len(got), len(want.Records)) - } - for index := range got { - got[index].ClientPhases = ClientPhases{} - want.Records[index].ClientPhases = ClientPhases{} - if !reflect.DeepEqual(got[index], want.Records[index]) { - t.Fatalf("stream record[%d] differs from single run:\n got: %#v\nwant: %#v", index, got[index], want.Records[index]) - } - } - if session.Root() != got[len(got)-1].Root { - t.Fatalf("stream root = %q, want final record root %q", session.Root(), got[len(got)-1].Root) - } -} - -func TestStreamSessionRejectsDuplicateCommitAcrossChunksWithoutAdvancing(t *testing.T) { - payload := []byte("payload") - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{frozenFile("file", payload)}) - session, _, err := StartStream(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - commit := Commit{CommitID: "c2", Mutations: []Mutation{replaceMutation("file", payload, []byte("next"))}} - if _, err := session.ApplyChunk(t.Context(), []Commit{commit}); err != nil { - t.Fatal(err) - } - root := session.Root() - if _, err := session.ApplyChunk(t.Context(), []Commit{commit}); err == nil || !strings.Contains(err.Error(), "duplicate commit_id") { - t.Fatalf("duplicate commit error = %v", err) - } - if session.Root() != root { - t.Fatalf("duplicate commit advanced root from %q to %q", root, session.Root()) - } -} - -func TestOneShotAppendDirectlyAfterSnapshotCountsOnlySuffix(t *testing.T) { - old := []byte("base") - next := []byte("base-suffix") - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{frozenFile("file", old)}) - spec.Commits = []Commit{{CommitID: "append", Mutations: []Mutation{appendMutation("file", old, next)}}} - result, err := Run(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - if got, want := result.Records[1].LogicalPayloadBytes, int64(len(next)-len(old)); got != want { - t.Fatalf("direct snapshot append logical bytes = %d, want suffix %d", got, want) - } -} - -func TestModeChangePreservesDiskBackedSizeAcrossAppendAndRename(t *testing.T) { - old := []byte("base") - next := []byte("base-suffix") - oldMode, newMode := uint32(0o644), uint32(0o755) - appendAfterMode := appendMutation("file", old, next) - appendAfterMode.ExpectedOldMode, appendAfterMode.Mode = &newMode, &newMode - renameAfterMode := pathMutation(MutationRename, "file", "renamed", next) - renameAfterMode.ExpectedOldMode, renameAfterMode.Mode = &newMode, &newMode - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{frozenFile("file", old)}) - spec.Commits = []Commit{ - {CommitID: "chmod", Mutations: []Mutation{{Kind: MutationModeChange, Path: "file", FileKind: FileKindRegular, ExpectedOldSHA256: digest(old), ExpectedOldMode: &oldMode, Mode: &newMode}}}, - {CommitID: "append", Mutations: []Mutation{appendAfterMode}}, - {CommitID: "rename", Mutations: []Mutation{renameAfterMode}}, - } - oneShot, err := Run(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - if got, want := oneShot.Records[2].LogicalPayloadBytes, int64(len(next)-len(old)); got != want { - t.Fatalf("one-shot append after mode-change logical bytes = %d, want %d", got, want) - } - - streamSpec := spec - streamSpec.Commits = []Commit{} - session, _, err := StartStream(t.Context(), streamSpec) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := session.Close(); err != nil { - t.Errorf("close mode-change stream: %v", err) - } - }) - for index, commit := range spec.Commits { - records, err := session.ApplyChunk(t.Context(), []Commit{commit}) - if err != nil { - t.Fatalf("stream commit %d (%s): %v", index, commit.CommitID, err) - } - if commit.CommitID == "append" && records[0].LogicalPayloadBytes != int64(len(next)-len(old)) { - t.Fatalf("stream append after mode-change logical bytes = %d", records[0].LogicalPayloadBytes) - } - } -} - -func TestValidateAndAccountSourceDoesNotConflateAdapterInputWithLogicalDelta(t *testing.T) { - old := []byte("abcd") - next := []byte("abcdefgh") - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{frozenFile("file", old)}) - spec.Commits = []Commit{ - {CommitID: "append", Mutations: []Mutation{appendMutation("file", old, next)}}, - {CommitID: "rename", Mutations: []Mutation{pathMutation(MutationRename, "file", "renamed", next)}}, - } - accounting, err := ValidateAndAccountSource(spec) - if err != nil { - t.Fatal(err) - } - if len(accounting) != 3 || accounting[0].AdapterPayloadInputBytes != int64(len(old)) || - accounting[1].AdapterPayloadInputBytes != int64(len(next)) || accounting[2].AdapterPayloadInputBytes != 0 || - accounting[2].LogicalBindingsChanged != 2 { - t.Fatalf("source accounting = %#v", accounting) - } -} - -func TestRunHAMTUsesCurrentEditorAndAccountsMixedBatch(t *testing.T) { - files := make([]FrozenFile, 0, 32) - for i := 0; i < 32; i++ { - files = append(files, frozenFile(fmt.Sprintf("files/%02d.txt", i), []byte(fmt.Sprintf("payload-%02d", i)))) - } - sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) - spec := baseSpec(SystemHAMTUnixFS, "hamt", 16, files) - spec.Commits = []Commit{{CommitID: "hamt-mixed", Mutations: []Mutation{ - insertMutation("files/zz.txt", []byte("new")), - pathMutation(MutationDelete, "files/00.txt", "", []byte("payload-00")), - }}} - - result, err := Run(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - if len(result.Records) != 2 || result.Records[1].Root == result.Records[0].Root { - t.Fatalf("HAMT roots = %#v", result.Records) - } - commit := result.Records[1] - if commit.LogicalObjectsChanged != 2 || commit.LogicalBindingsChanged != 2 { - t.Fatalf("HAMT mixed logical accounting = %#v", commit) - } - if commit.CAS.StructuralMetadata.AttemptedObjects == 0 || commit.CAS.Reads.Objects == 0 { - t.Fatalf("HAMT path did not expose structural writes/reads: %#v", commit.CAS) - } - for _, record := range result.Records { - for _, event := range record.CAS.Events { - if event.Category == categoryMixed { - t.Fatalf("raw-leaf HAMT bitfield was misclassified as payload: %#v", event) - } - } - } -} - -func TestRunSupportsCurrentTrickleFileLayout(t *testing.T) { - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{frozenFile("file.txt", []byte("0123456789abcdef"))}) - spec.Layout.FileLayout = "trickle" - result, err := Run(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - if len(result.Records) != 1 || result.Records[0].CAS.PayloadChunks.NewlyPersistedObjects < 2 { - t.Fatalf("trickle result = %#v", result) - } -} - -func TestRunNonRawLeavesDecodeExactMixedByteAccounting(t *testing.T) { - payload := []byte("embedded-payload") - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{frozenFile("file.txt", payload)}) - raw := false - spec.Layout.RawFileLeaf = &raw - result, err := Run(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - accounting := result.Records[0].CAS - foundMixed := false - for _, event := range accounting.Events { - if event.PayloadBytes+event.StructuralMetadataBytes != event.Bytes { - t.Fatalf("event byte split is not exact: %#v", event) - } - if event.Category == categoryMixed { - foundMixed = true - } - } - if !foundMixed || accounting.PayloadChunks.NewlyPersistedBytes == 0 || accounting.StructuralMetadata.NewlyPersistedBytes == 0 { - t.Fatalf("non-raw accounting = %#v", accounting) - } - imported, err := merkledagimport.ImportFiles(t.Context(), casmemory.New(), []merkledagimport.File{{Path: "file.txt", Data: payload, Mode: 0o644}}, merkledagimport.Options{ - Model: merkledagimport.ModelUnixFS, FileLayout: merkledagimport.FileLayoutBalanced, DirLayout: merkledagimport.DirLayoutBasic, ChunkSize: 4, RawFileLeaf: false, - }) - if err != nil { - t.Fatal(err) - } - if result.Records[0].Root != imported.Root { - t.Fatalf("Editor snapshot root %s differs from current ImportFiles root %s", result.Records[0].Root, imported.Root) - } -} - -func TestRunModeChangeMovesOnlyStructuralMetadata(t *testing.T) { - payload := []byte("unchanged") - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{frozenFile("script", payload)}) - oldMode, newMode := uint32(0o644), uint32(0o755) - spec.Commits = []Commit{{CommitID: "chmod", Mutations: []Mutation{{ - Kind: MutationModeChange, Path: "script", FileKind: FileKindRegular, ExpectedOldSHA256: digest(payload), ExpectedOldMode: &oldMode, Mode: &newMode, - }}}} - result, err := Run(t.Context(), spec) - if err != nil { - t.Fatal(err) - } - record := result.Records[1] - if record.Root == record.ParentRoot || record.LogicalPayloadBytes != 0 || record.Mutations[0].Translation != "put_file_mode_change" { - t.Fatalf("mode-change record = %#v", record) - } - if record.CAS.PayloadChunks.NewlyPersistedObjects != 0 || record.CAS.StructuralMetadata.NewlyPersistedObjects == 0 { - t.Fatalf("mode-change byte placement = %#v", record.CAS) - } -} - -func TestRunFailsClosedForCapabilityAndFrozenWorkloadViolations(t *testing.T) { - payload := []byte("payload") - tests := []struct { - name string - mutate func(*RunSpec) - wantGap string - wantErr string - }{ - { - name: "invalid HAMT fanout", - mutate: func(spec *RunSpec) { - spec.System = SystemHAMTUnixFS - spec.Layout.DirectoryLayout = "hamt" - spec.Layout.HAMTFanout = 2 - }, - wantErr: "multiple of 8", - }, - { - name: "directory rename", - mutate: func(spec *RunSpec) { - spec.Commits = []Commit{{CommitID: "c2", Mutations: []Mutation{pathMutation(MutationRename, "docs", "archive", payload)}}} - }, - wantGap: "directory_subtree_mutation", - }, - { - name: "symlink snapshot", - mutate: func(spec *RunSpec) { - spec.Snapshot.Files[0].FileKind = "symlink" - }, - wantGap: "symlink_and_special_file_mutation", - }, - { - name: "digest mismatch", - mutate: func(spec *RunSpec) { - spec.Snapshot.Files[0].PayloadSHA256 = strings.Repeat("0", 64) - }, - wantErr: "payload SHA-256 mismatch", - }, - { - name: "noncanonical base64", - mutate: func(spec *RunSpec) { - spec.Snapshot.Files[0].PayloadBase64 = base64.RawStdEncoding.EncodeToString(payload) - }, - wantErr: "not canonical standard base64", - }, - { - name: "unsorted snapshot", - mutate: func(spec *RunSpec) { - spec.Snapshot.Files = []FrozenFile{frozenFile("z", payload), frozenFile("a", payload)} - }, - wantErr: "strictly sorted", - }, - { - name: "stale old digest", - mutate: func(spec *RunSpec) { - mutation := replaceMutation("docs/file.txt", []byte("stale"), []byte("next")) - spec.Commits = []Commit{{CommitID: "c2", Mutations: []Mutation{mutation}}} - }, - wantErr: "old payload digest mismatch", - }, - { - name: "append is not prefix extension", - mutate: func(spec *RunSpec) { - spec.Commits = []Commit{{CommitID: "c2", Mutations: []Mutation{appendMutation("docs/file.txt", payload, []byte("other"))}}} - }, - wantErr: "strictly extend", - }, - { - name: "projected chunk output is bounded", - mutate: func(spec *RunSpec) { - spec.Layout.Chunking.SizeBytes = 1 - spec.Snapshot.Files = []FrozenFile{frozenFile("large", make([]byte, maxProjectedChunks+1))} - }, - wantErr: "projected payload chunks exceed", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - spec := baseSpec(SystemMerkleDAGUnixFS, "basic", 0, []FrozenFile{frozenFile("docs/file.txt", payload)}) - test.mutate(&spec) - _, err := Run(t.Context(), spec) - if err == nil { - t.Fatal("Run succeeded") - } - if test.wantGap != "" { - unsupported, ok := err.(*UnsupportedError) - if !ok || unsupported.Gap != test.wantGap { - t.Fatalf("error = %#v, want gap %q", err, test.wantGap) - } - } else if !strings.Contains(err.Error(), test.wantErr) { - t.Fatalf("error = %v, want substring %q", err, test.wantErr) - } - }) - } -} - -func TestCapabilityDocumentsFailClosedBoundaries(t *testing.T) { - capability := Capability() - if got, want := capability.SafetyLimits.MaximumJSONLRecordBytes, 512<<20; got != want { - t.Fatalf("maximum JSONL record bytes = %d, want %d", got, want) - } - if got, want := capability.SafetyLimits.MaximumDecodedPayloadBytes, 256<<20; got != want { - t.Fatalf("maximum decoded payload bytes = %d, want %d", got, want) - } - want := map[string]bool{ - "directory_subtree_mutation": false, - "physical_backend_bytes": false, - } - for _, gap := range capability.Gaps { - if !gap.FailClosed { - t.Errorf("capability gap %q is not fail closed", gap.Code) - } - if _, exists := want[gap.Code]; exists { - want[gap.Code] = gap.FailClosed - } - } - for code, failClosed := range want { - if !failClosed { - t.Errorf("capability does not declare fail-closed gap %q", code) - } - } -} - -func baseSpec(system, directoryLayout string, fanout int, files []FrozenFile) RunSpec { - raw := true - return RunSpec{ - System: system, - Layout: LayoutSpec{ - Model: "unixfs", - FileLayout: "balanced", - DirectoryLayout: directoryLayout, - Chunking: ChunkingSpec{Algorithm: "fixed", SizeBytes: 4}, - HAMTFanout: fanout, - RawFileLeaf: &raw, - }, - Snapshot: Snapshot{CommitID: "c1", Files: files}, - Commits: []Commit{}, - } -} - -func frozenFile(filePath string, data []byte) FrozenFile { - mode := uint32(0o644) - return FrozenFile{ - Path: filePath, - FileKind: FileKindRegular, - PayloadBase64: base64.StdEncoding.EncodeToString(data), - PayloadSHA256: digest(data), - Mode: &mode, - } -} - -func insertMutation(filePath string, data []byte) Mutation { - mode := uint32(0o644) - return Mutation{Kind: MutationInsert, Path: filePath, FileKind: FileKindRegular, PayloadBase64: base64.StdEncoding.EncodeToString(data), PayloadSHA256: digest(data), Mode: &mode} -} - -func replaceMutation(filePath string, old, next []byte) Mutation { - mutation := insertMutation(filePath, next) - mutation.Kind = MutationReplace - mutation.ExpectedOldSHA256 = digest(old) - expectedMode := uint32(0o644) - mutation.ExpectedOldMode = &expectedMode - return mutation -} - -func appendMutation(filePath string, old, next []byte) Mutation { - mutation := replaceMutation(filePath, old, next) - mutation.Kind = MutationAppend - return mutation -} - -func pathMutation(kind, source, destination string, old []byte) Mutation { - expectedMode := uint32(0o644) - mutation := Mutation{Kind: kind, Path: source, FileKind: FileKindRegular, Destination: destination, ExpectedOldSHA256: digest(old), ExpectedOldMode: &expectedMode} - if kind == MutationRename || kind == MutationMove { - mode := uint32(0o644) - mutation.Mode = &mode - } - return mutation -} - -func digest(data []byte) string { - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) -} - -func assertAccountingAddsUp(t *testing.T, accounting CASAccounting) { - t.Helper() - if accounting.Total.AttemptedObjects != len(accounting.Events) { - t.Fatalf("attempted objects = %d, events = %d", accounting.Total.AttemptedObjects, len(accounting.Events)) - } - if accounting.Total.AttemptedObjects != accounting.Total.NewlyPersistedObjects+accounting.Total.AlreadyPresentObjects+accounting.Total.DuplicateObjects { - t.Fatalf("status counts do not partition attempts: %#v", accounting.Total) - } - if accounting.Total.AttemptedBytes != accounting.Total.NewlyPersistedBytes+accounting.Total.AlreadyPresentBytes+accounting.Total.DuplicateBytes { - t.Fatalf("status bytes do not partition attempts: %#v", accounting.Total) - } - if accounting.Total.AttemptedBytes != accounting.PayloadChunks.AttemptedBytes+accounting.StructuralMetadata.AttemptedBytes || - accounting.Total.NewlyPersistedBytes != accounting.PayloadChunks.NewlyPersistedBytes+accounting.StructuralMetadata.NewlyPersistedBytes || - accounting.Total.AlreadyPresentBytes != accounting.PayloadChunks.AlreadyPresentBytes+accounting.StructuralMetadata.AlreadyPresentBytes || - accounting.Total.DuplicateBytes != accounting.PayloadChunks.DuplicateBytes+accounting.StructuralMetadata.DuplicateBytes { - t.Fatalf("byte categories do not partition attempts and statuses: %#v", accounting) - } -} diff --git a/internal/evaluation/rq3baseline/store.go b/internal/evaluation/rq3baseline/store.go deleted file mode 100644 index 04a14ba..0000000 --- a/internal/evaluation/rq3baseline/store.go +++ /dev/null @@ -1,319 +0,0 @@ -package rq3baseline - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "os" - "path/filepath" - "sync" - "time" - - clientcas "github.com/dewebprotocol/malt-client/internal/cas" - merkledag "github.com/ipfs/boxo/ipld/merkledag" - unixfs "github.com/ipfs/boxo/ipld/unixfs" - blocks "github.com/ipfs/go-block-format" - cid "github.com/ipfs/go-cid" -) - -const ( - categoryPayloadChunk = "payload_chunk" - categoryStructural = "cas_structural_metadata" - categoryMixed = "mixed_payload_and_structural_metadata" - statusNewlyPersisted = "newly_persisted" - statusAlreadyPresent = "already_present" - statusDuplicateInCommit = "duplicate_in_commit" -) - -type accountingStore struct { - mu sync.Mutex - - root string - initErr error - closed bool - archive *archiveCAS - - phaseAttempts map[string]struct{} - events []CASWriteEvent - putNanos int64 - getNanos int64 - readObjects int - readBytes int64 -} - -func newAccountingStore() *accountingStore { - root, err := os.MkdirTemp("", "malt-rq3-logical-cas-") - return &accountingStore{root: root, initErr: err} -} - -func newReplayAccountingStore() *accountingStore { - s := newAccountingStore() - if s.initErr == nil { - s.archive, s.initErr = newArchiveCAS(s.root, archiveSegmentBytes) - } - return s -} - -func (s *accountingStore) readBlock(key cid.Cid) ([]byte, error) { - if s.archive != nil { - return s.archive.get(key) - } - return os.ReadFile(s.blockPath(key)) -} - -func (s *accountingStore) beginPhase() { - s.mu.Lock() - defer s.mu.Unlock() - s.phaseAttempts = make(map[string]struct{}) - s.events = make([]CASWriteEvent, 0) - s.putNanos = 0 - s.getNanos = 0 - s.readObjects = 0 - s.readBytes = 0 -} - -func (s *accountingStore) Get(ctx context.Context, key cid.Cid) ([]byte, error) { - started := time.Now() - if err := ctx.Err(); err != nil { - return nil, err - } - s.mu.Lock() - if s.initErr != nil { - err := s.initErr - s.mu.Unlock() - return nil, err - } - if s.closed { - s.mu.Unlock() - return nil, fmt.Errorf("evaluator CAS is closed") - } - data, err := s.readBlock(key) - if err == nil { - s.readObjects++ - s.readBytes += int64(len(data)) - } - s.getNanos += time.Since(started).Nanoseconds() - s.mu.Unlock() - if os.IsNotExist(err) { - return nil, fmt.Errorf("%w: %s", clientcas.ErrNotFound, key) - } - if err != nil { - return nil, err - } - computed, err := clientcas.CIDForBlock(clientcas.Block{Data: data, Codec: key.Type()}) - if err != nil || !computed.Equals(key) { - return nil, fmt.Errorf("disk-backed evaluator CAS block does not match CID %s", key) - } - return data, nil -} - -func (s *accountingStore) PutWithCodec(ctx context.Context, data []byte, codec uint64) (cid.Cid, error) { - started := time.Now() - if err := ctx.Err(); err != nil { - return cid.Undef, err - } - if codec != cid.Raw && codec != cid.DagProtobuf { - return cid.Undef, fmt.Errorf("unsupported evaluator CAS codec %d", codec) - } - key, err := clientcas.CIDForBlock(clientcas.Block{Data: data, Codec: codec}) - if err != nil { - return cid.Undef, err - } - category, payloadBytes, structuralBytes, err := classifyBlock(key, data, codec) - if err != nil { - return cid.Undef, err - } - role := "file_content" - if codec == cid.DagProtobuf { - node, decodeErr := merkledag.DecodeProtobuf(data) - if decodeErr != nil { - return cid.Undef, decodeErr - } - info, decodeErr := unixfs.FSNodeFromBytes(node.Data()) - if decodeErr != nil { - return cid.Undef, decodeErr - } - switch info.Type() { - case unixfs.TDirectory: - role = "directory_relation" - case unixfs.THAMTShard: - role = "hamt_relation" - } - } - - s.mu.Lock() - defer s.mu.Unlock() - defer func() { s.putNanos += time.Since(started).Nanoseconds() }() - if s.initErr != nil { - return cid.Undef, s.initErr - } - if s.closed { - return cid.Undef, fmt.Errorf("evaluator CAS is closed") - } - if s.phaseAttempts == nil { - return cid.Undef, fmt.Errorf("CAS accounting phase is not active") - } - keyString := key.String() - status := statusNewlyPersisted - objectPath := s.blockPath(key) - if existing, readErr := s.readBlock(key); readErr == nil { - if !bytes.Equal(existing, data) { - return cid.Undef, fmt.Errorf("CID collision for %s", key) - } - if _, attempted := s.phaseAttempts[keyString]; attempted { - status = statusDuplicateInCommit - } else { - status = statusAlreadyPresent - } - } else if !os.IsNotExist(readErr) { - return cid.Undef, readErr - } else if s.archive != nil { - if err := s.archive.appendNew(key, data); err != nil { - return cid.Undef, err - } - } else { - if err := os.MkdirAll(filepath.Dir(objectPath), 0o700); err != nil { - return cid.Undef, err - } - file, err := os.OpenFile(objectPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return cid.Undef, err - } - _, writeErr := file.Write(data) - closeErr := file.Close() - if writeErr != nil || closeErr != nil { - return cid.Undef, cleanupFailedPayloadFile(objectPath, writeErr, closeErr, os.Remove) - } - } - s.phaseAttempts[keyString] = struct{}{} - s.events = append(s.events, CASWriteEvent{ - ObjectRole: role, - Sequence: len(s.events), - CID: keyString, - Codec: codec, - Category: category, - Bytes: int64(len(data)), - PayloadBytes: payloadBytes, - StructuralMetadataBytes: structuralBytes, - Status: status, - }) - return key, nil -} - -func (s *accountingStore) blockPath(key cid.Cid) string { - digest := sha256.Sum256(key.Bytes()) - name := hex.EncodeToString(digest[:]) - return filepath.Join(s.root, name[:2], name[2:4], name[4:]) -} - -func (s *accountingStore) close() error { - s.mu.Lock() - defer s.mu.Unlock() - s.closed = true - root := s.root - var closeErr error - if s.archive != nil { - closeErr = s.archive.closeHandles() - } - if root == "" { - return closeErr - } - if err := os.RemoveAll(root); err != nil { - return errors.Join(closeErr, err) - } - s.root = "" - return closeErr -} - -// retryCleanup gives one-shot and construction-failure paths a second owner -// attempt while close retains its path on failure. Active streams additionally -// keep their store handle for caller-driven retries. -func retryCleanup(close func() error) error { - if close == nil { - return nil - } - if err := close(); err != nil { - if retryErr := close(); retryErr != nil { - return errors.Join(err, retryErr) - } - } - return nil -} - -func (s *accountingStore) finishPhase() (CASAccounting, int64, int64) { - s.mu.Lock() - defer s.mu.Unlock() - accounting := CASAccounting{ - Events: append([]CASWriteEvent{}, s.events...), - Reads: CASReadAccounting{Objects: s.readObjects, Bytes: s.readBytes}, - } - for _, event := range accounting.Events { - accumulateCategory(&accounting.Total, event, event.Bytes) - if event.PayloadBytes > 0 || event.Category == categoryPayloadChunk { - accumulateCategory(&accounting.PayloadChunks, event, event.PayloadBytes) - } - if event.StructuralMetadataBytes > 0 { - accumulateCategory(&accounting.StructuralMetadata, event, event.StructuralMetadataBytes) - } - } - return accounting, s.putNanos, s.getNanos -} - -func accumulateCategory(summary *CategoryAccounting, event CASWriteEvent, bytes int64) { - summary.AttemptedObjects++ - summary.AttemptedBytes += bytes - switch event.Status { - case statusNewlyPersisted: - summary.NewlyPersistedObjects++ - summary.NewlyPersistedBytes += bytes - case statusAlreadyPresent: - summary.AlreadyPresentObjects++ - summary.AlreadyPresentBytes += bytes - case statusDuplicateInCommit: - summary.DuplicateObjects++ - summary.DuplicateBytes += bytes - } -} - -func classifyBlock(key cid.Cid, data []byte, codec uint64) (string, int64, int64, error) { - if codec == cid.Raw { - return categoryPayloadChunk, int64(len(data)), 0, nil - } - block, err := blocks.NewBlockWithCid(data, key) - if err != nil { - return "", 0, 0, fmt.Errorf("decode dag-pb accounting block %s: %w", key, err) - } - node, err := merkledag.DecodeProtobufBlock(block) - if err != nil { - return "", 0, 0, fmt.Errorf("decode dag-pb accounting node %s: %w", key, err) - } - protoNode, ok := node.(*merkledag.ProtoNode) - if !ok { - return "", 0, 0, fmt.Errorf("decoded dag-pb accounting node %s has type %T", key, node) - } - unixFSData, err := unixfs.FromBytes(protoNode.Data()) - if err != nil { - return "", 0, 0, fmt.Errorf("decode UnixFS accounting data %s: %w", key, err) - } - payloadBytes := int64(0) - switch unixFSData.GetType() { - case unixfs.TFile, unixfs.TRaw: - payloadBytes = int64(len(unixFSData.GetData())) - case unixfs.TDirectory, unixfs.THAMTShard: - // HAMT uses Data for its bitfield. It is authentication structure, - // not application payload, so the entire block remains metadata. - default: - return "", 0, 0, &UnsupportedError{Gap: "symlink_and_special_file_mutation", Message: fmt.Sprintf("UnixFS accounting node %s has unsupported type %d", key, unixFSData.GetType())} - } - structuralBytes := int64(len(data)) - payloadBytes - if structuralBytes < 0 { - return "", 0, 0, fmt.Errorf("UnixFS payload accounting exceeds dag-pb block %s", key) - } - if payloadBytes > 0 { - return categoryMixed, payloadBytes, structuralBytes, nil - } - return categoryStructural, 0, structuralBytes, nil -} diff --git a/internal/evaluation/rq3baseline/stream.go b/internal/evaluation/rq3baseline/stream.go deleted file mode 100644 index d919dac..0000000 --- a/internal/evaluation/rq3baseline/stream.go +++ /dev/null @@ -1,318 +0,0 @@ -package rq3baseline - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io/fs" - "time" - - merkledagimport "github.com/dewebprotocol/malt-client/merkledag/importer" -) - -// StreamSession retains one Editor and accounting CAS across bounded chunks. -type StreamSession struct { - spec RunSpec - store *accountingStore - editor *merkledagimport.Editor - state map[string]logicalFile - validationState map[string]logicalFile - payloads *payloadStore - seenCommits map[string]struct{} - parentRoot string - failed bool - commitIDs []string - flat bool - preserveMode bool -} - -// StartStream validates and applies one immutable initial snapshot. -func StartStream(ctx context.Context, spec RunSpec) (_ *StreamSession, _ CommitRecord, returnErr error) { - return startStream(ctx, spec, false, false) -} - -// StartFlatHAMTStream uses the same frozen source, file encoding and accounting -// store as StartStream, with full paths indexed in one HAMT instead of nested -// directories. The caller must publish this as a distinct adapter profile. -func StartFlatHAMTStream(ctx context.Context, spec RunSpec) (*StreamSession, CommitRecord, error) { - if spec.System != SystemHAMTUnixFS { - return nil, CommitRecord{}, fmt.Errorf("flat HAMT stream requires the HAMT file encoding profile") - } - return startStream(ctx, spec, true, false) -} - -// StartReplayStream preserves file permissions, including raw-leaf file roots. -// This is a new encoding profile, separate from the preserved RQ3 contract. -func StartReplayStream(ctx context.Context, spec RunSpec, flat bool) (*StreamSession, CommitRecord, error) { - if flat && spec.System != SystemHAMTUnixFS { - return nil, CommitRecord{}, fmt.Errorf("flat index requires HAMT") - } - return startStream(ctx, spec, flat, true) -} - -func startStream(ctx context.Context, spec RunSpec, flat, preserveMode bool) (_ *StreamSession, _ CommitRecord, returnErr error) { - factory := newAccountingStore - if preserveMode { - factory = newReplayAccountingStore - } - return startStreamWithStore(ctx, spec, flat, preserveMode, factory) -} - -func startStreamWithStore(ctx context.Context, spec RunSpec, flat, preserveMode bool, factory func() *accountingStore) (_ *StreamSession, _ CommitRecord, returnErr error) { - if spec.Commits == nil || len(spec.Commits) != 0 { - return nil, CommitRecord{}, fmt.Errorf("stream start requires an explicit empty commit chunk") - } - store := factory() - payloads, err := newPayloadStore() - if err != nil { - return nil, CommitRecord{}, errors.Join(err, retryCleanup(store.close)) - } - succeeded := false - defer func() { - if !succeeded { - returnErr = errors.Join(returnErr, retryCleanup(store.close), retryCleanup(payloads.close)) - } - }() - state, source, err := prepareStreamSnapshotWithEmpty(spec, payloads, preserveMode) - if err != nil { - return nil, CommitRecord{}, err - } - options := importerOptions(spec) - options.PreserveFileMode = preserveMode - editor, err := merkledagimport.NewEditor(store, options) - if flat { - editor, err = merkledagimport.NewFlatHAMTEditor(store, options) - } - if err != nil { - return nil, CommitRecord{}, fmt.Errorf("create UnixFS stream editor: %w", err) - } - minimal := RunSpec{System: spec.System, Layout: spec.Layout} - session := &StreamSession{spec: minimal, store: store, editor: editor, state: cloneLogicalState(state), validationState: state, payloads: payloads, seenCommits: map[string]struct{}{spec.Snapshot.CommitID: {}}, commitIDs: []string{spec.Snapshot.CommitID}, flat: flat, preserveMode: preserveMode} - if err := payloads.reconcile(session.state, session.validationState); err != nil { - return nil, CommitRecord{}, fmt.Errorf("reconcile hash stream snapshot payloads: %w", err) - } - store.beginPhase() - started := time.Now() - executions := make([]MutationExecution, 0, len(spec.Snapshot.Files)) - for index, frozen := range spec.Snapshot.Files { - if err := ctx.Err(); err != nil { - return nil, CommitRecord{}, err - } - file := state[frozen.Path] - data, err := payloads.read(file) - if err != nil { - return nil, CommitRecord{}, fmt.Errorf("snapshot commit %q read %q: %w", spec.Snapshot.CommitID, frozen.Path, err) - } - if err := editor.PutFile(ctx, frozen.Path, data, fs.FileMode(file.mode)); err != nil { - return nil, CommitRecord{}, fmt.Errorf("snapshot commit %q put %q: %w", spec.Snapshot.CommitID, frozen.Path, err) - } - executions = append(executions, MutationExecution{Index: index, Kind: MutationInsert, Path: frozen.Path, Translation: "snapshot_put_file", LogicalObjectsChanged: 1, LogicalBindingsChanged: 1, LogicalPayloadBytes: int64(len(data))}) - } - if len(state) == 0 { - if err := editor.EnsureRoot(ctx); err != nil { - return nil, CommitRecord{}, fmt.Errorf("materialize empty snapshot: %w", err) - } - } - elapsed := time.Since(started).Nanoseconds() - root := editor.Root() - if root == "" { - return nil, CommitRecord{}, fmt.Errorf("snapshot commit %q produced an empty root", spec.Snapshot.CommitID) - } - accounting, putNanos, getNanos := store.finishPhase() - session.parentRoot = root - succeeded = true - return session, CommitRecord{CommitID: spec.Snapshot.CommitID, Root: root, Snapshot: true, LogicalObjectsChanged: source.LogicalObjectsChanged, LogicalBindingsChanged: source.LogicalBindingsChanged, LogicalPayloadBytes: source.AdapterPayloadInputBytes, AdapterPayloadInputBytes: source.AdapterPayloadInputBytes, Mutations: executions, CAS: accounting, ClientPhases: phaseMetrics(elapsed, putNanos, getNanos)}, nil -} - -// ApplyChunk prevalidates a complete bounded chunk before mutating the Editor. -func (s *StreamSession) ApplyChunk(ctx context.Context, commits []Commit) ([]CommitRecord, error) { - return s.applyChunk(ctx, commits, false) -} - -// ApplyChunkWithReadback checks every touched path at its own commit root, -// before applying the next commit. Readback is excluded from write timings. -func (s *StreamSession) ApplyChunkWithReadback(ctx context.Context, commits []Commit) ([]CommitRecord, error) { - return s.applyChunk(ctx, commits, true) -} - -func (s *StreamSession) applyChunk(ctx context.Context, commits []Commit, readback bool) ([]CommitRecord, error) { - if s == nil || s.editor == nil || s.store == nil || s.failed { - return nil, fmt.Errorf("hash stream session is not initialized") - } - prepared, nextValidation, nextSeen, source, err := s.prepareChunk(commits) - if err != nil { - s.failed = true - return nil, err - } - s.failed = true - records := make([]CommitRecord, 0, len(prepared)) - for commitIndex, commit := range prepared { - s.store.beginPhase() - started := time.Now() - executions := make([]MutationExecution, 0, len(commit.mutations)) - logicalObjects, logicalBindings := 0, 0 - var logicalPayload int64 - for index, mutation := range commit.mutations { - if err := ctx.Err(); err != nil { - return nil, err - } - execution, err := executeMutation(ctx, s.editor, s.state, index, mutation, s.payloads) - if err != nil { - return nil, fmt.Errorf("commit %q mutation[%d]: %w", commit.id, index, err) - } - executions = append(executions, execution) - logicalObjects += execution.LogicalObjectsChanged - logicalBindings += execution.LogicalBindingsChanged - logicalPayload += execution.LogicalPayloadBytes - } - elapsed := time.Since(started).Nanoseconds() - root := s.editor.Root() - if root == "" { - return nil, fmt.Errorf("commit %q produced an empty root", commit.id) - } - accounting, putNanos, getNanos := s.store.finishPhase() - if readback { - paths := make([]string, 0, len(commit.mutations)*2) - for _, mutation := range commit.mutations { - paths = append(paths, mutation.mutation.Path) - if mutation.mutation.Destination != "" { - paths = append(paths, mutation.mutation.Destination) - } - } - if err := s.verifyPaths(ctx, paths); err != nil { - return nil, fmt.Errorf("commit %q: %w", commit.id, err) - } - } - records = append(records, CommitRecord{CommitID: commit.id, ParentRoot: s.parentRoot, Root: root, LogicalObjectsChanged: logicalObjects, LogicalBindingsChanged: logicalBindings, LogicalPayloadBytes: logicalPayload, AdapterPayloadInputBytes: source[commitIndex].AdapterPayloadInputBytes, Mutations: executions, CAS: accounting, ClientPhases: phaseMetrics(elapsed, putNanos, getNanos)}) - s.parentRoot = root - } - if err := s.payloads.reconcile(s.state, nextValidation); err != nil { - return nil, fmt.Errorf("reconcile hash stream chunk payloads: %w", err) - } - s.validationState, s.seenCommits = nextValidation, nextSeen - for _, commit := range commits { - s.commitIDs = append(s.commitIDs, commit.CommitID) - } - s.failed = false - return records, nil -} - -func (s *StreamSession) CommitListSHA256() (string, error) { - if s == nil || s.failed || len(s.commitIDs) == 0 { - return "", fmt.Errorf("hash stream commit list is unavailable") - } - encoded, err := json.Marshal(struct { - Commits []string `json:"commits"` - }{Commits: s.commitIDs}) - if err != nil { - return "", err - } - digest := sha256.Sum256(encoded) - return hex.EncodeToString(digest[:]), nil -} - -func (s *StreamSession) prepareChunk(commits []Commit) ([]preparedCommit, map[string]logicalFile, map[string]struct{}, []SourceCommitAccounting, error) { - return prepareStreamChunk(s.spec, s.validationState, s.seenCommits, commits, s.payloads) -} - -func prepareStreamChunk(spec RunSpec, validationState map[string]logicalFile, seenCommits map[string]struct{}, commits []Commit, payloads *payloadStore) ([]preparedCommit, map[string]logicalFile, map[string]struct{}, []SourceCommitAccounting, error) { - if commits == nil || len(commits) == 0 || len(commits) > maxCommits { - return nil, nil, nil, nil, fmt.Errorf("stream chunk requires 1..%d commits", maxCommits) - } - state := cloneLogicalState(validationState) - seen := make(map[string]struct{}, len(seenCommits)+len(commits)) - for commit := range seenCommits { - seen[commit] = struct{}{} - } - prepared := make([]preparedCommit, 0, len(commits)) - source := make([]SourceCommitAccounting, 0, len(commits)) - mutationCount, totalPayload, projected := 0, 0, 0 - for commitIndex, commit := range commits { - if err := validateCommitID(fmt.Sprintf("stream commits[%d].commit_id", commitIndex), commit.CommitID); err != nil { - return nil, nil, nil, nil, err - } - if _, duplicate := seen[commit.CommitID]; duplicate { - return nil, nil, nil, nil, fmt.Errorf("duplicate commit_id %q", commit.CommitID) - } - seen[commit.CommitID] = struct{}{} - if commit.Mutations == nil { - return nil, nil, nil, nil, fmt.Errorf("commit %q mutations must be an explicit array", commit.CommitID) - } - mutationCount += len(commit.Mutations) - if mutationCount > maxMutations { - return nil, nil, nil, nil, fmt.Errorf("stream chunk has more than %d mutations", maxMutations) - } - next := preparedCommit{id: commit.CommitID, mutations: make([]preparedMutation, 0, len(commit.Mutations))} - accounting := SourceCommitAccounting{CommitID: commit.CommitID, LogicalObjectsChanged: len(commit.Mutations)} - for mutationIndex, mutation := range commit.Mutations { - decoded, added, err := validateAndApplyMutation(state, commit.CommitID, mutationIndex, mutation, payloads) - if err != nil { - return nil, nil, nil, nil, err - } - totalPayload += added - if totalPayload > maxPayloadBytes { - return nil, nil, nil, nil, fmt.Errorf("stream chunk decoded payload bytes exceed %d", maxPayloadBytes) - } - if mutation.Kind == MutationInsert || mutation.Kind == MutationReplace || mutation.Kind == MutationAppend { - projected += projectedChunks(len(decoded), spec.Layout.Chunking.SizeBytes) - accounting.AdapterPayloadInputBytes += int64(len(decoded)) - } - if projected > maxProjectedChunks { - return nil, nil, nil, nil, fmt.Errorf("stream chunk projected payload chunks exceed %d", maxProjectedChunks) - } - if mutation.Kind == MutationRename || mutation.Kind == MutationMove { - accounting.LogicalBindingsChanged += 2 - } else { - accounting.LogicalBindingsChanged++ - } - next.mutations = append(next.mutations, preparedMutation{mutation: mutation, data: decoded}) - } - if len(state) > maxFiles { - return nil, nil, nil, nil, fmt.Errorf("stream state has more than %d files", maxFiles) - } - prepared = append(prepared, next) - source = append(source, accounting) - } - return prepared, state, seen, source, nil -} - -// Close releases the private disk-backed logical CAS retained by a stream. -func (s *StreamSession) Close() error { - if s == nil || s.store == nil && s.payloads == nil { - return nil - } - var storeErr, payloadErr error - if s.store != nil { - storeErr = s.store.close() - if storeErr == nil { - s.store = nil - } - } - if s.payloads != nil { - payloadErr = s.payloads.close() - if payloadErr == nil { - s.payloads = nil - } - } - s.editor = nil - s.failed = true - return errors.Join(storeErr, payloadErr) -} - -func cloneLogicalState(source map[string]logicalFile) map[string]logicalFile { - result := make(map[string]logicalFile, len(source)) - for path, file := range source { - result[path] = file - } - return result -} - -func (s *StreamSession) Root() string { - if s == nil { - return "" - } - return s.parentRoot -} diff --git a/internal/evaluation/rq3baseline/types.go b/internal/evaluation/rq3baseline/types.go deleted file mode 100644 index 29e8059..0000000 --- a/internal/evaluation/rq3baseline/types.go +++ /dev/null @@ -1,304 +0,0 @@ -// Package rq3baseline exposes the current hash-authenticated UnixFS write path -// as a narrow, deterministic evaluator primitive. It deliberately reports -// logical CAS accounting only; physical backend reconciliation belongs to the -// evaluator and gateway storage implementations. -package rq3baseline - -const ( - WorkerRequestSchema = "malt-rq3-hash-worker-request/v1" - WorkerResponseSchema = "malt-rq3-hash-worker-response/v1" - RunResultSchema = "malt-rq3-hash-run-result/v1" - CapabilitySchema = "malt-rq3-hash-capability/v1" - MaximumJSONLRecordBytes = 512 << 20 - - OperationCapabilities = "capabilities" - OperationRun = "run" - OperationStreamStart = "stream-start" - OperationStreamChunk = "stream-chunk" - OperationStreamFinish = "stream-finish" - - SystemMerkleDAGUnixFS = "merkledag-unixfs" - SystemHAMTUnixFS = "hamt-unixfs" - FileKindRegular = "regular" - - MutationInsert = "insert" - MutationReplace = "replace" - MutationAppend = "append" - MutationDelete = "delete" - MutationRename = "rename" - MutationMove = "move" - MutationModeChange = "mode-change" -) - -// WorkerRequest is one strict JSONL input record. Run must be absent for a -// capability request and present for a run request. -type WorkerRequest struct { - SchemaVersion string `json:"schema_version"` - RequestID string `json:"request_id"` - Operation string `json:"operation"` - Run *RunSpec `json:"run,omitempty"` -} - -type WorkerResponse struct { - SchemaVersion string `json:"schema_version"` - RequestID string `json:"request_id"` - OK bool `json:"ok"` - Capability *Capabilities `json:"capability,omitempty"` - Result *RunResult `json:"result,omitempty"` - Error *WorkerError `json:"error,omitempty"` - Stream *StreamResult `json:"stream,omitempty"` -} - -type StreamResult struct { - System string `json:"system"` - Layout LayoutSpec `json:"layout"` - Records []CommitRecord `json:"records"` - Root string `json:"root"` - CommitsApplied uint32 `json:"commits_applied"` - Complete bool `json:"complete"` - CommitListSHA256 string `json:"commit_list_sha256,omitempty"` -} - -type WorkerError struct { - Code string `json:"code"` - Message string `json:"message"` - CapabilityGap string `json:"capability_gap,omitempty"` -} - -// RunSpec keeps workload semantics separate from the selected hash baseline. -// Paths, payloads, modes, chunking, layouts, and commit boundaries are all -// explicit and therefore hashable before execution. -type RunSpec struct { - System string `json:"system"` - Layout LayoutSpec `json:"layout"` - Snapshot Snapshot `json:"snapshot"` - Commits []Commit `json:"commits"` -} - -type LayoutSpec struct { - Model string `json:"model"` - FileLayout string `json:"file_layout"` - DirectoryLayout string `json:"directory_layout"` - Chunking ChunkingSpec `json:"chunking"` - HAMTFanout int `json:"hamt_fanout"` - RawFileLeaf *bool `json:"raw_file_leaf"` -} - -type ChunkingSpec struct { - Algorithm string `json:"algorithm"` - SizeBytes int `json:"size_bytes"` -} - -type Snapshot struct { - CommitID string `json:"commit_id"` - Files []FrozenFile `json:"files"` -} - -type FrozenFile struct { - Path string `json:"path"` - FileKind string `json:"file_kind"` - PayloadBase64 string `json:"payload_base64"` - PayloadSHA256 string `json:"payload_sha256"` - Mode *uint32 `json:"mode"` -} - -type Commit struct { - CommitID string `json:"commit_id"` - Mutations []Mutation `json:"mutations"` -} - -// Mutation carries the complete resulting payload for insert, replace, and -// append. Append additionally proves that the previous payload is a prefix of -// the supplied result. Delete, rename, and move carry an expected old digest. -// Mode is the resulting mode for operations that leave a file present. -// ExpectedOldMode and ExpectedOldSHA256 freeze the prior file state for every -// operation other than insert. -type Mutation struct { - Kind string `json:"kind"` - Path string `json:"path"` - FileKind string `json:"file_kind"` - Destination string `json:"destination,omitempty"` - PayloadBase64 string `json:"payload_base64,omitempty"` - PayloadSHA256 string `json:"payload_sha256,omitempty"` - ExpectedOldSHA256 string `json:"expected_old_sha256,omitempty"` - ExpectedOldMode *uint32 `json:"expected_old_mode,omitempty"` - Mode *uint32 `json:"mode,omitempty"` -} - -type RunResult struct { - SchemaVersion string `json:"schema_version"` - CapabilityID string `json:"capability_id"` - System string `json:"system"` - WorkloadSHA256 string `json:"workload_sha256"` - Layout LayoutSpec `json:"layout"` - Records []CommitRecord `json:"records"` -} - -type CommitRecord struct { - CommitID string `json:"commit_id"` - ParentRoot string `json:"parent_root,omitempty"` - Root string `json:"root"` - Snapshot bool `json:"snapshot"` - LogicalObjectsChanged int `json:"logical_objects_changed"` - LogicalBindingsChanged int `json:"logical_bindings_changed"` - LogicalPayloadBytes int64 `json:"logical_payload_bytes"` - // AdapterPayloadInputBytes is the source envelope presented to the - // system-specific adapter: the complete snapshot bytes initially and the - // complete post-image for insert/replace/append. Path-only, delete, and - // mode-only mutations contribute zero. It is intentionally independent of - // LogicalPayloadBytes. - AdapterPayloadInputBytes int64 `json:"adapter_payload_input_bytes"` - Mutations []MutationExecution `json:"mutations"` - CAS CASAccounting `json:"cas"` - ClientPhases ClientPhases `json:"client_phases"` -} - -// SourceCommitAccounting is derived solely from the strict, system-neutral -// source envelope. It lets other adapters validate and account the exact same -// frozen workload without executing or warming the UnixFS baseline. -type SourceCommitAccounting struct { - CommitID string - LogicalObjectsChanged int - LogicalBindingsChanged int - AdapterPayloadInputBytes int64 -} - -type MutationExecution struct { - Index int `json:"index"` - Kind string `json:"kind"` - Path string `json:"path"` - Destination string `json:"destination,omitempty"` - Translation string `json:"translation"` - LogicalObjectsChanged int `json:"logical_objects_changed"` - LogicalBindingsChanged int `json:"logical_bindings_changed"` - LogicalPayloadBytes int64 `json:"logical_payload_bytes"` -} - -// CASAccounting counts Total objects once. A non-raw dag-pb leaf contributes -// one component-bearing object to both PayloadChunks and StructuralMetadata; -// therefore component object counts may overlap, while component byte counts -// always partition Total exactly. -type CASAccounting struct { - Events []CASWriteEvent `json:"events"` - Total CategoryAccounting `json:"total"` - PayloadChunks CategoryAccounting `json:"payload_chunks"` - StructuralMetadata CategoryAccounting `json:"structural_metadata"` - Reads CASReadAccounting `json:"reads"` -} - -type CASWriteEvent struct { - // ObjectRole is exported only by the separate replay-worker protocol. The - // preserved RQ3 wire format and its accounting categories stay unchanged. - ObjectRole string `json:"-"` - Sequence int `json:"sequence"` - CID string `json:"cid"` - Codec uint64 `json:"codec"` - Category string `json:"category"` - Bytes int64 `json:"bytes"` - PayloadBytes int64 `json:"payload_bytes"` - StructuralMetadataBytes int64 `json:"structural_metadata_bytes"` - Status string `json:"status"` -} - -type CategoryAccounting struct { - AttemptedObjects int `json:"attempted_objects"` - AttemptedBytes int64 `json:"attempted_bytes"` - NewlyPersistedObjects int `json:"newly_persisted_objects"` - NewlyPersistedBytes int64 `json:"newly_persisted_bytes"` - AlreadyPresentObjects int `json:"already_present_objects"` - AlreadyPresentBytes int64 `json:"already_present_bytes"` - DuplicateObjects int `json:"duplicate_objects"` - DuplicateBytes int64 `json:"duplicate_bytes"` -} - -type CASReadAccounting struct { - Objects int `json:"objects"` - Bytes int64 `json:"bytes"` -} - -// Durations are wall-clock measurements, not CPU time. ClientComputeWallNanos -// is the complete local Editor path. CAS timings are nested within it, and the -// final field is only the non-negative remainder after those calls. -type ClientPhases struct { - ClientComputeWallNanos int64 `json:"client_compute_wall_nanos"` - CASPutWallNanos int64 `json:"cas_put_wall_nanos"` - CASGetWallNanos int64 `json:"cas_get_wall_nanos"` - EditorOverheadExcludingCASWallNanos int64 `json:"editor_overhead_excluding_cas_wall_nanos"` -} - -type Capabilities struct { - SchemaVersion string `json:"schema_version"` - CapabilityID string `json:"capability_id"` - ExecutionPath string `json:"execution_path"` - Systems []string `json:"systems"` - FileLayouts []string `json:"file_layouts"` - DirectoryLayouts []string `json:"directory_layouts"` - MutationKinds []string `json:"mutation_kinds"` - AccountingCategories []string `json:"accounting_categories"` - WriteStatuses []string `json:"write_statuses"` - HistoricalRetention string `json:"historical_retention"` - ExactAccountingConditions []string `json:"exact_accounting_conditions"` - ExecutionNotes []string `json:"execution_notes"` - SafetyLimits CapabilityLimits `json:"safety_limits"` - Gaps []CapabilityGap `json:"gaps"` -} - -type CapabilityLimits struct { - MaximumJSONLRecordBytes int `json:"maximum_jsonl_record_bytes"` - MaximumFiles int `json:"maximum_files"` - MaximumCommits int `json:"maximum_commits"` - MaximumMutations int `json:"maximum_mutations"` - MaximumDecodedPayloadBytes int `json:"maximum_decoded_payload_bytes"` - MaximumProjectedChunks int `json:"maximum_projected_chunks"` - MaximumChunkSizeBytes int `json:"maximum_chunk_size_bytes"` -} - -type CapabilityGap struct { - Code string `json:"code"` - FailClosed bool `json:"fail_closed"` - Detail string `json:"detail"` -} - -func Capability() Capabilities { - return Capabilities{ - SchemaVersion: CapabilitySchema, - CapabilityID: "rq3.hash-unixfs-logical.v1", - ExecutionPath: "merkledag/importer.Editor", - Systems: []string{SystemMerkleDAGUnixFS, SystemHAMTUnixFS}, - FileLayouts: []string{"balanced", "trickle"}, - DirectoryLayouts: []string{"basic", "hamt"}, - MutationKinds: []string{MutationInsert, MutationReplace, MutationAppend, MutationDelete, MutationRename, MutationMove, MutationModeChange}, - AccountingCategories: []string{"payload_chunk", "cas_structural_metadata", "mixed_payload_and_structural_metadata"}, - WriteStatuses: []string{"newly_persisted", "already_present", "duplicate_in_commit"}, - HistoricalRetention: "all_blocks_for_all_emitted_roots", - ExactAccountingConditions: []string{ - "fixed-size chunking is explicit", - "HAMT fanout is an explicit power of two and multiple of 8 from 8 through 65536", - "raw_file_leaf is explicit; raw blocks are payload and dag-pb UnixFS nodes are decoded to split embedded payload from protobuf and link metadata", - "snapshot and mutations explicitly declare file_kind=regular, modes, and payload digests", - "mode is an unsigned Unix permission value from 0001 through 0777", - }, - ExecutionNotes: []string{ - "a commit emits one final root while all intermediate Editor writes remain included in attempted and newly-persisted accounting", - "content-addressed roots may repeat when a frozen commit is a semantic same-value operation", - "file rename and move execute as remove_file followed by put_file because that is the current Editor surface", - "Git regular modes 100644 and 100755 map to 0644 and 0755; Git symlink mode 120000 fails closed", - }, - SafetyLimits: CapabilityLimits{ - MaximumJSONLRecordBytes: MaximumJSONLRecordBytes, - MaximumFiles: maxFiles, - MaximumCommits: maxCommits, - MaximumMutations: maxMutations, - MaximumDecodedPayloadBytes: maxPayloadBytes, - MaximumProjectedChunks: maxProjectedChunks, - MaximumChunkSizeBytes: maxChunkSizeBytes, - }, - Gaps: []CapabilityGap{ - {Code: "directory_subtree_mutation", FailClosed: true, Detail: "the current incremental Editor has file put/remove but no directory or subtree rename/move API"}, - {Code: "symlink_and_special_file_mutation", FailClosed: true, Detail: "the frozen evaluator primitive accepts regular files only"}, - {Code: "empty_snapshot", FailClosed: true, Detail: "the current Editor cannot materialize an empty directory root before the first file put"}, - {Code: "external_root_resume", FailClosed: true, Detail: "the current Editor cannot initialize from an externally supplied root; each run imports an explicit frozen snapshot"}, - {Code: "physical_backend_bytes", FailClosed: true, Detail: "this primitive reports deterministic logical CAS events only; physical WAL/LSM reconciliation requires a storage-owned evaluator"}, - }, - } -} diff --git a/internal/evaluation/rq3baseline/validate.go b/internal/evaluation/rq3baseline/validate.go deleted file mode 100644 index 1707f51..0000000 --- a/internal/evaluation/rq3baseline/validate.go +++ /dev/null @@ -1,673 +0,0 @@ -package rq3baseline - -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "path" - "strings" - "unicode/utf8" -) - -const ( - maxFiles = 1_000_000 - maxCommits = 4_096 - maxMutations = 65_536 - maxPayloadBytes = 256 << 20 - maxChunkSizeBytes = 16 << 20 - maxProjectedChunks = 250_000 - maxPathBytes = 4_096 - maxPathSegments = 256 - maxCommitIDBytes = 256 -) - -// UnsupportedError identifies a request that the current implementation -// cannot measure without inventing behavior. -type UnsupportedError struct { - Gap string - Message string -} - -func (e *UnsupportedError) Error() string { return e.Message } - -type preparedRun struct { - spec RunSpec - snapshot []preparedFile - commits []preparedCommit - digest string -} - -type preparedFile struct { - path string - data []byte - mode uint32 - hash string -} - -type preparedCommit struct { - id string - mutations []preparedMutation -} - -type preparedMutation struct { - mutation Mutation - data []byte -} - -type logicalFile struct { - data []byte - mode uint32 - hash string - size int -} - -// ValidationStreamSession performs only strict frozen-source validation and -// accounting. It retains the live logical tree, but creates no UnixFS Editor -// and no historical CAS, so another adapter can prevalidate without warming or -// duplicating a baseline implementation. -type ValidationStreamSession struct { - spec RunSpec - state map[string]logicalFile - seenCommits map[string]struct{} - payloads *payloadStore - failed bool -} - -func StartValidationStream(spec RunSpec) (_ *ValidationStreamSession, _ SourceCommitAccounting, returnErr error) { - if spec.Commits == nil || len(spec.Commits) != 0 { - return nil, SourceCommitAccounting{}, fmt.Errorf("validation stream start requires an explicit empty commit chunk") - } - payloads, err := newPayloadStore() - if err != nil { - return nil, SourceCommitAccounting{}, err - } - succeeded := false - defer func() { - if !succeeded { - returnErr = errors.Join(returnErr, retryCleanup(payloads.close)) - } - }() - state, source, err := prepareStreamSnapshot(spec, payloads) - if err != nil { - return nil, SourceCommitAccounting{}, err - } - if err := payloads.reconcile(state); err != nil { - return nil, SourceCommitAccounting{}, fmt.Errorf("reconcile validation snapshot payloads: %w", err) - } - minimal := RunSpec{System: spec.System, Layout: spec.Layout} - succeeded = true - return &ValidationStreamSession{spec: minimal, state: state, seenCommits: map[string]struct{}{spec.Snapshot.CommitID: {}}, payloads: payloads}, source, nil -} - -// prepareStreamSnapshot validates and spills one initial snapshot without -// ever retaining a preparedRun containing all decoded file bodies. The input -// request still owns its encoded corpus, while live semantic state contains -// only path/mode/digest/size metadata backed by the private payload store. -func prepareStreamSnapshot(spec RunSpec, payloads *payloadStore) (map[string]logicalFile, SourceCommitAccounting, error) { - return prepareStreamSnapshotWithEmpty(spec, payloads, false) -} - -// Empty initial trees belong to the new replay profile. The preserved RQ3 -// protocol retains its published unsupported-gap behavior. -func prepareStreamSnapshotWithEmpty(spec RunSpec, payloads *payloadStore, allowEmpty bool) (map[string]logicalFile, SourceCommitAccounting, error) { - if payloads == nil { - return nil, SourceCommitAccounting{}, fmt.Errorf("stream snapshot payload store is nil") - } - if err := validateLayout(spec.System, spec.Layout); err != nil { - return nil, SourceCommitAccounting{}, err - } - if err := validateCommitID("snapshot commit_id", spec.Snapshot.CommitID); err != nil { - return nil, SourceCommitAccounting{}, err - } - if spec.Snapshot.Files == nil { - return nil, SourceCommitAccounting{}, fmt.Errorf("snapshot files must be an explicit array") - } - if len(spec.Snapshot.Files) == 0 && !allowEmpty { - return nil, SourceCommitAccounting{}, &UnsupportedError{Gap: "empty_snapshot", Message: "empty snapshot is unsupported because the current Editor cannot materialize or resume an empty directory root"} - } - if len(spec.Snapshot.Files) > maxFiles { - return nil, SourceCommitAccounting{}, fmt.Errorf("snapshot has %d files; maximum is %d", len(spec.Snapshot.Files), maxFiles) - } - if spec.Commits == nil || len(spec.Commits) != 0 { - return nil, SourceCommitAccounting{}, fmt.Errorf("stream start commits must be an explicit empty array") - } - - state := make(map[string]logicalFile, len(spec.Snapshot.Files)) - totalPayload, totalProjectedChunks := 0, 0 - previousPath := "" - for index, file := range spec.Snapshot.Files { - if err := validateFileKind(fmt.Sprintf("snapshot files[%d].file_kind", index), file.FileKind); err != nil { - return nil, SourceCommitAccounting{}, err - } - if err := validateCanonicalPath(fmt.Sprintf("snapshot files[%d].path", index), file.Path); err != nil { - return nil, SourceCommitAccounting{}, err - } - if index > 0 && file.Path <= previousPath { - return nil, SourceCommitAccounting{}, fmt.Errorf("snapshot files must be strictly sorted by path; %q follows %q", file.Path, previousPath) - } - previousPath = file.Path - mode, err := validateMode(fmt.Sprintf("snapshot files[%d].mode", index), file.Mode) - if err != nil { - return nil, SourceCommitAccounting{}, err - } - data, err := decodePayload(fmt.Sprintf("snapshot files[%d]", index), file.PayloadBase64, file.PayloadSHA256) - if err != nil { - return nil, SourceCommitAccounting{}, err - } - totalPayload += len(data) - if totalPayload > maxPayloadBytes { - return nil, SourceCommitAccounting{}, fmt.Errorf("decoded payload bytes exceed %d", maxPayloadBytes) - } - totalProjectedChunks += projectedChunks(len(data), spec.Layout.Chunking.SizeBytes) - if totalProjectedChunks > maxProjectedChunks { - return nil, SourceCommitAccounting{}, fmt.Errorf("projected payload chunks exceed %d", maxProjectedChunks) - } - if conflict := conflictingPath(state, file.Path, ""); conflict != "" { - return nil, SourceCommitAccounting{}, fmt.Errorf("snapshot path %q conflicts with file path %q", file.Path, conflict) - } - retained, err := retainLogicalFile(data, mode, file.PayloadSHA256, payloads) - if err != nil { - return nil, SourceCommitAccounting{}, err - } - state[file.Path] = retained - } - return state, SourceCommitAccounting{ - CommitID: spec.Snapshot.CommitID, LogicalObjectsChanged: len(state), LogicalBindingsChanged: len(state), - AdapterPayloadInputBytes: int64(totalPayload), - }, nil -} - -func (s *ValidationStreamSession) ApplyChunk(commits []Commit) ([]SourceCommitAccounting, error) { - if s == nil || s.failed { - return nil, fmt.Errorf("validation stream is not active") - } - _, nextState, nextSeen, source, err := prepareStreamChunk(s.spec, s.state, s.seenCommits, commits, s.payloads) - if err != nil { - s.failed = true - return nil, err - } - if err := s.payloads.reconcile(nextState); err != nil { - s.failed = true - return nil, fmt.Errorf("reconcile validation chunk payloads: %w", err) - } - s.state, s.seenCommits = nextState, nextSeen - return source, nil -} - -// Close releases the disk-backed live checkout retained for semantic checks. -func (s *ValidationStreamSession) Close() error { - if s == nil { - return nil - } - s.failed = true - s.state = nil - err := s.payloads.close() - if err == nil { - s.payloads = nil - } - return err -} - -// ValidateAndAccountSource validates the entire frozen workload without -// creating an Editor or CAS state, and returns system-neutral per-commit -// source accounting. In particular, it does not execute the UnixFS baseline -// and is safe to use before another adapter's measured path. -func ValidateAndAccountSource(spec RunSpec) ([]SourceCommitAccounting, error) { - prepared, err := prepare(spec) - if err != nil { - return nil, err - } - return accountPreparedSource(prepared), nil -} - -func accountPreparedSource(prepared *preparedRun) []SourceCommitAccounting { - result := make([]SourceCommitAccounting, 0, len(prepared.commits)+1) - var snapshotBytes int64 - for _, file := range prepared.snapshot { - snapshotBytes += int64(len(file.data)) - } - result = append(result, SourceCommitAccounting{ - CommitID: prepared.spec.Snapshot.CommitID, LogicalObjectsChanged: len(prepared.snapshot), - LogicalBindingsChanged: len(prepared.snapshot), AdapterPayloadInputBytes: snapshotBytes, - }) - for _, commit := range prepared.commits { - accounting := SourceCommitAccounting{CommitID: commit.id, LogicalObjectsChanged: len(commit.mutations)} - for _, mutation := range commit.mutations { - switch mutation.mutation.Kind { - case MutationRename, MutationMove: - accounting.LogicalBindingsChanged += 2 - default: - accounting.LogicalBindingsChanged++ - } - switch mutation.mutation.Kind { - case MutationInsert, MutationReplace, MutationAppend: - accounting.AdapterPayloadInputBytes += int64(len(mutation.data)) - } - } - result = append(result, accounting) - } - return result -} - -func prepare(spec RunSpec) (*preparedRun, error) { - if err := validateLayout(spec.System, spec.Layout); err != nil { - return nil, err - } - if err := validateCommitID("snapshot commit_id", spec.Snapshot.CommitID); err != nil { - return nil, err - } - if spec.Snapshot.Files == nil { - return nil, fmt.Errorf("snapshot files must be an explicit array") - } - if len(spec.Snapshot.Files) == 0 { - return nil, &UnsupportedError{Gap: "empty_snapshot", Message: "empty snapshot is unsupported because the current Editor cannot materialize or resume an empty directory root"} - } - if len(spec.Snapshot.Files) > maxFiles { - return nil, fmt.Errorf("snapshot has %d files; maximum is %d", len(spec.Snapshot.Files), maxFiles) - } - if spec.Commits == nil { - return nil, fmt.Errorf("commits must be an explicit array") - } - if len(spec.Commits) > maxCommits { - return nil, fmt.Errorf("run has %d commits; maximum is %d", len(spec.Commits), maxCommits) - } - - state := make(map[string]logicalFile, len(spec.Snapshot.Files)) - preparedSnapshot := make([]preparedFile, 0, len(spec.Snapshot.Files)) - totalPayload := 0 - totalProjectedChunks := 0 - previousPath := "" - for i, file := range spec.Snapshot.Files { - if err := validateFileKind(fmt.Sprintf("snapshot files[%d].file_kind", i), file.FileKind); err != nil { - return nil, err - } - if err := validateCanonicalPath(fmt.Sprintf("snapshot files[%d].path", i), file.Path); err != nil { - return nil, err - } - if i > 0 && file.Path <= previousPath { - return nil, fmt.Errorf("snapshot files must be strictly sorted by path; %q follows %q", file.Path, previousPath) - } - previousPath = file.Path - mode, err := validateMode(fmt.Sprintf("snapshot files[%d].mode", i), file.Mode) - if err != nil { - return nil, err - } - data, err := decodePayload(fmt.Sprintf("snapshot files[%d]", i), file.PayloadBase64, file.PayloadSHA256) - if err != nil { - return nil, err - } - totalPayload += len(data) - if totalPayload > maxPayloadBytes { - return nil, fmt.Errorf("decoded payload bytes exceed %d", maxPayloadBytes) - } - totalProjectedChunks += projectedChunks(len(data), spec.Layout.Chunking.SizeBytes) - if totalProjectedChunks > maxProjectedChunks { - return nil, fmt.Errorf("projected payload chunks exceed %d", maxProjectedChunks) - } - if conflict := conflictingPath(state, file.Path, ""); conflict != "" { - return nil, fmt.Errorf("snapshot path %q conflicts with file path %q", file.Path, conflict) - } - state[file.Path] = logicalFile{data: cloneBytes(data), mode: mode, hash: file.PayloadSHA256, size: len(data)} - preparedSnapshot = append(preparedSnapshot, preparedFile{path: file.Path, data: data, mode: mode, hash: file.PayloadSHA256}) - } - - seenCommits := map[string]struct{}{spec.Snapshot.CommitID: {}} - preparedCommits := make([]preparedCommit, 0, len(spec.Commits)) - mutationCount := 0 - for commitIndex, commit := range spec.Commits { - if err := validateCommitID(fmt.Sprintf("commits[%d].commit_id", commitIndex), commit.CommitID); err != nil { - return nil, err - } - if _, exists := seenCommits[commit.CommitID]; exists { - return nil, fmt.Errorf("duplicate commit_id %q", commit.CommitID) - } - seenCommits[commit.CommitID] = struct{}{} - if commit.Mutations == nil { - return nil, fmt.Errorf("commit %q mutations must be an explicit array", commit.CommitID) - } - mutationCount += len(commit.Mutations) - if mutationCount > maxMutations { - return nil, fmt.Errorf("run has more than %d mutations", maxMutations) - } - prepared := preparedCommit{id: commit.CommitID, mutations: make([]preparedMutation, 0, len(commit.Mutations))} - for mutationIndex, mutation := range commit.Mutations { - decoded, addedPayload, err := validateAndApplyMutation(state, commit.CommitID, mutationIndex, mutation, nil) - if err != nil { - return nil, err - } - totalPayload += addedPayload - if totalPayload > maxPayloadBytes { - return nil, fmt.Errorf("decoded payload bytes exceed %d", maxPayloadBytes) - } - if mutation.Kind == MutationInsert || mutation.Kind == MutationReplace || mutation.Kind == MutationAppend { - totalProjectedChunks += projectedChunks(len(decoded), spec.Layout.Chunking.SizeBytes) - if totalProjectedChunks > maxProjectedChunks { - return nil, fmt.Errorf("projected payload chunks exceed %d", maxProjectedChunks) - } - } - prepared.mutations = append(prepared.mutations, preparedMutation{mutation: mutation, data: decoded}) - } - preparedCommits = append(preparedCommits, prepared) - } - - encoded, err := json.Marshal(spec) - if err != nil { - return nil, fmt.Errorf("encode canonical workload: %w", err) - } - digest := sha256.Sum256(encoded) - return &preparedRun{ - spec: spec, - snapshot: preparedSnapshot, - commits: preparedCommits, - digest: hex.EncodeToString(digest[:]), - }, nil -} - -func validateLayout(system string, layout LayoutSpec) error { - if layout.Model != "unixfs" { - return fmt.Errorf("layout model must be %q", "unixfs") - } - if layout.FileLayout != "balanced" && layout.FileLayout != "trickle" { - return fmt.Errorf("unsupported file_layout %q", layout.FileLayout) - } - if layout.Chunking.Algorithm != "fixed" { - return fmt.Errorf("chunking algorithm must be %q", "fixed") - } - if layout.Chunking.SizeBytes <= 0 || layout.Chunking.SizeBytes > maxChunkSizeBytes { - return fmt.Errorf("chunking size_bytes must be in [1,%d]", maxChunkSizeBytes) - } - if layout.RawFileLeaf == nil { - return fmt.Errorf("raw_file_leaf must be explicit") - } - switch system { - case SystemMerkleDAGUnixFS: - if layout.DirectoryLayout != "basic" { - return fmt.Errorf("system %q requires directory_layout %q", system, "basic") - } - if layout.HAMTFanout != 0 { - return fmt.Errorf("system %q requires hamt_fanout=0", system) - } - case SystemHAMTUnixFS: - if layout.DirectoryLayout != "hamt" { - return fmt.Errorf("system %q requires directory_layout %q", system, "hamt") - } - if layout.HAMTFanout < 8 || layout.HAMTFanout > 65_536 || layout.HAMTFanout%8 != 0 || layout.HAMTFanout&(layout.HAMTFanout-1) != 0 { - return fmt.Errorf("hamt_fanout must be a power of two and multiple of 8 in [8,65536]") - } - default: - return fmt.Errorf("unsupported system %q", system) - } - return nil -} - -func validateAndApplyMutation(state map[string]logicalFile, commitID string, index int, mutation Mutation, store *payloadStore) ([]byte, int, error) { - label := fmt.Sprintf("commit %q mutation[%d]", commitID, index) - if err := validateFileKind(label+".file_kind", mutation.FileKind); err != nil { - return nil, 0, err - } - if err := validateCanonicalPath(label+".path", mutation.Path); err != nil { - return nil, 0, err - } - old, exists := state[mutation.Path] - switch mutation.Kind { - case MutationInsert: - if mutation.Destination != "" || mutation.ExpectedOldSHA256 != "" || mutation.ExpectedOldMode != nil { - return nil, 0, fmt.Errorf("%s insert has fields that are not applicable", label) - } - mode, err := validateMode(label+".mode", mutation.Mode) - if err != nil { - return nil, 0, err - } - if exists { - return nil, 0, fmt.Errorf("%s insert target already exists", label) - } - if conflict := conflictingPath(state, mutation.Path, ""); conflict != "" { - return nil, 0, fmt.Errorf("%s insert path conflicts with file %q", label, conflict) - } - data, err := decodePayload(label, mutation.PayloadBase64, mutation.PayloadSHA256) - if err != nil { - return nil, 0, err - } - retained, err := retainLogicalFile(data, mode, mutation.PayloadSHA256, store) - if err != nil { - return nil, 0, err - } - state[mutation.Path] = retained - return data, len(data), nil - - case MutationReplace, MutationAppend: - if mutation.Destination != "" { - return nil, 0, fmt.Errorf("%s %s has a destination", label, mutation.Kind) - } - mode, err := validateMode(label+".mode", mutation.Mode) - if err != nil { - return nil, 0, err - } - expectedMode, err := validateMode(label+".expected_old_mode", mutation.ExpectedOldMode) - if err != nil { - return nil, 0, err - } - if err := requireOldFile(state, mutation.Path, old, exists, mutation.ExpectedOldSHA256, expectedMode, label); err != nil { - return nil, 0, err - } - data, err := decodePayload(label, mutation.PayloadBase64, mutation.PayloadSHA256) - if err != nil { - return nil, 0, err - } - if mutation.Kind == MutationAppend { - oldData, err := store.read(old) - if store == nil { - oldData, err = old.data, nil - } - if err != nil { - return nil, 0, fmt.Errorf("%s read old append payload: %w", label, err) - } - if len(data) <= len(oldData) || !bytes.Equal(data[:len(oldData)], oldData) { - return nil, 0, fmt.Errorf("%s append payload must strictly extend the old payload", label) - } - } - retained, err := retainLogicalFile(data, mode, mutation.PayloadSHA256, store) - if err != nil { - return nil, 0, err - } - state[mutation.Path] = retained - return data, len(data), nil - - case MutationModeChange: - if mutation.Destination != "" || mutation.PayloadBase64 != "" || mutation.PayloadSHA256 != "" { - return nil, 0, fmt.Errorf("%s mode-change has fields that are not applicable", label) - } - mode, err := validateMode(label+".mode", mutation.Mode) - if err != nil { - return nil, 0, err - } - expectedMode, err := validateMode(label+".expected_old_mode", mutation.ExpectedOldMode) - if err != nil { - return nil, 0, err - } - if err := requireOldFile(state, mutation.Path, old, exists, mutation.ExpectedOldSHA256, expectedMode, label); err != nil { - return nil, 0, err - } - if mode == expectedMode { - return nil, 0, fmt.Errorf("%s mode-change must change the mode", label) - } - old.mode = mode - state[mutation.Path] = old - return nil, 0, nil - - case MutationDelete: - if mutation.Destination != "" || mutation.PayloadBase64 != "" || mutation.PayloadSHA256 != "" || mutation.Mode != nil { - return nil, 0, fmt.Errorf("%s delete has fields that are not applicable", label) - } - expectedMode, err := validateMode(label+".expected_old_mode", mutation.ExpectedOldMode) - if err != nil { - return nil, 0, err - } - if err := requireOldFile(state, mutation.Path, old, exists, mutation.ExpectedOldSHA256, expectedMode, label); err != nil { - return nil, 0, err - } - delete(state, mutation.Path) - return nil, 0, nil - - case MutationRename, MutationMove: - if mutation.PayloadBase64 != "" || mutation.PayloadSHA256 != "" { - return nil, 0, fmt.Errorf("%s %s has payload fields", label, mutation.Kind) - } - mode, err := validateMode(label+".mode", mutation.Mode) - if err != nil { - return nil, 0, err - } - expectedMode, err := validateMode(label+".expected_old_mode", mutation.ExpectedOldMode) - if err != nil { - return nil, 0, err - } - if mode != expectedMode { - return nil, 0, fmt.Errorf("%s %s cannot also change file mode", label, mutation.Kind) - } - if err := requireOldFile(state, mutation.Path, old, exists, mutation.ExpectedOldSHA256, expectedMode, label); err != nil { - return nil, 0, err - } - if err := validateCanonicalPath(label+".destination", mutation.Destination); err != nil { - return nil, 0, err - } - if mutation.Destination == mutation.Path { - return nil, 0, fmt.Errorf("%s destination equals source", label) - } - if mutation.Kind == MutationRename && path.Dir(mutation.Path) != path.Dir(mutation.Destination) { - return nil, 0, fmt.Errorf("%s rename must remain in the same parent directory; use move", label) - } - if mutation.Kind == MutationMove && path.Dir(mutation.Path) == path.Dir(mutation.Destination) { - return nil, 0, fmt.Errorf("%s move must change parent directory; use rename", label) - } - if _, destinationExists := state[mutation.Destination]; destinationExists { - return nil, 0, fmt.Errorf("%s destination already exists", label) - } - if conflict := conflictingPath(state, mutation.Destination, mutation.Path); conflict != "" { - return nil, 0, fmt.Errorf("%s destination conflicts with file %q", label, conflict) - } - delete(state, mutation.Path) - state[mutation.Destination] = old - return nil, 0, nil - - default: - return nil, 0, fmt.Errorf("%s has unsupported kind %q", label, mutation.Kind) - } -} - -func requireOldFile(state map[string]logicalFile, filePath string, old logicalFile, exists bool, digest string, mode uint32, label string) error { - if !exists { - for existing := range state { - if strings.HasPrefix(existing, filePath+"/") { - return &UnsupportedError{Gap: "directory_subtree_mutation", Message: fmt.Sprintf("%s selects directory or subtree %q", label, filePath)} - } - } - return fmt.Errorf("%s source does not exist", label) - } - if err := validateDigest(label+".expected_old_sha256", digest); err != nil { - return err - } - if old.hash != digest { - return fmt.Errorf("%s old payload digest mismatch", label) - } - if old.mode != mode { - return fmt.Errorf("%s old mode mismatch: got %04o, expected %04o", label, mode, old.mode) - } - return nil -} - -func validateCanonicalPath(label, raw string) error { - if raw == "" || len(raw) > maxPathBytes || !utf8.ValidString(raw) || strings.ContainsRune(raw, '\x00') || strings.ContainsRune(raw, '\\') { - return fmt.Errorf("%s is not a valid UTF-8 relative path", label) - } - if strings.Count(raw, "/")+1 > maxPathSegments { - return fmt.Errorf("%s has more than %d path segments", label, maxPathSegments) - } - clean := path.Clean(raw) - if raw != clean || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.HasPrefix(clean, "/") { - return fmt.Errorf("%s must be a canonical relative path", label) - } - return nil -} - -func validateMode(label string, mode *uint32) (uint32, error) { - if mode == nil { - return 0, fmt.Errorf("%s must be explicit", label) - } - if *mode == 0 || *mode > 0o777 { - return 0, fmt.Errorf("%s must contain only non-zero Unix permission bits", label) - } - return *mode, nil -} - -func validateFileKind(label, kind string) error { - if kind == "" { - return fmt.Errorf("%s must be explicit", label) - } - if kind != FileKindRegular { - return &UnsupportedError{Gap: "symlink_and_special_file_mutation", Message: fmt.Sprintf("%s %q is unsupported; only regular files can use the current Editor primitive", label, kind)} - } - return nil -} - -func validateCommitID(label, id string) error { - if strings.TrimSpace(id) != id || id == "" || len(id) > maxCommitIDBytes || !utf8.ValidString(id) { - return fmt.Errorf("%s must be a non-empty bounded canonical string", label) - } - for _, r := range id { - if r < 0x20 || r == 0x7f { - return fmt.Errorf("%s contains a control character", label) - } - } - return nil -} - -func decodePayload(label, encoded, digest string) ([]byte, error) { - if err := validateDigest(label+".payload_sha256", digest); err != nil { - return nil, err - } - data, err := base64.StdEncoding.Strict().DecodeString(encoded) - if err != nil || base64.StdEncoding.EncodeToString(data) != encoded { - return nil, fmt.Errorf("%s.payload_base64 is not canonical standard base64", label) - } - got := sha256.Sum256(data) - if hex.EncodeToString(got[:]) != digest { - return nil, fmt.Errorf("%s payload SHA-256 mismatch", label) - } - return data, nil -} - -func validateDigest(label, digest string) error { - if len(digest) != sha256.Size*2 || strings.ToLower(digest) != digest { - return fmt.Errorf("%s must be a lowercase SHA-256 hex digest", label) - } - decoded, err := hex.DecodeString(digest) - if err != nil || len(decoded) != sha256.Size { - return fmt.Errorf("%s must be a lowercase SHA-256 hex digest", label) - } - return nil -} - -func conflictingPath(state map[string]logicalFile, candidate, ignored string) string { - for existing := range state { - if existing == ignored { - continue - } - if strings.HasPrefix(candidate, existing+"/") || strings.HasPrefix(existing, candidate+"/") { - return existing - } - } - return "" -} - -func cloneBytes(value []byte) []byte { return append([]byte(nil), value...) } - -func projectedChunks(payloadBytes, chunkSize int) int { - if payloadBytes == 0 { - return 1 - } - return (payloadBytes + chunkSize - 1) / chunkSize -} diff --git a/nodeapi/block.go b/nodeapi/block.go new file mode 100644 index 0000000..0777484 --- /dev/null +++ b/nodeapi/block.go @@ -0,0 +1,21 @@ +package nodeapi + +import ( + cid "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" +) + +// CIDForBlock computes the CIDv1 SHA-256 identity of immutable block bytes. +// Codec zero selects raw bytes. This helper does not write or accept a block; +// callers still validate the CID returned by the selected node service. +func CIDForBlock(block Block) (cid.Cid, error) { + codec := block.Codec + if codec == 0 { + codec = cid.Raw + } + hash, err := mh.Sum(block.Data, mh.SHA2_256, -1) + if err != nil { + return cid.Undef, err + } + return cid.NewCidV1(codec, hash), nil +} diff --git a/nodeapi/block_test.go b/nodeapi/block_test.go new file mode 100644 index 0000000..e1aab6d --- /dev/null +++ b/nodeapi/block_test.go @@ -0,0 +1,31 @@ +package nodeapi_test + +import ( + "encoding/hex" + "testing" + + "github.com/dewebprotocol/malt-client/nodeapi" + cid "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" +) + +func TestCIDForBlockPreservesCodecAndSHA256Identity(t *testing.T) { + for _, codec := range []uint64{0, cid.Raw, cid.DagProtobuf, cid.DagCBOR} { + id, err := nodeapi.CIDForBlock(nodeapi.Block{Codec: codec, Data: []byte("abc")}) + if err != nil { + t.Fatal(err) + } + wantCodec := codec + if wantCodec == 0 { + wantCodec = cid.Raw + } + hash, err := mh.Decode(id.Hash()) + if err != nil { + t.Fatal(err) + } + if id.Version() != 1 || id.Type() != wantCodec || hash.Code != mh.SHA2_256 || + hex.EncodeToString(hash.Digest) != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" { + t.Fatalf("codec %d: unexpected block identity %s", codec, id) + } + } +} diff --git a/tools/evaluation/README.md b/tools/evaluation/README.md deleted file mode 100644 index cbe0bd4..0000000 --- a/tools/evaluation/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# MALT runtime evaluation adapters - -This directory contains runtime-owned process adapters launched by the separate -[`malt-evaluation`](https://github.com/DeWebProtocol/malt-evaluation) -repository. Keeping an adapter beside the implementation lets it measure and -verify the exact runtime code at a pinned commit. - -This directory does not own benchmark suites, campaign plans, comparison -policy, result schemas, result interpretation, or provenance. Those remain in -`malt-evaluation`. The adapters are not supported user commands or a public Go -API, even though their executable names and JSON/JSONL contracts are pinned -campaign inputs. - -## Commands - -| Command | Runtime-side responsibility | -| --- | --- | -| `malt-eval-machine-descriptor-probe` | Reports bounded machine/runtime evidence consumed by evaluation setup. | -| `malt-eval-rq1-fixture-build` | Builds and verifies the runtime-owned RQ1 route fixture against a disposable Gateway. | -| `malt-eval-rq1-worker` | Measures locally verified selective-CAR and Direct-CAS read paths. | -| `malt-eval-rq2-fixture-build` | Builds and verifies shared KZG/IPA client-root fixtures. | -| `malt-eval-rq2-worker` | Runs the native client-root mutation adapter. | -| `malt-eval-rq2-browser-wasm` | Implements the Go/WASM mutation boundary used by the browser adapter. | -| `malt-eval-rq2-browser-worker` | Drives the browser/WASM process boundary and emits its measurement records. | -| `malt-eval-rq3-malt-worker` | Replays the portable RQ3 mutation stream through the MALT runtime/Gateway path. | -| `malt-eval-rq3-hash-worker` | Replays the RQ3 comparison stream through the runtime-owned hash-system adapter. | - -Build the adapters without placing them in the product command namespace: - -```bash -go build -buildvcs=false ./tools/evaluation/cmd/... -``` - -## Private support boundary - -Shared adapter code lives under `internal/evaluation`. In particular, -`internal/evaluation/gatewaytransport` owns the disposable-Gateway instance -token, bootstrap authorization, unchecked raw-CAS fetch, selective-CAR fetch, -and evaluation health projection. These capabilities are intentionally absent -from public package `transport`. - -Production packages and `cmd/malt` must not import `internal/evaluation`. -Architecture tests enforce that dependency direction and keep `cmd/` reserved -for the product binary. Code under this directory may use public runtime -packages to exercise real product behavior, but production code must never -depend on an evaluator adapter. diff --git a/tools/evaluation/cmd/malt-eval-local-files/main.go b/tools/evaluation/cmd/malt-eval-local-files/main.go deleted file mode 100644 index 82a44be..0000000 --- a/tools/evaluation/cmd/malt-eval-local-files/main.go +++ /dev/null @@ -1,339 +0,0 @@ -// malt-eval-local-files measures the real verified UnixFS facade over bounded -// in-memory authentication and payload capabilities. It is not a Gateway API. -package main - -import ( - "bufio" - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/signal" - "sort" - "strings" - - casmemory "github.com/dewebprotocol/malt-client/internal/cas/memory" - "github.com/dewebprotocol/malt-client/internal/strictjson" - "github.com/dewebprotocol/malt-client/unixfs" - "github.com/dewebprotocol/malt-core/auth/arcset/materializer/memory" - "github.com/dewebprotocol/malt-core/auth/commitment/ipa" - "github.com/dewebprotocol/malt-core/auth/commitment/kzg" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/protocol" - "github.com/dewebprotocol/malt-core/sdk/authentication" - cid "github.com/ipfs/go-cid" -) - -const requestSchema = "malt-local-file-request/v1" -const responseSchema = "malt-local-file-response/v1" -const maxLine = 64 << 20 - -type request struct { - Schema string `json:"schema"` - ID string `json:"id"` - Operation string `json:"operation"` - Backend string `json:"backend,omitempty"` - ChunkBytes int `json:"chunk_bytes,omitempty"` - Files map[string][]byte `json:"files,omitempty"` - Root string `json:"root,omitempty"` - Path string `json:"path,omitempty"` - Offset *uint64 `json:"offset,omitempty"` - Length *uint64 `json:"length,omitempty"` -} - -type response struct { - Schema string `json:"schema"` - ID string `json:"id"` - Root string `json:"root"` - Verified bool `json:"verified"` - Absent bool `json:"absent"` - BodySHA256 string `json:"body_sha256"` - ReturnedBytes uint64 `json:"returned_bytes"` - ProofBytes uint64 `json:"proof_bytes"` - CASBytes uint64 `json:"cas_bytes"` - Calls uint64 `json:"calls"` - Error string `json:"error"` -} - -type sourceStore struct { - *casmemory.Store - e *engine.Engine - nodes *memory.Nodes - candidates map[string]protocol.AuthenticationCandidate - proofBytes, casBytes, calls uint64 -} - -func (s *sourceStore) Get(ctx context.Context, key cid.Cid) ([]byte, error) { - s.calls++ - body, err := s.Store.Get(ctx, key) - s.casBytes += uint64(len(body)) - return body, err -} -func (s *sourceStore) Authenticate(ctx context.Context, q protocol.AuthenticationRequest) (*protocol.AuthenticationResult, error) { - s.calls++ - result, err := authentication.Execute(ctx, s.e, q, s.nodes) - if err != nil { - return nil, err - } - body, err := json.Marshal(result) - if err != nil { - return nil, err - } - s.proofBytes += uint64(len(body)) - // The verified client consumes decoded untrusted evidence, not the server's - // internal object or a pre-supplied final CID. - var decoded protocol.AuthenticationResult - if err := strictjson.Decode(body, &decoded); err != nil { - return nil, err - } - return &decoded, nil -} -func (s *sourceStore) MaterializeAuthentication(ctx context.Context, candidate protocol.AuthenticationCandidate) (cid.Cid, error) { - if err := authentication.Materialize(ctx, s.e, candidate, s.nodes); err != nil { - return cid.Undef, err - } - root, err := cid.Decode(candidate.Root) - if err == nil { - s.candidates[root.KeyString()] = candidate - } - return root, err -} -func (s *sourceStore) AuthenticationCandidate(ctx context.Context, root cid.Cid) (*protocol.AuthenticationCandidate, error) { - candidate, ok := s.candidates[root.KeyString()] - if !ok { - return nil, fmt.Errorf("source candidate absent") - } - out, err := authentication.Export(ctx, s.e, root, candidate.State, s.nodes) - return &out, err -} - -type session struct { - root cid.Cid - store *sourceStore - reader unixfs.Reader -} - -func initialize(ctx context.Context, q request) (*session, error) { - if q.Files == nil || len(q.Files) > 100_000 || q.ChunkBytes < 1 || q.ChunkBytes > 4<<20 { - return nil, fmt.Errorf("explicit files and chunk size within 1..4MiB required") - } - names := make([]string, 0, len(q.Files)) - total := uint64(0) - for name, body := range q.Files { - parts, err := unixfs.ParseCanonicalStagedPath(name) - if err != nil || len(parts) == 0 || strings.Join(parts, "/") != name { - return nil, fmt.Errorf("invalid canonical source path %q", name) - } - for i := 1; i < len(parts); i++ { - if _, exists := q.Files[strings.Join(parts[:i], "/")]; exists { - return nil, fmt.Errorf("file/directory source collision") - } - } - total += uint64(len(body)) - if total > 32<<20 { - return nil, fmt.Errorf("source payload exceeds 32MiB") - } - names = append(names, name) - } - registry := engine.NewRegistry() - profile := maltcid.KZG4096 - if q.Backend == "kzg" { - scheme, err := kzg.NewScheme() - if err != nil { - return nil, err - } - if err = registry.Register(scheme); err != nil { - return nil, err - } - } else if q.Backend == "ipa" { - profile = maltcid.IPA256 - scheme, err := ipa.NewCommitterScheme(ipa.ProfileCompact) - if err != nil { - return nil, err - } - if err = registry.Register(scheme); err != nil { - return nil, err - } - } else { - return nil, fmt.Errorf("unknown backend") - } - store := &sourceStore{Store: casmemory.New(), e: engine.New(registry), nodes: memory.NewNodes(), candidates: map[string]protocol.AuthenticationCandidate{}} - adapter, err := unixfs.NewAuthenticationAdapter(unixfs.LayoutRootedV1, store, store.e, profile) - if err != nil { - return nil, err - } - tree := unixfs.NewStagedDirectory() - tree.Changed = true - sort.Strings(names) - for _, name := range names { - body := q.Files[name] - target, _, err := unixfs.MaterializeStagedFilePayload(ctx, store, adapter, bytes.NewReader(body), int64(len(body)), q.ChunkBytes) - if err != nil { - return nil, err - } - if err := unixfs.SetStagedFile(tree, name, target); err != nil { - return nil, err - } - } - layout, err := unixfs.NewLayout(unixfs.LayoutRootedV1) - if err != nil { - return nil, err - } - result, err := layout.Materialize(ctx, adapter, store, tree) - if err != nil { - return nil, err - } - reader, err := unixfs.NewReader(unixfs.ReaderOptions{Layout: unixfs.LayoutRootedV1, Remote: store, Blocks: store, Verifier: store.e}) - if err != nil { - return nil, err - } - // Independently read every fixture object through the client before timing. - for _, name := range names { - read, err := reader.ReadFile(ctx, result.Key, name) - if err != nil { - return nil, fmt.Errorf("source readback failed for %s: %w", name, err) - } - if read == nil || !bytes.Equal(read.Body, q.Files[name]) { - return nil, fmt.Errorf("source readback bytes disagree for %s", name) - } - } - return &session{root: result.Key, store: store, reader: reader}, nil -} - -func (s *session) execute(ctx context.Context, q request) response { - r := response{Schema: responseSchema, ID: q.ID, Root: s.root.String()} - s.store.proofBytes, s.store.casBytes, s.store.calls = 0, 0, 0 - if q.Root != s.root.String() { - r.Error = "query changed caller-selected root" - return r - } - parts, err := unixfs.ParseCanonicalStagedPath(q.Path) - if err != nil || len(parts) == 0 || strings.Join(parts, "/") != q.Path { - r.Error = "invalid original path" - return r - } - var read *unixfs.ReadResult - if q.Operation == "read" { - read, err = s.reader.ReadFile(ctx, s.root, q.Path) - } else { - read, err = s.reader.ReadFileRange(ctx, s.root, q.Path, *q.Offset, *q.Length) - } - r.ProofBytes, r.CASBytes, r.Calls = s.store.proofBytes, s.store.casBytes, s.store.calls - if errors.Is(err, unixfs.ErrNotFound) { - r.Verified, r.Absent = true, true - return r - } - if err != nil { - r.Error = err.Error() - return r - } - if read == nil { - r.Error = "missing verified read result" - return r - } - digest := sha256.Sum256(read.Body) - r.BodySHA256, r.ReturnedBytes, r.Verified = hex.EncodeToString(digest[:]), uint64(len(read.Body)), true - return r -} - -func decode(data []byte) (request, error) { - var q request - if err := strictjson.Decode(data, &q); err != nil { - return q, err - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(data, &fields); err != nil { - return q, err - } - keys := []string{"schema", "id", "operation"} - switch q.Operation { - case "init": - keys = append(keys, "backend", "chunk_bytes", "files") - case "read": - keys = append(keys, "root", "path") - case "range": - keys = append(keys, "root", "path", "offset", "length") - case "close": - default: - return q, fmt.Errorf("unknown operation") - } - if q.Schema != requestSchema || q.ID == "" || len(q.ID) > 128 || len(fields) != len(keys) { - return q, fmt.Errorf("invalid request or ignored operation fields") - } - for _, key := range keys { - if body, exists := fields[key]; !exists || bytes.Equal(body, []byte("null")) { - return q, fmt.Errorf("explicit nonnull %s required", key) - } - } - if q.Operation == "range" && (q.Length == nil || *q.Length == 0) { - return q, fmt.Errorf("positive range length required") - } - return q, nil -} - -func serve(ctx context.Context, input io.Reader, output io.Writer) error { - scanner := bufio.NewScanner(input) - scanner.Buffer(make([]byte, 64<<10), maxLine) - encoder := json.NewEncoder(output) - var state *session - ids := map[string]bool{} - closed := false - for scanner.Scan() { - q, err := decode(scanner.Bytes()) - if err != nil { - return err - } - if closed || ids[q.ID] || len(ids) >= 1_000_000 { - return fmt.Errorf("duplicate, excessive or terminal request") - } - ids[q.ID] = true - r := response{Schema: responseSchema, ID: q.ID} - if q.Operation == "init" { - if state != nil { - return fmt.Errorf("fixture already initialized") - } - state, err = initialize(ctx, q) - if err != nil { - return err - } - r.Root, r.Verified = state.root.String(), true - } else if q.Operation == "close" { - closed = true - r.Verified = true - } else { - if state == nil { - return fmt.Errorf("fixture not initialized") - } - r = state.execute(ctx, q) - } - if err := encoder.Encode(r); err != nil { - return err - } - } - if err := scanner.Err(); err != nil { - return err - } - if !closed { - return fmt.Errorf("missing explicit close") - } - return nil -} - -func main() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - if len(os.Args) > 1 { - fmt.Fprintln(os.Stderr, "local file worker accepts no arguments") - os.Exit(1) - } - if err := serve(ctx, os.Stdin, os.Stdout); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/tools/evaluation/cmd/malt-eval-local-files/main_test.go b/tools/evaluation/cmd/malt-eval-local-files/main_test.go deleted file mode 100644 index b3b67e6..0000000 --- a/tools/evaluation/cmd/malt-eval-local-files/main_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "strings" - "testing" - - "github.com/dewebprotocol/malt-client/unixfs" - cid "github.com/ipfs/go-cid" -) - -func digest(body []byte) string { h := sha256.Sum256(body); return hex.EncodeToString(h[:]) } - -func TestLocalFilesReadEveryPayloadChunkRangeAndAbsence(t *testing.T) { - for _, backend := range []string{"kzg", "ipa"} { - t.Run(backend, func(t *testing.T) { - files := map[string][]byte{"dir/a": bytes.Repeat([]byte("abc"), 25), "dir/deep/b": []byte("real side payload"), "empty": {}} - s, err := initialize(t.Context(), request{Backend: backend, ChunkBytes: 16, Files: files}) - if err != nil { - t.Fatal(err) - } - for path, body := range files { - got := s.execute(t.Context(), request{ID: path, Operation: "read", Root: s.root.String(), Path: path}) - if !got.Verified || got.Absent || got.Error != "" || got.BodySHA256 != digest(body) || got.ReturnedBytes != uint64(len(body)) || got.ProofBytes == 0 || got.Calls == 0 { - t.Fatalf("read %s: %+v", path, got) - } - } - for _, path := range []string{"missing", "dir/deep/missing"} { - got := s.execute(t.Context(), request{ID: path, Operation: "read", Root: s.root.String(), Path: path}) - if !got.Verified || !got.Absent || got.Error != "" || got.ReturnedBytes != 0 || got.ProofBytes == 0 { - t.Fatalf("absence: %+v", got) - } - } - for _, limits := range [][2]uint64{{0, 1}, {15, 35}, {70, 100}} { - offset, length := limits[0], limits[1] - got := s.execute(t.Context(), request{ID: "range", Operation: "range", Root: s.root.String(), Path: "dir/a", Offset: &offset, Length: &length}) - end := min(offset+length, uint64(len(files["dir/a"]))) - if !got.Verified || got.Absent || got.Error != "" || got.BodySHA256 != digest(files["dir/a"][offset:end]) || got.ReturnedBytes != end-offset { - t.Fatalf("range: %+v", got) - } - } - got := s.execute(t.Context(), request{ID: "wrong-root", Operation: "read", Root: "bafkqaaa", Path: "dir/a"}) - if got.Verified || got.Error == "" || got.Calls != 0 { - t.Fatal("changed selected root accepted", got) - } - }) - } -} - -type corruptBlocks struct{ *sourceStore } - -func (c corruptBlocks) Get(ctx context.Context, key cid.Cid) ([]byte, error) { - body, err := c.sourceStore.Get(ctx, key) - if len(body) > 0 { - body[0] ^= 0xff - } - return body, err -} - -func TestLocalFileClientRejectsWrongPayloadBytes(t *testing.T) { - s, err := initialize(t.Context(), request{Backend: "ipa", ChunkBytes: 8, Files: map[string][]byte{"a": bytes.Repeat([]byte("x"), 25)}}) - if err != nil { - t.Fatal(err) - } - s.reader, err = unixfs.NewReader(unixfs.ReaderOptions{Layout: unixfs.LayoutRootedV1, Remote: s.store, Blocks: corruptBlocks{s.store}, Verifier: s.store.e}) - if err != nil { - t.Fatal(err) - } - got := s.execute(t.Context(), request{ID: "corrupt", Operation: "read", Root: s.root.String(), Path: "a"}) - if got.Verified || got.Absent || got.Error == "" { - t.Fatal("corrupted CAS bytes accepted or misclassified absent", got) - } -} - -func TestLocalFileProtocolRejectsIgnoredFieldsAndPartialLifecycle(t *testing.T) { - for _, body := range []string{ - `{"schema":"malt-local-file-request/v1","id":"x","operation":"close","backend":"ipa"}`, - `{"schema":"malt-local-file-request/v1","id":"x","operation":"close","id":"y"}`, - `{"schema":"malt-local-file-request/v1","id":"x","operation":"range","root":"r","path":"a","offset":0,"length":null}`, - `{"schema":"malt-local-file-request/v1","id":"x","operation":"init","backend":"ipa","chunk_bytes":8,"files":{"a":"eA==","a":"eQ=="}}`, - } { - if _, err := decode([]byte(body)); err == nil { - t.Fatal("invalid protocol accepted", body) - } - } - if _, err := initialize(t.Context(), request{Backend: "ipa", ChunkBytes: 8, Files: map[string][]byte{"a": {}, "a/b": {}}}); err == nil { - t.Fatal("source file/directory collision accepted") - } - var input, output bytes.Buffer - enc := json.NewEncoder(&input) - enc.Encode(request{Schema: requestSchema, ID: "init", Operation: "init", Backend: "ipa", ChunkBytes: 8, Files: map[string][]byte{"a": []byte("content")}}) - if err := serve(t.Context(), &input, &output); err == nil || !strings.Contains(err.Error(), "close") { - t.Fatal("incomplete lifecycle accepted", err) - } -} diff --git a/tools/evaluation/cmd/malt-eval-machine-descriptor-probe/main.go b/tools/evaluation/cmd/malt-eval-machine-descriptor-probe/main.go deleted file mode 100644 index 5bdc3a3..0000000 --- a/tools/evaluation/cmd/malt-eval-machine-descriptor-probe/main.go +++ /dev/null @@ -1,47 +0,0 @@ -// Command malt-eval-machine-descriptor-probe emits a descriptor template from -// the exact host probe used by paper evaluator workers. The maintainer supplies -// the campaign ID and classification evidence; the resulting file is then -// pinned by SHA-256 and byte length in E0/campaign registration. -package main - -import ( - "encoding/json" - "flag" - "fmt" - "os" - "strings" - - "github.com/dewebprotocol/malt-client/internal/evaluation/machine" -) - -func main() { - if err := run(os.Args[1:]); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run(args []string) error { - flags := flag.NewFlagSet("malt-eval-machine-descriptor-probe", flag.ContinueOnError) - id := flags.String("id", "", "campaign machine descriptor ID") - classification := flags.String("classification", "", "general-purpose or low-power-arm") - evidence := flags.String("classification-evidence-source", "", "registered vendor/product evidence source") - if err := flags.Parse(args); err != nil { - return err - } - if flags.NArg() != 0 { - return fmt.Errorf("unexpected positional arguments") - } - identity, err := machine.Probe() - if err != nil { - return err - } - descriptor, err := machine.NewDescriptor(strings.TrimSpace(*id), strings.TrimSpace(*classification), strings.TrimSpace(*evidence), identity) - if err != nil { - return err - } - encoder := json.NewEncoder(os.Stdout) - encoder.SetEscapeHTML(false) - encoder.SetIndent("", " ") - return encoder.Encode(descriptor) -} diff --git a/tools/evaluation/cmd/malt-eval-replay-worker/main.go b/tools/evaluation/cmd/malt-eval-replay-worker/main.go deleted file mode 100644 index 2a76c5a..0000000 --- a/tools/evaluation/cmd/malt-eval-replay-worker/main.go +++ /dev/null @@ -1,199 +0,0 @@ -// Command malt-eval-replay-worker exposes actual incremental UnixFS writes. -// Its protocol is deliberately separate from both legacy RQ3 and the next -// protocol's latency executor. It makes no crash-durability claim. -package main - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/signal" - "reflect" - - "github.com/dewebprotocol/malt-client/internal/evaluation/rq3baseline" - "github.com/dewebprotocol/malt-client/internal/strictjson" -) - -const requestSchema = "malt-replay-worker-request/v2" -const responseSchema = "malt-replay-worker-response/v2" - -type request struct { - Schema string `json:"schema_version"` - ID string `json:"request_id"` - Operation string `json:"operation"` - Profile string `json:"profile"` - Run *rq3baseline.RunSpec `json:"run,omitempty"` -} - -type response struct { - Schema string `json:"schema_version"` - ID string `json:"request_id"` - OK bool `json:"ok"` - Error string `json:"error,omitempty"` - Profiles []string `json:"profiles,omitempty"` - Records []rq3baseline.CommitRecord `json:"records,omitempty"` - ObjectRoles [][]string `json:"object_roles,omitempty"` - Complete bool `json:"complete"` - ReadbackVerified bool `json:"readback_verified"` - CommitListSHA256 string `json:"commit_list_sha256,omitempty"` - Applied uint32 `json:"applied"` - RetainedObjects int64 `json:"retained_objects,string"` - RetainedBytes int64 `json:"retained_bytes,string"` - StorageBackend string `json:"storage_backend"` - Storage *rq3baseline.ReplayStorage `json:"storage,omitempty"` -} - -func main() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - if len(os.Args) > 1 { - fmt.Fprintln(os.Stderr, "replay worker accepts no arguments") - os.Exit(1) - } - if err := run(ctx, os.Stdin, os.Stdout); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run(ctx context.Context, input io.Reader, output io.Writer) (returnErr error) { - scanner := bufio.NewScanner(input) - scanner.Buffer(make([]byte, 64<<10), rq3baseline.MaximumJSONLRecordBytes) - encoder := json.NewEncoder(output) - var session *rq3baseline.StreamSession - defer func() { - if session != nil { - returnErr = errors.Join(returnErr, session.Close()) - } - }() - var profile string - var layout rq3baseline.LayoutSpec - var applied uint32 - capability, finished := false, false - for scanner.Scan() { - if err := ctx.Err(); err != nil { - return err - } - var req request - if !json.Valid(scanner.Bytes()) { - return fmt.Errorf("request must contain one JSON value") - } - if err := strictjson.Decode(scanner.Bytes(), &req); err != nil { - return err - } - if req.Schema != requestSchema || req.ID == "" || finished { - return fmt.Errorf("invalid replay request envelope or terminal stream") - } - res := response{Schema: responseSchema, ID: req.ID, OK: true, StorageBackend: rq3baseline.ReplayStorageBackend} - var err error - switch req.Operation { - case "capabilities": - if capability || session != nil || req.Run != nil || req.Profile != "" { - return fmt.Errorf("capabilities must be first") - } - capability = true - res.Profiles = []string{"merkledag-nested", "hamt-nested", "hamt-flat"} - case "start": - if !capability || session != nil || req.Run == nil { - return fmt.Errorf("invalid stream start") - } - profile, layout = req.Profile, req.Run.Layout - var record rq3baseline.CommitRecord - switch profile { - case "merkledag-nested": - if req.Run.System != rq3baseline.SystemMerkleDAGUnixFS { - return fmt.Errorf("Merkle DAG profile mismatch") - } - session, record, err = rq3baseline.StartReplayStream(ctx, *req.Run, false) - case "hamt-nested": - if req.Run.System != rq3baseline.SystemHAMTUnixFS { - return fmt.Errorf("HAMT profile mismatch") - } - session, record, err = rq3baseline.StartReplayStream(ctx, *req.Run, false) - case "hamt-flat": - session, record, err = rq3baseline.StartReplayStream(ctx, *req.Run, true) - default: - return fmt.Errorf("unsupported profile %q", profile) - } - if err == nil { - paths := make([]string, len(req.Run.Snapshot.Files)) - for i, file := range req.Run.Snapshot.Files { - paths[i] = file.Path - } - err = session.VerifyPaths(ctx, paths) - } - if err == nil { - res.Records = []rq3baseline.CommitRecord{record} - applied = 1 - } - case "chunk": - if session == nil || req.Run == nil || req.Profile != profile || !reflect.DeepEqual(layout, req.Run.Layout) || req.Run.Snapshot.CommitID != "" || req.Run.Snapshot.Files != nil { - return fmt.Errorf("chunk does not bind active stream") - } - if req.Run.System != rq3baseline.SystemMerkleDAGUnixFS && req.Run.System != rq3baseline.SystemHAMTUnixFS { - return fmt.Errorf("chunk system mismatch") - } - if (profile == "merkledag-nested") != (req.Run.System == rq3baseline.SystemMerkleDAGUnixFS) { - return fmt.Errorf("chunk system mismatch") - } - res.Records, err = session.ApplyChunkWithReadback(ctx, req.Run.Commits) - if err == nil { - applied += uint32(len(res.Records)) - } - case "finish": - if session == nil || req.Run != nil || req.Profile != profile { - return fmt.Errorf("invalid stream finish") - } - res.CommitListSHA256, err = session.CommitListSHA256() - if err == nil { - err = session.VerifyAll(ctx) - } - if err == nil { - res.Storage, err = session.FinishReplayStorage() - if err == nil { - res.RetainedObjects, res.RetainedBytes = res.Storage.Objects, res.Storage.BodyBytes - } - } - if err == nil { - err = session.Close() - } - if err == nil { - session = nil - finished = true - res.Complete = true - } - default: - return fmt.Errorf("unsupported replay operation %q", req.Operation) - } - res.Applied = applied - for _, record := range res.Records { - roles := make([]string, len(record.CAS.Events)) - for i, event := range record.CAS.Events { - roles[i] = event.ObjectRole - } - res.ObjectRoles = append(res.ObjectRoles, roles) - } - res.ReadbackVerified = applied > 0 && err == nil - if err != nil { - res.OK = false - res.Error = err.Error() - } - if encodeErr := encoder.Encode(res); encodeErr != nil { - return encodeErr - } - if err != nil { - return err - } - } - if err := scanner.Err(); err != nil { - return err - } - if !finished { - return fmt.Errorf("incomplete replay stream") - } - return nil -} diff --git a/tools/evaluation/cmd/malt-eval-replay-worker/main_test.go b/tools/evaluation/cmd/malt-eval-replay-worker/main_test.go deleted file mode 100644 index 094d219..0000000 --- a/tools/evaluation/cmd/malt-eval-replay-worker/main_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "strings" - "testing" - - "github.com/dewebprotocol/malt-client/internal/evaluation/rq3baseline" -) - -func TestReplayProtocolVerifiesModesAndRetainsNoOpCommits(t *testing.T) { - data := []byte("file") - hash := sha256.Sum256(data) - digest := hex.EncodeToString(hash[:]) - mode, newMode, raw := uint32(0o644), uint32(0o755), true - layout := rq3baseline.LayoutSpec{Model: "unixfs", FileLayout: "balanced", DirectoryLayout: "hamt", Chunking: rq3baseline.ChunkingSpec{Algorithm: "fixed", SizeBytes: 262144}, HAMTFanout: 256, RawFileLeaf: &raw} - initial := rq3baseline.RunSpec{System: rq3baseline.SystemHAMTUnixFS, Layout: layout, Snapshot: rq3baseline.Snapshot{CommitID: "initial", Files: []rq3baseline.FrozenFile{{Path: "nested/file", FileKind: "regular", PayloadBase64: base64.StdEncoding.EncodeToString(data), PayloadSHA256: digest, Mode: &mode}}}, Commits: []rq3baseline.Commit{}} - chunk := rq3baseline.RunSpec{System: rq3baseline.SystemHAMTUnixFS, Layout: layout, Commits: []rq3baseline.Commit{{CommitID: "mode", Mutations: []rq3baseline.Mutation{{Kind: "mode-change", Path: "nested/file", FileKind: "regular", ExpectedOldSHA256: digest, ExpectedOldMode: &mode, Mode: &newMode}}}, {CommitID: "no-op", Mutations: []rq3baseline.Mutation{}}}} - var input, output bytes.Buffer - encoder := json.NewEncoder(&input) - for _, req := range []request{{Schema: requestSchema, ID: "1", Operation: "capabilities"}, {Schema: requestSchema, ID: "2", Operation: "start", Profile: "hamt-flat", Run: &initial}, {Schema: requestSchema, ID: "3", Operation: "chunk", Profile: "hamt-flat", Run: &chunk}, {Schema: requestSchema, ID: "4", Operation: "finish", Profile: "hamt-flat"}} { - if err := encoder.Encode(req); err != nil { - t.Fatal(err) - } - } - if err := run(context.Background(), &input, &output); err != nil { - t.Fatal(err) - } - decoder := json.NewDecoder(&output) - var replies [4]response - for i := range replies { - if err := decoder.Decode(&replies[i]); err != nil { - t.Fatal(err) - } - if !replies[i].OK { - t.Fatal(replies[i].Error) - } - } - if !replies[3].Complete || !replies[3].ReadbackVerified || replies[3].Applied != 3 || replies[3].RetainedObjects == 0 { - t.Fatal("incomplete verified history") - } - storage := replies[3].Storage - if storage == nil || replies[3].StorageBackend != rq3baseline.ReplayStorageBackend || !storage.Reconciled || storage.Objects != replies[3].RetainedObjects || storage.BodyBytes != replies[3].RetainedBytes || storage.IndexWriterComplete { - t.Fatal("terminal response omitted or overclaimed backend observation") - } - if replies[1].Records[0].Root == replies[2].Records[0].Root || replies[2].Records[0].Root != replies[2].Records[1].Root { - t.Fatal("mode change or no-op root semantics lost") - } - if replies[2].Records[0].AdapterPayloadInputBytes != 0 || len(replies[2].Records[1].CAS.Events) != 0 || len(replies[2].ObjectRoles) != 2 { - t.Fatal("zero-source commit or ownership accounting lost") - } -} - -func TestReplayRejectsAmbiguousOrIncompleteInput(t *testing.T) { - for _, input := range []string{ - `{"schema_version":"malt-replay-worker-request/v2","request_id":"1","operation":"capabilities","profile":""} {}`, - `{"schema_version":"malt-replay-worker-request/v2","request_id":"1","request_id":"2","operation":"capabilities","profile":""}`, - `{"schema_version":"malt-replay-worker-request/v2","request_id":"1","operation":"capabilities","profile":""}`, - `{"schema_version":"malt-replay-worker-request/v1","request_id":"1","operation":"capabilities","profile":""}`, - } { - var output bytes.Buffer - if err := run(context.Background(), strings.NewReader(input+"\n"), &output); err == nil { - t.Fatal("ambiguous or unfinished stream accepted") - } - } -} - -func TestReplayEmptyInitialTreeDeleteAllAndResume(t *testing.T) { - for _, profile := range []string{"merkledag-nested", "hamt-nested", "hamt-flat"} { - t.Run(profile, func(t *testing.T) { - raw, mode := true, uint32(0o644) - layout := rq3baseline.LayoutSpec{Model: "unixfs", FileLayout: "balanced", DirectoryLayout: "hamt", Chunking: rq3baseline.ChunkingSpec{Algorithm: "fixed", SizeBytes: 262144}, HAMTFanout: 256, RawFileLeaf: &raw} - system := rq3baseline.SystemHAMTUnixFS - if profile == "merkledag-nested" { - system, layout.DirectoryLayout, layout.HAMTFanout = rq3baseline.SystemMerkleDAGUnixFS, "basic", 0 - } - initial := rq3baseline.RunSpec{System: system, Layout: layout, Snapshot: rq3baseline.Snapshot{CommitID: "empty", Files: []rq3baseline.FrozenFile{}}, Commits: []rq3baseline.Commit{}} - data := []byte("from empty") - digest := sha256.Sum256(data) - hash := hex.EncodeToString(digest[:]) - put := rq3baseline.Mutation{Kind: "insert", Path: "nested/file", FileKind: "regular", Mode: &mode, PayloadBase64: base64.StdEncoding.EncodeToString(data), PayloadSHA256: hash} - del := rq3baseline.Mutation{Kind: "delete", Path: "nested/file", FileKind: "regular", ExpectedOldMode: &mode, ExpectedOldSHA256: hash} - chunk := rq3baseline.RunSpec{System: system, Layout: layout, Commits: []rq3baseline.Commit{{CommitID: "put", Mutations: []rq3baseline.Mutation{put}}, {CommitID: "delete", Mutations: []rq3baseline.Mutation{del}}, {CommitID: "no-op", Mutations: []rq3baseline.Mutation{}}, {CommitID: "resume", Mutations: []rq3baseline.Mutation{put}}, {CommitID: "empty-again", Mutations: []rq3baseline.Mutation{del}}}} - var input, output bytes.Buffer - encoder := json.NewEncoder(&input) - for _, req := range []request{{Schema: requestSchema, ID: "1", Operation: "capabilities"}, {Schema: requestSchema, ID: "2", Operation: "start", Profile: profile, Run: &initial}, {Schema: requestSchema, ID: "3", Operation: "chunk", Profile: profile, Run: &chunk}, {Schema: requestSchema, ID: "4", Operation: "finish", Profile: profile}} { - if err := encoder.Encode(req); err != nil { - t.Fatal(err) - } - } - if err := run(context.Background(), &input, &output); err != nil { - t.Fatal(err) - } - decoder := json.NewDecoder(&output) - var replies [4]response - for i := range replies { - if err := decoder.Decode(&replies[i]); err != nil { - t.Fatal(err) - } - } - if !replies[3].Complete || !replies[3].ReadbackVerified || replies[3].Applied != 6 { - t.Fatal("empty history lost coverage or readback") - } - start := replies[1].Records[0] - if start.AdapterPayloadInputBytes != 0 || start.Root == "" || start.CAS.Total.NewlyPersistedBytes == 0 { - t.Fatal("empty root not materialized and accounted") - } - for _, index := range []int{1, 2, 4} { - if replies[2].Records[index].Root != start.Root { - t.Fatal("canonical empty root was not restored") - } - } - if len(replies[2].Records[2].CAS.Events) != 0 { - t.Fatal("empty no-op rewrote objects") - } - }) - } -} diff --git a/tools/evaluation/cmd/malt-eval-rooted-file-trace/main.go b/tools/evaluation/cmd/malt-eval-rooted-file-trace/main.go deleted file mode 100644 index 6e202ca..0000000 --- a/tools/evaluation/cmd/malt-eval-rooted-file-trace/main.go +++ /dev/null @@ -1,231 +0,0 @@ -// malt-eval-rooted-file-trace compiles bounded file snapshots through the real -// rooted-v1 application schema. It exports authentication-only evaluation data. -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "io" - "os" - "sort" - "strings" - - "github.com/dewebprotocol/malt-client/unixfs" - "github.com/dewebprotocol/malt-core/auth/commitment/ipa" - "github.com/dewebprotocol/malt-core/auth/commitment/kzg" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/protocol" - "github.com/dewebprotocol/malt-core/sdk/authentication" - cid "github.com/ipfs/go-cid" - mh "github.com/multiformats/go-multihash" -) - -type version struct { - Files map[string][]byte `json:"files"` - Queries []string `json:"queries"` -} -type trace struct { - Schema string `json:"schema"` - Integration string `json:"integration"` - SourceSHA256 string `json:"source_sha256"` - Candidates []protocol.AuthenticationCandidate `json:"candidates"` - Queries []protocol.AuthenticationRequest `json:"queries"` -} -type sink struct { - e *engine.Engine - candidates map[string]protocol.AuthenticationCandidate - ordered []protocol.AuthenticationCandidate -} - -func (s *sink) MaterializeAuthentication(ctx context.Context, c protocol.AuthenticationCandidate) (cid.Cid, error) { - if err := authentication.ValidateCandidate(ctx, s.e, c); err != nil { - return cid.Undef, err - } - root, err := cid.Decode(c.Root) - if err != nil { - return cid.Undef, err - } - if _, ok := s.candidates[c.Root]; !ok { - s.ordered = append(s.ordered, c) - s.candidates[c.Root] = c - } - return root, nil -} -func (s *sink) AuthenticationCandidate(_ context.Context, root cid.Cid) (*protocol.AuthenticationCandidate, error) { - c, ok := s.candidates[root.String()] - if !ok { - return nil, fmt.Errorf("base candidate missing") - } - return &c, nil -} -func (s *sink) Put(ctx context.Context, b []byte) (cid.Cid, error) { - return s.PutWithCodec(ctx, b, cid.Raw) -} -func (*sink) PutWithCodec(_ context.Context, b []byte, codec uint64) (cid.Cid, error) { - return (cid.Prefix{Version: 1, Codec: codec, MhType: mh.SHA2_256, MhLength: -1}).Sum(b) -} -func compile(ctx context.Context, data []byte, backend string, chunk int) (trace, error) { - out := trace{Schema: "malt.rooted-trace/v1", Integration: "malt.unixfs/rooted-v1/authentication-only"} - if chunk <= 0 || chunk > 64<<20 { - return out, fmt.Errorf("chunk size outside 1..64MiB") - } - var versions []version - d := json.NewDecoder(bytes.NewReader(data)) - d.DisallowUnknownFields() - if err := d.Decode(&versions); err != nil { - return out, err - } - if d.Decode(new(any)) != io.EOF { - return out, fmt.Errorf("trailing data") - } - if len(versions) == 0 || len(versions) > 1000 { - return out, fmt.Errorf("snapshot count outside 1..1000") - } - digest := sha256.Sum256(data) - out.SourceSHA256 = hex.EncodeToString(digest[:]) - profiles := engine.NewRegistry() - profile := maltcid.KZG4096 - switch backend { - case "kzg": - s, err := kzg.NewScheme() - if err != nil { - return out, err - } - if err = profiles.Register(s); err != nil { - return out, err - } - case "ipa": - profile = maltcid.IPA256 - s, err := ipa.NewCommitterScheme(ipa.ProfileCompact) - if err != nil { - return out, err - } - if err = profiles.Register(s); err != nil { - return out, err - } - default: - return out, fmt.Errorf("unknown backend") - } - store := &sink{e: engine.New(profiles), candidates: map[string]protocol.AuthenticationCandidate{}} - adapter, err := unixfs.NewAuthenticationAdapter(unixfs.LayoutRootedV1, store, store.e, profile) - if err != nil { - return out, err - } - layout, _ := unixfs.NewLayout(unixfs.LayoutRootedV1) - previous := map[string]cid.Cid{} - for _, v := range versions { - tree := unixfs.NewStagedDirectory() - tree.Changed = true - names := make([]string, 0, len(v.Files)) - for name := range v.Files { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - parts, err := unixfs.ParseCanonicalStagedPath(name) - if err != nil || len(parts) == 0 { - return out, fmt.Errorf("invalid file path %q", name) - } - for j := 1; j < len(parts); j++ { - if _, ok := v.Files[strings.Join(parts[:j], "/")]; ok { - return out, fmt.Errorf("file/directory collision: %s", name) - } - } - body := v.Files[name] - target, _, err := unixfs.MaterializeStagedFilePayload(ctx, store, adapter, bytes.NewReader(body), int64(len(body)), chunk) - if err != nil { - return out, err - } - if err = unixfs.SetStagedFile(tree, name, target); err != nil { - return out, err - } - } - var bind func(*unixfs.StagedNode, string) - bind = func(n *unixfs.StagedNode, path string) { - if n.Kind != unixfs.StagedKindDirectory { - return - } - n.Key = previous[path] - n.Changed = true - for name, child := range n.Children { - p := name - if path != "" { - p = path + "/" + name - } - bind(child, p) - } - } - bind(tree, "") - result, err := layout.Materialize(ctx, adapter, store, tree) - if err != nil { - return out, err - } - previous = map[string]cid.Cid{} - var remember func(*unixfs.StagedNode, string) - remember = func(n *unixfs.StagedNode, path string) { - if n.Kind != unixfs.StagedKindDirectory { - return - } - previous[path] = n.Key - for name, child := range n.Children { - p := name - if path != "" { - p = path + "/" + name - } - remember(child, p) - } - } - remember(tree, "") - for _, path := range v.Queries { - parts, err := unixfs.ParseCanonicalStagedPath(path) - if err != nil { - return out, err - } - steps := make([][]byte, 0, len(parts)) - for _, part := range parts { - steps = append(steps, []byte(part)) - } - out.Queries = append(out.Queries, protocol.AuthenticationRequest{Profile: protocol.AuthenticationPathProfile, Root: result.Key.String(), Steps: steps, Operation: "resolve"}) - } - } - out.Candidates = store.ordered - if len(out.Queries) == 0 { - return out, fmt.Errorf("queries are required") - } - return out, nil -} -func run() error { - file := flag.String("input", "", "JSON array of complete file snapshots; file values are base64") - backend := flag.String("backend", "kzg", "kzg or ipa") - chunk := flag.Int("chunk-size", 256<<10, "file chunk bytes") - flag.Parse() - f, err := os.Open(*file) - if err != nil { - return err - } - defer f.Close() - data, err := io.ReadAll(io.LimitReader(f, (256<<20)+1)) - if err != nil { - return err - } - if len(data) > 256<<20 { - return fmt.Errorf("input exceeds256MiB") - } - out, err := compile(context.Background(), data, *backend, *chunk) - if err != nil { - return err - } - return json.NewEncoder(os.Stdout).Encode(out) -} -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/tools/evaluation/cmd/malt-eval-rooted-file-trace/main_test.go b/tools/evaluation/cmd/malt-eval-rooted-file-trace/main_test.go deleted file mode 100644 index 2305cbe..0000000 --- a/tools/evaluation/cmd/malt-eval-rooted-file-trace/main_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - "testing" - - "github.com/dewebprotocol/malt-core/maltcid" -) - -func TestFileSnapshotsUseRuntimeSchemaAndRetainHistory(t *testing.T) { - data := []byte(`[{"files":{"dir/file":"aGVsbG8gd29ybGQ="},"queries":["dir/file","missing"]},{"files":{},"queries":["dir/file"]}]`) - out, err := compile(t.Context(), data, "ipa", 4) - if err != nil { - t.Fatal(err) - } - if len(out.Queries) != 3 || out.Queries[0].Root == out.Queries[2].Root { - t.Fatal("history lost") - } - positional := false - for _, c := range out.Candidates { - if c.State.Descriptor.Layout == maltcid.Positional { - positional = true - for _, e := range c.State.Entries { - if string(e.Label) == "@payload" { - t.Fatal("positional system") - } - } - } else { - for _, e := range c.State.Entries { - if string(e.Label) == "dir/file" { - t.Fatal("flattened alias") - } - } - } - } - if !positional { - t.Fatal("large file skipped measured sequence") - } - if _, err = compile(t.Context(), []byte(`[{"files":{"a":"","a/b":""},"queries":["a"]}]`), "ipa", 4); err == nil { - t.Fatal("accepted collision") - } -} diff --git a/tools/evaluation/cmd/malt-eval-rq1-fixture-build/main.go b/tools/evaluation/cmd/malt-eval-rq1-fixture-build/main.go deleted file mode 100644 index c355f54..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-fixture-build/main.go +++ /dev/null @@ -1,540 +0,0 @@ -// Command malt-eval-rq1-fixture-build materializes the paper's shared RQ1 -// payload/query fixture into one token-guarded empty Gateway. It creates one -// UnixFS root for the trusted Path/CAR/Direct-CAS routes and one MALT-KZG root, -// then verifies all D={1,2,4,8} paths before publishing a fixture descriptor. -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - "github.com/dewebprotocol/malt-client/merkledag" - merkledagimport "github.com/dewebprotocol/malt-client/merkledag/importer" - "github.com/dewebprotocol/malt-client/transport" - "github.com/dewebprotocol/malt-core/auth/commitment/kzg" - "github.com/dewebprotocol/malt-core/derivation" - "github.com/dewebprotocol/malt-core/engine" - "github.com/dewebprotocol/malt-core/maltcid" - "github.com/dewebprotocol/malt-core/protocol" - "github.com/dewebprotocol/malt-core/sdk/authentication" - authenticationbuiltin "github.com/dewebprotocol/malt-core/sdk/authentication/builtin" - cid "github.com/ipfs/go-cid" -) - -const ( - fixtureSchema = "malt-rq1-shared-fixture/v1" - descriptorSchema = "malt-rq1-shared-fixture-descriptor/v1" - payloadBytes = 4096 - unixFSChunkBytes = 262144 - maximumResponse = 8 << 20 - requestTimeout = 5 * time.Minute - fixtureIdentifier = "paper-rq1-shared-payload-depths-v1" - unixFSProfile = "unixfs-v1/basic-directory/balanced-file/raw-leaf/fixed-262144" - maltProfile = "canonical-nested-map/radix-256/kzg" - verificationMode = "trusted-path-plus-local-car-plus-local-direct-cas-plus-local-malt-prooflist-payload-binding" - bootstrapObjects = uint32(12) -) - -var exactDepths = []depthFixture{ - {Depth: 1, Segments: []string{"d1"}}, - {Depth: 2, Segments: []string{"d2", "payload"}}, - {Depth: 4, Segments: []string{"d4", "a", "b", "payload"}}, - {Depth: 8, Segments: []string{"d8", "a", "b", "c", "d", "e", "f", "payload"}}, -} - -type depthFixture struct { - Depth int `json:"depth"` - Segments []string `json:"segments"` -} - -type payloadFixture struct { - CID string `json:"cid"` - Bytes uint64 `json:"bytes"` - SHA256 string `json:"sha256"` -} - -type routeRoot struct { - Route string `json:"route"` - Root string `json:"root"` -} - -type fixture struct { - SchemaVersion string `json:"schema_version"` - FixtureID string `json:"fixture_id"` - Payload payloadFixture `json:"payload"` - Depths []depthFixture `json:"depths"` - Routes []routeRoot `json:"routes"` - UnixFSProfile string `json:"unixfs_profile"` - MALTProfile string `json:"malt_profile"` - Verification string `json:"verification"` - BootstrapCount uint32 `json:"bootstrap_object_count"` -} - -type descriptor struct { - SchemaVersion string `json:"schema_version"` - Path string `json:"path"` - SHA256 string `json:"sha256"` - Bytes int64 `json:"bytes"` -} - -type trieNode struct { - edges map[string]*trieEdge -} - -type trieEdge struct { - child *trieNode - payload bool -} - -func main() { - if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, "malt-eval-rq1-fixture-build:", err) - os.Exit(2) - } -} - -func run(arguments []string, stdout, stderr io.Writer) error { - flags := flag.NewFlagSet("malt-eval-rq1-fixture-build", flag.ContinueOnError) - flags.SetOutput(stderr) - baseURL := flags.String("base-url", "", "token-guarded empty Gateway origin") - instanceToken := flags.String("gateway-instance-token", "", "64-character evaluation instance token") - bootstrapAuthorizationToken := flags.String("gateway-bootstrap-authorization-token", "", "distinct 64-character secret bootstrap capability") - outputPath := flags.String("out", "", "new absolute fixture JSON path") - if err := flags.Parse(arguments); err != nil { - return err - } - if flags.NArg() != 0 || *baseURL == "" || *instanceToken == "" || *bootstrapAuthorizationToken == "" || *outputPath == "" { - return errors.New("usage: malt-eval-rq1-fixture-build --base-url URL --gateway-instance-token TOKEN --gateway-bootstrap-authorization-token SECRET --out NEW.json") - } - if !canonicalToken(*instanceToken) || !canonicalToken(*bootstrapAuthorizationToken) || *instanceToken == *bootstrapAuthorizationToken { - return errors.New("gateway instance and bootstrap authorization tokens must be distinct 64-character lowercase hexadecimal values") - } - parsed, err := url.Parse(strings.TrimRight(*baseURL, "/")) - if err != nil || parsed.Scheme != "http" && parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Opaque != "" || - parsed.Path != "" || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" { - return errors.New("base URL must be an absolute HTTP(S) origin") - } - output, err := filepath.Abs(*outputPath) - if err != nil { - return err - } - evaluation, err := gatewaytransport.New(gatewaytransport.Options{ - BaseURL: parsed.String(), InstanceToken: *instanceToken, HTTPClient: &http.Client{Timeout: requestTimeout}, - }) - if err != nil { - return err - } - httpClient := evaluation.InstanceHTTPClient() - remote, err := transport.New(transport.Options{BaseURL: parsed.String(), HTTPClient: httpClient}) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) - defer cancel() - if err := verifyGatewayHealth(ctx, evaluation, *instanceToken); err != nil { - return err - } - payload := deterministicPayload() - payloadCID, err := remote.Put(ctx, payload) - if err != nil { - return fmt.Errorf("store shared payload: %w", err) - } - unixRoot, err := buildUnixFS(ctx, remote, payload) - if err != nil { - return err - } - maltRoot, objects, err := buildMALT(ctx, payloadCID) - if err != nil { - return err - } - if err := bootstrapMALT(ctx, evaluation, *bootstrapAuthorizationToken, objects); err != nil { - return err - } - if err := verifyFixture(ctx, httpClient, parsed.String(), evaluation, remote, unixRoot, maltRoot, payloadCID, payload); err != nil { - return err - } - digest := sha256.Sum256(payload) - value := fixture{ - SchemaVersion: fixtureSchema, FixtureID: fixtureIdentifier, - Payload: payloadFixture{CID: payloadCID.String(), Bytes: uint64(len(payload)), SHA256: hex.EncodeToString(digest[:])}, - Depths: cloneDepths(exactDepths), - Routes: []routeRoot{ - {Route: "trusted-path-gateway", Root: unixRoot.String()}, {Route: "trustless-car", Root: unixRoot.String()}, - {Route: "direct-cas", Root: unixRoot.String()}, {Route: "malt-kzg", Root: maltRoot.String()}, - }, - UnixFSProfile: unixFSProfile, - MALTProfile: maltProfile, - Verification: verificationMode, - BootstrapCount: uint32(len(objects)), - } - if err := value.validate(); err != nil { - return err - } - raw, err := json.Marshal(value) - if err != nil { - return err - } - raw = append(raw, '\n') - if err := publishExclusive(output, raw); err != nil { - return err - } - artifactDigest := sha256.Sum256(raw) - encoder := json.NewEncoder(stdout) - encoder.SetEscapeHTML(false) - return encoder.Encode(descriptor{SchemaVersion: descriptorSchema, Path: output, SHA256: hex.EncodeToString(artifactDigest[:]), Bytes: int64(len(raw))}) -} - -func verifyGatewayHealth(ctx context.Context, evaluation *gatewaytransport.Client, token string) error { - health, err := evaluation.Health(ctx) - if err != nil { - return fmt.Errorf("read Gateway health: %w", err) - } - if health.Status != "ok" || health.EvaluationInstanceToken != token || health.KVBackend != "badger" || health.BlobBackend != "embedded" || - health.ArcTableMode != "versioned" || health.CommitmentProfile != "kzg" || health.EvaluationAuthenticationBootstrap != gatewaytransport.BootstrapProfile { - return errors.New("Gateway does not expose the exact empty RQ1 fixture-build capability") - } - return nil -} - -func deterministicPayload() []byte { - payload := make([]byte, payloadBytes) - for index := range payload { - payload[index] = byte((index*131 + 17) % 251) - } - return payload -} - -func buildUnixFS(ctx context.Context, remote *transport.Client, payload []byte) (cid.Cid, error) { - editor, err := merkledagimport.NewEditor(remote, merkledagimport.Options{ - Model: merkledagimport.ModelUnixFS, FileLayout: merkledagimport.FileLayoutBalanced, - DirLayout: merkledagimport.DirLayoutBasic, ChunkSize: unixFSChunkBytes, RawFileLeaf: true, - }) - if err != nil { - return cid.Undef, err - } - for _, depth := range exactDepths { - if err := editor.PutFile(ctx, strings.Join(depth.Segments, "/"), payload, 0o644); err != nil { - return cid.Undef, fmt.Errorf("materialize UnixFS depth %d: %w", depth.Depth, err) - } - } - root, err := cid.Parse(editor.Root()) - if err != nil { - return cid.Undef, fmt.Errorf("parse UnixFS fixture root: %w", err) - } - return root, nil -} - -func buildMALT(ctx context.Context, payload cid.Cid) (cid.Cid, []protocol.AuthenticationCandidate, error) { - root := &trieNode{edges: map[string]*trieEdge{}} - for _, depth := range exactDepths { - if err := root.insert(depth.Segments); err != nil { - return cid.Undef, nil, err - } - } - scheme, err := kzg.NewScheme() - if err != nil { - return cid.Undef, nil, err - } - profiles := engine.NewRegistry() - if err := profiles.Register(scheme); err != nil { - return cid.Undef, nil, err - } - e := engine.New(profiles) - rootCID, objects, err := commitTrie(ctx, e, root, payload) - if err != nil { - return cid.Undef, nil, err - } - return rootCID, objects, nil -} - -func (node *trieNode) insert(segments []string) error { - if node == nil || len(segments) == 0 { - return errors.New("MALT fixture path is empty") - } - current := node - for index, segment := range segments { - if segment == "" || strings.Contains(segment, "/") { - return fmt.Errorf("invalid MALT fixture segment %q", segment) - } - edge := current.edges[segment] - last := index == len(segments)-1 - if edge == nil { - edge = &trieEdge{} - current.edges[segment] = edge - } - if last { - if edge.child != nil || edge.payload { - return errors.New("MALT fixture path conflicts with an existing path") - } - edge.payload = true - continue - } - if edge.payload { - return errors.New("MALT fixture path crosses a payload leaf") - } - if edge.child == nil { - edge.child = &trieNode{edges: map[string]*trieEdge{}} - } - current = edge.child - } - return nil -} - -func commitTrie(ctx context.Context, e *engine.Engine, node *trieNode, payload cid.Cid) (cid.Cid, []protocol.AuthenticationCandidate, error) { - keys := make([]string, 0, len(node.edges)) - for key := range node.edges { - keys = append(keys, key) - } - sort.Strings(keys) - entries := make(map[string]cid.Cid, len(keys)) - objects := make([]protocol.AuthenticationCandidate, 0) - for _, key := range keys { - edge := node.edges[key] - if edge.payload { - entries[key] = payload - continue - } - childRoot, childObjects, err := commitTrie(ctx, e, edge.child, payload) - if err != nil { - return cid.Undef, nil, err - } - objects = append(objects, childObjects...) - entries[key] = childRoot - } - state := engine.State{Descriptor: maltcid.RootDescriptor{Layout: maltcid.Prefix, DerivationProfile: uint8(derivation.SHA256), Profile: maltcid.KZG4096}, Entries: make([]engine.Entry, 0, len(keys))} - for _, key := range keys { - state.Entries = append(state.Entries, engine.Entry{Label: []byte(key), Target: entries[key]}) - } - candidate, err := authentication.Prepare(ctx, e, state) - if err != nil { - return cid.Undef, nil, err - } - objects = append(objects, candidate) - return cid.MustParse(candidate.Root), objects, nil -} - -func bootstrapMALT(ctx context.Context, evaluation *gatewaytransport.Client, bootstrapAuthorizationToken string, objects []protocol.AuthenticationCandidate) error { - for index, object := range objects { - response, err := evaluation.BootstrapEvaluationObject(ctx, bootstrapAuthorizationToken, gatewaytransport.BootstrapObject{ - OperationID: fmt.Sprintf("rq1-fixture-prefix-%03d", index+1), Candidate: object, - }) - if err != nil { - return fmt.Errorf("bootstrap MALT object %d: %w", index, err) - } - if response.Root.String() != object.Root { - return fmt.Errorf("bootstrap MALT object %d returned a different root/profile", index) - } - } - return nil -} - -func verifyFixture(ctx context.Context, client *http.Client, baseURL string, evaluation *gatewaytransport.Client, remote *transport.Client, unixRoot, maltRoot, payloadCID cid.Cid, payload []byte) error { - verifier, err := authenticationbuiltin.NewVerifier(maltcid.KZG4096) - if err != nil { - return err - } - for _, depth := range exactDepths { - carRequest := merkledag.MerkleDAGReadRequest{ - Profile: merkledag.MerkleDAGReadProfile, Root: unixRoot.String(), Segments: append([]string(nil), depth.Segments...), - } - encodedCARRequest, err := json.Marshal(carRequest) - if err != nil { - return err - } - encodedCAR, err := evaluation.ReadCAR(ctx, encodedCARRequest) - if err != nil { - return fmt.Errorf("read CAR depth %d: %w", depth.Depth, err) - } - carResult, err := merkledag.VerifyMerkleDAGCARRead(ctx, carRequest, encodedCAR) - if err != nil { - return fmt.Errorf("verify CAR depth %d: %w", depth.Depth, err) - } - directResult, err := merkledag.ReadMerkleDAGDirectCASVerified( - ctx, gatewaytransport.RawCASGetter{Client: evaluation}, unixRoot, depth.Segments, nil, nil, - ) - if err != nil { - return fmt.Errorf("verify Direct CAS depth %d: %w", depth.Depth, err) - } - if !carResult.Target.Equals(payloadCID) || !directResult.Target.Equals(payloadCID) || !bytes.Equal(carResult.Data, payload) || !bytes.Equal(directResult.Data, payload) { - return fmt.Errorf("Merkle-DAG routes do not bind the shared payload at depth %d", depth.Depth) - } - if err := verifyTrustedPath(ctx, client, baseURL, unixRoot, depth.Segments, payloadCID, payload); err != nil { - return fmt.Errorf("verify trusted Path depth %d: %w", depth.Depth, err) - } - steps := make([][]byte, len(depth.Segments)) - for i, segment := range depth.Segments { - steps[i] = []byte(segment) - } - request := protocol.AuthenticationRequest{Profile: protocol.AuthenticationPathProfile, Root: maltRoot.String(), Steps: steps, Operation: "resolve"} - result, err := remote.Authenticate(ctx, request) - if err != nil { - return fmt.Errorf("resolve MALT depth %d: %w", depth.Depth, err) - } - valid, err := authentication.Verify(verifier, request, *result) - if err != nil || !valid || result.AbsentStep != nil { - return fmt.Errorf("locally verify MALT depth %d: valid=%t absent=%t: %v", depth.Depth, valid, result.AbsentStep != nil, err) - } - target, err := cid.Parse(result.Resolved) - if err != nil || !target.Equals(payloadCID) { - return fmt.Errorf("MALT depth %d returned a different target", depth.Depth) - } - bound, err := evaluation.GetRawCAS(ctx, target) - if err != nil { - return fmt.Errorf("MALT depth %d payload fetch failed: %w", depth.Depth, err) - } - boundCID, err := target.Prefix().Sum(bound) - if err != nil || !boundCID.Equals(target) { - return fmt.Errorf("MALT depth %d payload bytes do not match target CID %s", depth.Depth, target) - } - if !bytes.Equal(bound, payload) { - return fmt.Errorf("MALT depth %d payload binding returned different bytes", depth.Depth) - } - } - return nil -} - -func verifyTrustedPath(ctx context.Context, client *http.Client, baseURL string, root cid.Cid, segments []string, target cid.Cid, payload []byte) error { - request := struct { - Profile string `json:"profile"` - Root string `json:"root"` - Segments []string `json:"segments"` - }{Profile: merkledag.MerkleDAGReadProfile, Root: root.String(), Segments: append([]string(nil), segments...)} - encoded, err := json.Marshal(request) - if err != nil { - return err - } - httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+"/v1/compat/merkledag/path/read", bytes.NewReader(encoded)) - if err != nil { - return err - } - httpRequest.Header.Set("Content-Type", "application/json") - response, err := client.Do(httpRequest) - if err != nil { - return err - } - defer response.Body.Close() - body, err := io.ReadAll(io.LimitReader(response.Body, maximumResponse+1)) - if err != nil { - return err - } - if response.StatusCode != http.StatusOK || len(body) > maximumResponse || response.Header.Get("X-Malt-MerkleDAG-Target") != target.String() || !bytes.Equal(body, payload) { - return fmt.Errorf("trusted Path response status=%d does not match target/payload", response.StatusCode) - } - return nil -} - -func (value fixture) validate() error { - if value.SchemaVersion != fixtureSchema || value.FixtureID != fixtureIdentifier || value.Payload.Bytes != payloadBytes || !canonicalToken(value.Payload.SHA256) || - len(value.Depths) != len(exactDepths) || len(value.Routes) != 4 || value.BootstrapCount != bootstrapObjects || - value.UnixFSProfile != unixFSProfile || value.MALTProfile != maltProfile || value.Verification != verificationMode { - return errors.New("constructed RQ1 fixture is incomplete") - } - payload := deterministicPayload() - payloadDigest := sha256.Sum256(payload) - if value.Payload.SHA256 != hex.EncodeToString(payloadDigest[:]) { - return errors.New("constructed RQ1 fixture does not bind the deterministic payload digest") - } - payloadCID, err := cid.Parse(value.Payload.CID) - if err != nil || payloadCID.Prefix().Codec != cid.Raw { - return errors.New("constructed RQ1 payload requires a canonical raw CID") - } - expectedPayloadCID, err := payloadCID.Prefix().Sum(payload) - if err != nil || !payloadCID.Equals(expectedPayloadCID) { - return errors.New("constructed RQ1 payload CID does not bind the deterministic bytes") - } - for index, depth := range value.Depths { - if depth.Depth != exactDepths[index].Depth || !equalStrings(depth.Segments, exactDepths[index].Segments) { - return errors.New("constructed RQ1 depth matrix differs from the paper fixture") - } - } - exactRoutes := []string{"trusted-path-gateway", "trustless-car", "direct-cas", "malt-kzg"} - roots := make([]cid.Cid, len(value.Routes)) - for index, route := range value.Routes { - if route.Route != exactRoutes[index] { - return errors.New("constructed RQ1 route order differs from the paper matrix") - } - root, err := cid.Parse(route.Root) - if err != nil { - return fmt.Errorf("parse %s fixture root: %w", route.Route, err) - } - roots[index] = root - } - if roots[0].Prefix().Codec != cid.DagProtobuf || !roots[0].Equals(roots[1]) || !roots[0].Equals(roots[2]) || - !fixturePrefixRoot(roots[3]) { - return errors.New("constructed RQ1 route roots do not preserve shared Merkle/MALT boundaries") - } - return nil -} - -func publishExclusive(path string, raw []byte) (resultErr error) { - if !filepath.IsAbs(path) { - return errors.New("fixture output path must be absolute") - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return err - } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return err - } - defer func() { - if closeErr := file.Close(); resultErr == nil && closeErr != nil { - resultErr = closeErr - } - if resultErr != nil { - _ = os.Remove(path) - } - }() - if _, err := file.Write(raw); err != nil { - return err - } - return file.Sync() -} - -func cloneDepths(values []depthFixture) []depthFixture { - result := make([]depthFixture, len(values)) - for index, value := range values { - result[index] = depthFixture{Depth: value.Depth, Segments: append([]string(nil), value.Segments...)} - } - return result -} - -func equalStrings(left, right []string) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] != right[index] { - return false - } - } - return true -} - -func canonicalToken(value string) bool { - if len(value) != sha256.Size*2 { - return false - } - decoded, err := hex.DecodeString(value) - return err == nil && hex.EncodeToString(decoded) == value -} - -func fixturePrefixRoot(root cid.Cid) bool { - descriptor, _, err := maltcid.ParseRoot(root) - return err == nil && descriptor.Layout == maltcid.Prefix && descriptor.DerivationProfile == uint8(derivation.SHA256) && descriptor.Profile == maltcid.KZG4096 -} diff --git a/tools/evaluation/cmd/malt-eval-rq1-fixture-build/main_test.go b/tools/evaluation/cmd/malt-eval-rq1-fixture-build/main_test.go deleted file mode 100644 index a253ec7..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-fixture-build/main_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "testing" - - cid "github.com/ipfs/go-cid" - mh "github.com/multiformats/go-multihash" -) - -func TestBuildMALTProducesExactDepthTrie(t *testing.T) { - payload := deterministicPayload() - payloadCID, err := cid.Prefix{Version: 1, Codec: cid.Raw, MhType: mh.SHA2_256, MhLength: -1}.Sum(payload) - if err != nil { - t.Fatal(err) - } - root, objects, err := buildMALT(context.Background(), payloadCID) - if err != nil { - t.Fatal(err) - } - if !fixturePrefixRoot(root) { - t.Fatalf("root = %s", root) - } - // 1 root + (D-1) intermediate Prefix Roots for each disjoint first segment. - if len(objects) != 1+(1-1)+(2-1)+(4-1)+(8-1) { - t.Fatalf("object count = %d", len(objects)) - } - top := objects[len(objects)-1] - if top.Root != root.String() || len(top.State.Entries) != len(exactDepths) { - t.Fatalf("top object = %#v", top) - } - for _, name := range []string{"d1", "d2", "d4", "d8"} { - found := false - for _, entry := range top.State.Entries { - if string(entry.Label) == name && entry.Target.Defined() { - found = true - } - } - if !found { - t.Fatalf("top object lacks %q", name) - } - } -} - -func TestFixtureValidationRequiresSharedCompatibilityRoot(t *testing.T) { - payload := deterministicPayload() - digest := sha256.Sum256(payload) - payloadCID, err := cid.Prefix{Version: 1, Codec: cid.Raw, MhType: mh.SHA2_256, MhLength: -1}.Sum(payload) - if err != nil { - t.Fatal(err) - } - compatibilityRoot, err := cid.Prefix{Version: 1, Codec: cid.DagProtobuf, MhType: mh.SHA2_256, MhLength: -1}.Sum([]byte("compatibility-root")) - if err != nil { - t.Fatal(err) - } - maltRoot, _, err := buildMALT(context.Background(), payloadCID) - if err != nil { - t.Fatal(err) - } - value := fixture{ - SchemaVersion: fixtureSchema, FixtureID: fixtureIdentifier, - Payload: payloadFixture{CID: payloadCID.String(), Bytes: uint64(len(payload)), SHA256: hex.EncodeToString(digest[:])}, - Depths: cloneDepths(exactDepths), - Routes: []routeRoot{ - {Route: "trusted-path-gateway", Root: compatibilityRoot.String()}, {Route: "trustless-car", Root: compatibilityRoot.String()}, - {Route: "direct-cas", Root: compatibilityRoot.String()}, {Route: "malt-kzg", Root: maltRoot.String()}, - }, - UnixFSProfile: unixFSProfile, MALTProfile: maltProfile, Verification: verificationMode, BootstrapCount: bootstrapObjects, - } - if err := value.validate(); err != nil { - t.Fatal(err) - } - value.Routes[1].Root = payloadCID.String() - if err := value.validate(); err == nil { - t.Fatal("different CAR root was accepted") - } -} - -func TestDeterministicPayloadIsStable(t *testing.T) { - first, second := deterministicPayload(), deterministicPayload() - if len(first) != payloadBytes || !bytes.Equal(first, second) { - t.Fatal("fixture payload is not stable") - } - digest := sha256.Sum256(first) - if hex.EncodeToString(digest[:]) != "432f51b0d64d9bcdf2e738ee846a9f5740646e0bb1cc095616293e6cac8bfe40" { - t.Fatalf("payload digest = %x", digest) - } -} diff --git a/tools/evaluation/cmd/malt-eval-rq1-worker/main.go b/tools/evaluation/cmd/malt-eval-rq1-worker/main.go deleted file mode 100644 index e8d1b02..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-worker/main.go +++ /dev/null @@ -1,667 +0,0 @@ -// Command malt-eval-rq1-worker is the stable process boundary used by the -// evaluator for long-lived warm CAR/Direct-CAS clients. Cold runs start a new -// process. Stdout is JSONL only; diagnostics and flag errors go to stderr. -package main - -import ( - "bufio" - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "net" - "net/http" - "os" - "regexp" - "strings" - "sync" - "time" - - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - "github.com/dewebprotocol/malt-client/merkledag" - "github.com/dewebprotocol/malt-client/transport" - cid "github.com/ipfs/go-cid" -) - -const ( - workerRecordSchema = "malt-rq1-worker-record/v3" - workerRequestSchema = "malt-rq1-worker-request/v1" - maxWorkerLineBytes = 1 << 20 - maxWorkerRequests = 1_000_000 - routeCAR = "trustless-car" - routeDirectCAS = "direct-cas" - - evaluationInstanceTokenHeader = gatewaytransport.InstanceTokenHeader - evaluationOperationHeader = "X-Malt-Evaluation-RQ1-Operation" - evaluationRouteHeader = "X-Malt-Evaluation-RQ1-Route" - evaluationLeaseHeader = "X-Malt-Evaluation-RQ1-Lease" - evaluationCacheProfile = "gateway.rq1-cache-observation/v1" -) - -var operationIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$`) - -type workerRequest struct { - SchemaVersion string `json:"schema_version"` - OperationID string `json:"operation_id"` - Segments []string `json:"segments"` -} - -type workerRecord struct { - SchemaVersion string `json:"schema_version"` - WorkerID string `json:"worker_id"` - OperationID string `json:"operation_id,omitempty"` - Route string `json:"route"` - Endpoint string `json:"endpoint"` - VerificationRole string `json:"verification_role"` - Root string `json:"root"` - Segments []string `json:"segments"` - Success bool `json:"success"` - FailureClass string `json:"failure_class,omitempty"` - Error string `json:"error,omitempty"` - LatencyNS uint64 `json:"latency_ns"` - Target string `json:"target,omitempty"` - Kind string `json:"kind,omitempty"` - TotalSize uint64 `json:"total_size,omitempty"` - PayloadBytes uint64 `json:"payload_bytes,omitempty"` - PayloadSHA256 string `json:"payload_sha256,omitempty"` - Metrics *workerMetrics `json:"metrics,omitempty"` - CacheObservation *cacheObservation `json:"cache_observation,omitempty"` -} - -type workerMetrics struct { - NetworkRequests uint64 `json:"network_requests"` - CriticalSequentialRounds uint64 `json:"critical_sequential_rounds"` - BlockLoadCalls uint64 `json:"block_load_calls"` - BlocksVerified uint64 `json:"blocks_verified"` - CASGetRequests uint64 `json:"cas_get_requests"` - CASBytes uint64 `json:"cas_bytes"` - CARBytes uint64 `json:"car_bytes"` - CARBlocks uint64 `json:"car_blocks"` - ProofBytes uint64 `json:"proof_bytes"` - ProofSteps uint64 `json:"proof_steps"` - NetworkDurationNS uint64 `json:"network_duration_ns"` - CIDVerifyDurationNS uint64 `json:"cid_verify_duration_ns"` - CARDecodeDurationNS uint64 `json:"car_decode_duration_ns"` - PathReplayDurationNS uint64 `json:"path_replay_duration_ns"` - ProofDecodeDurationNS uint64 `json:"proof_decode_duration_ns"` - ProofVerifyDurationNS uint64 `json:"proof_verify_duration_ns"` - PayloadReadDurationNS uint64 `json:"payload_read_duration_ns"` - PayloadBindingDurationNS uint64 `json:"payload_binding_duration_ns"` - ServerArcTable serverPhaseTotal `json:"server_arc_table"` - ServerMaterialization serverPhaseTotal `json:"server_materialization"` - ServerOpen serverPhaseTotal `json:"server_open"` - - ServerResponseSerialization serverPhaseTotal `json:"server_response_serialization"` - ClientUserCPUNS uint64 `json:"client_user_cpu_ns"` - ClientSystemCPUNS uint64 `json:"client_system_cpu_ns"` - ProcessPeakRSSBytes uint64 `json:"process_peak_rss_bytes"` -} - -type serverPhaseTotal struct { - Operations uint64 `json:"operations"` - Items uint64 `json:"items"` - Bytes uint64 `json:"bytes"` - DurationNS uint64 `json:"duration_ns"` -} - -type cacheObservation struct { - Profile string `json:"profile"` - Action string `json:"action"` - OperationID string `json:"operation_id"` - Route string `json:"route"` - Lease string `json:"lease"` - InstanceToken string `json:"instance_token"` - CompletedOperationsBefore uint64 `json:"completed_operations_before"` - SuccessfulHTTPRequests uint64 `json:"successful_http_requests"` - ProcessState string `json:"process_state"` - ApplicationResultCache string `json:"application_result_cache"` - OSPageCache string `json:"os_page_cache"` - GatewayHealth gatewayHealth `json:"gateway_health"` -} - -type gatewayHealth struct { - Profile string `json:"profile"` - RawSHA256 string `json:"raw_sha256"` - RawBytes uint64 `json:"raw_bytes"` - InstanceToken string `json:"instance_token"` - CacheObservationProfile string `json:"cache_observation_profile"` - ReadPhasesProfile string `json:"read_phases_profile"` - ApplicationResultCache string `json:"application_result_cache"` - OSPageCache string `json:"os_page_cache"` -} - -type workerConfig struct { - baseURL string - route string - root cid.Cid - workerID string - instanceToken string - requestTimeout time.Duration - cacheClient *http.Client - routeHeaders *evaluationHeaderTransport - evaluation *gatewaytransport.Client -} - -func main() { - if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { - flags := flag.NewFlagSet("malt-eval-rq1-worker", flag.ContinueOnError) - flags.SetOutput(stderr) - baseURL := flags.String("base-url", "", "Gateway or direct-CAS base URL") - route := flags.String("route", "", "trustless-car or direct-cas") - rootText := flags.String("root", "", "caller-selected Merkle-DAG root CID") - workerID := flags.String("worker-id", "", "registered process/run identifier") - instanceToken := flags.String("gateway-instance-token", "", "canonical disposable Gateway evaluation instance token") - timeout := flags.Duration("request-timeout", 5*time.Minute, "per-read deadline") - selfTestCorpus := flags.String("self-test-corpus", "", "formal E0 CAR/Direct-CAS correctness corpus") - if err := flags.Parse(args); err != nil { - return err - } - if flags.NArg() != 0 { - return fmt.Errorf("unexpected positional arguments") - } - if *selfTestCorpus != "" { - if *baseURL != "" || *rootText != "" || *workerID != "" || *instanceToken != "" { - return fmt.Errorf("formal E0 self-test cannot be combined with runtime Gateway/root/worker flags") - } - return runClientRouteSelfTest(strings.TrimSpace(*route), *selfTestCorpus, stdout) - } - root, err := cid.Parse(*rootText) - if err != nil { - return fmt.Errorf("invalid -root: %w", err) - } - config := workerConfig{ - baseURL: strings.TrimSpace(*baseURL), route: strings.TrimSpace(*route), root: root, - workerID: strings.TrimSpace(*workerID), instanceToken: strings.TrimSpace(*instanceToken), requestTimeout: *timeout, - } - if err := config.validate(); err != nil { - return err - } - config.routeHeaders = &evaluationHeaderTransport{base: http.DefaultTransport} - evaluation, err := gatewaytransport.New(gatewaytransport.Options{ - BaseURL: config.baseURL, InstanceToken: config.instanceToken, - HTTPClient: &http.Client{Timeout: config.requestTimeout, Transport: config.routeHeaders}, - }) - if err != nil { - return err - } - config.evaluation = evaluation - config.cacheClient = evaluation.InstanceHTTPClient() - encoder := json.NewEncoder(stdout) - encoder.SetEscapeHTML(false) - scanner := bufio.NewScanner(stdin) - scanner.Buffer(make([]byte, 64<<10), maxWorkerLineBytes) - requests := 0 - var health gatewayHealth - var healthErr error - healthObserved := false - for scanner.Scan() { - requests++ - if requests > maxWorkerRequests { - return fmt.Errorf("worker exceeds %d-request process limit", maxWorkerRequests) - } - raw := append([]byte(nil), scanner.Bytes()...) - request, decodeErr := decodeWorkerRequest(raw) - if decodeErr != nil { - if request.OperationID == "" { - request.OperationID = fmt.Sprintf("invalid-input-%06d", requests) - } - if request.Segments == nil { - request.Segments = []string{} - } - record := config.baseRecord(request) - record.FailureClass = "input_invalid" - record.Error = decodeErr.Error() - if err := encoder.Encode(record); err != nil { - return err - } - continue - } - controlCtx, controlCancel := context.WithTimeout(context.Background(), config.requestTimeout) - if !healthObserved { - health, healthErr = config.observeGatewayHealth(controlCtx) - healthObserved = true - } - var cacheBegin cacheObservation - var cacheErr error - if healthErr == nil { - cacheBegin, cacheErr = config.exchangeCacheObservation(controlCtx, "begin", request, "") - } else { - cacheErr = healthErr - } - controlCancel() - if cacheErr == nil { - config.routeHeaders.Set(request.OperationID, config.route, cacheBegin.Lease) - } - usageBefore, usageErr := readProcessUsage() - operationCtx, operationCancel := context.WithTimeout(context.Background(), config.requestTimeout) - started := time.Now() - var result *merkledag.VerifiedReadResult - if cacheErr == nil && usageErr == nil { - switch config.route { - case routeCAR: - carRequest := merkledag.MerkleDAGReadRequest{ - Profile: merkledag.MerkleDAGReadProfile, Root: config.root.String(), - Segments: append([]string(nil), request.Segments...), - } - encodedRequest, encodeErr := json.Marshal(carRequest) - if encodeErr != nil { - err = encodeErr - break - } - networkStarted := time.Now() - encodedCAR, readErr := config.evaluation.ReadCAR(operationCtx, encodedRequest) - networkDuration := time.Since(networkStarted) - if readErr != nil { - err = readErr - break - } - result, err = merkledag.VerifyMerkleDAGCARRead(operationCtx, carRequest, encodedCAR) - if err == nil { - result.Metrics.NetworkRequests = 1 - result.Metrics.NetworkDurationNS = durationNS(networkDuration) - } - case routeDirectCAS: - result, err = merkledag.ReadMerkleDAGDirectCASVerified( - operationCtx, gatewaytransport.RawCASGetter{Client: config.evaluation}, config.root, request.Segments, nil, nil, - ) - } - } - config.routeHeaders.Clear() - operationCancel() - record := config.baseRecord(request) - record.LatencyNS = durationNS(time.Since(started)) - if cacheErr != nil { - record.FailureClass = "cache_observation" - record.Error = cacheErr.Error() - } else if usageErr != nil { - record.FailureClass = "resource_accounting" - record.Error = usageErr.Error() - } else if err != nil { - record.FailureClass = classifyFailure(err) - record.Error = err.Error() - } else { - metrics := newWorkerMetrics(result.Metrics) - usageAfter, usageErr := readProcessUsage() - if usageErr != nil { - record.FailureClass = "resource_accounting" - record.Error = usageErr.Error() - } else if usageErr := addResourceMetrics(&metrics, usageBefore, usageAfter); usageErr != nil { - record.FailureClass = "resource_accounting" - record.Error = usageErr.Error() - } else { - record.Success = true - record.Target = result.Target.String() - record.Kind = result.Kind - record.TotalSize = result.TotalSize - record.PayloadBytes = uint64(len(result.Data)) - digest := sha256.Sum256(result.Data) - record.PayloadSHA256 = hex.EncodeToString(digest[:]) - record.Metrics = &metrics - endCtx, endCancel := context.WithTimeout(context.Background(), config.requestTimeout) - cacheEnd, cacheErr := config.exchangeCacheObservation(endCtx, "end", request, cacheBegin.Lease) - endCancel() - if cacheErr != nil { - record.Success = false - record.FailureClass = "cache_observation" - record.Error = cacheErr.Error() - record.Target = "" - record.Kind = "" - record.TotalSize = 0 - record.PayloadBytes = 0 - record.PayloadSHA256 = "" - record.Metrics = nil - } else { - cacheEnd.GatewayHealth = health - record.CacheObservation = &cacheEnd - } - } - } - if err := encoder.Encode(record); err != nil { - return err - } - } - if err := scanner.Err(); err != nil { - if strings.Contains(err.Error(), "token too long") { - return fmt.Errorf("worker request exceeds %d bytes", maxWorkerLineBytes) - } - return err - } - return nil -} - -func newWorkerMetrics(value merkledag.VerifiedReadMetrics) workerMetrics { - return workerMetrics{ - NetworkRequests: value.NetworkRequests, CriticalSequentialRounds: value.CriticalSequentialRounds, - BlockLoadCalls: value.BlockLoadCalls, BlocksVerified: value.BlocksVerified, - CASGetRequests: value.CASGetRequests, CASBytes: value.CASBytes, CARBytes: value.CARBytes, CARBlocks: value.CARBlocks, - NetworkDurationNS: value.NetworkDurationNS, CIDVerifyDurationNS: value.CIDVerifyDurationNS, - PayloadBindingDurationNS: value.PayloadBindingDurationNS, - CARDecodeDurationNS: value.CARDecodeDurationNS, PathReplayDurationNS: value.PathReplayDurationNS, - PayloadReadDurationNS: value.PayloadReadDurationNS, - } -} - -func (config workerConfig) validate() error { - if config.baseURL == "" || config.workerID == "" { - return fmt.Errorf("-base-url and -worker-id are required") - } - if config.route != routeCAR && config.route != routeDirectCAS { - return fmt.Errorf("-route must be %q or %q", routeCAR, routeDirectCAS) - } - if config.requestTimeout <= 0 { - return fmt.Errorf("-request-timeout must be positive") - } - if !canonicalToken(config.instanceToken) { - return fmt.Errorf("-gateway-instance-token must be 64 lowercase hexadecimal characters") - } - return nil -} - -func (config workerConfig) baseRecord(request workerRequest) workerRecord { - verificationRole := "local-car-cid-link-replay" - if config.route == routeDirectCAS { - verificationRole = "local-direct-cas-cid-link-replay" - } - return workerRecord{ - SchemaVersion: workerRecordSchema, WorkerID: config.workerID, OperationID: request.OperationID, - Route: config.route, Endpoint: "verified-bytes", VerificationRole: verificationRole, - Root: config.root.String(), Segments: append([]string(nil), request.Segments...), - } -} - -func decodeWorkerRequest(data []byte) (workerRequest, error) { - if len(bytes.TrimSpace(data)) == 0 { - return workerRequest{}, fmt.Errorf("worker request is empty") - } - var request workerRequest - if err := decodeStrictJSON(data, &request); err != nil { - return workerRequest{}, fmt.Errorf("decode worker request: %w", err) - } - if request.SchemaVersion != workerRequestSchema { - return request, fmt.Errorf("schema_version must be %q", workerRequestSchema) - } - if !operationIDPattern.MatchString(request.OperationID) { - return request, fmt.Errorf("operation_id does not match the RQ1 worker contract") - } - if request.Segments == nil || len(request.Segments) > 256 { - return request, fmt.Errorf("segments must be an explicit array with at most 256 values") - } - for index, segment := range request.Segments { - if segment == "" || len(segment) > 1024 || strings.Contains(segment, "/") { - return request, fmt.Errorf("invalid segment %d", index) - } - } - return request, nil -} - -func classifyFailure(err error) string { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - return "deadline" - } - var transportErr *transport.Error - if errors.As(err, &transportErr) { - return "remote_rejection" - } - var networkErr net.Error - if errors.As(err, &networkErr) { - return "network" - } - return "local_verification" -} - -func durationNS(value time.Duration) uint64 { - if value <= 0 { - return 0 - } - return uint64(value) -} - -type cacheObservationRequest struct { - Profile string `json:"profile"` - Action string `json:"action"` - OperationID string `json:"operation_id"` - Route string `json:"route"` - Lease string `json:"lease,omitempty"` -} - -func (config workerConfig) exchangeCacheObservation(ctx context.Context, action string, operation workerRequest, lease string) (cacheObservation, error) { - body, err := json.Marshal(cacheObservationRequest{ - Profile: evaluationCacheProfile, Action: action, OperationID: operation.OperationID, Route: config.route, Lease: lease, - }) - if err != nil { - return cacheObservation{}, err - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(config.baseURL, "/")+"/v1/evaluation/rq1/cache-observation", bytes.NewReader(body)) - if err != nil { - return cacheObservation{}, err - } - request.Header.Set("Content-Type", "application/json") - response, err := config.cacheClient.Do(request) - if err != nil { - return cacheObservation{}, err - } - defer response.Body.Close() - encoded, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1)) - if err != nil { - return cacheObservation{}, err - } - if len(encoded) > 64<<10 { - return cacheObservation{}, fmt.Errorf("RQ1 cache observation exceeds 65536 bytes") - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return cacheObservation{}, fmt.Errorf("RQ1 cache observation HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(encoded))) - } - var observation cacheObservation - if err := decodeStrictJSON(encoded, &observation); err != nil { - return cacheObservation{}, fmt.Errorf("decode RQ1 cache observation: %w", err) - } - if observation.Profile != evaluationCacheProfile || observation.Action != action || observation.OperationID != operation.OperationID || - observation.Route != config.route || !canonicalToken(observation.Lease) || observation.InstanceToken != config.instanceToken || - observation.ApplicationResultCache != "none" || observation.OSPageCache != "uncontrolled" || - observation.ProcessState != "fresh-process-first-operation" && observation.ProcessState != "same-process-retained" { - return cacheObservation{}, fmt.Errorf("RQ1 cache observation does not bind the exact operation/instance/cache boundary") - } - if action == "begin" { - if lease != "" || observation.SuccessfulHTTPRequests != 0 { - return cacheObservation{}, fmt.Errorf("RQ1 cache begin has invalid lease/request state") - } - } else if action == "end" { - if observation.Lease != lease || observation.SuccessfulHTTPRequests == 0 { - return cacheObservation{}, fmt.Errorf("RQ1 cache end did not observe a successful concrete route request") - } - } else { - return cacheObservation{}, fmt.Errorf("unsupported RQ1 cache action %q", action) - } - return observation, nil -} - -func (config workerConfig) observeGatewayHealth(ctx context.Context) (gatewayHealth, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.baseURL, "/")+"/healthz", nil) - if err != nil { - return gatewayHealth{}, err - } - response, err := config.cacheClient.Do(request) - if err != nil { - return gatewayHealth{}, err - } - defer response.Body.Close() - raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) - if err != nil { - return gatewayHealth{}, err - } - if len(raw) > 1<<20 { - return gatewayHealth{}, fmt.Errorf("Gateway health exceeds 1048576 bytes") - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return gatewayHealth{}, fmt.Errorf("Gateway health HTTP %d", response.StatusCode) - } - var values map[string]json.RawMessage - if err := decodeStrictJSON(raw, &values); err != nil { - return gatewayHealth{}, fmt.Errorf("decode Gateway health: %w", err) - } - require := func(key, want string) error { - encoded, ok := values[key] - if !ok { - return fmt.Errorf("Gateway health omitted %q", key) - } - var got string - if err := json.Unmarshal(encoded, &got); err != nil || got != want { - return fmt.Errorf("Gateway health %q does not equal %q", key, want) - } - return nil - } - for key, want := range map[string]string{ - "status": "ok", - "evaluation_instance_token": config.instanceToken, - "evaluation_rq1_cache_observation": evaluationCacheProfile, - "evaluation_rq1_read_phases": "gateway.rq1-read-phases/v3", - "evaluation_rq1_application_result_cache": "none", - "evaluation_rq1_os_page_cache": "uncontrolled", - } { - if err := require(key, want); err != nil { - return gatewayHealth{}, err - } - } - digest := sha256.Sum256(raw) - return gatewayHealth{ - Profile: "malt-rq1-gateway-health-observation/v1", RawSHA256: hex.EncodeToString(digest[:]), RawBytes: uint64(len(raw)), - InstanceToken: config.instanceToken, CacheObservationProfile: evaluationCacheProfile, - ReadPhasesProfile: "gateway.rq1-read-phases/v3", ApplicationResultCache: "none", OSPageCache: "uncontrolled", - }, nil -} - -type evaluationHeaderTransport struct { - base http.RoundTripper - - mu sync.RWMutex - operationID string - route string - lease string -} - -func (transport *evaluationHeaderTransport) Set(operationID, route, lease string) { - transport.mu.Lock() - defer transport.mu.Unlock() - transport.operationID = operationID - transport.route = route - transport.lease = lease -} - -func (transport *evaluationHeaderTransport) Clear() { - transport.Set("", "", "") -} - -func (transport *evaluationHeaderTransport) RoundTrip(request *http.Request) (*http.Response, error) { - transport.mu.RLock() - operationID, route, lease := transport.operationID, transport.route, transport.lease - transport.mu.RUnlock() - if operationID == "" || route == "" || lease == "" { - return transport.base.RoundTrip(request) - } - copy := request.Clone(request.Context()) - copy.Header = request.Header.Clone() - copy.Header.Set(evaluationOperationHeader, operationID) - copy.Header.Set(evaluationRouteHeader, route) - copy.Header.Set(evaluationLeaseHeader, lease) - return transport.base.RoundTrip(copy) -} - -func decodeStrictJSON(data []byte, target any) error { - if err := rejectDuplicateJSONKeys(data); err != nil { - return err - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); err != io.EOF { - return fmt.Errorf("expected exactly one JSON object") - } - return nil -} - -func rejectDuplicateJSONKeys(data []byte) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.UseNumber() - if err := scanJSONValue(decoder, "$"); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); err != io.EOF { - return fmt.Errorf("unexpected trailing JSON") - } - return nil -} - -func scanJSONValue(decoder *json.Decoder, path string) error { - token, err := decoder.Token() - if err != nil { - return err - } - delimiter, ok := token.(json.Delim) - if !ok { - return nil - } - switch delimiter { - case '{': - seen := map[string]struct{}{} - for decoder.More() { - keyToken, err := decoder.Token() - if err != nil { - return err - } - key, ok := keyToken.(string) - if !ok { - return fmt.Errorf("non-string object key at %s", path) - } - if _, duplicate := seen[key]; duplicate { - return fmt.Errorf("duplicate JSON object key %q at %s", key, path) - } - seen[key] = struct{}{} - if err := scanJSONValue(decoder, path+"."+key); err != nil { - return err - } - } - _, err := decoder.Token() - return err - case '[': - for index := 0; decoder.More(); index++ { - if err := scanJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index)); err != nil { - return err - } - } - _, err := decoder.Token() - return err - default: - return fmt.Errorf("unexpected JSON delimiter %q", delimiter) - } -} - -func canonicalToken(value string) bool { - if len(value) != 64 { - return false - } - decoded, err := hex.DecodeString(value) - return err == nil && hex.EncodeToString(decoded) == value -} - -func addResourceMetrics(metrics *workerMetrics, before, after processUsage) error { - if metrics == nil || after.userCPUNS < before.userCPUNS || after.systemCPUNS < before.systemCPUNS || after.peakRSSBytes == 0 { - return fmt.Errorf("invalid process resource counter transition") - } - metrics.ClientUserCPUNS = after.userCPUNS - before.userCPUNS - metrics.ClientSystemCPUNS = after.systemCPUNS - before.systemCPUNS - metrics.ProcessPeakRSSBytes = after.peakRSSBytes - return nil -} diff --git a/tools/evaluation/cmd/malt-eval-rq1-worker/main_test.go b/tools/evaluation/cmd/malt-eval-rq1-worker/main_test.go deleted file mode 100644 index 54a7cdb..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-worker/main_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - cid "github.com/ipfs/go-cid" - mh "github.com/multiformats/go-multihash" -) - -func TestDirectCASWorkerEmitsAtomicVerifiedRecordAndSurvivesBadInput(t *testing.T) { - payload := []byte("locally verified direct CAS payload") - root, err := cid.Prefix{Version: 1, Codec: cid.Raw, MhType: mh.SHA2_256, MhLength: -1}.Sum(payload) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.URL.Path == "/healthz" { - _ = json.NewEncoder(writer).Encode(map[string]string{ - "status": "ok", "evaluation_instance_token": clientTestInstanceToken, - "evaluation_rq1_cache_observation": evaluationCacheProfile, - "evaluation_rq1_read_phases": "gateway.rq1-read-phases/v3", - "evaluation_rq1_application_result_cache": "none", - "evaluation_rq1_os_page_cache": "uncontrolled", - }) - return - } - if request.URL.Path == "/v1/evaluation/rq1/cache-observation" { - serveClientCacheObservation(t, writer, request) - return - } - if request.Method != http.MethodGet || request.URL.Path != "/v1/cas/"+root.String() { - http.NotFound(writer, request) - return - } - if request.Header.Get(evaluationInstanceTokenHeader) != clientTestInstanceToken || request.Header.Get(evaluationOperationHeader) != "read-1" || - request.Header.Get(evaluationRouteHeader) != routeDirectCAS || request.Header.Get(evaluationLeaseHeader) != clientTestLease { - t.Fatalf("route headers = %#v", request.Header) - } - writer.Header().Set("Content-Type", "application/octet-stream") - _, _ = writer.Write(payload) - })) - defer server.Close() - - stdin := strings.NewReader("{\"schema_version\":\"malt-rq1-worker-request/v1\",\"operation_id\":\"read-1\",\"segments\":[]}\n{\"schema_version\":\"malt-rq1-worker-request/v1\",\"operation_id\":\"bad\",\"segments\":[],\"unknown\":true}\n") - var stdout, stderr bytes.Buffer - err = run([]string{ - "-base-url", server.URL, "-route", routeDirectCAS, "-root", root.String(), "-worker-id", "process-1", - "-gateway-instance-token", clientTestInstanceToken, - }, stdin, &stdout, &stderr) - if err != nil { - t.Fatal(err) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q", stderr.String()) - } - scanner := bufio.NewScanner(&stdout) - var records []workerRecord - for scanner.Scan() { - var record workerRecord - if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { - t.Fatalf("decode output line: %v", err) - } - records = append(records, record) - } - if err := scanner.Err(); err != nil { - t.Fatal(err) - } - if len(records) != 2 { - t.Fatalf("records = %d, want 2; output=%q", len(records), stdout.String()) - } - verified := records[0] - if !verified.Success || verified.OperationID != "read-1" || verified.Route != routeDirectCAS || verified.Endpoint != "verified-bytes" || verified.VerificationRole != "local-direct-cas-cid-link-replay" || verified.Root != root.String() || - verified.Target != root.String() || verified.PayloadBytes != uint64(len(payload)) || verified.PayloadSHA256 == "" || verified.Metrics == nil { - t.Fatalf("verified record = %#v", verified) - } - if verified.Metrics.CASGetRequests != 1 || verified.Metrics.CriticalSequentialRounds != 1 || verified.Metrics.BlocksVerified != 1 { - t.Fatalf("verified metrics = %#v", verified.Metrics) - } - if verified.Metrics.CIDVerifyDurationNS == 0 || verified.Metrics.PayloadBindingDurationNS == 0 || - verified.Metrics.PayloadBindingDurationNS > verified.Metrics.CIDVerifyDurationNS { - t.Fatalf("verification phase metrics = %#v", verified.Metrics) - } - if verified.Metrics.ProcessPeakRSSBytes == 0 || verified.Metrics.ServerArcTable != (serverPhaseTotal{}) || - verified.Metrics.ServerResponseSerialization != (serverPhaseTotal{}) { - t.Fatalf("resource/server metrics = %#v", verified.Metrics) - } - if verified.CacheObservation == nil || verified.CacheObservation.CompletedOperationsBefore != 0 || - verified.CacheObservation.ProcessState != "fresh-process-first-operation" || verified.CacheObservation.SuccessfulHTTPRequests != 1 || - verified.CacheObservation.GatewayHealth.RawBytes == 0 || verified.CacheObservation.GatewayHealth.OSPageCache != "uncontrolled" { - t.Fatalf("cache observation = %#v", verified.CacheObservation) - } - invalid := records[1] - if invalid.Success || invalid.FailureClass != "input_invalid" || invalid.Error == "" || invalid.Metrics != nil || invalid.CacheObservation != nil { - t.Fatalf("invalid record = %#v", invalid) - } -} - -func TestWorkerRejectsInvalidConfigurationAndOversizedLine(t *testing.T) { - root, err := cid.Prefix{Version: 1, Codec: cid.Raw, MhType: mh.SHA2_256, MhLength: -1}.Sum([]byte("root")) - if err != nil { - t.Fatal(err) - } - if err := run([]string{"-base-url", "http://127.0.0.1", "-route", "trusted", "-root", root.String(), "-worker-id", "p", "-gateway-instance-token", clientTestInstanceToken}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err == nil { - t.Fatal("unsupported worker route was accepted") - } - line := strings.Repeat("x", maxWorkerLineBytes+1) + "\n" - if err := run([]string{"-base-url", "http://127.0.0.1", "-route", routeDirectCAS, "-root", root.String(), "-worker-id", "p", "-gateway-instance-token", clientTestInstanceToken}, strings.NewReader(line), &bytes.Buffer{}, &bytes.Buffer{}); err == nil { - t.Fatal("oversized worker request was accepted") - } -} - -func serveClientCacheObservation(t *testing.T, writer http.ResponseWriter, request *http.Request) { - t.Helper() - if request.Method != http.MethodPost || request.Header.Get(evaluationInstanceTokenHeader) != clientTestInstanceToken { - http.Error(writer, "invalid instance token", http.StatusForbidden) - return - } - var wire cacheObservationRequest - if err := json.NewDecoder(request.Body).Decode(&wire); err != nil { - http.Error(writer, err.Error(), http.StatusBadRequest) - return - } - requests := uint64(0) - if wire.Action == "end" { - if wire.Lease != clientTestLease { - http.Error(writer, "invalid lease", http.StatusConflict) - return - } - requests = 1 - } - _ = json.NewEncoder(writer).Encode(cacheObservation{ - Profile: evaluationCacheProfile, Action: wire.Action, OperationID: wire.OperationID, Route: wire.Route, - Lease: clientTestLease, InstanceToken: clientTestInstanceToken, CompletedOperationsBefore: 0, - SuccessfulHTTPRequests: requests, ProcessState: "fresh-process-first-operation", - ApplicationResultCache: "none", OSPageCache: "uncontrolled", - }) -} - -const clientTestInstanceToken = "8888888888888888888888888888888888888888888888888888888888888888" -const clientTestLease = "6666666666666666666666666666666666666666666666666666666666666666" - -func TestDecodeWorkerRequestRejectsTrailingValueAndMissingSegments(t *testing.T) { - if _, err := decodeWorkerRequest([]byte(`{"schema_version":"malt-rq1-worker-request/v1","operation_id":"x","segments":[]} {}`)); err == nil { - t.Fatal("trailing JSON was accepted") - } - if _, err := decodeWorkerRequest([]byte(`{"schema_version":"malt-rq1-worker-request/v1","operation_id":"x"}`)); err == nil { - t.Fatal("omitted segments were accepted") - } - if _, err := decodeWorkerRequest([]byte(`{"schema_version":"malt-rq1-worker-request/v1","operation_id":"bad/id","segments":[]}`)); err == nil { - t.Fatal("path-like operation ID was accepted") - } - if _, err := decodeWorkerRequest([]byte(`{"schema_version":"malt-rq1-worker-request/v1","operation_id":"x","segments":["a/b"]}`)); err == nil { - t.Fatal("slash-encoded segment was accepted") - } - if _, err := decodeWorkerRequest([]byte(`{"schema_version":"malt-rq1-worker-request/v1","operation_id":"one","operation_id":"two","segments":[]}`)); err == nil { - t.Fatal("duplicate worker request key was accepted") - } -} - -func TestStrictEvaluationJSONRejectsDuplicateKeys(t *testing.T) { - var value cacheObservation - if err := decodeStrictJSON([]byte(`{"profile":"one","profile":"two"}`), &value); err == nil { - t.Fatal("duplicate cache-observation key was accepted") - } -} diff --git a/tools/evaluation/cmd/malt-eval-rq1-worker/rss_bytes.go b/tools/evaluation/cmd/malt-eval-rq1-worker/rss_bytes.go deleted file mode 100644 index 84463f5..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-worker/rss_bytes.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build darwin - -package main - -const unixRSSIsKiB = false diff --git a/tools/evaluation/cmd/malt-eval-rq1-worker/rss_kib.go b/tools/evaluation/cmd/malt-eval-rq1-worker/rss_kib.go deleted file mode 100644 index b781503..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-worker/rss_kib.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build aix || dragonfly || freebsd || linux || netbsd || openbsd || solaris - -package main - -const unixRSSIsKiB = true diff --git a/tools/evaluation/cmd/malt-eval-rq1-worker/selftest.go b/tools/evaluation/cmd/malt-eval-rq1-worker/selftest.go deleted file mode 100644 index 78e7bd7..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-worker/selftest.go +++ /dev/null @@ -1,452 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - "sync/atomic" - "time" - - casmemory "github.com/dewebprotocol/malt-client/internal/cas/memory" - "github.com/dewebprotocol/malt-client/internal/evaluation/e0selftest" - "github.com/dewebprotocol/malt-client/merkledag" - merkledagimport "github.com/dewebprotocol/malt-client/merkledag/importer" - cid "github.com/ipfs/go-cid" - car "github.com/ipld/go-car/v2" - carstorage "github.com/ipld/go-car/v2/storage" -) - -const ( - clientRouteSelfTestCorpusSchema = "malt-rq1-client-route-correctness-corpus/v1" - clientRouteSelfTestToken = "9999999999999999999999999999999999999999999999999999999999999999" - clientRouteSelfTestLease = "7777777777777777777777777777777777777777777777777777777777777777" -) - -var clientRouteSelfTestProfiles = map[string]e0selftest.Profile{ - routeCAR: { - ProfileID: "rq1-trustless-car-positive-hostile-v1", - PositiveCases: []string{"car-positive"}, - HostileCases: []string{"malformed-request", "car-cid-mismatch", "wrong-path"}, - }, - routeDirectCAS: { - ProfileID: "rq1-direct-cas-positive-hostile-v1", - PositiveCases: []string{"direct-cas-positive"}, - HostileCases: []string{"malformed-request", "cid-content-mismatch", "wrong-path"}, - }, -} - -var clientRouteSelfTestCapabilities = map[string]string{ - routeCAR: "rq1.trustless-car", - routeDirectCAS: "rq1.direct-cas", -} - -type clientRouteSelfTestCorpus struct { - SchemaVersion string `json:"schema_version"` - TrustlessCARPayloadB64 string `json:"trustless_car_payload_base64"` - DirectCASPayloadB64 string `json:"direct_cas_payload_base64"` - Segments []string `json:"segments"` - ChunkBytes int `json:"chunk_bytes"` -} - -type clientRouteFixture struct { - store *casmemory.Store - root cid.Cid - segments []string - payload []byte - evidence []merkledag.MerkleDAGBlock -} - -type recordingBlockGetter struct { - store *casmemory.Store - evidence []merkledag.MerkleDAGBlock -} - -func (g *recordingBlockGetter) Get(ctx context.Context, key cid.Cid) ([]byte, error) { - data, err := g.store.Get(ctx, key) - if err != nil { - return nil, err - } - g.evidence = append(g.evidence, merkledag.MerkleDAGBlock{ - CID: key.String(), Codec: key.Type(), Data: append([]byte(nil), data...), - }) - return data, nil -} - -type selfTestWorkerRunner func(context.Context, string, string, cid.Cid, []string, bool) (workerRecord, error) - -func runClientRouteSelfTest(route, corpusPath string, output io.Writer) error { - return runClientRouteSelfTestWithRunner(route, corpusPath, output, runSelfTestWorkerProcess) -} - -func runClientRouteSelfTestWithRunner(route, corpusPath string, output io.Writer, runner selfTestWorkerRunner) error { - profile, ok := clientRouteSelfTestProfiles[route] - if !ok || runner == nil { - return fmt.Errorf("formal E0 self-test route must be %q or %q", routeCAR, routeDirectCAS) - } - corpus, err := loadClientRouteSelfTestCorpus(corpusPath) - if err != nil { - return err - } - carPayload, directPayload, err := corpus.validate() - if err != nil { - return err - } - payload := carPayload - if route == routeDirectCAS { - payload = directPayload - } - fixture, err := newClientRouteFixture(payload, corpus.Segments, corpus.ChunkBytes) - if err != nil { - return fmt.Errorf("build RQ1 client-route self-test fixture: %w", err) - } - results := make([]e0selftest.CaseResult, 0, len(profile.PositiveCases)+len(profile.HostileCases)) - for _, caseID := range append(append([]string(nil), profile.PositiveCases...), profile.HostileCases...) { - passed, err := runClientRouteSelfTestCase(route, caseID, fixture, runner) - if err != nil { - return fmt.Errorf("RQ1 %s self-test %s: %w", route, caseID, err) - } - if !passed { - return fmt.Errorf("RQ1 %s self-test %s did not observe its required accept/reject behavior", route, caseID) - } - results = append(results, e0selftest.CaseResult{ID: caseID, Passed: true}) - } - receipt, err := e0selftest.Issue(clientRouteSelfTestCapabilities[route], profile, results) - if err != nil { - return err - } - encoder := json.NewEncoder(output) - encoder.SetEscapeHTML(false) - return encoder.Encode(receipt) -} - -func loadClientRouteSelfTestCorpus(path string) (clientRouteSelfTestCorpus, error) { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return clientRouteSelfTestCorpus{}, errors.New("RQ1 client-route self-test corpus path must be canonical and absolute") - } - info, err := os.Lstat(path) - if err != nil { - return clientRouteSelfTestCorpus{}, fmt.Errorf("inspect RQ1 client-route self-test corpus: %w", err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWorkerLineBytes { - return clientRouteSelfTestCorpus{}, errors.New("RQ1 client-route self-test corpus must be a bounded regular non-symlink file") - } - resolved, err := filepath.EvalSymlinks(path) - if err != nil || resolved != path { - return clientRouteSelfTestCorpus{}, errors.New("RQ1 client-route self-test corpus path or parent is symlinked") - } - data, err := os.ReadFile(path) - if err != nil { - return clientRouteSelfTestCorpus{}, err - } - var corpus clientRouteSelfTestCorpus - if err := decodeStrictJSON(data, &corpus); err != nil { - return clientRouteSelfTestCorpus{}, fmt.Errorf("decode RQ1 client-route self-test corpus: %w", err) - } - return corpus, nil -} - -func (c clientRouteSelfTestCorpus) validate() ([]byte, []byte, error) { - if c.SchemaVersion != clientRouteSelfTestCorpusSchema || - !slices.Equal(c.Segments, []string{"docs", "payload.bin"}) || c.ChunkBytes != 5 { - return nil, nil, errors.New("RQ1 client-route self-test corpus has an invalid schema, exact path, or chunk profile") - } - carPayload, err := base64.StdEncoding.Strict().DecodeString(c.TrustlessCARPayloadB64) - if err != nil || len(carPayload) < c.ChunkBytes*2 { - return nil, nil, errors.New("RQ1 client-route self-test corpus has invalid Trustless-CAR payload bytes") - } - directPayload, err := base64.StdEncoding.Strict().DecodeString(c.DirectCASPayloadB64) - if err != nil || len(directPayload) < c.ChunkBytes*2 { - return nil, nil, errors.New("RQ1 client-route self-test corpus has invalid Direct-CAS payload bytes") - } - if bytes.Equal(carPayload, directPayload) { - return nil, nil, errors.New("RQ1 client-route self-test payloads must be distinct") - } - return carPayload, directPayload, nil -} - -func newClientRouteFixture(payload []byte, segments []string, chunkBytes int) (clientRouteFixture, error) { - store := casmemory.New() - result, err := merkledagimport.ImportFiles(context.Background(), store, []merkledagimport.File{{ - Path: strings.Join(segments, "/"), Data: append([]byte(nil), payload...), Mode: 0o644, - }}, merkledagimport.Options{ - Model: merkledagimport.ModelUnixFS, FileLayout: merkledagimport.FileLayoutBalanced, - DirLayout: merkledagimport.DirLayoutBasic, ChunkSize: chunkBytes, - }) - if err != nil { - return clientRouteFixture{}, err - } - root, err := cid.Parse(result.Root) - if err != nil { - return clientRouteFixture{}, err - } - recorder := &recordingBlockGetter{store: store} - verified, err := merkledag.ReadMerkleDAGDirectCASVerified(context.Background(), recorder, root, segments, nil, nil) - if err != nil { - return clientRouteFixture{}, err - } - if !bytes.Equal(verified.Data, payload) || !verified.Root.Equals(root) || len(recorder.evidence) == 0 { - return clientRouteFixture{}, errors.New("generated RQ1 client-route fixture failed local replay") - } - return clientRouteFixture{ - store: store, root: root, segments: append([]string(nil), segments...), payload: append([]byte(nil), payload...), - evidence: append([]merkledag.MerkleDAGBlock(nil), recorder.evidence...), - }, nil -} - -func runClientRouteSelfTestCase(route, caseID string, fixture clientRouteFixture, runner selfTestWorkerRunner) (bool, error) { - carBytes, err := encodeSelfTestCAR(fixture.root, fixture.evidence, caseID == "car-cid-mismatch") - if err != nil { - return false, err - } - var routeRequests atomic.Uint64 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if serveClientRouteSelfTestControl(writer, request, route, &routeRequests) { - return - } - if !validClientRouteSelfTestHeaders(request, route) { - http.Error(writer, "invalid RQ1 route binding", http.StatusForbidden) - return - } - switch route { - case routeCAR: - if request.Method != http.MethodPost || request.URL.Path != "/v1/compat/merkledag/car/read" || request.Header.Get("Content-Type") != "application/json" { - http.NotFound(writer, request) - return - } - body, readErr := io.ReadAll(io.LimitReader(request.Body, maxWorkerLineBytes+1)) - var wire merkledag.MerkleDAGReadRequest - if readErr != nil || len(body) > maxWorkerLineBytes || decodeStrictJSON(body, &wire) != nil || wire.Root != fixture.root.String() { - http.Error(writer, "invalid CAR read request", http.StatusBadRequest) - return - } - routeRequests.Add(1) - writer.Header().Set("Content-Type", merkledag.MerkleDAGCARReadMediaType) - _, _ = writer.Write(carBytes) - case routeDirectCAS: - if request.Method != http.MethodGet || !strings.HasPrefix(request.URL.Path, "/v1/cas/") { - http.NotFound(writer, request) - return - } - key, parseErr := cid.Parse(strings.TrimPrefix(request.URL.Path, "/v1/cas/")) - if parseErr != nil { - http.Error(writer, "invalid CID", http.StatusBadRequest) - return - } - data, getErr := fixture.store.Get(request.Context(), key) - if getErr != nil { - http.NotFound(writer, request) - return - } - if caseID == "cid-content-mismatch" && key.Equals(fixture.root) { - data[0] ^= 0xff - } - routeRequests.Add(1) - writer.Header().Set("Content-Type", "application/octet-stream") - _, _ = writer.Write(data) - default: - http.NotFound(writer, request) - } - })) - defer server.Close() - - segments := append([]string(nil), fixture.segments...) - if caseID == "wrong-path" { - segments = []string{"docs", "missing.bin"} - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - record, err := runner(ctx, server.URL, route, fixture.root, segments, caseID == "malformed-request") - if err != nil { - return false, err - } - requests := routeRequests.Load() - if record.Route != route || record.Root != fixture.root.String() || record.Endpoint != "verified-bytes" { - return false, nil - } - switch caseID { - case "car-positive", "direct-cas-positive": - digest := sha256.Sum256(fixture.payload) - role := "local-car-cid-link-replay" - if route == routeDirectCAS { - role = "local-direct-cas-cid-link-replay" - } - if !record.Success || record.VerificationRole != role || record.PayloadBytes != uint64(len(fixture.payload)) || - record.PayloadSHA256 != hex.EncodeToString(digest[:]) || record.Metrics == nil || requests == 0 { - return false, nil - } - if record.Metrics.CIDVerifyDurationNS == 0 || record.Metrics.PayloadBindingDurationNS == 0 || - record.Metrics.PayloadBindingDurationNS > record.Metrics.CIDVerifyDurationNS { - return false, nil - } - if route == routeCAR { - return record.Metrics.NetworkRequests == 1 && record.Metrics.CARBlocks > 0 && record.Metrics.BlocksVerified > 0, nil - } - return record.Metrics.CASGetRequests > 0 && record.Metrics.CASGetRequests == record.Metrics.BlocksVerified, nil - case "malformed-request": - return !record.Success && record.FailureClass == "input_invalid" && requests == 0, nil - case "car-cid-mismatch", "cid-content-mismatch", "wrong-path": - return !record.Success && record.FailureClass == "local_verification" && record.Error != "" && requests > 0, nil - default: - return false, fmt.Errorf("unknown RQ1 client-route self-test case %q", caseID) - } -} - -func encodeSelfTestCAR(root cid.Cid, evidence []merkledag.MerkleDAGBlock, corrupt bool) ([]byte, error) { - var output bytes.Buffer - writable, err := carstorage.NewWritable(&output, []cid.Cid{root}, car.WriteAsCarV1(true)) - if err != nil { - return nil, err - } - for index, item := range evidence { - key, err := cid.Parse(item.CID) - if err != nil { - return nil, err - } - data := append([]byte(nil), item.Data...) - if corrupt && index == 0 { - data[0] ^= 0xff - } - if err := writable.Put(context.Background(), key.KeyString(), data); err != nil { - return nil, err - } - } - if err := writable.Finalize(); err != nil { - return nil, err - } - return output.Bytes(), nil -} - -func serveClientRouteSelfTestControl(writer http.ResponseWriter, request *http.Request, route string, routeRequests *atomic.Uint64) bool { - switch request.URL.Path { - case "/healthz": - _ = json.NewEncoder(writer).Encode(map[string]string{ - "status": "ok", "evaluation_instance_token": clientRouteSelfTestToken, - "evaluation_rq1_cache_observation": evaluationCacheProfile, - "evaluation_rq1_read_phases": "gateway.rq1-read-phases/v3", - "evaluation_rq1_application_result_cache": "none", - "evaluation_rq1_os_page_cache": "uncontrolled", - }) - return true - case "/v1/evaluation/rq1/cache-observation": - if request.Method != http.MethodPost || request.Header.Get(evaluationInstanceTokenHeader) != clientRouteSelfTestToken { - http.Error(writer, "invalid RQ1 cache observation request", http.StatusForbidden) - return true - } - body, err := io.ReadAll(io.LimitReader(request.Body, (64<<10)+1)) - var wire cacheObservationRequest - if err != nil || len(body) > 64<<10 || decodeStrictJSON(body, &wire) != nil || wire.Profile != evaluationCacheProfile || wire.Route != route || wire.OperationID != "read-1" { - http.Error(writer, "invalid RQ1 cache observation binding", http.StatusBadRequest) - return true - } - requests := uint64(0) - if wire.Action == "end" { - if wire.Lease != clientRouteSelfTestLease { - http.Error(writer, "invalid RQ1 cache lease", http.StatusConflict) - return true - } - requests = routeRequests.Load() - } else if wire.Action != "begin" || wire.Lease != "" || routeRequests.Load() != 0 { - http.Error(writer, "invalid RQ1 cache action", http.StatusConflict) - return true - } - _ = json.NewEncoder(writer).Encode(cacheObservation{ - Profile: evaluationCacheProfile, Action: wire.Action, OperationID: wire.OperationID, Route: wire.Route, - Lease: clientRouteSelfTestLease, InstanceToken: clientRouteSelfTestToken, CompletedOperationsBefore: 0, - SuccessfulHTTPRequests: requests, ProcessState: "fresh-process-first-operation", - ApplicationResultCache: "none", OSPageCache: "uncontrolled", - }) - return true - default: - return false - } -} - -func validClientRouteSelfTestHeaders(request *http.Request, route string) bool { - return request.Header.Get(evaluationInstanceTokenHeader) == clientRouteSelfTestToken && - request.Header.Get(evaluationOperationHeader) == "read-1" && request.Header.Get(evaluationRouteHeader) == route && - request.Header.Get(evaluationLeaseHeader) == clientRouteSelfTestLease -} - -func runSelfTestWorkerProcess(ctx context.Context, baseURL, route string, root cid.Cid, segments []string, malformed bool) (workerRecord, error) { - executable, err := os.Executable() - if err != nil { - return workerRecord{}, err - } - input, args, err := selfTestWorkerInvocation(baseURL, route, root, segments, malformed) - if err != nil { - return workerRecord{}, err - } - command := exec.CommandContext(ctx, executable, args...) - command.Stdin = bytes.NewReader(input) - var stdout, stderr bytes.Buffer - command.Stdout, command.Stderr = &stdout, &stderr - if err := command.Run(); err != nil { - return workerRecord{}, fmt.Errorf("run RQ1 production worker process: %w (stderr=%q)", err, strings.TrimSpace(stderr.String())) - } - if stderr.Len() != 0 { - return workerRecord{}, fmt.Errorf("RQ1 production worker emitted stderr %q", strings.TrimSpace(stderr.String())) - } - return decodeSelfTestWorkerRecord(stdout.Bytes()) -} - -func runSelfTestWorkerInProcess(ctx context.Context, baseURL, route string, root cid.Cid, segments []string, malformed bool) (workerRecord, error) { - if err := ctx.Err(); err != nil { - return workerRecord{}, err - } - input, args, err := selfTestWorkerInvocation(baseURL, route, root, segments, malformed) - if err != nil { - return workerRecord{}, err - } - var stdout, stderr bytes.Buffer - if err := run(args, bytes.NewReader(input), &stdout, &stderr); err != nil { - return workerRecord{}, fmt.Errorf("run nested RQ1 worker: %w (stderr=%q)", err, strings.TrimSpace(stderr.String())) - } - if stderr.Len() != 0 { - return workerRecord{}, fmt.Errorf("nested RQ1 worker emitted stderr %q", strings.TrimSpace(stderr.String())) - } - return decodeSelfTestWorkerRecord(stdout.Bytes()) -} - -func selfTestWorkerInvocation(baseURL, route string, root cid.Cid, segments []string, malformed bool) ([]byte, []string, error) { - request := workerRequest{SchemaVersion: workerRequestSchema, OperationID: "read-1", Segments: append([]string(nil), segments...)} - input, err := json.Marshal(request) - if err != nil { - return nil, nil, err - } - if malformed { - input = []byte(`{"schema_version":"malt-rq1-worker-request/v1","operation_id":"read-1","segments":[],"unknown":true}`) - } - input = append(input, '\n') - args := []string{ - "-base-url", baseURL, "-route", route, "-root", root.String(), "-worker-id", "e0-process", - "-gateway-instance-token", clientRouteSelfTestToken, "-request-timeout", "20s", - } - return input, args, nil -} - -func decodeSelfTestWorkerRecord(data []byte) (workerRecord, error) { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var record workerRecord - if err := decoder.Decode(&record); err != nil { - return workerRecord{}, err - } - var trailing json.RawMessage - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - return workerRecord{}, errors.New("RQ1 worker process emitted trailing output") - } - return record, nil -} diff --git a/tools/evaluation/cmd/malt-eval-rq1-worker/selftest_test.go b/tools/evaluation/cmd/malt-eval-rq1-worker/selftest_test.go deleted file mode 100644 index 3f6101c..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-worker/selftest_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package main - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/dewebprotocol/malt-client/internal/evaluation/e0selftest" -) - -func TestClientRouteFormalE0SelfTestsIssueBoundReceipts(t *testing.T) { - corpus := writeClientRouteSelfTestCorpus(t) - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - for _, route := range []string{routeCAR, routeDirectCAS} { - t.Run(route, func(t *testing.T) { - profile := clientRouteSelfTestProfiles[route] - environment, err := e0selftest.BuildInvocationEnvironment( - clientRouteSelfTestCapabilities[route], profile, - e0selftest.InputFile{FileID: "rq1-client-route-worker", Path: executable}, - []e0selftest.InputFile{{FileID: "rq1-client-route-corpus", Path: corpus}}, - []string{"rq1-client-route-corpus"}, - ) - if err != nil { - t.Fatal(err) - } - t.Setenv(e0selftest.InvocationEnvironment, environment) - var output bytes.Buffer - if err := runClientRouteSelfTestWithRunner(route, corpus, &output, runSelfTestWorkerInProcess); err != nil { - t.Fatal(err) - } - var receipt e0selftest.Receipt - if err := json.Unmarshal(output.Bytes(), &receipt); err != nil { - t.Fatal(err) - } - if err := receipt.Validate(); err != nil { - t.Fatal(err) - } - if receipt.CapabilityID != clientRouteSelfTestCapabilities[route] || receipt.Positive.Executed != 1 || receipt.Hostile.Executed != 3 || - receipt.TestedExecutable.FileID != "rq1-client-route-worker" || len(receipt.TestedInputs) != 1 || receipt.TestedInputs[0].FileID != "rq1-client-route-corpus" { - t.Fatalf("receipt = %+v", receipt) - } - }) - } -} - -func TestClientRouteFormalE0RejectsCapabilityRelabelAndRuntimeFlags(t *testing.T) { - corpus := writeClientRouteSelfTestCorpus(t) - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - profile := clientRouteSelfTestProfiles[routeCAR] - environment, err := e0selftest.BuildInvocationEnvironment( - clientRouteSelfTestCapabilities[routeDirectCAS], profile, - e0selftest.InputFile{FileID: "rq1-client-route-worker", Path: executable}, - []e0selftest.InputFile{{FileID: "rq1-client-route-corpus", Path: corpus}}, - []string{"rq1-client-route-corpus"}, - ) - if err != nil { - t.Fatal(err) - } - t.Setenv(e0selftest.InvocationEnvironment, environment) - var output bytes.Buffer - if err := runClientRouteSelfTestWithRunner(routeCAR, corpus, &output, runSelfTestWorkerInProcess); err == nil || !strings.Contains(err.Error(), "does not match E0 invocation") { - t.Fatalf("route/capability relabel error = %v", err) - } - if output.Len() != 0 { - t.Fatalf("relabel emitted receipt %q", output.String()) - } - if err := run([]string{"-route", routeCAR, "-self-test-corpus", corpus, "-base-url", "http://127.0.0.1"}, bytes.NewReader(nil), &bytes.Buffer{}, &bytes.Buffer{}); err == nil { - t.Fatal("formal self-test accepted runtime Gateway flags") - } -} - -func TestClientRouteSelfTestProfileDigestsAreStable(t *testing.T) { - want := map[string]string{ - routeCAR: "89fbb5b13a51fe0b1a2fdc867f0aa27cb5f9be86aaaa0ef9eaf3239769e799f2", - routeDirectCAS: "04cd42d5dfeef2607600e0594ab11fbc1edf05ca563e752006d0cfc42f5d4d1a", - } - for route, profile := range clientRouteSelfTestProfiles { - contract, err := profile.Contract() - if err != nil { - t.Fatal(err) - } - if contract.ProfileSHA256 != want[route] { - t.Fatalf("%s profile digest = %s", route, contract.ProfileSHA256) - } - } -} - -func TestClientRouteSelfTestCorpusRejectsDrift(t *testing.T) { - path := writeClientRouteSelfTestCorpus(t) - corpus, err := loadClientRouteSelfTestCorpus(path) - if err != nil { - t.Fatal(err) - } - if _, _, err := corpus.validate(); err != nil { - t.Fatal(err) - } - corpus.Segments = []string{"payload.bin"} - if _, _, err := corpus.validate(); err == nil { - t.Fatal("client-route corpus accepted a changed semantic path") - } - corpus, err = loadClientRouteSelfTestCorpus(path) - if err != nil { - t.Fatal(err) - } - corpus.DirectCASPayloadB64 = corpus.TrustlessCARPayloadB64 - if _, _, err := corpus.validate(); err == nil { - t.Fatal("client-route corpus accepted route-aliased payloads") - } -} - -func writeClientRouteSelfTestCorpus(t *testing.T) string { - t.Helper() - value := clientRouteSelfTestCorpus{ - SchemaVersion: clientRouteSelfTestCorpusSchema, - TrustlessCARPayloadB64: base64.StdEncoding.EncodeToString([]byte("unit fixture for CAR verification")), - DirectCASPayloadB64: base64.StdEncoding.EncodeToString([]byte("unit fixture for direct CAS replay")), - Segments: []string{"docs", "payload.bin"}, ChunkBytes: 5, - } - data, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "rq1-client-route-corpus.json") - if err := os.WriteFile(path, data, 0o600); err != nil { - t.Fatal(err) - } - return path -} diff --git a/tools/evaluation/cmd/malt-eval-rq1-worker/usage_other.go b/tools/evaluation/cmd/malt-eval-rq1-worker/usage_other.go deleted file mode 100644 index decffcb..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-worker/usage_other.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris - -package main - -import "fmt" - -type processUsage struct { - userCPUNS uint64 - systemCPUNS uint64 - peakRSSBytes uint64 -} - -func readProcessUsage() (processUsage, error) { - return processUsage{}, fmt.Errorf("client CPU and peak RSS accounting is unavailable on this platform") -} diff --git a/tools/evaluation/cmd/malt-eval-rq1-worker/usage_unix.go b/tools/evaluation/cmd/malt-eval-rq1-worker/usage_unix.go deleted file mode 100644 index 3fd9f1a..0000000 --- a/tools/evaluation/cmd/malt-eval-rq1-worker/usage_unix.go +++ /dev/null @@ -1,34 +0,0 @@ -//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris - -package main - -import "golang.org/x/sys/unix" - -type processUsage struct { - userCPUNS uint64 - systemCPUNS uint64 - peakRSSBytes uint64 -} - -func readProcessUsage() (processUsage, error) { - var value unix.Rusage - if err := unix.Getrusage(unix.RUSAGE_SELF, &value); err != nil { - return processUsage{}, err - } - peak := uint64(value.Maxrss) - if unixRSSIsKiB { - peak *= 1024 - } - return processUsage{ - userCPUNS: timevalNS(value.Utime), - systemCPUNS: timevalNS(value.Stime), - peakRSSBytes: peak, - }, nil -} - -func timevalNS(value unix.Timeval) uint64 { - if value.Sec < 0 || value.Usec < 0 { - return 0 - } - return uint64(value.Sec)*1_000_000_000 + uint64(value.Usec)*1_000 -} diff --git a/tools/evaluation/cmd/malt-eval-rq2-browser-wasm/main.go b/tools/evaluation/cmd/malt-eval-rq2-browser-wasm/main.go deleted file mode 100644 index e627128..0000000 --- a/tools/evaluation/cmd/malt-eval-rq2-browser-wasm/main.go +++ /dev/null @@ -1,499 +0,0 @@ -//go:build js && wasm - -// Command malt-eval-rq2-browser-wasm runs retained typed authentication Writers -// inside a browser. Its Promise-based JSON RPC keeps candidate verification, -// payload CID binding, exact receipt validation, and retained-root state live -// inside WebAssembly rather than in the native browser driver. -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "syscall/js" - "time" - - "github.com/dewebprotocol/malt-client/internal/evaluation/authenticationgraph" - "github.com/dewebprotocol/malt-client/internal/evaluation/gatewaytransport" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2fixture" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2metrics" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2wire" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2write" - "github.com/dewebprotocol/malt-client/transport" - "github.com/dewebprotocol/malt-core/auth/commitment/ipa" - "github.com/dewebprotocol/malt-core/auth/commitment/kzg" - "github.com/dewebprotocol/malt-core/engine" - cid "github.com/ipfs/go-cid" - mh "github.com/multiformats/go-multihash" -) - -const ( - maxBrowserFixtureBytes = 32 << 20 -) - -var ( - registeredFunctions []js.Func - state browserWriter -) - -type initializeRequest struct { - GatewayBaseURL string `json:"gateway_base_url"` - GatewayInstanceToken string `json:"gateway_instance_token"` - FixtureURL string `json:"fixture_url"` - Backend string `json:"backend"` -} - -type initializeResponse struct { - ParameterLoadNS uint64 `json:"parameter_load_ns"` - ParameterLoadBytes uint64 `json:"parameter_load_bytes"` - ParameterProfile string `json:"parameter_profile"` - ParameterSHA256 string `json:"parameter_sha256"` - FixtureBytes uint64 `json:"fixture_bytes"` - FixtureSHA256 string `json:"fixture_sha256"` -} - -type browserWriter struct { - initialized bool - backend string - remote *transport.Client - app *authenticationgraph.Session - fixture *rq2fixture.Fixture - source map[string][]byte - root cid.Cid - serial uint64 - receiptCount uint64 - sessionID string -} - -func main() { - initialize := js.FuncOf(func(_ js.Value, args []js.Value) any { - return newPromise(args, func(raw string) (string, error) { - var request initializeRequest - if err := strictJSON([]byte(raw), &request); err != nil { - return "", err - } - result, err := state.initialize(request) - if err != nil { - return "", err - } - return marshalJSON(result) - }) - }) - exchange := js.FuncOf(func(_ js.Value, args []js.Value) any { - return newPromise(args, func(raw string) (string, error) { - started := time.Now() - var request rq2wire.WorkerRequest - if err := strictJSON([]byte(raw), &request); err != nil { - return "", err - } - if err := request.Validate(); err != nil { - return "", err - } - record := state.exchange(request) - result, err := marshalJSON(record) - js.Global().Set("maltRQ2LastExecutionNS", durationNS(time.Since(started))) - return result, err - }) - }) - registeredFunctions = append(registeredFunctions, initialize, exchange) - js.Global().Set("maltRQ2Initialize", initialize) - js.Global().Set("maltRQ2Exchange", exchange) - js.Global().Set("maltRQ2LastExecutionNS", uint64(0)) - js.Global().Set("maltRQ2WASMReady", true) - select {} -} - -func newPromise(args []js.Value, run func(string) (string, error)) js.Value { - promise := js.Global().Get("Promise") - if len(args) != 1 || args[0].Type() != js.TypeString { - return promise.Call("reject", "RQ2 WASM RPC requires one JSON string") - } - raw := args[0].String() - var executor js.Func - executor = js.FuncOf(func(_ js.Value, callbacks []js.Value) any { - resolve, reject := callbacks[0], callbacks[1] - go func() { - result, err := run(raw) - if err != nil { - reject.Invoke(err.Error()) - return - } - resolve.Invoke(result) - }() - return nil - }) - value := promise.New(executor) - executor.Release() - return value -} - -func (w *browserWriter) initialize(request initializeRequest) (initializeResponse, error) { - if w.initialized { - return initializeResponse{}, fmt.Errorf("RQ2 WASM writer is already initialized") - } - if request.Backend != "kzg" && request.Backend != "ipa" || request.GatewayBaseURL == "" || request.FixtureURL == "" || !canonicalSHA256(request.GatewayInstanceToken) { - return initializeResponse{}, fmt.Errorf("RQ2 WASM initialization is incomplete") - } - fixture, err := fetchFixture(request.FixtureURL) - if err != nil { - return initializeResponse{}, err - } - sourceFixture, err := rq2fixture.Decode(fixture) - if err != nil { - return initializeResponse{}, err - } - operations := make([]string, 0, len(browserOperations)) - for operation := range browserOperations { - operations = append(operations, operation) - } - if _, err := sourceFixture.Root(request.Backend); err != nil { - return initializeResponse{}, err - } - if err := sourceFixture.RequireOperations(operations); err != nil { - return initializeResponse{}, err - } - started := time.Now() - var scheme engine.Profile - if request.Backend == "kzg" { - scheme, err = kzg.NewScheme() - } else { - scheme, err = ipa.NewScheme() - } - parameterLoadNS := durationNS(time.Since(started)) - if err != nil { - return initializeResponse{}, fmt.Errorf("initialize %s parameters: %w", request.Backend, err) - } - parameterProfile, parameterSHA256, parameterBytes, ok := rq2wire.ParameterEvidence(request.Backend) - if !ok { - return initializeResponse{}, fmt.Errorf("commitment parameter provenance is unavailable") - } - evaluation, err := gatewaytransport.New(gatewaytransport.Options{ - BaseURL: request.GatewayBaseURL, InstanceToken: request.GatewayInstanceToken, - HTTPClient: &http.Client{Timeout: 24 * time.Hour}, - }) - if err != nil { - return initializeResponse{}, err - } - remote, err := transport.New(transport.Options{ - BaseURL: request.GatewayBaseURL, HTTPClient: evaluation.InstanceHTTPClient(), - }) - if err != nil { - return initializeResponse{}, err - } - health, err := evaluation.Health(context.Background()) - if err != nil || health.Status != "ok" || health.EvaluationInstanceToken != request.GatewayInstanceToken { - return initializeResponse{}, fmt.Errorf("browser Gateway health did not echo the exact disposable instance token") - } - registry := engine.NewRegistry() - if err := registry.Register(scheme); err != nil { - return initializeResponse{}, err - } - app, err := authenticationgraph.New(evaluation, engine.New(registry)) - if err != nil { - return initializeResponse{}, err - } - digest := sha256.Sum256(fixture) - *w = browserWriter{ - initialized: true, backend: request.Backend, remote: remote, app: app, - fixture: sourceFixture, - } - return initializeResponse{ - ParameterLoadNS: parameterLoadNS, ParameterLoadBytes: parameterBytes, - ParameterProfile: parameterProfile, ParameterSHA256: parameterSHA256, - FixtureBytes: uint64(len(fixture)), FixtureSHA256: hex.EncodeToString(digest[:]), - }, nil -} - -func fetchFixture(url string) ([]byte, error) { - request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) - if err != nil { - return nil, err - } - request.Header.Set("Cache-Control", "no-store") - response, err := http.DefaultClient.Do(request) - if err != nil { - return nil, fmt.Errorf("fetch browser fixture: %w", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK || !strings.Contains(response.Header.Get("Cache-Control"), "no-store") { - return nil, fmt.Errorf("browser fixture response is not a non-cacheable success") - } - data, err := io.ReadAll(io.LimitReader(response.Body, maxBrowserFixtureBytes+1)) - if err != nil { - return nil, err - } - if len(data) == 0 || len(data) > maxBrowserFixtureBytes { - return nil, fmt.Errorf("browser fixture size is outside 1..%d", maxBrowserFixtureBytes) - } - return data, nil -} - -func (w *browserWriter) exchange(request rq2wire.WorkerRequest) rq2wire.WorkerRecord { - if !w.initialized || request.ClientKind != rq2wire.ClientBrowserWASM || request.Backend != w.backend { - return rq2wire.FailedRecord(request, "capability_unavailable", fmt.Errorf("WASM writer coordinate was not initialized")) - } - switch request.RecordKind { - case rq2wire.RecordSessionStart: - return w.start(request) - case rq2wire.RecordMutation: - return w.mutate(request) - case rq2wire.RecordSessionEnd: - return w.end(request) - default: - return rq2wire.FailedRecord(request, "input_invalid", fmt.Errorf("preflight is owned by the browser host")) - } -} - -func (w *browserWriter) start(request rq2wire.WorkerRequest) rq2wire.WorkerRecord { - if w.root.Defined() || w.sessionID != "" { - return rq2wire.FailedRecord(request, "session_state", fmt.Errorf("browser session already started")) - } - root, err := cid.Parse(request.ExpectedAcceptedRoot) - if err != nil || !rq2wire.ValidTypedRoot(root.String(), w.backend) { - return rq2wire.FailedRecord(request, "input_invalid", fmt.Errorf("browser root is not a typed %s root", w.backend)) - } - if w.fixture == nil || request.FixtureID != w.fixture.FixtureID { - return rq2wire.FailedRecord(request, "fixture_incompatible", fmt.Errorf("browser request fixture identity does not match the pinned source fixture")) - } - load, err := w.app.Load(context.Background(), root) - if err != nil { - return rq2wire.FailedRecord(request, classifyFailure(err), err) - } - view, err := w.app.Snapshot() - if err != nil || w.fixture.ValidateInitialGraph(view, w.backend) != nil { - if err == nil { - err = w.fixture.ValidateInitialGraph(view, w.backend) - } - return rq2wire.FailedRecord(request, "fixture_incompatible", err) - } - w.root, w.sessionID, w.source = root, request.SessionID, w.fixture.InitialSource() - record := rq2wire.BaseRecord(request) - record.Success = true - record.Session = &rq2wire.SessionEvidence{AcceptedRoot: root.String(), GraphFetch: measured(load.FetchNS, load.WireBytes, load.Objects), GraphImport: measured(load.ImportNS, load.WireBytes, load.Objects)} - return record -} - -var browserOperations = map[string]struct{}{ - "document-edit-cid-binding-submit": {}, "list-append": {}, "list-replace": {}, "map-insert": {}, "map-replace": {}, -} - -func (w *browserWriter) mutate(request rq2wire.WorkerRequest) rq2wire.WorkerRecord { - if _, ok := browserOperations[request.Operation]; !ok { - return rq2wire.FailedRecord(request, "operation_unsupported", fmt.Errorf("unsupported browser operation %q", request.Operation)) - } - if request.SessionID != w.sessionID || !w.root.Defined() || request.ExpectedAcceptedRoot != w.root.String() { - return rq2wire.FailedRecord(request, "root_continuity", fmt.Errorf("browser request does not match retained root/session")) - } - mutationStarted := time.Now() - viewSnapshotStarted := time.Now() - view, err := w.app.Snapshot() - viewSnapshotNS := durationNS(time.Since(viewSnapshotStarted)) - if err != nil { - return rq2wire.FailedRecord(request, "verification_failed", err) - } - if err := w.fixture.ValidateGraphAgainstSource(view, w.backend, w.source); err != nil { - return rq2wire.FailedRecord(request, "fixture_incompatible", fmt.Errorf("browser source pre-image binding: %w", err)) - } - operation, err := w.fixture.Operation(request.Operation) - if err != nil { - return rq2wire.FailedRecord(request, "fixture_incompatible", err) - } - prepared, err := w.prepare(operation) - if err != nil { - return rq2wire.FailedRecord(request, "fixture_incompatible", err) - } - payloadUpload, err := w.remote.PutBatchMeasured(context.Background(), []transport.Block{{Codec: cid.Raw, Data: prepared.payload}}) - results := payloadUpload.Results - if err != nil || len(results) != 1 || !results[0].CID.Equals(prepared.payloadCID) { - if err == nil { - err = fmt.Errorf("browser CAS upload did not return the exact local payload CID") - } - return rq2wire.FailedRecord(request, classifyFailure(err), err) - } - projectionStarted := time.Now() - edit, err := w.app.Begin() - if err != nil { - return rq2wire.FailedRecord(request, "verification_failed", err) - } - candidate, err := rq2write.Apply(context.Background(), edit, w.root, operation, []cid.Cid{prepared.payloadCID}) - projectionNS := durationNS(time.Since(projectionStarted)) - if err != nil { - return rq2wire.FailedRecord(request, "projection_invalid", err) - } - result, err := w.app.Submit(context.Background(), operationID(request), edit, candidate) - if err != nil { - return rq2wire.FailedRecord(request, classifyFailure(err), err) - } - if result.Idempotent { - return rq2wire.FailedRecord(request, "gateway_instance_reused", fmt.Errorf("disposable Gateway returned an idempotent authentication batch replay")) - } - mutationTotalNS := durationNS(time.Since(mutationStarted)) - postView, err := w.app.Snapshot() - if err != nil { - return rq2wire.FailedRecord(request, "verification_failed", fmt.Errorf("snapshot browser post-image: %w", err)) - } - if err := w.fixture.ValidateGraphAgainstSource(postView, w.backend, prepared.postSource); err != nil { - return rq2wire.FailedRecord(request, "verification_failed", fmt.Errorf("browser full source post-image oracle: %w", err)) - } - encodedCandidate := result.Root.String() - parsedCandidate, err := cid.Parse(encodedCandidate) - if err != nil || !parsedCandidate.Equals(result.Root) { - return rq2wire.FailedRecord(request, "verification_failed", fmt.Errorf("expected-root CID encoding did not round-trip")) - } - record := rq2wire.BaseRecord(request) - record.Success = true - metrics, err := browserMetrics(prepared, result, viewSnapshotNS, projectionNS, payloadUpload, mutationTotalNS) - if err != nil { - return rq2wire.FailedRecord(request, "measurement_invalid", err) - } - record.Mutation = &rq2wire.MutationEvidence{ - Operation: request.Operation, PriorRoot: result.Base.String(), CandidateRoot: encodedCandidate, - ReceiptRoot: result.Receipt.Root, ReceiptAccepted: result.Receipt.Root == result.Root.String(), - BatchSHA256: result.Receipt.Digest, - Metrics: metrics, - } - w.root = result.Root - w.source = prepared.postSource - w.serial++ - w.receiptCount++ - return record -} - -func (w *browserWriter) end(request rq2wire.WorkerRequest) rq2wire.WorkerRecord { - if request.SessionID != w.sessionID || !w.root.Defined() || request.ExpectedAcceptedRoot != w.root.String() { - return rq2wire.FailedRecord(request, "root_continuity", fmt.Errorf("browser session-end root does not match retained root")) - } - if err := w.app.Audit(context.Background()); err != nil { - return rq2wire.FailedRecord(request, "audit_failed", err) - } - record := rq2wire.BaseRecord(request) - record.Success = true - record.Session = &rq2wire.SessionEvidence{AcceptedRoot: w.root.String(), ReceiptCount: w.receiptCount, AuditPassed: true, GraphFetch: rq2wire.NotApplicablePhase(), GraphImport: rq2wire.NotApplicablePhase()} - return record -} - -type browserPayload struct { - payload []byte - payloadCID cid.Cid - postSource map[string][]byte - scan rq2wire.PhaseMeasurement - chunk rq2wire.PhaseMeasurement - hash rq2wire.PhaseMeasurement -} - -func (w *browserWriter) prepare(operation rq2fixture.Operation) (browserPayload, error) { - prepared := browserPayload{ - scan: rq2wire.NotApplicablePhase(), chunk: rq2wire.NotApplicablePhase(), hash: rq2wire.NotApplicablePhase(), - } - postSource, payloads, err := w.fixture.ApplySourceOperation(w.source, operation, w.serial) - if err != nil || len(payloads) != 1 { - if err == nil { - err = fmt.Errorf("browser operation must produce exactly one changed payload") - } - return browserPayload{}, err - } - prepared.postSource, prepared.payload = postSource, payloads[0] - if operation.Kind == rq2fixture.KindDocumentEdit { - scanStarted := time.Now() - body := append([]byte(nil), prepared.payload...) - prepared.scan = measured(durationNS(time.Since(scanStarted)), uint64(len(body)), 1) - chunkStarted := time.Now() - chunkSize := 256 << 10 - chunkCount := max(1, (len(body)+chunkSize-1)/chunkSize) - for offset := 0; offset < len(body); offset += chunkSize { - _ = append([]byte(nil), body[offset:min(len(body), offset+chunkSize)]...) - } - prepared.chunk = measured(durationNS(time.Since(chunkStarted)), uint64(len(body)), uint64(chunkCount)) - } - hashStarted := time.Now() - digest, err := mh.Sum(prepared.payload, mh.SHA2_256, -1) - if err != nil { - return browserPayload{}, err - } - prepared.payloadCID = cid.NewCidV1(cid.Raw, digest) - prepared.hash = measured(durationNS(time.Since(hashStarted)), uint64(len(prepared.payload)), 1) - return prepared, nil -} - -func browserMetrics(payload browserPayload, result authenticationgraph.Result, snapshotNS, projectionNS uint64, payloadUpload transport.PutBatchMeasurement, mutationTotalNS uint64) (rq2wire.MutationMetrics, error) { - phase := func(duration, bytes, count uint64) rq2wire.PhaseMeasurement { - return measured(duration, bytes, max(uint64(1), count)) - } - generation, err := rq2metrics.AddDurations(snapshotNS, projectionNS) - if err != nil { - return rq2wire.MutationMetrics{}, err - } - computation, submission := result.Computation, result.Submission - return rq2wire.MutationMetrics{ - TaxonomyProfile: rq2metrics.TaxonomyProfile, MutationTotal: phase(mutationTotalNS, 0, 1), Scan: payload.scan, Chunk: payload.chunk, Hash: payload.hash, - GraphSnapshot: phase(snapshotNS, 0, 1), CandidateApply: phase(computation.ApplyNS, 0, computation.Candidates), CandidateExport: phase(computation.ExportNS, computation.CandidateBytes, computation.Candidates), CandidateGeneration: phase(generation, 0, 1), - BatchEncoding: phase(submission.RequestEncodingNS, submission.RequestWireBytes, 1), Upload: phase(payloadUpload.RoundTripNS, payloadUpload.RequestWireBytes, 1), - GatewayValidateStage: phase(submission.Gateway.ValidationAndStageNS, 0, 1), GatewayPersist: phase(submission.Gateway.PersistNS, 0, 1), ReceiptCheck: phase(submission.ResponseVerifyNS, submission.ResponseWireBytes, 1), - CPUTotal: rq2wire.NotApplicablePhase(), PeakMemory: rq2wire.NotApplicablePhase(), WASMDownload: rq2wire.NotApplicablePhase(), WASMInstantiate: rq2wire.NotApplicablePhase(), ParameterLoad: rq2wire.NotApplicablePhase(), FirstMutation: rq2wire.NotApplicablePhase(), JSWASMBoundary: rq2wire.NotApplicablePhase(), - }, nil -} - -func measured(duration, bytes, count uint64) rq2wire.PhaseMeasurement { - return rq2wire.ObservedPhase(duration, bytes, count) -} - -func canonicalSHA256(value string) bool { - if len(value) != 64 { - return false - } - for _, char := range value { - if char < '0' || char > '9' { - if char < 'a' || char > 'f' { - return false - } - } - } - return true -} - -func operationID(request rq2wire.WorkerRequest) string { - digest := sha256.Sum256([]byte(request.WorkerID + "\x00" + request.SessionID + "\x00" + request.RequestID + "\x00" + request.Operation)) - return "rq2-" + hex.EncodeToString(digest[:16]) -} - -func classifyFailure(err error) string { - var transportError *transport.Error - if errors.As(err, &transportError) { - return "gateway_rejected" - } - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return "timeout" - } - return "authentication_failed" -} - -func durationNS(value time.Duration) uint64 { - if value <= 0 { - return 0 - } - return uint64(value) -} - -func strictJSON(data []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return fmt.Errorf("JSON contains trailing data") - } - return nil -} - -func marshalJSON(value any) (string, error) { - data, err := json.Marshal(value) - return string(data), err -} diff --git a/tools/evaluation/cmd/malt-eval-rq2-browser-wasm/main_unsupported.go b/tools/evaluation/cmd/malt-eval-rq2-browser-wasm/main_unsupported.go deleted file mode 100644 index 8473209..0000000 --- a/tools/evaluation/cmd/malt-eval-rq2-browser-wasm/main_unsupported.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !js || !wasm - -package main - -import "fmt" - -func main() { - fmt.Println("malt-eval-rq2-browser-wasm must be built with GOOS=js GOARCH=wasm") -} diff --git a/tools/evaluation/cmd/malt-eval-rq2-browser-worker/artifacts.go b/tools/evaluation/cmd/malt-eval-rq2-browser-worker/artifacts.go deleted file mode 100644 index f5e7ced..0000000 --- a/tools/evaluation/cmd/malt-eval-rq2-browser-worker/artifacts.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "os" - "path/filepath" -) - -const ( - maxFixtureBytes = 32 << 20 - maxWASMBytes = 256 << 20 - maxSupportBytes = 8 << 20 -) - -type artifactPin struct { - path string - sha256 string - bytes int64 - executable bool - allowSymlink bool - maxBytes int64 -} - -type artifactSnapshot struct { - pin artifactPin - resolvedPath string - symlink string -} - -func loadPinnedArtifact(pin artifactPin, retain bool) (artifactSnapshot, []byte, error) { - if !filepath.IsAbs(pin.path) || !canonicalSHA256(pin.sha256) || pin.bytes <= 0 || pin.maxBytes <= 0 || pin.bytes > pin.maxBytes { - return artifactSnapshot{}, nil, fmt.Errorf("invalid artifact pin for %q", pin.path) - } - lstat, err := os.Lstat(pin.path) - if err != nil { - return artifactSnapshot{}, nil, err - } - symlink := "" - resolved := pin.path - if lstat.Mode()&os.ModeSymlink != 0 { - if !pin.allowSymlink { - return artifactSnapshot{}, nil, fmt.Errorf("artifact %q must not be a symlink", pin.path) - } - symlink, err = os.Readlink(pin.path) - if err != nil { - return artifactSnapshot{}, nil, err - } - resolved, err = filepath.EvalSymlinks(pin.path) - if err != nil { - return artifactSnapshot{}, nil, err - } - } else if !lstat.Mode().IsRegular() { - return artifactSnapshot{}, nil, fmt.Errorf("artifact %q is not a regular file", pin.path) - } - file, err := os.Open(resolved) - if err != nil { - return artifactSnapshot{}, nil, err - } - defer file.Close() - opened, err := file.Stat() - if err != nil || !opened.Mode().IsRegular() { - return artifactSnapshot{}, nil, fmt.Errorf("artifact %q did not open as a regular file", pin.path) - } - if pin.executable && opened.Mode()&0o111 == 0 { - return artifactSnapshot{}, nil, fmt.Errorf("artifact %q is not executable", pin.path) - } - hash := sha256.New() - var data []byte - var writer io.Writer = hash - if retain { - data = make([]byte, 0, pin.bytes) - writer = io.MultiWriter(hash, byteAppender{target: &data}) - } - written, err := io.Copy(writer, io.LimitReader(file, pin.maxBytes+1)) - if err != nil { - return artifactSnapshot{}, nil, err - } - if written != pin.bytes || written != opened.Size() || hex.EncodeToString(hash.Sum(nil)) != pin.sha256 { - return artifactSnapshot{}, nil, fmt.Errorf("artifact %q does not match its SHA-256/size pin", pin.path) - } - return artifactSnapshot{pin: pin, resolvedPath: resolved, symlink: symlink}, data, nil -} - -func (s artifactSnapshot) verify() error { - current, _, err := loadPinnedArtifact(s.pin, false) - if err != nil { - return err - } - if current.resolvedPath != s.resolvedPath || current.symlink != s.symlink { - return fmt.Errorf("artifact %q changed its resolved identity", s.pin.path) - } - return nil -} - -type byteAppender struct{ target *[]byte } - -func (w byteAppender) Write(value []byte) (int, error) { - *w.target = append(*w.target, value...) - return len(value), nil -} - -func readFixture(path string) ([]byte, error) { - if !filepath.IsAbs(path) { - return nil, fmt.Errorf("fixture path must be absolute") - } - lstat, err := os.Lstat(path) - if err != nil || lstat.Mode()&os.ModeSymlink != 0 || !lstat.Mode().IsRegular() { - return nil, fmt.Errorf("RQ2 browser fixture is not a regular non-symlink file: %v", err) - } - if lstat.Size() <= 0 || lstat.Size() > maxFixtureBytes { - return nil, fmt.Errorf("RQ2 browser fixture size must be in 1..%d bytes", maxFixtureBytes) - } - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - opened, err := file.Stat() - if err != nil || !opened.Mode().IsRegular() || !os.SameFile(lstat, opened) { - return nil, fmt.Errorf("RQ2 browser fixture changed before it was opened") - } - data, err := io.ReadAll(io.LimitReader(file, maxFixtureBytes+1)) - if err != nil { - return nil, err - } - post, err := os.Lstat(path) - if err != nil || !os.SameFile(lstat, post) || post.Mode()&os.ModeSymlink != 0 || post.Size() != lstat.Size() || int64(len(data)) != lstat.Size() { - return nil, fmt.Errorf("RQ2 browser fixture changed while it was read") - } - return data, nil -} diff --git a/tools/evaluation/cmd/malt-eval-rq2-browser-worker/browser.go b/tools/evaluation/cmd/malt-eval-rq2-browser-worker/browser.go deleted file mode 100644 index abd66d6..0000000 --- a/tools/evaluation/cmd/malt-eval-rq2-browser-worker/browser.go +++ /dev/null @@ -1,669 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net" - "net/http" - "net/http/httputil" - "net/url" - "os" - "os/exec" - "regexp" - "runtime" - "slices" - "strings" - "sync" - "time" - - "github.com/dewebprotocol/malt-client/internal/evaluation/machine" - "github.com/dewebprotocol/malt-client/internal/evaluation/rq2wire" -) - -const ( - maxBrowserLogBytes = 64 << 10 - browserForceTerminationBound = 2 * time.Second -) - -var ( - devtoolsPattern = regexp.MustCompile(`DevTools listening on (ws://[^[:space:]]+)`) - versionPattern = regexp.MustCompile(`^(Chromium|Google Chrome(?: for Testing)?) ([0-9]+(?:\.[0-9]+){1,3})(?:[[:space:]].*)?$`) -) - -type browserLaunchConfig struct { - path string - arguments []string - engine string - timeout time.Duration - gatewayURL string - token string - backend string - wasmSHA256 string -} - -type coldStartMetrics struct { - WASMDownloadNS uint64 - WASMDownloadBytes uint64 - WASMInstantiateNS uint64 - ParameterLoadNS uint64 - ParameterBytes uint64 - ParameterProfile string - ParameterSHA256 string -} - -type browserCall struct { - RecordJSON string `json:"record_json"` - CallNS uint64 `json:"call_ns"` - BoundaryNS uint64 `json:"boundary_ns"` - HeapBytes uint64 `json:"heap_bytes"` - WASMMemory uint64 `json:"wasm_memory_bytes"` -} - -type browserStatus struct { - Phase string `json:"phase"` - Error string `json:"error,omitempty"` - WASMSHA256 string `json:"wasm_sha256,omitempty"` - DownloadNS uint64 `json:"download_ns,omitempty"` - DownloadByte uint64 `json:"download_bytes,omitempty"` - Instantiate uint64 `json:"instantiate_ns,omitempty"` - Initialize *struct { - ParameterLoadNS uint64 `json:"parameter_load_ns"` - ParameterLoadBytes uint64 `json:"parameter_load_bytes"` - ParameterProfile string `json:"parameter_profile"` - ParameterSHA256 string `json:"parameter_sha256"` - FixtureBytes uint64 `json:"fixture_bytes"` - FixtureSHA256 string `json:"fixture_sha256"` - } `json:"initialize,omitempty"` -} - -type browserSession struct { - command *exec.Cmd - process *processCompletion - stderr *boundedLog - cdp *cdpClient - server *http.Server - listener net.Listener - profile string - engine string - cold coldStartMetrics - processID int - closeOnce sync.Once - closeErr error -} - -type processCompletion struct { - done chan struct{} - err error -} - -func detectBrowserEngine(ctx context.Context, config browserLaunchConfig) (string, error) { - arguments := append([]string(nil), config.arguments...) - arguments = append(arguments, "--version") - command := exec.CommandContext(ctx, config.path, arguments...) - output, err := command.CombinedOutput() - if err != nil { - return "", fmt.Errorf("run browser version probe: %w: %s", err, boundedText(output, 1024)) - } - engine, err := parseBrowserEngineOutput(string(output)) - if err != nil { - return "", err - } - if config.engine != engine { - return "", fmt.Errorf("browser engine/version is %q, registered %q", engine, config.engine) - } - return engine, nil -} - -func parseBrowserEngineOutput(output string) (string, error) { - line := strings.TrimSpace(output) - matches := versionPattern.FindStringSubmatch(line) - if len(matches) != 3 { - return "", fmt.Errorf("unsupported browser version output %q", boundedText([]byte(output), 1024)) - } - prefix := "chromium-" - if strings.HasPrefix(matches[1], "Google Chrome") { - prefix = "chrome-" - } - return prefix + matches[2], nil -} - -func startBrowser(ctx context.Context, config browserLaunchConfig, fixture, wasm, wasmExec []byte) (*browserSession, error) { - // The version subprocess is part of browser startup and must obey the same - // bound as DevTools discovery and WASM initialization. The Chromium session - // itself remains explicitly owned and closed by browserSession. - startupContext, cancel := context.WithTimeout(ctx, config.timeout) - defer cancel() - engine, err := detectBrowserEngine(startupContext, config) - if err != nil { - return nil, err - } - tokenBytes := make([]byte, 16) - if _, err := rand.Read(tokenBytes); err != nil { - return nil, err - } - prefix := "/rq2-" + hex.EncodeToString(tokenBytes) - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return nil, err - } - failed := true - defer func() { - if failed { - _ = listener.Close() - } - }() - baseURL := "http://" + listener.Addr().String() + prefix - page, err := browserPage(browserPageConfig{ - GatewayURL: baseURL + "/gateway", FixtureURL: baseURL + "/fixture", WASMURL: baseURL + "/writer.wasm", - WASMSHA256: config.wasmSHA256, GatewayToken: config.token, Backend: config.backend, - }) - if err != nil { - return nil, err - } - target, err := url.Parse(config.gatewayURL) - if err != nil { - return nil, err - } - proxy := httputil.NewSingleHostReverseProxy(target) - proxy.ErrorLog = log.New(io.Discard, "", 0) - proxy.ErrorHandler = func(response http.ResponseWriter, _ *http.Request, cause error) { - response.Header().Set("Cache-Control", "no-store") - http.Error(response, "Gateway proxy failed: "+cause.Error(), http.StatusBadGateway) - } - mux := http.NewServeMux() - serveBytes := func(contentType string, data []byte) http.HandlerFunc { - return func(response http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - response.Header().Set("Allow", http.MethodGet) - http.Error(response, "method not allowed", http.StatusMethodNotAllowed) - return - } - response.Header().Set("Cache-Control", "no-store, max-age=0") - response.Header().Set("Content-Type", contentType) - response.Header().Set("X-Content-Type-Options", "nosniff") - _, _ = response.Write(data) - } - } - mux.HandleFunc(prefix+"/", serveBytes("text/html; charset=utf-8", page)) - mux.HandleFunc(prefix+"/wasm_exec.js", serveBytes("text/javascript; charset=utf-8", wasmExec)) - mux.HandleFunc(prefix+"/writer.wasm", serveBytes("application/wasm", wasm)) - mux.HandleFunc(prefix+"/fixture", serveBytes("application/octet-stream", fixture)) - mux.Handle(prefix+"/gateway/", http.StripPrefix(prefix+"/gateway", proxy)) - server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, ErrorLog: log.New(io.Discard, "", 0)} - serveErr := make(chan error, 1) - go func() { - serveErr <- server.Serve(listener) - }() - - profile, err := os.MkdirTemp("", "malt-rq2-browser-profile-") - if err != nil { - _ = server.Close() - return nil, err - } - arguments := append([]string(nil), config.arguments...) - arguments = append(arguments, - "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-background-networking", - "--disable-component-update", "--disable-default-apps", "--disable-extensions", "--disable-sync", - "--enable-precise-memory-info", "--metrics-recording-only", "--no-first-run", "--password-store=basic", - "--remote-allow-origins=*", "--remote-debugging-port=0", "--user-data-dir="+profile, - baseURL+"/", - ) - command := exec.CommandContext(ctx, config.path, arguments...) - configureBrowserProcess(command) - command.Stdout = io.Discard - stderr := &boundedLog{limit: maxBrowserLogBytes} - stderrPipe, err := command.StderrPipe() - if err != nil { - _ = server.Close() - _ = os.RemoveAll(profile) - return nil, err - } - if err := command.Start(); err != nil { - _ = server.Close() - _ = os.RemoveAll(profile) - return nil, err - } - process := &processCompletion{done: make(chan struct{})} - go func() { - process.err = command.Wait() - close(process.done) - }() - devtoolsURL := make(chan string, 1) - go scanBrowserLog(stderrPipe, stderr, devtoolsURL) - session := &browserSession{ - command: command, process: process, stderr: stderr, server: server, listener: listener, profile: profile, - engine: engine, processID: command.Process.Pid, - } - var browserWebsocket string - select { - case browserWebsocket = <-devtoolsURL: - case <-process.done: - _ = session.close() - return nil, fmt.Errorf("browser exited before DevTools became ready: %v%s", process.err, formatBrowserLog(stderr)) - case err := <-serveErr: - _ = session.close() - return nil, fmt.Errorf("browser host HTTP server failed: %w", err) - case <-startupContext.Done(): - _ = session.close() - return nil, fmt.Errorf("browser DevTools startup: %w%s", startupContext.Err(), formatBrowserLog(stderr)) - } - pageWebsocket, err := findPageWebsocket(startupContext, browserWebsocket, baseURL+"/") - if err != nil { - _ = session.close() - return nil, fmt.Errorf("find browser page: %w%s", err, formatBrowserLog(stderr)) - } - client, err := newCDPClient(startupContext, pageWebsocket) - if err != nil { - _ = session.close() - return nil, fmt.Errorf("connect browser DevTools: %w", err) - } - session.cdp = client - if err := client.call(startupContext, "Performance.enable", map[string]any{}, nil); err != nil { - _ = session.close() - return nil, err - } - status, err := waitBrowserReady(startupContext, client) - if err != nil { - _ = session.close() - return nil, fmt.Errorf("initialize real browser/WASM writer: %w%s", err, formatBrowserLog(stderr)) - } - fixtureDigest := sha256.Sum256(fixture) - parameterProfile, parameterSHA256, parameterBytes, parameterOK := rq2wire.ParameterEvidence(config.backend) - if status.WASMSHA256 != config.wasmSHA256 || status.DownloadByte != uint64(len(wasm)) || status.DownloadNS == 0 || status.Instantiate == 0 || - status.Initialize == nil || status.Initialize.ParameterLoadNS == 0 || !parameterOK || - status.Initialize.ParameterLoadBytes != parameterBytes || status.Initialize.ParameterProfile != parameterProfile || status.Initialize.ParameterSHA256 != parameterSHA256 || - status.Initialize.FixtureBytes != uint64(len(fixture)) || status.Initialize.FixtureSHA256 != hex.EncodeToString(fixtureDigest[:]) { - _ = session.close() - return nil, fmt.Errorf("browser cold-start provenance/timing evidence is incomplete") - } - session.cold = coldStartMetrics{ - WASMDownloadNS: status.DownloadNS, WASMDownloadBytes: status.DownloadByte, - WASMInstantiateNS: status.Instantiate, ParameterLoadNS: status.Initialize.ParameterLoadNS, - ParameterBytes: status.Initialize.ParameterLoadBytes, - ParameterProfile: status.Initialize.ParameterProfile, ParameterSHA256: status.Initialize.ParameterSHA256, - } - failed = false - return session, nil -} - -func (b *browserSession) exchange(ctx context.Context, request rq2wire.WorkerRequest) (rq2wire.WorkerRecord, browserCall, uint64, uint64, uint64, error) { - rawRequest, err := json.Marshal(request) - if err != nil { - return rq2wire.WorkerRecord{}, browserCall{}, 0, 0, 0, err - } - beforeCPU, err := b.cdp.taskDuration(ctx) - if err != nil { - return rq2wire.WorkerRecord{}, browserCall{}, 0, 0, 0, err - } - sampler, err := startResidentSampler(b.processID) - if err != nil { - return rq2wire.WorkerRecord{}, browserCall{}, 0, 0, 0, err - } - quoted, _ := json.Marshal(string(rawRequest)) - returned, callErr := b.cdp.evaluateString(ctx, "window.maltHostExchange("+string(quoted)+")", true) - peak, samples, sampleErr := sampler.stop() - if callErr != nil { - return rq2wire.WorkerRecord{}, browserCall{}, 0, 0, 0, callErr - } - if sampleErr != nil { - return rq2wire.WorkerRecord{}, browserCall{}, 0, 0, 0, sampleErr - } - afterCPU, err := b.cdp.taskDuration(ctx) - if err != nil || afterCPU < beforeCPU { - return rq2wire.WorkerRecord{}, browserCall{}, 0, 0, 0, fmt.Errorf("invalid Chromium TaskDuration delta") - } - var call browserCall - if err := strictJSON([]byte(returned), &call); err != nil { - return rq2wire.WorkerRecord{}, browserCall{}, 0, 0, 0, err - } - if call.RecordJSON == "" || call.CallNS == 0 { - return rq2wire.WorkerRecord{}, browserCall{}, 0, 0, 0, fmt.Errorf("browser/WASM boundary omitted call evidence") - } - var record rq2wire.WorkerRecord - if err := strictJSON([]byte(call.RecordJSON), &record); err != nil { - return rq2wire.WorkerRecord{}, browserCall{}, 0, 0, 0, err - } - if call.HeapBytes > peak { - peak = call.HeapBytes - } - if call.WASMMemory > peak { - peak = call.WASMMemory - } - cpuNS := secondsToNS(afterCPU - beforeCPU) - return record, call, cpuNS, peak, max(uint64(1), samples), nil -} - -func (b *browserSession) close() error { - b.closeOnce.Do(func() { - if b.cdp != nil { - closeContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) - _ = b.cdp.call(closeContext, "Browser.close", map[string]any{}, nil) - cancel() - _ = b.cdp.Close() - } - if b.server != nil { - shutdownContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) - if err := b.server.Shutdown(shutdownContext); err != nil && !errors.Is(err, http.ErrServerClosed) { - b.closeErr = errors.Join(b.closeErr, err) - } - cancel() - } - if b.command != nil && b.command.Process != nil && b.process != nil { - select { - case <-b.process.done: - if b.process.err != nil && !strings.Contains(b.process.err.Error(), "signal: killed") { - b.closeErr = errors.Join(b.closeErr, b.process.err) - } - case <-time.After(3 * time.Second): - _ = forceKillBrowserProcess(b.command) - <-b.process.done - } - } - if b.profile != "" { - b.closeErr = errors.Join(b.closeErr, os.RemoveAll(b.profile)) - } - }) - return b.closeErr -} - -// forceTerminate is the failure-only process boundary. It sends an immediate -// non-graceful kill before attempting CDP or HTTP cleanup, then requires both -// process reaping and connection teardown to complete within one fixed bound. -// Normal successful sessions continue to use close's graceful path. -func (b *browserSession) forceTerminate() error { - if b == nil { - return nil - } - b.closeOnce.Do(func() { - started := time.Now() - exceeded := false - deadline := time.NewTimer(browserForceTerminationBound) - defer deadline.Stop() - - if b.command != nil && b.command.Process != nil { - b.closeErr = errors.Join(b.closeErr, forceKillBrowserProcess(b.command)) - } - if b.cdp != nil { - b.closeErr = errors.Join(b.closeErr, b.cdp.forceClose()) - } - if b.server != nil { - if err := b.server.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) { - b.closeErr = errors.Join(b.closeErr, err) - } - } - if b.listener != nil { - if err := b.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { - b.closeErr = errors.Join(b.closeErr, err) - } - } - if b.process != nil { - select { - case <-b.process.done: - if b.process.err != nil && !browserWasForceKilled(b.process.err) { - b.closeErr = errors.Join(b.closeErr, b.process.err) - } - case <-deadline.C: - exceeded = true - b.closeErr = errors.Join(b.closeErr, fmt.Errorf("force terminate Chromium exceeded %s", browserForceTerminationBound)) - } - } - if b.profile != "" { - profile := b.profile - b.profile = "" - b.closeErr = errors.Join(b.closeErr, os.RemoveAll(profile)) - } - if time.Since(started) > browserForceTerminationBound && !exceeded { - b.closeErr = errors.Join(b.closeErr, fmt.Errorf("force terminate Chromium exceeded %s", browserForceTerminationBound)) - } - }) - return b.closeErr -} - -func browserWasForceKilled(err error) bool { - if err == nil { - return true - } - text := strings.ToLower(err.Error()) - return strings.Contains(text, "signal: killed") || strings.Contains(text, "signal: terminated") || strings.Contains(text, "exit status 137") -} - -func findPageWebsocket(ctx context.Context, browserWebsocket, pageURL string) (string, error) { - parsed, err := url.Parse(browserWebsocket) - if err != nil { - return "", err - } - endpoint := "http://" + parsed.Host + "/json/list" - client := &http.Client{Timeout: 2 * time.Second} - ticker := time.NewTicker(20 * time.Millisecond) - defer ticker.Stop() - for { - request, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - response, err := client.Do(request) - if err == nil { - var targets []struct { - Type string `json:"type"` - URL string `json:"url"` - WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` - } - decodeErr := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&targets) - _ = response.Body.Close() - if decodeErr == nil { - for _, target := range targets { - if target.Type == "page" && target.URL == pageURL && strings.HasPrefix(target.WebSocketDebuggerURL, "ws://") { - return target.WebSocketDebuggerURL, nil - } - } - } - } - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-ticker.C: - } - } -} - -func waitBrowserReady(ctx context.Context, client *cdpClient) (browserStatus, error) { - ticker := time.NewTicker(10 * time.Millisecond) - defer ticker.Stop() - for { - raw, err := client.evaluateString(ctx, `typeof window.maltHostStatus === "function" ? window.maltHostStatus() : JSON.stringify({phase:"loading"})`, false) - if err == nil { - var status browserStatus - if err := strictJSON([]byte(raw), &status); err != nil { - return browserStatus{}, err - } - switch status.Phase { - case "ready": - return status, nil - case "failed": - return browserStatus{}, errors.New(status.Error) - } - } - select { - case <-ctx.Done(): - return browserStatus{}, ctx.Err() - case <-ticker.C: - } - } -} - -func scanBrowserLog(reader io.Reader, output *boundedLog, devtools chan<- string) { - buffer := make([]byte, 4096) - pending := "" - found := false - for { - count, err := reader.Read(buffer) - if count > 0 { - chunk := buffer[:count] - _, _ = output.Write(chunk) - if !found { - pending += string(chunk) - if matches := devtoolsPattern.FindStringSubmatch(pending); len(matches) == 2 { - devtools <- matches[1] - found = true - } - if len(pending) > 8192 { - pending = pending[len(pending)-4096:] - } - } - } - if err != nil { - return - } - } -} - -type browserPageConfig struct { - GatewayURL string `json:"gateway_url"` - FixtureURL string `json:"fixture_url"` - WASMURL string `json:"wasm_url"` - WASMSHA256 string `json:"wasm_sha256"` - GatewayToken string `json:"gateway_instance_token"` - Backend string `json:"backend"` -} - -func browserPage(config browserPageConfig) ([]byte, error) { - configuration, err := json.Marshal(config) - if err != nil { - return nil, err - } - quotedConfiguration, err := json.Marshal(string(configuration)) - if err != nil { - return nil, err - } - page := `