From 00a9815d574054fae358798041a37847ec5c3151 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Tue, 11 Aug 2026 05:28:01 +0100 Subject: [PATCH] feat(repo): add wf_mst_cids_for_path and wf_repo_get_record_proof Ports the reference's MST.cidsForPath (mst/mst.ts), the algorithm behind com.atproto.sync.getRecord's inclusion/non-inclusion proofs (repo/sync/provider.ts's getRecords -> mst.cidsForPath). Unlike wf_mst_get_covering_proof (built for range queries and pruned to subtrees containing at least one match), this always includes every node visited on the walk down to a key -- including on the path that proves a key's *absence* -- which a pruning range query cannot express: an empty subtree pointer just ends the walk at that node, it never omits it. wf_repo_get_record_proof wraps the walk with the collection/rkey -> MST key construction wf_repo_get_record already does, returning the node path plus (if present) the leaf's value CID separately. This is the missing piece for a proper getRecord implementation (MetalBear's currently ships only commit+leaf with no path, an unverifiable proof); wiring it into a PDS is left to the consumer. --- include/wolfram/repo/mst.h | 18 +++++ include/wolfram/repo/record.h | 19 +++++ src/repo/mst.c | 96 +++++++++++++++++++++++++ src/repo/repo.c | 37 ++++++++++ test/test_repo.c | 129 ++++++++++++++++++++++++++++++++++ 5 files changed, 299 insertions(+) diff --git a/include/wolfram/repo/mst.h b/include/wolfram/repo/mst.h index 0b08cc2..a8c70d2 100644 --- a/include/wolfram/repo/mst.h +++ b/include/wolfram/repo/mst.h @@ -125,6 +125,24 @@ wf_status wf_mst_get_covering_proof(wf_car *car, const wf_cid *root, size_t to_key_len, wf_cid **out, size_t *out_count); +/** + * The MST node path from `root` down to where `key` lives, present or not -- + * an inclusion or non-inclusion proof for a single key, matching the + * reference's MST.cidsForPath (mst/mst.ts, used by com.atproto.sync.getRecord + * via repo/sync/provider.ts's getRecords). Every node visited is included in + * *out_node_cids, unlike wf_mst_get_covering_proof's range pruning: an empty + * subtree pointer just ends the walk without omitting the node it ended at. + * + * *out_leaf_cid is zeroed (len == 0) when `key` is absent; otherwise it names + * the leaf's value CID, which the caller must also include in the proof + * alongside *out_node_cids. + * Ownership: *out_node_cids is caller-owned; free it with wf_mst_cid_list_free. + */ +wf_status wf_mst_cids_for_path(wf_car *car, const wf_cid *root_cid, + const unsigned char *key, size_t key_len, + wf_cid **out_node_cids, size_t *out_node_count, + wf_cid *out_leaf_cid); + #ifdef __cplusplus } #endif diff --git a/include/wolfram/repo/record.h b/include/wolfram/repo/record.h index 039009d..49f3520 100644 --- a/include/wolfram/repo/record.h +++ b/include/wolfram/repo/record.h @@ -20,6 +20,25 @@ wf_status wf_repo_get_record(wf_car *car, const wf_cid *commit_cid, unsigned char **out_data, size_t *out_len, wf_cid *out_record_cid); +/** + * Compute the MST inclusion/non-inclusion proof for a single record key: the + * minimal set of MST node CIDs on the path from the commit's MST root to + * where `collection`/`rkey` would live, whether or not a record is actually + * present there. Matches the reference's repo.getRecords, which walks + * mst.cidsForPath so a caller can prove existence *or* non-existence + * (packages/repo/src/sync/provider.ts, packages/repo/src/mst/mst.ts). + * + * On WF_OK, *out_proof_cids is caller-owned (free with wf_mst_cid_list_free). + * *out_record_cid is zeroed (len == 0) when no record exists at that key; + * otherwise it names the record leaf's CID, which the caller must also + * include in the proof CAR alongside the commit and the returned node CIDs. + */ +wf_status wf_repo_get_record_proof(wf_car *car, const wf_cid *commit_cid, + const char *collection, const char *rkey, + wf_cid **out_proof_cids, + size_t *out_proof_count, + wf_cid *out_record_cid); + wf_status wf_repo_update_record(wf_car *car, const wf_cid *prev_commit, const char *did, const char *collection, const char *rkey, diff --git a/src/repo/mst.c b/src/repo/mst.c index 2a3da51..a975137 100644 --- a/src/repo/mst.c +++ b/src/repo/mst.c @@ -1796,6 +1796,102 @@ static wf_status mst_count_in_range(wf_car *car, const wf_cid *cid, return s; } +/* + * The node path from `root_cid` down to where `key` lives (present or not), + * mirroring the reference's MST.cidsForPath (mst/mst.ts): every node visited + * is included regardless of whether it turns out to hold the key, which is + * what makes the result a valid non-inclusion proof and not just a shortest + * path to a hit. If the key is present, its leaf value CID is returned + * separately via `out_leaf_cid`; if absent, the walk stops at the node that + * would have held it (no subtree left to descend into) and `out_leaf_cid` is + * left zeroed. + * + * Unlike wf_mst_get_covering_proof (built for a range and pruned to + * subtrees containing at least one match), this never prunes: an empty + * subtree pointer just ends the walk, it never skips a node that was + * visited. + */ +wf_status wf_mst_cids_for_path(wf_car *car, const wf_cid *root_cid, + const unsigned char *key, size_t key_len, + wf_cid **out_node_cids, size_t *out_node_count, + wf_cid *out_leaf_cid) { + if (!car || !root_cid || !key || key_len == 0 || !out_node_cids || + !out_node_count || !out_leaf_cid) + return WF_ERR_INVALID_ARG; + *out_node_cids = NULL; + *out_node_count = 0; + memset(out_leaf_cid, 0, sizeof(*out_leaf_cid)); + if (root_cid->len == 0) return WF_OK; + + wf_cid *list = NULL; + size_t count = 0, cap = 0; + wf_cid current = *root_cid; + size_t steps = 0; + + while (1) { + if (steps > WF_MST_MAX_DEPTH || steps > car->block_count) { + free(list); + return WF_ERR_PARSE; + } + steps++; + + wf_status s = mst_cid_list_push(&list, &count, &cap, ¤t); + if (s != WF_OK) { + free(list); + return s; + } + + wf_car_block *block = wf_car_find_block(car, ¤t); + if (!block) { + free(list); + return WF_ERR_PARSE; + } + wf_mst_node node; + s = wf_mst_node_parse(block->data, block->data_len, ¤t, &node); + if (s != WF_OK) { + free(list); + return s; + } + + size_t idx = 0; + for (; idx < node.count; idx++) { + int cmp = wf_mst_key_cmp(key, key_len, node.entries[idx].key, + node.entries[idx].key_len); + if (cmp == 0) { + *out_leaf_cid = node.entries[idx].value; + wf_mst_node_free(&node); + *out_node_cids = list; + *out_node_count = count; + return WF_OK; + } + if (cmp < 0) break; + } + + wf_cid *subtree = NULL; + if (idx == 0) { + if (node.left.len > 0) subtree = &node.left; + } else { + if (node.entries[idx - 1].subtree.len > 0) + subtree = &node.entries[idx - 1].subtree; + } + if (!subtree && idx >= node.count && node.count > 0) { + if (node.entries[node.count - 1].subtree.len > 0) + subtree = &node.entries[node.count - 1].subtree; + } + + if (subtree && subtree->len > 0) { + current = *subtree; + wf_mst_node_free(&node); + continue; + } + + wf_mst_node_free(&node); + *out_node_cids = list; + *out_node_count = count; + return WF_OK; /* not found -- the collected path is the proof */ + } +} + /* Collect every node CID whose subtree contains at least one in-range leaf. */ static wf_status mst_collect_proof(wf_car *car, const wf_cid *cid, const unsigned char *from_key, diff --git a/src/repo/repo.c b/src/repo/repo.c index 37f75cc..fc393cc 100644 --- a/src/repo/repo.c +++ b/src/repo/repo.c @@ -261,6 +261,43 @@ wf_status wf_repo_get_record(wf_car *car, const wf_cid *commit_cid, return WF_OK; } +wf_status wf_repo_get_record_proof(wf_car *car, const wf_cid *commit_cid, + const char *collection, const char *rkey, + wf_cid **out_proof_cids, + size_t *out_proof_count, + wf_cid *out_record_cid) { + if (!car || !commit_cid || !collection || !rkey || !out_proof_cids || + !out_proof_count || !out_record_cid) { + return WF_ERR_INVALID_ARG; + } + + *out_proof_cids = NULL; + *out_proof_count = 0; + memset(out_record_cid, 0, sizeof(*out_record_cid)); + + wf_car_block *block = wf_car_find_block(car, commit_cid); + if (!block) return WF_ERR_PARSE; + + wf_commit commit; + memset(&commit, 0, sizeof(commit)); + wf_status s = wf_commit_parse(block->data, block->data_len, &commit); + if (s != WF_OK) return s; + + size_t col_len = strlen(collection); + size_t rkey_len = strlen(rkey); + size_t key_len = col_len + 1 + rkey_len; + unsigned char *mst_key = malloc(key_len); + if (!mst_key) return WF_ERR_ALLOC; + memcpy(mst_key, collection, col_len); + mst_key[col_len] = '/'; + memcpy(mst_key + col_len + 1, rkey, rkey_len); + + s = wf_mst_cids_for_path(car, &commit.data, mst_key, key_len, + out_proof_cids, out_proof_count, out_record_cid); + free(mst_key); + return s; +} + wf_status wf_repo_update_record(wf_car *car, const wf_cid *prev_commit, const char *did, const char *collection, const char *rkey, diff --git a/test/test_repo.c b/test/test_repo.c index 231e5a3..4fe546b 100644 --- a/test/test_repo.c +++ b/test/test_repo.c @@ -2011,6 +2011,135 @@ int main(void) { wf_car_free(&car); } + /* Record proof (com.atproto.sync.getRecord): inclusion and + * non-inclusion. The proof must be independently walkable from the + * commit's MST root down to the leaf (or the boundary where it would + * live), not merely non-empty. */ + { + wf_signing_key key; + memset(&key, 0, sizeof(key)); + WF_CHECK(wf_signing_key_generate(WF_KEY_TYPE_SECP256K1, &key) == WF_OK); + + wf_car car; + memset(&car, 0, sizeof(car)); + + unsigned char record_a[] = {0xA1, 0x64, 't', 'e', 's', 't', 0x18, 0x7B}; + unsigned char record_b[] = {0xA1, 0x64, 'n', 'a', 'm', + 'e', 0x63, 'b', 'o', 'b'}; + + wf_cid commit1, rec1; + memset(&commit1, 0, sizeof(commit1)); + memset(&rec1, 0, sizeof(rec1)); + WF_CHECK(wf_repo_create_record(&car, NULL, "did:plc:test", + "com.example.posts", "recA", record_a, + sizeof(record_a), &key, &commit1, + &rec1) == WF_OK); + + wf_cid commit2, rec2; + memset(&commit2, 0, sizeof(commit2)); + memset(&rec2, 0, sizeof(rec2)); + WF_CHECK(wf_repo_create_record(&car, &commit1, "did:plc:test", + "com.example.posts", "recB", record_b, + sizeof(record_b), &key, &commit2, + &rec2) == WF_OK); + + wf_car_block *commit_block = wf_car_find_block(&car, &commit2); + WF_CHECK(commit_block != NULL); + wf_commit commit; + memset(&commit, 0, sizeof(commit)); + WF_CHECK(wf_commit_parse(commit_block->data, commit_block->data_len, + &commit) == WF_OK); + + /* Present key: proof ends in the leaf's value CID, matching what + * wf_repo_get_record independently resolves for the same key, and + * every node CID in the proof is a real, parseable block reachable + * by walking from the commit's MST root. */ + { + static const char key_a[] = "com.example.posts/recA"; + wf_cid *proof = NULL; + size_t proof_count = 0; + wf_cid leaf; + memset(&leaf, 0, sizeof(leaf)); + WF_CHECK(wf_mst_cids_for_path( + &car, &commit.data, (const unsigned char *)key_a, + strlen(key_a), &proof, &proof_count, &leaf) == WF_OK); + WF_CHECK(proof_count >= 1); + WF_CHECK(leaf.len == rec1.len && + memcmp(leaf.bytes, rec1.bytes, leaf.len) == 0); + + /* Every proof node is a block actually present in the CAR and + * parses as an MST node. */ + for (size_t i = 0; i < proof_count; i++) { + wf_car_block *b = wf_car_find_block(&car, &proof[i]); + WF_CHECK(b != NULL); + if (b) { + wf_mst_node n; + memset(&n, 0, sizeof(n)); + WF_CHECK(wf_mst_node_parse(b->data, b->data_len, &proof[i], + &n) == WF_OK); + wf_mst_node_free(&n); + } + } + /* The proof's own path must actually find the leaf again via + * wf_mst_find, cross-checking against a second, independent + * traversal of the same tree. */ + wf_cid found; + memset(&found, 0, sizeof(found)); + WF_CHECK(wf_mst_find(&car, &commit.data, + (const unsigned char *)key_a, strlen(key_a), + &found) == WF_OK); + WF_CHECK(found.len == leaf.len && + memcmp(found.bytes, leaf.bytes, found.len) == 0); + wf_mst_cid_list_free(proof, proof_count); + } + + /* Absent key: no leaf CID, but the proof still ends at a real, + * parseable node -- a non-inclusion proof is not merely "no + * results", it is a verifiable dead end. */ + { + static const char key_nope[] = "com.example.posts/nope"; + wf_cid *proof = NULL; + size_t proof_count = 0; + wf_cid leaf; + memset(&leaf, 0xAA, sizeof(leaf)); /* poison: must be zeroed */ + WF_CHECK(wf_mst_cids_for_path(&car, &commit.data, + (const unsigned char *)key_nope, + strlen(key_nope), &proof, + &proof_count, &leaf) == WF_OK); + WF_CHECK(leaf.len == 0); + WF_CHECK(proof_count >= 1); + for (size_t i = 0; i < proof_count; i++) { + wf_car_block *b = wf_car_find_block(&car, &proof[i]); + WF_CHECK(b != NULL); + } + wf_mst_cid_list_free(proof, proof_count); + } + + /* wf_repo_get_record_proof wraps the same walk and agrees with it. */ + { + wf_cid *proof = NULL; + size_t proof_count = 0; + wf_cid record_cid; + memset(&record_cid, 0, sizeof(record_cid)); + WF_CHECK(wf_repo_get_record_proof( + &car, &commit2, "com.example.posts", "recB", &proof, + &proof_count, &record_cid) == WF_OK); + WF_CHECK(record_cid.len == rec2.len && + memcmp(record_cid.bytes, rec2.bytes, record_cid.len) == 0); + WF_CHECK(proof_count >= 1); + wf_mst_cid_list_free(proof, proof_count); + + memset(&record_cid, 0xAA, sizeof(record_cid)); + WF_CHECK(wf_repo_get_record_proof( + &car, &commit2, "com.example.posts", "missing", &proof, + &proof_count, &record_cid) == WF_OK); + WF_CHECK(record_cid.len == 0); + wf_mst_cid_list_free(proof, proof_count); + } + + wf_car_free(&car); + } + /* Delete non-existent record */ { wf_signing_key key; -- 2.51.2