From 29c3e4c2b56198128a917137b6adabad207b4619 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 00:48:13 +0200 Subject: [PATCH 01/21] Add the content plane to cultnet-rs: chunk messages, pack, answer, verified fetch ContentChunkRequest/Response join CultNetMessage on the raw path, byte identical to the reference's field order. content.rs owns normalize_hash, pack_content, validate_manifest, answer_content_chunk_request and fetch_content, mirroring CultMeshCdn and CultMeshLegacyRudpContentServer. The C# vector test is added here; the vector files and Rust tests follow. Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-rs/src/content.rs | 365 ++++++++++++++++++ packages/cultnet-rs/src/contracts.rs | 119 ++++++ packages/cultnet-rs/src/lib.rs | 2 + .../CultMeshContentVectorTests.cs | 258 +++++++++++++ 4 files changed, 744 insertions(+) create mode 100644 packages/cultnet-rs/src/content.rs create mode 100644 tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs diff --git a/packages/cultnet-rs/src/content.rs b/packages/cultnet-rs/src/content.rs new file mode 100644 index 00000000..bbf460b9 --- /dev/null +++ b/packages/cultnet-rs/src/content.rs @@ -0,0 +1,365 @@ +//! CultMesh content plane: chunking, hashing, manifest shape and validation, answering one chunk +//! request, and verifying a fetched body. Mirrors `CultMeshCdn` (`CultMeshCdn.cs`) and +//! `CultMeshLegacyRudpContentServer` (`CultMeshContentSessions.cs`); the two chunk messages are +//! `CultNetMessage::ContentChunkRequest`/`ContentChunkResponse` in `contracts.rs`. +//! +//! This is the only owner of these rules in Rust. A consumer hands `fetch_content` a transport +//! closure and gets verified bytes; it never hashes, chunks or validates a manifest itself. + +use std::collections::BTreeMap; + +use anyhow::{Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::CultNetMessage; + +/// One chunk of a packed body. `chunk_hash` is lowercase SHA-256 hex of `payload`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshCdnChunk { + pub chunk_hash: String, + pub payload: Vec, +} + +/// `CultMeshCdnChunkRef`. Encodes as the reference's positional array under every encoder. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "ChunkRefWire", into = "ChunkRefWire")] +pub struct CultMeshCdnChunkRef { + pub chunk_hash: String, + pub offset: i64, + pub size_bytes: i32, + pub record_key: String, +} + +/// `CultMeshCdnArtifactManifest`. Encodes as the reference's positional array (keys 0-9). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "ManifestWire", into = "ManifestWire")] +pub struct CultMeshCdnArtifactManifest { + pub artifact_id: String, + pub kind: String, + pub version: String, + pub content_hash: String, + pub size_bytes: i64, + pub mime_type: String, + pub created_at_utc: String, + pub chunks: Vec, + pub tags: Vec, + pub metadata: BTreeMap, +} + +// The tuple mirrors: `to_vec_named` would write a map for a named-field struct, but a tuple +// struct is an array under every serde encoder. +#[derive(Serialize, Deserialize)] +struct ChunkRefWire(String, i64, i32, String); + +#[derive(Serialize, Deserialize)] +struct ManifestWire( + String, + String, + String, + String, + i64, + String, + String, + Vec, + Vec, + BTreeMap, +); + +impl From for ChunkRefWire { + fn from(value: CultMeshCdnChunkRef) -> Self { + Self(value.chunk_hash, value.offset, value.size_bytes, value.record_key) + } +} + +impl From for CultMeshCdnChunkRef { + fn from(value: ChunkRefWire) -> Self { + Self { + chunk_hash: value.0, + offset: value.1, + size_bytes: value.2, + record_key: value.3, + } + } +} + +impl From for ManifestWire { + fn from(value: CultMeshCdnArtifactManifest) -> Self { + Self( + value.artifact_id, + value.kind, + value.version, + value.content_hash, + value.size_bytes, + value.mime_type, + value.created_at_utc, + value.chunks, + value.tags, + value.metadata, + ) + } +} + +impl From for CultMeshCdnArtifactManifest { + fn from(value: ManifestWire) -> Self { + Self { + artifact_id: value.0, + kind: value.1, + version: value.2, + content_hash: value.3, + size_bytes: value.4, + mime_type: value.5, + created_at_utc: value.6, + chunks: value.7, + tags: value.8, + metadata: value.9, + } + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn chunk_record_key(hash: &str) -> String { + format!("mesh:cdn:chunk:{hash}") +} + +/// `CultMeshCdn.NormalizeHash`: an optional case-insensitive `sha256:` prefix is stripped, then the +/// rest is trimmed and lowercased. A blank hash is refused. +pub fn normalize_hash(hash: &str) -> Result { + if hash.trim().is_empty() { + bail!("Hash must be non-empty."); + } + let stripped = match hash.get(..7) { + Some(prefix) if prefix.eq_ignore_ascii_case("sha256:") => &hash[7..], + _ => hash, + }; + Ok(stripped.trim().to_lowercase()) +} + +/// `CultMeshCdn.PackArtifact`: splits `bytes` into content-addressed chunks and builds the manifest. +/// An empty body packs as one zero-length chunk, as the reference does. +pub fn pack_content( + artifact_id: &str, + kind: &str, + version: &str, + mime_type: &str, + created_at_utc: &str, + bytes: &[u8], + chunk_size: usize, +) -> Result<(CultMeshCdnArtifactManifest, Vec)> { + if artifact_id.trim().is_empty() { + bail!("Artifact id must be non-empty."); + } + if chunk_size == 0 || i32::try_from(chunk_size).is_err() { + bail!("ChunkSizeBytes must be greater than zero and fit in an int."); + } + let pieces: Vec<&[u8]> = if bytes.is_empty() { + vec![bytes] + } else { + bytes.chunks(chunk_size).collect() + }; + let mut offset = 0i64; + let mut chunks = Vec::with_capacity(pieces.len()); + let mut refs = Vec::with_capacity(pieces.len()); + for piece in pieces { + let chunk_hash = sha256_hex(piece); + refs.push(CultMeshCdnChunkRef { + record_key: chunk_record_key(&chunk_hash), + chunk_hash: chunk_hash.clone(), + offset, + // A piece is at most `chunk_size` bytes, which fits an i32 (checked above). + size_bytes: piece.len() as i32, + }); + offset += piece.len() as i64; + chunks.push(CultMeshCdnChunk { + chunk_hash, + payload: piece.to_vec(), + }); + } + let manifest = CultMeshCdnArtifactManifest { + artifact_id: artifact_id.to_string(), + kind: non_blank_or(kind, "asset"), + version: version.to_string(), + content_hash: sha256_hex(bytes), + size_bytes: bytes.len() as i64, + mime_type: non_blank_or(mime_type, "application/octet-stream"), + created_at_utc: created_at_utc.to_string(), + chunks: refs, + tags: Vec::new(), + metadata: BTreeMap::new(), + }; + validate_manifest(&manifest)?; + Ok((manifest, chunks)) +} + +fn non_blank_or(value: &str, default: &str) -> String { + if value.trim().is_empty() { + default.to_string() + } else { + value.to_string() + } +} + +/// The chunk list in offset order, as the reference reads it (`OrderBy(chunk => chunk.Offset)`). +fn in_offset_order(manifest: &CultMeshCdnArtifactManifest) -> Vec<&CultMeshCdnChunkRef> { + let mut refs: Vec<&CultMeshCdnChunkRef> = manifest.chunks.iter().collect(); + refs.sort_by_key(|chunk| chunk.offset); + refs +} + +/// `CultMeshCdn.ValidateManifestShape`: non-negative size, chunks contiguous from offset 0 once +/// sorted by offset, non-negative chunk sizes, and chunk sizes summing to the manifest size. +pub fn validate_manifest(manifest: &CultMeshCdnArtifactManifest) -> Result<()> { + if manifest.size_bytes < 0 { + bail!("CDN artifact manifest has a negative size."); + } + let mut expected_offset = 0i64; + for chunk in in_offset_order(manifest) { + if chunk.offset != expected_offset { + bail!("CDN artifact chunks are not contiguous."); + } + if chunk.size_bytes < 0 { + bail!("CDN artifact chunk has a negative size."); + } + expected_offset = expected_offset + .checked_add(i64::from(chunk.size_bytes)) + .ok_or_else(|| anyhow!("CDN artifact chunk sizes overflow."))?; + } + if expected_offset != manifest.size_bytes { + bail!("CDN artifact chunk sizes do not sum to the manifest size."); + } + Ok(()) +} + +/// `CultMeshCdn.ValidateChunk`: the chunk's size and full SHA-256 must be what the reference and +/// the chunk's own claim say. `ref_hash` is already normalized. +fn validate_chunk( + ref_hash: &str, + ref_size: i32, + claimed_hash: &str, + claimed_size: i64, + payload: &[u8], +) -> Result<()> { + if i64::try_from(payload.len()).ok() != Some(i64::from(ref_size)) || claimed_size != i64::from(ref_size) { + bail!("CDN artifact chunk payload metadata does not match its manifest reference."); + } + let actual = sha256_hex(payload); + if ref_hash != actual || normalize_hash(claimed_hash)? != actual { + bail!("CDN artifact chunk hash does not match its payload."); + } + Ok(()) +} + +/// `CultMeshLegacyRudpContentServer.HandleAsync`, in its validation order: message id, hash +/// normalisation, record-key agreement with `mesh:cdn:chunk:`, lookup, then size and hash of +/// what is served. A failure is `found: false` with an empty payload and the reference's +/// `: ` in `error`; it is never silence. `lookup` takes the normalized hash. +pub fn answer_content_chunk_request<'a>( + request: &CultNetMessage, + lookup: impl Fn(&str) -> Option<&'a [u8]>, +) -> CultNetMessage { + let CultNetMessage::ContentChunkRequest { + message_id, + chunk_hash, + record_key, + expected_size_bytes, + } = request + else { + return CultNetMessage::Error { + error: "not a cultmesh.content_chunk_request.v1 message".to_string(), + code: None, + details: None, + }; + }; + let served = (|| -> std::result::Result<(String, Vec), String> { + if message_id.trim().is_empty() { + return Err("InvalidDataException: Content chunk request requires a message identity.".to_string()); + } + let hash = normalize_hash(chunk_hash) + .map_err(|_| "ArgumentException: Hash must be non-empty. (Parameter 'ChunkHash')".to_string())?; + if !record_key.trim().is_empty() && *record_key != chunk_record_key(&hash) { + return Err("InvalidDataException: Content chunk record key disagrees with its content hash.".to_string()); + } + let payload = lookup(&hash).ok_or_else(|| "FileNotFoundException: Content chunk is not available.".to_string())?; + validate_chunk(&hash, *expected_size_bytes, &hash, payload.len() as i64, payload) + .map_err(|error| format!("InvalidDataException: {error}"))?; + Ok((hash, payload.to_vec())) + })(); + match served { + Ok((hash, payload)) => CultNetMessage::ContentChunkResponse { + message_id: message_id.clone(), + found: true, + chunk_hash: hash, + size_bytes: *expected_size_bytes, + payload, + error: String::new(), + }, + Err(error) => CultNetMessage::ContentChunkResponse { + message_id: message_id.clone(), + found: false, + chunk_hash: chunk_hash.clone(), + size_bytes: 0, + payload: Vec::new(), + error, + }, + } +} + +/// The client half: fetches every chunk of `manifest` through `ask`, one in flight, in offset +/// order, and returns the body only after each chunk and then the whole body verify. `max_bytes` +/// is checked against the manifest before the first request. `ask` sends a request on the +/// caller's session and returns the answer; it owns transport, this function owns verification. +pub fn fetch_content( + manifest: &CultMeshCdnArtifactManifest, + max_bytes: u64, + mut ask: impl FnMut(CultNetMessage) -> Result, +) -> Result> { + validate_manifest(manifest)?; + if manifest.size_bytes as u64 > max_bytes { + bail!( + "CDN artifact of {} bytes exceeds the {max_bytes}-byte cap.", + manifest.size_bytes + ); + } + let mut body = Vec::new(); + for chunk in in_offset_order(manifest) { + let hash = normalize_hash(&chunk.chunk_hash)?; + let message_id = uuid::Uuid::new_v4().simple().to_string(); + let record_key = if chunk.record_key.trim().is_empty() { + chunk_record_key(&hash) + } else { + chunk.record_key.clone() + }; + let answer = ask(CultNetMessage::ContentChunkRequest { + message_id: message_id.clone(), + chunk_hash: hash.clone(), + record_key, + expected_size_bytes: chunk.size_bytes, + })?; + let CultNetMessage::ContentChunkResponse { + message_id: answered, + found, + chunk_hash, + size_bytes, + payload, + error, + } = answer + else { + bail!("chunk '{hash}' was answered with something other than a content chunk response"); + }; + if answered != message_id { + bail!("chunk '{hash}' was answered for message '{answered}', not '{message_id}'"); + } + if !found { + bail!("content provider rejected chunk '{hash}': {error}"); + } + validate_chunk(&hash, chunk.size_bytes, &chunk_hash, i64::from(size_bytes), &payload)?; + body.extend_from_slice(&payload); + } + if sha256_hex(&body) != normalize_hash(&manifest.content_hash)? { + bail!("CDN artifact content hash does not match its manifest."); + } + Ok(body) +} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index 68edf97a..038b0708 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -508,6 +508,26 @@ pub enum CultNetMessage { #[serde(default, skip_serializing_if = "Option::is_none")] source_runtime_id: Option, }, + /// `cultmesh.content_chunk_request.v1`. Mirrors `CultMeshContentChunkRequestMessage`; raw path. + #[serde(rename = "cultmesh.content_chunk_request.v1", rename_all = "camelCase")] + ContentChunkRequest { + message_id: String, + chunk_hash: String, + record_key: String, + expected_size_bytes: i32, + }, + /// `cultmesh.content_chunk_response.v1`. Mirrors `CultMeshContentChunkResponseMessage`. `payload` + /// is `bin`, so it travels the raw path; the serde attribute never runs on the wire. + #[serde(rename = "cultmesh.content_chunk_response.v1", rename_all = "camelCase")] + ContentChunkResponse { + message_id: String, + found: bool, + chunk_hash: String, + size_bytes: i32, + #[serde(with = "serde_bytes")] + payload: Vec, + error: String, + }, } fn messagepack_base64_encoding() -> String { @@ -529,6 +549,8 @@ pub fn parse_cultnet_message( "cultnet.document_put_raw.v0" | "cultnet.snapshot_response_raw.v0" | "cultnet.snapshot_response_raw.v1" + | "cultmesh.content_chunk_request.v1" + | "cultmesh.content_chunk_response.v1" | "cultnet.error.v0", ) => parse_raw_cultnet_schema_message(wire_value)?, _ => { @@ -553,6 +575,8 @@ pub fn encode_cultnet_message_for_wire( CultNetMessage::DocumentPutRaw { .. } | CultNetMessage::SnapshotResponseRaw { .. } | CultNetMessage::SnapshotResponseRawV1 { .. } + | CultNetMessage::ContentChunkRequest { .. } + | CultNetMessage::ContentChunkResponse { .. } | CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message), _ => Ok(rmp_serde::from_slice(&rmp_serde::to_vec( &serde_json::to_value(message)?, @@ -777,6 +801,40 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { validate_shard_descriptor(shard)?; } } + CultNetMessage::ContentChunkRequest { + message_id, + chunk_hash, + record_key: _, + expected_size_bytes, + } => { + require_non_empty(message_id, "messageId")?; + if crate::normalize_hash(chunk_hash)?.is_empty() { + return Err(anyhow!("CultNet field chunkHash must be non-empty")); + } + if *expected_size_bytes < 0 { + return Err(anyhow!("CultNet field expectedSizeBytes must not be negative")); + } + } + CultNetMessage::ContentChunkResponse { + message_id, + found, + chunk_hash: _, + size_bytes, + payload, + error, + } => { + require_non_empty(message_id, "messageId")?; + if *found { + if i64::try_from(payload.len()).ok() != Some(i64::from(*size_bytes)) { + return Err(anyhow!("CultNet field payload length must equal sizeBytes")); + } + if !error.is_empty() { + return Err(anyhow!("CultNet field error must be empty when found")); + } + } else if !payload.is_empty() { + return Err(anyhow!("CultNet field payload must be empty when not found")); + } + } CultNetMessage::OperationRequest { message_id, service_id, @@ -1044,6 +1102,23 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result Ok(CultNetMessage::ContentChunkRequest { + message_id: require_legacy_string(get("messageId"), "messageId")?, + chunk_hash: require_legacy_string(get("chunkHash"), "chunkHash")?, + record_key: require_legacy_string(get("recordKey"), "recordKey")?, + expected_size_bytes: require_legacy_i32(get("expectedSizeBytes"), "expectedSizeBytes")?, + }), + "cultmesh.content_chunk_response.v1" => Ok(CultNetMessage::ContentChunkResponse { + message_id: require_legacy_string(get("messageId"), "messageId")?, + found: require_legacy_bool(get("found"), "found")?, + chunk_hash: require_legacy_string(get("chunkHash"), "chunkHash")?, + size_bytes: require_legacy_i32(get("sizeBytes"), "sizeBytes")?, + payload: get("payload") + .and_then(rmpv::Value::as_slice) + .ok_or_else(|| anyhow!("payload must be binary data"))? + .to_vec(), + error: require_legacy_string(get("error"), "error")?, + }), "cultnet.error.v0" => { let code = get("code") .filter(|value| !value.is_nil()) @@ -1209,6 +1284,43 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result vec![ + ( + rmpv::Value::from("schemaVersion"), + rmpv::Value::from("cultmesh.content_chunk_request.v1"), + ), + (rmpv::Value::from("messageId"), rmpv::Value::from(message_id.as_str())), + (rmpv::Value::from("chunkHash"), rmpv::Value::from(chunk_hash.as_str())), + (rmpv::Value::from("recordKey"), rmpv::Value::from(record_key.as_str())), + ( + rmpv::Value::from("expectedSizeBytes"), + rmpv::Value::from(*expected_size_bytes), + ), + ], + CultNetMessage::ContentChunkResponse { + message_id, + found, + chunk_hash, + size_bytes, + payload, + error, + } => vec![ + ( + rmpv::Value::from("schemaVersion"), + rmpv::Value::from("cultmesh.content_chunk_response.v1"), + ), + (rmpv::Value::from("messageId"), rmpv::Value::from(message_id.as_str())), + (rmpv::Value::from("found"), rmpv::Value::from(*found)), + (rmpv::Value::from("chunkHash"), rmpv::Value::from(chunk_hash.as_str())), + (rmpv::Value::from("sizeBytes"), rmpv::Value::from(*size_bytes)), + (rmpv::Value::from("payload"), rmpv::Value::Binary(payload.clone())), + (rmpv::Value::from("error"), rmpv::Value::from(error.as_str())), + ], // R-N: matches the landed C# `CultNetErrorMessage` // (src/GameCult.Networking/CultNetSchemaMessages.cs) key for key and in its declared // order - schemaVersion, error, routingHint, code, details - with every optional key @@ -1422,6 +1534,13 @@ fn require_legacy_u32(value: Option<&rmpv::Value>, field_name: &str) -> Result, field_name: &str) -> Result { + let number = value + .and_then(rmpv::Value::as_i64) + .ok_or_else(|| anyhow!("{field_name} must be an integer"))?; + i32::try_from(number).map_err(|_| anyhow!("{field_name} does not fit in i32")) +} + fn require_legacy_u64(value: Option<&rmpv::Value>, field_name: &str) -> Result { value .and_then(rmpv::Value::as_u64) diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index cf858647..5bfd1857 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -1,3 +1,4 @@ +mod content; mod contracts; mod idunn_deployment_brake; mod idunn_lifecycle_brake; @@ -19,6 +20,7 @@ mod selection; mod shard_catalog; mod transport; +pub use content::*; pub use contracts::*; pub use idunn_deployment_brake::*; pub use idunn_lifecycle_brake::*; diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs new file mode 100644 index 00000000..2e156891 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +/// +/// Content-plane parity vectors shared with packages/cultnet-rs/tests/content.rs. Each runtime +/// writes its own file under contracts/cultmesh and judges both. The chunk answers in these +/// files are produced by itself, so the failure +/// spellings are the reference's own bytes. +/// +[TestFixture] +public sealed class CultMeshContentVectorTests +{ + private const int BodyLength = 1_315_551; + private const int ChunkSize = 262_144; + private const string CreatedAtUtc = "2026-09-22T00:00:00.0000000+00:00"; + private const string ArtifactId = "eureka/content-vector"; + + private sealed record Vector(string Label, string Kind, byte[] Bytes); + + [Test] + public void WriteContentVectors() + { + if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1") + Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file."); + File.WriteAllText(VectorPath("content-vectors.cs-written.json"), ToJson(BuildVectors(out _, out _))); + } + + [Test] + public void CommittedCsVectorsEqualAFreshEncode() + { + var fresh = BuildVectors(out _, out _); + var committed = ReadVectors("content-vectors.cs-written.json"); + committed.Select(v => (v.Label, v.Kind)).Should().Equal(fresh.Select(v => (v.Label, v.Kind))); + foreach (var (expected, actual) in fresh.Zip(committed)) + actual.Bytes.Should().Equal(expected.Bytes, $"vector '{actual.Label}' drifted from the reference's encode"); + } + + [Test] + public void RustVectorsDecodeInTheReference() + { + BuildVectors(out var cache, out var messages); + var rust = ReadVectors("content-vectors.rs-written.json"); + rust.Should().NotBeEmpty(); + rust.Select(v => v.Label).Should().Equal(messages.Keys, "both runtimes write the same labelled vectors"); + foreach (var vector in rust) + { + var expected = messages[vector.Label]; + switch (vector.Kind) + { + case "request": + var request = MessagePackSerializer.Deserialize(vector.Bytes, CultNetSchemaMessageSerialization.Options); + request.Should().BeEquivalentTo((CultMeshContentChunkRequestMessage)expected); + Reencode(request).Should().Equal(vector.Bytes, vector.Label); + break; + case "response": + var response = MessagePackSerializer.Deserialize(vector.Bytes, CultNetSchemaMessageSerialization.Options); + response.Should().BeEquivalentTo((CultMeshContentChunkResponseMessage)expected); + Reencode(response).Should().Equal(vector.Bytes, vector.Label); + break; + case "manifest": + var manifest = MessagePackSerializer.Deserialize(vector.Bytes, CultNetSchemaMessageSerialization.Options); + manifest.Should().BeEquivalentTo((CultMeshCdnArtifactManifest)expected); + Reencode(manifest).Should().Equal(vector.Bytes, vector.Label); + break; + default: + Assert.Fail($"unknown vector kind '{vector.Kind}'"); + break; + } + } + + // Rust's answers to Rust's requests must be what the reference answers to the same bytes. + foreach (var vector in rust.Where(v => v.Label.EndsWith(".request", StringComparison.Ordinal))) + { + var request = MessagePackSerializer.Deserialize(vector.Bytes, CultNetSchemaMessageSerialization.Options); + var rustAnswer = rust.Single(v => v.Label == vector.Label[..^".request".Length] + ".response"); + Reencode(Answer(cache, request)).Should().Equal(rustAnswer.Bytes, vector.Label); + } + } + + [Test] + public void PackArtifactOfTheFixtureBodyEqualsTheFixtureManifest() + { + var artifact = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()); + var committed = ReadVectors("content-vectors.cs-written.json").Single(v => v.Label == "manifest"); + Reencode(artifact.Manifest).Should().Equal(committed.Bytes); + var rust = ReadVectors("content-vectors.rs-written.json").Single(v => v.Label == "manifest"); + rust.Bytes.Should().Equal(committed.Bytes, "Rust's pack_content and the reference pack the same bytes to the same manifest"); + } + + private static byte[] Body() => + Enumerable.Range(0, BodyLength).Select(i => (byte)(i % 251)).ToArray(); + + private static CultMeshCdnPackOptions Options() => new() + { + ChunkSizeBytes = ChunkSize, + Version = "1", + MimeType = "application/x-cultnet-vector", + CreatedAtUtc = CreatedAtUtc + }; + + private static byte[] Reencode(CultMeshContentChunkRequestMessage message) => + CultNetSchemaMessageSerialization.Serialize(message); + + private static byte[] Reencode(CultMeshContentChunkResponseMessage message) => + CultNetSchemaMessageSerialization.Serialize(message); + + private static byte[] Reencode(CultMeshCdnArtifactManifest manifest) => + MessagePackSerializer.Serialize(manifest, CultNetSchemaMessageSerialization.Options); + + private static CultMeshContentChunkRequestMessage Request(string id, string hash, string key, int size) => new() + { + MessageId = id, + ChunkHash = hash, + RecordKey = key, + ExpectedSizeBytes = size + }; + + /// The cs-written file's vectors, in file order. Messages are keyed by label. + private static List BuildVectors(out CultCache cache, out Dictionary messages) + { + var artifact = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()); + var content = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( + typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk))); + CultMeshCdn.PublishAsync(content, artifact).GetAwaiter().GetResult(); + cache = content; + + var last = artifact.Chunks[^1]; + var key = "mesh:cdn:chunk:" + last.ChunkHash; + var lastDigit = last.ChunkHash[^1] == '0' ? '1' : '0'; + var vectors = new List(); + var map = new Dictionary(StringComparer.Ordinal); + messages = map; + + void AddRequest(string label, CultMeshContentChunkRequestMessage message) + { + map[label] = message; + vectors.Add(new Vector(label, "request", Reencode(message))); + } + + void AddResponse(string label, CultMeshContentChunkResponseMessage message) + { + map[label] = message; + vectors.Add(new Vector(label, "response", Reencode(message))); + } + + void AddAnswer(string name, CultMeshContentChunkRequestMessage request) + { + AddRequest(name + ".request", request); + AddResponse(name + ".response", Answer(content, request)); + } + + AddAnswer("answer_found", Request("vector-found", "SHA256:" + last.ChunkHash, key, last.SizeBytes)); + AddAnswer("answer_not_found", Request("vector-missing", new string('0', 64), string.Empty, 100)); + AddAnswer("answer_record_key_disagrees", Request("vector-key", last.ChunkHash, "mesh:cdn:chunk:other", last.SizeBytes)); + AddAnswer("answer_size_mismatch", Request("vector-size", last.ChunkHash, key, last.SizeBytes - 1)); + AddResponse("response_hash_last_digit", new CultMeshContentChunkResponseMessage + { + MessageId = "vector-digit", + Found = true, + ChunkHash = last.ChunkHash[..^1] + lastDigit, + SizeBytes = last.SizeBytes, + Payload = last.Payload, + Error = string.Empty + }); + + void AddManifest(string label, CultMeshCdnArtifactManifest manifest) + { + map[label] = manifest; + vectors.Add(new Vector(label, "manifest", Reencode(manifest))); + } + + AddManifest("manifest", artifact.Manifest); + var shuffled = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest; + shuffled.Chunks = new[] { 3, 0, 5, 1, 4, 2 }.Select(i => shuffled.Chunks[i]).ToArray(); + AddManifest("manifest_out_of_order", shuffled); + AddManifest("manifest_empty_body", CultMeshCdn.PackArtifact(ArtifactId, Array.Empty(), Options()).Manifest); + return vectors; + } + + private static CultMeshContentChunkResponseMessage Answer(CultCache cache, CultMeshContentChunkRequestMessage request) + { + var host = new ReferenceHost(); + using var server = new CultMeshLegacyRudpContentServer(host, cache); + var peer = new ReferencePeer(); + host.Dispatch(request, peer).GetAwaiter().GetResult(); + return peer.Sent ?? throw new InvalidOperationException("The reference server answered with silence."); + } + + private sealed class ReferenceHost : ICultNetSchemaServer + { + private Func? _handler; + + public void OnCultNet(Func callback) + where TMessage : ICultNetSchemaMessage => + _handler = (Func)(object)callback; + + public void RemoveCultNetMessageListener(Delegate callback) + where TMessage : ICultNetSchemaMessage => _handler = null; + + public Task Dispatch(CultMeshContentChunkRequestMessage request, ICultNetSchemaServerPeer peer) => + _handler!(request, peer); + } + + private sealed class ReferencePeer : ICultNetSchemaServerPeer + { + public CultMeshContentChunkResponseMessage? Sent { get; private set; } + + public void SendCultNet(TMessage message) where TMessage : ICultNetSchemaMessage => + Sent = (CultMeshContentChunkResponseMessage)(object)message; + } + + private static string ToJson(List vectors) => JsonSerializer.Serialize( + new + { + vectors = vectors.Select(v => new + { + label = v.Label, + kind = v.Kind, + messagePackBase64 = Convert.ToBase64String(v.Bytes) + }) + }, + new JsonSerializerOptions { WriteIndented = true }); + + private static List ReadVectors(string name) + { + var path = VectorPath(name); + if (!File.Exists(path)) + Assert.Inconclusive($"{path} is missing - write it first (CULTNET_WRITE_VECTORS=1)."); + using var document = JsonDocument.Parse(File.ReadAllText(path)); + return document.RootElement.GetProperty("vectors").EnumerateArray() + .Select(v => new Vector( + v.GetProperty("label").GetString()!, + v.GetProperty("kind").GetString()!, + Convert.FromBase64String(v.GetProperty("messagePackBase64").GetString()!))) + .ToList(); + } + + private static string VectorPath(string name) => Path.Combine(RepoRoot(), "contracts", "cultmesh", name); + + private static string RepoRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory."); + } +} From 11485fb4914a3e0778ffb2f44e8381e38f53812f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 00:49:58 +0200 Subject: [PATCH 02/21] Pin the content plane against the reference's bytes content-vectors.cs-written.json is written by CultMeshContentVectorTests on Linux, and includes the answers CultMeshLegacyRudpContentServer gives, so the failure spellings are the reference's. tests/content.rs judges it. The rs-written mirror follows in the next commit. Co-Authored-By: Claude Sonnet 5 --- .../cultmesh/content-vectors.cs-written.json | 64 +++ packages/cultnet-rs/tests/content.rs | 520 ++++++++++++++++++ 2 files changed, 584 insertions(+) create mode 100644 contracts/cultmesh/content-vectors.cs-written.json create mode 100644 packages/cultnet-rs/tests/content.rs diff --git a/contracts/cultmesh/content-vectors.cs-written.json b/contracts/cultmesh/content-vectors.cs-written.json new file mode 100644 index 00000000..30a139ed --- /dev/null +++ b/contracts/cultmesh/content-vectors.cs-written.json @@ -0,0 +1,64 @@ +{ + "vectors": [ + { + "label": "answer_found.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrHZlY3Rvci1mb3VuZKljaHVua0hhc2jZR1NIQTI1Njo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtledlPbWVzaDpjZG46Y2h1bms6NTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y7FleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_found.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKx2ZWN0b3ItZm91bmSlZm91bmTDqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXPNEt\u002BncGF5bG9hZMUS3/n6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5OjulZXJyb3Kg" + }, + { + "label": "answer_not_found.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrnZlY3Rvci1taXNzaW5nqWNodW5rSGFzaNlAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKlyZWNvcmRLZXmgsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "answer_not_found.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK52ZWN0b3ItbWlzc2luZ6Vmb3VuZMKpY2h1bmtIYXNo2UAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2TZGaWxlTm90Rm91bmRFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgaXMgbm90IGF2YWlsYWJsZS4=" + }, + { + "label": "answer_record_key_disagrees.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkqnZlY3Rvci1rZXmpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtlebRtZXNoOmNkbjpjaHVuazpvdGhlcrFleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_record_key_disagrees.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKp2ZWN0b3Ita2V5pWZvdW5kwqljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpc2l6ZUJ5dGVzAKdwYXlsb2FkxAClZXJyb3LZT0ludmFsaWREYXRhRXhjZXB0aW9uOiBDb250ZW50IGNodW5rIHJlY29yZCBrZXkgZGlzYWdyZWVzIHdpdGggaXRzIGNvbnRlbnQgaGFzaC4=" + }, + { + "label": "answer_size_mismatch.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkq3ZlY3Rvci1zaXplqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lyZWNvcmRLZXnZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOxZXhwZWN0ZWRTaXplQnl0ZXPNEt4=" + }, + { + "label": "answer_size_mismatch.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKt2ZWN0b3Itc2l6ZaVmb3VuZMKpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2WBJbnZhbGlkRGF0YUV4Y2VwdGlvbjogQ0ROIGFydGlmYWN0IGNodW5rIHBheWxvYWQgbWV0YWRhdGEgZG9lcyBub3QgbWF0Y2ggaXRzIG1hbmlmZXN0IHJlZmVyZW5jZS4=" + }, + { + "label": "response_hash_last_digit", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKx2ZWN0b3ItZGlnaXSlZm91bmTDqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0MKlzaXplQnl0ZXPNEt\u002BncGF5bG9hZMUS3/n6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5OjulZXJyb3Kg" + }, + { + "label": "manifest", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQgA==" + }, + { + "label": "manifest_out_of_order", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA==" + }, + { + "label": "manifest_empty_body", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NZCA" + } + ] +} diff --git a/packages/cultnet-rs/tests/content.rs b/packages/cultnet-rs/tests/content.rs new file mode 100644 index 00000000..363a600d --- /dev/null +++ b/packages/cultnet-rs/tests/content.rs @@ -0,0 +1,520 @@ +//! The content plane (`src/content.rs`, the two chunk messages in `src/contracts.rs`), pinned against +//! the C# reference. `contracts/cultmesh/content-vectors.cs-written.json` is the reference's own +//! bytes (written by `CultMeshContentVectorTests`, including the answers `CultMeshLegacyRudpContentServer` +//! gives); `content-vectors.rs-written.json` is this binary's mirror, regenerated with +//! `CULTNET_WRITE_VECTORS=1`, and judged by the C# side. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use base64::Engine; +use cultnet_rs::{ + CultMeshCdnArtifactManifest, CultNetMessage, CultNetWireContract, answer_content_chunk_request, + decode_cultnet_message_from_slice, encode_cultnet_message_for_wire, encode_cultnet_message_to_vec, + fetch_content, normalize_hash, pack_content, validate_manifest, +}; +use serde::{Deserialize, Serialize}; + +const CHUNK: usize = 262_144; +const BODY_LEN: usize = 1_315_551; +const CREATED: &str = "2026-09-22T00:00:00.0000000+00:00"; +const SCHEMA_V0: CultNetWireContract = CultNetWireContract::CultNetSchemaV0; + +fn body() -> Vec { + (0..BODY_LEN).map(|i| (i % 251) as u8).collect() +} + +fn pack(bytes: &[u8]) -> (CultMeshCdnArtifactManifest, Vec) { + pack_content( + "eureka/content-vector", + "asset", + "1", + "application/x-cultnet-vector", + CREATED, + bytes, + CHUNK, + ) + .expect("packs") +} + +fn store(chunks: &[cultnet_rs::CultMeshCdnChunk]) -> HashMap> { + chunks + .iter() + .map(|chunk| (chunk.chunk_hash.clone(), chunk.payload.clone())) + .collect() +} + +fn lookup_in<'a>(store: &'a HashMap>) -> impl Fn(&str) -> Option<&'a [u8]> { + move |hash| store.get(hash).map(Vec::as_slice) +} + +fn wire(message: &CultNetMessage) -> Vec { + encode_cultnet_message_to_vec(message, SCHEMA_V0).expect("encodes") +} + +fn unwire(bytes: &[u8]) -> Result { + decode_cultnet_message_from_slice(bytes, SCHEMA_V0) +} + +fn manifest_bytes(manifest: &CultMeshCdnArtifactManifest) -> Vec { + rmp_serde::to_vec_named(manifest).expect("encodes") +} + +fn request(id: &str, hash: &str, key: &str, size: i32) -> CultNetMessage { + CultNetMessage::ContentChunkRequest { + message_id: id.to_string(), + chunk_hash: hash.to_string(), + record_key: key.to_string(), + expected_size_bytes: size, + } +} + +fn response_of(message: &CultNetMessage) -> (&str, bool, &str, &[u8], &str) { + match message { + CultNetMessage::ContentChunkResponse { + message_id, + found, + chunk_hash, + payload, + error, + .. + } => (message_id, *found, chunk_hash, payload, error), + other => panic!("not a content chunk response: {other:?}"), + } +} + +/// A transport closure that answers from `chunks` through the wire, counting calls. +fn serving<'a>( + chunks: &'a HashMap>, + calls: &'a std::cell::Cell, +) -> impl FnMut(CultNetMessage) -> Result + 'a { + move |message| { + calls.set(calls.get() + 1); + let request = unwire(&wire(&message))?; + unwire(&wire(&answer_content_chunk_request(&request, lookup_in(chunks)))) + } +} + +fn flip_last_digit(hash: &str) -> String { + let last = if hash.ends_with('0') { '1' } else { '0' }; + format!("{}{last}", &hash[..hash.len() - 1]) +} + +// ------------------------------------------------------------------------------------------ +// Vector files +// ------------------------------------------------------------------------------------------ + +#[derive(Serialize, Deserialize)] +struct VectorFile { + vectors: Vec, +} + +#[derive(Serialize, Deserialize)] +struct VectorEntry { + label: String, + kind: String, + #[serde(rename = "messagePackBase64")] + message_pack_base64: String, +} + +struct Vector { + label: String, + kind: &'static str, + bytes: Vec, +} + +fn vector_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("contracts") + .join("cultmesh") + .join(name) +} + +fn read_vectors(name: &str) -> Vec<(String, String, Vec)> { + let path = vector_path(name); + let text = std::fs::read_to_string(&path).unwrap_or_else(|_| { + panic!("{} is missing - write it first (CULTNET_WRITE_VECTORS=1)", path.display()) + }); + let file: VectorFile = serde_json::from_str(&text).expect("vector file parses"); + assert!(!file.vectors.is_empty(), "the vector file must carry at least one vector"); + file.vectors + .into_iter() + .map(|v| { + let bytes = base64::engine::general_purpose::STANDARD + .decode(&v.message_pack_base64) + .expect("base64"); + (v.label, v.kind, bytes) + }) + .collect() +} + +/// The vectors this runtime writes: the same labelled set the C# writer produces, built from Rust +/// values and answered by Rust's `answer_content_chunk_request`. +fn local_vectors() -> Vec { + let (manifest, chunks) = pack(&body()); + let store = store(&chunks); + let last = chunks.last().expect("chunks"); + let last_size = last.payload.len() as i32; + let key = format!("mesh:cdn:chunk:{}", last.chunk_hash); + let mut vectors = Vec::new(); + let mut push_message = |label: String, kind: &'static str, message: &CultNetMessage| { + vectors.push(Vector { label, kind, bytes: wire(message) }); + }; + let answers = [ + ("answer_found", request("vector-found", &format!("SHA256:{}", last.chunk_hash), &key, last_size)), + ("answer_not_found", request("vector-missing", &"0".repeat(64), "", 100)), + ( + "answer_record_key_disagrees", + request("vector-key", &last.chunk_hash, "mesh:cdn:chunk:other", last_size), + ), + ("answer_size_mismatch", request("vector-size", &last.chunk_hash, &key, last_size - 1)), + ]; + for (name, request) in &answers { + push_message(format!("{name}.request"), "request", request); + let answer = answer_content_chunk_request(request, lookup_in(&store)); + push_message(format!("{name}.response"), "response", &answer); + } + push_message( + "response_hash_last_digit".to_string(), + "response", + &CultNetMessage::ContentChunkResponse { + message_id: "vector-digit".to_string(), + found: true, + chunk_hash: flip_last_digit(&last.chunk_hash), + size_bytes: last_size, + payload: last.payload.clone(), + error: String::new(), + }, + ); + let mut push_manifest = |label: &str, manifest: &CultMeshCdnArtifactManifest| { + vectors.push(Vector { + label: label.to_string(), + kind: "manifest", + bytes: manifest_bytes(manifest), + }); + }; + push_manifest("manifest", &manifest); + let mut shuffled = manifest.clone(); + shuffled.chunks = [3, 0, 5, 1, 4, 2].iter().map(|&i| manifest.chunks[i].clone()).collect(); + push_manifest("manifest_out_of_order", &shuffled); + push_manifest("manifest_empty_body", &pack(&[]).0); + vectors +} + +fn to_json(vectors: &[Vector]) -> String { + let file = VectorFile { + vectors: vectors + .iter() + .map(|v| VectorEntry { + label: v.label.clone(), + kind: v.kind.to_string(), + message_pack_base64: base64::engine::general_purpose::STANDARD.encode(&v.bytes), + }) + .collect(), + }; + serde_json::to_string_pretty(&file).expect("serializes") +} + +/// The committed rs-written file is what this binary encodes now. Under `CULTNET_WRITE_VECTORS=1` +/// it is rewritten instead, for the C# test `RustVectorsDecodeInTheReference` to judge. +#[test] +fn rust_written_vectors_are_current() { + let fresh = local_vectors(); + let path = vector_path("content-vectors.rs-written.json"); + if std::env::var("CULTNET_WRITE_VECTORS").as_deref() == Ok("1") { + std::fs::write(&path, to_json(&fresh)).expect("writes the vector file"); + return; + } + let committed = read_vectors("content-vectors.rs-written.json"); + assert_eq!(committed.len(), fresh.len()); + for ((label, kind, bytes), fresh) in committed.iter().zip(&fresh) { + assert_eq!((label.as_str(), kind.as_str()), (fresh.label.as_str(), fresh.kind)); + assert_eq!(bytes, &fresh.bytes, "vector '{label}' drifted from this binary's encode"); + } +} + +/// Byte identity, both ways, against the reference's own bytes: every vector the reference wrote +/// decodes here and this runtime's independently built value encodes to exactly those bytes. The +/// manifest vector also pins `pack_content` (same body and options, same manifest, byte for byte). +#[test] +fn reference_vectors_encode_byte_identically() { + let local = local_vectors(); + let reference = read_vectors("content-vectors.cs-written.json"); + assert_eq!( + reference.iter().map(|(l, _, _)| l.as_str()).collect::>(), + local.iter().map(|v| v.label.as_str()).collect::>(), + "both runtimes write the same labelled vectors" + ); + for ((label, kind, reference_bytes), mine) in reference.iter().zip(&local) { + assert_eq!(reference_bytes, &mine.bytes, "vector '{label}' is not byte identical"); + match kind.as_str() { + "request" | "response" => { + let decoded = unwire(reference_bytes).expect("the reference's bytes decode"); + assert_eq!(&wire(&decoded), reference_bytes, "vector '{label}' does not re-encode identically"); + } + "manifest" => { + let decoded: CultMeshCdnArtifactManifest = + rmp_serde::from_slice(reference_bytes).expect("the reference's manifest decodes"); + assert_eq!(&manifest_bytes(&decoded), reference_bytes, "manifest '{label}' does not re-encode identically"); + } + other => panic!("unknown vector kind {other}"), + } + } +} + +/// F1's failure mode: a chunk response carries `bin`, which the JSON-value path cannot carry. +#[test] +fn a_chunk_response_carries_bin_through_the_raw_path() { + let payload: Vec = (0..=255u8).collect(); + let message = CultNetMessage::ContentChunkResponse { + message_id: "m".to_string(), + found: true, + chunk_hash: "ab".to_string(), + size_bytes: 256, + payload: payload.clone(), + error: String::new(), + }; + let wire_value = encode_cultnet_message_for_wire(&message, SCHEMA_V0).expect("encodes"); + let entries = wire_value.as_map().expect("a map"); + let (_, value) = entries.iter().find(|(k, _)| k.as_str() == Some("payload")).expect("payload key"); + assert!(matches!(value, rmpv::Value::Binary(bytes) if *bytes == payload), "payload must be bin"); + assert_eq!(unwire(&wire(&message)).expect("decodes"), message); +} + +#[test] +fn normalize_hash_matches_the_reference() { + for (input, expected) in [ + ("SHA256:ABCdef", "abcdef"), + ("sha256:abc", "abc"), + ("sha256: ABC ", "abc"), + (" ABC ", "abc"), + (" sha256:abc", "sha256:abc"), + ("sha256:", ""), + ("plain", "plain"), + ] { + assert_eq!(normalize_hash(input).expect("normalizes"), expected, "input {input:?}"); + } + for blank in ["", " "] { + assert!(normalize_hash(blank).is_err(), "{blank:?} must be refused"); + } + // The fixture's uppercase-prefix request names the same chunk as its bare hash. + let reference = read_vectors("content-vectors.cs-written.json"); + let (_, _, bytes) = reference.iter().find(|(l, _, _)| l == "answer_found.request").expect("vector"); + let CultNetMessage::ContentChunkRequest { chunk_hash, .. } = unwire(bytes).expect("decodes") else { + panic!("not a request"); + }; + assert!(chunk_hash.starts_with("SHA256:")); + let (_, chunks) = pack(&body()); + assert_eq!(normalize_hash(&chunk_hash).expect("normalizes"), chunks.last().unwrap().chunk_hash); +} + +#[test] +fn a_manifest_out_of_offset_order_is_accepted_as_the_reference_accepts_it() { + let reference = read_vectors("content-vectors.cs-written.json"); + let (_, _, bytes) = reference.iter().find(|(l, _, _)| l == "manifest_out_of_order").expect("vector"); + let manifest: CultMeshCdnArtifactManifest = rmp_serde::from_slice(bytes).expect("decodes"); + assert_ne!(manifest.chunks[0].offset, 0, "the fixture's chunk list is shuffled"); + validate_manifest(&manifest).expect("the reference accepts it"); + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let calls = std::cell::Cell::new(0); + let fetched = fetch_content(&manifest, BODY_LEN as u64, serving(&store, &calls)).expect("fetches"); + assert_eq!(fetched, body()); + assert_eq!(calls.get(), 6); +} + +#[test] +fn a_hostile_manifest_is_refused_before_any_request() { + let (good, chunks) = pack(&body()); + let store = store(&chunks); + let mut hostile = Vec::new(); + let mut negative = good.clone(); + negative.size_bytes = -1; + hostile.push(negative); + let mut gap = good.clone(); + gap.chunks[2].offset += 1; + hostile.push(gap); + let mut overlap = good.clone(); + overlap.chunks[3].offset -= 1; + hostile.push(overlap); + let mut short = good.clone(); + short.size_bytes -= 1; + hostile.push(short); + let mut negative_chunk = good.clone(); + negative_chunk.chunks[5].size_bytes = -4831; + hostile.push(negative_chunk); + for manifest in hostile { + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&manifest, u64::MAX, serving(&store, &calls)).is_err()); + assert_eq!(calls.get(), 0, "a corrupt manifest must not make the client ask"); + } +} + +#[test] +fn a_manifest_over_the_cap_is_refused_before_any_request() { + let (manifest, chunks) = pack(&body()); + let store = store(&chunks); + let size = BODY_LEN as u64; + + let calls = std::cell::Cell::new(0); + let fetched = fetch_content(&manifest, size, serving(&store, &calls)).expect("exactly the cap is accepted"); + assert_eq!(fetched.len(), BODY_LEN); + assert_eq!(calls.get(), 6); + + // One byte over, and sizes where doubling the cap would admit the body. + for cap in [size - 1, size / 2 + 1, size / 2, 0] { + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&manifest, cap, serving(&store, &calls)).is_err(), "cap {cap} must refuse"); + assert_eq!(calls.get(), 0, "cap {cap} must refuse before any request"); + } +} + +#[test] +fn a_chunk_whose_hash_differs_in_the_last_digit_is_refused() { + let reference = read_vectors("content-vectors.cs-written.json"); + let (_, _, bytes) = reference.iter().find(|(l, _, _)| l == "response_hash_last_digit").expect("vector"); + let CultNetMessage::ContentChunkResponse { chunk_hash: claimed, payload, .. } = unwire(bytes).expect("decodes") else { + panic!("not a response"); + }; + // A one-chunk manifest for exactly that payload: its bytes are right, only the response's claim is off. + let (manifest, chunks) = pack(&payload); + let honest = chunks[0].chunk_hash.clone(); + assert_ne!(claimed, honest); + assert_eq!(claimed[..claimed.len() - 1], honest[..honest.len() - 1], "they differ in the last digit only"); + let error = fetch_content(&manifest, u64::MAX, |message| { + let CultNetMessage::ContentChunkRequest { message_id, .. } = message else { + panic!("not a request"); + }; + let CultNetMessage::ContentChunkResponse { found, size_bytes, payload, error, .. } = + unwire(bytes).expect("decodes") + else { + panic!("not a response"); + }; + Ok(CultNetMessage::ContentChunkResponse { + message_id, + found, + chunk_hash: claimed.clone(), + size_bytes, + payload, + error, + }) + }) + .expect_err("a hash off by one digit is a different hash"); + assert!(error.to_string().contains("does not match its payload"), "{error}"); +} + +#[test] +fn a_body_whose_chunks_verify_but_whole_hash_does_not_is_refused() { + let (mut manifest, chunks) = pack(&body()); + manifest.content_hash = pack(b"another body").0.content_hash; + let store = store(&chunks); + let calls = std::cell::Cell::new(0); + let error = fetch_content(&manifest, u64::MAX, serving(&store, &calls)).expect_err("whole body must verify"); + assert_eq!(calls.get(), 6, "every chunk verified; only the whole body is wrong"); + assert!(error.to_string().contains("content hash"), "{error}"); +} + +#[test] +fn a_response_for_another_message_id_is_refused() { + let (manifest, chunks) = pack(&body()); + let store = store(&chunks); + let error = fetch_content(&manifest, u64::MAX, |message| { + let mut answer = answer_content_chunk_request(&message, lookup_in(&store)); + if let CultNetMessage::ContentChunkResponse { message_id, .. } = &mut answer { + *message_id = "someone-else".to_string(); + } + Ok(answer) + }) + .expect_err("an answer to another request is not this answer"); + assert!(error.to_string().contains("someone-else"), "{error}"); +} + +#[test] +fn an_empty_body_packs_one_zero_length_chunk_and_fetches() { + let (manifest, chunks) = pack(&[]); + assert_eq!((manifest.chunks.len(), chunks.len(), manifest.size_bytes), (1, 1, 0)); + let store = store(&chunks); + let calls = std::cell::Cell::new(0); + assert_eq!(fetch_content(&manifest, 0, serving(&store, &calls)).expect("fetches"), Vec::::new()); + assert_eq!(calls.get(), 1); +} + +#[test] +fn answer_refuses_a_record_key_that_disagrees_with_its_hash() { + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let last = chunks.last().unwrap(); + let asked = std::cell::Cell::new(false); + let answer = answer_content_chunk_request( + &request("m", &last.chunk_hash, "mesh:cdn:chunk:other", last.payload.len() as i32), + |hash| { + asked.set(true); + lookup_in(&store)(hash) + }, + ); + let (id, found, _, payload, error) = response_of(&answer); + assert_eq!((id, found, payload.len()), ("m", false, 0)); + assert_eq!(error, "InvalidDataException: Content chunk record key disagrees with its content hash."); + assert!(!asked.get(), "the key check precedes the lookup, as in HandleAsync"); +} + +/// Every failure is an answer (`found: false`, empty payload, the reference's spelling) and equals, +/// byte for byte, what `CultMeshLegacyRudpContentServer` answered to the same request bytes. +#[test] +fn answer_serves_found_false_with_the_reference_error_spelling() { + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let reference = read_vectors("content-vectors.cs-written.json"); + let mut judged = 0; + for (label, _, request_bytes) in reference.iter().filter(|(l, _, _)| l.ends_with(".request")) { + let name = label.trim_end_matches(".request"); + let request = unwire(request_bytes).expect("decodes"); + let answer = answer_content_chunk_request(&request, lookup_in(&store)); + let (_, _, expected_bytes) = reference + .iter() + .find(|(l, _, _)| *l == format!("{name}.response")) + .expect("paired response"); + assert_eq!(&wire(&answer), expected_bytes, "{name}: answer differs from the reference's"); + let (_, found, _, payload, error) = response_of(&answer); + if name == "answer_found" { + assert!(found && error.is_empty() && !payload.is_empty()); + } else { + assert!(!found && payload.is_empty(), "{name}"); + assert!(error.contains("Exception: "), "{name}: {error}"); + } + judged += 1; + } + assert_eq!(judged, 4); +} + +#[test] +fn content_messages_refuse_what_the_reference_never_produces() { + let refused = |message: CultNetMessage| assert!(encode_cultnet_message_for_wire(&message, SCHEMA_V0).is_err(), "{message:?}"); + refused(request("", "abc", "", 1)); + refused(request("m", "sha256: ", "", 1)); + refused(request("m", "abc", "", -1)); + let response = |found: bool, size: i32, payload: &[u8], error: &str| CultNetMessage::ContentChunkResponse { + message_id: "m".to_string(), + found, + chunk_hash: "abc".to_string(), + size_bytes: size, + payload: payload.to_vec(), + error: error.to_string(), + }; + refused(response(true, 3, &[1, 2], "")); + refused(response(true, 2, &[1, 2], "boom")); + refused(response(false, 0, &[1], "boom")); + assert!(encode_cultnet_message_for_wire(&response(true, 2, &[1, 2], ""), SCHEMA_V0).is_ok()); + assert!(encode_cultnet_message_for_wire(&response(false, 0, &[], "boom"), SCHEMA_V0).is_ok()); +} + +#[test] +fn gamecult_networking_contract_refuses_content_messages() { + let legacy = CultNetWireContract::GameCultNetworkingV0; + let message = request("m", "abc", "", 1); + assert!(encode_cultnet_message_for_wire(&message, legacy).is_err()); + assert!(decode_cultnet_message_from_slice(&wire(&message), legacy).is_err()); +} From 8a66b1d23032564d4c660eac3a9ee0c886cf3f5d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 00:51:55 +0200 Subject: [PATCH 03/21] Commit the Rust-written content vectors and document the Rust content path content-vectors.rs-written.json is written by cultnet-rs and is identical to the reference's file. transport-planes.md and content-sessions.md name the Rust runtime's content path as the explicit compatibility path on the session the client already holds, lasting until an authenticated content connector exists behind the same fetch_content owner; runtime-parity-scope.md gets the evidence row. Co-Authored-By: Claude Sonnet 5 --- .../cultmesh/content-vectors.rs-written.json | 64 +++++++++++++++++++ docs/runtime-parity-scope.md | 10 +++ src/GameCult.Mesh/docs/content-sessions.md | 8 +++ src/GameCult.Mesh/docs/transport-planes.md | 8 +++ 4 files changed, 90 insertions(+) create mode 100644 contracts/cultmesh/content-vectors.rs-written.json diff --git a/contracts/cultmesh/content-vectors.rs-written.json b/contracts/cultmesh/content-vectors.rs-written.json new file mode 100644 index 00000000..c4b58833 --- /dev/null +++ b/contracts/cultmesh/content-vectors.rs-written.json @@ -0,0 +1,64 @@ +{ + "vectors": [ + { + "label": "answer_found.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrHZlY3Rvci1mb3VuZKljaHVua0hhc2jZR1NIQTI1Njo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtledlPbWVzaDpjZG46Y2h1bms6NTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y7FleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_found.response", + "kind": "response", + "messagePackBase64": "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" + }, + { + "label": "answer_not_found.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrnZlY3Rvci1taXNzaW5nqWNodW5rSGFzaNlAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKlyZWNvcmRLZXmgsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "answer_not_found.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK52ZWN0b3ItbWlzc2luZ6Vmb3VuZMKpY2h1bmtIYXNo2UAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2TZGaWxlTm90Rm91bmRFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgaXMgbm90IGF2YWlsYWJsZS4=" + }, + { + "label": "answer_record_key_disagrees.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkqnZlY3Rvci1rZXmpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtlebRtZXNoOmNkbjpjaHVuazpvdGhlcrFleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_record_key_disagrees.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKp2ZWN0b3Ita2V5pWZvdW5kwqljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpc2l6ZUJ5dGVzAKdwYXlsb2FkxAClZXJyb3LZT0ludmFsaWREYXRhRXhjZXB0aW9uOiBDb250ZW50IGNodW5rIHJlY29yZCBrZXkgZGlzYWdyZWVzIHdpdGggaXRzIGNvbnRlbnQgaGFzaC4=" + }, + { + "label": "answer_size_mismatch.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkq3ZlY3Rvci1zaXplqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lyZWNvcmRLZXnZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOxZXhwZWN0ZWRTaXplQnl0ZXPNEt4=" + }, + { + "label": "answer_size_mismatch.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKt2ZWN0b3Itc2l6ZaVmb3VuZMKpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2WBJbnZhbGlkRGF0YUV4Y2VwdGlvbjogQ0ROIGFydGlmYWN0IGNodW5rIHBheWxvYWQgbWV0YWRhdGEgZG9lcyBub3QgbWF0Y2ggaXRzIG1hbmlmZXN0IHJlZmVyZW5jZS4=" + }, + { + "label": "response_hash_last_digit", + "kind": "response", + "messagePackBase64": "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" + }, + { + "label": "manifest", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQgA==" + }, + { + "label": "manifest_out_of_order", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA==" + }, + { + "label": "manifest_empty_body", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NZCA" + } + ] +} diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 72768673..109a287f 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -73,6 +73,16 @@ to speak CultNet or participate in CultMesh. `tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs` carry the semantic ones. A single rows-to-bytes path driving both halves in one call remains its own cut (R-AR) and is not part of this claim. +- CultMesh content plane (`cultmesh.content_chunk_request.v1` and `.response.v1` + plus the CDN manifest, C#/Rust only): byte parity in both directions, and the + reference's answer to a chunk request, including its failure spellings. + `packages/cultnet-rs/tests/content.rs` and + `tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs` judge + `contracts/cultmesh/content-vectors.cs-written.json` and + `content-vectors.rs-written.json`. Rust carries these chunks only on the + session the client already holds (see `src/GameCult.Mesh/docs/transport-planes.md`); + it has no authenticated content connector, so it claims no TCP+TLS or QUIC + content path. ## Expansion Work Outside The Current Parity Claim diff --git a/src/GameCult.Mesh/docs/content-sessions.md b/src/GameCult.Mesh/docs/content-sessions.md index 313a21cf..673327e2 100644 --- a/src/GameCult.Mesh/docs/content-sessions.md +++ b/src/GameCult.Mesh/docs/content-sessions.md @@ -47,6 +47,14 @@ The content protocol uses one bounded response per manifest chunk. A snapshot response containing `gamecult.mesh.cdn_artifact_chunk.v1` payload records is a legacy transport path and must not be used for bulk delivery. +The Rust runtime speaks the same chunk messages through `cultnet-rs` +`fetch_content`, which verifies every chunk and then the whole body, and +`answer_content_chunk_request`, which mirrors the legacy server's validation +order and error spelling. Its transport is an explicit closure over the session +the caller already holds, carrying the chunks as ordinary schema frames until +Rust has an authenticated content connector; the chunk bytes, manifest shape +and verification do not change when that connector arrives. + The transfer owner may keep a bounded window of requests in flight per content hash (four by default, never more than 32). Candidate chunks can arrive out of order, but only the transfer owner writes them, and it flushes and checkpoints diff --git a/src/GameCult.Mesh/docs/transport-planes.md b/src/GameCult.Mesh/docs/transport-planes.md index eddb9794..ea6a6389 100644 --- a/src/GameCult.Mesh/docs/transport-planes.md +++ b/src/GameCult.Mesh/docs/transport-planes.md @@ -102,6 +102,14 @@ lower priority than TCP. Existing hot-state publishers that still package payloads as schema messages must move to the realtime connector contract; the new seam does not pretend that migration has already occurred. +The Rust runtime's content path is this compatibility path, chosen explicitly: +`cultnet-rs` sends chunk requests and reads chunk responses on the session the +client already holds, byte-identical on the wire to +`CultMeshLegacyRudpContentServer` (`fetch_content` and +`answer_content_chunk_request`). It is never an implicit default, and it lasts +until Rust has an authenticated content connector (TCP+TLS or QUIC), which will +sit behind the same `fetch_content` owner rather than beside it. + ## Security and release gate The current TCP control and content connectors are plaintext and therefore From 0e7d2204cd105ecc8dcde01cfe16d1394d49aff1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 01:32:57 +0200 Subject: [PATCH 04/21] cultnet-rs: example generating Rust-owned Idunn runtime fixtures Real enrolled identities, a validated Expected, an Activation issued by IdunnRuntimeActivationLaunch, bundle files through the CultCache store, and runtime-presence vectors signed by the Rust signers and run through authenticate/correlate before they are written. cultnet-ts tests will commit the output. Co-Authored-By: Claude Sonnet 5 --- .../examples/idunn_runtime_fixture.rs | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 packages/cultnet-rs/examples/idunn_runtime_fixture.rs diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs new file mode 100644 index 00000000..e97eb47b --- /dev/null +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -0,0 +1,276 @@ +//! Generate the Idunn runtime fixtures that cultnet-ts is tested against. +//! +//! The Rust owner builds every input: real enrolled identities, an Expected that +//! passes `validate`, an Activation issued by `IdunnRuntimeActivationLaunch`, the +//! bundle files written through `SingleFileMessagePackBackingStore`, and +//! runtime-presence vectors signed by the Rust signers. Each vector is also run +//! through `authenticate_runtime_presence_claim` and +//! `correlate_runtime_presence_claim` before it is written, so the bytes +//! committed to the TypeScript tests are ones this verifier accepted (or, for +//! the low-capacity vector, ones it correlates to the typed capacity +//! disagreement). Ed25519 is deterministic, so the TypeScript signer must +//! reproduce each vector byte for byte from the same fields. +//! +//! Linux only: the provider identity is bound to `/etc/machine-id`, and the +//! generated `machine-id` file records the value the identities were bound to. +//! +//! Usage: idunn_runtime_fixture + +use std::fs; +use std::path::Path; + +use anyhow::{Result, bail, ensure}; +use cultcache_rs::{ + CacheBackingStore, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore, +}; +use cultnet_rs::{ + GameCultProviderHealthIdentity, GameCultRuntimeCapability, + GameCultRuntimePresenceHealthPurpose, GameCultRuntimePresenceHealthRecord, + IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_RUNTIME_ACTIVATION_SCHEMA, + IdunnExpectedCapability, IdunnExpectedDependency, IdunnExpectedIncarnationRecord, + IdunnExpectedRoute, IdunnRuntimeActivationLaunch, IdunnRuntimeActivationRecord, + IdunnRuntimeActivationSigner, IdunnServiceIdentity, RuntimePresenceAuthenticationContext, + authenticate_runtime_presence_claim, correlate_runtime_presence_claim, + enroll_service_identity_at, verify_runtime_authority, +}; + +/// The TypeScript tests pin the same values; drift shows up as a byte mismatch. +const OBSERVED_AT: u64 = 1_790_000_000_000; +const ISSUED_AT: u64 = OBSERVED_AT - 5_000; +const SEQUENCE: u64 = 1; +const DETAIL: &str = "route-observation:route-challenge-1"; +const STORED_AT: &str = "2026-01-01T00:00:00.000Z"; + +struct Set { + dir: &'static str, + target: &'static str, + transport: &'static str, + stable: &'static str, + candidate: &'static str, + minimum_capacity: u32, + odin_dependency: bool, +} + +const SETS: [Set; 3] = [ + Set { + dir: "web", + target: "streampixels-web", + transport: "http", + stable: "http://127.0.0.1:8830", + candidate: "http://127.0.0.1:18830", + minimum_capacity: 1, + odin_dependency: false, + }, + Set { + dir: "service", + target: "streampixels-service", + transport: "tcp", + stable: "tcp://127.0.0.1:8831", + candidate: "tcp://127.0.0.1:18831", + minimum_capacity: 2, + odin_dependency: true, + }, + Set { + dir: "rudp-route", + target: "streampixels-rudp", + transport: "rudp", + stable: "rudp://127.0.0.1:8832", + candidate: "rudp://127.0.0.1:18832", + minimum_capacity: 1, + odin_dependency: false, + }, +]; + +fn main() -> Result<()> { + let Some(out) = std::env::args().nth(1) else { + bail!("usage: idunn_runtime_fixture "); + }; + let machine_id = fs::read_to_string("/etc/machine-id")?; + fs::create_dir_all(&out)?; + fs::write(Path::new(&out).join("machine-id"), machine_id.trim())?; + let idunn = enroll_service_identity_at::( + &Path::new(&out).join("idunn-identity.private"), + )?; + let idunn_anchor = idunn.trust_anchor()?; + fs::remove_file(Path::new(&out).join("idunn-identity.private"))?; + let _ = fs::remove_file(Path::new(&out).join("idunn-identity.private.lock")); + for set in &SETS { + build(Path::new(&out).join(set.dir).as_path(), set, &idunn, &idunn_anchor)?; + } + Ok(()) +} + +fn digest(byte: char) -> String { + format!("sha256-{}", byte.to_string().repeat(64)) +} + +fn build( + dir: &Path, + set: &Set, + idunn: &cultnet_rs::ServiceIdentitySigner, + idunn_anchor: &cultnet_rs::ServiceIdentityTrustAnchor, +) -> Result<()> { + fs::create_dir_all(dir)?; + let provider_path = dir.join("provider-identity.credential"); + let provider = enroll_service_identity_at::(&provider_path)?; + let provider_anchor = provider.trust_anchor()?; + let _ = fs::remove_file(dir.join("provider-identity.credential.lock")); + + let capability = format!("{}.api", set.target); + let capability_schema = format!("{}.api.v1", set.target); + let expected = IdunnExpectedIncarnationRecord { + schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), + target: set.target.into(), + plan_id: digest('1'), + incarnation_id: format!("{}/incarnation-1", set.target), + sealed_release_id: digest('2'), + source_repository: "github.com/GameCult/StreamPixels".into(), + source_revision: "a".repeat(40), + recipe_sha256: digest('3'), + runtime_id: format!("{}-yggdrasil", set.target), + expected_signer_identity_id: provider_anchor.identity_id.clone(), + health_contract: format!("{}.runtime-health", set.target), + artifact_sha256: digest('4'), + state_schema_generation: None, + state_contract_sha256: None, + write_lease_required: false, + route: Some(IdunnExpectedRoute { + route_id: format!("{}-route", set.target), + transport: set.transport.into(), + stable_endpoint: set.stable.into(), + candidate_endpoint: set.candidate.into(), + }), + capabilities: vec![IdunnExpectedCapability { + capability: capability.clone(), + schema: capability_schema.clone(), + compatibility: "v1".into(), + minimum_capacity: set.minimum_capacity, + }], + dependencies: if set.odin_dependency { + vec![IdunnExpectedDependency { + kind: "shared-infrastructure".into(), + capability: "odin.verse-rendezvous".into(), + schema: "odin.verse-topology.v1".into(), + compatibility: "v1".into(), + minimum_capacity: 1, + startup: "before-promotion".into(), + provider_id: Some("odin".into()), + provider_authority: Some("managed-incarnation".into()), + provider_expected_projection_sha256: Some(digest('5')), + provider_endpoint: Some("rudp://127.0.0.1:17871".into()), + }] + } else { + Vec::new() + }, + }; + expected.validate()?; + + let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest('6'), ISSUED_AT, idunn)?; + let mut credential = Vec::new(); + let activation = launch.write_credential(&mut credential)?; + ensure!(credential.len() == 32, "activation credential is a 32-byte seed"); + fs::write(dir.join("activation-key.seed"), &credential)?; + let activation_signer = IdunnRuntimeActivationSigner::from_credential_reader(&credential[..])?; + + write_record( + &dir.join("expected.cc"), + &expected.target, + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + expected.canonical_bytes()?, + )?; + write_record( + &dir.join("activation.cc"), + &expected.target, + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + activation.canonical_bytes()?, + )?; + + let authority = + verify_runtime_authority(&expected, &activation, idunn_anchor, &provider_anchor.public_key)?; + let vectors = [ + ("presence-active.bin", "active", set.minimum_capacity, None), + ("presence-warming.bin", "warming", set.minimum_capacity, None), + ( + "presence-active-below-minimum.bin", + "active", + set.minimum_capacity.saturating_sub(1), + Some("expected-capability-000-capacity"), + ), + ]; + for (name, state, capacity, disagreement) in vectors { + if capacity == 0 { + continue; + } + let mut presence = GameCultRuntimePresenceHealthRecord { + schema_version: cultnet_rs::GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + target: expected.target.clone(), + expected_projection_sha256: activation.expected_projection_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: activation.canonical_sha256()?, + state_schema_generation: None, + state_contract_sha256: None, + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: activation.runtime_instance_id.clone(), + bound_endpoint: Some(set.candidate.into()), + capabilities: vec![GameCultRuntimeCapability { + capability: capability.clone(), + schema: capability_schema.clone(), + compatibility: "v1".into(), + capacity, + }], + health_contract: expected.health_contract.clone(), + state: state.into(), + detail: DETAIL.into(), + write_lease_sha256: None, + signer_identity_id: provider_anchor.identity_id.clone(), + publisher_sequence: SEQUENCE, + observed_at_unix_millis: OBSERVED_AT, + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + activation_signer_identity_id: activation_signer.identity_id(), + activation_signature: Vec::new(), + }; + let proof = presence.canonical_proof_payload()?; + presence.signature = provider + .sign::(&proof) + .signature; + presence.activation_signature = activation_signer.sign_presence_proof(&presence)?; + let bytes = rmp_serde::to_vec(&presence)?; + + let claim = authenticate_runtime_presence_claim( + &bytes, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: OBSERVED_AT + 1_000, + maximum_age_millis: 30_000, + maximum_future_skew_millis: 0, + }, + )?; + let correlation = correlate_runtime_presence_claim(claim, &authority)?; + let codes: Vec<&str> = correlation + .disagreements() + .iter() + .map(|entry| entry.code.as_str()) + .collect(); + match disagreement { + None => ensure!(codes.is_empty(), "{name}: unexpected disagreements {codes:?}"), + Some(code) => ensure!(codes == [code], "{name}: expected only {code}, got {codes:?}"), + } + fs::write(dir.join(name), bytes)?; + } + Ok(()) +} + +fn write_record(path: &Path, key: &str, kind: &str, schema: &str, payload: Vec) -> Result<()> { + SingleFileMessagePackBackingStore::new(path).push(&CultCacheEnvelope { + key: key.into(), + r#type: kind.into(), + payload, + stored_at: STORED_AT.into(), + schema_id: Some(schema.into()), + }) +} From f5c597a81dd23a313c3d31374603dead7f6be500 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 01:40:30 +0200 Subject: [PATCH 05/21] cultnet-rs fixture example: remove the store lock files it leaves Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-rs/examples/idunn_runtime_fixture.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs index e97eb47b..185f9a4e 100644 --- a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -272,5 +272,9 @@ fn write_record(path: &Path, key: &str, kind: &str, schema: &str, payload: Vec Date: Tue, 29 Sep 2026 01:44:04 +0200 Subject: [PATCH 06/21] cultnet-rs fixture example: name an Odin port a dev machine does not run Co-Authored-By: Claude Sonnet 5 --- .../cultnet-rs/examples/idunn_runtime_fixture.rs | 2 +- .../test/fixtures/idunn-runtime/.gitattributes | 1 + .../test/fixtures/idunn-runtime/README.md | 15 +++++++++++++++ .../test/fixtures/idunn-runtime/machine-id | 1 + .../idunn-runtime/rudp-route/activation-key.seed | 2 ++ .../idunn-runtime/rudp-route/activation.cc | Bin 0 -> 800 bytes .../idunn-runtime/rudp-route/expected.cc | Bin 0 -> 1076 bytes .../idunn-runtime/rudp-route/presence-active.bin | Bin 0 -> 922 bytes .../rudp-route/presence-warming.bin | Bin 0 -> 923 bytes .../rudp-route/provider-identity.credential | Bin 0 -> 680 bytes .../idunn-runtime/service/activation-key.seed | 1 + .../fixtures/idunn-runtime/service/activation.cc | Bin 0 -> 806 bytes .../fixtures/idunn-runtime/service/expected.cc | Bin 0 -> 1308 bytes .../service/presence-active-below-minimum.bin | Bin 0 -> 940 bytes .../idunn-runtime/service/presence-active.bin | Bin 0 -> 940 bytes .../idunn-runtime/service/presence-warming.bin | Bin 0 -> 941 bytes .../service/provider-identity.credential | Bin 0 -> 685 bytes .../idunn-runtime/web/activation-key.seed | 1 + .../fixtures/idunn-runtime/web/activation.cc | Bin 0 -> 798 bytes .../test/fixtures/idunn-runtime/web/expected.cc | Bin 0 -> 1067 bytes .../idunn-runtime/web/presence-active.bin | Bin 0 -> 915 bytes .../idunn-runtime/web/presence-warming.bin | Bin 0 -> 916 bytes .../web/provider-identity.credential | Bin 0 -> 683 bytes 23 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/README.md create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs index 185f9a4e..d6b432a6 100644 --- a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -157,7 +157,7 @@ fn build( provider_id: Some("odin".into()), provider_authority: Some("managed-incarnation".into()), provider_expected_projection_sha256: Some(digest('5')), - provider_endpoint: Some("rudp://127.0.0.1:17871".into()), + provider_endpoint: Some("rudp://127.0.0.1:47871".into()), }] } else { Vec::new() diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes b/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes new file mode 100644 index 00000000..fa1385d9 --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md new file mode 100644 index 00000000..99e7b07f --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -0,0 +1,15 @@ +# Idunn runtime fixtures + +Written by the Rust owner, not by the TypeScript tests. Regenerate with +`packages/cultnet-rs/examples/idunn_runtime_fixture.rs` (CultLib `f5c597a`; it uses +cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's). +It needs Linux and a machine-id equal to the `machine-id` file here, because the +provider identity is bound to it. On Yggdrasil: + + DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out' + +then copy `/tmp/out` over this directory. Every `presence-*.bin` was accepted by +`authenticate_runtime_presence_claim` and correlated by +`correlate_runtime_presence_claim` before being written; the +`below-minimum` vector correlates to exactly `expected-capability-000-capacity`. +Ed25519 is deterministic, so the TypeScript signer must reproduce them byte for byte. diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id b/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id new file mode 100644 index 00000000..a71716fc --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id @@ -0,0 +1 @@ +c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1 \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed new file mode 100644 index 00000000..ddaffdda --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed @@ -0,0 +1,2 @@ + +²ÊVÖH¾\IJ½ôðŸíȈ{’^ª+l£Z¼}k† \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc new file mode 100644 index 0000000000000000000000000000000000000000..556e8a173ce1d8648b70b1435402d0bdd183fff8 GIT binary patch literal 800 zcmbu5J!lj`6vvZB5J9v^P_WP|#3FaTnVpZF1Iej{R+4B4Q8978_Ofz&7xv>wOtgw3 zRxSn<5wtRXfT&d<5EM>GBZ4Bhm4&D&1$%K2v5{=!@E8W(|Nno#c{g9^td(l1r)@1s zJF%^GQGRPRG+Db{i?enbMs`Z-H0;VW?6hlLys}wuZL@|mxzw`E;=+6-aR`w-AtPI< zS4yHPLw}^>HclK;x!MhOhR8P-Rxn^z z1>|ZBp#HmR0C47YfA3D2u{f4YOH5hS5QP-O0Rdz&mPAr!jj+t)RQr?*g(b&9Krt6U zvD6l*;6iJ`DPdOf)qi-abIqoSWfHdj4q^X!iU!6^3ZzZoBLon*r=mm=3D#&26VTiv z)=2HUUl0hCFAWAK=gb&i5wExR%f98OS9+aqPX@Q$-v5KP*I&Iqv=N^!&KC#nKG@p4 z__nXF|9)>|Vzy`y7AOQp3kn7?!a;fju)C?}R)CD}f$uh=J%Krs2CNbSdloqaMKGXc zYcQoq^cu$(M}|H>-h;*_4j#U@dw5`6Y+w2?`tj()s|%m5y}7C99JK~Cm20>uxXN}UE7o3VK)&J4~j^dW^18ewCR|L@37*)yZ8bM)1in% z5TBvo!jLR}U1OUU8>@%w+~>Yf#$V3heBQ*Q&;hYlFM8ox<@0fI7%a(vi)s*#Is!H_Lsd3FI^%Yxzb*7 zPEIe&=5&VT=q?u-r%@UVc$lkM;ib(k6AZDbLL~1YgwFf|2;uSj#>eN{X0vlWSk=jW ziVagmE8?|QhxTKtDp~}>E23)k0^t`fU`eEP

