diff --git a/pkg/dotc1z/engine/pebble/digest.go b/pkg/dotc1z/engine/pebble/digest.go index d05951a14..f72b0c5e1 100644 --- a/pkg/dotc1z/engine/pebble/digest.go +++ b/pkg/dotc1z/engine/pebble/digest.go @@ -494,12 +494,10 @@ 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() { - // 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". + if e.grantDigestStateUntrusted() { + // See grantDigestStateUntrusted: no stored root may be trusted + // while either flag is set — report "never built", which every + // consumer already treats as "recalculate". 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 9ac18502d..92d587abf 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 acf267eee..093a08f16 100644 --- a/pkg/dotc1z/engine/pebble/engine.go +++ b/pkg/dotc1z/engine/pebble/engine.go @@ -133,6 +133,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). @@ -307,6 +318,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 + } // Arm the mutation-path source-scope index obligations iff the file // actually holds by_source_scope entries (bounded seeks, same // contract as the digest probe): scope-free stores keep the exact @@ -572,6 +595,33 @@ func (e *Engine) IsFreshSync() bool { // See WithGrantDigestIndex. func (e *Engine) GrantDigestIndexEnabled() bool { return e.opts.grantDigestIndex } +// GrantDigestsPresent reports whether this engine currently holds ANY +// grant-digest state (nodes + the by_entitlement_principal_hash index +// beneath them) — the same Open-probed flag the record write paths +// gate their per-write invalidation obligation on. Exported for +// callers outside this package that need to tell "no digest state at +// all" apart from "digest state present but stale/invalidated" (e.g. +// the compactor's fold, deciding whether a byte-copied base needs a +// one-time digest build). +func (e *Engine) GrantDigestsPresent() bool { return e.db.GrantDigestsPresent() } + +// grantDigestStateUntrusted reports whether NO stored grant-digest +// state — digest nodes, the whole-file root, or the +// by_entitlement_principal_hash index beneath them — may be trusted +// right now. Both flags it OR's together mean this by construction: +// grantDigestBuildPending means an interrupted build may have left +// digest nodes durable while the hash index under them never finished +// ingesting; grantDigestAbiStale means the nodes and hash-index +// content hashes were computed by a different hash ABI (a read-only +// open of a file whose stamp doesn't name the current +// GrantDigestABIVersion). Either way, every getter and on-demand fold +// over that state must report "not built" rather than trust or +// recompute from it — see getPartitionDigestRoot, +// GetGrantDigestGlobalRoot, and ComputeEntitlementBucketDigest. +func (e *Engine) grantDigestStateUntrusted() bool { + return e.grantDigestBuildPending.Load() || e.grantDigestAbiStale.Load() +} + // takeFreshGrantsEmpty / takeFreshResourcesEmpty return true // exactly once per fresh sync, for the first PutXxxRecords call // of that type after diff --git a/pkg/dotc1z/engine/pebble/grant_digest.go b/pkg/dotc1z/engine/pebble/grant_digest.go index c13e4eb53..f395eadf6 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" @@ -55,13 +57,102 @@ 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. +// A file with digest nodes but NO stamp was sealed by an SDK that +// predates the stamp; those builds all hashed at version 1, so absence +// reads as grantDigestABIVersionUnstamped and is current for as long +// as this constant stays 1 — introducing the stamp costs no rebuild. +// No index-migration entry is needed — see the note on digest-ABI +// handling in index_migrations.go. // // Exported so consumers of GrantContentHash / GrantDigestAccumulator // can check a stored root's abi_version before comparing. const GrantDigestABIVersion uint32 = 1 +// grantDigestABIVersionUnstamped is the ABI version a file with digest +// nodes but no stamp key is read as: every SDK build that predates the +// stamp hashed at version 1. Fixed forever — it describes shipped +// history, not the current ABI, and must not move when +// GrantDigestABIVersion does. +const grantDigestABIVersionUnstamped uint32 = 1 + +// 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) whose stamped ABI version (readGrantDigestABIStamp) +// is not 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 + } + stamped, err := e.readGrantDigestABIStamp() + if err != nil { + return err + } + if stamped == GrantDigestABIVersion { + return nil + } + 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("stamped_abi", stamped), + zap.Uint32("current_abi", GrantDigestABIVersion)) + return e.dropAllGrantDigestStateLocked() +} + +// readGrantDigestABIStamp returns the ABI version the file's digest +// state is stamped with. A missing stamp key reads as +// grantDigestABIVersionUnstamped (the pre-stamp SDKs all hashed at +// version 1); a malformed value reads as 0, which no real ABI version +// is, so it can never pass as current. Only meaningful when digest +// nodes are present — with none, there is no state for the stamp to +// describe. +func (e *Engine) readGrantDigestABIStamp() (uint32, error) { + val, closer, err := e.db.Get(rawdb.GrantDigestABIStampKey()) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return grantDigestABIVersionUnstamped, nil + } + return 0, err + } + defer closer.Close() + if len(val) != 4 { + return 0, nil + } + return binary.BigEndian.Uint32(val), nil +} + // The whole-file grant digest root's node-key level lives in // internal/keys (rawdb.DigestLevelGlobalRoot, consumed by // rawdb.GlobalGrantDigestNodeKey): the XOR fold of every @@ -85,7 +176,8 @@ func digestPartitionForEntitlement(id entitlementIdentity) string { // ABI: the two hash definitions below are part of the stored format. // Two SDK builds must hash identical grants identically or the digest // comparison reads "everything differs"; changing either input framing -// requires an index-migration bump (index_migrations.go). +// requires a GrantDigestABIVersion bump (which the durable ABI stamp +// then enforces at Open — no index migration is involved). // grantPrincipalBucketHash64 is the bucket address for a principal: // xxHash64 over the ENCODED principal segments @@ -110,7 +202,7 @@ func grantPrincipalBucketHash64(encodedPrincipalSegments []byte) uint64 { // ScanEntitlementGrantBucket). // // ABI: the stored truncation width, pinned to GrantDigestABIVersion. It may -// only grow, and only under an index-migration bump — which is why it is a +// only grow, and only under a GrantDigestABIVersion bump — which is why it is a // named constant rather than a literal in PrincipalBucketHash's signature: // widening the addressable bucket space must not change that signature. const DigestBucketHashBits = digestBucketHashLen * 8 @@ -146,7 +238,7 @@ const DigestBucketHashBits = digestBucketHashLen * 8 // // ABI: pinned to GrantDigestABIVersion alongside GrantContentHash. Two // SDK builds must place the same principal in the same bucket, so the -// input framing changes only under an index-migration bump. +// input framing changes only under a GrantDigestABIVersion bump. func PrincipalBucketHash(principalRT, principalID string) uint64 { enc := codec.AppendTupleStrings(make([]byte, 0, 64), principalRT, principalID) return grantPrincipalBucketHash64(enc) @@ -437,10 +529,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() { + if e.grantDigestStateUntrusted() { // Same guard 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()) @@ -470,7 +564,18 @@ func (e *Engine) GetGrantDigestGlobalRoot(ctx context.Context) (DigestRoot, bool // absent index range and returns {0, 0} — "zero grants", not "unknown". // Never use it as a fallback for a missing root; see // GetEntitlementDigestRoot and computeBucketDigest's precondition. +// +// Gated on grantDigestStateUntrusted: while either flag is set, the +// hash index this folds may be half-built (grantDigestBuildPending) or +// hold content hashes from a different ABI (grantDigestAbiStale), so +// folding it directly — unlike getPartitionDigestRoot, this method has +// no stored-root check of its own to lean on — would return digests +// derived from untrustworthy content. Report the same {0, 0} "not +// built / absent" shape a never-built partition already produces. func (e *Engine) ComputeEntitlementBucketDigest(ctx context.Context, id entitlementIdentity, bucket DigestBucket) ([]byte, int64, error) { + if e.grantDigestStateUntrusted() { + return make([]byte, hashLen), 0, nil + } return e.computeBucketDigest(ctx, grantDigestSpec, digestPartitionForEntitlement(id), bucket) } @@ -489,6 +594,17 @@ func (e *Engine) DirtyEntitlementBuckets(ctx context.Context, other *Engine, id // The primary key is reconstructed from each index key by byte splice // (no decode); the point Get per entry is the cost of MATERIALIZING a // changed grant, not of finding it. Orphan index entries are skipped. +// +// Deliberately NOT gated by grantDigestStateUntrusted, unlike +// getPartitionDigestRoot / GetGrantDigestGlobalRoot / +// ComputeEntitlementBucketDigest: a bucket's MEMBERSHIP is the +// principal bucket hash over the encoded principal identity, frozen by +// the v3 key encoding and untouched by any GrantDigestABIVersion bump +// (only the stored CONTENT hashes and digest nodes are ABI-dependent — +// see grantContentHash64 vs grantPrincipalBucketHash64). So a stale +// file's bucket placement is still exact, and this keeps yielding the +// grants a caller already knows to be dirty even on a read-only open +// over a stale ABI stamp. func (e *Engine) IterateGrantsByEntitlementBucket(ctx context.Context, id entitlementIdentity, bucket DigestBucket, yield func(*v3.GrantRecord) bool) error { lower, upper := grantDigestSpec.bucketBounds(digestPartitionForEntitlement(id), bucket) iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) 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..89a66291c --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_abi_test.go @@ -0,0 +1,536 @@ +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" + + 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/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 whose stamp (a missing +// stamp reads as version 1, the pre-stamp ABI) does not name 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 +} + +// staleABIVersion is a fake ABI version guaranteed to differ from the +// current GrantDigestABIVersion (and from the implicit version 1 that a +// missing stamp reads as), for tests that need "a stamp naming some +// other ABI". +const staleABIVersion = GrantDigestABIVersion + 1 + +// 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, staleABIVersion) // a fake ABI version that is not the current one + 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") +} + +// TestGrantDigestABIMissingStampReadsAsVersion1 pins the reading of a +// file that has digest nodes but no stamp key at all — one sealed by a +// pre-stamp SDK build. Every such build hashed at ABI version 1, so the +// stamp reader must report exactly that, independent of what the +// current GrantDigestABIVersion is: Open then treats the file like any +// other stamped-at-1 file (kept while the current ABI is 1, dropped and +// rebuilt once it is not). The reader-level assertion is the durable +// contract; the Open-level half below is only meaningful while the +// current ABI is still 1 (once it moves, the missing-stamp file is +// just another stale file, covered by +// TestGrantDigestABIStaleStampDroppedAtWritableOpen). +func TestGrantDigestABIMissingStampReadsAsVersion1(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") + nodesBefore := digestNodeCount(t, e) + + deleteABIStamp(t, e) + _, _, err := e.db.Get(rawdb.GrantDigestABIStampKey()) + require.ErrorIs(t, err, pebble.ErrNotFound, "precondition: stamp key must be gone") + + stamped, err := e.readGrantDigestABIStamp() + require.NoError(t, err) + require.EqualValues(t, 1, stamped, "a missing stamp must read as ABI version 1, the only version pre-stamp SDKs ever hashed at") + require.NoError(t, e.Close()) + + if GrantDigestABIVersion != 1 { + t.Skip("current ABI is past 1; a missing stamp is now just a stale stamp — see TestGrantDigestABIStaleStampDroppedAtWritableOpen") + } + + // Current ABI is 1: introducing the stamp must cost a pre-stamp file + // nothing. Open keeps its digest state, and the next seal merely + // adds the stamp. + 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.Equal(t, nodesBefore, digestNodeCount(t, e2), "missing-stamp file at ABI 1 must keep every digest node") + require.EqualValues(t, n, entHashIndexRowCount(t, e2, entID), "missing-stamp file at ABI 1 must keep every hash-index row") + _, ok, err := e2.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, ok, "global root must still read as present") + require.False(t, e2.grantDigestAbiStale.Load()) + require.True(t, e2.db.GrantDigestsPresent()) + require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e2), "kept state must still check out against the primaries") + + // An unstamped file stays unstamped until something rewrites the + // global root — a seal that finds nothing missing takes the repair + // fast path and writes nothing, and that is fine: absence keeps + // reading as version 1. The first root rewrite (here: one grant + // mutation invalidates its partition + the root, and EndSync's + // targeted repair rebuilds both) must stamp the file explicitly. + a2 := NewAdapter(e2) + require.NoError(t, a2.SetCurrentSync(ctx, syncID)) + require.NoError(t, e2.PutGrantRecords(ctx, makeGrant("", "g-"+entID+"-extra", entID, "user-extra"))) + require.NoError(t, a2.EndSync(ctx)) + + require.EqualValues(t, n+1, entHashIndexRowCount(t, e2, entID), "repair must cover the mutated partition") + require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e2), "repaired state must check out against the primaries") + stampVal, closer, err := e2.db.Get(rawdb.GrantDigestABIStampKey()) + require.NoError(t, err, "the first global-root rewrite must write the stamp") + gotStamp := append([]byte(nil), stampVal...) + closer.Close() + require.Equal(t, grantDigestABIStampValue(), gotStamp, "the root rewrite must stamp the file at the current ABI") +} + +// 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, staleABIVersion) + 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") +} + +// TestGrantDigestABIStaleReadOnlyGatesBucketDigest verifies +// ComputeEntitlementBucketDigest is gated by grantDigestStateUntrusted +// exactly like the root getters: on a read-only open over a stale ABI +// stamp it must report "not built" ({0, 0}), even though the hash +// index it would otherwise fold is still fully present on disk — never +// silently fold content hashes computed under a different ABI. A fresh +// non-stale seal must still report the real digest, pinning that the +// gate only trips on the untrusted-state flags, not always. +// +// ScanEntitlementGrantBucket (which reads through +// IterateGrantsByEntitlementBucket) is asserted to still yield every +// grant on the SAME stale read-only engine: bucket membership is +// deliberately NOT gated (see IterateGrantsByEntitlementBucket's +// doc comment) because it is exact regardless of ABI. +func TestGrantDigestABIStaleReadOnlyGatesBucketDigest(t *testing.T) { + ctx := context.Background() + const entID = "ent-A" + const n = 20 + + // Fresh, non-stale engine: bucket digest must report present (a + // non-zero digest and the true grant count). + fresh, _ := newTestEngine(t) + seedEntitlement(t, fresh, entID, makeTestGrants(entID, n)) + freshDigest, freshCount, err := fresh.ComputeEntitlementBucketDigest(ctx, testEntIdentity(entID), DigestBucket{}) + require.NoError(t, err) + require.EqualValues(t, n, freshCount, "non-stale engine must report the real grant count") + require.NotEqual(t, make([]byte, hashLen), freshDigest, "non-stale engine must report a real, non-zero digest") + + // Stale read-only engine: same call must report "not built". + e, dbDir, _ := sealedGrantDigestEngine(t, entID, n) + setABIStamp(t, e, staleABIVersion) + 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(), "precondition: stale flag must be set") + + staleDigest, staleCount, err := e2.ComputeEntitlementBucketDigest(ctx, testEntIdentity(entID), DigestBucket{}) + require.NoError(t, err) + require.Zero(t, staleCount, "stale ABI must report not-built, not the real count") + require.Equal(t, make([]byte, hashLen), staleDigest, "stale ABI must report the zero digest, not one folded from untrusted content hashes") + + // Bucket membership (not content) is exact regardless of ABI: the + // grants must still come back through ScanEntitlementGrantBucket on + // the very same stale engine. + var got int + require.NoError(t, e2.ScanEntitlementGrantBucket(ctx, testV2Ent(entID), connectorstore.GrantDigestBucket{}, func(g *v2.Grant) bool { + got++ + return true + }), "ScanEntitlementGrantBucket must not be gated by the stale ABI flag") + require.Equal(t, n, got, "bucket membership must still yield every grant on a stale read-only engine") +} + +// 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, staleABIVersion) + 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..17aaef51c 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_build.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_build.go @@ -285,15 +285,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 +492,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_repair.go b/pkg/dotc1z/engine/pebble/grant_digest_repair.go index ef93fb070..365d813cb 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_repair.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair.go @@ -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 3f8bc8127..743ff7830 100644 --- a/pkg/dotc1z/engine/pebble/index_migrations.go +++ b/pkg/dotc1z/engine/pebble/index_migrations.go @@ -80,6 +80,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 ab2d92262..470de6fa0 100644 --- a/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go +++ b/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go @@ -77,6 +77,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 @@ -490,9 +498,34 @@ 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 an SDK that +// predates the stamp" — every such build hashed at ABI version 1, so +// the engine reads a missing stamp as version 1 and compares that to +// its current ABI like any other stamp (drop and rebuild iff they +// differ). 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 b46db6ccb..5157fd7f2 100644 --- a/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go +++ b/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go @@ -290,9 +290,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/synccompactor/compactor_grant_digest_test.go b/pkg/synccompactor/compactor_grant_digest_test.go index e67f744cb..3be568400 100644 --- a/pkg/synccompactor/compactor_grant_digest_test.go +++ b/pkg/synccompactor/compactor_grant_digest_test.go @@ -310,6 +310,101 @@ func TestCompactPebbleFoldRepairsOnlyTouchedEntitlements(t *testing.T) { "compacted root %x must byte-equal a from-scratch build %x", gotRoot.Hash, wantRoot.Hash) } +// TestCompactPebbleFoldRebuildsDigestsWhenBaseHasNone pins the fold's +// heal-on-fold branch (compactPebbleFold, the case added alongside the +// digest-index-disabled and targeted-repair branches above): a fold +// that writes ZERO grants must still end up with a present, correct +// grant digest when the byte-copied base it started from carried NONE +// — e.g. because the dest's writable Open just dropped state stamped +// with a different GrantDigestABIVersion, or the base was sealed with +// the digest index disabled. Before this branch, such a base's absent digest state rode +// straight through an all-resource/entitlement partial with nothing to +// notice or repair it, shipping a digest-free output that nothing +// downstream would ever fix. +func TestCompactPebbleFoldRebuildsDigestsWhenBaseHasNone(t *testing.T) { + logger, capture := newCapturingLogger() + ctx := ctxzap.ToContext(context.Background(), logger) + inDir := t.TempDir() + outDir := t.TempDir() + + basePath := filepath.Join(inDir, "base.c1z") + partialPath := filepath.Join(inDir, "partial-no-grants.c1z") + baseSync := buildPebbleInput(t, ctx, basePath, connectorstore.SyncTypeFull, "g1", "g2") + // A partial that writes zero grants (only the shared + // resource/entitlement shape): the fold's grants-bucket scan + // iterates nothing, so FoldStats.TouchedGrantPartitions is empty — + // the only way to reach the "no grant writes" branch family. + partialSync := buildPebbleInput(t, ctx, partialPath, connectorstore.SyncTypePartial) + + // Precondition: the freshly sealed base DOES carry digest state. + wantBaseRoot := grantDigestGlobalRootOf(t, ctx, basePath, baseSync) + require.NotZero(t, wantBaseRoot.Count) + + // Drop the base's digest state entirely, simulating a base that + // carries none (what a writable Open leaves behind after a stale ABI + // stamp, or a base sealed with the digest index disabled) — + // Engine.DropAllGrantDigestState is the + // exported production op for exactly this state. The drop goes + // straight through the raw engine, bypassing the wrapping store's + // dirty-marking, so MarkStoreDirty is required for Close to persist + // it (mirrors dotc1z's own TestVerificationSourceCache* pattern). + baseStore, err := dotc1z.NewStore(ctx, basePath, dotc1z.WithEngine(c1zstore.EnginePebble), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + baseEng, ok := enginepkg.AsEngine(baseStore) + require.True(t, ok) + require.True(t, baseEng.GrantDigestsPresent(), "precondition: reopened base still carries digest state") + require.NoError(t, baseEng.DropAllGrantDigestState(ctx)) + require.False(t, baseEng.GrantDigestsPresent()) + require.True(t, enginepkg.MarkStoreDirty(baseStore), "base must be a registered pebble store") + require.NoError(t, baseStore.Close(ctx)) + + // Confirm the drop actually persisted: a fresh read-only open sees + // no digest state at all. + { + reopened, err := dotc1z.NewStore(ctx, basePath, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + reopenedEng, ok := enginepkg.AsEngine(reopened) + require.True(t, ok) + require.False(t, reopenedEng.GrantDigestsPresent(), "precondition: base must carry no digest state on disk") + require.NoError(t, reopened.Close(ctx)) + } + + entries := []*CompactableSync{{FilePath: basePath, SyncID: baseSync}, {FilePath: partialPath, SyncID: partialSync}} + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(PebbleCompactorModeFold), + WithSkipGrantExpansion()) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + var sawBuilt bool + for _, msg := range capture() { + if msg == "compactPebbleFold: no grant writes, but the base carried no grant digest state; built it" { + sawBuilt = true + } + } + require.True(t, sawBuilt, "expected the fold to log that it built digests for a digest-less base") + + // The output must now carry a present, correct grant digest — + // matching the ORIGINAL base's digest (the fold wrote no grants, so + // the final grant set is unchanged from the base's). + gotRoot := grantDigestGlobalRootOf(t, ctx, out.FilePath, out.SyncID) + require.Equal(t, wantBaseRoot.Count, gotRoot.Count, "grant count must match the base's, unchanged") + require.True(t, bytes.Equal(wantBaseRoot.Hash, gotRoot.Hash), + "rebuilt output root %x must byte-equal the original base's root %x", gotRoot.Hash, wantBaseRoot.Hash) + + // Oracle: also matches a from-scratch build over the final records. + oraclePath := filepath.Join(outDir, "oracle.c1z") + oracleSyncID := buildOracleFromCompacted(t, ctx, out, oraclePath) + wantRoot := grantDigestGlobalRootOf(t, ctx, oraclePath, oracleSyncID) + require.Equal(t, wantRoot.Count, gotRoot.Count) + require.True(t, bytes.Equal(wantRoot.Hash, gotRoot.Hash), + "compacted root %x must byte-equal a from-scratch build %x", gotRoot.Hash, wantRoot.Hash) +} + // requireEmptyKeyRange asserts the engine's raw keyspace holds nothing // in [lo, hi). func requireEmptyKeyRange(t testing.TB, eng *enginepkg.Engine, lo, hi []byte, what string) { diff --git a/pkg/synccompactor/compactor_pebble.go b/pkg/synccompactor/compactor_pebble.go index 602f6df35..f098fb610 100644 --- a/pkg/synccompactor/compactor_pebble.go +++ b/pkg/synccompactor/compactor_pebble.go @@ -671,28 +671,48 @@ func (c *Compactor) compactPebbleFold(ctx context.Context) (string, error) { // bucket copy up front, keeps the no-grant-write fold preserving // the base's still-exact digests for free even on a disabled-index // engine. See TestCompactPebbleFoldDigestIndexDisabledDropsDigests. - if len(foldStats.TouchedGrantPartitions) > 0 { - if !destEng.GrantDigestIndexEnabled() { - if err := destEng.DropAllGrantDigestState(ctx); err != nil { - return "", fmt.Errorf("compactPebbleFold: drop grant digest state (digest index disabled): %w", err) - } - l.Info("compactPebbleFold: grant writes with digest index disabled; dropped the base's copied digest state", - zap.Int("touched_partitions", len(foldStats.TouchedGrantPartitions))) - } else { - partitions := make([]string, 0, len(foldStats.TouchedGrantPartitions)) - for p := range foldStats.TouchedGrantPartitions { - partitions = append(partitions, p) - } - if err := destEng.InvalidateGrantDigestPartitions(ctx, partitions); err != nil { - return "", fmt.Errorf("compactPebbleFold: invalidate grant digest partitions: %w", err) - } - if err := destEng.RepairMissingGrantDigests(ctx); err != nil { - return "", fmt.Errorf("compactPebbleFold: repair grant digests: %w", err) - } - l.Info("compactPebbleFold: repaired grant digests for touched entitlements", - zap.Int("touched_partitions", len(partitions))) + // + // When the fold writes NO grants, the base's copied digest state is + // normally left exactly as it was (cheapest possible: zero touched + // partitions, nothing to repair). But that copy is only ever as + // good as what Open decided to keep: the dest's writable Open just + // dropped a base whose stamp named a different GrantDigestABIVersion, + // or the base was sealed with the digest index disabled, or a prior + // digest-build failure dropped it — either way the dest can carry NO + // digest state at all, and with no grant write to trigger the repair + // branch above, nothing else in this fold would ever fix that. So when + // the dest engine wants digests but GrantDigestsPresent() reports + // none, RepairMissingGrantDigests runs anyway: with nothing present + // it delegates straight to the full BuildGrantDigests, a one-time + // O(base) scan the first time a digest-less base is folded. Every + // later fold of the same lineage finds digests already present and + // stamped, and pays the normal O(partials) cost again. + switch { + case len(foldStats.TouchedGrantPartitions) > 0 && !destEng.GrantDigestIndexEnabled(): + if err := destEng.DropAllGrantDigestState(ctx); err != nil { + return "", fmt.Errorf("compactPebbleFold: drop grant digest state (digest index disabled): %w", err) } - } else { + l.Info("compactPebbleFold: grant writes with digest index disabled; dropped the base's copied digest state", + zap.Int("touched_partitions", len(foldStats.TouchedGrantPartitions))) + case len(foldStats.TouchedGrantPartitions) > 0: + partitions := make([]string, 0, len(foldStats.TouchedGrantPartitions)) + for p := range foldStats.TouchedGrantPartitions { + partitions = append(partitions, p) + } + if err := destEng.InvalidateGrantDigestPartitions(ctx, partitions); err != nil { + return "", fmt.Errorf("compactPebbleFold: invalidate grant digest partitions: %w", err) + } + if err := destEng.RepairMissingGrantDigests(ctx); err != nil { + return "", fmt.Errorf("compactPebbleFold: repair grant digests: %w", err) + } + l.Info("compactPebbleFold: repaired grant digests for touched entitlements", + zap.Int("touched_partitions", len(partitions))) + case destEng.GrantDigestIndexEnabled() && !destEng.GrantDigestsPresent(): + if err := destEng.RepairMissingGrantDigests(ctx); err != nil { + return "", fmt.Errorf("compactPebbleFold: build grant digests for a base with none: %w", err) + } + l.Info("compactPebbleFold: no grant writes, but the base carried no grant digest state; built it") + default: l.Info("compactPebbleFold: no grant writes; base grant digest state left untouched") }