From 936a922487cb3688d10e04a21d3a57c4ef9bbdd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 02:33:15 -0500 Subject: [PATCH] fix(asset-bridge): an empty catalog is an absent one on every family, not just the tree Phase 7 found this on the tree family and fixed it there. It was inline in THREE places: the body catalogue (phase 3), statics and land (phase 5), and the tree. `expected != null` treats "" as a real fingerprint, so a caller that serialises a missing value as an empty string has EVERY fetch refused -- with a sentence that names no catalog at all ("catalog is now 8159778b"), which reads as a shard fault rather than a caller one. All three now go through one BridgeAssets.CatalogMismatch. Three copies of a comparison are three chances for the next family to get it wrong in a way only a differently-written client would ever reveal. BridgeLeases keeps its own `expected != null` and is deliberately untouched: there the value is a world property, where an empty string is a legitimate thing to expect. Verified against a live shard on a stock ServUO install, every family asked three ways -- with a real catalog, with the field absent, and with an empty string: cliloc.table walk 67,496 rows, 12 pages body manifest / fetch 1,095 rows; ok all three ways static + land fetch ok all three ways static/land carry their OWN catalog art 66a112c1 vs body 323f284f a cross-family catalog refused 422 tree manifest / fetch 141 files incl. BOTH empty ones, all three ways empty files carry a VALID gzip member 2 rows gunzip to 0 bytes a STALE catalog still refused on body, static and tree Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- overlay/Scripts/Custom/Bridge/BridgeArt.cs | 2 +- overlay/Scripts/Custom/Bridge/BridgeAssets.cs | 24 +++++++++++++++++++ .../Scripts/Custom/Bridge/BridgeCatalog.cs | 2 +- overlay/Scripts/Custom/Bridge/BridgeTree.cs | 10 ++++---- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/overlay/Scripts/Custom/Bridge/BridgeArt.cs b/overlay/Scripts/Custom/Bridge/BridgeArt.cs index 3cdbfe1..c00ef8c 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeArt.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeArt.cs @@ -175,7 +175,7 @@ namespace Server.Custom.Bridge string id = SourceId(); - if (expected != null && expected != id) + if (BridgeAssets.CatalogMismatch(expected, id)) { BridgeAssets.Fail(reqId, "UNREADABLE", "the shard's client files changed since that catalogue was read (catalog " diff --git a/overlay/Scripts/Custom/Bridge/BridgeAssets.cs b/overlay/Scripts/Custom/Bridge/BridgeAssets.cs index 4b68f68..1490de0 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeAssets.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeAssets.cs @@ -670,6 +670,30 @@ namespace Server.Custom.Bridge } } + /// + /// Does a caller's asserted catalog id disagree with what this shard holds? + /// + /// **An absent fingerprint and an empty one mean the same thing**, and that is the + /// whole reason this is a function rather than an inline `expected != null`. A caller + /// with nothing to assert sends the field absent or empty depending on how its own + /// client serialises a missing value, and treating `""` as a real id refuses **every** + /// fetch it makes — with a sentence naming no catalog at all ("catalog is now + /// 8159778b"), which reads as a shard fault rather than a caller one. + /// + /// Phase 7 found this on the tree family, where a probe passed an empty string by + /// accident. It was inline in three places by then; it is one function now, because + /// three copies of a comparison are three chances for the next family to get it wrong + /// in a way only a differently-written client would ever reveal. + /// + /// Note this is deliberately NOT the shape `BridgeLeases` uses for its own `expected`: + /// there the value is a world property, where an empty string is a legitimate thing to + /// expect and `!= null` is correct. + /// + internal static bool CatalogMismatch(string expected, string actual) + { + return !String.IsNullOrEmpty(expected) && !String.Equals(expected, actual, StringComparison.Ordinal); + } + /// /// SHA-256, lowercase hex. Shared because the hash in a manifest row, the hash in a /// fetch row and the hash the website stores must be one function. diff --git a/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs b/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs index d24e868..859bc89 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs @@ -357,7 +357,7 @@ namespace Server.Custom.Bridge string id = SourceId(); - if (expected != null && expected != id) + if (BridgeAssets.CatalogMismatch(expected, id)) { // The client files moved between the manifest and this fetch. Refusing is the only // honest answer: the keys were chosen against a catalogue that no longer describes diff --git a/overlay/Scripts/Custom/Bridge/BridgeTree.cs b/overlay/Scripts/Custom/Bridge/BridgeTree.cs index c6e27a7..0727cdd 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeTree.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeTree.cs @@ -421,12 +421,10 @@ namespace Server.Custom.Bridge List files = Enumerate(); string fingerprint = FingerprintOf(files); - // `IsNullOrEmpty`, not `!= null`. A caller that has no fingerprint to assert sends - // the field absent OR empty depending on how its own client serialises a missing - // value, and the two must mean the same thing — an empty string compared against a - // real id refuses every fetch, with a sentence that names no catalog at all - // ("catalog is now 8159778b"). Found by a probe that passed one. - if (!String.IsNullOrEmpty(expected) && expected != fingerprint) + // Shared with every other family on this plane, because an absent fingerprint and an + // empty one have to mean the same thing here and there — see + // `BridgeAssets.CatalogMismatch` for what treating them differently costs. + if (BridgeAssets.CatalogMismatch(expected, fingerprint)) { // The tree moved between the manifest and this fetch. The same refusal the // catalogue makes for a patched client, and for the same reason: these keys were