CultLib/packages/cultnet-rs/tests/error_message.rs
Libby 8f71bff69c cultnet error contract: one message_invalid code replaces malformed_document_put/delete
Q6-A of docs/cultnet-error-contract-cut.md. message_invalid joins the closed
cultnet.error.v0 code enum (both schema copies), with details.field naming the
request field at fault ("message" when the whole frame cannot be read).
C# ForMessageInvalid, Rust CultNetErrorCode::MessageInvalid, TS
cultNetMessageInvalidError and Python message_invalid_error all write the same
captured bytes (error-vectors.json plus the C# and Rust cases). cultmesh-py and
cultmesh-ts refuse malformed puts and deletes with it; the C# subscription
servers use it for the missing subscriptionId.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:14:12 +00:00

262 lines
15 KiB
Rust

//! R-N (docs/cultnet-selection-cut.md, fix batch 3): `cultnet.error.v0`'s `code`/`details` fields,
//! pinned byte-for-byte against the landed C# reference
//! (`CultNetErrorMessage.ForSelectionInvalid`/`ForCursor`/`ForReferenceOutsideTarget`,
//! `src/GameCult.Networking/CultNetSchemaMessages.cs`, commit `e8a3dec`).
//!
//! The expected bytes below are not hand-derived: they were captured by running MessagePack C#
//! 3.1.7 (the exact package/version `GameCult.Networking` depends on) against a standalone
//! `CultNetErrorMessage`/`CultNetErrorDetails` pair carrying the landed `[Key(...)]` shape, using
//! `MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)` - the
//! same options `CultNetSchemaMessageSerialization.Options` uses - then
//! `Convert.ToHexString(MessagePackSerializer.Serialize(message, options))`. The probe program lived
//! at `%TEMP%\claude\probe-msgpack` (outside this repo) and was run in the `dotnet` Yggdrasil verify
//! image via `ygg-verify.sh`; it is not part of this crate. Each case below documents the C# call
//! that produced its bytes.
//!
//! This confirms the one thing that matters for byte parity here: MessagePack C#'s
//! `[Key("name")]`-attributed map formatter always writes every declared key, in declaration order,
//! nil for a null value - it never omits a key. `CultNetMessage::Error` is therefore encoded through
//! the same `rmpv::Value::Map`-literal path as the crate's other raw `cultnet.schema.v0` messages
//! (`encode_raw_cultnet_schema_message`/`parse_raw_cultnet_schema_message` in `src/contracts.rs`)
//! rather than through the generic `serde_json::Value` round trip every other `CultNetMessage`
//! variant uses - that path's `serde_json::Value::Object` is a `BTreeMap` (no `preserve_order`
//! feature enabled in this crate's `serde_json` dependency), so it would emit keys sorted
//! alphabetically and silently break this exact byte match.
use cultnet_rs::{
CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};
fn hex_decode(hex: &str) -> Vec<u8> {
assert!(hex.len() % 2 == 0, "odd-length hex string");
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte"))
.collect()
}
struct Case {
name: &'static str,
/// Captured via `Convert.ToHexString(MessagePackSerializer.Serialize(msg, options))` in the
/// probe program described in this file's header comment.
csharp_hex: &'static str,
message: fn() -> CultNetMessage,
}
fn cases() -> Vec<Case> {
vec![
Case {
name: "selection_invalid_schemas_null",
// C#: ForSelectionInvalid(new CultNetSelectionInvalidException("schemas", null,
// "must not be empty"))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "selection_invalid: must not be empty".to_string(),
code: Some(CultNetErrorCode::SelectionInvalid),
details: Some(CultNetErrorDetails {
field: Some("schemas".to_string()),
value: None,
as_of: None,
current: None,
}),
},
},
Case {
name: "selection_invalid_field_value",
// C#: ForSelectionInvalid(new CultNetSelectionInvalidException("keys", " ",
// "blank key entry"))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "selection_invalid: blank key entry".to_string(),
code: Some(CultNetErrorCode::SelectionInvalid),
details: Some(CultNetErrorDetails {
field: Some("keys".to_string()),
value: Some(" ".to_string()),
as_of: None,
current: None,
}),
},
},
Case {
name: "cursor_stale",
// C#: ForCursor(new CultNetSelectionCursorException("cursor_stale",
// "cursor is stale", asOf: 1, current: 2))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
message: || CultNetMessage::Error {
error: "cursor_stale: cursor is stale".to_string(),
code: Some(CultNetErrorCode::CursorStale),
details: Some(CultNetErrorDetails {
field: None,
value: None,
as_of: Some(1),
current: Some(2),
}),
},
},
Case {
name: "cursor_invalid",
// C#: ForCursor(new CultNetSelectionCursorException("cursor_invalid",
// "cannot parse cursor")) - no asOf/current, so Details stays null.
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
message: || CultNetMessage::Error {
error: "cursor_invalid: cannot parse cursor".to_string(),
code: Some(CultNetErrorCode::CursorInvalid),
details: None,
},
},
Case {
name: "reference_outside_target",
// C#: ForReferenceOutsideTarget(...) - built directly with the final Error text here
// (the constructor path itself double-prefixes "reference_outside_target: ", see this
// crate's SelectionRefusal -> CultNetMessage mapping doc comment in src/selection.rs
// for the discrepancy); the wire shape under test does not depend on the text's content.
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
message: || CultNetMessage::Error {
error: "reference_outside_target: outside declared target".to_string(),
code: Some(CultNetErrorCode::ReferenceOutsideTarget),
details: None,
},
},
Case {
name: "variant_unsupported_subscription_ended",
// C#: ForVariantUnsupported("variant unsupported here", "sub-1")
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "variant unsupported here".to_string(),
code: Some(CultNetErrorCode::VariantUnsupported),
details: Some(CultNetErrorDetails {
field: Some("subscriptionId".to_string()),
value: Some("sub-1".to_string()),
as_of: None,
current: None,
}),
},
},
Case {
name: "variant_unsupported_no_subscription",
// C#: ForVariantUnsupported("variant unsupported here") - no subscription ended, so Details stays null.
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0",
message: || CultNetMessage::Error {
error: "variant unsupported here".to_string(),
code: Some(CultNetErrorCode::VariantUnsupported),
details: None,
},
},
Case {
name: "unowned_schema",
// C#: ForUnownedSchema(new CultNetUnownedSchemaException("some.schema.v1", new CultRecordKey("some:key")))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.".to_string(),
code: Some(CultNetErrorCode::UnownedSchema),
details: Some(CultNetErrorDetails {
field: Some("schemaId".to_string()),
value: Some("some.schema.v1".to_string()),
as_of: None,
current: None,
}),
},
},
Case {
name: "write_not_authorized",
// C#: ForRemoteWriteRefused(new CultNetRemoteWriteRequest(null, Put, "some.schema.v1", new CultRecordKey("some:key"), null, null), "not granted")
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "write_not_authorized: not granted".to_string(),
code: Some(CultNetErrorCode::WriteNotAuthorized),
details: Some(CultNetErrorDetails {
field: Some("recordKey".to_string()),
value: Some("some:key".to_string()),
as_of: None,
current: None,
}),
},
},
Case {
name: "unsupported_schema_version",
// C#: ForUnsupportedSchemaVersion("cultnet.hello.v1")
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "unsupported_schema_version: cultnet.hello.v1".to_string(),
code: Some(CultNetErrorCode::UnsupportedSchemaVersion),
details: Some(CultNetErrorDetails {
field: Some("schemaVersion".to_string()),
value: Some("cultnet.hello.v1".to_string()),
as_of: None,
current: None,
}),
},
},
Case {
name: "message_invalid",
// C#: ForMessageInvalid("document", "Raw put messages must contain a document map.")
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "message_invalid: Raw put messages must contain a document map.".to_string(),
code: Some(CultNetErrorCode::MessageInvalid),
details: Some(CultNetErrorDetails {
field: Some("document".to_string()),
value: None,
as_of: None,
current: None,
}),
},
},
]
}
#[test]
fn rust_encode_matches_csharp_bytes_for_every_r_n_code() {
for case in cases() {
let expected = hex_decode(case.csharp_hex);
let actual = encode_cultnet_message_to_vec(&(case.message)(), CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name));
assert_eq!(
actual, expected,
"{}: Rust-encoded bytes do not match the captured C# encoding",
case.name
);
}
}
#[test]
fn rust_decodes_the_csharp_written_bytes_back_into_the_same_typed_message() {
for case in cases() {
let bytes = hex_decode(case.csharp_hex);
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name));
assert_eq!(decoded, (case.message)(), "{}: decoded value mismatch", case.name);
}
}
#[test]
fn round_trip_is_stable() {
for case in cases() {
let message = (case.message)();
let encoded = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name));
let decoded = decode_cultnet_message_from_slice(&encoded, CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name));
assert_eq!(decoded, message, "{}: round trip mismatch", case.name);
}
}
/// `contracts/cultnet/interop/error-vectors.json` is the file the TypeScript and Python runtimes
/// decode; it must stay byte-identical to the inline C# captures above, or those runtimes would be
/// pinned to bytes this crate no longer pins.
#[test]
fn error_vectors_file_matches_the_inline_csharp_captures() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../contracts/cultnet/interop/error-vectors.json");
let file: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).expect("read error-vectors.json"))
.expect("parse error-vectors.json");
let vectors = file["vectors"].as_array().expect("vectors array");
let all = cases();
assert_eq!(vectors.len(), all.len(), "vector count differs from the inline cases");
for (vector, case) in vectors.iter().zip(all.iter()) {
assert_eq!(vector["name"].as_str(), Some(case.name));
assert_eq!(vector["messagePackHex"].as_str(), Some(case.csharp_hex), "{}", case.name);
}
}