diff --git a/memex/aspire/Memex.Database.Migration/MigrationRegistry.cs b/memex/aspire/Memex.Database.Migration/MigrationRegistry.cs index 45163334e0..896f4075b1 100644 --- a/memex/aspire/Memex.Database.Migration/MigrationRegistry.cs +++ b/memex/aspire/Memex.Database.Migration/MigrationRegistry.cs @@ -59,6 +59,7 @@ public static class MigrationRegistry new V48_RetypeBuiltinFeedbackToPlugin(), new V49_FixAccessAssignmentMainNode(), new V50_RescopePlatformAdminGrants(), + new V51_DropInvalidPartitionSchemas(), ]; /// diff --git a/memex/aspire/Memex.Database.Migration/Migrations/V51_DropInvalidPartitionSchemas.cs b/memex/aspire/Memex.Database.Migration/Migrations/V51_DropInvalidPartitionSchemas.cs new file mode 100644 index 0000000000..919a2d3d3b --- /dev/null +++ b/memex/aspire/Memex.Database.Migration/Migrations/V51_DropInvalidPartitionSchemas.cs @@ -0,0 +1,131 @@ +using MeshWeaver.Mesh; +using Microsoft.Extensions.Logging; + +namespace Memex.Database.Migration.Migrations; + +/// +/// Drop schemas whose names could never be valid partition segments (#714). +/// +/// Bug: before the partition router learned to reject malformed first segments and +/// before schema creation was gated to partition-owning creates, navigating to (or probing) +/// an arbitrary top-level URL lazily provisioned a real schema + tables. memex and +/// memexcloud accumulated querystring-shaped junk like +/// search?q=query%20syntax&hq=scope%3adescendants (nine variants) and +/// login?returnurl=https%3a%2f%2f…. only dropped a +/// hardcoded bare-word list, which cannot match these. +/// +/// This migration is pattern-based, not name-list based: it enumerates every +/// non-system schema and drops exactly those that FAIL the shared segment-validity rule +/// ( — the SAME predicate the +/// router and the provisioning boundary enforce), i.e. names that do not start with a +/// letter/digit, contain anything other than a letter/digit/./-/_, or +/// exceed 63 UTF-8 bytes. The rule is Unicode-aware, NOT ASCII-only +/// (char.IsLetterOrDigit): an accented schema such as müller PASSES and is +/// never dropped — do not read this as an [A-Za-z0-9._-] whitelist. Every legitimate schema — +/// partitions, their *_versions twins, admin/auth/apitoken/ +/// system_access/event_log — passes the rule and is untouched; a schema that +/// fails it can never be routed to (the router's validity check precedes even its +/// registered-partition lookup), so it can hold nothing reachable — this also covers +/// legacy email-named user schemas (…@…), stranded-unreachable since the router +/// hardening. Drops are best-effort CASCADE with per-schema logging and a pre-drop +/// row-count audit so a surprise is visible in the log (mirrors +/// / ), plus a +/// searchable_schemas de-registration so queries stop fanning out to dropped schemas. +/// +public sealed class V51_DropInvalidPartitionSchemas : IMigration +{ + public int Version => 51; + public string Description => "Drop junk schemas whose names can never be valid partition segments (#714)"; + + public async Task RunAsync(MigrationContext ctx) + { + // Enumerate candidates: every schema except the engine's own (pg_catalog, pg_toast, + // pg_temp_*, …) and the SQL-standard information_schema / public. Validity is decided + // in C# by the ONE shared segment rule — never a duplicated regex or a name list. + var invalid = new List(); + await using (var listCmd = ctx.DataSource.CreateCommand(""" + SELECT nspname FROM pg_namespace + WHERE nspname NOT LIKE 'pg%' + AND nspname NOT IN ('information_schema', 'public') + ORDER BY nspname + """)) + await using (var reader = await listCmd.ExecuteReaderAsync()) + while (await reader.ReadAsync()) + { + var name = reader.GetString(0); + if (!PartitionDefinition.IsValidPartitionSegment(name)) + invalid.Add(name); + } + + if (invalid.Count == 0) + { + ctx.Logger.LogInformation("Repair v51: no invalid-named schemas found — nothing to drop."); + return; + } + + ctx.Logger.LogInformation( + "Repair v51: found {Count} schema(s) failing the partition-segment rule: {Schemas}", + invalid.Count, string.Join(", ", invalid.Select(s => $"\"{s}\""))); + + foreach (var schema in invalid) + { + // Audit what we're removing before the CASCADE so a surprise is visible in the log + // (mirrors V38). Tolerant — a junk schema may not even have a mesh_nodes table. + try + { + await using var countCmd = ctx.DataSource.CreateCommand( + "SELECT count(*) FROM \"" + schema.Replace("\"", "\"\"") + "\".mesh_nodes"); + var rows = await countCmd.ExecuteScalarAsync(); + ctx.Logger.LogInformation( + "Repair v51: dropping invalid-named schema \"{Schema}\" ({Rows} mesh_nodes rows).", + schema, rows); + } + catch (Exception ex) + { + ctx.Logger.LogInformation( + "Repair v51: dropping invalid-named schema \"{Schema}\" (no readable mesh_nodes table: {Reason}).", + schema, ex.Message); + } + + // Best-effort, log-and-continue (mirrors V03): a failed DROP (lock contention, an + // unexpected dependency) must NOT abort the whole migration run and block the deploy. + try + { + var quoted = schema.Replace("\"", "\"\""); + await using (var dropCmd = ctx.DataSource.CreateCommand( + $"DROP SCHEMA IF EXISTS \"{quoted}\" CASCADE")) + await dropCmd.ExecuteNonQueryAsync(); + // The versions twin of a junk schema fails the rule itself and is dropped by its + // own iteration; this explicit twin drop (V03/V38 parity) is an idempotent no-op + // when it is already gone. + await using (var dropVCmd = ctx.DataSource.CreateCommand( + $"DROP SCHEMA IF EXISTS \"{quoted}_versions\" CASCADE")) + await dropVCmd.ExecuteNonQueryAsync(); + ctx.Logger.LogInformation("Repair v51: dropped invalid-named schema \"{Schema}\"", schema); + } + catch (Exception ex) + { + ctx.Logger.LogWarning(ex, "Repair v51: failed to drop schema \"{Schema}\" (continuing)", schema); + } + + // De-register from the searchable-schemas index (mirrors V38): a dangling row keeps + // cross-schema queries fanning out to the dropped schema. Tolerant — the table may + // not exist on every deployment shape, and a missing row is a no-op. + try + { + await using var deregCmd = ctx.DataSource.CreateCommand( + "DELETE FROM public.searchable_schemas WHERE lower(schema_name) = lower($1)"); + deregCmd.Parameters.AddWithValue(schema); + var removed = await deregCmd.ExecuteNonQueryAsync(); + if (removed > 0) + ctx.Logger.LogInformation( + "Repair v51: de-registered \"{Schema}\" from searchable_schemas.", schema); + } + catch (Exception ex) + { + ctx.Logger.LogWarning(ex, + "Repair v51: could not de-register \"{Schema}\" from searchable_schemas (continuing).", schema); + } + } + } +} diff --git a/src/MeshWeaver.Graph/Security/OwnsPartitionProvisioningValidator.cs b/src/MeshWeaver.Graph/Security/OwnsPartitionProvisioningValidator.cs index 078dd89a92..4e3fe9245a 100644 --- a/src/MeshWeaver.Graph/Security/OwnsPartitionProvisioningValidator.cs +++ b/src/MeshWeaver.Graph/Security/OwnsPartitionProvisioningValidator.cs @@ -99,6 +99,26 @@ public IObservable Validate(NodeValidationContext context) if (string.IsNullOrEmpty(partitionName)) return Observable.Return(NodeValidationResult.Valid()); + // 🚨 Defense-in-depth (#714): the partition id becomes the backing schema name. + // A malformed id (URL/query-string shaped — '?', '%', ':', '=', '&', whitespace; + // >63 chars; not starting with a letter/digit) must NEVER provision a schema. The + // storage routers refuse to ROUTE such segments (the same shared + // PartitionDefinition.IsValidPartitionSegment rule), so a partition created with + // one would be permanently unreachable anyway — reject the create with a speaking + // error naming the offending id instead of provisioning junk. + // 🚨 NO `_`-prefix exemption. A `_`-prefixed name is a SATELLITE CONTAINER segment + // ({P}/_Access/…, {P}/_Thread/…) or a global-satellite NAMESPACE, never a partition: + // the router resolves a `_`-prefixed first segment ONLY through a registered + // PartitionDefinition with an EXPLICIT schema (`_Access` → `system_access`) and never + // derives one from the name, so a schema named `_access` is unroutable by construction — + // exactly the ghost this guard exists to prevent. A `Space` create with id `_Access` + // is therefore a bug in the CALLER, and rejecting it loud is the point. + if (!PartitionDefinition.IsValidPartitionSegment(partitionName)) + return Observable.Return(NodeValidationResult.Invalid( + $"Cannot create '{context.Node.NodeType}' with id '{partitionName}': " + + $"{PartitionDefinition.PartitionSegmentRequirement}.", + NodeRejectionReason.InvalidPath)); + var providers = _hub.ServiceProvider.GetServices().ToList(); if (providers.Count == 0) return Observable.Return(NodeValidationResult.Valid()); diff --git a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPartitionStorageProvider.cs b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPartitionStorageProvider.cs index c2404cdf0f..b83c193b91 100644 --- a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPartitionStorageProvider.cs +++ b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPartitionStorageProvider.cs @@ -241,6 +241,22 @@ private async Task EnsureSchemaAsync( var schema = !string.IsNullOrEmpty(def.Schema) ? def.Schema : def.Namespace; if (string.IsNullOrEmpty(schema)) return def; + // 🚨 Defense-in-depth (#714): this is the LAST stop before + // public.ensure_partition_schema materialises the schema, so validate the resolved + // schema name here no matter which code path called (EnsurePartitionProvisioned, + // the boot-time static-partition seeding, tests). A URL/query-string-shaped name + // (`search?q=…`, `login?returnurl=…`) must never become a schema — fail LOUD, + // naming the offender, instead of provisioning junk the router can never route to. + // (Explicit schemas of the `_`-prefix globals — `system_access` etc. — are ordinary + // identifiers and pass; the rule constrains shape, not the registered names. That is + // precisely why a `_`-prefixed name must NOT be exempted here: `_Access` reaching this + // point means the caller resolved the schema from the NAMESPACE instead of the + // registered definition, and `_access` is a schema the router can never route to.) + if (!PartitionDefinition.IsValidPartitionSegment(schema)) + throw new ArgumentException( + $"Cannot provision partition '{def.Namespace}' (schema '{schema}'): " + + $"{PartitionDefinition.PartitionSegmentRequirement}."); + if (_schemasInitialized.ContainsKey(schema)) return def; // Single source of truth for per-partition DDL: the public.ensure_partition_schema @@ -330,6 +346,21 @@ public IObservable EnsurePartitionProvisioned(string @namespace) { if (string.IsNullOrEmpty(@namespace)) return Observable.Return(Unit.Default); + // 🚨 Defense-in-depth (#714): the namespace becomes the schema name verbatim + // (lowercased). The router already refuses to ROUTE malformed segments, but a + // deliberate top-level create — or any future caller — must never reach + // public.ensure_partition_schema with a URL-shaped id (`search?q=…`, + // `login?returnurl=…`: the junk schemas that filled the memex/memexcloud DBs). + // Fail BEFORE the promise-cache so the rejection is loud (OnError naming the + // offending id), never a cached silent no-op the caller mistakes for success. + // A `_`-prefixed namespace is rejected like any other malformed name: global + // satellites (`_Access`) get their schema from a REGISTERED PartitionDefinition + // (`system_access`), never from this namespace-derived path — see EnsureSchemaAsync. + if (!PartitionDefinition.IsValidPartitionSegment(@namespace)) + return Observable.Throw(new ArgumentException( + $"Cannot provision partition '{@namespace}': " + + $"{PartitionDefinition.PartitionSegmentRequirement}.", + nameof(@namespace))); var def = new PartitionDefinition { Namespace = @namespace, diff --git a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPathRoutingAdapter.cs b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPathRoutingAdapter.cs index af66701e7b..649cb6b807 100644 --- a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPathRoutingAdapter.cs +++ b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPathRoutingAdapter.cs @@ -121,7 +121,10 @@ public PostgreSqlPathRoutingAdapter(PostgreSqlPartitionStorageProvider provider) // be lazily CREATE SCHEMA'd. Prod 2026-06-05: the atioz DB filled with exactly // these garbage schemas (request URLs routed as mesh paths) and corrupted itself. // → null so no schema is created; the write falls through to the next provider. - if (!IsValidPartitionSegment(seg)) + // The charset rule is the shared PartitionDefinition.IsValidPartitionSegment — + // the SAME predicate the provisioning boundary enforces (#714), so a segment + // this router refuses to route can also never be provisioned. + if (!PartitionDefinition.IsValidPartitionSegment(seg)) return null; // Valid partition segment → schema is the lowercased first segment. If a // richer PartitionDefinition was registered for it (e.g. a non-default @@ -140,19 +143,6 @@ public PostgreSqlPathRoutingAdapter(PostgreSqlPartitionStorageProvider provider) }; } - /// - /// A partition's first path segment becomes a Postgres schema, so it must be a simple - /// identifier: start with a letter/digit, then only letters/digits/. - _, ≤63 - /// chars (PG identifier limit). Rejects URL/query-string-shaped segments - /// (containing ? = & % # :, whitespace, …) that the partition router would - /// otherwise materialise as garbage schemas — the atioz DB-corruption root cause - /// (2026-06-05). (_-prefixed global-satellite segments are handled before this.) - /// - internal static bool IsValidPartitionSegment(string seg) => - seg.Length is > 0 and <= 63 - && char.IsLetterOrDigit(seg[0]) - && seg.All(c => char.IsLetterOrDigit(c) || c is '.' or '-' or '_'); - /// /// Schema-bound adapter cache: once we've materialised the /// PostgreSqlStorageAdapter for a (def.Schema), reuse it. diff --git a/src/MeshWeaver.Hosting.Snowflake/SnowflakePartitionStorageProvider.cs b/src/MeshWeaver.Hosting.Snowflake/SnowflakePartitionStorageProvider.cs index edf32dbca0..acd7c5f637 100644 --- a/src/MeshWeaver.Hosting.Snowflake/SnowflakePartitionStorageProvider.cs +++ b/src/MeshWeaver.Hosting.Snowflake/SnowflakePartitionStorageProvider.cs @@ -354,6 +354,15 @@ public IObservable EnsurePartitionProvisioned(string @namespace) { if (string.IsNullOrEmpty(@namespace)) return Observable.Return(Unit.Default); + // 🚨 Defense-in-depth (#714, PG lineage): the namespace becomes the schema name + // verbatim (lowercased) — a malformed top-level id (URL/query-string shaped) must + // never provision a schema, no matter which code path calls. Fail BEFORE the + // promise-cache so the rejection is loud (OnError naming the offending id). + if (!PartitionDefinition.IsValidPartitionSegment(@namespace)) + return Observable.Throw(new ArgumentException( + $"Cannot provision partition '{@namespace}': " + + $"{PartitionDefinition.PartitionSegmentRequirement}.", + nameof(@namespace))); var def = new PartitionDefinition { Namespace = @namespace, diff --git a/src/MeshWeaver.Hosting.Snowflake/SnowflakePathRoutingAdapter.cs b/src/MeshWeaver.Hosting.Snowflake/SnowflakePathRoutingAdapter.cs index f1431f8066..7fb54add7a 100644 --- a/src/MeshWeaver.Hosting.Snowflake/SnowflakePathRoutingAdapter.cs +++ b/src/MeshWeaver.Hosting.Snowflake/SnowflakePathRoutingAdapter.cs @@ -130,7 +130,9 @@ public SnowflakePathRoutingAdapter(SnowflakePartitionStorageProvider provider) // be lazily CREATE SCHEMA'd. Prod 2026-06-05 (PG): the atioz DB filled with exactly // these garbage schemas (request URLs routed as mesh paths) and corrupted itself. // → null so no schema is created; the write falls through to the next provider. - if (!IsValidPartitionSegment(seg)) + // The charset rule is the shared PartitionDefinition.IsValidPartitionSegment — + // one predicate for both backends AND the provisioning boundary (#714). + if (!PartitionDefinition.IsValidPartitionSegment(seg)) return null; // Valid partition segment → schema is the lowercased first segment. If a // richer PartitionDefinition was registered for it (e.g. a non-default @@ -149,20 +151,6 @@ public SnowflakePathRoutingAdapter(SnowflakePartitionStorageProvider provider) }; } - /// - /// A partition's first path segment becomes a Snowflake schema, so it must be a simple - /// identifier: start with a letter/digit, then only letters/digits/. - _, ≤63 - /// chars (kept identical to the PG rule — same 63-char cap — so a path routable on one - /// backend is routable on the other). Rejects URL/query-string-shaped segments - /// (containing ? = & % # :, whitespace, …) that the partition router would - /// otherwise materialise as garbage schemas — the atioz DB-corruption root cause - /// (2026-06-05). (_-prefixed global-satellite segments are handled before this.) - /// - internal static bool IsValidPartitionSegment(string seg) => - seg.Length is > 0 and <= 63 - && char.IsLetterOrDigit(seg[0]) - && seg.All(c => char.IsLetterOrDigit(c) || c is '.' or '-' or '_'); - /// /// Schema-bound adapter cache: once we've materialised the /// for a (def.Schema), reuse it. Every adapter diff --git a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs index 055a434212..5ba0dcdfed 100644 --- a/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs +++ b/src/MeshWeaver.Mesh.Contract/MeshExtensions.cs @@ -1243,8 +1243,11 @@ void ReportError() /// /// No recursion: the root create (empty namespace) and the grant create (path /// under /_Access/) are exactly the two node shapes skipped at the top, so they never - /// re-enter the bootstrap. Idempotent + race-safe: a concurrent first-writer that - /// loses the create race sees "already exists" and treats it as success. Re-heals: + /// re-enter the bootstrap. A ROOT-SCOPE grant (_Access/{subject}_Access, scope + /// "") does not match that /_Access/ test — it is stopped instead by the + /// partition-segment gate below, which is what keeps _Access from being mistaken for a + /// partition and provisioned as a schema (#714). Idempotent + race-safe: a concurrent + /// first-writer that loses the create race sees "already exists" and treats it as success. Re-heals: /// root + grant presence are re-probed on every child create, so a partition left half-broken /// (root-missing, root GHOSTED — a row with no type and no content, grant-missing, /// grant-less altogether, or any combination) is repaired on the next child create — @@ -1274,6 +1277,25 @@ void ReportError() if (string.IsNullOrEmpty(partition)) return Observable.Return(System.Reactive.Unit.Default); + // 🚨 The first segment must actually BE a partition (#714). A partition becomes a + // backing-store SCHEMA derived from its name, so only a name satisfying the ONE shared + // rule can be bootstrapped — anything else would provision a schema the router refuses + // to route to, i.e. a ghost by construction. + // + // The shape this caught: a ROOT-SCOPE access grant lives at + // `_Access/{subject}_Access` (namespace `_Access`, scope ""). The skip test above is + // `Path.Contains("/_Access/")`, whose leading slash matches `{P}/_Access/…` but NOT that + // root-scope path — so it fell through here, `Segments[0]` read `_Access` as a PARTITION, + // and the bootstrap created a `Space` root at `_Access` AND provisioned a schema + // literally named `_access`. The router resolves a `_`-prefixed first segment ONLY via a + // registered PartitionDefinition with an explicit schema (`_Access` → `system_access`) + // and never derives one from the name, so nothing could ever read or write `_access`. + // The rule is general, not an `_Access` special case: no `_`-prefixed satellite container + // (`_Thread`, `_Activity`, …) and no URL-shaped junk segment is a bootstrappable + // partition. + if (!PartitionDefinition.IsValidPartitionSegment(partition)) + return Observable.Return(System.Reactive.Unit.Default); + // Never bootstrap a system-managed MIRROR partition (User, Auth). These reject ALL // interactive writes (PartitionWriteGuardValidator Rule 1), so a create attempt into e.g. // `Auth/…` — which this method reaches BEFORE the validators run — would otherwise heal a diff --git a/src/MeshWeaver.Mesh.Contract/PartitionDefinition.cs b/src/MeshWeaver.Mesh.Contract/PartitionDefinition.cs index cb55eda5b8..9343361013 100644 --- a/src/MeshWeaver.Mesh.Contract/PartitionDefinition.cs +++ b/src/MeshWeaver.Mesh.Contract/PartitionDefinition.cs @@ -141,6 +141,48 @@ public static bool IsSatelliteNodeType(string? nodeType) => nodeType != null && SatelliteTableMapping.Defaults.Any( s => s.NodeTypes.Any(nt => string.Equals(nt, nodeType, StringComparison.OrdinalIgnoreCase))); + /// + /// Human-readable statement of the rule, for + /// composing speaking rejection messages that name the offending id. + /// + public const string PartitionSegmentRequirement = + "a partition name must start with a letter or digit, contain only letters, digits, " + + "'.', '-' or '_', and be at most 63 bytes when encoded as UTF-8"; + + /// + /// Whether is a valid partition name (a top-level id / first + /// path segment). A partition becomes a backing-store SCHEMA (Postgres/Snowflake), so it + /// must be a simple identifier: start with a letter/digit, then only + /// letters/digits/. - _. Rejects URL/query-string-shaped segments (containing + /// ? = & % # :, whitespace, …) — the atioz DB-corruption root cause + /// (2026-06-05, #714): request URLs routed as mesh paths materialised garbage schemas + /// like login?returnurl=…. + /// + /// Letters/digits are Unicode, not ASCII (), + /// so an accented partition name such as müller is legitimate. The length cap is + /// therefore counted in UTF-8 BYTES, not chars — 63 is Postgres' NAMEDATALEN + /// identifier limit, which is a byte limit that SILENTLY TRUNCATES anything longer. A + /// char-counted cap would admit a 63-char multi-byte name that Postgres then stores under a + /// truncated schema name, so the router (which computes the UNtruncated name) could never + /// route back to it — the very unroutable-schema class this rule exists to prevent. The cap + /// is kept identical across backends so a path routable on one is routable on the other. + /// + /// The ONE charset rule, shared by the storage routers + /// (PostgreSqlPathRoutingAdapter / SnowflakePathRoutingAdapter: unroutable → + /// no schema), the provisioning boundary itself + /// (EnsurePartitionProvisioned / OwnsPartitionProvisioningValidator: + /// defense-in-depth — a malformed id can never provision a schema no matter which code + /// path calls it), and the cleanup migration that drops schemas whose names could never + /// have passed it. (_-prefixed global-satellite namespaces like _Access are + /// deliberately NOT valid partition segments — their explicit schemas, e.g. + /// system_access, are registered at boot, never derived from the namespace.) + /// + public static bool IsValidPartitionSegment(string? segment) + => !string.IsNullOrEmpty(segment) + && System.Text.Encoding.UTF8.GetByteCount(segment) <= 63 + && char.IsLetterOrDigit(segment[0]) + && segment.All(c => char.IsLetterOrDigit(c) || c is '.' or '-' or '_'); + /// /// Checks if a path contains the given segment as a complete path component. /// The segment must appear after a '/' and be followed by '/' or end of string. diff --git a/test/MeshWeaver.Content.Test/TrackedChangeProjectionTest.cs b/test/MeshWeaver.Content.Test/TrackedChangeProjectionTest.cs index 6f702fd3f2..e76d412aeb 100644 --- a/test/MeshWeaver.Content.Test/TrackedChangeProjectionTest.cs +++ b/test/MeshWeaver.Content.Test/TrackedChangeProjectionTest.cs @@ -102,8 +102,30 @@ private IObservable> WaitForVersions(string path, int atL .Where(v => v.Count >= atLeast); } - private IObservable CurrentNode(string path) => - Mesh.GetWorkspace().GetMeshNodeStream(path).Where(n => n is not null).Take(1)!; + /// + /// The node ONCE it has caught up to — the newest version the + /// history says its edits landed at. + /// + /// 🚨 Deliberately NOT a bare Take(1) on the stream. GetMeshNodeStream + /// replays the node's currently cached snapshot, and immediately after an edit that is still the + /// PRE-edit one: stream.Update returns the locally computed node optimistically and the + /// storage layer stamps the reconciled version a moment later ("take the next emission off the + /// same handle" for the owner's reconciled state). ChangeProjection.FromHistory projects + /// only versions STRICTLY OLDER than the node it is handed, so a stale snapshot makes its + /// older set empty and the projection comes back EMPTY — with every version row already + /// sitting in the store. Waiting on the version ROWS does not imply the NODE has caught up: the + /// history and the node are two different stores, and a bare Take(1) silently asserted + /// against whichever snapshot happened to be cached. + /// + /// The live view never trips on this, which is why it stayed hidden: + /// CollaborativeMarkdownView re-projects with Switch on every node emission, so a + /// stale emission yields an empty list for an instant and is immediately superseded. Only a + /// ONE-SHOT assertion has to wait for the emission that actually carries the edit. + /// + private IObservable NodeAtVersion(string path, long atLeastVersion) => + Mesh.GetWorkspace().GetMeshNodeStream(path) + .Where(n => n is not null && n.Version >= atLeastVersion) + .Take(1)!; [Fact(Timeout = 60000)] public async Task TrackedChanges_AreProjectedFromVersionHistory_WithNoTrackingSatellite() @@ -127,7 +149,10 @@ public async Task TrackedChanges_AreProjectedFromVersionHistory_WithNoTrackingSa var versions = await WaitForVersions(path, 4).Should().Within(Step).Emit(); Output.WriteLine($"Version snapshots: {string.Join(", ", versions.Select(v => v.Version))}"); - var current = await CurrentNode(path).Should().Within(Step).Emit(); + // Same discipline as the revert test: read the node AT the newest recorded version. Three + // edits give this one more slack than the single-edit case, but the hazard is identical — + // a stale snapshot projects nothing (see NodeAtVersion). + var current = await NodeAtVersion(path, versions.Max(v => v.Version)).Should().Within(Step).Emit(); var versionQuery = Mesh.ServiceProvider.GetRequiredService(); var changes = await ChangeProjection @@ -181,9 +206,14 @@ await NodeFactory.CreateNode(MeshNode.FromPath(path) with }).Should().Within(Step).Emit(); await EditAs(path, "dave", "remains cautious", "remains RECKLESS"); - await WaitForVersions(path, 2).Should().Within(Step).Emit(); - - var edited = await CurrentNode(path).Should().Within(Step).Emit(); + var rows = await WaitForVersions(path, 2).Should().Within(Step).Emit(); + + // Read the node AT the version the history just recorded — not whatever snapshot the + // stream has cached. The rows landing does not mean the node has caught up (see + // NodeAtVersion): projecting from a stale pre-edit node yields nothing at all. + var edited = await NodeAtVersion(path, rows.Max(r => r.Version)).Should().Within(Step).Emit(); + MarkdownOverviewLayoutArea.GetMarkdownContent(edited).Should().Contain("RECKLESS", + "the projection is only meaningful against the node that actually carries the edit"); var versionQuery = Mesh.ServiceProvider.GetRequiredService(); var change = (await ChangeProjection.FromHistory(versionQuery, edited, Mesh.JsonSerializerOptions) .Should().Within(Step).Emit()) diff --git a/test/MeshWeaver.Hosting.Monolith.Test/PartitionRootBootstrapTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/PartitionRootBootstrapTest.cs index a9c4d7cafa..ee72376499 100644 --- a/test/MeshWeaver.Hosting.Monolith.Test/PartitionRootBootstrapTest.cs +++ b/test/MeshWeaver.Hosting.Monolith.Test/PartitionRootBootstrapTest.cs @@ -206,6 +206,51 @@ await MeshService.CreateNode(new MeshNode("Bob_Access", $"{partition}/_Access") "creating an _Access node completes without recursion"); } + /// + /// #714 — a ROOT-SCOPE grant must not turn _Access into a partition. + /// + /// A mesh-wide grant lives at _Access/{subject}_Access (namespace _Access, + /// scope "") — the shape AssignmentNodeFactory.UserRole(user, role) with no scope + /// produces, used at ~200 harness call sites. The bootstrap's skip test is + /// Path.Contains("/_Access/"), whose LEADING SLASH matches {P}/_Access/… but NOT + /// this root-scope path. So it fell through, Segments[0] read _Access as a + /// PARTITION, and the bootstrap created a Space root at _Access and provisioned a + /// backing schema literally named _access. + /// + /// That schema is a ghost by construction: the router resolves a _-prefixed first + /// segment ONLY through a registered PartitionDefinition carrying an explicit schema + /// (_Access → system_access) and never derives one from the segment name — so + /// nothing could ever read or write _access. It is the exact unroutable-schema class + /// #714 exists to prevent, which is why the provisioning boundary must keep rejecting + /// _access rather than exempting _-prefixed names. + /// + [Fact] + public async Task RootScopeAccessGrant_DoesNotBootstrap_AccessAsAPartition() + { + // The canonical root-scope grant: no scope → namespace "_Access", MainNode "". + var grant = AssignmentNodeFactory.UserRole("Carol", Role.Admin.Id); + grant.Namespace.Should().Be("_Access", + "pre-condition: a root-scope grant's first path segment IS `_Access`"); + grant.Path.Should().Be("_Access/Carol_Access"); + + await MeshService.CreateNode(grant).Should().Within(30.Seconds()).Emit(); + + // The grant itself landed — the fix must not cost root-scope grants their write path. + (await ReadStorage("_Access/Carol_Access")).Should().NotBeNull( + "a root-scope grant is a supported shape and must still be creatable"); + + // ...and `_Access` was NOT bootstrapped as a partition. + (await ReadStorage("_Access")).Should().BeNull( + "`_Access` is a satellite container namespace, never a partition root — a `Space` here " + + "would also have provisioned an unroutable `_access` schema (#714)"); + + // The same mis-read minted the creator grant at `{partition}/_Access/{creator}_Access` + // with partition == "_Access" — i.e. a doubled-up `_Access/_Access/…` node. Its absence + // pins that the bootstrap did not run at all here, not merely that the root was skipped. + (await ReadStorage($"_Access/_Access/{Creator}_Access")).Should().BeNull( + "no `_Access/_Access/…` grant — treating `_Access` as a partition doubled the segment"); + } + [Fact] public async Task MainNode_StaleSelfDefaultAfterRebase_IsRepairedToPath_OnCreate_NoPhantomPartition() { diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/PartitionSegmentValidationTest.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/PartitionSegmentValidationTest.cs index 4d01ec242c..92ea4007d9 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/PartitionSegmentValidationTest.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/PartitionSegmentValidationTest.cs @@ -1,14 +1,19 @@ +using MeshWeaver.Mesh; using Xunit; namespace MeshWeaver.Hosting.PostgreSql.Test; /// -/// #15 root-cause guard. -/// must reject URL/query-string-shaped path segments so the partition router NEVER -/// lazily CREATE SCHEMAs a garbage schema. Prod 2026-06-05: the atioz DB filled -/// with schemas like login?error=auth_failed and +/// #15 / #714 root-cause guard. +/// — the ONE shared charset rule used by the storage routers +/// (PostgreSqlPathRoutingAdapter / SnowflakePathRoutingAdapter) AND the +/// provisioning boundary (EnsurePartitionProvisioned / +/// OwnsPartitionProvisioningValidator) — must reject URL/query-string-shaped path +/// segments so a garbage schema can neither be routed to NOR provisioned. Prod 2026-06-05: +/// the atioz DB filled with schemas like login?error=auth_failed and /// search?q=agent&hq=scope%3adescendants — request URLs routed as mesh paths — -/// and corrupted itself. Pure validation: no DB, no Docker. +/// and corrupted itself; #714 found the same junk (query-string schemas, each with its own +/// mesh_nodes) in the memex and memexcloud databases. Pure validation: no DB, no Docker. /// public class PartitionSegmentValidationTest { @@ -16,15 +21,26 @@ public class PartitionSegmentValidationTest [InlineData("login?error=auth_failed")] // the real atioz garbage schemas [InlineData("search?q=agent&hq=scope%3adescendants")] [InlineData("search?q=coder&hq=scope%3adescendants")] + // The literal junk schema names found in the memex/memexcloud DBs (#714): + [InlineData("search?q=query%20syntax&hq=scope%3adescendants")] + [InlineData("login?returnurl=https%3a%2f%2fmemex.systemorph.com%2fauthorize")] [InlineData("a b")] // whitespace [InlineData("ns:with:colons")] // colons [InlineData("path/with/slash")] // slash [InlineData("name#frag")] // fragment + [InlineData("name=value")] // '=' + [InlineData("a&b")] // '&' + [InlineData("100%hundred")] // '%' [InlineData("")] // empty [InlineData("_access")] // leading underscore (not a partition name) public void Rejects_NonIdentifierSegments(string seg) - => PostgreSqlPathRoutingAdapter.IsValidPartitionSegment(seg).Should().BeFalse( - "URL/query-string/garbage segments must never become a Postgres schema"); + => PartitionDefinition.IsValidPartitionSegment(seg).Should().BeFalse( + "URL/query-string/garbage segments must never become a schema — routed OR provisioned"); + + [Fact] + public void Rejects_Null() + => PartitionDefinition.IsValidPartitionSegment(null).Should().BeFalse( + "a null id can never name a partition"); [Theory] [InlineData("rbuergi")] @@ -35,12 +51,25 @@ public void Rejects_NonIdentifierSegments(string seg) [InlineData("space-1")] [InlineData("my_space")] [InlineData("a")] + // The rule is Unicode-aware (char.IsLetterOrDigit), NOT an [A-Za-z0-9._-] whitelist — an + // accented partition name is legitimate and must not be refused (nor dropped by V51). + [InlineData("müller")] + [InlineData("Straße")] public void Accepts_ValidPartitionNames(string seg) - => PostgreSqlPathRoutingAdapter.IsValidPartitionSegment(seg).Should().BeTrue( + => PartitionDefinition.IsValidPartitionSegment(seg).Should().BeTrue( "a simple identifier is a valid partition / schema name"); [Fact] - public void Rejects_TooLong() - => PostgreSqlPathRoutingAdapter.IsValidPartitionSegment(new string('a', 64)).Should().BeFalse( - "Postgres identifiers are capped at 63 characters"); + public void Accepts_ExactlyAtTheByteLimit() + => PartitionDefinition.IsValidPartitionSegment(new string('a', 63)).Should().BeTrue( + "63 bytes is Postgres' NAMEDATALEN limit — the boundary itself is still valid"); + + [Theory] + [InlineData(64, 'a')] // 64 ASCII chars = 64 bytes + [InlineData(32, 'ü')] // 32 two-byte chars = 64 bytes, but only 32 CHARS + public void Rejects_LongerThan63Utf8Bytes(int count, char fill) + => PartitionDefinition.IsValidPartitionSegment(new string(fill, count)).Should().BeFalse( + "Postgres identifiers are capped at 63 BYTES (NAMEDATALEN) and silently truncate " + + "beyond it — a char-counted cap would let a multi-byte name be provisioned under a " + + "truncated schema the router could never route back to"); } diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/ProvisioningBoundaryValidationTest.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/ProvisioningBoundaryValidationTest.cs new file mode 100644 index 0000000000..32d630779e --- /dev/null +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/ProvisioningBoundaryValidationTest.cs @@ -0,0 +1,120 @@ +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using MeshWeaver.Mesh; +using Npgsql; +using Xunit; + +namespace MeshWeaver.Hosting.PostgreSql.Test; + +/// +/// #714 defense-in-depth guard at the provisioning BOUNDARY itself. The router refusing to +/// ROUTE a malformed segment is not enough — a deliberate top-level create (or any future +/// caller) must not be able to reach public.ensure_partition_schema with a +/// URL-shaped id either. These tests exercise the REAL +/// seams +/// ( and the +/// EnsureSchemaAsync funnel via EnsureSchemaForPartitionAsync) and assert the +/// rejection is LOUD — an naming the offending id through +/// the observable's OnError — never a silent no-op the caller mistakes for success. +/// The guard fires before any DB round-trip, so no container is needed: the data source +/// below is never connected. +/// +public class ProvisioningBoundaryValidationTest +{ + private const string NeverConnectedConnectionString = + "Host=localhost;Port=1;Database=never;Username=never;Password=never"; + + /// + /// Provider + the data source it was built over, disposed together. The provider's own + /// Dispose does not own the injected data source, so a bare CreateProvider() + /// would leak one pooled per test case. + /// + private sealed record ProviderScope( + PostgreSqlPartitionStorageProvider Provider, NpgsqlDataSource DataSource) : IDisposable + { + public void Dispose() + { + Provider.Dispose(); + DataSource.Dispose(); + } + } + + private static ProviderScope CreateProvider() + { + var dataSource = NpgsqlDataSource.Create(NeverConnectedConnectionString); + return new ProviderScope( + new PostgreSqlPartitionStorageProvider( + dataSource, + NeverConnectedConnectionString, + new PostgreSqlStorageOptions { ConnectionString = NeverConnectedConnectionString }), + dataSource); + } + + [Theory] + [InlineData("search?q=query%20syntax&hq=scope%3adescendants")] // the literal #714 junk schemas + [InlineData("login?returnurl=https%3a%2f%2fmemex.systemorph.com%2fauthorize")] + [InlineData("a b")] + [InlineData("ns:with:colons")] + // 🚨 `_access` is junk AS A PARTITION NAME, and must stay junk. A `_`-prefixed name is a + // satellite CONTAINER segment ({P}/_Access/…) or a global-satellite NAMESPACE whose schema + // comes from a REGISTERED PartitionDefinition (`_Access` → `system_access`) — the router + // never derives a schema from a `_`-prefixed segment, so a schema named `_access` could + // never be routed to. Exempting `_`-prefixed names here (as an earlier revision of this PR + // did) re-opens exactly the ghost-schema hole #714 is about; the real defect that motivated + // the exemption was a CALLER passing the root-scope `_Access` grant's namespace as a + // partition — fixed in MeshExtensions.EnsurePartitionBootstrap. + [InlineData("_access")] + public async Task EnsurePartitionProvisioned_ErrorsLoud_OnMalformedId(string junk) + { + using var scope = CreateProvider(); + + var ex = await Assert.ThrowsAsync(() => + scope.Provider.EnsurePartitionProvisioned(junk) + .FirstAsync() + .ToTask(TestContext.Current.CancellationToken)); + + ex.Message.Should().Contain(junk, + "the rejection must NAME the offending id so the caller can see what was refused"); + ex.Message.Should().Contain(PartitionDefinition.PartitionSegmentRequirement, + "the rejection must say WHY the id was refused"); + } + + [Theory] + [InlineData(64, 'a')] // 64 ASCII chars = 64 bytes + // 32 two-byte chars = 64 UTF-8 BYTES but only 32 chars: Postgres' NAMEDATALEN cap is a BYTE + // cap that silently TRUNCATES, so a char-counted rule would provision this under a truncated + // schema name the router (computing the untruncated name) could never route back to. + [InlineData(32, 'ü')] + public async Task EnsurePartitionProvisioned_TooLongId_ErrorsLoud(int count, char fill) + { + using var scope = CreateProvider(); + var tooLong = new string(fill, count); + + var ex = await Assert.ThrowsAsync(() => + scope.Provider.EnsurePartitionProvisioned(tooLong) + .FirstAsync() + .ToTask(TestContext.Current.CancellationToken)); + + ex.Message.Should().Contain(tooLong); + } + + [Fact] + public async Task EnsureSchemaFunnel_ErrorsLoud_OnMalformedSchema() + { + // The EnsureSchemaAsync funnel (also fed by the boot-time static-partition seeding + // via EnsureSchemaForPartitionAsync) must apply the SAME rule to the resolved schema + // name — no code path may pass a junk name into public.ensure_partition_schema. + using var scope = CreateProvider(); + var def = new PartitionDefinition + { + Namespace = "search?q=agent&hq=scope%3adescendants", + Schema = "search?q=agent&hq=scope%3adescendants", + }; + + var ex = await Assert.ThrowsAsync(() => + scope.Provider.EnsureSchemaForPartitionAsync(def, TestContext.Current.CancellationToken)); + + ex.Message.Should().Contain("search?q=agent&hq=scope%3adescendants"); + ex.Message.Should().Contain(PartitionDefinition.PartitionSegmentRequirement); + } +} diff --git a/test/MeshWeaver.Query.Test/QueryAsyncIntegrationTests.cs b/test/MeshWeaver.Query.Test/QueryAsyncIntegrationTests.cs index 5c5a3f0e0e..02ad95545e 100644 --- a/test/MeshWeaver.Query.Test/QueryAsyncIntegrationTests.cs +++ b/test/MeshWeaver.Query.Test/QueryAsyncIntegrationTests.cs @@ -19,8 +19,16 @@ public class QueryAsyncIntegrationTests(ITestOutputHelper output) : MonolithMesh protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => base.ConfigureMesh(builder).AddAI(); - // Use unique prefixes per test to avoid cross-test contamination (shared mesh instance) - private static string P([CallerMemberName] string name = "") => name; + // Use unique prefixes per test to avoid cross-test contamination (shared mesh instance). + // 🚨 Bounded to 40 chars, leaving room for the suffixes callers append (e.g. "other"): a + // partition id becomes a Postgres SCHEMA name, and PG truncates identifiers at 63 bytes — so + // two long names sharing a 63-char prefix would silently land in the SAME schema. Provisioning + // now rejects an over-long id rather than colliding (#714), and several method names here are + // long enough to trip it. The prefix stays unique because the truncation keeps the leading, + // most-distinctive part of each method name. + private const int MaxPartitionPrefix = 40; + private static string P([CallerMemberName] string name = "") => + name.Length <= MaxPartitionPrefix ? name : name[..MaxPartitionPrefix]; /// /// Reactive replacement for QueryAsync(...).ToListAsync(): the first