diff --git a/pkg/dotc1z/engine/pebble/digest.go b/pkg/dotc1z/engine/pebble/digest.go index a37ff721f..7b87f041d 100644 --- a/pkg/dotc1z/engine/pebble/digest.go +++ b/pkg/dotc1z/engine/pebble/digest.go @@ -481,12 +481,15 @@ type DigestRoot struct { // computeBucketDigest would read that absence as "zero records" — the // false-clean trap dirtyPartitionBuckets' doc comment describes. func (e *Engine) getPartitionDigestRoot(spec digestIndexSpec, partition string) (DigestRoot, bool, error) { - if e.grantDigestBuildPending.Load() { + if e.grantDigestBuildPending.Load() || e.grantDigestAbiStale.Load() { // An interrupted digest build's half-committed nodes may be // durable while its hash index never ingested; until the pending // state is consumed (a writable Open drops it; a read-only open // cannot), no stored root may be trusted — report "never built", - // which every consumer already treats as "recalculate". + // which every consumer already treats as "recalculate". Roots + // computed under a different hash ABI (grantDigestAbiStale, only + // ever set on read-only opens) are equally untrustworthy: their + // hashes come from a different input framing. return DigestRoot{}, false, nil } val, closer, err := e.db.Get(encodeDigestNodeKey(spec.indexID, partition, digestLevelRoot, nil)) diff --git a/pkg/dotc1z/engine/pebble/digest_test.go b/pkg/dotc1z/engine/pebble/digest_test.go index 837e712e8..c3c82e987 100644 --- a/pkg/dotc1z/engine/pebble/digest_test.go +++ b/pkg/dotc1z/engine/pebble/digest_test.go @@ -95,10 +95,12 @@ func sealGrantDigests(t testing.TB, e *Engine) { // keyspace but is a single fold-of-everything summary the seal build // writes once per file, not a per-partition node, so counting it here // would throw off every existing "N nodes for this one entitlement" -// assertion by a constant +1. +// assertion by a constant +1. The ABI stamp is excluded structurally: +// the node bounds end at the DigestMetaIndexID sub-range it lives in. func digestNodeCount(t testing.TB, e *Engine) int { t.Helper() - n := countKeyRangeTest(t, e, DigestLowerBound(), DigestUpperBound()) + lo, hi := rawdb.DigestNodeKeyspaceBounds() + n := countKeyRangeTest(t, e, lo, hi) if _, ok, err := e.GetGrantDigestGlobalRoot(context.Background()); err != nil { t.Fatalf("GetGrantDigestGlobalRoot: %v", err) } else if ok { diff --git a/pkg/dotc1z/engine/pebble/engine.go b/pkg/dotc1z/engine/pebble/engine.go index 6e702cf08..9532ca56c 100644 --- a/pkg/dotc1z/engine/pebble/engine.go +++ b/pkg/dotc1z/engine/pebble/engine.go @@ -131,6 +131,17 @@ type Engine struct { // built" instead of trusting nodes a crashed build half-committed. grantDigestBuildPending atomic.Bool + // grantDigestAbiStale is the read-only-open counterpart of the ABI + // check in verifyGrantDigestABI: true when the file holds digest + // nodes whose stamp (rawdb.GrantDigestABIStampKey) does not name + // the current GrantDigestABIVersion — state built by different hash + // code, e.g. a file sealed by an older SDK. A writable Open drops + // such state instead of setting this, so on a writable engine it is + // always false; on a read-only engine it makes the digest root + // getters report "never built" (the same fail-safe shape as + // grantDigestBuildPending above), and consumers recalculate. + grantDigestAbiStale atomic.Bool + // test holds every test-only injection seam, sequestered on one // field so hooks don't accumulate on the production struct. All // zero in production; see testSeams (test_seams.go). @@ -305,6 +316,18 @@ func Open(ctx context.Context, dir string, opts ...Option) (*Engine, error) { _ = e.Close() return nil, err } + // Enforce the digest ABI contract: digest nodes not certified by a + // stamp naming the CURRENT GrantDigestABIVersion were computed by + // different hash code and must never be trusted or extended — a + // writable open drops them wholesale (the next EndSync's existing + // digests-absent path rebuilds everything at the current ABI); a + // read-only open flags them so the root getters report "never + // built". Runs after the probe so it sees post-marker-recovery + // presence, and its own drop re-falses the flag. + if err := e.verifyGrantDigestABI(ctx, o.readOnly); err != nil { + _ = e.Close() + return nil, err + } // Run secondary-index migrations before returning. Migrations // are skipped for read-only opens (the on-disk file is // immutable, so we'd error out trying to backfill). diff --git a/pkg/dotc1z/engine/pebble/grant_digest.go b/pkg/dotc1z/engine/pebble/grant_digest.go index b173e5de1..259ef89d3 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest.go +++ b/pkg/dotc1z/engine/pebble/grant_digest.go @@ -9,6 +9,8 @@ import ( "github.com/cespare/xxhash/v2" "github.com/cockroachdb/pebble/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" @@ -54,12 +56,75 @@ var grantDigestSpec = digestIndexSpec{ // ABI bump (a change to either hash's input framing) makes stored // manifest roots computed under different versions incomparable by // construction, rather than silently comparing unrelated hash schemes. -// Bump alongside any index-migration version bump that touches these -// hashes (see index_migrations.go). +// +// Enforcement on the stored state itself is the durable ABI stamp +// (rawdb.GrantDigestABIStampKey), written alongside every global-root +// write and checked once per Open (verifyGrantDigestABI): digest state +// whose stamp does not name this constant is dropped (writable open) +// or reported "never built" (read-only open), so a bump here is +// sufficient by itself to force every previously-sealed file's digest +// state to be rebuilt in full at the new ABI on its next writable use. +// No index-migration entry is needed — see the note on digest-ABI +// handling in index_migrations.go. +// +// v2 folds two more facts into grantContentHash64: the GrantImmutable +// annotation (isImmutable) and, per source, GrantSourceRecord.is_direct +// — both are stable per-grant facts (not sync-transient bookkeeping; +// see the ABI doc on grantContentHash64) that v1 silently dropped along +// with the rest of `annotations`. // // Exported so consumers of GrantContentHash / GrantDigestAccumulator // can check a stored root's abi_version before comparing. -const GrantDigestABIVersion uint32 = 1 +const GrantDigestABIVersion uint32 = 2 + +// grantDigestABIStampValue is the ABI stamp's stored value: the +// current GrantDigestABIVersion, uint32 BE (the index-migration +// applied-version encoding). +func grantDigestABIStampValue() []byte { + var buf [4]byte + binary.BigEndian.PutUint32(buf[:], GrantDigestABIVersion) + return buf[:] +} + +// verifyGrantDigestABI is the Open-time half of the ABI stamp contract +// (rawdb.GrantDigestABIStampKey; the write half is every global-root +// write site). If the file holds digest nodes (per the just-probed +// presence flag) but no stamp naming the current GrantDigestABIVersion, +// that state was computed by different hash code: a writable open +// restores the always-safe "digests absent" state — the next EndSync's +// existing digests-absent path (RepairMissingGrantDigests delegating to +// BuildGrantDigests) then rebuilds everything, hash rows and nodes and +// manifest root alike, at the current ABI. A read-only open cannot +// drop; it sets grantDigestAbiStale, which makes the digest root +// getters report "never built" (present-means-exact consumers +// recalculate — never a wrong answer, mirroring grantDigestBuildPending). +// +// A stale or orphaned stamp over an EMPTY node keyspace is left alone: +// with no nodes there is nothing to trust, and every build rewrites the +// stamp on its completion side (the fold's opening DeleteRange erases +// it first). +func (e *Engine) verifyGrantDigestABI(ctx context.Context, readOnly bool) error { + if !e.db.GrantDigestsPresent() { + return nil + } + val, closer, err := e.db.Get(rawdb.GrantDigestABIStampKey()) + if err == nil { + current := len(val) == 4 && binary.BigEndian.Uint32(val) == GrantDigestABIVersion + closer.Close() + if current { + return nil + } + } else if !errors.Is(err, pebble.ErrNotFound) { + return err + } + if readOnly { + e.grantDigestAbiStale.Store(true) + return nil + } + ctxzap.Extract(ctx).Warn("pebble: grant digest state was built under a different hash ABI; dropping it — the next EndSync rebuilds it from scratch", + zap.Uint32("current_abi", GrantDigestABIVersion)) + return e.dropAllGrantDigestStateLocked() +} // The whole-file grant digest root's node-key level lives in // internal/keys (rawdb.DigestLevelGlobalRoot, consumed by @@ -117,72 +182,114 @@ func principalBucketHash(principalRT, principalID string) []byte { return out } +// grantSourceFact is one entry of a grant's sources map as the content +// hash sees it: the source-entitlement id (the map key) plus whether +// that contribution is direct (GrantSourceRecord.is_direct, the map +// value's only content-hash-relevant field — resource_type_id/ +// resource_id/entitlement_id are redundant with the key's own +// entitlement identity and not folded in). key is a borrowed slice on +// the raw-scan path; sortGrantSourceFacts sorts a slice of these by key. +type grantSourceFact struct { + key []byte + isDirect bool +} + +// sortGrantSourceFacts sorts by key ascending (bytes.Compare order). +func sortGrantSourceFacts(s []grantSourceFact) { + // Small-n insertion sort: source sets are tiny (usually 0–4). + for i := 1; i < len(s); i++ { + for j := i; j > 0 && bytes.Compare(s[j].key, s[j-1].key) < 0; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + // grantContentHash64 is the canonical content hash of a grant — the // value stored in the hash index and the unit the grant digest folds. // -// ABI: "the same grant" is defined as +// ABI (v2): "the same grant" is defined as // -// xxHash64( primaryKeyTail ‖ ( 0x00 ‖ esc(source_id) )* ) +// xxHash64( primaryKeyTail ‖ 0x00 ‖ bool(isImmutable) ‖ +// ( 0x00 ‖ esc(source_id) ‖ 0x00 ‖ bool(is_direct) )* ) // // where primaryKeyTail is the grant's encoded primary-key tail (the // 6-segment identity tuple ent_rt|ent_rid|ent_flag|ent_tail|p_rt|p_id, -// escaped and separator-delimited exactly as stored) and the source -// ids — the keys of the grant's sources map, its expansion -// provenance — are appended as additional tuple segments in ascending -// byte order (sortedSourceKeys must already be sorted; the escape is -// order-preserving so raw order == encoded order). +// escaped and separator-delimited exactly as stored), isImmutable is +// whether the grant carries a GrantImmutable annotation, and the +// sources — the grant's expansion provenance — are appended as +// (source_id, is_direct) pairs in ascending source_id byte order +// (sortedSources must already be sorted; the escape is order-preserving +// so raw order == encoded order). bool(...) is codec.AppendTupleBool's +// single-byte encoding (0x26/0x27 — disjoint from the tuple separator +// and escape bytes, so it needs no escaping of its own). // -// The field set deliberately covers the membership EDGE (the identity -// tuple) plus the grant's source-entitlement set, and deliberately -// EXCLUDES everything sync-relative or transient — external_id (not -// identity under the injective-key scheme; the same edge keeps its -// hash when a connector changes its id grammar), discovered_at, -// needs_expansion, expansion state, and annotations — none of which -// change "which principal holds which entitlement". The source map -// VALUES (GrantSourceRecord) are not folded in v1 — only the set of -// source ids, which is the membership-composition signal. +// The field set covers the membership EDGE (the identity tuple), the +// grant's source-entitlement set and each source's direct/indirect +// provenance, and whether the grant is immutable — and deliberately +// EXCLUDES everything else sync-relative, transient, or connector- +// opaque: external_id (not identity under the injective-key scheme; +// the same edge keeps its hash when a connector changes its id +// grammar), discovered_at, needs_expansion, expansion state, and every +// other annotation (e.g. GrantMetadata). isImmutable and is_direct are +// the two exceptions to "annotations/source values are excluded": both +// are stable per-grant facts about what the edge IS — not bookkeeping +// that would legitimately churn every sync — and both are already used +// elsewhere in the SDK to distinguish grants whose identity tuple and +// source-id set are otherwise identical (see rollback_expansion.go's +// suspect-grant check and topological_merge.go's direct-wins-over- +// indirect upgrade). v1 folded neither; see GrantDigestABIVersion. // // This is a hand-rolled framing, NOT proto marshal: deterministic-proto // output is not canonical across protobuf library versions, which // would make two files written by different SDK builds hash identical // grants differently. The tuple framing is injective: every segment is -// escaped and separator-delimited, and the identity is a fixed six -// segments, so no source list can alias a different identity split (a -// naive 0x00-joined concatenation would collide e.g. sources +// escaped and separator-delimited, the identity is a fixed six +// segments, and the isImmutable flag always occupies the fixed slot +// right after it (whether or not any source follows), so no source +// list can alias a different identity split or a different isImmutable +// value (a naive 0x00-joined concatenation would collide e.g. sources // ["a","b"] vs ["a\x00b"]). // // tuple is a caller-reused scratch buffer, returned grown for reuse. -func grantContentHash64(tuple, primaryKeyTail []byte, sortedSourceKeys [][]byte) (uint64, []byte) { - if len(sortedSourceKeys) == 0 { - // Common case: no sources — hash the tail bytes in place. +func grantContentHash64(tuple, primaryKeyTail []byte, isImmutable bool, sortedSources []grantSourceFact) (uint64, []byte) { + if !isImmutable && len(sortedSources) == 0 { + // Common case: not immutable, no sources — hash the tail bytes + // in place. return xxhash.Sum64(primaryKeyTail), tuple } tuple = append(tuple[:0], primaryKeyTail...) - for _, k := range sortedSourceKeys { + tuple = codec.AppendTupleSeparator(tuple) + tuple = codec.AppendTupleBool(tuple, isImmutable) + for _, s := range sortedSources { + tuple = codec.AppendTupleSeparator(tuple) + tuple = codec.AppendTupleBytes(tuple, s.key) tuple = codec.AppendTupleSeparator(tuple) - tuple = codec.AppendTupleBytes(tuple, k) + tuple = codec.AppendTupleBool(tuple, s.isDirect) } return xxhash.Sum64(tuple), tuple } // grantContentHashForRecord is the from-record form of the content -// hash: encodes the grant's identity tuple and sorts its source keys, -// then delegates to grantContentHash64. The seal-time build never uses -// this (it splices key bytes and raw-scans the value); it exists for -// readers, tests, and any future repair path, and is pinned against -// the splice form by TestGrantDigestSpliceMatchesEncode. +// hash: encodes the grant's identity tuple, its immutability, and its +// sorted sources, then delegates to grantContentHash64. The seal-time +// build never uses this (it splices key bytes and raw-scans the +// value); it exists for readers, tests, and any future repair path, +// and is pinned against the splice form by +// TestGrantDigestSpliceMatchesEncode. func grantContentHashForRecord(r *v3.GrantRecord) ([]byte, error) { id, err := grantIdentityFromRecord(r) if err != nil { return nil, err } key := encodeGrantIdentityKey(id) - srcs := make([][]byte, 0, len(r.GetSources())) - for k := range r.GetSources() { - srcs = append(srcs, []byte(k)) + isImmutable := annsContainType(r.GetAnnotations(), grantImmutableAnnotationTypeName) + srcMap := r.GetSources() + srcs := make([]grantSourceFact, 0, len(srcMap)) + for k, v := range srcMap { + srcs = append(srcs, grantSourceFact{key: []byte(k), isDirect: v.GetIsDirect()}) } - sortByteSlices(srcs) - h, _ := grantContentHash64(nil, key[grantPrimaryKeyPrefixLen:], srcs) + sortGrantSourceFacts(srcs) + h, _ := grantContentHash64(nil, key[grantPrimaryKeyPrefixLen:], isImmutable, srcs) out := make([]byte, hashLen) binary.BigEndian.PutUint64(out, h) return out, nil @@ -219,13 +326,14 @@ func GrantContentHash(g *v2.Grant) (uint64, error) { principalID: princ.GetResource(), } key := encodeGrantIdentityKey(id) + isImmutable := annsContainType(g.GetAnnotations(), grantImmutableAnnotationTypeName) sources := g.GetSources().GetSources() - srcs := make([][]byte, 0, len(sources)) - for k := range sources { - srcs = append(srcs, []byte(k)) + srcs := make([]grantSourceFact, 0, len(sources)) + for k, v := range sources { + srcs = append(srcs, grantSourceFact{key: []byte(k), isDirect: v.GetIsDirect()}) } - sortByteSlices(srcs) - h, _ := grantContentHash64(nil, key[grantPrimaryKeyPrefixLen:], srcs) + sortGrantSourceFacts(srcs) + h, _ := grantContentHash64(nil, key[grantPrimaryKeyPrefixLen:], isImmutable, srcs) return h, nil } @@ -266,16 +374,6 @@ func (a *GrantDigestAccumulator) Root() DigestRoot { } } -// sortByteSlices sorts byte slices ascending (bytes.Compare order). -func sortByteSlices(s [][]byte) { - // Small-n insertion sort: source sets are tiny (usually 0–4). - for i := 1; i < len(s); i++ { - for j := i; j > 0 && bytes.Compare(s[j], s[j-1]) < 0; j-- { - s[j], s[j-1] = s[j-1], s[j] - } - } -} - // --- Key splices --- // grantPrimaryKeyPrefixLen is the byte length of the grant primary-key @@ -363,10 +461,12 @@ func (e *Engine) GetEntitlementDigestRoot(ctx context.Context, id entitlementIde // invalidation paths that drop any per-entitlement root — see // stageGrantDigestInvalidation and the Drop* functions below. func (e *Engine) GetGrantDigestGlobalRoot(ctx context.Context) (DigestRoot, bool, error) { - if e.grantDigestBuildPending.Load() { - // Same guard as getPartitionDigestRoot: a global root committed + if e.grantDigestBuildPending.Load() || e.grantDigestAbiStale.Load() { + // Same guards as getPartitionDigestRoot: a global root committed // by an interrupted build must read as absent, not certify a - // hash index that was never ingested. + // hash index that was never ingested — and one computed under a + // different hash ABI (read-only open of an old file) must read + // as absent rather than compare hashes from another scheme. return DigestRoot{}, false, nil } val, closer, err := e.db.Get(rawdb.GlobalGrantDigestNodeKey()) diff --git a/pkg/dotc1z/engine/pebble/grant_digest_abi_test.go b/pkg/dotc1z/engine/pebble/grant_digest_abi_test.go new file mode 100644 index 000000000..e46f9d2ca --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_abi_test.go @@ -0,0 +1,451 @@ +package pebble + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "path/filepath" + "testing" + + "github.com/cockroachdb/pebble/v2" + "github.com/segmentio/ksuid" + "github.com/stretchr/testify/require" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/connectorstore" + "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb" +) + +// Tests for the digest ABI stamp (rawdb.GrantDigestABIStampKey, +// verifyGrantDigestABI, grantDigestABIStampValue/GrantDigestABIVersion +// in grant_digest.go): digest nodes present without a stamp naming the +// current GrantDigestABIVersion must make a writable Open drop ALL +// digest state so the next seal rebuilds it, and a read-only Open +// report the digest roots as "never built" instead. See grant_digest.go +// and engine.go's Open for the production contract these pin. + +// makeTestGrants builds n distinct grants for one entitlement, +// following the same shape as digest_test.go's makeGrant. +func makeTestGrants(entID string, n int) []*v3.GrantRecord { + grants := make([]*v3.GrantRecord, 0, n) + for i := range n { + grants = append(grants, makeGrant("", fmt.Sprintf("g-%s-%03d", entID, i), entID, fmt.Sprintf("user-%03d", i))) + } + return grants +} + +// abiStampBytes encodes a (possibly fake) ABI version the way the +// production stamp does: uint32 BE. +func abiStampBytes(version uint32) []byte { + buf := make([]byte, 4) + binary.BigEndian.PutUint32(buf, version) + return buf +} + +// setABIStamp overwrites the durable ABI stamp with an arbitrary +// version — DigestSet is the production write for a digest-keyspace +// row (the family the stamp key itself lives in), so this exercises +// exactly the "stamp names a different version" state Open must guard +// against, without going through any other digest bookkeeping. +func setABIStamp(t *testing.T, e *Engine, version uint32) { + t.Helper() + require.NoError(t, e.db.DigestSet(rawdb.GrantDigestABIStampKey(), abiStampBytes(version), pebble.Sync)) +} + +// deleteABIStamp removes the stamp key entirely, simulating a file +// sealed by a pre-stamp SDK build (digest nodes present, no stamp at +// all). No exported DB operation deletes a single digest-family key by +// design (digest.go's writers only ever Set), so this is exactly the +// kind of production-inexpressible state rawdb.DB.UnsafeForTesting +// exists for. +func deleteABIStamp(t *testing.T, e *Engine) { + t.Helper() + require.NoError(t, e.db.UnsafeForTesting().Delete(rawdb.GrantDigestABIStampKey(), pebble.Sync)) +} + +// sealedGrantDigestEngine builds a small sealed file through the +// normal StartNewSync -> EndSync path — so a durable SyncRunRecord +// exists and a later SetCurrentSync/EndSync can resume the same sync +// after a reopen, exactly the pattern +// grant_digest_build_crash_test.go uses to drive Open's +// crash-recovery paths — and returns the engine, its on-disk "db" +// directory (ready for a bare Open(ctx, dbDir, ...) reopen), and the +// sync id. +func sealedGrantDigestEngine(t *testing.T, entID string, n int, opts ...Option) (*Engine, string, string) { + t.Helper() + ctx := context.Background() + e, dir := newTestEngine(t, opts...) + a := NewAdapter(e) + syncID, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + require.NoError(t, err, "StartNewSync") + putEnt(t, e, ctx, entID) + require.NoError(t, e.PutGrantRecords(ctx, makeTestGrants(entID, n)...), "PutGrantRecords") + require.NoError(t, a.EndSync(ctx), "EndSync") + return e, filepath.Join(dir, "db"), syncID +} + +// verifyGrantHashIndexAgainstPrimaries is the positive-evidence oracle +// for a built hash index: for every grant PRIMARY record it decodes +// the record, independently recomputes the expected content hash +// (grantContentHashForRecord — the from-record path, not the +// seal-time splice), splices the same grant's hash-index key from the +// raw primary key, and requires the stored row's 8-byte value to match +// — then requires the hash-index row count to equal the grant count +// exactly (no missing or orphaned rows). Returns an error rather than +// failing the test directly so a test can assert BOTH that it passes +// after a clean seal and that it can detect a tampered row (see +// TestGrantDigestABIOracle). +func verifyGrantHashIndexAgainstPrimaries(t testing.TB, e *Engine) error { + t.Helper() + ctx := context.Background() + giter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: GrantLowerBound(), UpperBound: GrantUpperBound()}) + if err != nil { + return err + } + defer giter.Close() + var grantCount int + for giter.First(); giter.Valid(); giter.Next() { + if err := ctx.Err(); err != nil { + return err + } + grantCount++ + key := append([]byte(nil), giter.Key()...) + sep4, ok := rawdb.SplitGrantPrimaryKey(key) + if !ok { + return fmt.Errorf("verifyGrantHashIndexAgainstPrimaries: grant primary key %x did not split as a 6-segment identity", key) + } + rec := &v3.GrantRecord{} + if err := unmarshalRecord(giter.Value(), rec); err != nil { + return fmt.Errorf("verifyGrantHashIndexAgainstPrimaries: unmarshal grant %x: %w", key, err) + } + wantHash, err := grantContentHashForRecord(rec) + if err != nil { + return fmt.Errorf("verifyGrantHashIndexAgainstPrimaries: grantContentHashForRecord(%x): %w", key, err) + } + bh64 := grantPrincipalBucketHash64(key[sep4+1:]) + idxKey := appendGrantHashIndexKeyFromPrimary(nil, key, sep4, bh64) + val, closer, err := e.db.Get(idxKey) + if err != nil { + return fmt.Errorf("verifyGrantHashIndexAgainstPrimaries: hash-index row for grant %x: %w", key, err) + } + gotHash := append([]byte(nil), val...) + closer.Close() + if !bytes.Equal(gotHash, wantHash) { + return fmt.Errorf("verifyGrantHashIndexAgainstPrimaries: hash-index row for grant %x = %x, want %x", key, gotHash, wantHash) + } + } + if err := giter.Error(); err != nil { + return err + } + + iiter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: GrantByEntPrincHashLowerBound(), UpperBound: GrantByEntPrincHashUpperBound()}) + if err != nil { + return err + } + defer iiter.Close() + var rowCount int + for iiter.First(); iiter.Valid(); iiter.Next() { + rowCount++ + } + if err := iiter.Error(); err != nil { + return err + } + if rowCount != grantCount { + return fmt.Errorf("verifyGrantHashIndexAgainstPrimaries: hash-index row count = %d, want %d (one per grant)", rowCount, grantCount) + } + return nil +} + +// TestGrantDigestABIStampWrittenBySeal verifies the write half of the +// ABI stamp contract: a normal seal (grants present, and separately +// zero grants at all) writes rawdb.GrantDigestABIStampKey() == +// GrantDigestABIVersion (uint32 BE), the stamp is visible inside +// [DigestLowerBound, DigestUpperBound), and it is excluded from +// rawdb.DigestNodeKeyspaceBounds() — the presence-probe range that +// must never see it (DigestMetaIndexID). +func TestGrantDigestABIStampWrittenBySeal(t *testing.T) { + const entID = "ent-A" + stampKey := rawdb.GrantDigestABIStampKey() + + e, _ := newTestEngine(t) + seedEntitlement(t, e, entID, makeTestGrants(entID, 20)) + + val, closer, err := e.db.Get(stampKey) + require.NoError(t, err, "stamp must be present after a normal seal") + got := append([]byte(nil), val...) + closer.Close() + require.Equal(t, grantDigestABIStampValue(), got, "stamp value must be the current ABI version, uint32 BE") + + // dumpDigestNodes-style iteration of the whole digest keyspace + // must surface the stamp. + nodes := dumpDigestNodes(t, e) + stampVal, ok := nodes[string(stampKey)] + require.True(t, ok, "stamp key must be inside [DigestLowerBound, DigestUpperBound)") + require.Equal(t, grantDigestABIStampValue(), stampVal) + + // But the NODE-only bounds (the presence probe's range) must + // exclude it. + nodeLo, nodeHi := rawdb.DigestNodeKeyspaceBounds() + nodeIter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: nodeLo, UpperBound: nodeHi}) + require.NoError(t, err) + foundInNodeRange := nodeIter.SeekGE(stampKey) && bytes.Equal(nodeIter.Key(), stampKey) + require.NoError(t, nodeIter.Error()) + require.NoError(t, nodeIter.Close()) + require.False(t, foundInNodeRange, "stamp key must be excluded from the digest node-keyspace probe bounds") + + // Zero-grant seal path: no entitlements, no grants at all — the + // stamp must still be written (the "digest was built" certificate + // covers the zero-chunks branch too). + e2, _ := newTestEngine(t) + require.NoError(t, e2.bindCurrentSync(ksuid.New().String())) + sealGrantDigests(t, e2) + val2, closer2, err := e2.db.Get(stampKey) + require.NoError(t, err, "stamp must be present after a zero-grant seal") + got2 := append([]byte(nil), val2...) + closer2.Close() + require.Equal(t, grantDigestABIStampValue(), got2) +} + +// TestGrantDigestABIStaleStampDroppedAtWritableOpen verifies the core +// writable-open contract: digest nodes present with a stamp naming a +// different (older) ABI version make Open drop the ENTIRE digest +// state — nodes and the by_entitlement_principal_hash index alike — +// rather than trusting anything under it, so that a subsequent +// EndSync rebuilds it all from scratch at the current ABI. +func TestGrantDigestABIStaleStampDroppedAtWritableOpen(t *testing.T) { + ctx := context.Background() + const entID = "ent-A" + const n = 20 + + e, dbDir, syncID := sealedGrantDigestEngine(t, entID, n) + require.NotZero(t, digestNodeCount(t, e), "precondition: seal must have built digest nodes") + require.NotZero(t, entHashIndexRowCount(t, e, entID), "precondition: seal must have built hash-index rows") + require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e), "precondition: oracle must pass right after seal") + + setABIStamp(t, e, 1) // a fake old ABI version + require.NoError(t, e.Close()) + + e2, err := Open(ctx, dbDir) + require.NoError(t, err, "writable open over a stale-ABI stamp must not error") + t.Cleanup(func() { _ = e2.Close() }) + + require.Zero(t, digestNodeCount(t, e2), "stale-ABI writable open must drop every digest node") + require.Zero(t, countKeyRangeTest(t, e2, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + "stale-ABI writable open must drop the whole hash index") + _, ok, err := e2.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.False(t, ok, "global root must read as absent after the drop") + require.False(t, e2.grantDigestAbiStale.Load(), "a writable open must drop the state, never set the read-only stale flag") + require.False(t, e2.db.GrantDigestsPresent()) + + // Reseal through the normal repair path (resume the sync + + // EndSync, like a real second process would) and require the + // rebuilt state to check out. + a2 := NewAdapter(e2) + require.NoError(t, a2.SetCurrentSync(ctx, syncID)) + require.NoError(t, a2.EndSync(ctx)) + + require.NotZero(t, digestNodeCount(t, e2), "reseal must rebuild digest nodes") + require.EqualValues(t, n, entHashIndexRowCount(t, e2, entID), "reseal must rebuild every hash-index row") + require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e2), "oracle must pass over the rebuilt state") + + stampVal, closer, err := e2.db.Get(rawdb.GrantDigestABIStampKey()) + require.NoError(t, err) + gotStamp := append([]byte(nil), stampVal...) + closer.Close() + require.Equal(t, grantDigestABIStampValue(), gotStamp, "reseal must write the CURRENT ABI version") +} + +// TestGrantDigestABIMissingStampTreatedAsStale is +// TestGrantDigestABIStaleStampDroppedAtWritableOpen but with the stamp +// key DELETED outright rather than rewritten — simulating a file +// sealed by a pre-stamp SDK build (digest nodes exist, no stamp at +// all). Absence-with-nodes-present must be treated exactly like a +// wrong-version stamp: dropped wholesale by a writable open. +func TestGrantDigestABIMissingStampTreatedAsStale(t *testing.T) { + ctx := context.Background() + const entID = "ent-A" + const n = 20 + + e, dbDir, syncID := sealedGrantDigestEngine(t, entID, n) + require.NotZero(t, digestNodeCount(t, e), "precondition: seal must have built digest nodes") + + deleteABIStamp(t, e) + _, _, err := e.db.Get(rawdb.GrantDigestABIStampKey()) + require.ErrorIs(t, err, pebble.ErrNotFound, "precondition: stamp key must be gone") + require.NoError(t, e.Close()) + + e2, err := Open(ctx, dbDir) + require.NoError(t, err, "writable open over digest nodes with NO stamp at all must not error") + t.Cleanup(func() { _ = e2.Close() }) + + require.Zero(t, digestNodeCount(t, e2), "missing-stamp writable open must drop every digest node") + require.Zero(t, countKeyRangeTest(t, e2, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + "missing-stamp writable open must drop the whole hash index") + _, ok, err := e2.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.False(t, ok, "global root must read as absent after the drop") + require.False(t, e2.grantDigestAbiStale.Load()) + + a2 := NewAdapter(e2) + require.NoError(t, a2.SetCurrentSync(ctx, syncID)) + require.NoError(t, a2.EndSync(ctx)) + + require.NotZero(t, digestNodeCount(t, e2), "reseal must rebuild digest nodes") + require.EqualValues(t, n, entHashIndexRowCount(t, e2, entID), "reseal must rebuild every hash-index row") + require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e2), "oracle must pass over the rebuilt state") + + stampVal, closer, err := e2.db.Get(rawdb.GrantDigestABIStampKey()) + require.NoError(t, err) + gotStamp := append([]byte(nil), stampVal...) + closer.Close() + require.Equal(t, grantDigestABIStampValue(), gotStamp, "reseal must write the CURRENT ABI version") +} + +// TestGrantDigestABIStaleReadOnlyOpen verifies the read-only-open +// counterpart: a read-only Open can never drop anything, so a +// stale-ABI file must instead make the digest root getters report +// "never built" (ok=false, err=nil) while leaving every underlying key +// exactly where it was on disk. +func TestGrantDigestABIStaleReadOnlyOpen(t *testing.T) { + ctx := context.Background() + const entID = "ent-A" + + e, dbDir, _ := sealedGrantDigestEngine(t, entID, 20) + setABIStamp(t, e, 1) + require.NoError(t, e.Close()) + + e2, err := Open(ctx, dbDir, WithReadOnly(true)) + require.NoError(t, err, "read-only open over a stale-ABI stamp must not error") + t.Cleanup(func() { _ = e2.Close() }) + + require.True(t, e2.grantDigestAbiStale.Load(), "read-only open must set the stale flag rather than drop") + + _, ok, err := e2.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.False(t, ok, "global root must report not-built under a stale ABI") + + _, ok, err = e2.GetEntitlementDigestRoot(ctx, testEntIdentity(entID)) + require.NoError(t, err) + require.False(t, ok, "entitlement root must report not-built under a stale ABI") + + // Nothing was dropped: the keys are still on disk. + require.NotZero(t, digestNodeCount(t, e2), "read-only open must not drop digest nodes") + require.NotZero(t, countKeyRangeTest(t, e2, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + "read-only open must not drop the hash index") +} + +// TestGrantDigestABIStampOrphanIgnored verifies the "empty node +// keyspace" carve-out: a stamp naming the WRONG version sitting over +// an otherwise digest-EMPTY file (no digest nodes ever built) must be +// left alone by a writable open — there is nothing to trust or drop — +// and a subsequent normal sync+seal must build fine and end with the +// CURRENT stamp. +func TestGrantDigestABIStampOrphanIgnored(t *testing.T) { + ctx := context.Background() + const entID = "ent-A" + const n = 10 + + e, dir := newTestEngine(t) + setABIStamp(t, e, 999) // wrong version, no digest nodes exist at all + require.False(t, e.db.GrantDigestsPresent(), "precondition: no digest nodes exist yet") + require.NoError(t, e.Close()) + + dbDir := filepath.Join(dir, "db") + e2, err := Open(ctx, dbDir) + require.NoError(t, err, "writable open over an orphaned stamp with an empty node keyspace must not error") + t.Cleanup(func() { _ = e2.Close() }) + require.False(t, e2.grantDigestAbiStale.Load()) + + a2 := NewAdapter(e2) + _, err = a2.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + require.NoError(t, err) + putEnt(t, e2, ctx, entID) + require.NoError(t, e2.PutGrantRecords(ctx, makeTestGrants(entID, n)...)) + require.NoError(t, a2.EndSync(ctx)) + + root, ok, err := e2.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, ok, "digests must build fine over an ignored orphan stamp") + require.EqualValues(t, n, root.Count) + require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e2)) + + stampVal, closer, err := e2.db.Get(rawdb.GrantDigestABIStampKey()) + require.NoError(t, err) + gotStamp := append([]byte(nil), stampVal...) + closer.Close() + require.Equal(t, grantDigestABIStampValue(), gotStamp) +} + +// TestGrantDigestABIOracle validates verifyGrantHashIndexAgainstPrimaries +// itself: it must pass right after a clean seal, and it must detect a +// deliberately tampered hash-index row (proving it is a real oracle, +// not a tautology) — the same helper TestGrantDigestABIStaleStampDroppedAtWritableOpen +// and TestGrantDigestABIMissingStampTreatedAsStale rely on to certify a +// rebuilt file. +func TestGrantDigestABIOracle(t *testing.T) { + const entID = "ent-A" + e, _ := newTestEngine(t) + seedEntitlement(t, e, entID, makeTestGrants(entID, 20)) + + require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e), "oracle must pass right after a clean seal") + + // Flip a byte in one hash-index row's stored content hash. There is + // no production API for this (the family's writers only ever Set a + // row they themselves derived), so this goes through the raw + // pebble handle — exactly the corruption-planter use case + // rawdb.DB.UnsafeForTesting documents. + prefix := rawdb.GrantHashIndexEntitlementPrefix(testEntPartition(entID)) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: prefix, UpperBound: upperBoundOf(prefix)}) + require.NoError(t, err) + require.True(t, iter.First(), "expected at least one hash-index row to tamper") + key := append([]byte(nil), iter.Key()...) + val := append([]byte(nil), iter.Value()...) + require.NoError(t, iter.Close()) + + val[len(val)-1] ^= 0xFF + require.NoError(t, e.db.UnsafeForTesting().Set(key, val, pebble.Sync)) + + err = verifyGrantHashIndexAgainstPrimaries(t, e) + require.Error(t, err, "the oracle must detect a tampered hash-index row") +} + +// TestGrantDigestABIStaleWithPendingMarker verifies Open handles BOTH +// crash markers armed at once: a stale ABI stamp AND the digest-build +// pending marker (encodeGrantDigestBuildPendingKey, the crash-window +// guard grant_digest_build_crash_test.go exercises on its own). Open +// must succeed, end with every digest range empty, and still support a +// normal reseal afterward. +func TestGrantDigestABIStaleWithPendingMarker(t *testing.T) { + ctx := context.Background() + const entID = "ent-A" + const n = 20 + + e, dbDir, syncID := sealedGrantDigestEngine(t, entID, n) + require.NotZero(t, digestNodeCount(t, e), "precondition: seal must have built digest nodes") + + setABIStamp(t, e, 1) + require.NoError(t, e.db.MetaSet(encodeGrantDigestBuildPendingKey(), nil, pebble.Sync)) + require.NoError(t, e.Close()) + + e2, err := Open(ctx, dbDir) + require.NoError(t, err, "open must succeed with both the stale stamp and the pending marker armed") + t.Cleanup(func() { _ = e2.Close() }) + + require.False(t, e2.grantDigestBuildPending.Load(), "the pending marker must be consumed at open") + require.Zero(t, digestNodeCount(t, e2), "digest nodes must be empty after open") + require.Zero(t, countKeyRangeTest(t, e2, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + "the hash index must be empty after open") + _, ok, err := e2.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.False(t, ok) + + a2 := NewAdapter(e2) + require.NoError(t, a2.SetCurrentSync(ctx, syncID)) + require.NoError(t, a2.EndSync(ctx)) + + require.NotZero(t, digestNodeCount(t, e2), "reseal must rebuild digest nodes") + require.EqualValues(t, n, entHashIndexRowCount(t, e2, entID)) + require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e2)) +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_build.go b/pkg/dotc1z/engine/pebble/grant_digest_build.go index 8043f53ba..a9b6f285f 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_build.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_build.go @@ -49,7 +49,7 @@ import ( type grantHashRowScratch struct { keyBuf []byte tupleBuf []byte - srcKeys [][]byte + srcKeys []grantSourceFact } // appendGrantHashIndexRow derives one hash-index row from a raw @@ -59,10 +59,11 @@ type grantHashRowScratch struct { // (appendGrantHashIndexKeyFromPrimary — no decode); // - the bucket hash is xxHash64 of the primary key's principal // region (grantPrincipalBucketHash64 — a raw sub-slice); -// - the content hash covers the primary-key tail plus the grant's -// sorted source-entitlement ids, pulled from the value with a raw -// protobuf field scan (scanGrantSourceKeysRawBytes — no proto -// unmarshal anywhere on this path). +// - the content hash covers the primary-key tail, the grant's +// immutability, and its sorted (source-entitlement id, is_direct) +// pairs, pulled from the value with a raw protobuf field scan +// (scanGrantContentFactsRawBytes — no proto unmarshal anywhere on +// this path). // // key/value are only borrowed (the sorter copies before returning). func appendGrantHashIndexRow(sorter *spillSorter, primaryKey, value []byte, s *grantHashRowScratch) error { @@ -72,15 +73,15 @@ func appendGrantHashIndexRow(sorter *spillSorter, primaryKey, value []byte, s *g // splice; reaching here means the two splitters disagree. return fmt.Errorf("grant hash index: primary key %x did not split as a 6-segment identity", primaryKey) } - srcs, err := scanGrantSourceKeysRawBytes(value, s.srcKeys[:0]) + isImmutable, srcs, err := scanGrantContentFactsRawBytes(value, s.srcKeys[:0]) if err != nil { - return fmt.Errorf("grant hash index: scan sources: %w", err) + return fmt.Errorf("grant hash index: scan content facts: %w", err) } s.srcKeys = srcs if len(srcs) > 1 { - sortByteSlices(srcs) + sortGrantSourceFacts(srcs) } - ch64, tuple := grantContentHash64(s.tupleBuf, primaryKey[grantPrimaryKeyPrefixLen:], srcs) + ch64, tuple := grantContentHash64(s.tupleBuf, primaryKey[grantPrimaryKeyPrefixLen:], isImmutable, srcs) s.tupleBuf = tuple bh64 := grantPrincipalBucketHash64(primaryKey[sep4+1:]) s.keyBuf = appendGrantHashIndexKeyFromPrimary(s.keyBuf[:0], primaryKey, sep4, bh64) @@ -285,15 +286,20 @@ func (f *grantDigestFold) closePartition() error { // finish closes the last partition, writes the whole-file global root // (the fold of every partition this build touched — see globalXor/ -// globalTotal), and commits the tail batch. The global root lands in -// the same final batch as the last partition's nodes, so it is never -// visible without them: a crash between batches can only leave the -// global root ABSENT, never present ahead of a partition it should -// have folded in. +// globalTotal) plus the ABI stamp certifying which hash version +// computed it (rawdb.GrantDigestABIStampKey — the fold's opening +// DeleteRange erased any prior stamp), and commits the tail batch. The +// global root and stamp land in the same final batch as the last +// partition's nodes, so neither is ever visible without them: a crash +// between batches can only leave them ABSENT, never present ahead of a +// partition the root should have folded in. func (f *grantDigestFold) finish() error { if err := f.closePartition(); err != nil { return err } + if err := f.batch.Set(rawdb.GrantDigestABIStampKey(), grantDigestABIStampValue()); err != nil { + return err + } if err := f.batch.Set(rawdb.GlobalGrantDigestNodeKey(), packDigestLeaf(f.globalTotal, f.globalXor[:])); err != nil { return err } @@ -487,7 +493,12 @@ func (e *Engine) buildGrantDigestsFromSpill(ctx context.Context, dir string, has } // Zero grants still means the digest WAS built (present-means- // exact — an absent global root would tell a manifest reader to - // recalculate instead of trusting "nothing to diff"). + // recalculate instead of trusting "nothing to diff"). The ABI + // stamp precedes the root: WAL prefix ordering then guarantees a + // durable root is never uncertified. + if err := e.db.DigestSet(rawdb.GrantDigestABIStampKey(), grantDigestABIStampValue(), opts); err != nil { + return err + } if err := e.db.DigestSet(rawdb.GlobalGrantDigestNodeKey(), packDigestLeaf(0, zeroDigest[:]), opts); err != nil { return err } diff --git a/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go b/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go index 4aa83cf86..5692e68aa 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go @@ -12,10 +12,19 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/codec" "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb" ) +// testSrcFact is one (source-entitlement id, is_direct) pair used to +// build sources maps in the tests below — the test-side mirror of +// grantSourceFact. +type testSrcFact struct { + id string + direct bool +} + // The seal-time digest build never decodes anything: the hash-index key // is spliced out of the grant primary key, the bucket hash is computed // over a raw sub-slice of it, the content hash over the primary tail @@ -31,15 +40,21 @@ func TestGrantDigestSpliceMatchesEncode(t *testing.T) { name string entRT, entRID, entID string prt, pid, ext string - srcs []string + immutable bool + srcs []testSrcFact }{ {name: "plain opaque ent id", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", ext: "grant-1"}, {name: "stripped ent id", entRT: "app", entRID: "github", entID: "app:github:member", prt: "user", pid: "user-42", ext: "app:github:member:user:user-42"}, - {name: "sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", ext: "g", srcs: []string{"c-src", "a-src", "b-src"}}, - {name: "embedded NUL", entRT: "a\x00pp", entRID: "git\x00hub", entID: "ent\x00x", prt: "us\x00er", pid: "id\x00", ext: "g", srcs: []string{"s\x00rc", "\x00"}}, - {name: "escape byte", entRT: "a\x01pp", entRID: "hub", entID: "ent\x01x", prt: "us\x01er", pid: "\x01id", ext: "g", srcs: []string{"\x01", "\x00"}}, + {name: "sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", ext: "g", srcs: []testSrcFact{{"c-src", true}, {"a-src", false}, {"b-src", true}}}, + {name: "immutable, no sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", ext: "g", immutable: true}, + { + name: "immutable with sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", ext: "g", + immutable: true, srcs: []testSrcFact{{"c-src", false}, {"a-src", true}}, + }, + {name: "embedded NUL", entRT: "a\x00pp", entRID: "git\x00hub", entID: "ent\x00x", prt: "us\x00er", pid: "id\x00", ext: "g", srcs: []testSrcFact{{"s\x00rc", true}, {"\x00", false}}}, + {name: "escape byte", entRT: "a\x01pp", entRID: "hub", entID: "ent\x01x", prt: "us\x01er", pid: "\x01id", ext: "g", srcs: []testSrcFact{{"\x01", false}, {"\x00", true}}}, {name: "unicode", entRT: "приложение", entRID: "гитхаб", entID: "entitlé", prt: "usér", pid: "ид-42", ext: "грант"}, - {name: "duplicate-source keys impossible but sorted singleton", entRT: "app", entRID: "gh", entID: "e", prt: "u", pid: "p", ext: "", srcs: []string{"only"}}, + {name: "duplicate-source keys impossible but sorted singleton", entRT: "app", entRID: "gh", entID: "e", prt: "u", pid: "p", ext: "", srcs: []testSrcFact{{"only", true}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -58,10 +73,15 @@ func TestGrantDigestSpliceMatchesEncode(t *testing.T) { if len(tc.srcs) > 0 { m := make(map[string]*v3.GrantSourceRecord, len(tc.srcs)) for _, s := range tc.srcs { - m[s] = v3.GrantSourceRecord_builder{}.Build() + m[s.id] = v3.GrantSourceRecord_builder{IsDirect: s.direct}.Build() } rec.SetSources(m) } + if tc.immutable { + annos := annotations.Annotations(rec.GetAnnotations()) + annos.Update(&v2.GrantImmutable{}) + rec.SetAnnotations(annos) + } id, err := grantIdentityFromRecord(rec) require.NoError(t, err) @@ -88,10 +108,11 @@ func TestGrantDigestSpliceMatchesEncode(t *testing.T) { // Content hash from raw key+value bytes == content hash from // the decoded record. - srcs, err := scanGrantSourceKeysRawBytes(val, nil) + isImmutable, srcs, err := scanGrantContentFactsRawBytes(val, nil) require.NoError(t, err) - sortByteSlices(srcs) - ch64, _ := grantContentHash64(nil, priKey[grantPrimaryKeyPrefixLen:], srcs) + require.Equal(t, tc.immutable, isImmutable, "raw-scanned isImmutable") + sortGrantSourceFacts(srcs) + ch64, _ := grantContentHash64(nil, priKey[grantPrimaryKeyPrefixLen:], isImmutable, srcs) fromRecord, err := grantContentHashForRecord(rec) require.NoError(t, err) require.Equal(t, fromRecord, binary.BigEndian.AppendUint64(nil, ch64), "content hash: raw scan vs decoded record") @@ -119,8 +140,19 @@ func TestGrantDigestSpliceMatchesEncode(t *testing.T) { } // grantV2WithSources builds the v2 proto form of a grant for the public -// hash API, with an optional source-entitlement set. +// hash API, with an optional source-entitlement set (all indirect, +// non-immutable) — the common case most tests need. func grantV2WithSources(entRT, entRID, entID, prt, pid string, sources ...string) *v2.Grant { + facts := make([]testSrcFact, len(sources)) + for i, s := range sources { + facts[i] = testSrcFact{id: s} + } + return grantV2WithSourceFacts(entRT, entRID, entID, prt, pid, false, facts...) +} + +// grantV2WithSourceFacts is grantV2WithSources's full form: an optional +// GrantImmutable annotation plus per-source is_direct. +func grantV2WithSourceFacts(entRT, entRID, entID, prt, pid string, immutable bool, sources ...testSrcFact) *v2.Grant { b := v2.Grant_builder{ Id: entID + ":" + prt + ":" + pid, Entitlement: v2.Entitlement_builder{ @@ -142,11 +174,17 @@ func grantV2WithSources(entRT, entRID, entID, prt, pid string, sources ...string if len(sources) > 0 { m := make(map[string]*v2.GrantSources_GrantSource, len(sources)) for _, s := range sources { - m[s] = v2.GrantSources_GrantSource_builder{}.Build() + m[s.id] = v2.GrantSources_GrantSource_builder{IsDirect: s.direct}.Build() } b.Sources = v2.GrantSources_builder{Sources: m}.Build() } - return b.Build() + g := b.Build() + if immutable { + annos := annotations.Annotations(g.GetAnnotations()) + annos.Update(&v2.GrantImmutable{}) + g.SetAnnotations(annos) + } + return g } // TestGrantContentHashMatchesRecord pins the public from-v2 form of the @@ -160,18 +198,21 @@ func TestGrantContentHashMatchesRecord(t *testing.T) { name string entRT, entRID, entID string prt, pid string - srcs []string + immutable bool + srcs []testSrcFact }{ {name: "plain opaque ent id", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42"}, {name: "stripped ent id", entRT: "app", entRID: "github", entID: "app:github:member", prt: "user", pid: "user-42"}, - {name: "sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", srcs: []string{"c-src", "a-src", "b-src"}}, - {name: "embedded NUL", entRT: "a\x00pp", entRID: "git\x00hub", entID: "ent\x00x", prt: "us\x00er", pid: "id\x00", srcs: []string{"s\x00rc", "\x00"}}, - {name: "escape byte", entRT: "a\x01pp", entRID: "hub", entID: "ent\x01x", prt: "us\x01er", pid: "\x01id", srcs: []string{"\x01", "\x00"}}, + {name: "sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", srcs: []testSrcFact{{"c-src", true}, {"a-src", false}, {"b-src", true}}}, + {name: "immutable, no sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", immutable: true}, + {name: "immutable with sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", immutable: true, srcs: []testSrcFact{{"c-src", false}, {"a-src", true}}}, + {name: "embedded NUL", entRT: "a\x00pp", entRID: "git\x00hub", entID: "ent\x00x", prt: "us\x00er", pid: "id\x00", srcs: []testSrcFact{{"s\x00rc", true}, {"\x00", false}}}, + {name: "escape byte", entRT: "a\x01pp", entRID: "hub", entID: "ent\x01x", prt: "us\x01er", pid: "\x01id", srcs: []testSrcFact{{"\x01", false}, {"\x00", true}}}, {name: "unicode", entRT: "приложение", entRID: "гитхаб", entID: "entitlé", prt: "usér", pid: "ид-42"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - g := grantV2WithSources(tc.entRT, tc.entRID, tc.entID, tc.prt, tc.pid, tc.srcs...) + g := grantV2WithSourceFacts(tc.entRT, tc.entRID, tc.entID, tc.prt, tc.pid, tc.immutable, tc.srcs...) want, err := grantContentHashForRecord(V2GrantToV3("", g)) require.NoError(t, err) got, err := GrantContentHash(g) @@ -181,6 +222,34 @@ func TestGrantContentHashMatchesRecord(t *testing.T) { } } +// TestGrantContentHashDistinguishesImmutabilityAndDirectness pins the +// ABI v2 addition (GrantDigestABIVersion): two grants with an identical +// identity tuple and source-id set, differing only in isImmutable or +// one source's is_direct, must hash differently. Every other case in +// this file uses a single fixed value for both, so a framing bug that +// silently turned either back into a no-op (regressing to v1's blind +// spot — see grantContentHash64's ABI doc) would otherwise go +// unnoticed. +func TestGrantContentHashDistinguishesImmutabilityAndDirectness(t *testing.T) { + base := grantV2WithSourceFacts("app", "github", "ent-1", "user", "user-42", false, + testSrcFact{"src-a", false}, testSrcFact{"src-b", true}) + immutable := grantV2WithSourceFacts("app", "github", "ent-1", "user", "user-42", true, + testSrcFact{"src-a", false}, testSrcFact{"src-b", true}) + flippedDirect := grantV2WithSourceFacts("app", "github", "ent-1", "user", "user-42", false, + testSrcFact{"src-a", true}, testSrcFact{"src-b", true}) + + baseHash, err := GrantContentHash(base) + require.NoError(t, err) + immutableHash, err := GrantContentHash(immutable) + require.NoError(t, err) + directHash, err := GrantContentHash(flippedDirect) + require.NoError(t, err) + + require.NotEqual(t, baseHash, immutableHash, "isImmutable must change the content hash") + require.NotEqual(t, baseHash, directHash, "a source's is_direct must change the content hash") + require.NotEqual(t, immutableHash, directHash) +} + // TestGrantContentHashMissingIdentity pins the error half of the // divergence contract: a grant missing any structural identity part // cannot exist in a pebble file, so it must error rather than hash. diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair.go b/pkg/dotc1z/engine/pebble/grant_digest_repair.go index ef93fb070..0f5e2cc3b 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_repair.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair.go @@ -461,16 +461,16 @@ func (e *Engine) repairOneGrantDigestPartitionLocked(ctx context.Context, partit droppedMalformedKeys++ continue } - srcs, serr := scanGrantSourceKeysRawBytes(value, scratch.srcKeys[:0]) + isImmutable, srcs, serr := scanGrantContentFactsRawBytes(value, scratch.srcKeys[:0]) if serr != nil { _ = iter.Close() return serr } scratch.srcKeys = srcs if len(srcs) > 1 { - sortByteSlices(srcs) + sortGrantSourceFacts(srcs) } - ch64, tuple := grantContentHash64(scratch.tupleBuf, key[grantPrimaryKeyPrefixLen:], srcs) + ch64, tuple := grantContentHash64(scratch.tupleBuf, key[grantPrimaryKeyPrefixLen:], isImmutable, srcs) scratch.tupleBuf = tuple bh64 := grantPrincipalBucketHash64(key[sep4+1:]) scratch.keyBuf = appendGrantHashIndexKeyFromPrimary(scratch.keyBuf[:0], key, sep4, bh64) @@ -583,6 +583,16 @@ func (e *Engine) recomputeGrantDigestGlobalRootLocked(ctx context.Context) error if e.IsFreshSync() { opts = pebble.NoSync } + // Re-stamp the ABI with the root. Redundant when the stamp survived + // (only full-range deletes remove it, and those remove the roots + // this recompute folds too), but writing both here keeps the + // invariant locally checkable: every global-root write site + // certifies the ABI that produced the state under it. Stamp first — + // WAL prefix ordering then guarantees a durable root is never + // uncertified. + if err := e.db.DigestSet(rawdb.GrantDigestABIStampKey(), grantDigestABIStampValue(), opts); err != nil { + return err + } if err := e.db.DigestSet(rawdb.GlobalGrantDigestNodeKey(), packDigestLeaf(total, xor[:]), opts); err != nil { return err } diff --git a/pkg/dotc1z/engine/pebble/index_migrations.go b/pkg/dotc1z/engine/pebble/index_migrations.go index b94e8a670..492e77d3d 100644 --- a/pkg/dotc1z/engine/pebble/index_migrations.go +++ b/pkg/dotc1z/engine/pebble/index_migrations.go @@ -72,6 +72,15 @@ type indexMigration struct { // bait (unbounded latency and memory at Open on large files); prefer // seal-time derivation or explicit rebuild commands over registering // one here. +// +// GrantDigestABIVersion bumps in particular do NOT belong here: a +// migration records that it ran once, but old binaries can rewrite +// digest state afterwards without re-triggering it. The digest ABI is +// instead enforced by a stamp stored WITH the state +// (rawdb.GrantDigestABIStampKey, checked every Open by +// verifyGrantDigestABI), so re-polluted state is re-detected — and the +// remedy is again a cheap drop plus seal-time rebuild, never an +// Open-time backfill. var indexMigrations []indexMigration // applyIndexMigrations runs on engine Open (writable opens only — diff --git a/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go b/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go index 19ee79095..09a133a8b 100644 --- a/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go +++ b/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go @@ -70,6 +70,14 @@ const GrantPrimaryKeyPrefixLen = 3 // per-partition node regardless of partition bytes. const DigestLevelGlobalRoot byte = 2 +// DigestMetaIndexID is the reserved index-discriminator for +// engine-owned metadata keys inside the digest keyspace (today only +// GrantDigestABIStampKey). 0xFF sorts after every real digested +// index, so [v3|TypeDigest, v3|TypeDigest|DigestMetaIndexID) bounds +// exactly the digest NODES (see DigestNodeKeyspaceBounds). No +// digestIndexSpec may ever claim this byte. +const DigestMetaIndexID byte = 0xFF + // === grant primary-key splices === // SplitGrantPrimaryKey locates the partition/principal boundary of a @@ -332,9 +340,31 @@ func DeferredIdxPendingKey() []byte { return codec.AppendTupleStrings(buf, "deferred_grant_idx_pending") } -// DigestKeyspaceBounds bounds the entire digest keyspace (all digested -// indexes) — the presence-probe range for the digests-present flag. -func DigestKeyspaceBounds() ([]byte, []byte) { - lo := []byte{VersionV3, TypeDigest} - return lo, UpperBound(lo) +// GrantDigestABIStampKey is the durable record of which grant-digest +// hash ABI (the engine's GrantDigestABIVersion) this file's digest +// state — hash-index values and digest nodes — was computed under. +// Value: uint32 BE. Written only alongside the whole-file global root +// (the same present-means-exact certificate), read only at Open. +// +// It lives INSIDE the digest keyspace deliberately: every wholesale +// destroyer of digest state — the drop paths' full-range deletes, +// ResetForNewSync's excision, the fold build's opening DeleteRange — +// erases it without knowing it exists, INCLUDING the copies of those +// paths in already-shipped SDKs that predate the stamp. Absence with +// digest nodes present therefore always means "built by code stamping +// a different ABI (or none)" → the state must be dropped and rebuilt. +// Under DigestMetaIndexID so no node scan or presence probe visits it. +func GrantDigestABIStampKey() []byte { + buf := make([]byte, 0, 3+len("grant_digest_abi")+2) + buf = append(buf, VersionV3, TypeDigest, DigestMetaIndexID) + return codec.AppendTupleStrings(buf, "grant_digest_abi") +} + +// DigestNodeKeyspaceBounds bounds the digest NODE keyspace: all +// digested indexes, excluding the DigestMetaIndexID metadata sub-range +// — the presence-probe range for the digests-present flag. The ABI +// stamp must not arm that flag: presence gates mutation-path +// invalidation and repair delegation, which are about nodes. +func DigestNodeKeyspaceBounds() ([]byte, []byte) { + return []byte{VersionV3, TypeDigest}, []byte{VersionV3, TypeDigest, DigestMetaIndexID} } diff --git a/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go b/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go index 9b7912357..ae392bb0e 100644 --- a/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go +++ b/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go @@ -220,9 +220,11 @@ func (d *DB) GrantDigestsPresent() bool { return d.grantDigestsPresent.Load() } func (d *DB) SetGrantDigestsPresent(present bool) { d.grantDigestsPresent.Store(present) } // ProbeGrantDigestsPresent initializes the presence flag with one -// bounded seek over the digest keyspace (the Open-time probe). +// bounded seek over the digest NODE keyspace (the Open-time probe). +// The ABI stamp's metadata sub-range is outside the bounds: a file +// holding only a leftover stamp has no digest state to invalidate. func (d *DB) ProbeGrantDigestsPresent() error { - lo, hi := DigestKeyspaceBounds() + lo, hi := DigestNodeKeyspaceBounds() iter, err := d.db.NewIter(&pebble.IterOptions{LowerBound: lo, UpperBound: hi}) if err != nil { return err diff --git a/pkg/dotc1z/engine/pebble/raw_records.go b/pkg/dotc1z/engine/pebble/raw_records.go index a3db1e826..fd2a8e6a0 100644 --- a/pkg/dotc1z/engine/pebble/raw_records.go +++ b/pkg/dotc1z/engine/pebble/raw_records.go @@ -1,6 +1,7 @@ package pebble import ( + "bytes" "fmt" "math" "time" @@ -189,60 +190,185 @@ func scanGrantEntitlementResourceTypeRaw(value []byte) ([]byte, error) { return entRT, nil } -// scanGrantSourceKeysRawBytes extracts the source-entitlement ID keys -// from a marshaled GrantRecord without a full unmarshal. Sources are -// field 9 (map), encoded as repeated embedded -// messages each with sub-field 1 = key string. The keys are views -// borrowed from value (valid only while value's backing bytes are), -// appended to keys — pass a recycled keys[:0] to reuse its backing -// array across calls. The seal-time grant digest build calls this once -// per grant (see appendGrantHashIndexRow). -func scanGrantSourceKeysRawBytes(value []byte, out [][]byte) ([][]byte, error) { +// grantImmutableAnnotationTypeName is the c1.connector.v2.GrantImmutable +// message's fully-qualified name — the tail of its anypb type URL +// ("type.googleapis.com/c1.connector.v2.GrantImmutable"). Matched by +// name, never by unmarshaling the annotation payload: existence is the +// only fact the content hash folds in (see grantContentHash64's ABI doc +// in grant_digest.go). +const grantImmutableAnnotationTypeName = "c1.connector.v2.GrantImmutable" + +// scanGrantContentFactsRawBytes extracts the two grant-content facts a +// marshaled GrantRecord's value carries beyond its primary-key identity, +// in one raw field scan (no proto unmarshal): +// +// - isImmutable: whether `annotations` (field 8, repeated +// google.protobuf.Any) carries a GrantImmutable entry, checked +// against the tail of the embedded Any's type_url (its own field 1) +// without unmarshaling the annotation payload. +// - sources: the `sources` map (field 9, map) +// as (key, is_direct) pairs — a map entry is a submessage with +// sub-field 1 = key string, sub-field 2 = the GrantSourceRecord +// value, whose own field 4 is is_direct. +// +// out is a recycled scratch slice (pass out[:0] to reuse its backing +// array across calls, as the seal-time grant digest build does — see +// appendGrantHashIndexRow); its key slices are views borrowed from +// value, valid only while value's backing bytes are. Sources are +// returned in encounter order, NOT sorted — callers sort by key +// themselves (sortGrantSourceFacts). +func scanGrantContentFactsRawBytes(value []byte, out []grantSourceFact) (bool, []grantSourceFact, error) { + isImmutable := false for len(value) > 0 { num, typ, n := protowire.ConsumeTag(value) if n < 0 { - return nil, protowire.ParseError(n) + return false, nil, protowire.ParseError(n) } value = value[n:] - if num != 9 { + switch num { + case 8: + if typ != protowire.BytesType { + return false, nil, fmt.Errorf("raw record: grant annotations entry has wire type %v", typ) + } + entry, en := protowire.ConsumeBytes(value) + if en < 0 { + return false, nil, protowire.ParseError(en) + } + value = value[en:] + if !isImmutable { + var err error + isImmutable, err = scanAnyEntryIsTypeRaw(entry, grantImmutableAnnotationTypeName) + if err != nil { + return false, nil, err + } + } + case 9: + if typ != protowire.BytesType { + return false, nil, fmt.Errorf("raw record: grant sources entry has wire type %v", typ) + } + entry, en := protowire.ConsumeBytes(value) + if en < 0 { + return false, nil, protowire.ParseError(en) + } + value = value[en:] + var key []byte + var isDirect bool + for len(entry) > 0 { + eNum, eTyp, ren := protowire.ConsumeTag(entry) + if ren < 0 { + return false, nil, protowire.ParseError(ren) + } + entry = entry[ren:] + switch { + case eNum == 1 && eTyp == protowire.BytesType: + k, kn := protowire.ConsumeBytes(entry) + if kn < 0 { + return false, nil, protowire.ParseError(kn) + } + key = k + entry = entry[kn:] + case eNum == 2 && eTyp == protowire.BytesType: + v, vn := protowire.ConsumeBytes(entry) + if vn < 0 { + return false, nil, protowire.ParseError(vn) + } + var err error + isDirect, err = scanGrantSourceRecordIsDirectRaw(v) + if err != nil { + return false, nil, err + } + entry = entry[vn:] + default: + ren = protowire.ConsumeFieldValue(eNum, eTyp, entry) + if ren < 0 { + return false, nil, protowire.ParseError(ren) + } + entry = entry[ren:] + } + } + out = append(out, grantSourceFact{key: key, isDirect: isDirect}) + default: n = protowire.ConsumeFieldValue(num, typ, value) if n < 0 { - return nil, protowire.ParseError(n) + return false, nil, protowire.ParseError(n) } value = value[n:] + } + } + return isImmutable, out, nil +} + +// scanAnyEntryIsTypeRaw reports whether one serialized google.protobuf.Any +// entry names typeName, checked against the tail of its type_url (field +// 1) without unmarshaling the payload (field 2). +// +// Stays on []byte throughout and keeps the final string(tail) conversion +// INLINE in the comparison: the compiler rewrites string(b) == s in that +// position to a non-allocating alias of b's backing array (OBYTES2STRTMP +// — safe because a comparison cannot retain its operands). Hoisting the +// conversion into a local would silently restore a copy, and +// protowire.ConsumeString is exactly that copy — it heap-allocates for +// anything over 32 bytes, which every real type URL is. The seal-time +// digest build calls this once per annotation per grant, and that path +// must not allocate per row (see grantHashRowScratch). +func scanAnyEntryIsTypeRaw(entry []byte, typeName string) (bool, error) { + for len(entry) > 0 { + num, typ, n := protowire.ConsumeTag(entry) + if n < 0 { + return false, protowire.ParseError(n) + } + entry = entry[n:] + if num != 1 { + n = protowire.ConsumeFieldValue(num, typ, entry) + if n < 0 { + return false, protowire.ParseError(n) + } + entry = entry[n:] continue } if typ != protowire.BytesType { - return nil, fmt.Errorf("raw record: grant sources entry has wire type %v", typ) + return false, fmt.Errorf("raw record: any type_url has wire type %v", typ) + } + url, un := protowire.ConsumeBytes(entry) + if un < 0 { + return false, protowire.ParseError(un) } - entry, n := protowire.ConsumeBytes(value) + name := url + if i := bytes.LastIndexByte(url, '/'); i >= 0 { + name = url[i+1:] + } + return string(name) == typeName, nil + } + return false, nil +} + +// scanGrantSourceRecordIsDirectRaw extracts is_direct (GrantSourceRecord +// field 4) from one marshaled map-entry value. +func scanGrantSourceRecordIsDirectRaw(value []byte) (bool, error) { + for len(value) > 0 { + num, typ, n := protowire.ConsumeTag(value) if n < 0 { - return nil, protowire.ParseError(n) + return false, protowire.ParseError(n) } value = value[n:] - for len(entry) > 0 { - eNum, eTyp, en := protowire.ConsumeTag(entry) - if en < 0 { - return nil, protowire.ParseError(en) - } - entry = entry[en:] - if eNum == 1 && eTyp == protowire.BytesType { - k, kn := protowire.ConsumeBytes(entry) - if kn < 0 { - return nil, protowire.ParseError(kn) - } - out = append(out, k) - entry = entry[kn:] - } else { - en = protowire.ConsumeFieldValue(eNum, eTyp, entry) - if en < 0 { - return nil, protowire.ParseError(en) - } - entry = entry[en:] + if num != 4 { + n = protowire.ConsumeFieldValue(num, typ, value) + if n < 0 { + return false, protowire.ParseError(n) } + value = value[n:] + continue + } + if typ != protowire.VarintType { + return false, fmt.Errorf("raw record: grant source is_direct has wire type %v", typ) + } + v, n := protowire.ConsumeVarint(value) + if n < 0 { + return false, protowire.ParseError(n) } + return v != 0, nil } - return out, nil + return false, nil } // scanEntitlementResourceTypeRaw extracts only the entitlement's