Merge remote-tracking branch 'origin/main' into hands/variants-c1
This commit is contained in:
commit
de65c82768
52 changed files with 3239 additions and 447 deletions
189
contracts/cultmesh/content-vectors.cs-written.json
Normal file
189
contracts/cultmesh/content-vectors.cs-written.json
Normal file
File diff suppressed because one or more lines are too long
159
contracts/cultmesh/content-vectors.rs-written.json
Normal file
159
contracts/cultmesh/content-vectors.rs-written.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -78,6 +78,23 @@ 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 (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
|
||||
`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.
|
||||
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
|
||||
|
||||
|
|
|
|||
445
packages/cultnet-rs/examples/idunn_runtime_fixture.rs
Normal file
445
packages/cultnet-rs/examples/idunn_runtime_fixture.rs
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! 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 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 <out-dir> <spec>...
|
||||
//!
|
||||
//! The specification is command-line flags. `@<file>` 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 <repo> Expected.source_repository (default github.com/GameCult/Example)
|
||||
//! --detail <text> presence detail (default route-observation:route-challenge-1)
|
||||
//!
|
||||
//! `--set <dir>` opens a fixture set written to `<out-dir>/<dir>`; the flags
|
||||
//! below apply to the set most recently opened:
|
||||
//! --target <name> required
|
||||
//! --transport <t> --stable <endpoint> --candidate <endpoint> required: the route
|
||||
//! --capability <name> default `<target>.api`
|
||||
//! --schema <schema> default `<capability>.v1`
|
||||
//! --health-contract <contract> default `<target>.runtime-health`
|
||||
//! --minimum-capacity <n> default 1; above 1 also writes presence-active-below-minimum.bin
|
||||
//! --stateful state lineage, write lease and lease vectors
|
||||
//! --state-schema-generation <id> stateful only; default state-v1
|
||||
//! --lease-other-target <name> 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::{Context, Result, bail, ensure};
|
||||
use cultcache_rs::{
|
||||
CacheBackingStore, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore,
|
||||
};
|
||||
use cultnet_rs::{
|
||||
GameCultProviderHealthIdentity, GameCultRuntimeCapability,
|
||||
GameCultRuntimePresenceHealthPurpose, GameCultRuntimePresenceHealthRecord,
|
||||
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,
|
||||
};
|
||||
|
||||
/// 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 STORED_AT: &str = "2026-01-01T00:00:00.000Z";
|
||||
|
||||
struct Spec {
|
||||
source_repository: String,
|
||||
detail: String,
|
||||
sets: Vec<Set>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Set {
|
||||
dir: String,
|
||||
target: String,
|
||||
transport: String,
|
||||
stable: String,
|
||||
candidate: String,
|
||||
capability: Option<String>,
|
||||
schema: Option<String>,
|
||||
health_contract: Option<String>,
|
||||
minimum_capacity: u32,
|
||||
stateful: bool,
|
||||
state_schema_generation: Option<String>,
|
||||
lease_other_target: Option<String>,
|
||||
dependencies: Vec<IdunnExpectedDependency>,
|
||||
}
|
||||
|
||||
fn expand(args: impl Iterator<Item = String>) -> Result<Vec<String>> {
|
||||
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<IdunnExpectedDependency> {
|
||||
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<String> {
|
||||
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::<Vec<_>>());
|
||||
Ok(dependency)
|
||||
}
|
||||
|
||||
fn parse(args: impl Iterator<Item = String>) -> Result<Spec> {
|
||||
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 mut args = std::env::args().skip(1);
|
||||
let Some(out) = args.next() else {
|
||||
bail!("usage: idunn_runtime_fixture <out-dir> <spec>... (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())?;
|
||||
let idunn = enroll_service_identity_at::<IdunnServiceIdentity>(
|
||||
&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 &spec.sets {
|
||||
build(Path::new(&out).join(&set.dir).as_path(), &spec, set, &idunn, &idunn_anchor)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn digest(byte: char) -> String {
|
||||
format!("sha256-{}", byte.to_string().repeat(64))
|
||||
}
|
||||
|
||||
fn build(
|
||||
dir: &Path,
|
||||
spec: &Spec,
|
||||
set: &Set,
|
||||
idunn: &cultnet_rs::ServiceIdentitySigner<IdunnServiceIdentity>,
|
||||
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::<GameCultProviderHealthIdentity>(&provider_path)?;
|
||||
let provider_anchor = provider.trust_anchor()?;
|
||||
let _ = fs::remove_file(dir.join("provider-identity.credential.lock"));
|
||||
|
||||
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.clone(),
|
||||
plan_id: digest('1'),
|
||||
incarnation_id: format!("{}/incarnation-1", set.target),
|
||||
sealed_release_id: digest('2'),
|
||||
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: set.health_contract.clone().unwrap_or_else(|| format!("{}.runtime-health", set.target)),
|
||||
artifact_sha256: digest('4'),
|
||||
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.clone(),
|
||||
stable_endpoint: set.stable.clone(),
|
||||
candidate_endpoint: set.candidate.clone(),
|
||||
}),
|
||||
capabilities: vec![IdunnExpectedCapability {
|
||||
capability: capability.clone(),
|
||||
schema: capability_schema.clone(),
|
||||
compatibility: "v1".into(),
|
||||
minimum_capacity: set.minimum_capacity,
|
||||
}],
|
||||
dependencies: set.dependencies.clone(),
|
||||
};
|
||||
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 sign_vector = |name: &str,
|
||||
state: &str,
|
||||
capacity: u32,
|
||||
sequence: u64,
|
||||
write_lease_sha256: Option<String>,
|
||||
disagreement: Option<&str>|
|
||||
-> Result<String> {
|
||||
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: 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.clone()),
|
||||
capabilities: vec![GameCultRuntimeCapability {
|
||||
capability: capability.clone(),
|
||||
schema: capability_schema.clone(),
|
||||
compatibility: "v1".into(),
|
||||
capacity,
|
||||
}],
|
||||
health_contract: expected.health_contract.clone(),
|
||||
state: state.into(),
|
||||
detail: spec.detail.clone(),
|
||||
write_lease_sha256,
|
||||
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::<GameCultRuntimePresenceHealthPurpose>(&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 presence_sha256 = claim.signed_presence_sha256().to_string();
|
||||
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(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 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),
|
||||
("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(())
|
||||
}
|
||||
|
||||
fn write_record(path: &Path, key: &str, kind: &str, schema: &str, payload: Vec<u8>) -> Result<()> {
|
||||
SingleFileMessagePackBackingStore::new(path).push(&CultCacheEnvelope {
|
||||
key: key.into(),
|
||||
r#type: kind.into(),
|
||||
payload,
|
||||
stored_at: STORED_AT.into(),
|
||||
schema_id: Some(schema.into()),
|
||||
})?;
|
||||
let mut lock = path.as_os_str().to_owned();
|
||||
lock.push(".lock");
|
||||
let _ = fs::remove_file(lock);
|
||||
Ok(())
|
||||
}
|
||||
426
packages/cultnet-rs/src/content.rs
Normal file
426
packages/cultnet-rs/src/content.rs
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
//! 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 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<u8>,
|
||||
}
|
||||
|
||||
/// `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<CultMeshCdnChunkRef>,
|
||||
pub tags: Vec<String>,
|
||||
/// 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
|
||||
// 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<CultMeshCdnChunkRef>,
|
||||
Vec<String>,
|
||||
#[serde(with = "ordered_map")] Vec<(String, String)>,
|
||||
);
|
||||
|
||||
/// A string map that keeps its key order and refuses a duplicate key, as `Dictionary<string, string>`
|
||||
/// 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<S: Serializer>(pairs: &[(String, String)], serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.collect_map(pairs.iter().map(|(key, value)| (key, value)))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<(String, String)>, 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<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut pairs = Vec::new();
|
||||
while let Some((key, value)) = map.next_entry::<String, String>()? {
|
||||
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<CultMeshCdnChunkRef> for ChunkRefWire {
|
||||
fn from(value: CultMeshCdnChunkRef) -> Self {
|
||||
Self(value.chunk_hash, value.offset, value.size_bytes, value.record_key)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChunkRefWire> 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<CultMeshCdnArtifactManifest> 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<ManifestWire> 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<String> {
|
||||
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())
|
||||
}
|
||||
|
||||
/// A hash the client will put on the wire or compare against: normalized and not empty.
|
||||
fn non_empty_hash(hash: &str) -> Result<String> {
|
||||
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(
|
||||
artifact_id: &str,
|
||||
kind: &str,
|
||||
version: &str,
|
||||
mime_type: &str,
|
||||
created_at_utc: &str,
|
||||
bytes: &[u8],
|
||||
chunk_size: usize,
|
||||
) -> Result<(CultMeshCdnArtifactManifest, Vec<CultMeshCdnChunk>)> {
|
||||
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: Vec::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:<hash>`, lookup, then size and hash of
|
||||
/// what is served. A failure is `found: false` with an empty payload and the reference's
|
||||
/// `<ExceptionKind>: <message>` 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<u8>), String> {
|
||||
if message_id.trim().is_empty() {
|
||||
return Err("InvalidDataException: Content chunk request requires a message identity.".to_string());
|
||||
}
|
||||
// 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(&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()))
|
||||
})();
|
||||
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<CultNetMessage>,
|
||||
) -> Result<Vec<u8>> {
|
||||
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
|
||||
);
|
||||
}
|
||||
// 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::<Result<Vec<_>>>()?;
|
||||
let mut body = Vec::new();
|
||||
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)
|
||||
} 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) != content_hash {
|
||||
bail!("CDN artifact content hash does not match its manifest.");
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
|
@ -508,6 +508,26 @@ pub enum CultNetMessage {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
source_runtime_id: Option<String>,
|
||||
},
|
||||
/// `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<u8>,
|
||||
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,10 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
|
|||
validate_shard_descriptor(shard)?;
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
|
|
@ -1044,6 +1072,23 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result<CultNetMessag
|
|||
)?,
|
||||
})
|
||||
}
|
||||
"cultmesh.content_chunk_request.v1" => Ok(CultNetMessage::ContentChunkRequest {
|
||||
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 {
|
||||
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 +1254,43 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::V
|
|||
.unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
],
|
||||
CultNetMessage::ContentChunkRequest {
|
||||
message_id,
|
||||
chunk_hash,
|
||||
record_key,
|
||||
expected_size_bytes,
|
||||
} => 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 +1504,13 @@ fn require_legacy_u32(value: Option<&rmpv::Value>, field_name: &str) -> Result<u
|
|||
u32::try_from(number).map_err(|_| anyhow!("{field_name} does not fit in u32"))
|
||||
}
|
||||
|
||||
fn require_legacy_i32(value: Option<&rmpv::Value>, field_name: &str) -> Result<i32> {
|
||||
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<u64> {
|
||||
value
|
||||
.and_then(rmpv::Value::as_u64)
|
||||
|
|
@ -1632,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<String> {
|
||||
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<String> {
|
||||
let value = value
|
||||
.and_then(rmpv::Value::as_str)
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
806
packages/cultnet-rs/tests/content.rs
Normal file
806
packages/cultnet-rs/tests/content.rs
Normal file
|
|
@ -0,0 +1,806 @@
|
|||
//! 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<u8> {
|
||||
(0..BODY_LEN).map(|i| (i % 251) as u8).collect()
|
||||
}
|
||||
|
||||
fn pack(bytes: &[u8]) -> (CultMeshCdnArtifactManifest, Vec<cultnet_rs::CultMeshCdnChunk>) {
|
||||
pack_content(
|
||||
"eureka/content-vector",
|
||||
"asset",
|
||||
"1",
|
||||
"application/x-cultnet-vector",
|
||||
CREATED,
|
||||
bytes,
|
||||
CHUNK,
|
||||
)
|
||||
.expect("packs")
|
||||
}
|
||||
|
||||
fn store(chunks: &[cultnet_rs::CultMeshCdnChunk]) -> HashMap<String, Vec<u8>> {
|
||||
chunks
|
||||
.iter()
|
||||
.map(|chunk| (chunk.chunk_hash.clone(), chunk.payload.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lookup_in<'a>(store: &'a HashMap<String, Vec<u8>>) -> impl Fn(&str) -> Option<&'a [u8]> {
|
||||
move |hash| store.get(hash).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
fn wire(message: &CultNetMessage) -> Vec<u8> {
|
||||
encode_cultnet_message_to_vec(message, SCHEMA_V0).expect("encodes")
|
||||
}
|
||||
|
||||
fn unwire(bytes: &[u8]) -> Result<CultNetMessage> {
|
||||
decode_cultnet_message_from_slice(bytes, SCHEMA_V0)
|
||||
}
|
||||
|
||||
fn manifest_bytes(manifest: &CultMeshCdnArtifactManifest) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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(),
|
||||
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<String, Vec<u8>>,
|
||||
calls: &'a std::cell::Cell<usize>,
|
||||
) -> impl FnMut(CultNetMessage) -> Result<CultNetMessage> + '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<VectorEntry>,
|
||||
}
|
||||
|
||||
#[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<u8>,
|
||||
}
|
||||
|
||||
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<u8>)> {
|
||||
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<Vector> {
|
||||
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)),
|
||||
("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);
|
||||
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);
|
||||
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
|
||||
}
|
||||
|
||||
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();
|
||||
// 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::<Vec<_>>(),
|
||||
local.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
|
||||
"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<u8> = (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::<u8>::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 ["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}");
|
||||
assert!(error.contains("Exception: "), "{name}: {error}");
|
||||
}
|
||||
judged += 1;
|
||||
}
|
||||
assert_eq!(judged, 16);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// 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<cultnet_rs::CultMeshCdnChunkRef>) -> 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::<u8>::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}");
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
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::<CultMeshCdnArtifactManifest>(&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::<CultMeshCdnArtifactManifest>(¬_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.
|
||||
#[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, Vec<u8>>) -> (String, bool, String, i32, Vec<u8>, 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<u8>) -> (i32, Vec<u8>);
|
||||
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<String> = 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
244
packages/cultnet-ts/src/idunn-odin-presence-publisher.ts
Normal file
244
packages/cultnet-ts/src/idunn-odin-presence-publisher.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
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";
|
||||
|
||||
const ODIN_CAPABILITY = "odin.verse-rendezvous";
|
||||
const MAX_LEASE_WAIT_ROUNDS = 720;
|
||||
|
||||
export type IdunnRuntimePresencePublisher = {
|
||||
readonly runtimeId: string;
|
||||
readonly runtimeInstanceId: string;
|
||||
readonly requiresWriteLease: boolean;
|
||||
/** Publishes the signer's presence in the health the signer reports. */
|
||||
publish(detail: string): Promise<string>;
|
||||
/**
|
||||
* 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<string>;
|
||||
assertWriteLease(): Promise<string>;
|
||||
};
|
||||
|
||||
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 (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,
|
||||
});
|
||||
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("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<string, unknown>,
|
||||
): Promise<void> {
|
||||
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
|
||||
await new Promise<void>((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<CultNetRudpPacket>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
const wire = encodeRudpPacket(packet);
|
||||
await new Promise<void>((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<void> {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -32,7 +29,10 @@ 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<object>();
|
||||
const authorityKeys = new WeakMap<object, { providerPrivateKey: crypto.KeyObject; activationPrivateKey: crypto.KeyObject }>();
|
||||
|
||||
type ExpectedIncarnation = {
|
||||
|
|
@ -54,7 +54,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;
|
||||
};
|
||||
|
||||
|
|
@ -64,46 +63,77 @@ type RuntimeActivation = {
|
|||
runtimeId: string;
|
||||
runtimeInstanceId: string;
|
||||
activationSignerIdentityId: string;
|
||||
activationSignerPublicKey: Buffer;
|
||||
activationSignerPublicKeyHex: string;
|
||||
canonicalSha256: string;
|
||||
};
|
||||
|
||||
export type IdunnRuntimeAuthority = {
|
||||
type DeepReadonly<T> = T extends readonly (infer U)[]
|
||||
? readonly DeepReadonly<U>[]
|
||||
: T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : 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;
|
||||
canonicalSha256: string;
|
||||
};
|
||||
|
||||
export type IdunnRuntimePresencePublisher = {
|
||||
/**
|
||||
* 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`. 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;
|
||||
latestPresenceDocument(): CultNetRawDocumentRecord | null;
|
||||
publishRouteObservation(request: CultNetSnapshotRequestMessage): Promise<CultNetRawDocumentRecord>;
|
||||
publish(state: RuntimePresenceHealth["state"], detail: string): Promise<string>;
|
||||
waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise<string>;
|
||||
assertWriteLease(): Promise<string>;
|
||||
/** 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 reported health, with no network.
|
||||
*/
|
||||
answerRouteObservation(request: CultNetSnapshotRequestMessage): CultNetRawDocumentRecord;
|
||||
/** Reads Idunn's write lease and checks it names this incarnation and a warming presence this signer signed. */
|
||||
assertWriteLease(): string;
|
||||
};
|
||||
|
||||
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 +143,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.");
|
||||
|
|
@ -138,7 +163,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.");
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +178,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,
|
||||
|
|
@ -162,16 +187,17 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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<RuntimePresenceHealth, "target" | "expectedProjectionSha256" | "planId" | "incarnationId" | "sealedReleaseId" | "activationWitnessSha256" | "stateSchemaGeneration" | "stateContractSha256" | "runtimeId" | "runtimeInstanceId" | "boundEndpoint" | "healthContract" | "signerIdentityId" | "activationSignerIdentityId">,
|
||||
): { presence: RuntimePresenceHealth; payload: Uint8Array; canonicalSha256: string } {
|
||||
|
|
@ -205,42 +231,35 @@ 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;
|
||||
/**
|
||||
* 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 latestLeaseSha256: string | null = null;
|
||||
let latestPresenceDocument: CultNetRawDocumentRecord | null = null;
|
||||
let health: RuntimePresenceHealth["state"] = "warming";
|
||||
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 = (detail: string): SignedRuntimePresence => {
|
||||
const state = health;
|
||||
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 +267,35 @@ 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) {
|
||||
reportHealth(state) {
|
||||
health = state;
|
||||
},
|
||||
sign,
|
||||
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))
|
||||
|
|
@ -293,157 +303,18 @@ 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(`route-observation:${request.messageId}`).document;
|
||||
},
|
||||
assertWriteLease,
|
||||
};
|
||||
}
|
||||
|
||||
async function publishDocument(
|
||||
endpoint: { host: string; port: number },
|
||||
connectionId: number,
|
||||
message: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
|
||||
await new Promise<void>((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();
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
Object.freeze(value);
|
||||
}
|
||||
}
|
||||
|
||||
type PacketReceiver = {
|
||||
socket: dgram.Socket;
|
||||
next(timeoutMs: number): Promise<CultNetRudpPacket>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
const wire = encodeRudpPacket(packet);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.send(wire, endpoint.port, endpoint.host, (error) => error ? reject(error) : resolve());
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
function readAuthorityRecord(filePath: string, type: string, schemaId: string): { payload: Uint8Array } {
|
||||
|
|
@ -495,9 +366,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,
|
||||
|
|
@ -517,7 +386,6 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation {
|
|||
route,
|
||||
capabilities: decodeExpectedCapabilities(values[16]),
|
||||
dependencies: decodeExpectedDependencies(values[17]),
|
||||
canonicalBytes,
|
||||
canonicalSha256: prefixedSha256(canonicalBytes),
|
||||
};
|
||||
}
|
||||
|
|
@ -545,7 +413,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),
|
||||
};
|
||||
}
|
||||
|
|
@ -575,6 +443,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<string, { stable: string[]; candidate: string[] }> = {
|
||||
http: { stable: ["http://", "https://"], candidate: ["http://"] },
|
||||
tcp: { stable: ["tcp://"], candidate: ["tcp://"] },
|
||||
rudp: { stable: ["rudp://"], candidate: ["rudp://"] },
|
||||
};
|
||||
|
||||
function validateRoute(route: NonNullable<ExpectedIncarnation["route"]>): 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 +481,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 +502,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 +588,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<void> {
|
||||
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.`);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
1
packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes
vendored
Normal file
1
packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* -text
|
||||
42
packages/cultnet-ts/test/fixtures/idunn-runtime/README.md
vendored
Normal file
42
packages/cultnet-ts/test/fixtures/idunn-runtime/README.md
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Idunn runtime fixtures
|
||||
|
||||
Written by the Rust owner, not by the TypeScript tests. Regenerate with
|
||||
`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:
|
||||
|
||||
DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh <CultLib> HEAD rust 'cd packages/cultnet-rs && cp <fixed-machine-id> /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. `--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.
|
||||
|
||||
## 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; `@<file>` 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 @<path>/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 <dir>` opens a set written to `<out>/<dir>`, and the flags after it configure it:
|
||||
`--target`, `--transport`, `--stable`, `--candidate` (required); `--capability` (default
|
||||
`<target>.api`), `--schema` (default `<capability>.v1`), `--health-contract` (default
|
||||
`<target>.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.
|
||||
35
packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args
vendored
Normal file
35
packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# CultLib's own fixture sets, in the generator's response-file form:
|
||||
# idunn_runtime_fixture <out-dir> @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
|
||||
1
packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id
vendored
Normal file
1
packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1
|
||||
1
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed
vendored
Normal file
1
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
<EFBFBD>_<EFBFBD>#<23>/<2F>"Q0\<5C>iC<69>]\ʝ<><CA9D><EFBFBD><EFBFBD>;<3B>Y<EFBFBD><14><>k
|
||||
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential
vendored
Normal file
Binary file not shown.
1
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed
vendored
Normal file
1
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
Kýaźz´-2‰ËˇV>)ë»xěşÚĎ®˘T
=/‰*
|
||||
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential
vendored
Normal file
Binary file not shown.
1
packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed
vendored
Normal file
1
packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
<EFBFBD><EFBFBD><EFBFBD>Bf<EFBFBD><EFBFBD>=?<3F><><EFBFBD>Z<EFBFBD>j<EFBFBD><6A><EFBFBD>&<26><15><>3<EFBFBD><33>ܟf<DC9F>mJ
|
||||
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential
vendored
Normal file
Binary file not shown.
1
packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed
vendored
Normal file
1
packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
¯ÂOd×f¯l*oµ‘ÀFªv‰n<E280B0>hLûÑpšS´p
|
||||
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential
vendored
Normal file
BIN
packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential
vendored
Normal file
Binary file not shown.
|
|
@ -1,30 +1,98 @@
|
|||
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 { spawnSync } from "node:child_process";
|
||||
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" | "service-stateful";
|
||||
|
||||
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"; lease?: string } = {},
|
||||
): 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;
|
||||
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";
|
||||
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, lease?: string) {
|
||||
const authority = openAuthority(context, set, { lease });
|
||||
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 +102,290 @@ 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).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/,
|
||||
);
|
||||
});
|
||||
|
||||
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, "service", -1);
|
||||
signer.reportHealth("active");
|
||||
assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile("service", "presence-active-below-minimum.bin"));
|
||||
});
|
||||
|
||||
test("the signer owns the reported health: a fresh signer is warming, reportHealth moves it, the sequence keeps counting", (context) => {
|
||||
freezeClock(context);
|
||||
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").document, 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);
|
||||
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), /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<void>((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;
|
||||
}
|
||||
|
||||
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");
|
||||
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 });
|
||||
|
||||
descriptorProviderCredential = Buffer.from(encode([
|
||||
...(decode(providerCredential) as unknown[][]),
|
||||
...(decode(providerCredential) as unknown[][]),
|
||||
]));
|
||||
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");
|
||||
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");
|
||||
|
||||
peer.reject = true;
|
||||
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 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);
|
||||
});
|
||||
|
||||
test("the Odin publisher takes only the endpoint Expected names", (context) => {
|
||||
assert.throws(
|
||||
() => loadIdunnRuntimeAuthorityFromEnvironment(target, contract, "127.0.0.1:17871"),
|
||||
/exactly one envelope/,
|
||||
() => 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/,
|
||||
);
|
||||
});
|
||||
|
||||
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 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],
|
||||
);
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
function privateKeyFromSeed(seed: Uint8Array): crypto.KeyObject {
|
||||
return crypto.createPrivateKey({
|
||||
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from(seed)]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
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/);
|
||||
});
|
||||
}
|
||||
|
||||
function rawPublic(key: crypto.KeyObject): Buffer {
|
||||
const der = key.export({ type: "spki", format: "der" }) as Buffer;
|
||||
return Buffer.from(der.subarray(der.length - 32));
|
||||
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);
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
function sha256Hex(bytes: Uint8Array): string {
|
||||
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).signIdunnRuntimePresence, undefined);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 _);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
284
tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs
Normal file
284
tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Content-plane parity vectors shared with <c>packages/cultnet-rs/tests/content.rs</c>. Each runtime
|
||||
/// writes its own file under <c>contracts/cultmesh</c> and judges both. The chunk answers in these
|
||||
/// files are produced by <see cref="CultMeshLegacyRudpContentServer"/> itself, so the failure
|
||||
/// spellings are the reference's own bytes.
|
||||
/// </summary>
|
||||
[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<CultMeshContentChunkRequestMessage>(vector.Bytes, CultNetSchemaMessageSerialization.Options);
|
||||
request.Should().BeEquivalentTo((CultMeshContentChunkRequestMessage)expected);
|
||||
Reencode(request).Should().Equal(vector.Bytes, vector.Label);
|
||||
break;
|
||||
case "response":
|
||||
var response = MessagePackSerializer.Deserialize<CultMeshContentChunkResponseMessage>(vector.Bytes, CultNetSchemaMessageSerialization.Options);
|
||||
response.Should().BeEquivalentTo((CultMeshContentChunkResponseMessage)expected);
|
||||
Reencode(response).Should().Equal(vector.Bytes, vector.Label);
|
||||
break;
|
||||
case "manifest":
|
||||
var manifest = MessagePackSerializer.Deserialize<CultMeshCdnArtifactManifest>(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<CultMeshContentChunkRequestMessage>(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
|
||||
};
|
||||
|
||||
/// <summary>The cs-written file's vectors, in file order. Messages are keyed by label.</summary>
|
||||
private static List<Vector> BuildVectors(out CultCache cache, out Dictionary<string, object> 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<Vector>();
|
||||
var map = new Dictionary<string, object>(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));
|
||||
// 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));
|
||||
|
||||
// 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",
|
||||
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);
|
||||
var tagged = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest;
|
||||
tagged.Metadata = new Dictionary<string, string> { ["zeta"] = "1", ["alpha"] = "2", ["mid"] = "3" };
|
||||
AddManifest("manifest_metadata_order", tagged);
|
||||
AddManifest("manifest_empty_body", CultMeshCdn.PackArtifact(ArtifactId, Array.Empty<byte>(), 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<CultMeshContentChunkRequestMessage, ICultNetSchemaServerPeer, Task>? _handler;
|
||||
|
||||
public void OnCultNet<TMessage>(Func<TMessage, ICultNetSchemaServerPeer, Task> callback)
|
||||
where TMessage : ICultNetSchemaMessage =>
|
||||
_handler = (Func<CultMeshContentChunkRequestMessage, ICultNetSchemaServerPeer, Task>)(object)callback;
|
||||
|
||||
public void RemoveCultNetMessageListener<TMessage>(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>(TMessage message) where TMessage : ICultNetSchemaMessage =>
|
||||
Sent = (CultMeshContentChunkResponseMessage)(object)message;
|
||||
}
|
||||
|
||||
private static string ToJson(List<Vector> 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<Vector> 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.");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue