CultNet selection cut1 fix batch: R-L (contract, Python, mutate-dotnet.ps1)
- cultnet.selection.schema.json: schemas/keys gain minItems: 1 (present and empty is refused at the door, never "every schema/key" - omit the field instead); limit documents that it is clamped 1..=200 by the answering server rather than refused outside that range. - cultnet_py/interop_peer.py: handle_server_message refused only an exact ".v1" suffix, so a v2+ selection message fell through to a silent empty-list answer instead of a loud refusal. is_unsupported_cultnet_version matches any cultnet.*.vN with N > 0 instead. test_cultnet.py's R-2 test now covers v2 and v10, not only v1. - mutate-dotnet.ps1: an entry's File (from -Entries, often written with "/") is looked up against the $targets dictionary keyed by -Target's own spelling; a "\\" vs "/" mismatch didn't throw at the lookup - $targets[$file] silently returned $null - so the crash surfaced one call later as a null-argument error inside a .NET path API instead of as "unknown target". Both -Target and every File read from an entry now normalise through Normalize-TargetPath before indexing $targets. Discrepancy: the ruling names "both R-2 sites" for the Python fix; only one site with this exact narrow-match bug exists in packages/cultnet-py and packages/cultmesh-py (cultmesh_py/server.py's handle_message already refuses every unrecognised schemaVersion, v1+ included, through its own default branch). Fixed the one that exists; did not invent a second. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
915262fee2
commit
c01bc0a129
4 changed files with 52 additions and 12 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue