diff --git a/contracts/cultnet/cultnet.selection.schema.json b/contracts/cultnet/cultnet.selection.schema.json index bf3cb534..4ae06173 100644 --- a/contracts/cultnet/cultnet.selection.schema.json +++ b/contracts/cultnet/cultnet.selection.schema.json @@ -7,11 +7,15 @@ "properties": { "schemas": { "type": "array", - "items": { "type": "string", "minLength": 1 } + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "Absent means every schema. Present and empty is refused at the door (selection_invalid), never treated as \"every schema\": omit the field instead." }, "keys": { "type": "array", - "items": { "type": "string", "minLength": 1 } + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "Absent means every key. Present and empty is refused at the door (selection_invalid), never treated as \"every key\": omit the field instead." }, "fields": { "type": "array", @@ -25,7 +29,12 @@ "default": "header" }, "descending": { "type": "boolean", "default": false }, - "limit": { "type": "integer", "minimum": 1, "maximum": 200 }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "description": "Clamped to 1..=200 by the answering server, not refused outside that range: an absent value or one outside the bound is treated as the nearer bound (absent = 200)." + }, "cursor": { "type": "string" } }, "$defs": { diff --git a/packages/cultnet-py/src/cultnet_py/interop_peer.py b/packages/cultnet-py/src/cultnet_py/interop_peer.py index 4ff89314..ba236686 100644 --- a/packages/cultnet-py/src/cultnet_py/interop_peer.py +++ b/packages/cultnet-py/src/cultnet_py/interop_peer.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse from collections import defaultdict import json +import re import selectors import signal import socket @@ -384,14 +385,27 @@ def handle_server_message(state: PeerState, message: dict[str, Any], subscriptio return [state.verse_catalog.create_response(message)] if schema_version == PEER_EXCHANGE_REQUEST: return [state.peer_catalog.create_response(message)] - if isinstance(schema_version, str) and schema_version.startswith("cultnet.") and schema_version.endswith(".v1"): + if is_unsupported_cultnet_version(schema_version): # R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this peer speaks - # cultnet.selection.v1's carrier messages (snapshot_request.v1, database_subscribe.v1) not at - # all - it is v0-only. A v1 message is refused loudly, not answered with a silent empty list. + # cultnet.selection.v1's carrier messages (snapshot_request.v1, database_subscribe.v1, and + # any later vN) not at all - it is v0-only. A non-v0 message is refused loudly, not answered + # with a silent empty list. The prior check only matched ".v1" exactly, so v2+ fell through + # to the silent empty return below instead of being refused (R-L). return [unsupported_schema_version_error(message)] return [] +_CULTNET_SCHEMA_VERSION_SUFFIX = re.compile(r"^cultnet\..*\.v(\d+)$") + + +def is_unsupported_cultnet_version(schema_version: Any) -> bool: + """True for any cultnet.*.vN with N > 0 - every version this v0-only peer does not speak (R-2/R-L).""" + if not isinstance(schema_version, str): + return False + match = _CULTNET_SCHEMA_VERSION_SUFFIX.match(schema_version) + return match is not None and int(match.group(1)) > 0 + + def unsupported_schema_version_error(message: dict[str, Any]) -> dict[str, Any]: schema_version = message.get("schemaVersion") return { diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index b95686be..475305d3 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -1954,11 +1954,19 @@ class CultNetTests(unittest.TestCase): self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"]) def test_python_interop_peer_refuses_v1_selection_messages(self) -> None: - # R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): a v0-only peer refuses a v1 - # message loudly - cultnet.error.v0 { code: "unsupported_schema_version" } - rather than + # R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): a v0-only peer refuses any + # non-v0 message loudly - cultnet.error.v0 { code: "unsupported_schema_version" } - rather than # silently answering with an empty list. handle_server_message never touches `state` before - # this refusal fires, so no PeerState fixture is needed here. - for schema_version in ("cultnet.snapshot_request.v1", "cultnet.database_subscribe.v1"): + # this refusal fires, so no PeerState fixture is needed here. R-L: the original check matched + # only ".v1" exactly, so v2+ silently fell through to an empty list instead of being refused - + # this covers v2 and v10 (double-digit) too, not only the exact v1 spelling. + for schema_version in ( + "cultnet.snapshot_request.v1", + "cultnet.database_subscribe.v1", + "cultnet.snapshot_request.v2", + "cultnet.database_subscribe.v2", + "cultnet.snapshot_request.v10", + ): with self.subTest(schema_version=schema_version): responses = handle_server_message( None, # type: ignore[arg-type] diff --git a/scripts/mutate-dotnet.ps1 b/scripts/mutate-dotnet.ps1 index d3201b8a..1ccfd9bf 100644 --- a/scripts/mutate-dotnet.ps1 +++ b/scripts/mutate-dotnet.ps1 @@ -42,6 +42,15 @@ param( $ErrorActionPreference = 'Stop' +# R-L: an entry's `File` (from -Entries, often written with forward slashes) is looked up against the +# $targets dictionary keyed by -Target's own spelling. A bare string-key mismatch ("a\b.cs" vs +# "a/b.cs") doesn't throw at the lookup - $targets[$file] just silently returns $null - so the crash +# happens one call later, deep inside a .NET path/string API, as a null-argument error that reads +# nothing like "unknown target". Normalizing both sides to '/' up front turns that into either a +# clean match or an honest "Target ... does not exist" at line 99, never a null-argument surprise. +function Normalize-TargetPath([string]$path) { $path -replace '\\', '/' } +$Target = @($Target | ForEach-Object { Normalize-TargetPath $_ }) + $repo = if ($Repo) { (Resolve-Path -LiteralPath $Repo).Path } else { (Resolve-Path (Join-Path $PSScriptRoot '..')).Path } $testProjectPath = Join-Path $repo $TestProject $utf8 = [System.Text.UTF8Encoding]::new($false) @@ -165,11 +174,11 @@ foreach ($mutation in $mutations) { Write-Host "--- $($mutation.Id) ($($mutation.Mutant)): $($mutation.Rule)" $texts = @{} - $files = @($edits | ForEach-Object { if ($_.File) { $_.File } elseif ($Target.Count -eq 1) { $Target[0] } else { throw "$($mutation.Id): an edit names no File and there is more than one target." } } | Select-Object -Unique) + $files = @($edits | ForEach-Object { if ($_.File) { Normalize-TargetPath $_.File } elseif ($Target.Count -eq 1) { $Target[0] } else { throw "$($mutation.Id): an edit names no File and there is more than one target." } } | Select-Object -Unique) foreach ($file in $files) { $texts[$file] = $utf8.GetString($originalBytes[$file]) } foreach ($edit in $edits) { - $file = if ($edit.File) { $edit.File } else { $Target[0] } + $file = if ($edit.File) { Normalize-TargetPath $edit.File } else { $Target[0] } $text = $texts[$file] $eol = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" } $old = ($edit.Old -replace "`r`n", "`n")