-1f@ufyF7D5}SZ&dNVv&@-BA#p2 ztLMF*m(e^3s~yzVyB@X>GMHsyn^>-H6NYTWbnfYd7+q*$k8u|x(=;p>Q{rLba^0pj zBal?B8a>F0SOgJQeNIE+FA5~S;=;WmKsAjh(F{7{pk XnYbK{Yg~Y)!Q@w|{605*v_AX-A<<{3 literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin new file mode 100644 index 0000000000000000000000000000000000000000..13a93f5562abac952f33314dc17202825e216e8d GIT binary patch literal 922 zcmbu7O=uHA7=}~4iO_?HUaUXGqLfW%zMYxf2?{w>L{Fg?L9AtV=G$atn}pp>G#AB_ zh#-hPi?+~$AYwuB4~!TOidY1#gjR}L{5c8=J&3wm$tB+U&Sm&!_}&-ZXEC#--CtDW zsB2-8jcVm27{{?{gfW&ij+Ib_Nhw>0n{g7M8m|T?F^sLKW~%K2aY+FemSG?aypCX8uhR%<4{o6Dk`fasFW?* zF@iO!vxuQW1=7{7Ef}~Cm0TDgL{g$C8fuK^D&F z6pJQOaS(QF#2R(`PhYvAAqq~Gq=)9H>)LQ};#QY?R#k&L|D9&*bh1u++S|Kd|6yaT z(lV`d64bHXA604zS`{zGsQ$Nt-0DP^R2X8ph!$gGoL;_SeW*B>j1JQ%;-@n~R>u54TQQY*a4k9_kk?OoT- z0|=EyFn$`05I?j}!+RH=92tJ_ ztg%VIIyU<2ZqT>0_5AXU!snl}@0Uh)&n-@BJe29(xb^As#LIW^;f$D2#D*AK63 J=>OE$`y2izGi?9> literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin new file mode 100644 index 0000000000000000000000000000000000000000..1211a6059e5c63d55e7ba915d9f89e5dd5ca2b66 GIT binary patch literal 923 zcmbu7KWG#|6vpq+fTjNm5hTHAxtE!@GqXE~;)w*Chzd%y3bQkB?-uszZo{Jk%Jt*T)HO3l78iz`#!g?v!ga@^HjH+A>&SF@z;)bavhifGTTv&#A)Dz${ zLJ>eQ#RVFP!gjcJxg$ML4){I?M-mQ7BjM7KT1$sBfy!wwqU+R5N5;M&(khNrJ*Y$$ zZ5zQl)n3F<+m$mnE;drnn@C;y%1nzYrV*`%lQ^&d9Y z8ZF~zR9p_CV$vH|8g;ZPUJc{s-wyI?6J1hah*1$OI-3-zWBUeNN>f~1kj(XL$uyNz z+h}aYxK9KajUG@TxSQg{3@{IEsibCvWnW}6*|GU!VP+f+a8BiX;cMyH_islFLG+Z| zu8$5x*MGd;vwdao*T>V-b8kMMKe@PT=Z(tM{nwVa+#0?xvNCbx=Dq%hn=-8{hc1V`M$&Xew_OY?Y87@u(qZ*Su6 zG*)))!FT+^*PU4Rrq5r)8$3VKc@B=KjiU&Q+AHQCmaHp|=cW3cwqUoBo-hl=$ zV!XsU*3PvPxhqCj;>_hXo-Na^Xftqy?I^^FD^gd?w3o-I{z=noNc7j(5%#KqrXi1r z4h`pmTdt)RwhNgWooFf~QIxPGlc@?ss4`(g!81-QQ!FDHH7X+_lusJcdSw;6qN;VC z>)MsY=A}tFD$>~GWnp#FU7qO5c0p{rpZb)0zVfI%LxU)gQ7HOCh>%i0AcuTE^8G(x xU-j@Fw@*#s8@}Kneq7A3iY1AASjHXvCC=}ZL8TVr>$llCwNwwJZ`Hz9&eW0baA8G(nbeoa;Zg`y>pkm%poM|m`c#A zdIeG5{y)-_m}U;CSnVD=LxPERYOi|w%;ioI$V}(vvn)?hCFkP_Y-FVj({^mI$g@a+ ziV_glCjhFyy9@v$uliT!ij2KO*_fifC`BkZpjCk)#wsE?53IH*t!6e1Ad(P*LZp_9 zkZB19j8R$&Akyb5vP=I$E=|_!HdR^N`1?ovXZ30ZsAY+^=*3a?ZEfS z@bJ67zW({{@lzAM8W)i!U`^&(S&fG{!ZA;$4$*%;%;-><*_ zaplHz@@Tz&zs{)Z2Z+o>Z;froxpcmW48`1;=yy_~vnH{bES!CFo_Ebva~^E;yuZ=2 zzeWEOLsQ;Nd>s=29i=H(v=(`P2PoMkkr!~ldo1+4#P>PS#7!dC3!=nNJU4+HsYL-O1Dfk>*v{@uTwHuxo{tqoN;VqaX;z(>KXx4A zhBh`IiKECPtG|t8ZS9t4V_Y_^#OG}e^QhjoXdUhsu-12R2ofJ zTxgroZK{1p7_^!j2+;aS2(yGS3j}pQg|gacWt21)w3OBdt|fBHP;uZMrJAwj<)u!s z-SfqwO;jz4+fM#A^&~abk>O#WB2QWcBOoP%EAvk~`R7$NdiK9Tz7BWm(3RynF8l+@ z-QwrIGHF!DESF5rr07oTKm7dt#>iI3lvEsJrHC#pX9ZN?fn>f#5*Wdl=G>yvR7=Wy zskvrc`ymM>q7a1G7FX5+=Mt0*EG1p5j?;CgF&;Y)&{EDJXcUf4eDMzs4UFIDUHta; zd1Ldy&XtKv&f(eF@8ZMy^`CREUp#!gcUAQ?XK!~$Hy^F{Z+@Mhe6w+OY>R?{qW6e7`WC)y)1hTT~-x z#>z>qIu<422o6@N7-M8`u!O3dlyWutI8LglMk?V1mSZQzYArOF_QWMcx#tAvxdg3} z3XD}CTvu9cQCQ8aFG(PXPZT1xuIDo?h#|%(Ej$8(yDG5FL$F1q9j$Wa!^o&=q>^wn za-eBOx2WbJVbE%Bz(?yLAJ*nM)k^5sD$ zEf_*AQ-FfH9t&Ja17sdB20mpJ1D}Iwv`9dyFQjV~bp_GNg3u_i0mpLT4quqN_^HgtSrDc;+8qW9QZ-_hdRJ=bRYFTYzo-+A-w LjrE^>r?&qF!IL?E literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin new file mode 100644 index 0000000000000000000000000000000000000000..62b7ff86cceb9219a5d8c0e059e2473baff42e0a GIT binary patch literal 941 zcmbu7J!lkR5QZf<-_L%3sm{uXjYSH@B78rycME%a=kDIc6e1#u zpiP2Zf=CDk#L}P;Fb4@~)l@DwdAlscO|F zIt`&^ Cr8(08 literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential new file mode 100644 index 0000000000000000000000000000000000000000..45db9617f2fcfb960f3afda9b8ec42fdc1a6b2d1 GIT binary patch literal 685 zcmb7?KWG#|6vjm@v=?kt5(Nv*9y7Bye|9S^1gjuoBM8gP&TMvNZ#UlUT@nHT8(TFZ zij5!y4WgaI&LBuczruD^)FNn;#$M5pc%@)?FudV?-{YGfbMtmPIiq_n9hOZn+pkB3 zb^RcBx@_{m_f=Cg3+>cc(iuyVZAmhg_(3t!jq8l)tKEC=?aa}ha+-Fj3WaqfN@W-t z&aKmi8O1`O3^yS+A|sh{PD(_dx^>cWKr}%jQ8~gU)(Gs>M z4)(Ep7GLnKJz5ska6ZY3(j~px`a`FAQ8~{cF-2vIYNnkV!}=%BU_|4;M$b4HI?zo@c@YSB^P2/W•ËW;ÆsÖSÅ2�µ4 \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc new file mode 100644 index 0000000000000000000000000000000000000000..6127df727f18aed025681ae2377af563f9d11ed6 GIT binary patch literal 798 zcmbtPKWGzS6mK^R;-cu{Acs0=65sd#zJMBVa4Qz1h*ISHckPv2TJA0}w&i)csD$?7E54-p5UxA=Ix<@fi^bxpIL8)YV3CCjH$S81Wn zt)Y1?%ouVSwJ2xI>dFoU$9k*RQE1z?^h`)#d^D-d6CjqLz zw*mmCUkt9^E)w>SXA=rDUS?7OMFJ@+z(ioe2rH^}6k5&&5rD9V85W_jm|8_b%)HDZ z8-f%X^wWRqmd}rm+f-$7{ci^U&*|4dA*2OoLQ1ND8xV%0P-$zJW>RY~Is&9MRfdGr z84OkfR}nh{S>`3r`sO);SR&SJ)p?A2z} zrENPFmb+IDBpZ8o?p~MsHm)66LETC5@XpAOk>5E8WdT3+}}Oh-j69 zwg?^~;9;B=DU#YK^qa>l-p}{`X09K2+LWAeA5bD&F`y3V-@FDQjUoiyHgE~zEQmOZ zBPN4ZggWMExiu>-x2Z@)UQC@ums+A~3E3VCL36c2I&J&g`3T}f)%KUaO0Pmf2E;fMaM+RA;#`Z5XSiUV`Kcewpkp!kSnVt zJ~PV|ovetrd>6)#mFi@k$6OIvsS!v&ts~wFjhb5Fu6kX@!EA*4vk_)n?4OtyMOVZq z-@Ja&Xo#34K{MOon2S2G9M7?;94KJR0{{Vz&8h8Km{FjZS8%l~EMeNB>X;s7)ONt~ zge~YePmba=l0gW%53DKuc^B4-m`vUW!+bJ5Zlx0Ld@0*c)5|psV%i9+LtMw9c(|Y? XQ+0pvn5ROl9X$W0RKMo7L)Lp=Z0uzi literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin new file mode 100644 index 0000000000000000000000000000000000000000..15ae2e8c9912a0fa89482131e573b2a8f3a72105 GIT binary patch literal 915 zcmbtSO-NKx7##^(7((qTf-INuX72yJZx%7TA_&^F47oqwo%ixNGrf5;Wr0xyfe<2P zEsdzGorx6`q@`esuvOGh&?a}e(M2t^8Zr#5o#)$qhwprc!}*lk-Rddpez1)s%cqS> z7WYGMErmK%Ec8a8lPtX9Z~1U%G;VTrYq$a-XzCz=`K zl@mfzi7;u?oJeIIVNO#;c>By@r%)&j`YVLDh!>kJ7UGK4X+>vowG!YB!(@xt5Mma9 z35GlakF5vJ5z^Xm&O8^yS}M5LL~zRpSIR0WfPz=XU{9H+;mpjl&6b0Ka@nQ29w!@L z#1`=+%CcIqP{2gyQG0L^D@D=Bjj7Gg^STy4{ogX*#CMvwWBMQNPhW!dn%vhmi<{8u zNvn+vf~u)Q+Wehj;&3fTHV(*G|}}7TJtl| literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin new file mode 100644 index 0000000000000000000000000000000000000000..1d106eb5ff53a16de8ad39b93facaf8125c67181 GIT binary patch literal 916 zcmbtSO-NKx7@fhS76r8kY?G1LqIl!|zxT}|XHi%SNz$ZNa(})%^J4yb^Bl`f3$xaO z1wnxn-Dd0hfKTud_Y za5;%eFjS4924xFF1<=K$kg1cIS`vdURin#LtOX-r8a=gwCR_wO)QDk;wUfwtWR)kH z8RL}`LQ;t^Y0{iXWgTHoQ$=|5%weZcC=L26gtv+3TP=1)Wvk<|PNGUVz?+82HnAzh zEC3S>c?KR^51b>Uwd0(5E{L^MaIcBrmJzO$RZ;*2uZ+Q-GEc*~x!J9jgDd&Gi*+q3 zZhjHl#N$GeRI^=On8*xj4$fkwD4LjjwDoyLSEDEYTW0F`ULB`j{KNg}OR!Os=@A{5 zqH?~`5m$y22r8xqas7Av*^P}Y=wcDdc?j@aBWICy1~cUq3vDwR#5@2vP|{NA8PXIe zvI2$NF-N2e74cM4sepMW9ZkPYrP5RLeZ|x?IKnv&=W`#D9p77L7vB%|Ex+mi{5pOA z!TvpqSKl36-uv*_)WV7Gb^rDBC6ZsA{B&)1|6q6T#i!@TU-qt~1{%-cK&HC0kwY3| zBL$aX$TSibNh`3@##wH7XbiH(BWw)QmMR98AZL)!9(fL?S%~?ZUmIvF)Y`_+9X>fa zu)fx^{-yoZk>fu*?~X04T)%OvW&HNO(XsYUTDj2kBYX2sX4iyGeLPFPojEjfs#;3CV5$`#dIw=@*?Pp45!)4##s zIqLTeOGHXdVug?bgi%II1%HjfSkV0`SbmHTaQ(m%zL*`yFNdkdA5-ant=-)(`3)Eu B6f*z- literal 0 HcmV?d00001 From 6b107c1adc2c083b922caf4139859ca4f5cacac8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:05:14 +0200 Subject: [PATCH 07/21] cultnet-ts: Rust-generated Idunn runtime fixtures Expected, Activation, identities and signed presence vectors from the cultnet-rs example; the README says how to regenerate them. Co-Authored-By: Claude Sonnet 5 --- .../test/fixtures/idunn-runtime/README.md | 2 +- .../rudp-route/activation-key.seed | 3 +-- .../idunn-runtime/rudp-route/activation.cc | Bin 800 -> 800 bytes .../idunn-runtime/rudp-route/expected.cc | Bin 1076 -> 1076 bytes .../rudp-route/presence-active.bin | Bin 922 -> 922 bytes .../rudp-route/presence-warming.bin | Bin 923 -> 923 bytes .../rudp-route/provider-identity.credential | Bin 680 -> 688 bytes .../idunn-runtime/service/activation-key.seed | 2 +- .../idunn-runtime/service/activation.cc | Bin 806 -> 806 bytes .../idunn-runtime/service/expected.cc | Bin 1308 -> 1308 bytes .../service/presence-active-below-minimum.bin | Bin 940 -> 940 bytes .../idunn-runtime/service/presence-active.bin | Bin 940 -> 940 bytes .../service/presence-warming.bin | Bin 941 -> 941 bytes .../service/provider-identity.credential | Bin 685 -> 673 bytes .../idunn-runtime/web/activation-key.seed | 3 ++- .../fixtures/idunn-runtime/web/activation.cc | Bin 798 -> 798 bytes .../fixtures/idunn-runtime/web/expected.cc | Bin 1067 -> 1067 bytes .../idunn-runtime/web/presence-active.bin | Bin 915 -> 915 bytes .../idunn-runtime/web/presence-warming.bin | Bin 916 -> 916 bytes .../web/provider-identity.credential | Bin 683 -> 676 bytes 20 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md index 99e7b07f..de1ccea4 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -1,7 +1,7 @@ # Idunn runtime fixtures Written by the Rust owner, not by the TypeScript tests. Regenerate with -`packages/cultnet-rs/examples/idunn_runtime_fixture.rs` (CultLib `f5c597a`; it uses +`packages/cultnet-rs/examples/idunn_runtime_fixture.rs` (CultLib `9ba6ac4`; it uses cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's). It needs Linux and a machine-id equal to the `machine-id` file here, because the provider identity is bound to it. On Yggdrasil: diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed index ddaffdda..1ae250bc 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed @@ -1,2 +1 @@ - -²ÊVÖH¾\IJ½ôðŸíȈ{’^ª+l£Z¼}k† \ No newline at end of file +v$ñ´ØÃ/Š ¼Èá|Æuö«g4FÜK%k±€~qû \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc index 556e8a173ce1d8648b70b1435402d0bdd183fff8..3676c985fb95c7105989d04f20473d4644699db5 100644 GIT binary patch delta 325 zcmWN}JuE{}7=Yn+(8U#t5GE6Dkeu(_^Xo4iCDMkaVQHN2+;a>>V(TU;Q$s{71W7kG zO*&AEkTwm5&I|^@Y>`+b?)DB(-fE_rsg=`N4gi)2D=9O?06K?}5+=nMjluh)jB;gi3-tsW6hsg=uX?0u&G~EdvuL zZ;P75zBzm_TIp^L%??}#h25}z`02lM`HABGb9v={YAfvR>ZDTXYIAY9^pYi!avX&= z(mUb_Qd3e8fr>b1+9Riydc}3@0Tl{x)L82x&%m*mAVYfLV#+z_X14F|ZR9FE@0kxS z&z6G8o7Laq&tShRwJLWruaEWd!q>@1qup6wc+YRR;OTaxF*mk1eYz8F*4nvamih;r C+-_k2 delta 325 zcmWO0zbnLX9LMpyWLX!Ywl(|I!V%U4==&tu`1_+vR8ViXFC7PHee(p_Db3%8fejlb>EPr6WP GrTzhj)NV%r diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc index e546f849e2b62d9874000d6a21ad4bb9c741651b..040ea0f69b70a5dcd3de806d7bc62e3d2000f294 100644 GIT binary patch delta 76 zcmV~$yAgmO3;@uA^Nw*sh>&zP4nlqlxrhaOxq|QQy6bzCD=-F6x9G6g8;Y}bnA!w^ aI8D5FR0zs(8o^u5nL~a`)De0d3JcnEixkwjA1O;K6`#6^tMPOhr$iiq5Sq(u8 zv78`^Aead^f}5CT(Mv3(K&TdhOQRrHh4gRr`xd{&$2Z+I-F0_x*Z@EjHae-j@DP}T zLhF@8LMl*1&VVS`C6banfrhAL@Dgkwr&kW0JLRRZ{q_2RdSgrd{a7su$;4o@lRhys zgs8H?kTrTHNNh7gmYB$^9DFe;>HwM9VE~=xu9P>ICs?vYmm(|SF@dCnK?xUtq|lfn zB~@q$*4dh=bs;Irc~Ycdthm8a>%Z-Zq3RR=nIBA@nOk0c)moh2d1vfRWu<1= zQ#8(KTLj5A1nsg%iv^hf{>l}>G*Pt00YMlSHTYCyjDbYTom$`I)@{%BzwN)j*xUSc zu+_3JdKXqNZ@<|yyy<84?2q>Cj~kWdpPud$zh*|iT(~zo`*QCi{5rU?dpuMc*DhV} F{s*O)pYH$w delta 442 zcmWO2O^D4=7zXfrLsL?-kd!h)CYsba?|IM1JE(sdX{>IuQJPWb<2|9NDVKCNbDNc< zp6Q?uL7Rh}&Y{lCrq1H|3fZ#l^EBX;n_(q$~~xgf1w;C`m@e zNg?Vi45Y{bq%qnRCnmtaiabRWSYN8u>NDNOcx{doXf3H*OE`ZhqMPtbahR?^^wFvGJ}N`4w&)-mp>u z7+(x27nu;b#Go;t7o4I36wxZ=&}HQH(!6;JbNz>oO`-9fB)qAmc8Hl GdjA7pE1M_) diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin index 1211a6059e5c63d55e7ba915d9f89e5dd5ca2b66..ad4c0d9f1d15e506031aba710fe9223d3f672f0a 100644 GIT binary patch delta 442 zcmWlVKSNHt8uZ|OpZ9qYF|dC_4GIpo)Zn2l zx*RTP&{PnK2*QDR22FuNQA0yRoJ2?k4gH$FpW!=v_^b@93@p^I7yuw)$w?hVfXEz` zw?Rpd9!nI&8N3(l5=w~yK?5lng9IDV>4VGnCWAC~q}{&Q?o`|F?$$_1Mh2Um42hW` zlFG)%tbJgF#5Q}#(kBcm$52ip9U(J2A3>*=vqiO-W69RLM65*c2_z;Tm2eSI3hgst zQl*Aqovk4)TwvBQCq&dDlceH(rBZad*QYDbI7wpwyR}ERf1l^x$rm!7pw9f^*$eCY z%f}-Q4V~)^|5=^cSsY&5d(ynqYOGGp-Q1WO9DV%dQ)8$3c31AI=I4iv;-F?YAQ@-0 zErVnmqjouX>q{{I{go?&X+*N+!Gkc4G=x-Uq=+KrZf$Ytet+hO&ir&w)wS`zSpT*) zzkDOV9y>Mu^~1>I#OT=7V;g&eM@y$)ZMOKq_mBO4uYPOc`K8vI?fuzbKdS!#kwBg{ delta 442 zcmWO2O^A(A90u_BjoVoIT1{yt%`B(R`Jey)eE7(#X_6YD=(0SYMln;^D9yFNW{P=B zQj^^70#g=hSgDcb)?^|JDYsBFDTO9kpd`-jSv-s1^UU?l_05hR4CuI%kdW~RV9}C{ z!9*z(c}>bVunJNlP=*m$9~l{Cyf=zaMyq-U&QDk%Ia1fV>vp4ld9K1}qXnbA77{@# zZb$}T%y4FT3_Jv#JMtv@W*lwfRH0AC4_-DmvFjH#a&PuQqxW zfV9~VQkF56a&&|aEsMbmK<1Ue7_8U%PGl)B&C_#T$q=+d(XEERoqGJ_<6v{^rLjGq zmfz3s+pzOT8QXu~EFT*@dhgNGf#&*t`?kM#<@3seMft3=K7Mu^j4Z9bqRS@+zU=bF Ef9koK*Z=?k diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential index be91ad0caedb4a48db32cb012ca9745c8ec3a654..8e1849bed2cbbf500423c8ae70cbb54bb1d8aff1 100644 GIT binary patch delta 323 zcmZ{cyGz4R7)P~IbaZsA#NyDTe!00#bGs-w_`0Sh>b7%PFY(@?)aV%f|Ktu_qivbE{x5OU>z;Kg!2@o@Qp8Q zV{IBA7wJF@-v4pVGpoVZzQ483dTutIZ!v*SS51~D`yja2+?(vDnEbefN z5I?vaMm11-n`MzF$`Q|H%(P+Z7H7Iisji~#bPHE#pn_@=2Y5x47{CdRig@YRJ?!J! lK?@uB&Ew6}W)w8>g!f2c#7vEZ#{R!YYQZi_H>ku${{WO5kc|KU delta 315 zcmZ{e%`1a(0LN{$93KbHWO2aO@9lY>-?JKpl=~)4(epNWI}F=lV!2QnVM#bTcs;l& zIk>oe@AGJ(9c1SzqRjjOZlBBN)8{I18rY8ceYKqtnxi^TI!sck+EOw@NQ)c7rUtdS z>u@2|B+-~-yB4FGrb>&M!ePR4xnUZ%AfnXJ1`pR!(bxxX8c#UE5ni#1+ZAjiP-$=Jq;k28O2qAwHmkJ)RHWbGP-ms6anIy`%m)JrP>-g4C!vTsd kDLlthgYzgyy&xk@3lSs4{x5qyljfXjr)L+#g-w6(7j8(1CIA2c diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed index 06b4b38e..96376850 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed @@ -1 +1 @@ -ÿWýck1òT ÷²¹ú[Á^ka©$}R%.\Ú–—¨k2 \ No newline at end of file +>�Õ8ÑÇ£rÆyÜà�&—ž@‚â± ç�ÜÌqM}‰ \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc index 9ab8bf43efb594ecaf705082f7c8077721c1a83c..b633e64c21e43688fba5f0946933b20b0da03d1c 100644 GIT binary patch delta 325 zcmWlTze|Ea9EWug9HeloDZ#Pi?%w;}dj|~?($Ex$R8!oK?+p$SM&J_B;A%rO1`VPa z4$%}SEg8~KER5V#VQ84AKEqCBDmrKM+<;mn{ zgwkPD?1^EzDrrDv0Fm4qV3zsF>O3{l2#QQwi-G49xg&-f)(K^fFd)*OUfe_xT3+ch%iPpWz+{Pi0dW@lxnFs8p{y>O&C!Ot?e%ZtL^RmeWY<-3{w$>D|jvDNQN_96X~$>$#viDb1oH(z{?c@!)G zbXowCs4JNw<3PbM!2~q~6$2Fnbxczk83b>2_rnv$z2y|hkw+>7(am^yt24XtKHjc= z{ox7xzPZE2`t(aWGuVOl@oO$LZgz4WHw1TN5+G@q-RwAFy~cy$I5oW^Z@?+RCV#&%s$pl0Pw06ZF* pOX5X$j>u%Dnjq~|n2RHnB8slAWHq8}l;xYxp_|^Ly_=py1%4MO7fApB delta 89 zcmV~$!3{uA3;@swE^Z_yut58(zjd|AuU29nS2KvjG`8{H)m_~))N2%+Q7L=CvI&tf odlXBiiLx3SvE?0hjSvCwNbZizibWSs@J(mmjPFt3Rg0p6KV}*jmH+?% diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin index ed6f48b9bb48dc5dadc90d1d4454676ca49d764b..441e0bf465768df545ba951e9fe70b01a27e10e3 100644 GIT binary patch delta 444 zcmWlUJ808U6o(U9=pa4@2OoQvBHY)x_nbu|Qmu4Qs%wS&I2T{IC^k!pLs0~wMzJ^u zx`>L!K?zRLf&mRg+6o0D5)4@CP!ybe7XSX%?*|{>{LuW+^@E3Ri88Zt7DxfB? zEYVsY*+f>-11ph2445<8g0O+Cm_b2+?xFK1y#l+ZUf)}PTRp%+r-WKL3C=_ifjsz# z=mmKVkdlXFNfizkIAswM7eD36qq9org@qUO^la$?Cj}s>RhhZ^^Hpc9apuUzt-nv! zx4f^8O+0;heBjZ?xz96S+p9O$?k|tL+j)6Y?@yz)GQ8vU!{)Q;^~S}!dH$TAuCmoU;_!t%m_tBR?+xS;P8Jk7!VqqMh1wPQf^gfwAY`V8-KpBbg8{G zS)01@ZG5n|^>P1N|4sGw%J&OXAG(vxQ@c9N(zcP)i~D9j4eUNQoKJKczb1}###Y;d F{{X|HrojLJ delta 444 zcmWO2KSv#I_U8pWp@0>baiq#+-eZjU?mRJP|NqDHSQ3xw>HO2)9nM9Gt~ zKAEIUvL%#W#5n-&A(_z_kI@GvsH_!5<#}uTa`94_jKR>VAD{SX4<8&Fe^gug{rPQc zYhdT<#I53Jqw&Xm-FUb?|LNVUH+$A%f4A{$uJ6+0*BgUdzwS@2Z(bYQb>-sVi5*3! zo`G0J6i3>sQhO>zSyT!GDUvc6%)JC9QE*;!B<&zYMwi4%?@X)yv^}|Zb7pn={Os_z z?t}Mt#}@se_R!hCUlwO;_+sSW#~bHvc7GfgJ=5m?eSPysuyf(|eqUXlrrk^5Yr|8; Ef6}*|IRF3v diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin index f9bb5d1887b9ab4524b31b33838ce55994ac48f3..1f13b519925596e9b77619edbbd991d2321f5b30 100644 GIT binary patch delta 444 zcmWlUIcSto6h>#TNa2bM;?@MkM%1_6`|dLsCIliP5v@cE!&~kn30g>+6d5Lk*hTUy zVqppqi3q90CLlTk!=T{c0wO^REyczns4#K){o3=v;j9d-3@nbFvVzbUlQLx%3REEN z09g{Ek8C0<>4B9D`ghWxNHFxF$#^w zIU&1zRf;5{B&$_6%QRB zslM2BvD@C*xZXe5|NCkD#_~jMaOS~<^`CD(ylie6i6`do_WEC!?$7O7*qZKqJ~mld ztCw9YoM6Xb97+`=U~s}^W`Z&!t7v>Eari$O37z$ zjObE9q2Q9mx`pMNX?ZNioy)$Ps8wE_k1xyssD&;5= zTZ)prgKWV=U>2l|m(fCkltQ!y&}tPjt&Pr11)z zb(A`q4B0VT3qXA&5o+x)I8Oq~tEE!;Nq6je=^2yI8hy96bsP7OA8D?vcV1rmI~x}4 zRIk^o37J$9m3KYsPo(EZl9Q|D6t)+I(eu=2P=tY-kEpi9TZsztPX*YP*DW)=ba)X3K24b1?#4e zA0CymMW`Sz9rTn4t#lBn2&p9s=@0~UD5R)E^!2+wKX`Z+I~O~r2Txi-Mr)liWfp)C zNI5ht$rvA5M+WJUL8K4^-x?aJJmdt3(zWV4E-ZCC-v4N0;r@?_ z=U3j&f4dDsxm$YtMLih}@A1XH0PA01$Yz2TAh**K7 zfG%>%Ddy-itK>afWs(mxLSSksuuPyOtiK>JbQI)=F{$<=LY?wp?UJ!g}ciW8}$sx z6qPvCmWuXRgsdV`0STo9qw*+0K@84oj;I}^$jT*gGCI?%Pj?&3dxuvJ9oT2=YYg5r2``QP2SkCL Apa1{> diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential index 45db9617f2fcfb960f3afda9b8ec42fdc1a6b2d1..0b9ae91fe0544f8f1b0d3f6074e252b4e2d62efb 100644 GIT binary patch delta 316 zcmZ{c&nts*0EfNPike)w%wc}C)4Xrr_t*Ew)@n&sk{WNxL3!V8uhc{ua?mSonl;Tq zlgvd$C?c*Y~UL2jY+{n@P2RJ@YPtQfL7OYt9((iHA3n-qS(sZU~ZBBDcRb3(s z;WCqIJWq*E4Iwx;RWolmlsc}iIu7Hy%Uy1WEYVrcu-SWCe<*=ks$+fQo8|uBd#aEZ*^`Sh1 zhJ*@E@l;i$S5H`k#EB6%siK)oBf7~%tfffn`GWcu_R?t?2PokOnA(UYr(JxGVn35E q-~rco42N+P!d=E6(uhW7B4q9Vt3SM8FXgjqg`%>uy1ZU32O@udkbXM= delta 317 zcmZ{eze@s90LRM`Y7bf}j6nLK^6ov~-MiZwf+mTOAZWSsJ(EBx;V2N4me$N0YKsOb z3ERT9q{XQ3(0UftAho!(7j^vs@twYW`F!}Eh8y8ZIusf_T+zzP){>fFx>Ur>7>qfb zOJzIM5tQk|;SS@Do+VkKlwlAe6_rwIoD0nsrljP#FM`&vhWa)-ljv?P;{{I%+@p>z z+Bkzp0MxKIzEDJOD~D!Kn*lWzo5wz;?^biJxr-}_R~#(i5tsNLL3?Nb=Oy$aqXkqK z@rDm?roU4bY04}nEN&!pLkK}d(wHN}B7Zkh_`(B@@q?<1#sqHhiR%qq;JpxNZ*YRz lPBd66LHyZ#3P)>b`BRioJx/W•ËW;ÆsÖSÅ2�µ4 \ No newline at end of file +c$…Jp[vcϽ|½Ô@ ž×í†`JÛûd +Í_z3Í3¢ \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc index 6127df727f18aed025681ae2377af563f9d11ed6..00c3c07fe4835b043b3eb018a11aa82dc52faabf 100644 GIT binary patch delta 325 zcmWO0zbnLX9LMoH>c}Mw5*EX4bba2RU*FG1UA`29>Cz>|aG#%7%vWa;16?VDn=i$s z7_eE@cc#>lB0m@93>Jf~eE)#gZ$Y1WGI0MXPn^30)P#)#atR;loiH12@(^~ z&IpW|p;T$Cz)SDF`&I2Mjlb3A zRx%@7mkYPwg~liucwMixm+#vjx%X%%`#jB#8kwG<=3-Ykx%P7SG?ncb9$f9a-uo^0 F{sSiua76$B delta 325 zcmWO0JtzcF902gU<#Htpu^0>(xblDB?+v)J%Pq1P-v50RW${rLMIsi9E~CqI1}UA+ zLS2`Ol#!T$8yi`&CB-s^+O_&JgQI4ulJ9c;?nfoWcnZHT5~4= diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc index 0b02a2e82b37bd2acf684a71abdc3196199b0264..748cc83dc11de7479ac6e80fcb4d8d49f79eb59a 100644 GIT binary patch delta 76 zcmWN{u@S%^2mrv#**Fdg3UtXRfJ~GzY%*7-NWT7EcYTg>+GrvkX`KZPAf}Gw6i;G; Z=wT*EBbu^n$Wol}Pc)w1(fPj0`vEWg6-odA delta 76 zcmV~$!4ZHk2n4`!@<}-uP{>b9N#JP6GV-x5t8jPKRo|^#P9Ad?OOx(kkR*VBsk5fg YU@-Yf{RoMs(XAy9R)b_{@N<{%2U>L&bN~PV diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin index 15ae2e8c9912a0fa89482131e573b2a8f3a72105..cefcf1d0f05aedf619ed6d6bb5b67de23d286e83 100644 GIT binary patch delta 440 zcmWNNO^D4=7{>oMG8UtdWU!)HSZL09&wD;z)ZAvHnUqec_|BkO+O@Y@^RsfV+V)Rl?hFL$2| z&rfbNzXlh&{rhvfc8{&Tf4ust@u4}`99bKwTttEv9Wf4@Y62LO|-D zP#s%JrMhX+$w4%M2uhvA76%u**`*nDNUd4F-}beJTfg%u%LAO>LbBu=Hp3w%Fh8F0Re|4aMH62V+mi zPj574N7j4qCJ$YCG4bul)>7;HMq_!dHvjs^$ceMN&5N7;FQ4Y`e)w3~u0aqYT7%Ym z9l#J+GQx$J2qXF!fg*#5Q3p~^XOfU2n83i8<6ujt2Hob{pR?}H^X2xv_H`Kjeen2F xufHh%J?qx1Lx)e^+Uy-Y_G`5MXkzDOdv3w(y*X1F+o?|PzrWf)UTMCn{0GnGpGW`z diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin index 1d106eb5ff53a16de8ad39b93facaf8125c67181..375131d00f282c71e7b67679f28c03c0bc167f71 100644 GIT binary patch delta 440 zcmWNNO-Phc7=^z<@FUt7h)_@x{K?<{S5xn=k_hK1_3H^W}5kw-z zd@x4PLP2CA+&Bv*h=iDM5mC5vR}?`D!A0%6`@=csc{*jMY&WkuE^MS|H4B{cV0-}W z$PxxiDLU>za39G6D3TIAAWtr(`FzEG^(M0Q`G?Ebz zq7_mRcgE+t!3oWBQlQZugg3RXMNwPpPuwoLF@RFs^&8vwMu%@L|N6eV^RczR^x<`V zcI^7G=THnhYZgntM_#>rxAJ1|esB2kPJ8j(=@ajF&m3K9ws*Vq_QB8D-uRD!g9fP} zECdxPyONs4q>OPwYRR*pY%R;umI2Fzg6Y7SWQZzyff+x_eq*ET*3Yum`N^@Tbmv<& zKC}O3`N{OZ`QvnQZezQ*l^)K_9gdv3_UX~hM(0oa%TQTgaW}%=)@JK(H8nI?{s()C Bm6iYi delta 440 zcmWO2J!q3r6oBFHqt-Ym=pd+r6`>A_<=mfhe>#{TO6^cAHWnT4J@+0(6o2YegbX#h z`au)~g+M_U2WNE=q7)qz9AZ#9NwqE_NO04v@3Xy~TBmmJ_$>MWT38X11!5q_GUyoD zC1b3zAmoTqT1QFh6qJ)F(Ik-^cpD)OW|l67Q8`*w(^YqC_5Mbka@Ixz>&${UBLakw z5H+KbM9LrsX^^x;=g3);Rc05XV5M34)mvrRBA^t7cI>pLk%Q)(bIQ(=vaBJftbh!7 zOgc%MypybgvMy^*HmUMMQIy@@{N>_lP7+c0UgPu4Eq}&e^gk`mufJRP{P3D&SyuuYbWYM{xy4DUii`ZcDuEGVX;1U>E*fh>$#1hIe3-#PF<@F8W4nt)}ZxX z2QUPdjBp_)!iYXbpvWL%)Pa=KnIxnLCNMDOINH){@Md$cwzQ{x_Tb6o=5TXt_;cdT zp=19hAFq7fxO4ZxSo`6w<&}wjlj`dEnZMKbA5CqmsQ7Y5{yu%+`Kc@Y!{h$}_;i># diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential index baff802e3960ec5acb009a91fd33d54d7a4b471f..52b1b8e809059949d9b7af89dab19973f0b1e529 100644 GIT binary patch delta 316 zcmZ3@x`cH?2s7jFS(9s-^y+UqB$^spSR^HZ^Gn1X!t@IZJBwDEucV-)#R4Q6nh?puUDJ%p* zNuw6p2@wRr@&+snJ`k}IMG|b%sD*29cZ+imr)rm@G*cb)Nz4tG*QAi z8cVSv&J>z94yVx=#tq82o$88iDO@LW>4>^Q8}Haa4ZCP1dgmiW6{q-IMQw66HiXT- zZoJOnE>juld|Xs{#(l2*tQ>bqamfT@V?;jDU&=G?Q+frhiM42!@Dn%jDD5RGI>rI& eJ Date: Tue, 29 Sep 2026 02:05:14 +0200 Subject: [PATCH 08/21] cultnet-ts: split Idunn authority from the Odin publisher; answer route challenges locally idunn-runtime-authority.ts now loads and signs only: no Odin endpoint, no Odin dependency check, no RUDP. createIdunnRuntimeSigner signs presence and answers Idunn's route challenge with answerRouteObservation(request, state), a synchronous local signature in the state the app reports. The RUDP publisher moves to idunn-odin-presence-publisher.ts for services that declare odin.verse-rendezvous, and keeps the Expected-endpoint check. Deleted: the TS capability-minimum refusal (the Rust correlation records the typed disagreement), latestPresenceDocument, the forced "active", and the publisher's redundant health-contract and bind checks. Route validation now accepts http, tcp and rudp as Rust does; the machine-id falls back to /var/lib/dbus/machine-id. Tests run against the Rust-written fixtures. Co-Authored-By: Claude Sonnet 5 --- .../src/idunn-odin-presence-publisher.ts | 240 +++++++++++ .../cultnet-ts/src/idunn-runtime-authority.ts | 344 ++++----------- packages/cultnet-ts/src/index.ts | 10 +- .../test/idunn-runtime-authority.test.ts | 398 +++++++++--------- 4 files changed, 538 insertions(+), 454 deletions(-) create mode 100644 packages/cultnet-ts/src/idunn-odin-presence-publisher.ts diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts new file mode 100644 index 00000000..8e13acb4 --- /dev/null +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -0,0 +1,240 @@ +import dgram from "node:dgram"; +import { decode, encode } from "@msgpack/msgpack"; + +import { encodeCultNetMessageForWire, parseCultNetMessage } from "./contracts"; +import type { IdunnRuntimeSigner } from "./idunn-runtime-authority"; +import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; +import type { CultNetRudpPacket } from "./rudp"; +import type { RuntimePresenceHealth } from "./runtime-presence-health"; + +const ODIN_CAPABILITY = "odin.verse-rendezvous"; +const MAX_LEASE_WAIT_ROUNDS = 720; + +export type IdunnRuntimePresencePublisher = { + readonly runtimeId: string; + readonly runtimeInstanceId: string; + readonly requiresWriteLease: boolean; + publish(state: RuntimePresenceHealth["state"], detail: string): Promise; + waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; + assertWriteLease(): Promise; +}; + +export type IdunnRuntimePresencePublisherOptions = { + signer: IdunnRuntimeSigner; + endpoint: string; +}; + +/** + * Publishes the signer's presence to Odin over RUDP, for services that are + * CultMesh-aware and declare `odin.verse-rendezvous` in Expected. The route + * proof never passes through here: `signer.answerRouteObservation` answers + * Idunn's challenge locally and needs no Odin. Capability shortfalls are not + * judged here either; the correlation owner records a typed disagreement. + */ +export function createIdunnRuntimePresencePublisher( + options: IdunnRuntimePresencePublisherOptions, +): IdunnRuntimePresencePublisher { + const { signer } = options; + const endpoint = parseRudpEndpoint(options.endpoint); + const odinDependency = signer.authority.expected.dependencies.find((dependency) => dependency.kind === "shared-infrastructure" + && dependency.capability === ODIN_CAPABILITY); + if (!odinDependency || normalizeRudpEndpoint(odinDependency.providerEndpoint ?? "") !== normalizeRudpEndpoint(options.endpoint)) { + throw new Error("Configured Odin endpoint does not match Idunn Expected dependency authority."); + } + const connectionId = 0x0d1d0002; + let messageSequence = 0; + + const publish = async (state: RuntimePresenceHealth["state"], detail: string) => { + const signed = signer.sign(state, detail); + messageSequence += 1; + await publishDocument(endpoint, connectionId, { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, + document: signed.document, + }); + if (state === "warming") signer.recordPublishedWarming(signed.canonicalSha256); + return signed.canonicalSha256; + }; + + return { + runtimeId: signer.runtimeId, + runtimeInstanceId: signer.runtimeInstanceId, + requiresWriteLease: signer.requiresWriteLease, + publish, + async waitForWriteLease({ pollIntervalMs = 5000, signal } = {}) { + if (!signer.requiresWriteLease) return ""; + let warmingSequence = 0; + while (!signal?.aborted) { + await publish("warming", "waiting-for-process-write-lease"); + warmingSequence += 1; + try { + return signer.assertWriteLease(); + } catch (error) { + if (warmingSequence > MAX_LEASE_WAIT_ROUNDS) throw new Error("Timed out waiting for Idunn process write lease.", { cause: error }); + } + await abortableDelay(pollIntervalMs, signal); + } + throw signal?.reason ?? new Error("Aborted while waiting for Idunn process write lease."); + }, + async assertWriteLease() { + return signer.assertWriteLease(); + }, + }; +} + +async function publishDocument( + endpoint: { host: string; port: number }, + connectionId: number, + message: Record, +): Promise { + const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { + socket.off("error", reject); + resolve(); + }); + }); + const receiver = receivePackets(socket); + const session = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 100 }); + try { + await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array())); + await receiveUntil(receiver, session, endpoint, (packet) => packet.packetType === "accept", 5000); + const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); + const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); + for (const packet of packets) await sendPacket(socket, endpoint, packet); + await receiveUntil( + receiver, + session, + endpoint, + () => session.outstandingReliablePacketCount === 0, + 2000, + (frame) => { + if (frame.channelId !== "schema") return; + const response = parseCultNetMessage(decode(frame.payload)); + if (response.schemaVersion === "cultnet.error.v0") { + throw new Error(`Odin rejected runtime presence: ${response.error}`); + } + }, + ); + } finally { + receiver.close(); + socket.close(); + } +} + +type PacketReceiver = { + socket: dgram.Socket; + next(timeoutMs: number): Promise; + close(): void; +}; + +function receivePackets(socket: dgram.Socket): PacketReceiver { + const packets: CultNetRudpPacket[] = []; + const waiters: Array<{ resolve: (packet: CultNetRudpPacket) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }> = []; + const errors: Error[] = []; + const drain = () => { + while (waiters.length && (packets.length || errors.length)) { + const waiter = waiters.shift()!; + clearTimeout(waiter.timer); + if (errors.length) waiter.reject(errors.shift()!); + else waiter.resolve(packets.shift()!); + } + }; + const onMessage = (wire: Buffer) => { + try { packets.push(decodeRudpPacket(wire)); } catch (error) { errors.push(error as Error); } + drain(); + }; + const onError = (error: Error) => { errors.push(error); drain(); }; + socket.on("message", onMessage); + socket.on("error", onError); + return { + socket, + next(timeoutMs) { + if (packets.length) return Promise.resolve(packets.shift()!); + if (errors.length) return Promise.reject(errors.shift()!); + return new Promise((resolve, reject) => { + const waiter = { + resolve, + reject, + timer: setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) waiters.splice(index, 1); + const error = new Error("Timed out waiting for CultNet RUDP packet.") as NodeJS.ErrnoException; + error.code = "ETIMEDOUT"; + reject(error); + }, Math.max(1, timeoutMs)), + }; + waiters.push(waiter); + }); + }, + close() { + socket.off("message", onMessage); + socket.off("error", onError); + for (const waiter of waiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(new Error("CultNet RUDP publisher closed.")); + } + }, + }; +} + +async function receiveUntil( + receiver: PacketReceiver, + session: CultNetRudpSession, + endpoint: { host: string; port: number }, + predicate: (packet: CultNetRudpPacket) => boolean, + timeoutMs: number, + onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const packet = await receiver.next(Math.min(100, deadline - Date.now())); + const received = session.receive(packet, Date.now()); + if (received.reply) throw new Error("Runtime presence received an unexpected reply-required packet."); + for (const frame of received.delivered) onDelivered?.(frame); + if (predicate(packet)) return; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; + } + for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); + } + throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); +} + +async function sendPacket(socket: dgram.Socket, endpoint: { host: string; port: number }, packet: CultNetRudpPacket): Promise { + const wire = encodeRudpPacket(packet); + await new Promise((resolve, reject) => { + socket.send(wire, endpoint.port, endpoint.host, (error) => error ? reject(error) : resolve()); + }); +} + +function parseRudpEndpoint(value: string): { host: string; port: number } { + const text = value.replace(/^rudp:\/\//i, ""); + const match = text.match(/^\[([^\]]+)\]:(\d+)$/) ?? text.match(/^([^:]+):(\d+)$/); + if (!match) throw new Error(`Invalid Odin RUDP endpoint: ${value}`); + const port = Number(match[2]); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid Odin RUDP port: ${match[2]}`); + return { host: match[1]!, port }; +} + +function normalizeRudpEndpoint(value: string): string { + const normalized = value.trim().toLowerCase().replace(/^rudp:\/\//, ""); + const parsed = parseRudpEndpoint(normalized); + return `${parsed.host.toLowerCase()}:${parsed.port}`; +} + +function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("Aborted.")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index 5ea30fff..47e132b3 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -15,10 +15,7 @@ import { type RuntimePresenceHealth, } from "./runtime-presence-health"; import { truncateUtf8Bytes } from "./utf8-bound"; -import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetRawDocumentRecord, type CultNetSnapshotRequestMessage } from "./contracts"; -import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; -import type { CultNetRudpPacket } from "./rudp"; -import dgram from "node:dgram"; +import type { CultNetRawDocumentRecord, CultNetSnapshotRequestMessage } from "./contracts"; export const IDUNN_EXPECTED_INCARNATION_SCHEMA = "idunn.expected_incarnation.v2"; export const IDUNN_RUNTIME_ACTIVATION_SCHEMA = "idunn.runtime_activation.v2"; @@ -77,33 +74,50 @@ export type IdunnRuntimeAuthority = { processWriteLeasePath?: string; }; -export type IdunnRuntimePresencePublisher = { +export type SignedRuntimePresence = { + document: CultNetRawDocumentRecord; + canonicalSha256: string; +}; + +/** + * Signs this launch's runtime presence and answers Idunn's route challenge. It + * owns no network: Odin publication, if a service wants it, is a separate + * consumer of `sign`. + */ +export type IdunnRuntimeSigner = { + readonly authority: IdunnRuntimeAuthority; readonly runtimeId: string; readonly runtimeInstanceId: string; readonly requiresWriteLease: boolean; - latestPresenceDocument(): CultNetRawDocumentRecord | null; - publishRouteObservation(request: CultNetSnapshotRequestMessage): Promise; - publish(state: RuntimePresenceHealth["state"], detail: string): Promise; - waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; - assertWriteLease(): Promise; + /** Signs one presence document. Every call takes the next publisher sequence. */ + sign(state: RuntimePresenceHealth["state"], detail: string): SignedRuntimePresence; + /** + * Answers an Idunn route challenge from local authority alone: signs a fresh + * presence in the given state, with no network. The caller supplies the + * app's current health (`warming` until first healthy). + */ + answerRouteObservation(request: CultNetSnapshotRequestMessage, state: RuntimePresenceHealth["state"]): CultNetRawDocumentRecord; + /** Reads Idunn's write lease and checks it names this incarnation and a warming proof this process published. */ + assertWriteLease(): string; + /** Marks a warming presence as accepted by its consumer, making it a valid lease target. */ + recordPublishedWarming(canonicalSha256: string): void; }; -export type IdunnRuntimePresencePublisherOptions = { +export type IdunnRuntimeSignerOptions = { authority: IdunnRuntimeAuthority; - endpoint: string; - healthContract: string; capabilities: RuntimePresenceHealth["capabilities"]; }; /** * Loads the root-provisioned Idunn bundle and parent-only signing descriptors. * The expected record and activation witness are accepted only when their - * exact positional contracts and mutual incarnation binding agree. + * exact positional contracts and mutual incarnation binding agree. Nothing + * here reads Odin: whether a target talks to Odin is decided by the + * publisher a service chooses to create. */ export function loadIdunnRuntimeAuthorityFromEnvironment( target: string, healthContract: string, - odinEndpoint: string, ): IdunnRuntimeAuthority { const bundle = requiredEnvironment(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT); const expected = readAuthorityRecord(path.join(bundle, "expected.cc"), "idunn.expected_incarnation", IDUNN_EXPECTED_INCARNATION_SCHEMA); @@ -113,11 +127,6 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( if (expectedValue.target !== target || expectedValue.healthContract !== healthContract) { throw new Error("Idunn Expected target or health contract does not match this runtime."); } - const odinDependency = expectedValue.dependencies.find((dependency) => dependency.kind === "shared-infrastructure" - && dependency.capability === "odin.verse-rendezvous"); - if (!odinDependency || normalizeRudpEndpoint(odinDependency.providerEndpoint ?? "") !== normalizeRudpEndpoint(odinEndpoint)) { - throw new Error("Configured Odin endpoint does not match Idunn Expected dependency authority."); - } if (activationValue.expectedProjectionSha256 !== expectedValue.canonicalSha256 || activationValue.runtimeId !== expectedValue.runtimeId) { throw new Error("Idunn Activation does not bind this exact Expected incarnation."); @@ -167,6 +176,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( return authority; } + /** * Signs the fixed Idunn v2 positional runtime-presence contract with the * provider identity and this launch's Idunn-issued activation key. @@ -205,42 +215,26 @@ export function signIdunnRuntimePresence( }; } -export function createIdunnRuntimePresencePublisher( - options: IdunnRuntimePresencePublisherOptions, -): IdunnRuntimePresencePublisher { - const endpoint = parseRudpEndpoint(options.endpoint); - const expected = options.authority.expected; - if (options.healthContract !== expected.healthContract) throw new Error("Runtime presence contract differs from Expected."); - if (options.authority.boundEndpoint !== expected.route?.candidateEndpoint) throw new Error("Runtime presence bind differs from Expected."); - for (const required of expected.capabilities) { - if (!options.capabilities.some((actual) => actual.capability === required.capability - && actual.schema === required.schema - && actual.compatibility === required.compatibility - && actual.capacity >= required.minimumCapacity)) { - throw new Error(`Runtime does not provide Expected capability ${required.capability}/${required.schema}.`); - } - } - const connectionId = 0x0d1d0002; +export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): IdunnRuntimeSigner { + const { authority } = options; + const expected = authority.expected; let sequence = 0; - let latestLeaseSha256: string | null = null; - let latestPresenceDocument: CultNetRawDocumentRecord | null = null; const recentWarmingProofs: string[] = []; - const assertWriteLease = async () => { + const assertWriteLease = () => { if (!expected.writeLeaseRequired) return ""; - const leasePath = options.authority.processWriteLeasePath; + const leasePath = authority.processWriteLeasePath; if (!leasePath) throw new Error("Idunn process write lease is required but no lease path is configured."); const record = readAuthorityRecord(leasePath, "idunn.process_write_lease", IDUNN_PROCESS_WRITE_LEASE_SCHEMA); const lease = decodeArray(record.payload, 14, "Idunn process write lease"); - assertLeaseMatches(lease, options.authority, recentWarmingProofs); - latestLeaseSha256 = prefixedSha256(Buffer.from(encode(lease))); - return latestLeaseSha256; + assertLeaseMatches(lease, authority, recentWarmingProofs); + return prefixedSha256(Buffer.from(encode(lease))); }; - const publish = async (state: RuntimePresenceHealth["state"], detail: string) => { - const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : await assertWriteLease(); + const sign = (state: RuntimePresenceHealth["state"], detail: string): SignedRuntimePresence => { + const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : assertWriteLease(); sequence += 1; - const signed = signIdunnRuntimePresence(options.authority, { + const signed = signIdunnRuntimePresence(authority, { capabilities: options.capabilities, state, detail: truncateUtf8Bytes(detail, RUNTIME_PRESENCE_DETAIL_MAX_BYTES), @@ -248,44 +242,28 @@ export function createIdunnRuntimePresencePublisher( publisherSequence: sequence, observedAtUnixMillis: Date.now(), }); - const document: CultNetRawDocumentRecord = { - schemaId: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, - recordKey: expected.target, - storedAt: new Date().toISOString(), - payloadEncoding: "messagepack", - payload: new Uint8Array(signed.payload), - sourceRuntimeId: expected.runtimeId, - sourceRole: "runtime-presence-health-publisher", - tags: ["cultnet.transport.rudp.v0", "runtime-presence"], - }; - await publishDocument(endpoint, connectionId, { - schemaVersion: "cultnet.document_put_raw.v0", - messageId: `runtime-presence:${expected.target}:${options.authority.activation.runtimeInstanceId}:${sequence}`, - document, - }); - latestPresenceDocument = document; - if (state === "warming") { - recentWarmingProofs.push(signed.canonicalSha256); - if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); - } - return signed.canonicalSha256; - }; - - const copyLatestPresenceDocument = (): CultNetRawDocumentRecord | null => { - if (!latestPresenceDocument) return null; return { - ...latestPresenceDocument, - payload: new Uint8Array(latestPresenceDocument.payload), - ...(latestPresenceDocument.tags ? { tags: [...latestPresenceDocument.tags] } : {}), + canonicalSha256: signed.canonicalSha256, + document: { + schemaId: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, + recordKey: expected.target, + storedAt: new Date().toISOString(), + payloadEncoding: "messagepack", + payload: new Uint8Array(signed.payload), + sourceRuntimeId: expected.runtimeId, + sourceRole: "runtime-presence-health-publisher", + tags: ["cultnet.transport.rudp.v0", "runtime-presence"], + }, }; }; return { + authority, runtimeId: expected.runtimeId, - runtimeInstanceId: options.authority.activation.runtimeInstanceId, + runtimeInstanceId: authority.activation.runtimeInstanceId, requiresWriteLease: expected.writeLeaseRequired, - latestPresenceDocument: copyLatestPresenceDocument, - async publishRouteObservation(request) { + sign, + answerRouteObservation(request, state) { if (!request.messageId || request.messageId.trim() !== request.messageId) throw new Error("Idunn route observation requires a message id."); if ((request.schemaIds && !request.schemaIds.includes(GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA)) || (request.recordKeys && !request.recordKeys.includes(expected.target)) @@ -293,159 +271,16 @@ export function createIdunnRuntimePresencePublisher( || request.shardEpoch !== undefined) { throw new Error("Idunn route observation does not request this runtime presence document."); } - await publish("active", `route-observation:${request.messageId}`); - const document = copyLatestPresenceDocument(); - if (!document) throw new Error("Idunn route observation was published without a presence document."); - return document; - }, - publish, - async waitForWriteLease({ pollIntervalMs = 5000, signal } = {}) { - if (!expected.writeLeaseRequired) return ""; - let warmingSequence = 0; - while (!signal?.aborted) { - await publish("warming", "waiting-for-process-write-lease"); - warmingSequence += 1; - try { - return await assertWriteLease(); - } catch (error) { - if (warmingSequence > 720) throw new Error("Timed out waiting for Idunn process write lease.", { cause: error }); - } - await abortableDelay(pollIntervalMs, signal); - } - throw signal?.reason ?? new Error("Aborted while waiting for Idunn process write lease."); + return sign(state, `route-observation:${request.messageId}`).document; }, assertWriteLease, - }; -} - -async function publishDocument( - endpoint: { host: string; port: number }, - connectionId: number, - message: Record, -): Promise { - const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); - await new Promise((resolve, reject) => { - socket.once("error", reject); - socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { - socket.off("error", reject); - resolve(); - }); - }); - const receiver = receivePackets(socket); - const session = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 100 }); - try { - await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array())); - await receiveUntil(receiver, session, endpoint, (packet) => packet.packetType === "accept", 5000); - const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); - const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); - for (const packet of packets) await sendPacket(socket, endpoint, packet); - await receiveUntil( - receiver, - session, - endpoint, - () => session.outstandingReliablePacketCount === 0, - 2000, - (frame) => { - if (frame.channelId !== "schema") return; - const response = parseCultNetMessage(decode(frame.payload)); - if (response.schemaVersion === "cultnet.error.v0") { - throw new Error(`Odin rejected runtime presence: ${response.error}`); - } - }, - ); - } finally { - receiver.close(); - socket.close(); - } -} - -type PacketReceiver = { - socket: dgram.Socket; - next(timeoutMs: number): Promise; - close(): void; -}; - -function receivePackets(socket: dgram.Socket): PacketReceiver { - const packets: CultNetRudpPacket[] = []; - const waiters: Array<{ resolve: (packet: CultNetRudpPacket) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }> = []; - const errors: Error[] = []; - const drain = () => { - while (waiters.length && (packets.length || errors.length)) { - const waiter = waiters.shift()!; - clearTimeout(waiter.timer); - if (errors.length) waiter.reject(errors.shift()!); - else waiter.resolve(packets.shift()!); - } - }; - const onMessage = (wire: Buffer) => { - try { packets.push(decodeRudpPacket(wire)); } catch (error) { errors.push(error as Error); } - drain(); - }; - const onError = (error: Error) => { errors.push(error); drain(); }; - socket.on("message", onMessage); - socket.on("error", onError); - return { - socket, - next(timeoutMs) { - if (packets.length) return Promise.resolve(packets.shift()!); - if (errors.length) return Promise.reject(errors.shift()!); - return new Promise((resolve, reject) => { - const waiter = { - resolve, - reject, - timer: setTimeout(() => { - const index = waiters.indexOf(waiter); - if (index >= 0) waiters.splice(index, 1); - const error = new Error("Timed out waiting for CultNet RUDP packet.") as NodeJS.ErrnoException; - error.code = "ETIMEDOUT"; - reject(error); - }, Math.max(1, timeoutMs)), - }; - waiters.push(waiter); - }); - }, - close() { - socket.off("message", onMessage); - socket.off("error", onError); - for (const waiter of waiters.splice(0)) { - clearTimeout(waiter.timer); - waiter.reject(new Error("CultNet RUDP publisher closed.")); - } + recordPublishedWarming(canonicalSha256) { + recentWarmingProofs.push(canonicalSha256); + if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); }, }; } -async function receiveUntil( - receiver: PacketReceiver, - session: CultNetRudpSession, - endpoint: { host: string; port: number }, - predicate: (packet: CultNetRudpPacket) => boolean, - timeoutMs: number, - onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const packet = await receiver.next(Math.min(100, deadline - Date.now())); - const received = session.receive(packet, Date.now()); - if (received.reply) throw new Error("Runtime presence received an unexpected reply-required packet."); - for (const frame of received.delivered) onDelivered?.(frame); - if (predicate(packet)) return; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; - } - for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); - } - throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); -} - -async function sendPacket(socket: dgram.Socket, endpoint: { host: string; port: number }, packet: CultNetRudpPacket): Promise { - const wire = encodeRudpPacket(packet); - await new Promise((resolve, reject) => { - socket.send(wire, endpoint.port, endpoint.host, (error) => error ? reject(error) : resolve()); - }); -} - function readAuthorityRecord(filePath: string, type: string, schemaId: string): { payload: Uint8Array } { const decoded = decode(fs.readFileSync(filePath)); if (!Array.isArray(decoded) || decoded[0] !== CULTCACHE_STORE_FORMAT || !Array.isArray(decoded[1]) || !Array.isArray(decoded[2])) { @@ -495,9 +330,7 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation { if (!/^sha256-[0-9a-f]{64}$/u.test(hash)) throw new Error(`Expected ${name} hash is malformed.`); } if (!/^[0-9a-f]{40}$/u.test(sourceRevision)) throw new Error("Expected source revision is malformed."); - if (!route || route.transport !== "http" || !route.candidateEndpoint.startsWith("http://")) { - throw new Error("Idunn Expected must include an HTTP candidate route."); - } + if (route) validateRoute(route); return { schemaVersion: string(values[0], "Expected schema"), target, @@ -575,6 +408,24 @@ function decodeExpectedDependencies(value: unknown): ExpectedIncarnation["depend }); } +// The Rust owner (IdunnExpectedRoute::validate) accepts http, tcp and rudp +// routes; a runtime is not entitled to a narrower admission than Idunn's. +const ROUTE_SCHEMES: Record = { + http: { stable: ["http://", "https://"], candidate: ["http://"] }, + tcp: { stable: ["tcp://"], candidate: ["tcp://"] }, + rudp: { stable: ["rudp://"], candidate: ["rudp://"] }, +}; + +function validateRoute(route: NonNullable): void { + const schemes = ROUTE_SCHEMES[route.transport]; + if (!schemes) throw new Error("Idunn Expected route transport is unsupported."); + if (!schemes.stable.some((scheme) => route.stableEndpoint.startsWith(scheme)) + || !schemes.candidate.some((scheme) => route.candidateEndpoint.startsWith(scheme))) { + throw new Error(`Idunn Expected ${route.transport} route endpoints use an unsupported scheme.`); + } + if (route.stableEndpoint === route.candidateEndpoint) throw new Error("Idunn Expected stable and candidate route endpoints are identical."); +} + function decodeRoute(value: unknown): ExpectedIncarnation["route"] { if (value === null) return null; const route = array(value, 4, "Expected route"); @@ -595,7 +446,7 @@ function openProviderHealthIdentity(credentialBytes: Buffer): crypto.KeyObject { if (enrollmentNonce.length !== 32) throw new Error("Provider-health enrollment nonce has an invalid length."); const publicKey = Buffer.from(providerIdentityByteVector(publicKeyValue, "Provider-health public key")); const protectedSeed = Buffer.from(providerIdentityByteVector(protectedSeedValue, "Provider-health protected seed")); - const machineId = fs.readFileSync("/etc/machine-id", "utf8").trim(); + const machineId = readMachineId(); if (!machineId) throw new Error("Linux machine-id is unavailable for provider-health identity."); const expectedBinding = `${PROVIDER_PROTECTOR_CONTEXT}:machine-id-sha256:${sha256Hex(Buffer.from(machineId))}`; if (protectorKind !== "linux_file_mode_machine_id_binding" || binding !== expectedBinding || assurance !== "os_installation_file_bound_cloneable_baseline") { @@ -616,6 +467,15 @@ function openProviderHealthIdentity(credentialBytes: Buffer): crypto.KeyObject { return key; } +// Same sources, same order as the Rust identity protector. +function readMachineId(): string { + try { + return fs.readFileSync("/etc/machine-id", "utf8").trim(); + } catch { + return fs.readFileSync("/var/lib/dbus/machine-id", "utf8").trim(); + } +} + function readProviderHealthIdentityCredential(bytes: Buffer): Uint8Array { const decoded = decode(bytes); if (!Array.isArray(decoded) || decoded.length !== 1) { @@ -693,40 +553,12 @@ function assertLeaseMatches(values: unknown[], authority: IdunnRuntimeAuthority, if (!matches) throw new Error("Idunn process write lease does not match the current Expected incarnation."); } -function parseRudpEndpoint(value: string): { host: string; port: number } { - const text = value.replace(/^rudp:\/\//i, ""); - const match = text.match(/^\[([^\]]+)\]:(\d+)$/) ?? text.match(/^([^:]+):(\d+)$/); - if (!match) throw new Error(`Invalid Odin RUDP endpoint: ${value}`); - const port = Number(match[2]); - if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid Odin RUDP port: ${match[2]}`); - return { host: match[1]!, port }; -} - -function normalizeRudpEndpoint(value: string): string { - const normalized = value.trim().toLowerCase().replace(/^rudp:\/\//, ""); - const parsed = parseRudpEndpoint(normalized); - return `${parsed.host.toLowerCase()}:${parsed.port}`; -} - function requiredEnvironment(name: string): string { const value = process.env[name]?.trim(); if (!value) throw new Error(`${name} is required for Idunn runtime presence.`); return value; } -function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, milliseconds); - const onAbort = () => { - clearTimeout(timer); - reject(signal?.reason ?? new Error("Aborted.")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} function array(value: unknown, length: number, label: string): unknown[] { if (!Array.isArray(value) || value.length !== length) throw new Error(`${label} is not a ${length}-field positional contract.`); diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 3db890ef..8950f638 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -205,9 +205,15 @@ export { IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, IDUNN_RUNTIME_ACTIVATION_SCHEMA, - createIdunnRuntimePresencePublisher, + createIdunnRuntimeSigner, loadIdunnRuntimeAuthorityFromEnvironment, type IdunnRuntimeAuthority, + type IdunnRuntimeSigner, + type IdunnRuntimeSignerOptions, + type SignedRuntimePresence, +} from "./idunn-runtime-authority"; +export { + createIdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisherOptions, -} from "./idunn-runtime-authority"; +} from "./idunn-odin-presence-publisher"; diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 7969fde2..1639b9aa 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -1,30 +1,92 @@ -import { mock, test } from "node:test"; +import { test, type TestContext } from "node:test"; import assert from "node:assert/strict"; -import crypto from "node:crypto"; import dgram from "node:dgram"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { decode, encode } from "@msgpack/msgpack"; import { createIdunnRuntimePresencePublisher, + createIdunnRuntimeSigner, loadIdunnRuntimeAuthorityFromEnvironment, - signIdunnRuntimePresence, - systemdListenPidMatches, -} from "../src/idunn-runtime-authority"; + type IdunnRuntimeAuthority, +} from "../src"; +import { systemdListenPidMatches } from "../src/idunn-runtime-authority"; +import type { CultNetSnapshotRequestMessage } from "../src/contracts"; import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp"; -import { - runtimePresenceActivationSigningMessage, - runtimePresenceProofPayload, - runtimePresenceProviderSigningMessage, - RUNTIME_PRESENCE_SLOT, -} from "../src/runtime-presence-health"; +import { RUNTIME_PRESENCE_SLOT } from "../src/runtime-presence-health"; -const providerIdentityContext = "gamecult-provider-health-identity-v1"; -const providerIdDomain = Buffer.from("gamecult.provider-health.identity.v1\0", "utf8"); -const activationIdDomain = Buffer.from("idunn.runtime-activation.id.v1\0", "utf8"); +// Every input below was written by the Rust owner; see fixtures/idunn-runtime/README.md. +// The only values this file pins are the ones the generator also pins. +const FIXTURES = path.join(__dirname, "..", "..", "test", "fixtures", "idunn-runtime"); +const OBSERVED_AT = 1_790_000_000_000; +const CHALLENGE: CultNetSnapshotRequestMessage = { + schemaVersion: "cultnet.snapshot_request.v0", + messageId: "route-challenge-1", +}; + +type FixtureSet = "web" | "service" | "rudp-route"; + +function fixtureFile(set: FixtureSet, name: string): Buffer { + return fs.readFileSync(path.join(FIXTURES, set, name)); +} + +/** The Expected fields the tests need, read from the Rust-written file rather than re-spelled. */ +function expectedFacts(set: FixtureSet) { + const store = decode(fixtureFile(set, "expected.cc")) as unknown[][][]; + const values = decode(store[2]![0]![3] as Uint8Array) as unknown[]; + const route = values[15] as string[]; + const capabilities = (values[16] as Array<[string, string, string, number]>).map(([capability, schema, compatibility, minimum]) => ({ + capability, schema, compatibility, capacity: minimum, + })); + const dependencies = values[17] as unknown[][]; + return { + target: values[1] as string, + healthContract: values[10] as string, + candidate: route[3]!, + capabilities, + odinEndpoint: dependencies.map((dependency) => dependency[9] as string)[0], + }; +} + +/** Points the process at one Rust-written bundle and its inherited descriptors, as Idunn's systemd unit would. */ +function openAuthority(context: TestContext, set: FixtureSet, options: { machineId?: "etc" | "dbus" } = {}): IdunnRuntimeAuthority { + const facts = expectedFacts(set); + const originalEnvironment = { ...process.env }; + context.after(() => { + for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key]; + Object.assign(process.env, originalEnvironment); + }); + process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = path.join(FIXTURES, set); + process.env.GAMECULT_IDUNN_CANDIDATE_BIND = facts.candidate; + delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; + process.env.LISTEN_PID = String(process.pid); + process.env.LISTEN_FDS = "2"; + process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; + const machineId = fs.readFileSync(path.join(FIXTURES, "machine-id"), "utf8"); + const realReadFileSync = fs.readFileSync.bind(fs); + context.mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (filePath === 3) return fixtureFile(set, "activation-key.seed"); + if (filePath === 4) return fixtureFile(set, "provider-identity.credential"); + if (filePath === "/etc/machine-id") { + if (options.machineId === "dbus") throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + return machineId; + } + if (filePath === "/var/lib/dbus/machine-id") return machineId; + return realReadFileSync(filePath as never, ...args as never[]); + }); + return loadIdunnRuntimeAuthorityFromEnvironment(facts.target, facts.healthContract); +} + +function signerFor(context: TestContext, set: FixtureSet, capacityDelta = 0) { + const authority = openAuthority(context, set); + const capabilities = expectedFacts(set).capabilities.map((capability) => ({ ...capability, capacity: capability.capacity + capacityDelta })); + return createIdunnRuntimeSigner({ authority, capabilities }); +} + +function freezeClock(context: TestContext): void { + context.mock.method(Date, "now", () => OBSERVED_AT); +} test("matches systemd's host PID for the private PID namespace init only", () => { assert.equal(systemdListenPidMatches(String(process.pid), process.pid), true); @@ -34,216 +96,160 @@ test("matches systemd's host PID for the private PID namespace init only", () => assert.equal(systemdListenPidMatches(undefined, 1), false); }); -test("opens Idunn identity and publishes valid CultNet data while surfacing piggybacked application errors", async (context) => { - const directory = mkdtempSync(path.join(os.tmpdir(), "cultnet-runtime-authority-")); - context.after(() => rmSync(directory, { recursive: true, force: true })); +for (const set of ["web", "service", "rudp-route"] as const) { + test(`opens the Rust-written ${set} bundle with no Odin endpoint, whatever route transport Rust admits`, (context) => { + const authority = openAuthority(context, set); + assert.equal(authority.expected.target, expectedFacts(set).target); + assert.equal(authority.boundEndpoint, expectedFacts(set).candidate); + }); +} - const machineId = "1234567890abcdef1234567890abcdef"; - const providerSeed = Buffer.alloc(32, 0x31); - const activationSeed = Buffer.alloc(32, 0x72); - const providerKey = privateKeyFromSeed(providerSeed); - const activationKey = privateKeyFromSeed(activationSeed); - const providerPublic = rawPublic(crypto.createPublicKey(providerKey)); - const activationPublic = rawPublic(crypto.createPublicKey(activationKey)); - const providerIdentity = sha256Hex(Buffer.concat([providerIdDomain, providerPublic])); - const activationIdentity = sha256Hex(Buffer.concat([activationIdDomain, activationPublic])); - const target = "streampixels-service"; - const contract = "streampixels-service.cultnet-rudp-service-health"; - const candidate = "http://127.0.0.1:18831"; - const expectedValues = [ - "idunn.expected_incarnation.v2", target, `sha256-${"1".repeat(64)}`, "incarnation-1", - `sha256-${"2".repeat(64)}`, "https://github.com/GameCult/StreamPixels.git", "a".repeat(40), - `sha256-${"3".repeat(64)}`, "streampixels-service-yggdrasil", providerIdentity, contract, - `sha256-${"4".repeat(64)}`, "service-boundary-v1", `sha256-${"5".repeat(64)}`, false, - ["streampixels-service-http", "http", "http://127.0.0.1:8831", candidate], - [["streampixels.service.api", "streampixels.api.v1", "v1", 1]], - [["shared-infrastructure", "odin.verse-rendezvous", "odin.verse-topology.v1", "v1", 1, "before-promotion", null, null, null, "rudp://127.0.0.1:17871"]], +test("a target that declares no Odin dependency opens and answers Idunn's challenge without one", (context) => { + assert.equal(expectedFacts("web").odinEndpoint, undefined); + const signer = signerFor(context, "web"); + assert.ok(signer.answerRouteObservation(CHALLENGE, "warming").payload.byteLength > 0); +}); + +test("finds the machine-id at the dbus path when /etc/machine-id is absent, as the Rust protector does", (context) => { + assert.ok(openAuthority(context, "web", { machineId: "dbus" })); +}); + +test("rejects a credential file holding more than one envelope", (context) => { + openAuthority(context, "web"); + const credential = decode(fixtureFile("web", "provider-identity.credential")) as unknown[][]; + const doubled = Buffer.from(encode([...credential, ...credential])); + const realReadFileSync = fs.readFileSync; + context.mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => + filePath === 4 ? doubled : (realReadFileSync as (...all: unknown[]) => unknown)(filePath, ...args)); + assert.throws( + () => loadIdunnRuntimeAuthorityFromEnvironment(expectedFacts("web").target, expectedFacts("web").healthContract), + /exactly one envelope/, + ); +}); + +test("answers a route challenge byte for byte as the Rust-verified vectors, in the state the app reports", (context) => { + freezeClock(context); + const signer = signerFor(context, "web"); + const warming = signer.answerRouteObservation(CHALLENGE, "warming"); + assert.deepEqual(Buffer.from(warming.payload), fixtureFile("web", "presence-warming.bin")); + assert.equal((decode(warming.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.state], "warming"); + const second = signer.answerRouteObservation(CHALLENGE, "active"); + assert.equal((decode(second.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.publisherSequence], 2); + // Sequence 2 cannot match a vector written at sequence 1; sign the same fields from a fresh signer. + const fresh = signerFor(context, "web"); + assert.deepEqual(Buffer.from(fresh.answerRouteObservation(CHALLENGE, "active").payload), fixtureFile("web", "presence-active.bin")); +}); + +test("a service with an Odin dependency signs the Rust-verified vectors, including a shortfall Rust correlates to a capacity disagreement", (context) => { + freezeClock(context); + assert.deepEqual( + Buffer.from(signerFor(context, "service").answerRouteObservation(CHALLENGE, "active").payload), + fixtureFile("service", "presence-active.bin"), + ); + assert.deepEqual( + Buffer.from(signerFor(context, "service", -1).answerRouteObservation(CHALLENGE, "active").payload), + fixtureFile("service", "presence-active-below-minimum.bin"), + ); +}); + +test("answers locally: synchronously, with no socket opened", (context) => { + const sockets = context.mock.method(dgram, "createSocket"); + const signer = signerFor(context, "web"); + const answer = signer.answerRouteObservation(CHALLENGE, "warming"); + assert.equal(typeof (answer as unknown as { then?: unknown }).then, "undefined"); + assert.equal(sockets.mock.callCount(), 0); +}); + +test("refuses a challenge that does not ask for this runtime's presence", (context) => { + const signer = signerFor(context, "web"); + const cases: CultNetSnapshotRequestMessage[] = [ + { ...CHALLENGE, messageId: "" }, + { ...CHALLENGE, messageId: " padded" }, + { ...CHALLENGE, recordKeys: ["another-target"] }, + { ...CHALLENGE, schemaIds: ["another.schema"] }, + { ...CHALLENGE, shardId: "shard-1" }, ]; - const expectedBytes = Buffer.from(encode(expectedValues)); - const expectedSha = `sha256-${sha256Hex(expectedBytes)}`; - const activationValues = [ - "idunn.runtime_activation.v2", expectedSha, "streampixels-service-yggdrasil", - `sha256-${"6".repeat(64)}`, activationIdentity, activationPublic, Date.now(), - "7".repeat(64), "ed25519", new Uint8Array(64).fill(0x17), - ]; - const bundle = path.join(directory, "bundle"); - fs.mkdirSync(bundle); - writeFileSync(path.join(bundle, "expected.cc"), cultCacheStore( - "idunn.expected_incarnation", "idunn.expected_incarnation.v2", "expected", expectedBytes, - )); - writeFileSync(path.join(bundle, "activation.cc"), cultCacheStore( - "idunn.runtime_activation", "idunn.runtime_activation.v2", "activation", Buffer.from(encode(activationValues)), - )); - - const binding = `${providerIdentityContext}:machine-id-sha256:${sha256Hex(Buffer.from(machineId))}`; - const mask = crypto.createHash("sha256").update(Buffer.concat([ - Buffer.from("gamecult-linux-service-seed-v1\0", "utf8"), - Buffer.from(providerIdentityContext, "utf8"), - Buffer.from(binding, "utf8"), - ])).digest(); - const protectedSeed = Buffer.from(providerSeed.map((value, index) => value ^ mask[index]!)); - const providerCredential = Buffer.from(encode([[ - "gamecult-provider-health-identity", - "gamecult.provider_health_identity.private.v1", - Buffer.from(encode([ - "gamecult.provider_health_identity.private.v1", providerIdentity, Array.from(providerPublic), Array.from(protectedSeed), - "linux_file_mode_machine_id_binding", binding, "v1", "os_installation_file_bound_cloneable_baseline", - "2026-01-01T00:00:00Z", Array(32).fill(0x2a), - ])), - "2026-01-01T00:00:00Z", - "gamecult.provider_health_identity.private.v1", - ]])); - let descriptorProviderCredential = providerCredential; - - const originalEnvironment = { ...process.env }; - context.after(() => { - for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key]; - Object.assign(process.env, originalEnvironment); - }); - process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = bundle; - process.env.GAMECULT_IDUNN_CANDIDATE_BIND = candidate; - delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; - process.env.LISTEN_PID = String(process.pid); - process.env.LISTEN_FDS = "2"; - process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; - const realReadFileSync = fs.readFileSync.bind(fs); - mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => { - if (filePath === 3) return activationSeed; - if (filePath === 4) return descriptorProviderCredential; - if (filePath === "/etc/machine-id") return machineId; - return realReadFileSync(filePath as never, ...args as never[]); - }); - - const authority = loadIdunnRuntimeAuthorityFromEnvironment(target, contract, "127.0.0.1:17871"); - const signed = signIdunnRuntimePresence(authority, { - capabilities: [{ capability: "streampixels.service.api", schema: "streampixels.api.v1", compatibility: "v1", capacity: 1 }], - state: "warming", - detail: "starting", - writeLeaseSha256: null, - publisherSequence: 1, - observedAtUnixMillis: Date.now(), - }); - const proof = runtimePresenceProofPayload(signed.presence); - assert.equal((decode(expectedBytes) as unknown[]).length, 18); - assert.equal(signed.presence.expectedProjectionSha256, expectedSha); - const signedRecord = decode(signed.payload) as unknown[]; - assert.equal(crypto.verify(null, runtimePresenceProviderSigningMessage(proof), crypto.createPublicKey(providerKey), Buffer.from(signedRecord[21] as Uint8Array)), true); - assert.equal(crypto.verify(null, runtimePresenceActivationSigningMessage(proof), crypto.createPublicKey(activationKey), Buffer.from(signedRecord[23] as Uint8Array)), true); + for (const request of cases) assert.throws(() => signer.answerRouteObservation(request, "active"), /route observation/); +}); +async function startOdinPeer(context: TestContext, endpoint: string) { const peer = dgram.createSocket("udp4"); + const port = Number(endpoint.slice(endpoint.lastIndexOf(":") + 1)); await new Promise((resolve, reject) => { peer.once("error", reject); - peer.bind(0, "127.0.0.1", resolve); + peer.bind(port, "127.0.0.1", resolve); }); context.after(() => peer.close()); - const address = peer.address(); - assert.equal(typeof address, "object"); - const peerPort = (address as { port: number }).port; + const state = { + reject: false, + received: [] as Array<{ schemaVersion?: string; document: { payload: Uint8Array } }>, + responseAck: undefined as number | undefined, + failure: undefined as Error | undefined, + }; let serverSession: CultNetRudpSession | undefined; - let receivedMessage: unknown; - let responseAck: number | undefined; - let peerFailure: Error | undefined; - let rejectOdinAdmission = true; peer.on("message", (wire, remote) => { try { const packet = decodeRudpPacket(wire); if (packet.packetType === "connect") { serverSession = new CultNetRudpSession({ connectionId: packet.connectionId, initialSequence: 100 }); - const accept = serverSession.acceptConnect(packet, Date.now()); - peer.send(encodeRudpPacket(accept), remote.port, remote.address); + peer.send(encodeRudpPacket(serverSession.acceptConnect(packet, Date.now())), remote.port, remote.address); return; } if (!serverSession) throw new Error("Publisher sent data before establishing RUDP."); - const result = serverSession.receive(packet, Date.now()); - const frame = result.delivered.find((candidate) => candidate.channelId === "schema"); + const frame = serverSession.receive(packet, Date.now()).delivered.find((candidate) => candidate.channelId === "schema"); if (!frame) return; - receivedMessage = decode(frame.payload); - const responsePayload = encode(rejectOdinAdmission ? { - schemaVersion: "cultnet.error.v0", - error: "test admission denied", - routingHint: null, - code: null, - details: null, - } : { - schemaVersion: "cultnet.snapshot_response.v0", - messageId: "ack", - documents: [], - }); - const [response] = serverSession.sendMany("schema", responsePayload, { + state.received.push(decode(frame.payload) as never); + const [response] = serverSession.sendMany("schema", encode(state.reject + ? { schemaVersion: "cultnet.error.v0", error: "test admission denied", routingHint: null, code: null, details: null } + : { schemaVersion: "cultnet.snapshot_response.v0", messageId: "ack", documents: [] }), { reliable: true, ordered: true, nowMs: Date.now(), }); assert.ok(response); - responseAck = response.ack; + state.responseAck = response.ack; peer.send(encodeRudpPacket(response), remote.port, remote.address); } catch (error) { - peerFailure = error as Error; + state.failure = error as Error; } }); - const publisher = createIdunnRuntimePresencePublisher({ - authority, - endpoint: `rudp://127.0.0.1:${peerPort}`, - healthContract: contract, - capabilities: [{ capability: "streampixels.service.api", schema: "streampixels.api.v1", compatibility: "v1", capacity: 1 }], - }); - await assert.rejects(publisher.publish("warming", "local RUDP integration"), /Odin rejected runtime presence: test admission denied/); - assert.deepEqual(receivedMessage && (receivedMessage as { schemaVersion?: string }).schemaVersion, "cultnet.document_put_raw.v0"); - assert.ok(Buffer.from(encode(receivedMessage)).byteLength > 0); - assert.equal(responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); - assert.equal(peerFailure, undefined, peerFailure?.message); - assert.equal(publisher.latestPresenceDocument(), null, "a rejected publication must not become the snapshot source"); + return state; +} + +test("the Odin publisher publishes the signer's document to the endpoint Expected names and surfaces Odin's rejection", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); - rejectOdinAdmission = false; await publisher.publish("warming", "accepted presence"); - const snapshotDocument = await publisher.publishRouteObservation({ - schemaVersion: "cultnet.snapshot_request.v0", - messageId: "route-challenge-1", - schemaIds: ["gamecult.runtime_presence_health.v2"], - recordKeys: [target], - }); - const publishedDocument = (receivedMessage as { document: { payload: Uint8Array; schemaId: string; recordKey: string } }).document; - assert.ok(snapshotDocument); - assert.equal(snapshotDocument.schemaId, publishedDocument.schemaId); - assert.equal(snapshotDocument.recordKey, publishedDocument.recordKey); - assert.deepEqual(Buffer.from(snapshotDocument.payload), Buffer.from(publishedDocument.payload)); - const routePresence = decode(snapshotDocument.payload) as unknown[]; - assert.equal(routePresence[RUNTIME_PRESENCE_SLOT.state], "active"); - assert.equal(routePresence[RUNTIME_PRESENCE_SLOT.detail], "route-observation:route-challenge-1"); - snapshotDocument.payload[0] = snapshotDocument.payload[0]! ^ 0xff; - assert.deepEqual(Buffer.from(publisher.latestPresenceDocument()?.payload ?? []), Buffer.from(publishedDocument.payload), "snapshot readers cannot mutate publisher state"); + assert.equal(peer.received[0]?.schemaVersion, "cultnet.document_put_raw.v0"); + const published = decode(peer.received[0]!.document.payload) as unknown[]; + assert.equal(published[RUNTIME_PRESENCE_SLOT.state], "warming"); + assert.equal(published[RUNTIME_PRESENCE_SLOT.publisherSequence], 1); + assert.equal(peer.responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); - descriptorProviderCredential = Buffer.from(encode([ - ...(decode(providerCredential) as unknown[][]), - ...(decode(providerCredential) as unknown[][]), - ])); - assert.throws( - () => loadIdunnRuntimeAuthorityFromEnvironment(target, contract, "127.0.0.1:17871"), - /exactly one envelope/, - ); + peer.reject = true; + await assert.rejects(publisher.publish("active", "denied presence"), /Odin rejected runtime presence: test admission denied/); + assert.equal(peer.failure, undefined, peer.failure?.message); }); -function cultCacheStore(type: string, schemaId: string, key: string, payload: Uint8Array): Buffer { - return Buffer.from(encode([ - "cultcache.store.v1", - [[schemaId, type, `${schemaId}.v1`, schemaId, "{}", [schemaId], []]], - [[key, schemaId, "2026-01-01T00:00:00Z", payload]], - ])); -} +test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const publisher = createIdunnRuntimePresencePublisher({ signer: signerFor(context, "service", -1), endpoint }); + await publisher.publish("active", "below minimum"); + const capabilities = (decode(peer.received[0]!.document.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.capabilities] as unknown[][]; + assert.equal(capabilities[0]![3], expectedFacts("service").capabilities[0]!.capacity - 1); +}); -function privateKeyFromSeed(seed: Uint8Array): crypto.KeyObject { - return crypto.createPrivateKey({ - key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from(seed)]), - format: "der", - type: "pkcs8", - }); -} - -function rawPublic(key: crypto.KeyObject): Buffer { - const der = key.export({ type: "spki", format: "der" }) as Buffer; - return Buffer.from(der.subarray(der.length - 32)); -} - -function sha256Hex(bytes: Uint8Array): string { - return crypto.createHash("sha256").update(bytes).digest("hex"); -} +test("the Odin publisher takes only the endpoint Expected names", (context) => { + assert.throws( + () => createIdunnRuntimePresencePublisher({ signer: signerFor(context, "service"), endpoint: "rudp://127.0.0.1:1" }), + /does not match Idunn Expected dependency authority/, + ); + assert.throws( + () => createIdunnRuntimePresencePublisher({ signer: signerFor(context, "web"), endpoint: expectedFacts("service").odinEndpoint! }), + /does not match Idunn Expected dependency authority/, + ); +}); From 16432d4b1ec8af338b375b896207d08da4a4fdbc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:12:43 +0200 Subject: [PATCH 09/21] Fix the BP-1 findings in the cultnet-rs content plane A hostile manifest of empty chunks no longer becomes a request flood: fetch_content refuses a zero-size chunk beside other chunks, and settles the content hash and every chunk hash (normalised, non-empty) before the first request. Every bad chunk request is now answered found:false in the reference handler's validation order, never refused at decode. validate_message no longer refuses content chunk requests or responses, so a blank message id or hash, a prefix-only hash and a negative size reach answer_content_chunk_request. The answer follows the reference's double normalisation (handler, then CreateRecordKey), which fixes the prefix-only hash spelling to ArgumentException on 'chunkHash'. The cs vectors gain nine reference-server answers (blank and whitespace id, blank, whitespace and prefix-only hash, negative size, record-key case, blank record key, double prefix) and a metadata-order manifest; Rust answers all of them byte-identically. Manifest metadata is Vec<(String, String)> in wire order and refuses a duplicate key, replacing the BTreeMap that re-sorted keys. Tests pin the surviving mutants: unusable chunk sizes, the payload-length and claimed-size halves of the chunk check, stable sort at equal offsets, the normalised hash and canonical key actually sent, and the kind/mime defaults never trimming. Co-Authored-By: Claude Sonnet 5 --- .../cultmesh/content-vectors.cs-written.json | 95 ++++++ .../cultmesh/content-vectors.rs-written.json | 95 ++++++ packages/cultnet-rs/src/content.rs | 87 +++++- packages/cultnet-rs/src/contracts.rs | 38 +-- packages/cultnet-rs/tests/content.rs | 293 ++++++++++++++++-- .../CultMeshContentVectorTests.cs | 13 + 6 files changed, 551 insertions(+), 70 deletions(-) diff --git a/contracts/cultmesh/content-vectors.cs-written.json b/contracts/cultmesh/content-vectors.cs-written.json index 30a139ed..a0ae2fe2 100644 --- a/contracts/cultmesh/content-vectors.cs-written.json +++ b/contracts/cultmesh/content-vectors.cs-written.json @@ -40,6 +40,96 @@ "kind": "response", "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKt2ZWN0b3Itc2l6ZaVmb3VuZMKpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2WBJbnZhbGlkRGF0YUV4Y2VwdGlvbjogQ0ROIGFydGlmYWN0IGNodW5rIHBheWxvYWQgbWV0YWRhdGEgZG9lcyBub3QgbWF0Y2ggaXRzIG1hbmlmZXN0IHJlZmVyZW5jZS4=" }, + { + "label": "answer_blank_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkoKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_blank_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "answer_whitespace_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkoyAgIKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_whitespace_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKMgICClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "answer_blank_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksXZlY3Rvci1ibGFuay1oYXNoqWNodW5rSGFzaKCpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc2Q=" + }, + { + "label": "answer_blank_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLF2ZWN0b3ItYmxhbmstaGFzaKVmb3VuZMKpY2h1bmtIYXNooKlzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlCQXJndW1lbnRFeGNlcHRpb246IEhhc2ggbXVzdCBiZSBub24tZW1wdHkuIChQYXJhbWV0ZXIgJ0NodW5rSGFzaCcp" + }, + { + "label": "answer_whitespace_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksXZlY3Rvci1zcGFjZS1oYXNoqWNodW5rSGFzaKMgICCpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc2Q=" + }, + { + "label": "answer_whitespace_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLF2ZWN0b3Itc3BhY2UtaGFzaKVmb3VuZMKpY2h1bmtIYXNooyAgIKlzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlCQXJndW1lbnRFeGNlcHRpb246IEhhc2ggbXVzdCBiZSBub24tZW1wdHkuIChQYXJhbWV0ZXIgJ0NodW5rSGFzaCcp" + }, + { + "label": "answer_prefix_only_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksnZlY3Rvci1wcmVmaXgtaGFzaKljaHVua0hhc2iqc2hhMjU2OiAgIKlyZWNvcmRLZXmgsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "answer_prefix_only_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLJ2ZWN0b3ItcHJlZml4LWhhc2ilZm91bmTCqWNodW5rSGFzaKpzaGEyNTY6ICAgqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2UJBcmd1bWVudEV4Y2VwdGlvbjogSGFzaCBtdXN0IGJlIG5vbi1lbXB0eS4gKFBhcmFtZXRlciAnY2h1bmtIYXNoJyk=" + }, + { + "label": "answer_negative_size.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1uZWdhdGl2ZaljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVz/w==" + }, + { + "label": "answer_negative_size.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3ItbmVnYXRpdmWlZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlgSW52YWxpZERhdGFFeGNlcHRpb246IENETiBhcnRpZmFjdCBjaHVuayBwYXlsb2FkIG1ldGFkYXRhIGRvZXMgbm90IG1hdGNoIGl0cyBtYW5pZmVzdCByZWZlcmVuY2Uu" + }, + { + "label": "answer_record_key_case.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1rZXktY2FzZaljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9NRVNIOkNETjpDSFVOSzo1OTkzQUM2MDQwN0U4NjYwMTg3M0JDMzhFRTZGOERCM0VBOTEyRTk4NzYxQ0I1NThBQjk3OTVEQjMwNkI0NzRDsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_record_key_case.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3Ita2V5LWNhc2WlZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlPSW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVjb3JkIGtleSBkaXNhZ3JlZXMgd2l0aCBpdHMgY29udGVudCBoYXNoLg==" + }, + { + "label": "answer_blank_record_key.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksHZlY3Rvci1rZXktYmxhbmupY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtleaMgICCxZXhwZWN0ZWRTaXplQnl0ZXPNEt8=" + }, + { + "label": "answer_blank_record_key.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLB2ZWN0b3Ita2V5LWJsYW5rpWZvdW5kw6ljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpc2l6ZUJ5dGVzzRLfp3BheWxvYWTFEt/5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7pWVycm9yoA==" + }, + { + "label": "answer_double_prefix.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrXZlY3Rvci1kb3VibGWpY2h1bmtIYXNo2U5zaGEyNTY6c2hhMjU2OjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_double_prefix.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK12ZWN0b3ItZG91YmxlpWZvdW5kw6ljaHVua0hhc2jZR3NoYTI1Njo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXNpemVCeXRlc80S36dwYXlsb2FkxRLf\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6O6VlcnJvcqA=" + }, { "label": "response_hash_last_digit", "kind": "response", @@ -55,6 +145,11 @@ "kind": "manifest", "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA==" }, + { + "label": "manifest_metadata_order", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw==" + }, { "label": "manifest_empty_body", "kind": "manifest", diff --git a/contracts/cultmesh/content-vectors.rs-written.json b/contracts/cultmesh/content-vectors.rs-written.json index c4b58833..38538e2b 100644 --- a/contracts/cultmesh/content-vectors.rs-written.json +++ b/contracts/cultmesh/content-vectors.rs-written.json @@ -40,6 +40,96 @@ "kind": "response", "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKt2ZWN0b3Itc2l6ZaVmb3VuZMKpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2WBJbnZhbGlkRGF0YUV4Y2VwdGlvbjogQ0ROIGFydGlmYWN0IGNodW5rIHBheWxvYWQgbWV0YWRhdGEgZG9lcyBub3QgbWF0Y2ggaXRzIG1hbmlmZXN0IHJlZmVyZW5jZS4=" }, + { + "label": "answer_blank_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkoKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_blank_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "answer_whitespace_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkoyAgIKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_whitespace_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKMgICClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "answer_blank_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksXZlY3Rvci1ibGFuay1oYXNoqWNodW5rSGFzaKCpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc2Q=" + }, + { + "label": "answer_blank_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLF2ZWN0b3ItYmxhbmstaGFzaKVmb3VuZMKpY2h1bmtIYXNooKlzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlCQXJndW1lbnRFeGNlcHRpb246IEhhc2ggbXVzdCBiZSBub24tZW1wdHkuIChQYXJhbWV0ZXIgJ0NodW5rSGFzaCcp" + }, + { + "label": "answer_whitespace_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksXZlY3Rvci1zcGFjZS1oYXNoqWNodW5rSGFzaKMgICCpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc2Q=" + }, + { + "label": "answer_whitespace_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLF2ZWN0b3Itc3BhY2UtaGFzaKVmb3VuZMKpY2h1bmtIYXNooyAgIKlzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlCQXJndW1lbnRFeGNlcHRpb246IEhhc2ggbXVzdCBiZSBub24tZW1wdHkuIChQYXJhbWV0ZXIgJ0NodW5rSGFzaCcp" + }, + { + "label": "answer_prefix_only_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksnZlY3Rvci1wcmVmaXgtaGFzaKljaHVua0hhc2iqc2hhMjU2OiAgIKlyZWNvcmRLZXmgsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "answer_prefix_only_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLJ2ZWN0b3ItcHJlZml4LWhhc2ilZm91bmTCqWNodW5rSGFzaKpzaGEyNTY6ICAgqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2UJBcmd1bWVudEV4Y2VwdGlvbjogSGFzaCBtdXN0IGJlIG5vbi1lbXB0eS4gKFBhcmFtZXRlciAnY2h1bmtIYXNoJyk=" + }, + { + "label": "answer_negative_size.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1uZWdhdGl2ZaljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVz/w==" + }, + { + "label": "answer_negative_size.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3ItbmVnYXRpdmWlZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlgSW52YWxpZERhdGFFeGNlcHRpb246IENETiBhcnRpZmFjdCBjaHVuayBwYXlsb2FkIG1ldGFkYXRhIGRvZXMgbm90IG1hdGNoIGl0cyBtYW5pZmVzdCByZWZlcmVuY2Uu" + }, + { + "label": "answer_record_key_case.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1rZXktY2FzZaljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9NRVNIOkNETjpDSFVOSzo1OTkzQUM2MDQwN0U4NjYwMTg3M0JDMzhFRTZGOERCM0VBOTEyRTk4NzYxQ0I1NThBQjk3OTVEQjMwNkI0NzRDsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_record_key_case.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3Ita2V5LWNhc2WlZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlPSW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVjb3JkIGtleSBkaXNhZ3JlZXMgd2l0aCBpdHMgY29udGVudCBoYXNoLg==" + }, + { + "label": "answer_blank_record_key.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksHZlY3Rvci1rZXktYmxhbmupY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtleaMgICCxZXhwZWN0ZWRTaXplQnl0ZXPNEt8=" + }, + { + "label": "answer_blank_record_key.response", + "kind": "response", + "messagePackBase64": "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" + }, + { + "label": "answer_double_prefix.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrXZlY3Rvci1kb3VibGWpY2h1bmtIYXNo2U5zaGEyNTY6c2hhMjU2OjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_double_prefix.response", + "kind": "response", + "messagePackBase64": "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" + }, { "label": "response_hash_last_digit", "kind": "response", @@ -55,6 +145,11 @@ "kind": "manifest", "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA==" }, + { + "label": "manifest_metadata_order", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw==" + }, { "label": "manifest_empty_body", "kind": "manifest", diff --git a/packages/cultnet-rs/src/content.rs b/packages/cultnet-rs/src/content.rs index bbf460b9..843d4b86 100644 --- a/packages/cultnet-rs/src/content.rs +++ b/packages/cultnet-rs/src/content.rs @@ -6,8 +6,6 @@ //! This is the only owner of these rules in Rust. A consumer hands `fetch_content` a transport //! closure and gets verified bytes; it never hashes, chunks or validates a manifest itself. -use std::collections::BTreeMap; - use anyhow::{Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -44,7 +42,9 @@ pub struct CultMeshCdnArtifactManifest { pub created_at_utc: String, pub chunks: Vec, pub tags: Vec, - pub metadata: BTreeMap, + /// Caller metadata in wire order. The reference writes a `Dictionary` in insertion order and + /// refuses a duplicate key at read, so this keeps the order it was read in and refuses a duplicate. + pub metadata: Vec<(String, String)>, } // The tuple mirrors: `to_vec_named` would write a map for a named-field struct, but a tuple @@ -63,9 +63,46 @@ struct ManifestWire( String, Vec, Vec, - BTreeMap, + #[serde(with = "ordered_map")] Vec<(String, String)>, ); +/// A string map that keeps its key order and refuses a duplicate key, as `Dictionary` +/// deserialises. `Vec<(String, String)>` is the in-memory shape; the wire shape is a msgpack map. +mod ordered_map { + use std::fmt; + + use serde::de::{self, MapAccess, Visitor}; + use serde::{Deserializer, Serializer}; + + pub fn serialize(pairs: &[(String, String)], serializer: S) -> Result { + serializer.collect_map(pairs.iter().map(|(key, value)| (key, value))) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + struct Pairs; + impl<'de> Visitor<'de> for Pairs { + type Value = Vec<(String, String)>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map of strings") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut seen = std::collections::HashSet::new(); + let mut pairs = Vec::new(); + while let Some((key, value)) = map.next_entry::()? { + if !seen.insert(key.clone()) { + return Err(de::Error::custom(format!("duplicate metadata key '{key}'"))); + } + pairs.push((key, value)); + } + Ok(pairs) + } + } + deserializer.deserialize_map(Pairs) + } +} + impl From for ChunkRefWire { fn from(value: CultMeshCdnChunkRef) -> Self { Self(value.chunk_hash, value.offset, value.size_bytes, value.record_key) @@ -138,6 +175,15 @@ pub fn normalize_hash(hash: &str) -> Result { Ok(stripped.trim().to_lowercase()) } +/// A hash the client will put on the wire or compare against: normalized and not empty. +fn non_empty_hash(hash: &str) -> Result { + let normalized = normalize_hash(hash)?; + if normalized.is_empty() { + bail!("Hash must be non-empty."); + } + Ok(normalized) +} + /// `CultMeshCdn.PackArtifact`: splits `bytes` into content-addressed chunks and builds the manifest. /// An empty body packs as one zero-length chunk, as the reference does. pub fn pack_content( @@ -188,7 +234,7 @@ pub fn pack_content( created_at_utc: created_at_utc.to_string(), chunks: refs, tags: Vec::new(), - metadata: BTreeMap::new(), + metadata: Vec::new(), }; validate_manifest(&manifest)?; Ok((manifest, chunks)) @@ -277,13 +323,17 @@ pub fn answer_content_chunk_request<'a>( if message_id.trim().is_empty() { return Err("InvalidDataException: Content chunk request requires a message identity.".to_string()); } - let hash = normalize_hash(chunk_hash) - .map_err(|_| "ArgumentException: Hash must be non-empty. (Parameter 'ChunkHash')".to_string())?; - if !record_key.trim().is_empty() && *record_key != chunk_record_key(&hash) { + // The handler normalises the wire hash, then `CreateRecordKey` normalises that result again + // (and spells its parameter `chunkHash`); the lookup, the size check and the hash check all + // use the second result, and the answer echoes the first. + let blank = |parameter: &str| format!("ArgumentException: Hash must be non-empty. (Parameter '{parameter}')"); + let hash = normalize_hash(chunk_hash).map_err(|_| blank("ChunkHash"))?; + let canonical = normalize_hash(&hash).map_err(|_| blank("chunkHash"))?; + if !record_key.trim().is_empty() && *record_key != chunk_record_key(&canonical) { return Err("InvalidDataException: Content chunk record key disagrees with its content hash.".to_string()); } - let payload = lookup(&hash).ok_or_else(|| "FileNotFoundException: Content chunk is not available.".to_string())?; - validate_chunk(&hash, *expected_size_bytes, &hash, payload.len() as i64, payload) + let payload = lookup(&canonical).ok_or_else(|| "FileNotFoundException: Content chunk is not available.".to_string())?; + validate_chunk(&canonical, *expected_size_bytes, &canonical, payload.len() as i64, payload) .map_err(|error| format!("InvalidDataException: {error}"))?; Ok((hash, payload.to_vec())) })(); @@ -323,9 +373,20 @@ pub fn fetch_content( manifest.size_bytes ); } + // Everything the client will send or compare is settled before the first request. A zero-size + // chunk is legal only as an empty body's single chunk (the reference packs it so); anywhere else + // it is a request that yields nothing, and a manifest of them is a request flood. + let ordered = in_offset_order(manifest); + if ordered.len() > 1 && ordered.iter().any(|chunk| chunk.size_bytes == 0) { + bail!("CDN artifact has a zero-size chunk beside other chunks."); + } + let content_hash = non_empty_hash(&manifest.content_hash)?; + let hashes = ordered + .iter() + .map(|chunk| non_empty_hash(&chunk.chunk_hash)) + .collect::>>()?; let mut body = Vec::new(); - for chunk in in_offset_order(manifest) { - let hash = normalize_hash(&chunk.chunk_hash)?; + for (chunk, hash) in ordered.into_iter().zip(hashes) { let message_id = uuid::Uuid::new_v4().simple().to_string(); let record_key = if chunk.record_key.trim().is_empty() { chunk_record_key(&hash) @@ -358,7 +419,7 @@ pub fn fetch_content( validate_chunk(&hash, chunk.size_bytes, &chunk_hash, i64::from(size_bytes), &payload)?; body.extend_from_slice(&payload); } - if sha256_hex(&body) != normalize_hash(&manifest.content_hash)? { + if sha256_hex(&body) != content_hash { bail!("CDN artifact content hash does not match its manifest."); } Ok(body) diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index 038b0708..0925215e 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -801,40 +801,10 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { validate_shard_descriptor(shard)?; } } - CultNetMessage::ContentChunkRequest { - message_id, - chunk_hash, - record_key: _, - expected_size_bytes, - } => { - require_non_empty(message_id, "messageId")?; - if crate::normalize_hash(chunk_hash)?.is_empty() { - return Err(anyhow!("CultNet field chunkHash must be non-empty")); - } - if *expected_size_bytes < 0 { - return Err(anyhow!("CultNet field expectedSizeBytes must not be negative")); - } - } - CultNetMessage::ContentChunkResponse { - message_id, - found, - chunk_hash: _, - size_bytes, - payload, - error, - } => { - require_non_empty(message_id, "messageId")?; - if *found { - if i64::try_from(payload.len()).ok() != Some(i64::from(*size_bytes)) { - return Err(anyhow!("CultNet field payload length must equal sizeBytes")); - } - if !error.is_empty() { - return Err(anyhow!("CultNet field error must be empty when found")); - } - } else if !payload.is_empty() { - return Err(anyhow!("CultNet field payload must be empty when not found")); - } - } + // The reference's serializer accepts any content chunk message. It refuses a bad request in + // its handler and answers `found: false` (`answer_content_chunk_request`); a bad response is + // refused by `fetch_content`'s verification. Refusing either here would leave a peer in silence. + CultNetMessage::ContentChunkRequest { .. } | CultNetMessage::ContentChunkResponse { .. } => {} CultNetMessage::OperationRequest { message_id, service_id, diff --git a/packages/cultnet-rs/tests/content.rs b/packages/cultnet-rs/tests/content.rs index 363a600d..e4edb0bf 100644 --- a/packages/cultnet-rs/tests/content.rs +++ b/packages/cultnet-rs/tests/content.rs @@ -61,6 +61,24 @@ fn manifest_bytes(manifest: &CultMeshCdnArtifactManifest) -> Vec { rmp_serde::to_vec_named(manifest).expect("encodes") } +fn decode_manifest(bytes: &[u8]) -> CultMeshCdnArtifactManifest { + rmp_serde::from_slice(bytes).expect("decodes") +} + +/// The manifest's bytes with its metadata map replaced by `pairs`, written in exactly that order +/// (and with duplicates, if given): what a peer that is not this crate can put on the wire. +fn manifest_with_metadata(manifest: &CultMeshCdnArtifactManifest, pairs: &[(&str, &str)]) -> Vec { + let encoded = manifest_bytes(manifest); + let mut value = rmpv::decode::read_value(&mut encoded.as_slice()).expect("a msgpack value"); + let rmpv::Value::Array(fields) = &mut value else { + panic!("the manifest is a positional array"); + }; + fields[9] = rmpv::Value::Map(pairs.iter().map(|(k, v)| (rmpv::Value::from(*k), rmpv::Value::from(*v))).collect()); + let mut out = Vec::new(); + rmpv::encode::write_value(&mut out, &value).expect("writes"); + out +} + fn request(id: &str, hash: &str, key: &str, size: i32) -> CultNetMessage { CultNetMessage::ContentChunkRequest { message_id: id.to_string(), @@ -171,6 +189,21 @@ fn local_vectors() -> Vec { request("vector-key", &last.chunk_hash, "mesh:cdn:chunk:other", last_size), ), ("answer_size_mismatch", request("vector-size", &last.chunk_hash, &key, last_size - 1)), + ("answer_blank_message_id", request("", &last.chunk_hash, &key, last_size)), + ("answer_whitespace_message_id", request(" ", &last.chunk_hash, &key, last_size)), + ("answer_blank_hash", request("vector-blank-hash", "", "", 100)), + ("answer_whitespace_hash", request("vector-space-hash", " ", "", 100)), + ("answer_prefix_only_hash", request("vector-prefix-hash", "sha256: ", "", 100)), + ("answer_negative_size", request("vector-negative", &last.chunk_hash, &key, -1)), + ( + "answer_record_key_case", + request("vector-key-case", &last.chunk_hash, &key.to_uppercase(), last_size), + ), + ("answer_blank_record_key", request("vector-key-blank", &last.chunk_hash, " ", last_size)), + ( + "answer_double_prefix", + request("vector-double", &format!("sha256:sha256:{}", last.chunk_hash), "", last_size), + ), ]; for (name, request) in &answers { push_message(format!("{name}.request"), "request", request); @@ -200,6 +233,8 @@ fn local_vectors() -> Vec { let mut shuffled = manifest.clone(); shuffled.chunks = [3, 0, 5, 1, 4, 2].iter().map(|&i| manifest.chunks[i].clone()).collect(); push_manifest("manifest_out_of_order", &shuffled); + let tagged = decode_manifest(&manifest_with_metadata(&manifest, &[("zeta", "1"), ("alpha", "2"), ("mid", "3")])); + push_manifest("manifest_metadata_order", &tagged); push_manifest("manifest_empty_body", &pack(&[]).0); vectors } @@ -479,7 +514,7 @@ fn answer_serves_found_false_with_the_reference_error_spelling() { .expect("paired response"); assert_eq!(&wire(&answer), expected_bytes, "{name}: answer differs from the reference's"); let (_, found, _, payload, error) = response_of(&answer); - if name == "answer_found" { + if ["answer_found", "answer_blank_record_key", "answer_double_prefix"].contains(&name) { assert!(found && error.is_empty() && !payload.is_empty()); } else { assert!(!found && payload.is_empty(), "{name}"); @@ -487,28 +522,7 @@ fn answer_serves_found_false_with_the_reference_error_spelling() { } judged += 1; } - assert_eq!(judged, 4); -} - -#[test] -fn content_messages_refuse_what_the_reference_never_produces() { - let refused = |message: CultNetMessage| assert!(encode_cultnet_message_for_wire(&message, SCHEMA_V0).is_err(), "{message:?}"); - refused(request("", "abc", "", 1)); - refused(request("m", "sha256: ", "", 1)); - refused(request("m", "abc", "", -1)); - let response = |found: bool, size: i32, payload: &[u8], error: &str| CultNetMessage::ContentChunkResponse { - message_id: "m".to_string(), - found, - chunk_hash: "abc".to_string(), - size_bytes: size, - payload: payload.to_vec(), - error: error.to_string(), - }; - refused(response(true, 3, &[1, 2], "")); - refused(response(true, 2, &[1, 2], "boom")); - refused(response(false, 0, &[1], "boom")); - assert!(encode_cultnet_message_for_wire(&response(true, 2, &[1, 2], ""), SCHEMA_V0).is_ok()); - assert!(encode_cultnet_message_for_wire(&response(false, 0, &[], "boom"), SCHEMA_V0).is_ok()); + assert_eq!(judged, 13); } #[test] @@ -518,3 +532,236 @@ fn gamecult_networking_contract_refuses_content_messages() { assert!(encode_cultnet_message_for_wire(&message, legacy).is_err()); assert!(decode_cultnet_message_from_slice(&wire(&message), legacy).is_err()); } + +// ------------------------------------------------------------------------------------------ +// BP-1 fixes +// ------------------------------------------------------------------------------------------ + +fn chunk_ref(hash: &str, offset: i64, size: i32) -> cultnet_rs::CultMeshCdnChunkRef { + cultnet_rs::CultMeshCdnChunkRef { + chunk_hash: hash.to_string(), + offset, + size_bytes: size, + record_key: String::new(), + } +} + +/// A manifest with the given chunk list and stated size, everything else from a real pack. +fn manifest_of(size: i64, chunks: Vec) -> CultMeshCdnArtifactManifest { + let mut manifest = pack(&[]).0; + manifest.size_bytes = size; + manifest.chunks = chunks; + manifest +} + +// F1: a zero-size chunk is legal only as an empty body's single chunk. +#[test] +fn a_zero_size_chunk_flood_is_refused_before_any_request() { + let (empty, empty_chunks) = pack(&[]); + let empty_store = store(&empty_chunks); + let flood = manifest_of(0, (0..100_000).map(|_| chunk_ref(&empty.content_hash, 0, 0)).collect()); + validate_manifest(&flood).expect("the shared shape rule accepts it, as the reference does"); + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&flood, u64::MAX, serving(&empty_store, &calls)).is_err()); + assert_eq!(calls.get(), 0, "100,000 empty chunks must not become 100,000 requests"); + + // A zero-size chunk beside real ones is refused too, so skipping it is never the client's job. + let (good, chunks) = pack(&body()); + let body_store = store(&chunks); + let mut zero_inside = good.clone(); + let boundary = zero_inside.chunks[2].offset; + zero_inside.chunks.insert(2, chunk_ref(&empty.content_hash, boundary, 0)); + validate_manifest(&zero_inside).expect("shape-valid"); + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&zero_inside, u64::MAX, serving(&body_store, &calls)).is_err()); + assert_eq!(calls.get(), 0); + + // An empty body still fetches, through its one zero-length chunk. + let calls = std::cell::Cell::new(0); + assert_eq!(fetch_content(&empty, 0, serving(&empty_store, &calls)).expect("fetches"), Vec::::new()); + assert_eq!(calls.get(), 1); +} + +// F2, F5: every bad request is answered, in the reference's validation order and spelling. The +// byte-identity against the reference server's own answers is `answer_serves_found_false_...`. +#[test] +fn a_bad_request_is_answered_never_dropped() { + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let last = chunks.last().unwrap(); + let size = last.payload.len() as i32; + let size_mismatch = "InvalidDataException: CDN artifact chunk payload metadata does not match its manifest reference."; + for (label, bad, spelling) in [ + ( + "blank id", + request("", &last.chunk_hash, "", size), + "InvalidDataException: Content chunk request requires a message identity.", + ), + ("blank hash", request("m", "", "", size), "ArgumentException: Hash must be non-empty. (Parameter 'ChunkHash')"), + ( + "prefix-only hash", + request("m", "sha256: ", "", size), + "ArgumentException: Hash must be non-empty. (Parameter 'chunkHash')", + ), + ("negative size", request("m", &last.chunk_hash, "", -1), size_mismatch), + ] { + let answer = answer_content_chunk_request(&bad, lookup_in(&store)); + let (_, found, _, payload, error) = response_of(&answer); + assert!(!found && payload.is_empty(), "{label}"); + assert_eq!(error, spelling, "{label}"); + // And both travel: neither the request nor its answer is stopped at decode or encode. + assert_eq!(unwire(&wire(&bad)).expect("the request decodes"), bad, "{label}"); + assert_eq!(unwire(&wire(&answer)).expect("the answer encodes and decodes"), answer, "{label}"); + } +} + +// F3: metadata keeps the order it was read in and refuses a duplicate key. +#[test] +fn manifest_metadata_keeps_wire_order_and_refuses_a_duplicate_key() { + let reference = read_vectors("content-vectors.cs-written.json"); + let (_, _, bytes) = reference.iter().find(|(l, _, _)| l == "manifest_metadata_order").expect("vector"); + let decoded = decode_manifest(bytes); + assert_eq!(&manifest_bytes(&decoded), bytes, "zeta, alpha, mid re-encode as zeta, alpha, mid"); + + let base = pack(&body()).0; + let duplicated = manifest_with_metadata(&base, &[("k", "1"), ("other", "2"), ("k", "3")]); + assert!(rmp_serde::from_slice::(&duplicated).is_err()); + let unique = manifest_with_metadata(&base, &[("k", "1"), ("other", "2")]); + assert_eq!(manifest_bytes(&decode_manifest(&unique)), unique); +} + +// F4: hashes are settled before the first request. +#[test] +fn a_blank_hash_is_refused_before_any_request() { + let (good, chunks) = pack(&body()); + let store = store(&chunks); + let mut blank_content = good.clone(); + blank_content.content_hash = " ".to_string(); + let mut prefix_content = good.clone(); + prefix_content.content_hash = "sha256: ".to_string(); + let mut blank_chunk = good.clone(); + blank_chunk.chunks[3].chunk_hash = " ".to_string(); + let mut prefix_chunk = good.clone(); + prefix_chunk.chunks[3].chunk_hash = "SHA256: ".to_string(); + for (label, manifest) in [ + ("blank content hash", blank_content), + ("prefix-only content hash", prefix_content), + ("blank chunk hash at 3", blank_chunk), + ("prefix-only chunk hash at 3", prefix_chunk), + ] { + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&manifest, u64::MAX, serving(&store, &calls)).is_err(), "{label}"); + assert_eq!(calls.get(), 0, "{label}: refused before any request"); + } +} + +// F6: `pack_content` refuses a chunk size the reference's int cannot hold, without panicking. +#[test] +fn pack_content_refuses_an_unusable_chunk_size() { + for size in [0usize, i32::MAX as usize + 1, usize::MAX] { + let refused = std::panic::catch_unwind(|| pack_content("a", "", "", "", "", b"abc", size)); + assert!(matches!(refused, Ok(Err(_))), "chunk size {size} must be refused cleanly"); + } + let (manifest, _) = + pack_content("a", "", "", "", "", b"abc", i32::MAX as usize).expect("i32::MAX is a valid chunk size"); + assert_eq!(manifest.chunks.len(), 1); +} + +// F6: `PackArtifact` defaults a blank kind and mime type and otherwise uses the value untouched. +#[test] +fn pack_defaults_only_a_blank_kind_and_mime_type_and_never_trims() { + let packed = |kind: &str, mime: &str| pack_content("a", kind, "v", mime, CREATED, b"abc", 2).expect("packs").0; + for blank in ["", " ", "\t \n"] { + let manifest = packed(blank, blank); + assert_eq!( + (manifest.kind.as_str(), manifest.mime_type.as_str()), + ("asset", "application/octet-stream"), + "{blank:?}" + ); + } + let manifest = packed(" build ", " text/plain "); + assert_eq!((manifest.kind.as_str(), manifest.mime_type.as_str()), (" build ", " text/plain ")); +} + +/// The honest answer to `request`, taken apart so a test can lie in one field. +fn honest(request: &CultNetMessage, store: &HashMap>) -> (String, bool, String, i32, Vec, String) { + match answer_content_chunk_request(request, lookup_in(store)) { + CultNetMessage::ContentChunkResponse { message_id, found, chunk_hash, size_bytes, payload, error } => { + (message_id, found, chunk_hash, size_bytes, payload, error) + } + other => panic!("{other:?}"), + } +} + +// F6: the two halves of the per-chunk check, each on its own. +#[test] +fn a_chunk_whose_length_or_claim_disagrees_with_the_manifest_is_refused() { + let (manifest, chunks) = pack(&body()); + let store = store(&chunks); + // Each lie maps (honest size, honest payload) to (claimed size, payload served). + type Lie = fn(i32, Vec) -> (i32, Vec); + let lies: [(&str, Lie); 4] = [ + ("a payload of the claimed length that is not the manifest's length", |size, mut p| { + p.truncate(p.len() - 1); + (size - 1, p) + }), + ("a payload whose length is not its claimed size, the claim being the manifest's", |size, mut p| { + p.truncate(p.len() - 1); + (size, p) + }), + ("the right payload under a wrong claimed size", |size, p| (size - 1, p)), + ("a longer payload of its claimed length", |size, mut p| { + p.push(0); + (size + 1, p) + }), + ]; + for (label, lie) in lies { + let error = fetch_content(&manifest, u64::MAX, |message| { + let (message_id, found, chunk_hash, honest_size, payload, error) = honest(&message, &store); + let (size_bytes, payload) = lie(honest_size, payload); + Ok(CultNetMessage::ContentChunkResponse { message_id, found, chunk_hash, size_bytes, payload, error }) + }) + .expect_err(label); + assert!(error.to_string().contains("payload metadata does not match"), "{label}: {error}"); + } +} + +// F6: `[5@0, 0@0]` is not contiguous under the reference's stable sort by offset alone. +#[test] +fn equal_offsets_keep_their_manifest_order() { + let hash = "0".repeat(64); + let five_then_zero = manifest_of(5, vec![chunk_ref(&hash, 0, 5), chunk_ref(&hash, 0, 0)]); + assert!(validate_manifest(&five_then_zero).is_err(), "the stable sort keeps 5@0 first, so 0@0 lands at offset 5"); + let zero_then_five = manifest_of(5, vec![chunk_ref(&hash, 0, 0), chunk_ref(&hash, 0, 5)]); + validate_manifest(&zero_then_five).expect("0@0 first leaves 5@0 at offset 0"); +} + +// F6: what goes on the wire is the normalized hash and the canonical record key. +#[test] +fn the_client_sends_the_normalized_hash_and_canonical_record_key() { + let (mut manifest, chunks) = pack(&body()); + let store = store(&chunks); + let honest_hashes: Vec = manifest.chunks.iter().map(|c| c.chunk_hash.clone()).collect(); + manifest.content_hash = format!("SHA256:{}", manifest.content_hash.to_uppercase()); + for (index, chunk) in manifest.chunks.iter_mut().enumerate() { + chunk.chunk_hash = format!("sha256:{}", chunk.chunk_hash.to_uppercase()); + // Alternate chunks name their key; the rest leave it blank for the client to derive. + chunk.record_key = + if index % 2 == 0 { format!("mesh:cdn:chunk:{}", honest_hashes[index]) } else { " ".to_string() }; + } + let mut sent = Vec::new(); + let fetched = fetch_content(&manifest, u64::MAX, |message| { + sent.push(message.clone()); + Ok(answer_content_chunk_request(&message, lookup_in(&store))) + }) + .expect("fetches"); + assert_eq!(fetched, body()); + assert_eq!(sent.len(), honest_hashes.len()); + for (message, hash) in sent.iter().zip(&honest_hashes) { + let CultNetMessage::ContentChunkRequest { chunk_hash, record_key, .. } = message else { + panic!("not a request"); + }; + assert_eq!(chunk_hash, hash, "the normalized hash is what is sent"); + assert_eq!(record_key, &format!("mesh:cdn:chunk:{hash}"), "and the canonical key"); + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs index 2e156891..6c5763bb 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs @@ -164,6 +164,16 @@ public sealed class CultMeshContentVectorTests AddAnswer("answer_not_found", Request("vector-missing", new string('0', 64), string.Empty, 100)); AddAnswer("answer_record_key_disagrees", Request("vector-key", last.ChunkHash, "mesh:cdn:chunk:other", last.SizeBytes)); AddAnswer("answer_size_mismatch", Request("vector-size", last.ChunkHash, key, last.SizeBytes - 1)); + // Requests the reference refuses in its handler, never at deserialisation: each is answered. + AddAnswer("answer_blank_message_id", Request(string.Empty, last.ChunkHash, key, last.SizeBytes)); + AddAnswer("answer_whitespace_message_id", Request(" ", last.ChunkHash, key, last.SizeBytes)); + AddAnswer("answer_blank_hash", Request("vector-blank-hash", string.Empty, string.Empty, 100)); + AddAnswer("answer_whitespace_hash", Request("vector-space-hash", " ", string.Empty, 100)); + AddAnswer("answer_prefix_only_hash", Request("vector-prefix-hash", "sha256: ", string.Empty, 100)); + AddAnswer("answer_negative_size", Request("vector-negative", last.ChunkHash, key, -1)); + AddAnswer("answer_record_key_case", Request("vector-key-case", last.ChunkHash, key.ToUpperInvariant(), last.SizeBytes)); + AddAnswer("answer_blank_record_key", Request("vector-key-blank", last.ChunkHash, " ", last.SizeBytes)); + AddAnswer("answer_double_prefix", Request("vector-double", "sha256:sha256:" + last.ChunkHash, string.Empty, last.SizeBytes)); AddResponse("response_hash_last_digit", new CultMeshContentChunkResponseMessage { MessageId = "vector-digit", @@ -184,6 +194,9 @@ public sealed class CultMeshContentVectorTests var shuffled = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest; shuffled.Chunks = new[] { 3, 0, 5, 1, 4, 2 }.Select(i => shuffled.Chunks[i]).ToArray(); AddManifest("manifest_out_of_order", shuffled); + var tagged = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest; + tagged.Metadata = new Dictionary { ["zeta"] = "1", ["alpha"] = "2", ["mid"] = "3" }; + AddManifest("manifest_metadata_order", tagged); AddManifest("manifest_empty_body", CultMeshCdn.PackArtifact(ArtifactId, Array.Empty(), Options()).Manifest); return vectors; } From dc9162f85ddc7162473d62ddf0c27db88352fcb9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:12:44 +0200 Subject: [PATCH 10/21] Correct the content-plane parity claim for metadata order and bad requests Co-Authored-By: Claude Sonnet 5 --- docs/runtime-parity-scope.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 109a287f..6710c2dd 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -74,8 +74,10 @@ to speak CultNet or participate in CultMesh. semantic ones. A single rows-to-bytes path driving both halves in one call remains its own cut (R-AR) and is not part of this claim. - CultMesh content plane (`cultmesh.content_chunk_request.v1` and `.response.v1` - plus the CDN manifest, C#/Rust only): byte parity in both directions, and the - reference's answer to a chunk request, including its failure spellings. + plus the CDN manifest, C#/Rust only): byte parity in both directions (manifest metadata keeps its wire key order and a + duplicate key is refused, as the reference refuses it), and the reference's answer + to a chunk request, including its failure spellings: every bad request is + answered `found: false`, never refused at decode. `packages/cultnet-rs/tests/content.rs` and `tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs` judge `contracts/cultmesh/content-vectors.cs-written.json` and From a7eb9967e9541c42f10c8217d9e8710b76765bb4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:59:59 +0200 Subject: [PATCH 11/21] Pin the metadata decode message so the last content.rs mutant is caught Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-rs/tests/content.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/cultnet-rs/tests/content.rs b/packages/cultnet-rs/tests/content.rs index e4edb0bf..b36ae5f4 100644 --- a/packages/cultnet-rs/tests/content.rs +++ b/packages/cultnet-rs/tests/content.rs @@ -628,6 +628,17 @@ fn manifest_metadata_keeps_wire_order_and_refuses_a_duplicate_key() { assert!(rmp_serde::from_slice::(&duplicated).is_err()); let unique = manifest_with_metadata(&base, &[("k", "1"), ("other", "2")]); assert_eq!(manifest_bytes(&decode_manifest(&unique)), unique); + + // Metadata that is not a map is refused, and says what was expected. + let mut value = rmpv::decode::read_value(&mut manifest_bytes(&base).as_slice()).expect("a msgpack value"); + let rmpv::Value::Array(fields) = &mut value else { + panic!("the manifest is a positional array"); + }; + fields[9] = rmpv::Value::from(5); + let mut not_a_map = Vec::new(); + rmpv::encode::write_value(&mut not_a_map, &value).expect("writes"); + let error = rmp_serde::from_slice::(¬_a_map).expect_err("not a map"); + assert!(error.to_string().contains("a map of strings"), "{error}"); } // F4: hashes are settled before the first request. From 3c9ce3823ce7039db84947ae3c3fecfd52ba9fa0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:05:12 +0200 Subject: [PATCH 12/21] cultnet-rs fixture example: add a lease-bound stateful Idunn set Adds `service-stateful` (streampixels-service shape, state lineage set, write_lease_required) with a Rust-issued lease.cc, warming/active/degraded vectors bound to it, and two wrong leases (other warming digest, other target). Each vector still passes authenticate + correlate before it is written. Co-Authored-By: Claude Sonnet 5 --- .../examples/idunn_runtime_fixture.rs | 129 ++++++++++++++---- .../test/fixtures/idunn-runtime/README.md | 8 +- 2 files changed, 111 insertions(+), 26 deletions(-) diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs index d6b432a6..57364c10 100644 --- a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -11,6 +11,13 @@ //! disagreement). Ed25519 is deterministic, so the TypeScript signer must //! reproduce each vector byte for byte from the same fields. //! +//! The `service-stateful` set is the lease-bound shape: Expected carries a state +//! lineage and requires a write lease, and Rust issues `lease.cc` naming the +//! warming vector's digest. Its warming vector is sequence 1; the active and +//! degraded vectors are sequence 2 and bind to that lease. Two further leases are +//! deliberately wrong (`lease-other-warming.cc` names a different warming digest, +//! `lease-other-target.cc` names another target) for the TypeScript refusal tests. +//! //! Linux only: the provider identity is bound to `/etc/machine-id`, and the //! generated `machine-id` file records the value the identities were bound to. //! @@ -26,9 +33,10 @@ use cultcache_rs::{ use cultnet_rs::{ GameCultProviderHealthIdentity, GameCultRuntimeCapability, GameCultRuntimePresenceHealthPurpose, GameCultRuntimePresenceHealthRecord, - IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_RUNTIME_ACTIVATION_SCHEMA, - IdunnExpectedCapability, IdunnExpectedDependency, IdunnExpectedIncarnationRecord, - IdunnExpectedRoute, IdunnRuntimeActivationLaunch, IdunnRuntimeActivationRecord, + IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, IdunnExpectedCapability, IdunnExpectedDependency, + IdunnExpectedIncarnationRecord, IdunnExpectedRoute, IdunnProcessWriteLeaseRecord, + IdunnRuntimeActivationLaunch, IdunnRuntimeActivationRecord, IdunnRuntimeActivationSigner, IdunnServiceIdentity, RuntimePresenceAuthenticationContext, authenticate_runtime_presence_claim, correlate_runtime_presence_claim, enroll_service_identity_at, verify_runtime_authority, @@ -49,9 +57,10 @@ struct Set { candidate: &'static str, minimum_capacity: u32, odin_dependency: bool, + stateful: bool, } -const SETS: [Set; 3] = [ +const SETS: [Set; 4] = [ Set { dir: "web", target: "streampixels-web", @@ -60,6 +69,7 @@ const SETS: [Set; 3] = [ candidate: "http://127.0.0.1:18830", minimum_capacity: 1, odin_dependency: false, + stateful: false, }, Set { dir: "service", @@ -69,6 +79,17 @@ const SETS: [Set; 3] = [ candidate: "tcp://127.0.0.1:18831", minimum_capacity: 2, odin_dependency: true, + stateful: false, + }, + Set { + dir: "service-stateful", + target: "streampixels-service", + transport: "tcp", + stable: "tcp://127.0.0.1:8833", + candidate: "tcp://127.0.0.1:18833", + minimum_capacity: 2, + odin_dependency: true, + stateful: true, }, Set { dir: "rudp-route", @@ -78,6 +99,7 @@ const SETS: [Set; 3] = [ candidate: "rudp://127.0.0.1:18832", minimum_capacity: 1, odin_dependency: false, + stateful: false, }, ]; @@ -131,9 +153,9 @@ fn build( expected_signer_identity_id: provider_anchor.identity_id.clone(), health_contract: format!("{}.runtime-health", set.target), artifact_sha256: digest('4'), - state_schema_generation: None, - state_contract_sha256: None, - write_lease_required: false, + state_schema_generation: set.stateful.then(|| "streampixels-state-v1".to_string()), + state_contract_sha256: set.stateful.then(|| digest('7')), + write_lease_required: set.stateful, route: Some(IdunnExpectedRoute { route_id: format!("{}-route", set.target), transport: set.transport.into(), @@ -189,20 +211,13 @@ fn build( let authority = verify_runtime_authority(&expected, &activation, idunn_anchor, &provider_anchor.public_key)?; - let vectors = [ - ("presence-active.bin", "active", set.minimum_capacity, None), - ("presence-warming.bin", "warming", set.minimum_capacity, None), - ( - "presence-active-below-minimum.bin", - "active", - set.minimum_capacity.saturating_sub(1), - Some("expected-capability-000-capacity"), - ), - ]; - for (name, state, capacity, disagreement) in vectors { - if capacity == 0 { - continue; - } + let sign_vector = |name: &str, + state: &str, + capacity: u32, + sequence: u64, + write_lease_sha256: Option, + disagreement: Option<&str>| + -> Result { let mut presence = GameCultRuntimePresenceHealthRecord { schema_version: cultnet_rs::GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), target: expected.target.clone(), @@ -211,8 +226,8 @@ fn build( incarnation_id: expected.incarnation_id.clone(), sealed_release_id: expected.sealed_release_id.clone(), activation_witness_sha256: activation.canonical_sha256()?, - state_schema_generation: None, - state_contract_sha256: None, + state_schema_generation: expected.state_schema_generation.clone(), + state_contract_sha256: expected.state_contract_sha256.clone(), runtime_id: expected.runtime_id.clone(), runtime_instance_id: activation.runtime_instance_id.clone(), bound_endpoint: Some(set.candidate.into()), @@ -225,9 +240,9 @@ fn build( health_contract: expected.health_contract.clone(), state: state.into(), detail: DETAIL.into(), - write_lease_sha256: None, + write_lease_sha256, signer_identity_id: provider_anchor.identity_id.clone(), - publisher_sequence: SEQUENCE, + publisher_sequence: sequence, observed_at_unix_millis: OBSERVED_AT, signature_algorithm: "ed25519".into(), signature: Vec::new(), @@ -250,6 +265,7 @@ fn build( maximum_future_skew_millis: 0, }, )?; + let presence_sha256 = claim.signed_presence_sha256().to_string(); let correlation = correlate_runtime_presence_claim(claim, &authority)?; let codes: Vec<&str> = correlation .disagreements() @@ -261,6 +277,69 @@ fn build( Some(code) => ensure!(codes == [code], "{name}: expected only {code}, got {codes:?}"), } fs::write(dir.join(name), bytes)?; + Ok(presence_sha256) + }; + + if !set.stateful { + sign_vector("presence-active.bin", "active", set.minimum_capacity, SEQUENCE, None, None)?; + sign_vector("presence-warming.bin", "warming", set.minimum_capacity, SEQUENCE, None, None)?; + if set.minimum_capacity > 1 { + sign_vector( + "presence-active-below-minimum.bin", + "active", + set.minimum_capacity - 1, + SEQUENCE, + None, + Some("expected-capability-000-capacity"), + )?; + } + return Ok(()); + } + + // Lease-bound set. The warming vector comes first because the lease names it. + let warming_sha256 = + sign_vector("presence-warming.bin", "warming", set.minimum_capacity, SEQUENCE, None, None)?; + let activation_sha256 = activation.canonical_sha256()?; + let lease_for = |target: &str, warming_presence_sha256: &str| IdunnProcessWriteLeaseRecord { + schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), + target: target.into(), + expected_projection_sha256: activation.expected_projection_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: activation_sha256.clone(), + state_schema_generation: expected.state_schema_generation.clone().unwrap_or_default(), + state_contract_sha256: expected.state_contract_sha256.clone().unwrap_or_default(), + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: activation.runtime_instance_id.clone(), + warming_presence_sha256: warming_presence_sha256.into(), + lease_epoch: 1, + issued_at_unix_millis: OBSERVED_AT - 1_000, + }; + let lease = lease_for(set.target, &warming_sha256); + let other_warming = lease_for(set.target, &digest('d')); + let other_target = lease_for("streampixels-web", &warming_sha256); + for (name, record) in [ + ("lease.cc", &lease), + ("lease-other-warming.cc", &other_warming), + ("lease-other-target.cc", &other_target), + ] { + let bytes = record.canonical_bytes()?; + ensure!( + IdunnProcessWriteLeaseRecord::decode_canonical(&bytes)? == *record, + "{name}: lease does not round-trip" + ); + write_record( + &dir.join(name), + &record.target, + IdunnProcessWriteLeaseRecord::TYPE, + IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + bytes, + )?; + } + let lease_sha256 = lease.canonical_sha256()?; + for (name, state) in [("presence-active.bin", "active"), ("presence-degraded.bin", "degraded")] { + sign_vector(name, state, set.minimum_capacity, SEQUENCE + 1, Some(lease_sha256.clone()), None)?; } Ok(()) } diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md index de1ccea4..dd683a16 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -8,8 +8,14 @@ provider identity is bound to it. On Yggdrasil: DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out' -then copy `/tmp/out` over this directory. Every `presence-*.bin` was accepted by +then copy `/tmp/out` over this directory. `--cap-add SYS_ADMIN` exists only so the bind mount can present the committed `machine-id`, which keeps Yggdrasil's real machine-id out of the repo. Every `presence-*.bin` was accepted by `authenticate_runtime_presence_claim` and correlated by `correlate_runtime_presence_claim` before being written; the `below-minimum` vector correlates to exactly `expected-capability-000-capacity`. Ed25519 is deterministic, so the TypeScript signer must reproduce them byte for byte. + +`service-stateful` is the lease-bound shape (`streampixels-service`, state lineage set, +`write_lease_required: true`). Rust issues `lease.cc`, which names the digest of its +`presence-warming.bin`; `presence-active.bin` and `presence-degraded.bin` are sequence 2 and +carry that lease's digest. `lease-other-warming.cc` names a different warming digest and +`lease-other-target.cc` names another target; the TypeScript lease check must refuse both. From 4ec75eec18976f57a08082cb633f7088cb5a05c2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:10:55 +0200 Subject: [PATCH 13/21] cultnet-ts: regenerate Idunn runtime fixtures with the stateful lease-bound set Output of the Rust generator (3c9ce38) on Yggdrasil. Every set is re-enrolled, so all bytes change; adds service-stateful with lease.cc, the two wrong leases and the warming/active/degraded vectors. Co-Authored-By: Claude Sonnet 5 --- .../rudp-route/activation-key.seed | 2 +- .../idunn-runtime/rudp-route/activation.cc | Bin 800 -> 800 bytes .../idunn-runtime/rudp-route/expected.cc | Bin 1076 -> 1076 bytes .../rudp-route/presence-active.bin | Bin 922 -> 922 bytes .../rudp-route/presence-warming.bin | Bin 923 -> 923 bytes .../rudp-route/provider-identity.credential | Bin 688 -> 671 bytes .../service-stateful/activation-key.seed | 1 + .../service-stateful/activation.cc | Bin 0 -> 806 bytes .../service-stateful/expected.cc | Bin 0 -> 1401 bytes .../service-stateful/lease-other-target.cc | Bin 0 -> 1012 bytes .../service-stateful/lease-other-warming.cc | Bin 0 -> 1020 bytes .../idunn-runtime/service-stateful/lease.cc | Bin 0 -> 1020 bytes .../service-stateful/presence-active.bin | Bin 0 -> 1105 bytes .../service-stateful/presence-degraded.bin | Bin 0 -> 1107 bytes .../service-stateful/presence-warming.bin | Bin 0 -> 1034 bytes .../provider-identity.credential | Bin 0 -> 680 bytes .../idunn-runtime/service/activation-key.seed | 2 +- .../idunn-runtime/service/activation.cc | Bin 806 -> 806 bytes .../idunn-runtime/service/expected.cc | Bin 1308 -> 1308 bytes .../service/presence-active-below-minimum.bin | Bin 940 -> 940 bytes .../idunn-runtime/service/presence-active.bin | Bin 940 -> 940 bytes .../service/presence-warming.bin | Bin 941 -> 941 bytes .../service/provider-identity.credential | Bin 673 -> 672 bytes .../idunn-runtime/web/activation-key.seed | 3 +-- .../fixtures/idunn-runtime/web/activation.cc | Bin 798 -> 798 bytes .../fixtures/idunn-runtime/web/expected.cc | Bin 1067 -> 1067 bytes .../idunn-runtime/web/presence-active.bin | Bin 915 -> 915 bytes .../idunn-runtime/web/presence-warming.bin | Bin 916 -> 916 bytes .../web/provider-identity.credential | Bin 676 -> 682 bytes 29 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed index 1ae250bc..b0f0159c 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed @@ -1 +1 @@ -v$ñ´ØÃ/Š ¼Èá|Æuö«g4FÜK%k±€~qû \ No newline at end of file +¢_Î#Ÿ/ "Q0\ûiCâ]\Ê�¦‚†ó;ÛY¨ø„k \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc index 3676c985fb95c7105989d04f20473d4644699db5..57e2c86029f18c42beddce4b50be125380e8fcfc 100644 GIT binary patch delta 324 zcmWN|Jt)Ls9Ki9rQv^|5mpaKnxm~PuI=P?ucKtW=nn)z$Mxj{x${OK;AOS-p zFk>~45|F@L<0VHy1?TvR#c2gs}xOzCgbM^c_d7E!G zJGJ4x_UBXT9CA}`aN&9>wft8*3y(){v(?d&mHVFWq1}n&y-I8PB0k+pR38R<{{cce BZk7N5 delta 324 zcmWN}FGxc{9Ki7{xZ)FwD4I;uhYI(*|L5ODK{m7uEyKHecL<7LTN9>fC?Xa`g*9ys z7IYRFyfM_Utid2^wg?u%*S_Jyx0b18>Xq~Y0|1M-6_{$I!8m7#YME6W6DM)!z2V+|X@M+z%VapZ+^vm@FMWSJqn7J7IrMH#_s_k}yVK|0X BZY%%* diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc index 040ea0f69b70a5dcd3de806d7bc62e3d2000f294..878c090bc8ed0450e3418b0897d15ba3e4eddb71 100644 GIT binary patch delta 76 zcmWN>u@QhE3mewscsnj8f1fl-d7w=T?U? b$AnO`snc9~T%8D1y9O`-Q!O5HpXvPoI6W3X delta 76 zcmV~$yAgmO3;@uA^Nw*sh>&zP4nlqlxrhaOxq|QQy6bzCD=-F6x9G6g8;Y}bnA!w^ aI8D5FR0zs(8o^u~Z-rDiWw1do6+3P!7Pj?q$lF<0xW%}1qxgdpuQ0;DQT0H zF<^+v+00f-lFDKx^8b<5w(M%%qNpu(CMJvJoWL0AJN07i%b81Kts8@Dd-pG{w(pMQ ziAQc~;Qh~se=mR7IQ!>XV`KP0_ttt_znX9KKD`{<_51tmzVYVA*H^|DzFvJk(5puw z!5QO)1EX!u8WRS}j1oXqSFuyUgLYLR8cr#OOc*L-XFZZJo%+z+p?hyXKOQaCw-3%< z9NM~m{Qi$q&!+E;bY~6^(utmZ`sDm(bI5nL~a`)De0d3JcnEixkwjA1O;K6`#6^tMPOhr$iiq5Sq(u8 zv78`^Aead^f}5CT(Mv3(K&TdhOQRrHh4gRr`xd{&$2Z+I-F0_x*Z@EjHae-j@DP}T zLhF@8LMl*1&VVS`C6banfrhAL@Dgkwr&kW0JLRRZ{q_2RdSgrd{a7su$;4o@lRhys zgs8H?kTrTHNNh7gmYB$^9DFe;>HwM9VE~=xu9P>ICs?vYmm(|SF@dCnK?xUtq|lfn zB~@q$*4dh=bs;Irc~Ycdthm8a>%Z-Zq3RR=nIBA@nOk0c)moh2d1vfRWu<1= zQ#8(KTLj5A1nsg%iv^hf{>l}>G*Pt00YMlSHTYCyjDbYTom$`I)@{%BzwN)j*xUSc zu+_3JdKXqNZ@<|yyy<84?2q>Cj~kWdpPud$zh*|iT(~zo`*QCi{5rU?dpuMc*DhV} F{s*O)pYH$w diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin index ad4c0d9f1d15e506031aba710fe9223d3f672f0a..246e99cb2b4f3d875d4b9ab2a7e3c52a0151c603 100644 GIT binary patch delta 442 zcmWlVOGuSb6ou~}TpcuUkdXyaLc@e}_Id4t6h8@O1U)7Of#liyoKum5N)SR!ND`gI zy--9r5qby_7!?K8$nK;FDuPI87Bz@QH^Lh9_?xWJf^U6mv2(GrF}R;{vL4V%QsAOc zAiYLsm6sVv37ZXC(@Y>6LSDC)%wD+MG3X_pWnR$(G&)rd27scv8BM@+ zTFF9!GsX)CM%$D$MhuiPN&r<|m7NM6w5tkia*P}jVW^Cq^+?9F%EtHlryINbwyvIj zIDLEa<NHt8uZ|OpZ9qYF|dC_4GIpo)Zn2l zx*RTP&{PnK2*QDR22FuNQA0yRoJ2?k4gH$FpW!=v_^b@93@p^I7yuw)$w?hVfXEz` zw?Rpd9!nI&8N3(l5=w~yK?5lng9IDV>4VGnCWAC~q}{&Q?o`|F?$$_1Mh2Um42hW` zlFG)%tbJgF#5Q}#(kBcm$52ip9U(J2A3>*=vqiO-W69RLM65*c2_z;Tm2eSI3hgst zQl*Aqovk4)TwvBQCq&dDlceH(rBZad*QYDbI7wpwyR}ERf1l^x$rm!7pw9f^*$eCY z%f}-Q4V~)^|5=^cSsY&5d(ynqYOGGp-Q1WO9DV%dQ)8$3c31AI=I4iv;-F?YAQ@-0 zErVnmqjouX>q{{I{go?&X+*N+!Gkc4G=x-Uq=+KrZf$Ytet+hO&ir&w)wS`zSpT*) zzkDOV9y>Mu^~1>I#OT=7V;g&eM@y$)ZMOKq_mBO4uYPOc`K8vI?fuzbKdS!#kwBg{ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential index 8e1849bed2cbbf500423c8ae70cbb54bb1d8aff1..d8026bb447ee01203b4b6a3a9e1cf3ef1984f771 100644 GIT binary patch delta 285 zcmZ9EJxBs!9EVk~MM5a3poT&lM3n#gzmNA$#A*s7qQM-R?%unLS{rM)p$HNJ!nYXIbP2@;x$%XI!l&ujdQSac8@r{k^cLRAE6X^pCg<@LA1)IflS2;bxEn`p6Q4!Y U+*XQ-%^cY|wk!01+1QR5e|nN~TmS$7 delta 323 zcmZw9JxIe)7=>Z26dfHMD{U8>)Gs%;X>Jz<2mf&EEHueYMeQIc6hx{nI*5M(2f>PN z4$@AFh=b^O1lvJ~=wfkk5mXAgXu3O|7tV7Su79ih&BZ!VN9`Gb;4!%RE2)G22= z8WWCb>6ErK!HAo#EgW5E7D?_JDVtL|CndLBM+m{mwVD0Y7EvbqCt_GdgHPi$K?!{0 z3!7LT!N&l$;04=bI6`F@SGX^t7JN`atvjmZu|BjE$DN8ZRMG4~b9`p1h!fo66ZJ9N z;2Jgj;Jg=Qudn^NL>c0ll#$d8&E!loC_zD@mbifn)Ukqc9D8_$AL+s|4hnb)>JD~s sX`z8N{ATgysH0Il;sNh|lo2yj5-Jnf7WJR*EBRbO+6%Mu-eM^B2gKHq3jhEB diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed new file mode 100644 index 00000000..491dbddb --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed @@ -0,0 +1 @@ +KýaŸz´-2‰Ë¡V>)ë»xìºÚÏ®¢T =/‰* \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc new file mode 100644 index 0000000000000000000000000000000000000000..3d670ab64a880f82aa8a23f590cbf40a670e3be8 GIT binary patch literal 806 zcmbu5J!n%=6vq=090VO46kH@Kh?sEieeZr{$|hY(1(ncJ&UiStxD2vWl;hG!`geKn=-!JDGN2k*nMJOwW90E*3W4Ut%fMBbQ3mkV`b4<9Af@;JG z4}oJGf(#Ht99xDcRfhqn{qxv| z;p(^NmxqNy@#$cqJ~ygqzb_JqYeYHGMsgqFh*iKu5Mt063j!8Qz?juogh4>D#MqzB zLsDa2fF;y_4Qe;17q@SX6{9EphmGCK2N&+VTG_m@b-P}xcZTEpulBaS9A2B^h3n7Q b+R<~H!$$|soY?++?|Z|1-9Gtd_;K$qI`kv; literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc new file mode 100644 index 0000000000000000000000000000000000000000..8e96b29b011a97ffb9d2abad505584f2de0ea0ce GIT binary patch literal 1401 zcmbtU&5G1O5LWTx)hAfu#e<#c&R=J85Aw9UOK*@VM&( zi0&-QieAKr2>3Ae%u3vm%qZkb=TbjkRabS@(I?*Inlqk(U2CbpZpr-JSCKSjX~RJU zTto6wl$@!OX(3CybuPARyS?o?<7vouQ+MTSt2Vh5>>(?_imaV<*1g}xPeIitw}082 z@W!QpA~yC`^!&w}`R;Vy&KIY(R=|o%96(;98dNJd?Djf$uoECm%z1)wv_3Y*_}Pz} zw|-u`z1w*(UbkOo%<%(6MxwKZt~0sPUqt#~&Pa57GoiC4wz(`O`=*}{%u|~@Uf1){ zx}M!#_MhmJa!2CZPyx{4Y?dfii@dv^`W#QW8w50T!Q(XHNytObY}J%n6o4{dxz76Qcq6gg45pa|)c$E99f|LYi_HL+ z?;k1IXgJZlIyHYZnGlEC*nA`kL)TsYEhH=Hd~rU+WwT0r*`_cPd)$&6^Y=?L016T$ z%GC5ptA^`F!BQr|Y$P+yfE1JoylQ1rpIwn^S;<_^4ljwWe~ckDOAb{^7IL-DKCEm% a7p!EnHG9A3`VRRcai*GNvqT;ZDftC?QqFb& literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc new file mode 100644 index 0000000000000000000000000000000000000000..9f99abd088dd3391e03cdc3212121cea2fc80037 GIT binary patch literal 1012 zcmbtQ%Ze015Zv`EJoniiGwazkKj2L~hzCWMtg5V?hVGuFt9Mor|G`1QoA?PL2rGzQ zcF&&YZ)_j546^NEg1BWyWMsVl7|xp^P+0LI59^*68{EGAQpfYITb%Xlz&W2>^!30e z4O9L({(R%`+l`Y^HgeW>*Ssk57^9{8)N-*b=FEyae|8^npX+t^pY`$fsA#$M<9e}t z^7Q3)Hp{E+n-6*DncB1Z88^ASU_UFk6d+6qIPxLD@)s8X@c8=H_T8-VpF6vIc|}5- zQdyR$RfsmFz$`HOkYuvSdW*saS7{GcSj84mv6EoHVl<#Nk#=G-!UM4@_PWU2Z|cBz z>n>2=(Jq&R`~tPOit5ra#Wbe1&+f)o{10ZfWAk&DKA6pYeX z{t=ex9Ei!t8%$P+=%WKeF4zi9!pcQps(^(g gQE5=5v_VBAiINC{jnOia1~~qHbaedw>cQ&v4|jw^VE_OC literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc new file mode 100644 index 0000000000000000000000000000000000000000..cc21e54c91719e55e58befe66fc9b3e5e93c2f2a GIT binary patch literal 1020 zcmbtS%SyvQ6m9(q*IAo*XOhe${Q-C4LR=_P=5ePDBx%W{RT2NeqT){c22oHD-RRnN z|Hk+rQfT8Mhr2uXJmzt6rzUBsgc_n}%Ta;el+3Q56FtduZ(NKNTH8M=k`ntV3j0Mr ztt4MpGL@{FM%#IM>e#AACbmU}PS0sFb2ff=_pq?ZDF0`@%B~a{X0lp$dIyJRv&Jsx zv&(y17AUfDa*U~UEf&*6VWa4g0%b0cYIXs7-{S$m{`=b1O`~#Y)_ltj1r3=SaP&zS zD9w#gh?HoljBmK%k`u~R9EB1%uMhC+lft&K=a%$;R-vYF&c6uBs? zXK+a!Mq8pb0xBi=sEi5F0GAjtlyM{wViWl>$B-xiMh7BNz>M~dh&7XvP$WL%_^?>I r6eYUTzn;7$UKZ_t{)u}s80bRSB(0CZTB0`6Ez$kxbh@{1JHz!)1eH#i literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc new file mode 100644 index 0000000000000000000000000000000000000000..b387f6e67d284a14f33e3cc85be640bccb5a74c3 GIT binary patch literal 1020 zcmbtS%W4!s6ixgJ*S)qU_tvv(ejqz>AubdNx2kSs8oGNzS7%hje{fK7Cw_w{D2Q%! z?Rx&kRzpi5=`7}Ocjul*JzjkX=gkl(tay=!b~z3y&UAG1eA%dH>Ri{+!o zFSfH?UT$B1%tO!Ap4Cse$z|q#QwN?EeHI{02{_JufaTv=0Kmhax8A&)RbH93JI*T- z+LX$&M6E)!DFtSM(T60HP1ai!Hn>WAu)->~h>D#A0~VtJt%|}`2NSe)$QNC+eXs> literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin new file mode 100644 index 0000000000000000000000000000000000000000..b1c33139c5ba7d8e8cb1d71bd3e64614386b23b7 GIT binary patch literal 1105 zcmbtTPe_zO990hm5;q^oB7RnsEq=#E}e^z`SY9Ig>{wP&$YnJ=+Lc5 zJb4li1)jo^f*>l)o7%yMg)#^tLV~)aVMw1ETY{bT4a{L?c<;@;H}4wSs_j|Ri*BG0 zr%MCHINyss{Ut18(P2*xO(D*uE9_A@E}`k|&tJhpIaS6|CGW7>Rn8eEWGVvWR7B20 z2o8}^Z(ZO40$897oL0gTU=Sft8lmPy5};lZA&e24)re@Fs2<(TXGgy1OsQz%d|xp| zYhtWU)QAYPmT+_-L>LHI6cVjg28j-eYd}GrA;CvRIUDd!8dYsfk=6TAM-j*cYoG)V^Sq)KsqckIb-`f1ajfBL_T zbcIe%kh1*`3)zJcW3`s`*ox1#RcUU4~7C}1&*|3n|Ga;T{1 z$z*~8F2FkGgaK|ji%$a%W+&ab@ao^S6zUA3iTkA74sMrJH|}8>54-=jIQIYhA;W-$$Bn z?yY8o5nF{*5}1ZagYqE+k33>p5u>dRm>8yLHrvU9mD+d#AZ7ADiE*=;p|Q1tui9Yc z&G^RtUoXy|+_QMr4Zgp9W^HtOJv0AlspZn@!(+>>!{cl#5TTPYkKgPr?Ti1eYepU-Iz|W< z^uh?CD0&qDp4Ul@N5klsyBva=Z*-HEW@MyqwX5@pvGBVis5@ zyte=ZJ7Ycd!V4oX5mrfNkRT!efpGweB8~)h95JRfQ=~zJn?${9GoQ(9!RoM}E7@Wp zjvHd6Ni>KMF$Phv-Ur~3Fu*t%B{d-BB~=0#wiMXu6^+ z5U>9ADVoIdjr8l;#mCZV7wU30*SH3660;Rs>P;r2z!NAMxED)F>BQvI&GZwxlzsNU zjYJjSsp7VJY|{gOS{h$3=RF7MP&;t-SXdm1Uan|X>$w(o@A`_(=v)p8Y4|66Z=?l@ z9gQaKrJzh0ODxlZ8cG6S3}fP=n2>Nnc&Zo}EV+@?SmPwFr#!PrS<1PP6ap)pb<)Zp zu%5^$aRNqa!MMlYlKcD4 z?MMu^*HcWhwaQ5@h!TMk(s}P4(10k3v@*&=WEg{9?I-buEA5yNoJT(tX)CSl!=Zr_`v$HJ&F>xE O*)cu3>&L-~!M5LP#AciT literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin new file mode 100644 index 0000000000000000000000000000000000000000..9634ec21965c0a46f18c63457cbc5d5a20ec5512 GIT binary patch literal 1034 zcmbtTJ!n%=7)_j{6vRytiX?;6OYXhz-M=hmQK9}-I@s2s+c2)Z~( zJJizRAZoQ}9n_&%D2O5$TognqVn7h>BseJQq7J65L<)7v`L=sM&Ue0h&V85I)k+ss z8BDDdXQEmd2W1$oL{Np$z-SRvDK2K}iA|IZ)>HuZ1|f}XCs^o<0>i#VWE|d#%k?{8`Ep)T=zP+abk+9RD!So4t~?y`K8%?J1h_^?8n bzppnY_uuBPZ+-7sSs2{&13f!-ZKU@PdMZ+@ literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential new file mode 100644 index 0000000000000000000000000000000000000000..3429906f256d59afca8712a91eb1360581602e80 GIT binary patch literal 680 zcmb7?yK59t6vo%i!XkxY5d@!w##!#2$9+ttTf|z_Mu@|mJNNDmOlBlIJ6Z8hkf0%I zkwP(QA*h9wWtv#XR|ysqjUZ?WwGaeby%M(+OmjGg^L>YNew^hy?UBBkH+odm-qnSw z$uqOy4U8)4f#>qF&g*OK>4~H}kt92kWFm2myiv93Hgsdpuhrc-+A}uNMCyPsE&>y3 zp6N_z5r*3O!bH?JsZcy(Tq!1HN=cN4RuQgwC;~w(f8RNbWh@=X3p}2}`boUR{R?wF zZ1-pIqhwhkT& z@W8dUuz6}9wy{#W+wAA`qTFN7_*3j0g zhDlxyYgH7g&Z}}~)3h3uS)z-oG%9tQr)p^2A*Ma%6Q6lRdcM3!XiP~=f^H}T3ArTU z3~@F2!}lv5jj)Pm4Q}9dCqF+shbNc!Ps�Õ8ÑÇ£rÆyÜà�&—ž@‚â± ç�ÜÌqM}‰ \ No newline at end of file +ö�„Bf®Õ=?ÞÀ�Z¹jÇâÏ&ŠÜæ3¦õÜŸf…mJ \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc index b633e64c21e43688fba5f0946933b20b0da03d1c..3fa6a1f3f4885dd988a9625af676acb4509d69ff 100644 GIT binary patch delta 325 zcmWN~zbk}c902fp{kXqCY+?|@rRVuRzrN2vH_2eQ(W35sp7;9>N=aRv=}P%ANr=U8 zhC4qdw@68jUa}bsSPlcppeu?=Zv8BuYPy;}S`4xdEQ__aC@lwXKsyx~h)^>Xq7dLU zmyUSMypjYy^c);Wt&IT5Z7epfg6WbZO@QP*a*tMfO9akijLdQ;1tYYI2}KNqlK>z> z2BZZu=v}Nsk@THRH0%3a+xO4qyPn2o=R@Uupxb6XYr~Do*?8u4<~EfI%1Lgr_?UH) z3`1rdQ>L|8!-|H*5QU^sNKTG(1d7NDts}=#3Zb~=+{8wqP~PuR1gb?z_HyBE<@$p< z4wjw{Ywd&YatDv+wx;TZw)w)y+StqQ_h~x!l}}2|zx8~mYZtxC{Wm{DtFxoU!Y+lu EKdLKpKmY&$ delta 325 zcmWlTze|Ea9EWug9HeloDZ#Pi?%w;}dj|~?($Ex$R8!oK?+p$SM&J_B;A%rO1`VPa z4$%}SEg8~KER5V#VQ84AKEqCBDmrKM+<;mn{ zgwkPD?1^EzDrrDv0Fm4qV3zsF>O3{l2#QQwi-G49xg&-f)PDnz5;ok(sfHxsj=*nQ=;5vT<^%k&%f>no**;xq-2X msY$A_QA%=Ra&n@DfpLX6kT1(&V+K&A>aB;4Dk@`XOU2#(86ew zAGs(hN(gJ=GKi!?=(P&VhyoXi&>~vsI*_t&cQyyknQfbGn;aMl;6&EiS#lECfDS4e zh*k^bEh4a%6q3w>6(wNL5(1Fc27#o@xODXTWe-XpD2m=yKXRTAil@}?3Qqr~R{k2_hY207gw|pYh)_-(1 z*1FG5F5a8|;I7_jPN)m*6CaD!k&_);xA%Q z(g|#0wvw&W>{5cjHe2CjAS{=h5~z}Z@)rQm3rHG;u^h7Y*s2e0oW68%?ndorWBK>m z;hELv?{Iy}!GnM24&83P?qB)V*u5|~x99G|mCwgI2G49X&)+&Tm~p2hKNQ%^qi Gw*LpWt(hhO delta 444 zcmWlUJ808U6o(U9=pa4@2OoQvBHY)x_nbu|Qmu4Qs%wS&I2T{IC^k!pLs0~wMzJ^u zx`>L!K?zRLf&mRg+6o0D5)4@CP!ybe7XSX%?*|{>{LuW+^@E3Ri88Zt7DxfB? zEYVsY*+f>-11ph2445<8g0O+Cm_b2+?xFK1y#l+ZUf)}PTRp%+r-WKL3C=_ifjsz# z=mmKVkdlXFNfizkIAswM7eD36qq9org@qUO^la$?Cj}s>RhhZ^^Hpc9apuUzt-nv! zx4f^8O+0;heBjZ?xz96S+p9O$?k|tL+j)6Y?@yz)GQ8vU!{)Q;^~S}!dH$TAuCmoU;_!t%m_tBR?+xS;P8Jk7!VqqMh1wPQf^gfwAY`V8-KpBbg8{G zS)01@ZG5n|^>P1N|4sGw%J&OXAG(vxQ@c9N(zcP)i~D9j4eUNQoKJKczb1}###Y;d F{{X|HrojLJ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin index 1f13b519925596e9b77619edbbd991d2321f5b30..d28b1349f8a9ebaa5d5536d8b5407273e36d7bde 100644 GIT binary patch delta 444 zcmWNMO-of#6hQ9_%OEI(Y9M8S?+v1obN1fnYw+ShVXrT0+Q`n?_Z%ezgCZ0M(g#6M z)Fp@rtw%%-R0wL&D2-mu!W={xL`d`l9GM{Dovq1&wPrhKJ0JI-3E)K1+F5cE*nkcy zH;`K`l(&e$T2hEI304$=Jxd5cS{nqCF5$}Q>q8!tK3)_jim#(5jppDfNrYrAdlMx| z3o)Y0a}4BS08GfjVhne;-xr4_tFKJM``M{nq^LdS7kz)l_}##>AcJZ5P+@SF^Y0{JX0=J6d1Pep<>_ z8WeQ|8=0+S>omID9J*VzIug!>~9S^JD z_YW*@KKnU3^LpRL!NK|e)!n!H%=RDGCJ%q^zSnhN#TNa2bM;?@MkM%1_6`|dLsCIliP5v@cE!&~kn30g>+6d5Lk*hTUy zVqppqi3q90CLlTk!=T{c0wO^REyczns4#K){o3=v;j9d-3@nbFvVzbUlQLx%3REEN z09g{Ek8C0<>4B9D`ghWxNHFxF$#^w zIU&1zRf;5{B&$_6%QRB zslM2BvD@C*xZXe5|NCkD#_~jMaOS~<^`CD(ylie6i6`do_WEC!?$7O7*qZKqJ~mld ztCw9YoM6Xb97+`=U~s}^W`Z&!t7v>Eari$O3b`+k3kbF#8&r#0nfx%$5M`#vk&SQ%SdDN%|Y zTu_oMcH^X^6gpN;?MjlXaj_dVB_ug=q5STio2Q_m2_1WX=oBd}d zS0~2Dw*3Bbvo~<=LUnQ5=2sd#|L@7*)Q(Rt7F6!N?%WzXG19*BunXUpc0QUqT3u_% zq!Zf2Yz14V*`?){p3HS=SA`or_; EfBcV{pa1{> delta 444 zcmWlUPe_zO7>9Q|D6t)+I(eu=2P=tY-kEpi9TZsztPX*YP*DW)=ba)X3K24b1?#4e zA0CymMW`Sz9rTn4t#lBn2&p9s=@0~UD5R)E^!2+wKX`Z+I~O~r2Txi-Mr)liWfp)C zNI5ht$rvA5M+WJUL8K4^-x?aJJmdt3(zWV4E-ZCC-v4N0;r@?_ z=U3j&f4OOcM>^ZosvzY`{t#ogrwe}2#Jd7gC0CMBsGEK;W{ z*-!?9D;s67u;^pZmAWudEG(2ilgDg)53l!raxYoQCqB0d##-R3D3m^95jTk}E^IPg ziwBY7DkKsjB5B)<3sZ!g0wtA_4&%P#QVb09UhBr&Xc{Lad`#eA4ma4w8NOHW(~H~r z;Sr;C5WCnx4ePkZrHSLzpDCj@iLbVvrDi3uF@ySSS0R@vqS29F&7+PB+@XR;5AP|w zR{IhUf#DgSuAYcs7ml5YUN3ZCNkkY0AOoI9_mC;<}FK#de%a4M#1w W8jBhkVq|5RnAZ9F&=z9jQ4gIcUXAOS`Ru zQrbN_BXW{*dt9Wp4L2ukZsch8==l75`qdMSL}f|)tuC7-Y6-L8NgfE(42fbY6gH=_ z$ce?7Qc~KcU1UDzerTD#FQgU9P%>2@mI#<9-nvGTSu`el*5}dcAH^Qd3~vDKxs{m! z`@`|R!sY5tZWH&o7>nOH$!_)HLyugP*O0!M#WU(Sz&Bpe9KD01MmWxy2Bl(>TgI5gu@j$5aZZ XN!;aIX-X(Jh%%^hJH&sr7Im{f0hn|8 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed index 1f8ff352..7b6d37ff 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed @@ -1,2 +1 @@ -c$…Jp[vcϽ|½Ô@ ž×í†`JÛûd -Í_z3Í3¢ \ No newline at end of file +¯ÂOd×f¯l*oµ‘ÀFªv‰n�hLûÑpšS´p \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc index 00c3c07fe4835b043b3eb018a11aa82dc52faabf..3931baf2d0ad2e084e2704913f368f46605cef11 100644 GIT binary patch delta 325 zcmWN~zbnLH9Kdmx>no@LtU~+2E=M0`~x;u@9DL?%b9Yfx{}H}<{h)1qtp_D0kmjC)ZT0Gh8yNVa)T-g zXO$-FlE_Ii6hvHWrQ`N(DlS_LF(ybFI3a3>DhWn`4vGQ;E~G#UsH`U7T47X-aT8HS zX>>hZ>zt|%^fhg{$Y=Z=Fc}Mw5*EX4bba2RU*FG1UA`29>Cz>|aG#%7%vWa;16?VDn=i$s z7_eE@cc#>lB0m@93>Jf~eE)#gZ$Y1WGI0MXPn^30)P#)#atR;loiH12@(^~ z&IpW|p;T$Cz)SDF`&I2Mjlb3A zRx%@7mkYPwg~liucwMixm+#vjx%X%%`#jB#8kwG<=3-Ykx%P7SG?ncb9$f9a-uo^0 F{sSiua76$B diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc index 748cc83dc11de7479ac6e80fcb4d8d49f79eb59a..dbf7153b5b59ff258a410787f85a7f7695945346 100644 GIT binary patch delta 76 zcmV~$!4-fY2msL6^Wr#(fN-l(0A-=Z@X}mO(f_;d`X1%-IoU>t7%LKElO}0ukbw~k ZbU{mX!tTSH5Q6wnm delta 76 zcmWN{u@S%^2mrv#**Fdg3UtXRfJ~GzY%*7-NWT7EcYTg>+GrvkX`KZPAf}Gw6i;G; Z=wT*EBbu^n$Wol}Pc)w1(fPj0`vEWg6-odA diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin index cefcf1d0f05aedf619ed6d6bb5b67de23d286e83..37edbf6569b2e60ee4819cef5745c6445523a4aa 100644 GIT binary patch delta 441 zcmWlUU1-f=9DvU$b2PhAa>0d0(^?(9zxVfhKYmj>x!_P5xL8VVbXG3#Up?1P&(o^3DmQlR4I&2NBcWyu$pL!w zDRYbr(UB7oG&!_c1#j8-ATt>)P#Htamh-}%lOs+lU2Qf8o9)%jx$&CdY^-obqa}l= zh@`?0fdGYGk$`ZH0kxAR7*r7;I0`Of7F#m zmbW~3`Qpcp?!^;FPIcDozxDX*+kFGQM+cu?Z}fE<&)=PsyT=Z4#i&6!W|y=US>&#OBV(;qM0{`+&o)uoMn^V5sls@vxWs(0(h>O+&)Cbu43S{|-! G>iQ4Da+wtX delta 441 zcmWNNO^D4=7{>oMG8UtdYCS{ha^q#MLg=tobd!yN@$#s)V zsf)%{YRbY`DRG<9j7W_d5lW4f?3lX6**`tc^ZT_2+5Z^k9+bDWcU4vE^v2Iu-4sD&9A|^ZvWxT?mZ)`@1L%|X?$o7Hiy@SEBQm7c(k^2>ErcA!@pX;QV%*R2T`X` zWT6o$YimiNL0JqHZN-*x0z@HH!bB-hFze(M7yN|1`qc8m^Pl_o9zQqp^~~LTc@8%B ztJiODT>M1e`-jH9Eg$L3zMP)lbyn>A{9@tiwjHCV?mf7D=j4{de=bbv`K_C(|AX_J ALI3~& diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin index 375131d00f282c71e7b67679f28c03c0bc167f71..55409b8dfb48737e1978b3c92261076c18e2bded 100644 GIT binary patch delta 441 zcmWlUOGuPa7(nMI%7Qj7q8xBxMnM$a?|%33eNi^JsUV^UXj#GBCxK~5k5weIQ&4bf zU{--3;@TG>(rzH=fr}z=XA3t$n_5f#!dV~AX?3-_<_50^5rgnipzv$95@wpK9{=k2S{9y_Vc`DXKCbN*2C^F&Q>wj`W2NXjB=rKH9X zK>-T0F9G2k0~#l7u&5(Ia4PuV60v~1@Tw>#eT>H0K;#T7Z4?ktcI;#hDug6CNyzNA zLSG6=D@oq6jHx^kWp1%msTA{_TTd!WDN3z@JGF_S&G*z;{We4Ob+xp7VfuID+OIRm zcVDjc9oYHV+c^E{#}QxUQ@i6mlY1lg#)iJHjohw3UA=T}WAE?t%O{R**F2`2C0d64 zH4aEJIRZ{(lYlZ*qX#6<78Da^5<b&NEl;#_qRzvuTnkJYjqqd%0HNHgWU zoXzN>kvT~>I~SWsBPY8^%Iza39G6D3TIAAWtr(`_;(nvCgo<11DIypvkTKFuMH8)$(nv-? zh*n5N+!>$$1}8MjNr6Ut5Z*LC7e!-pc==}0j{%h8ZdmQyo|?F^`s3^K-S_Q-m3PmY z3)7d5Jb_~5ajRJQIk~a^X6@hAZ2!OXXj!-`Z8 z7J`bDWl7CqQpPwTwdC1Q)|PE)%YbDFW|u-Yp7%~Py>=F0RVy7i@= znLl{B`f%>w;!!%*+1eRwr+f3A-;>8@KRmcz^?r3fjg`$ccP;F1zi$7nua1qD{{i1~ BmC67B diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential index 52b1b8e809059949d9b7af89dab19973f0b1e529..2e617139cb114b951bfdc3fca179a02ff872830c 100644 GIT binary patch delta 297 zcmZXOyGjE=07cD8w6d^Ku@WC((CEzU?Ci{bf?yGdAx$9rEG#5JB$7rY(Z*&a3U-1T zBtoh{&`L|u3pNW9P;4x11i?ZFr zt!x&!+;x4Xd`1K17+iNuhf0Gp>6nUnKGy}2f$`ioF^M`J(MsW`i1t!BgU;w`4)-Hy z;s)3Ft|A=6U1fL?Ux+f*crjhVAujN?f|;vzoMC%bSRDHCb6!7U(}Wnq2u6qego}(9F;fmfKR-mSHW)l)f$x2$8Z+F+H=lAqG51a;$QtiG*v9RM;bG5iIHO3t23TiW5 z5F#yZy0*(4*RTyDsT6|ghAA~}J6y6wu3N-2bzxf$)gAk-Z3rJY8z-nHaDhYQQ4H>S z2&nc@EX|aom4uyA{maa z3GAUfQ&4>GIT9mA%(PUEX(rXZ(+cqrX~j+!rsi;m12k|mk47JU@I08o6ZY|f>&VCw tnn44uOLtR^6%@*?h&>uD;gJS>y From 8072314b7c47c20f594d7585250296949299904e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:11:48 +0200 Subject: [PATCH 14/21] cultnet-ts: the Idunn signer owns the reported health and warming proofs The signer holds the last reported health, starting at warming, and gains reportHealth(state). sign(detail), answerRouteObservation(request) and the Odin publisher's publish(detail) no longer take a state, so the route answer and the Odin publish cannot disagree and a fresh signer cannot answer active. Every warming presence the signer signs becomes a valid write-lease target; the publisher's recordPublishedWarming is deleted. Tests now assert every committed stateless vector, dead ones included. Co-Authored-By: Claude Sonnet 5 --- .../src/idunn-odin-presence-publisher.ts | 11 +- .../cultnet-ts/src/idunn-runtime-authority.ts | 42 +++++--- .../test/idunn-runtime-authority.test.ts | 100 ++++++++++++------ 3 files changed, 100 insertions(+), 53 deletions(-) diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts index 8e13acb4..2e992658 100644 --- a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -5,7 +5,6 @@ import { encodeCultNetMessageForWire, parseCultNetMessage } from "./contracts"; import type { IdunnRuntimeSigner } from "./idunn-runtime-authority"; import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; import type { CultNetRudpPacket } from "./rudp"; -import type { RuntimePresenceHealth } from "./runtime-presence-health"; const ODIN_CAPABILITY = "odin.verse-rendezvous"; const MAX_LEASE_WAIT_ROUNDS = 720; @@ -14,7 +13,8 @@ export type IdunnRuntimePresencePublisher = { readonly runtimeId: string; readonly runtimeInstanceId: string; readonly requiresWriteLease: boolean; - publish(state: RuntimePresenceHealth["state"], detail: string): Promise; + /** Publishes the signer's presence in the health the signer reports. */ + publish(detail: string): Promise; waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; assertWriteLease(): Promise; }; @@ -44,15 +44,14 @@ export function createIdunnRuntimePresencePublisher( const connectionId = 0x0d1d0002; let messageSequence = 0; - const publish = async (state: RuntimePresenceHealth["state"], detail: string) => { - const signed = signer.sign(state, detail); + const publish = async (detail: string) => { + const signed = signer.sign(detail); messageSequence += 1; await publishDocument(endpoint, connectionId, { schemaVersion: "cultnet.document_put_raw.v0", messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, document: signed.document, }); - if (state === "warming") signer.recordPublishedWarming(signed.canonicalSha256); return signed.canonicalSha256; }; @@ -65,7 +64,7 @@ export function createIdunnRuntimePresencePublisher( if (!signer.requiresWriteLease) return ""; let warmingSequence = 0; while (!signal?.aborted) { - await publish("warming", "waiting-for-process-write-lease"); + await publish("waiting-for-process-write-lease"); warmingSequence += 1; try { return signer.assertWriteLease(); diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index 47e132b3..bb217ed0 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -82,25 +82,30 @@ export type SignedRuntimePresence = { /** * Signs this launch's runtime presence and answers Idunn's route challenge. It * owns no network: Odin publication, if a service wants it, is a separate - * consumer of `sign`. + * consumer of `sign`. The signer is the single owner of the reported health: + * it starts `warming`, the app moves it with `reportHealth`, and every + * presence it signs, however it is carried, states that health. */ export type IdunnRuntimeSigner = { readonly authority: IdunnRuntimeAuthority; readonly runtimeId: string; readonly runtimeInstanceId: string; readonly requiresWriteLease: boolean; - /** Signs one presence document. Every call takes the next publisher sequence. */ - sign(state: RuntimePresenceHealth["state"], detail: string): SignedRuntimePresence; + /** Records the app's health. Until the first call the signer reports `warming`. */ + reportHealth(state: RuntimePresenceHealth["state"]): void; + /** + * Signs one presence document in the reported health. Every call takes the + * next publisher sequence; every warming presence signed becomes a valid + * write-lease target. + */ + sign(detail: string): SignedRuntimePresence; /** * Answers an Idunn route challenge from local authority alone: signs a fresh - * presence in the given state, with no network. The caller supplies the - * app's current health (`warming` until first healthy). + * presence in the reported health, with no network. */ - answerRouteObservation(request: CultNetSnapshotRequestMessage, state: RuntimePresenceHealth["state"]): CultNetRawDocumentRecord; - /** Reads Idunn's write lease and checks it names this incarnation and a warming proof this process published. */ + answerRouteObservation(request: CultNetSnapshotRequestMessage): CultNetRawDocumentRecord; + /** Reads Idunn's write lease and checks it names this incarnation and a warming presence this signer signed. */ assertWriteLease(): string; - /** Marks a warming presence as accepted by its consumer, making it a valid lease target. */ - recordPublishedWarming(canonicalSha256: string): void; }; export type IdunnRuntimeSignerOptions = { @@ -219,6 +224,7 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id const { authority } = options; const expected = authority.expected; let sequence = 0; + let health: RuntimePresenceHealth["state"] = "warming"; const recentWarmingProofs: string[] = []; const assertWriteLease = () => { @@ -231,7 +237,8 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id return prefixedSha256(Buffer.from(encode(lease))); }; - const sign = (state: RuntimePresenceHealth["state"], detail: string): SignedRuntimePresence => { + const sign = (detail: string): SignedRuntimePresence => { + const state = health; const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : assertWriteLease(); sequence += 1; const signed = signIdunnRuntimePresence(authority, { @@ -242,6 +249,10 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id publisherSequence: sequence, observedAtUnixMillis: Date.now(), }); + if (state === "warming") { + recentWarmingProofs.push(signed.canonicalSha256); + if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); + } return { canonicalSha256: signed.canonicalSha256, document: { @@ -262,8 +273,11 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id runtimeId: expected.runtimeId, runtimeInstanceId: authority.activation.runtimeInstanceId, requiresWriteLease: expected.writeLeaseRequired, + reportHealth(state) { + health = state; + }, sign, - answerRouteObservation(request, state) { + answerRouteObservation(request) { if (!request.messageId || request.messageId.trim() !== request.messageId) throw new Error("Idunn route observation requires a message id."); if ((request.schemaIds && !request.schemaIds.includes(GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA)) || (request.recordKeys && !request.recordKeys.includes(expected.target)) @@ -271,13 +285,9 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id || request.shardEpoch !== undefined) { throw new Error("Idunn route observation does not request this runtime presence document."); } - return sign(state, `route-observation:${request.messageId}`).document; + return sign(`route-observation:${request.messageId}`).document; }, assertWriteLease, - recordPublishedWarming(canonicalSha256) { - recentWarmingProofs.push(canonicalSha256); - if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); - }, }; } diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 1639b9aa..1df6e98f 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -25,7 +25,7 @@ const CHALLENGE: CultNetSnapshotRequestMessage = { messageId: "route-challenge-1", }; -type FixtureSet = "web" | "service" | "rudp-route"; +type FixtureSet = "web" | "service" | "rudp-route" | "service-stateful"; function fixtureFile(set: FixtureSet, name: string): Buffer { return fs.readFileSync(path.join(FIXTURES, set, name)); @@ -50,7 +50,11 @@ function expectedFacts(set: FixtureSet) { } /** Points the process at one Rust-written bundle and its inherited descriptors, as Idunn's systemd unit would. */ -function openAuthority(context: TestContext, set: FixtureSet, options: { machineId?: "etc" | "dbus" } = {}): IdunnRuntimeAuthority { +function openAuthority( + context: TestContext, + set: FixtureSet, + options: { machineId?: "etc" | "dbus"; lease?: string } = {}, +): IdunnRuntimeAuthority { const facts = expectedFacts(set); const originalEnvironment = { ...process.env }; context.after(() => { @@ -59,7 +63,8 @@ function openAuthority(context: TestContext, set: FixtureSet, options: { machine }); process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = path.join(FIXTURES, set); process.env.GAMECULT_IDUNN_CANDIDATE_BIND = facts.candidate; - delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; + if (options.lease) process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE = path.join(FIXTURES, set, options.lease); + else delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; process.env.LISTEN_PID = String(process.pid); process.env.LISTEN_FDS = "2"; process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; @@ -78,8 +83,8 @@ function openAuthority(context: TestContext, set: FixtureSet, options: { machine return loadIdunnRuntimeAuthorityFromEnvironment(facts.target, facts.healthContract); } -function signerFor(context: TestContext, set: FixtureSet, capacityDelta = 0) { - const authority = openAuthority(context, set); +function signerFor(context: TestContext, set: FixtureSet, capacityDelta = 0, lease?: string) { + const authority = openAuthority(context, set, { lease }); const capabilities = expectedFacts(set).capabilities.map((capability) => ({ ...capability, capacity: capability.capacity + capacityDelta })); return createIdunnRuntimeSigner({ authority, capabilities }); } @@ -107,7 +112,7 @@ for (const set of ["web", "service", "rudp-route"] as const) { test("a target that declares no Odin dependency opens and answers Idunn's challenge without one", (context) => { assert.equal(expectedFacts("web").odinEndpoint, undefined); const signer = signerFor(context, "web"); - assert.ok(signer.answerRouteObservation(CHALLENGE, "warming").payload.byteLength > 0); + assert.ok(signer.answerRouteObservation(CHALLENGE).payload.byteLength > 0); }); test("finds the machine-id at the dbus path when /etc/machine-id is absent, as the Rust protector does", (context) => { @@ -127,35 +132,44 @@ test("rejects a credential file holding more than one envelope", (context) => { ); }); -test("answers a route challenge byte for byte as the Rust-verified vectors, in the state the app reports", (context) => { +function presenceSlot(document: { payload: Uint8Array }, slot: number): unknown { + return (decode(document.payload) as unknown[])[slot]; +} + +// Every committed vector is asserted, dead ones included. Stateless sets sign at sequence 1. +for (const set of ["web", "service", "rudp-route"] as const) { + test(`${set}: a fresh signer answers warming, and after reportHealth('active') answers active, byte for byte as the Rust-verified vectors`, (context) => { + freezeClock(context); + assert.deepEqual(Buffer.from(signerFor(context, set).answerRouteObservation(CHALLENGE).payload), fixtureFile(set, "presence-warming.bin")); + const active = signerFor(context, set); + active.reportHealth("active"); + assert.deepEqual(Buffer.from(active.answerRouteObservation(CHALLENGE).payload), fixtureFile(set, "presence-active.bin")); + }); +} + +test("a shortfall signs the vector Rust correlates to the typed capacity disagreement", (context) => { freezeClock(context); - const signer = signerFor(context, "web"); - const warming = signer.answerRouteObservation(CHALLENGE, "warming"); - assert.deepEqual(Buffer.from(warming.payload), fixtureFile("web", "presence-warming.bin")); - assert.equal((decode(warming.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.state], "warming"); - const second = signer.answerRouteObservation(CHALLENGE, "active"); - assert.equal((decode(second.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.publisherSequence], 2); - // Sequence 2 cannot match a vector written at sequence 1; sign the same fields from a fresh signer. - const fresh = signerFor(context, "web"); - assert.deepEqual(Buffer.from(fresh.answerRouteObservation(CHALLENGE, "active").payload), fixtureFile("web", "presence-active.bin")); + const signer = signerFor(context, "service", -1); + signer.reportHealth("active"); + assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile("service", "presence-active-below-minimum.bin")); }); -test("a service with an Odin dependency signs the Rust-verified vectors, including a shortfall Rust correlates to a capacity disagreement", (context) => { +test("the signer owns the reported health: a fresh signer is warming, reportHealth moves it, the sequence keeps counting", (context) => { freezeClock(context); - assert.deepEqual( - Buffer.from(signerFor(context, "service").answerRouteObservation(CHALLENGE, "active").payload), - fixtureFile("service", "presence-active.bin"), - ); - assert.deepEqual( - Buffer.from(signerFor(context, "service", -1).answerRouteObservation(CHALLENGE, "active").payload), - fixtureFile("service", "presence-active-below-minimum.bin"), - ); + const signer = signerFor(context, "web"); + const first = signer.answerRouteObservation(CHALLENGE); + assert.equal(presenceSlot(first, RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.sign("detail"), RUNTIME_PRESENCE_SLOT.state), "warming"); + signer.reportHealth("active"); + const third = signer.answerRouteObservation(CHALLENGE); + assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.state), "active"); + assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.publisherSequence), 3); }); test("answers locally: synchronously, with no socket opened", (context) => { const sockets = context.mock.method(dgram, "createSocket"); const signer = signerFor(context, "web"); - const answer = signer.answerRouteObservation(CHALLENGE, "warming"); + const answer = signer.answerRouteObservation(CHALLENGE); assert.equal(typeof (answer as unknown as { then?: unknown }).then, "undefined"); assert.equal(sockets.mock.callCount(), 0); }); @@ -169,7 +183,7 @@ test("refuses a challenge that does not ask for this runtime's presence", (conte { ...CHALLENGE, schemaIds: ["another.schema"] }, { ...CHALLENGE, shardId: "shard-1" }, ]; - for (const request of cases) assert.throws(() => signer.answerRouteObservation(request, "active"), /route observation/); + for (const request of cases) assert.throws(() => signer.answerRouteObservation(request), /route observation/); }); async function startOdinPeer(context: TestContext, endpoint: string) { @@ -222,7 +236,7 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte const signer = signerFor(context, "service"); const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); - await publisher.publish("warming", "accepted presence"); + await publisher.publish("accepted presence"); assert.equal(peer.received[0]?.schemaVersion, "cultnet.document_put_raw.v0"); const published = decode(peer.received[0]!.document.payload) as unknown[]; assert.equal(published[RUNTIME_PRESENCE_SLOT.state], "warming"); @@ -230,15 +244,18 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte assert.equal(peer.responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); peer.reject = true; - await assert.rejects(publisher.publish("active", "denied presence"), /Odin rejected runtime presence: test admission denied/); + signer.reportHealth("active"); + await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/); assert.equal(peer.failure, undefined, peer.failure?.message); }); test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => { const endpoint = expectedFacts("service").odinEndpoint!; const peer = await startOdinPeer(context, endpoint); - const publisher = createIdunnRuntimePresencePublisher({ signer: signerFor(context, "service", -1), endpoint }); - await publisher.publish("active", "below minimum"); + const signer = signerFor(context, "service", -1); + signer.reportHealth("active"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + await publisher.publish("below minimum"); const capabilities = (decode(peer.received[0]!.document.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.capabilities] as unknown[][]; assert.equal(capabilities[0]![3], expectedFacts("service").capabilities[0]!.capacity - 1); }); @@ -253,3 +270,24 @@ test("the Odin publisher takes only the endpoint Expected names", (context) => { /does not match Idunn Expected dependency authority/, ); }); + +test("the route answer and the Odin publish report the same state, because the signer owns it", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + + await publisher.publish("first"); + assert.equal(presenceSlot(peer.received[0]!.document, RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.state), "warming"); + + signer.reportHealth("active"); + await publisher.publish("second"); + assert.equal(presenceSlot(peer.received[1]!.document, RUNTIME_PRESENCE_SLOT.state), "active"); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.state), "active"); + // One sequence across both carriers. + assert.deepEqual( + [1, 2].map((index) => presenceSlot(peer.received[index - 1]!.document, RUNTIME_PRESENCE_SLOT.publisherSequence)), + [1, 3], + ); +}); From 67b8ae3eda1915a86aecb1befbbbb8676be44696 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:13:28 +0200 Subject: [PATCH 15/21] cultnet-ts: freeze the Idunn authority and allow one signer per authority The loaded authority is deeply frozen (bind, health contract, Expected, Activation, identities), so a caller cannot steer what the signer signs; the typed Buffers that could not be frozen are dropped (Expected canonical bytes, Activation public key kept as hex only for the load-time comparison). A second createIdunnRuntimeSigner on the same authority throws, so two signers cannot emit sequence 1 under one runtimeInstanceId. Tests: stateful set pinned against Rust (warming, active, degraded), lease accepted for a route-answered warming, refused for another warming and another target, one signer per authority, frozen authority. Co-Authored-By: Claude Sonnet 5 --- .../cultnet-ts/src/idunn-runtime-authority.ts | 41 ++++++++--- .../test/idunn-runtime-authority.test.ts | 72 +++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index bb217ed0..cd942cec 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -30,6 +30,7 @@ const ACTIVATION_ID_DOMAIN = Buffer.from("idunn.runtime-activation.id.v1\0", "ut const CULTCACHE_STORE_FORMAT = "cultcache.store.v1"; const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); const MAX_RECENT_WARMING_PROOFS = 64; +const authoritiesWithSigner = new WeakSet(); const authorityKeys = new WeakMap(); type ExpectedIncarnation = { @@ -51,7 +52,6 @@ type ExpectedIncarnation = { route: { routeId: string; transport: string; stableEndpoint: string; candidateEndpoint: string } | null; capabilities: Array<{ capability: string; schema: string; compatibility: string; minimumCapacity: number }>; dependencies: Array<{ kind: string; capability: string; schema: string; compatibility: string; providerEndpoint: string | null }>; - canonicalBytes: Buffer; canonicalSha256: string; }; @@ -61,18 +61,27 @@ type RuntimeActivation = { runtimeId: string; runtimeInstanceId: string; activationSignerIdentityId: string; - activationSignerPublicKey: Buffer; + activationSignerPublicKeyHex: string; canonicalSha256: string; }; -export type IdunnRuntimeAuthority = { +type DeepReadonly = T extends readonly (infer U)[] + ? readonly DeepReadonly[] + : T extends object ? { readonly [K in keyof T]: DeepReadonly } : T; + +/** + * What Idunn admitted for this launch. Deeply frozen at load: bind, health + * contract, Expected, Activation and identities are read-only for the life of + * the process, so no caller can steer what the signer signs. + */ +export type IdunnRuntimeAuthority = DeepReadonly<{ expected: ExpectedIncarnation; activation: RuntimeActivation; providerSignerIdentityId: string; activationSignerIdentityId: string; boundEndpoint: string; processWriteLeasePath?: string; -}; +}>; export type SignedRuntimePresence = { document: CultNetRawDocumentRecord; @@ -152,7 +161,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( const activationPublicKey = rawEd25519PublicKey(crypto.createPublicKey(activationPrivateKey)); const activationSignerIdentityId = sha256Hex(Buffer.concat([ACTIVATION_ID_DOMAIN, activationPublicKey])); if (activationSignerIdentityId !== activationValue.activationSignerIdentityId - || !activationPublicKey.equals(activationValue.activationSignerPublicKey)) { + || activationPublicKey.toString("hex") !== activationValue.activationSignerPublicKeyHex) { throw new Error("Idunn activation credential does not match the root-provisioned Activation witness."); } @@ -167,7 +176,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( throw new Error("Provider identity does not match Idunn Expected."); } - const authority: IdunnRuntimeAuthority = { + const authority: IdunnRuntimeAuthority = deepFreeze({ expected: expectedValue, activation: activationValue, providerSignerIdentityId: expectedValue.expectedSignerIdentityId, @@ -176,7 +185,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( ...(process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT] ? { processWriteLeasePath: process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT] } : {}), - }; + }); authorityKeys.set(authority, { providerPrivateKey, activationPrivateKey }); return authority; } @@ -220,8 +229,15 @@ export function signIdunnRuntimePresence( }; } +/** + * One signer per loaded authority: the publisher sequence and the warming proofs + * are the signer's, and a second signer would restart the sequence under the + * same runtimeInstanceId. + */ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): IdunnRuntimeSigner { const { authority } = options; + if (authoritiesWithSigner.has(authority)) throw new Error("This Idunn runtime authority already has a signer."); + authoritiesWithSigner.add(authority); const expected = authority.expected; let sequence = 0; let health: RuntimePresenceHealth["state"] = "warming"; @@ -291,6 +307,14 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id }; } +function deepFreeze(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + function readAuthorityRecord(filePath: string, type: string, schemaId: string): { payload: Uint8Array } { const decoded = decode(fs.readFileSync(filePath)); if (!Array.isArray(decoded) || decoded[0] !== CULTCACHE_STORE_FORMAT || !Array.isArray(decoded[1]) || !Array.isArray(decoded[2])) { @@ -360,7 +384,6 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation { route, capabilities: decodeExpectedCapabilities(values[16]), dependencies: decodeExpectedDependencies(values[17]), - canonicalBytes, canonicalSha256: prefixedSha256(canonicalBytes), }; } @@ -388,7 +411,7 @@ function decodeActivation(payload: Uint8Array): RuntimeActivation { runtimeId: string(values[2], "Activation runtime id"), runtimeInstanceId: string(values[3], "Activation instance id"), activationSignerIdentityId, - activationSignerPublicKey: Buffer.from(publicKey), + activationSignerPublicKeyHex: Buffer.from(publicKey).toString("hex"), canonicalSha256: prefixedSha256(canonicalBytes), }; } diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 1df6e98f..5f34c02c 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -291,3 +291,75 @@ test("the route answer and the Odin publish report the same state, because the s [1, 3], ); }); + +// The lease-bound set: Rust issued lease.cc naming presence-warming.bin's digest, and presence-active.bin +// and presence-degraded.bin carry that lease's digest. +const STATEFUL = "service-stateful" as const; + +function statefulSigner(context: TestContext, lease = "lease.cc") { + return signerFor(context, STATEFUL, 0, lease); +} + +test("stateful: warming, active and degraded match the Rust-verified vectors byte for byte, active and degraded bound to Rust's lease", (context) => { + freezeClock(context); + const signer = statefulSigner(context); + assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-warming.bin")); + signer.reportHealth("active"); + const active = signer.answerRouteObservation(CHALLENGE); + assert.deepEqual(Buffer.from(active.payload), fixtureFile(STATEFUL, "presence-active.bin")); + assert.match(String(presenceSlot(active, RUNTIME_PRESENCE_SLOT.writeLeaseSha256)), /^sha256-[0-9a-f]{64}$/); + + const degraded = statefulSigner(context); + degraded.answerRouteObservation(CHALLENGE); + degraded.reportHealth("degraded"); + assert.deepEqual(Buffer.from(degraded.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-degraded.bin")); +}); + +test("a warming answered on the route becomes a lease target: Rust's lease naming it is accepted", (context) => { + freezeClock(context); + const signer = statefulSigner(context); + assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/, "no warming signed yet"); + signer.answerRouteObservation(CHALLENGE); + assert.equal(signer.assertWriteLease(), presenceSlot({ payload: fixtureFile(STATEFUL, "presence-active.bin") }, RUNTIME_PRESENCE_SLOT.writeLeaseSha256)); +}); + +for (const [lease, reason] of [["lease-other-warming.cc", "names another warming digest"], ["lease-other-target.cc", "names another target"]] as const) { + test(`a lease that ${reason} is refused, and the signer cannot sign active under it`, (context) => { + freezeClock(context); + const signer = statefulSigner(context, lease); + signer.answerRouteObservation(CHALLENGE); + assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/); + signer.reportHealth("active"); + assert.throws(() => signer.answerRouteObservation(CHALLENGE), /does not match the current Expected incarnation/); + }); +} + +test("one signer per loaded authority", (context) => { + const authority = openAuthority(context, "web"); + const capabilities = expectedFacts("web").capabilities; + createIdunnRuntimeSigner({ authority, capabilities }); + assert.throws(() => createIdunnRuntimeSigner({ authority, capabilities }), /already has a signer/); +}); + +test("the loaded authority is frozen: the bind, contract, Expected, Activation and identities cannot change, and the signer signs the loaded endpoint", (context) => { + freezeClock(context); + const authority = openAuthority(context, "web"); + const loaded = expectedFacts("web"); + const open = authority as unknown as { [key: string]: any }; + const mutations: Array<[string, () => void]> = [ + ["boundEndpoint", () => { open.boundEndpoint = "http://127.0.0.1:1"; }], + ["providerSignerIdentityId", () => { open.providerSignerIdentityId = "forged"; }], + ["activationSignerIdentityId", () => { open.activationSignerIdentityId = "forged"; }], + ["expected.healthContract", () => { open.expected.healthContract = "forged"; }], + ["expected.target", () => { open.expected.target = "forged"; }], + ["expected.canonicalSha256", () => { open.expected.canonicalSha256 = "forged"; }], + ["expected.route.candidateEndpoint", () => { open.expected.route.candidateEndpoint = "http://127.0.0.1:1"; }], + ["expected.capabilities[0].minimumCapacity", () => { open.expected.capabilities[0].minimumCapacity = 0; }], + ["expected.capabilities.push", () => { open.expected.capabilities.push({}); }], + ["activation.runtimeInstanceId", () => { open.activation.runtimeInstanceId = "forged"; }], + ["activation.canonicalSha256", () => { open.activation.canonicalSha256 = "forged"; }], + ]; + for (const [name, mutate] of mutations) assert.throws(mutate, TypeError, name); + const signer = createIdunnRuntimeSigner({ authority, capabilities: loaded.capabilities }); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.boundEndpoint), loaded.candidate); +}); From 06d250070a9ece3b62a5e6565ea1fc84bf654513 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:13:37 +0200 Subject: [PATCH 16/21] cultnet-ts: add an idunn-runtime subpath export that does not load dgram `cultnet-ts/idunn-runtime` exposes the signer and loader alone. Web apps import it instead of the package root, which also carries the Odin publisher and node:dgram. The root export is unchanged. Test loads both under a stubbed dgram: the subpath loads, the root fails. Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-ts/package.json | 5 ++++ .../test/idunn-runtime-authority.test.ts | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index f33b8f83..e72a10aa 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -15,6 +15,11 @@ "require": "./dist/contracts.js", "default": "./dist/contracts.js" }, + "./idunn-runtime": { + "types": "./dist/idunn-runtime-authority.d.ts", + "require": "./dist/idunn-runtime-authority.js", + "default": "./dist/idunn-runtime-authority.js" + }, "./authority": { "types": "./dist/cultmesh-authority.d.ts", "require": "./dist/cultmesh-authority.js", diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 5f34c02c..075738be 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -1,5 +1,6 @@ import { test, type TestContext } from "node:test"; import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import dgram from "node:dgram"; import fs from "node:fs"; import path from "node:path"; @@ -363,3 +364,26 @@ test("the loaded authority is frozen: the bind, contract, Expected, Activation a const signer = createIdunnRuntimeSigner({ authority, capabilities: loaded.capabilities }); assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.boundEndpoint), loaded.candidate); }); + +// A web app must be able to import the signer without pulling the Odin publisher and dgram. +function loadUnderDgramStub(modulePath: string): string { + const script = [ + 'const Module = require("node:module");', + "const load = Module._load;", + 'Module._load = function (request, ...rest) { if (request === "dgram" || request === "node:dgram") throw new Error("dgram was loaded"); return load.call(this, request, ...rest); };', + "try { require(process.argv[1]); process.stdout.write(\"loaded\"); } catch (error) { process.stdout.write(\"failed: \" + error.message); }", + ].join("\n"); + const run = spawnSync(process.execPath, ["-e", script, modulePath], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stderr); + return run.stdout; +} + +test("the idunn-runtime subpath loads the signer and loader without loading dgram; the package root does load it", () => { + const subpath = require.resolve("cultnet-ts/idunn-runtime"); + assert.equal(loadUnderDgramStub(subpath), "loaded"); + assert.equal(loadUnderDgramStub(require.resolve("cultnet-ts")), "failed: dgram was loaded"); + const exported = require(subpath) as Record; + assert.equal(typeof exported.createIdunnRuntimeSigner, "function"); + assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function"); + assert.equal(exported.createIdunnRuntimePresencePublisher, undefined); +}); From 322b7c815ae64f2aa008c344dc4a995f345c3a20 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:15:40 +0200 Subject: [PATCH 17/21] cultnet-ts test: read the signed document, not the SignedRuntimePresence wrapper Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-ts/test/idunn-runtime-authority.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 075738be..560f5933 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -160,7 +160,7 @@ test("the signer owns the reported health: a fresh signer is warming, reportHeal const signer = signerFor(context, "web"); const first = signer.answerRouteObservation(CHALLENGE); assert.equal(presenceSlot(first, RUNTIME_PRESENCE_SLOT.state), "warming"); - assert.equal(presenceSlot(signer.sign("detail"), RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.sign("detail").document, RUNTIME_PRESENCE_SLOT.state), "warming"); signer.reportHealth("active"); const third = signer.answerRouteObservation(CHALLENGE); assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.state), "active"); From 5ed9dbbcb8562b552bcf4f49cd2f731464665348 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:26:10 +0200 Subject: [PATCH 18/21] cultnet-ts: stop exporting signIdunnRuntimePresence; document two limits The function took state and lease digest from the caller, so anyone holding the authority could sign active without reportHealth or assertWriteLease. It is now private to the signer. Test: the subpath and root do not expose it. Docs: waitForWriteLease throws out of publish if health is already non-warming; MAX_RECENT_WARMING_PROOFS=64 is a recorded limit. Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-ts/src/idunn-odin-presence-publisher.ts | 5 +++++ packages/cultnet-ts/src/idunn-runtime-authority.ts | 4 +++- packages/cultnet-ts/test/idunn-runtime-authority.test.ts | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts index 2e992658..3713363d 100644 --- a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -15,6 +15,11 @@ export type IdunnRuntimePresencePublisher = { readonly requiresWriteLease: boolean; /** Publishes the signer's presence in the health the signer reports. */ publish(detail: string): Promise; + /** + * Publishes warming presence until Idunn's lease names one. Call it before + * `reportHealth('active')`: once the signer reports a non-warming health, + * `publish` inside this loop throws (it needs the lease) instead of polling. + */ waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; assertWriteLease(): Promise; }; diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index cd942cec..7f7b882f 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -29,6 +29,8 @@ const PROVIDER_PROTECTOR_CONTEXT = "gamecult-provider-health-identity-v1"; const ACTIVATION_ID_DOMAIN = Buffer.from("idunn.runtime-activation.id.v1\0", "utf8"); const CULTCACHE_STORE_FORMAT = "cultcache.store.v1"; const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); +// Limit: more than this many warmings signed between Idunn's observation and +// assertWriteLease would push the leased one out and refuse a valid lease. const MAX_RECENT_WARMING_PROOFS = 64; const authoritiesWithSigner = new WeakSet(); const authorityKeys = new WeakMap(); @@ -195,7 +197,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( * Signs the fixed Idunn v2 positional runtime-presence contract with the * provider identity and this launch's Idunn-issued activation key. */ -export function signIdunnRuntimePresence( +function signIdunnRuntimePresence( authority: IdunnRuntimeAuthority, fields: Omit, ): { presence: RuntimePresenceHealth; payload: Uint8Array; canonicalSha256: string } { diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 560f5933..b5c9236a 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -386,4 +386,6 @@ test("the idunn-runtime subpath loads the signer and loader without loading dgra assert.equal(typeof exported.createIdunnRuntimeSigner, "function"); assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function"); assert.equal(exported.createIdunnRuntimePresencePublisher, undefined); + assert.equal(exported.signIdunnRuntimePresence, undefined); + assert.equal((require("cultnet-ts") as Record).signIdunnRuntimePresence, undefined); }); From 5b538aaf07a73bb1b1d99384d8af0d12d670e395 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:29:41 +0200 Subject: [PATCH 19/21] Answer chunk requests whose string fields are nil A C# caller that leaves ChunkHash, MessageId or RecordKey null writes msgpack nil; the reference reads it back as null, treats it as blank and answers found:false. Rust now decodes nil as blank in content chunk requests, so those requests are answered with the reference bytes instead of dropped. Three nil vectors from the reference server are judged by decoding them in Rust. Record two content-plane limits in the parity scope. Co-Authored-By: Claude Sonnet 5 --- .../cultmesh/content-vectors.cs-written.json | 30 +++++++++++++++++ docs/runtime-parity-scope.md | 5 +++ packages/cultnet-rs/src/contracts.rs | 15 +++++++-- packages/cultnet-rs/tests/content.rs | 32 +++++++++++++++++-- .../CultMeshContentVectorTests.cs | 13 ++++++++ 5 files changed, 90 insertions(+), 5 deletions(-) diff --git a/contracts/cultmesh/content-vectors.cs-written.json b/contracts/cultmesh/content-vectors.cs-written.json index a0ae2fe2..0eaaaece 100644 --- a/contracts/cultmesh/content-vectors.cs-written.json +++ b/contracts/cultmesh/content-vectors.cs-written.json @@ -130,6 +130,36 @@ "kind": "response", "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK12ZWN0b3ItZG91YmxlpWZvdW5kw6ljaHVua0hhc2jZR3NoYTI1Njo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXNpemVCeXRlc80S36dwYXlsb2FkxRLf\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6O6VlcnJvcqA=" }, + { + "label": "nil_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkwKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "nil_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "nil_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1uaWwtaGFzaKljaHVua0hhc2jAqXJlY29yZEtleaCxZXhwZWN0ZWRTaXplQnl0ZXNk" + }, + { + "label": "nil_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3ItbmlsLWhhc2ilZm91bmTCqWNodW5rSGFzaKCpc2l6ZUJ5dGVzAKdwYXlsb2FkxAClZXJyb3LZQkFyZ3VtZW50RXhjZXB0aW9uOiBIYXNoIG11c3QgYmUgbm9uLWVtcHR5LiAoUGFyYW1ldGVyICdDaHVua0hhc2gnKQ==" + }, + { + "label": "nil_record_key.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrnZlY3Rvci1uaWwta2V5qWNodW5rSGFzaNlAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKlyZWNvcmRLZXnAsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "nil_record_key.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK52ZWN0b3ItbmlsLWtleaVmb3VuZMKpY2h1bmtIYXNo2UAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2TZGaWxlTm90Rm91bmRFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgaXMgbm90IGF2YWlsYWJsZS4=" + }, { "label": "response_hash_last_digit", "kind": "response", diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 6710c2dd..4ea8f845 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -85,6 +85,11 @@ to speak CultNet or participate in CultMesh. session the client already holds (see `src/GameCult.Mesh/docs/transport-planes.md`); it has no authenticated content connector, so it claims no TCP+TLS or QUIC content path. + Recorded limits: a caller that passes `max_bytes = u64::MAX` to `fetch_content` gets an unbounded + body, because the reference refuses artifacts over `int.MaxValue` bytes and Rust bounds them only by the + caller's cap. + `normalize_hash` matches the `sha256:` prefix ASCII-case-insensitively while C# `OrdinalIgnoreCase` may + also match non-ASCII forms (for example `Å¿ha256:`); unprobed, and it affects hostile input only. ## Expansion Work Outside The Current Parity Claim diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index 0925215e..cc50171d 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -1073,9 +1073,9 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result Ok(CultNetMessage::ContentChunkRequest { - message_id: require_legacy_string(get("messageId"), "messageId")?, - chunk_hash: require_legacy_string(get("chunkHash"), "chunkHash")?, - record_key: require_legacy_string(get("recordKey"), "recordKey")?, + message_id: legacy_string_or_nil(get("messageId"), "messageId")?, + chunk_hash: legacy_string_or_nil(get("chunkHash"), "chunkHash")?, + record_key: legacy_string_or_nil(get("recordKey"), "recordKey")?, expected_size_bytes: require_legacy_i32(get("expectedSizeBytes"), "expectedSizeBytes")?, }), "cultmesh.content_chunk_response.v1" => Ok(CultNetMessage::ContentChunkResponse { @@ -1721,6 +1721,15 @@ fn require_legacy_bytes(value: Option<&rmpv::Value>, field_name: &str) -> Result Ok(URL_SAFE_NO_PAD.encode(bytes)) } +/// A string the reference reads back as null when its writer set it to null (msgpack nil), which its +/// handlers treat as blank. Only content chunk requests use it: they are answered, never dropped. +fn legacy_string_or_nil(value: Option<&rmpv::Value>, field_name: &str) -> Result { + match value { + Some(rmpv::Value::Nil) => Ok(String::new()), + other => require_legacy_string(other, field_name), + } +} + fn require_legacy_string(value: Option<&rmpv::Value>, field_name: &str) -> Result { let value = value .and_then(rmpv::Value::as_str) diff --git a/packages/cultnet-rs/tests/content.rs b/packages/cultnet-rs/tests/content.rs index b36ae5f4..178ebe3c 100644 --- a/packages/cultnet-rs/tests/content.rs +++ b/packages/cultnet-rs/tests/content.rs @@ -277,7 +277,11 @@ fn rust_written_vectors_are_current() { #[test] fn reference_vectors_encode_byte_identically() { let local = local_vectors(); - let reference = read_vectors("content-vectors.cs-written.json"); + // The nil-field vectors are bytes only the reference writes; `nil_fields_are_answered_as_blanks` judges them. + let reference: Vec<_> = read_vectors("content-vectors.cs-written.json") + .into_iter() + .filter(|(label, _, _)| !label.starts_with("nil_")) + .collect(); assert_eq!( reference.iter().map(|(l, _, _)| l.as_str()).collect::>(), local.iter().map(|v| v.label.as_str()).collect::>(), @@ -522,7 +526,7 @@ fn answer_serves_found_false_with_the_reference_error_spelling() { } judged += 1; } - assert_eq!(judged, 13); + assert_eq!(judged, 16); } #[test] @@ -615,6 +619,30 @@ fn a_bad_request_is_answered_never_dropped() { } } +// A caller that leaves a string null writes msgpack nil. The reference reads null, treats it as blank +// and answers; Rust decodes nil as blank so the same request gets the same answer, byte for byte. +#[test] +fn nil_fields_are_answered_as_blanks() { + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let reference = read_vectors("content-vectors.cs-written.json"); + for (name, spelling) in [ + ("nil_message_id", "InvalidDataException: Content chunk request requires a message identity."), + ("nil_hash", "ArgumentException: Hash must be non-empty. (Parameter 'ChunkHash')"), + ("nil_record_key", "FileNotFoundException: Content chunk is not available."), + ] { + let find = |suffix: &str| { + let label = format!("{name}.{suffix}"); + reference.iter().find(|(l, _, _)| *l == label).expect("vector").2.clone() + }; + let request_bytes = find("request"); + assert!(request_bytes.contains(&0xc0), "{name}: the reference wrote nil"); + let answer = answer_content_chunk_request(&unwire(&request_bytes).expect("nil decodes"), lookup_in(&store)); + assert_eq!(wire(&answer), find("response"), "{name}: answer differs from the reference's"); + assert_eq!(response_of(&answer).4, spelling, "{name}"); + } +} + // F3: metadata keeps the order it was read in and refuses a duplicate key. #[test] fn manifest_metadata_keeps_wire_order_and_refuses_a_duplicate_key() { diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs index 6c5763bb..2fa26aa1 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs @@ -174,6 +174,19 @@ public sealed class CultMeshContentVectorTests AddAnswer("answer_record_key_case", Request("vector-key-case", last.ChunkHash, key.ToUpperInvariant(), last.SizeBytes)); AddAnswer("answer_blank_record_key", Request("vector-key-blank", last.ChunkHash, " ", last.SizeBytes)); AddAnswer("answer_double_prefix", Request("vector-double", "sha256:sha256:" + last.ChunkHash, string.Empty, last.SizeBytes)); + + // A caller that leaves a string null writes msgpack nil; the reference reads it back as null and + // answers. These are judged by Rust decoding the reference's bytes (Rust never writes nil), so + // they stay out of the label-matched mirror set. + void AddNilAnswer(string name, CultMeshContentChunkRequestMessage request) + { + vectors.Add(new Vector(name + ".request", "request", Reencode(request))); + vectors.Add(new Vector(name + ".response", "response", Reencode(Answer(content, request)))); + } + + AddNilAnswer("nil_message_id", Request(null, last.ChunkHash, key, last.SizeBytes)); + AddNilAnswer("nil_hash", Request("vector-nil-hash", null, string.Empty, 100)); + AddNilAnswer("nil_record_key", Request("vector-nil-key", new string('0', 64), null, 100)); AddResponse("response_hash_last_digit", new CultMeshContentChunkResponseMessage { MessageId = "vector-digit", From 1ddf6e0d562f855fbdb04888674264ebf3bdea98 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 04:17:31 +0200 Subject: [PATCH 20/21] Mark the three share-mode tests Windows-only They hold a FileShare.Read handle and expect a delete or replace to fail; Linux unlink and rename ignore open handles, so they can never pass there and showed as three standing failures. [Platform("Win")] reports them skipped with a reason on Linux and keeps them running on Windows. No Linux failure injection exists: the store names its temp file with a fresh GUID and the suite is likely root in the verify container, so read-only directories do not fail. Co-Authored-By: Claude Sonnet 5 --- tests/GameCult.Caching.Tests/BackingStoreTests.cs | 1 + tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs | 1 + tests/GameCult.Caching.Tests/StoreRoutingTests.cs | 1 + 3 files changed, 3 insertions(+) diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 86613c52..7e88c283 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -707,6 +707,7 @@ namespace GameCult.Caching.Tests } [Test] + [Platform("Win", Reason = "Holds an open FileShare.Read handle and expects a delete or replace to fail; Linux unlink and rename ignore open handles.")] public async Task DirectoryMessagePackBackingStore_Deletion_Commits_While_Locked_Old_Page_Remains_An_Orphan() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); diff --git a/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs b/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs index 41ef46ad..840110bc 100644 --- a/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs +++ b/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs @@ -37,6 +37,7 @@ namespace GameCult.Caching.Tests } [Test] + [Platform("Win", Reason = "Holds an open FileShare.Read handle and expects a delete or replace to fail; Linux unlink and rename ignore open handles.")] public async Task FailedManifestReplaceKeepsPreviousGeneration() { using var cache = Open(out _); diff --git a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs index fc99f01f..af7e7da7 100644 --- a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs +++ b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs @@ -439,6 +439,7 @@ namespace GameCult.Caching.Tests } [Test] + [Platform("Win", Reason = "Holds an open FileShare.Read handle and expects a delete or replace to fail; Linux unlink and rename ignore open handles.")] public void BatchIsAllOrNothingOnStoreFailure() { var registry = Registry(); From c3add7dd1b9f212c4c30465a22815e365d502d95 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 04:30:40 +0200 Subject: [PATCH 21/21] Idunn runtime fixture generator takes its specification as input The four hard-coded sets become flags, with @file response-file splicing. A consumer keeps its own .args file instead of committing a patch against the example. fixture.args carries CultLib own sets; the README documents the flags. Committed fixture bytes are unchanged. Co-Authored-By: Claude Sonnet 5 --- .../examples/idunn_runtime_fixture.rs | 266 ++++++++++++------ .../test/fixtures/idunn-runtime/README.md | 21 ++ .../test/fixtures/idunn-runtime/fixture.args | 35 +++ 3 files changed, 232 insertions(+), 90 deletions(-) create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs index 57364c10..287b0f34 100644 --- a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -11,22 +11,48 @@ //! disagreement). Ed25519 is deterministic, so the TypeScript signer must //! reproduce each vector byte for byte from the same fields. //! -//! The `service-stateful` set is the lease-bound shape: Expected carries a state +//! A `stateful` set is the lease-bound shape: Expected carries a state //! lineage and requires a write lease, and Rust issues `lease.cc` naming the //! warming vector's digest. Its warming vector is sequence 1; the active and //! degraded vectors are sequence 2 and bind to that lease. Two further leases are //! deliberately wrong (`lease-other-warming.cc` names a different warming digest, -//! `lease-other-target.cc` names another target) for the TypeScript refusal tests. +//! `lease-other-target.cc` names another target) for refusal tests. //! //! Linux only: the provider identity is bound to `/etc/machine-id`, and the //! generated `machine-id` file records the value the identities were bound to. //! -//! Usage: idunn_runtime_fixture +//! Usage: idunn_runtime_fixture ... +//! +//! The specification is command-line flags. `@` splices in a response +//! file: one flag per line, `--flag value` split at the first whitespace, blank +//! lines and `#` lines ignored. CultLib's own sets are +//! `packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args`. +//! +//! Global flags: +//! --source-repository Expected.source_repository (default github.com/GameCult/Example) +//! --detail presence detail (default route-observation:route-challenge-1) +//! +//! `--set ` opens a fixture set written to `/`; the flags +//! below apply to the set most recently opened: +//! --target required +//! --transport --stable --candidate required: the route +//! --capability default `.api` +//! --schema default `.v1` +//! --health-contract default `.runtime-health` +//! --minimum-capacity default 1; above 1 also writes presence-active-below-minimum.bin +//! --stateful state lineage, write lease and lease vectors +//! --state-schema-generation stateful only; default state-v1 +//! --lease-other-target stateful only; default the first other set's target +//! --dependency k=v,k=v,... repeatable; keys kind, capability, schema, provider, +//! endpoint (required), compatibility (v1), capacity (1), +//! startup (before-promotion), authority (managed-incarnation), +//! projection (one hex digit the digest is filled with, 5) +use std::collections::BTreeMap; use std::fs; use std::path::Path; -use anyhow::{Result, bail, ensure}; +use anyhow::{Context, Result, bail, ensure}; use cultcache_rs::{ CacheBackingStore, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore, }; @@ -46,67 +72,138 @@ use cultnet_rs::{ const OBSERVED_AT: u64 = 1_790_000_000_000; const ISSUED_AT: u64 = OBSERVED_AT - 5_000; const SEQUENCE: u64 = 1; -const DETAIL: &str = "route-observation:route-challenge-1"; const STORED_AT: &str = "2026-01-01T00:00:00.000Z"; -struct Set { - dir: &'static str, - target: &'static str, - transport: &'static str, - stable: &'static str, - candidate: &'static str, - minimum_capacity: u32, - odin_dependency: bool, - stateful: bool, +struct Spec { + source_repository: String, + detail: String, + sets: Vec, } -const SETS: [Set; 4] = [ - Set { - dir: "web", - target: "streampixels-web", - transport: "http", - stable: "http://127.0.0.1:8830", - candidate: "http://127.0.0.1:18830", - minimum_capacity: 1, - odin_dependency: false, - stateful: false, - }, - Set { - dir: "service", - target: "streampixels-service", - transport: "tcp", - stable: "tcp://127.0.0.1:8831", - candidate: "tcp://127.0.0.1:18831", - minimum_capacity: 2, - odin_dependency: true, - stateful: false, - }, - Set { - dir: "service-stateful", - target: "streampixels-service", - transport: "tcp", - stable: "tcp://127.0.0.1:8833", - candidate: "tcp://127.0.0.1:18833", - minimum_capacity: 2, - odin_dependency: true, - stateful: true, - }, - Set { - dir: "rudp-route", - target: "streampixels-rudp", - transport: "rudp", - stable: "rudp://127.0.0.1:8832", - candidate: "rudp://127.0.0.1:18832", - minimum_capacity: 1, - odin_dependency: false, - stateful: false, - }, -]; +#[derive(Default)] +struct Set { + dir: String, + target: String, + transport: String, + stable: String, + candidate: String, + capability: Option, + schema: Option, + health_contract: Option, + minimum_capacity: u32, + stateful: bool, + state_schema_generation: Option, + lease_other_target: Option, + dependencies: Vec, +} + +fn expand(args: impl Iterator) -> Result> { + let mut flat = Vec::new(); + for arg in args { + let Some(file) = arg.strip_prefix('@') else { + flat.push(arg); + continue; + }; + for line in fs::read_to_string(file).with_context(|| format!("reading {file}"))?.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + match line.split_once(char::is_whitespace) { + Some((flag, value)) => flat.extend([flag.to_string(), value.trim().to_string()]), + None => flat.push(line.to_string()), + } + } + } + Ok(flat) +} + +fn dependency(text: &str) -> Result { + let mut fields = BTreeMap::new(); + for pair in text.split(',') { + let (key, value) = pair + .split_once('=') + .with_context(|| format!("dependency field `{pair}` is not key=value"))?; + ensure!(fields.insert(key, value).is_none(), "dependency key `{key}` repeated"); + } + let mut take = |key: &str, default: Option<&str>| -> Result { + match (fields.remove(key), default) { + (Some(value), _) => Ok(value.to_string()), + (None, Some(default)) => Ok(default.to_string()), + (None, None) => bail!("dependency needs `{key}`"), + } + }; + let dependency = IdunnExpectedDependency { + kind: take("kind", None)?, + capability: take("capability", None)?, + schema: take("schema", None)?, + compatibility: take("compatibility", Some("v1"))?, + minimum_capacity: take("capacity", Some("1"))?.parse()?, + startup: take("startup", Some("before-promotion"))?, + provider_id: Some(take("provider", None)?), + provider_authority: Some(take("authority", Some("managed-incarnation"))?), + provider_expected_projection_sha256: Some(digest( + take("projection", Some("5"))?.chars().next().context("empty projection")?, + )), + provider_endpoint: Some(take("endpoint", None)?), + }; + ensure!(fields.is_empty(), "unknown dependency keys {:?}", fields.keys().collect::>()); + Ok(dependency) +} + +fn parse(args: impl Iterator) -> Result { + let mut spec = Spec { + source_repository: "github.com/GameCult/Example".into(), + detail: "route-observation:route-challenge-1".into(), + sets: Vec::new(), + }; + let mut args = expand(args)?.into_iter(); + while let Some(flag) = args.next() { + let mut value = || args.next().with_context(|| format!("{flag} needs a value")); + match flag.as_str() { + "--source-repository" => spec.source_repository = value()?, + "--detail" => spec.detail = value()?, + "--set" => spec.sets.push(Set { dir: value()?, minimum_capacity: 1, ..Set::default() }), + _ => { + let set = spec.sets.last_mut().with_context(|| format!("{flag} before any --set"))?; + match flag.as_str() { + "--target" => set.target = value()?, + "--transport" => set.transport = value()?, + "--stable" => set.stable = value()?, + "--candidate" => set.candidate = value()?, + "--capability" => set.capability = Some(value()?), + "--schema" => set.schema = Some(value()?), + "--health-contract" => set.health_contract = Some(value()?), + "--minimum-capacity" => set.minimum_capacity = value()?.parse()?, + "--stateful" => set.stateful = true, + "--state-schema-generation" => set.state_schema_generation = Some(value()?), + "--lease-other-target" => set.lease_other_target = Some(value()?), + "--dependency" => set.dependencies.push(dependency(&value()?)?), + other => bail!("unknown flag {other}"), + } + } + } + } + ensure!(!spec.sets.is_empty(), "no --set given"); + for set in &spec.sets { + for (name, field) in [ + ("target", &set.target), + ("transport", &set.transport), + ("stable", &set.stable), + ("candidate", &set.candidate), + ] { + ensure!(!field.is_empty(), "set {} needs --{name}", set.dir); + } + } + Ok(spec) +} fn main() -> Result<()> { - let Some(out) = std::env::args().nth(1) else { - bail!("usage: idunn_runtime_fixture "); + let mut args = std::env::args().skip(1); + let Some(out) = args.next() else { + bail!("usage: idunn_runtime_fixture ... (see the file header)"); }; + let spec = parse(args)?; let machine_id = fs::read_to_string("/etc/machine-id")?; fs::create_dir_all(&out)?; fs::write(Path::new(&out).join("machine-id"), machine_id.trim())?; @@ -116,8 +213,8 @@ fn main() -> Result<()> { let idunn_anchor = idunn.trust_anchor()?; fs::remove_file(Path::new(&out).join("idunn-identity.private"))?; let _ = fs::remove_file(Path::new(&out).join("idunn-identity.private.lock")); - for set in &SETS { - build(Path::new(&out).join(set.dir).as_path(), set, &idunn, &idunn_anchor)?; + for set in &spec.sets { + build(Path::new(&out).join(&set.dir).as_path(), &spec, set, &idunn, &idunn_anchor)?; } Ok(()) } @@ -128,6 +225,7 @@ fn digest(byte: char) -> String { fn build( dir: &Path, + spec: &Spec, set: &Set, idunn: &cultnet_rs::ServiceIdentitySigner, idunn_anchor: &cultnet_rs::ServiceIdentityTrustAnchor, @@ -138,29 +236,29 @@ fn build( let provider_anchor = provider.trust_anchor()?; let _ = fs::remove_file(dir.join("provider-identity.credential.lock")); - let capability = format!("{}.api", set.target); - let capability_schema = format!("{}.api.v1", set.target); + let capability = set.capability.clone().unwrap_or_else(|| format!("{}.api", set.target)); + let capability_schema = set.schema.clone().unwrap_or_else(|| format!("{capability}.v1")); let expected = IdunnExpectedIncarnationRecord { schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), - target: set.target.into(), + target: set.target.clone(), plan_id: digest('1'), incarnation_id: format!("{}/incarnation-1", set.target), sealed_release_id: digest('2'), - source_repository: "github.com/GameCult/StreamPixels".into(), + source_repository: spec.source_repository.clone(), source_revision: "a".repeat(40), recipe_sha256: digest('3'), runtime_id: format!("{}-yggdrasil", set.target), expected_signer_identity_id: provider_anchor.identity_id.clone(), - health_contract: format!("{}.runtime-health", set.target), + health_contract: set.health_contract.clone().unwrap_or_else(|| format!("{}.runtime-health", set.target)), artifact_sha256: digest('4'), - state_schema_generation: set.stateful.then(|| "streampixels-state-v1".to_string()), + state_schema_generation: set.stateful.then(|| set.state_schema_generation.clone().unwrap_or_else(|| "state-v1".into())), state_contract_sha256: set.stateful.then(|| digest('7')), write_lease_required: set.stateful, route: Some(IdunnExpectedRoute { route_id: format!("{}-route", set.target), - transport: set.transport.into(), - stable_endpoint: set.stable.into(), - candidate_endpoint: set.candidate.into(), + transport: set.transport.clone(), + stable_endpoint: set.stable.clone(), + candidate_endpoint: set.candidate.clone(), }), capabilities: vec![IdunnExpectedCapability { capability: capability.clone(), @@ -168,22 +266,7 @@ fn build( compatibility: "v1".into(), minimum_capacity: set.minimum_capacity, }], - dependencies: if set.odin_dependency { - vec![IdunnExpectedDependency { - kind: "shared-infrastructure".into(), - capability: "odin.verse-rendezvous".into(), - schema: "odin.verse-topology.v1".into(), - compatibility: "v1".into(), - minimum_capacity: 1, - startup: "before-promotion".into(), - provider_id: Some("odin".into()), - provider_authority: Some("managed-incarnation".into()), - provider_expected_projection_sha256: Some(digest('5')), - provider_endpoint: Some("rudp://127.0.0.1:47871".into()), - }] - } else { - Vec::new() - }, + dependencies: set.dependencies.clone(), }; expected.validate()?; @@ -230,7 +313,7 @@ fn build( state_contract_sha256: expected.state_contract_sha256.clone(), runtime_id: expected.runtime_id.clone(), runtime_instance_id: activation.runtime_instance_id.clone(), - bound_endpoint: Some(set.candidate.into()), + bound_endpoint: Some(set.candidate.clone()), capabilities: vec![GameCultRuntimeCapability { capability: capability.clone(), schema: capability_schema.clone(), @@ -239,7 +322,7 @@ fn build( }], health_contract: expected.health_contract.clone(), state: state.into(), - detail: DETAIL.into(), + detail: spec.detail.clone(), write_lease_sha256, signer_identity_id: provider_anchor.identity_id.clone(), publisher_sequence: sequence, @@ -316,9 +399,12 @@ fn build( lease_epoch: 1, issued_at_unix_millis: OBSERVED_AT - 1_000, }; - let lease = lease_for(set.target, &warming_sha256); - let other_warming = lease_for(set.target, &digest('d')); - let other_target = lease_for("streampixels-web", &warming_sha256); + let other_target_name = set.lease_other_target.clone().unwrap_or_else(|| { + spec.sets.iter().map(|other| other.target.clone()).find(|target| *target != set.target).unwrap_or_else(|| format!("{}-other", set.target)) + }); + let lease = lease_for(&set.target, &warming_sha256); + let other_warming = lease_for(&set.target, &digest('d')); + let other_target = lease_for(&other_target_name, &warming_sha256); for (name, record) in [ ("lease.cc", &lease), ("lease-other-warming.cc", &other_warming), diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md index dd683a16..7856c78d 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -19,3 +19,24 @@ Ed25519 is deterministic, so the TypeScript signer must reproduce them byte for `presence-warming.bin`; `presence-active.bin` and `presence-degraded.bin` are sequence 2 and carry that lease's digest. `lease-other-warming.cc` names a different warming digest and `lease-other-target.cc` names another target; the TypeScript lease check must refuse both. + +## Generating a consumer's own set + +The generator takes its specification as input, so a consumer needs no patch to CultLib. The +specification is command-line flags; `@` splices in a response file (one `--flag value` +per line, `#` comments). CultLib's own sets are `fixture.args` here: + + cargo run -q --example idunn_runtime_fixture /tmp/out @/fixture.args + +The consumer keeps its own `.args` file in its repository and runs the same command with the +same machine-id bind mount as above. Global flags: `--source-repository`, `--detail`. Each +`--set ` opens a set written to `/`, and the flags after it configure it: +`--target`, `--transport`, `--stable`, `--candidate` (required); `--capability` (default +`.api`), `--schema` (default `.v1`), `--health-contract` (default +`.runtime-health`), `--minimum-capacity` (default 1), `--stateful` with +`--state-schema-generation` and `--lease-other-target`, and repeatable +`--dependency kind=..,capability=..,schema=..,provider=..,endpoint=..` (optional `compatibility`, +`capacity`, `startup`, `authority`, `projection`). The full grammar is the header of the example. + +Every generation enrolls fresh keys, so regenerated bytes differ from the committed ones. The +committed set stays; `fixture.args` regenerates the same file list, sizes and structure. diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args b/packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args new file mode 100644 index 00000000..04dc36aa --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args @@ -0,0 +1,35 @@ +# CultLib's own fixture sets, in the generator's response-file form: +# idunn_runtime_fixture @fixture.args +--source-repository github.com/GameCult/StreamPixels +--detail route-observation:route-challenge-1 + +--set web +--target streampixels-web +--transport http +--stable http://127.0.0.1:8830 +--candidate http://127.0.0.1:18830 + +--set service +--target streampixels-service +--transport tcp +--stable tcp://127.0.0.1:8831 +--candidate tcp://127.0.0.1:18831 +--minimum-capacity 2 +--dependency kind=shared-infrastructure,capability=odin.verse-rendezvous,schema=odin.verse-topology.v1,provider=odin,endpoint=rudp://127.0.0.1:47871 + +--set service-stateful +--target streampixels-service +--transport tcp +--stable tcp://127.0.0.1:8833 +--candidate tcp://127.0.0.1:18833 +--minimum-capacity 2 +--stateful +--state-schema-generation streampixels-state-v1 +--lease-other-target streampixels-web +--dependency kind=shared-infrastructure,capability=odin.verse-rendezvous,schema=odin.verse-topology.v1,provider=odin,endpoint=rudp://127.0.0.1:47871 + +--set rudp-route +--target streampixels-rudp +--transport rudp +--stable rudp://127.0.0.1:8832 +--candidate rudp://127.0.0.1:18832