Fix the BP-1 findings in the cultnet-rs content plane

A hostile manifest of empty chunks no longer becomes a request flood: fetch_content
refuses a zero-size chunk beside other chunks, and settles the content hash and every
chunk hash (normalised, non-empty) before the first request.

Every bad chunk request is now answered found:false in the reference handler's
validation order, never refused at decode. validate_message no longer refuses content
chunk requests or responses, so a blank message id or hash, a prefix-only hash and a
negative size reach answer_content_chunk_request. The answer follows the reference's
double normalisation (handler, then CreateRecordKey), which fixes the prefix-only
hash spelling to ArgumentException on 'chunkHash'. The cs vectors gain nine
reference-server answers (blank and whitespace id, blank, whitespace and prefix-only
hash, negative size, record-key case, blank record key, double prefix) and a
metadata-order manifest; Rust answers all of them byte-identically.

Manifest metadata is Vec<(String, String)> in wire order and refuses a duplicate key,
replacing the BTreeMap that re-sorted keys.

Tests pin the surviving mutants: unusable chunk sizes, the payload-length and
claimed-size halves of the chunk check, stable sort at equal offsets, the normalised
hash and canonical key actually sent, and the kind/mime defaults never trimming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 02:12:43 +02:00
commit 16432d4b1e
6 changed files with 551 additions and 70 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,8 +6,6 @@
//! This is the only owner of these rules in Rust. A consumer hands `fetch_content` a transport
//! closure and gets verified bytes; it never hashes, chunks or validates a manifest itself.
use std::collections::BTreeMap;
use anyhow::{Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@ -44,7 +42,9 @@ pub struct CultMeshCdnArtifactManifest {
pub created_at_utc: String,
pub chunks: Vec<CultMeshCdnChunkRef>,
pub tags: Vec<String>,
pub metadata: BTreeMap<String, 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
@ -63,9 +63,46 @@ struct ManifestWire(
String,
Vec<CultMeshCdnChunkRef>,
Vec<String>,
BTreeMap<String, 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)
@ -138,6 +175,15 @@ pub fn normalize_hash(hash: &str) -> Result<String> {
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(
@ -188,7 +234,7 @@ pub fn pack_content(
created_at_utc: created_at_utc.to_string(),
chunks: refs,
tags: Vec::new(),
metadata: BTreeMap::new(),
metadata: Vec::new(),
};
validate_manifest(&manifest)?;
Ok((manifest, chunks))
@ -277,13 +323,17 @@ pub fn answer_content_chunk_request<'a>(
if message_id.trim().is_empty() {
return Err("InvalidDataException: Content chunk request requires a message identity.".to_string());
}
let hash = normalize_hash(chunk_hash)
.map_err(|_| "ArgumentException: Hash must be non-empty. (Parameter 'ChunkHash')".to_string())?;
if !record_key.trim().is_empty() && *record_key != chunk_record_key(&hash) {
// The handler normalises the wire hash, then `CreateRecordKey` normalises that result again
// (and spells its parameter `chunkHash`); the lookup, the size check and the hash check all
// use the second result, and the answer echoes the first.
let blank = |parameter: &str| format!("ArgumentException: Hash must be non-empty. (Parameter '{parameter}')");
let hash = normalize_hash(chunk_hash).map_err(|_| blank("ChunkHash"))?;
let canonical = normalize_hash(&hash).map_err(|_| blank("chunkHash"))?;
if !record_key.trim().is_empty() && *record_key != chunk_record_key(&canonical) {
return Err("InvalidDataException: Content chunk record key disagrees with its content hash.".to_string());
}
let payload = lookup(&hash).ok_or_else(|| "FileNotFoundException: Content chunk is not available.".to_string())?;
validate_chunk(&hash, *expected_size_bytes, &hash, payload.len() as i64, payload)
let payload = lookup(&canonical).ok_or_else(|| "FileNotFoundException: Content chunk is not available.".to_string())?;
validate_chunk(&canonical, *expected_size_bytes, &canonical, payload.len() as i64, payload)
.map_err(|error| format!("InvalidDataException: {error}"))?;
Ok((hash, payload.to_vec()))
})();
@ -323,9 +373,20 @@ pub fn fetch_content(
manifest.size_bytes
);
}
// Everything the client will send or compare is settled before the first request. A zero-size
// chunk is legal only as an empty body's single chunk (the reference packs it so); anywhere else
// it is a request that yields nothing, and a manifest of them is a request flood.
let ordered = in_offset_order(manifest);
if ordered.len() > 1 && ordered.iter().any(|chunk| chunk.size_bytes == 0) {
bail!("CDN artifact has a zero-size chunk beside other chunks.");
}
let content_hash = non_empty_hash(&manifest.content_hash)?;
let hashes = ordered
.iter()
.map(|chunk| non_empty_hash(&chunk.chunk_hash))
.collect::<Result<Vec<_>>>()?;
let mut body = Vec::new();
for chunk in in_offset_order(manifest) {
let hash = normalize_hash(&chunk.chunk_hash)?;
for (chunk, hash) in ordered.into_iter().zip(hashes) {
let message_id = uuid::Uuid::new_v4().simple().to_string();
let record_key = if chunk.record_key.trim().is_empty() {
chunk_record_key(&hash)
@ -358,7 +419,7 @@ pub fn fetch_content(
validate_chunk(&hash, chunk.size_bytes, &chunk_hash, i64::from(size_bytes), &payload)?;
body.extend_from_slice(&payload);
}
if sha256_hex(&body) != normalize_hash(&manifest.content_hash)? {
if sha256_hex(&body) != content_hash {
bail!("CDN artifact content hash does not match its manifest.");
}
Ok(body)

View file

@ -801,40 +801,10 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
validate_shard_descriptor(shard)?;
}
}
CultNetMessage::ContentChunkRequest {
message_id,
chunk_hash,
record_key: _,
expected_size_bytes,
} => {
require_non_empty(message_id, "messageId")?;
if crate::normalize_hash(chunk_hash)?.is_empty() {
return Err(anyhow!("CultNet field chunkHash must be non-empty"));
}
if *expected_size_bytes < 0 {
return Err(anyhow!("CultNet field expectedSizeBytes must not be negative"));
}
}
CultNetMessage::ContentChunkResponse {
message_id,
found,
chunk_hash: _,
size_bytes,
payload,
error,
} => {
require_non_empty(message_id, "messageId")?;
if *found {
if i64::try_from(payload.len()).ok() != Some(i64::from(*size_bytes)) {
return Err(anyhow!("CultNet field payload length must equal sizeBytes"));
}
if !error.is_empty() {
return Err(anyhow!("CultNet field error must be empty when found"));
}
} else if !payload.is_empty() {
return Err(anyhow!("CultNet field payload must be empty when not found"));
}
}
// The reference's serializer accepts any content chunk message. It refuses a bad request in
// its handler and answers `found: false` (`answer_content_chunk_request`); a bad response is
// refused by `fetch_content`'s verification. Refusing either here would leave a peer in silence.
CultNetMessage::ContentChunkRequest { .. } | CultNetMessage::ContentChunkResponse { .. } => {}
CultNetMessage::OperationRequest {
message_id,
service_id,

View file

@ -61,6 +61,24 @@ 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(),
@ -171,6 +189,21 @@ fn local_vectors() -> Vec<Vector> {
request("vector-key", &last.chunk_hash, "mesh:cdn:chunk:other", last_size),
),
("answer_size_mismatch", request("vector-size", &last.chunk_hash, &key, last_size - 1)),
("answer_blank_message_id", request("", &last.chunk_hash, &key, last_size)),
("answer_whitespace_message_id", request(" ", &last.chunk_hash, &key, last_size)),
("answer_blank_hash", request("vector-blank-hash", "", "", 100)),
("answer_whitespace_hash", request("vector-space-hash", " ", "", 100)),
("answer_prefix_only_hash", request("vector-prefix-hash", "sha256: ", "", 100)),
("answer_negative_size", request("vector-negative", &last.chunk_hash, &key, -1)),
(
"answer_record_key_case",
request("vector-key-case", &last.chunk_hash, &key.to_uppercase(), last_size),
),
("answer_blank_record_key", request("vector-key-blank", &last.chunk_hash, " ", last_size)),
(
"answer_double_prefix",
request("vector-double", &format!("sha256:sha256:{}", last.chunk_hash), "", last_size),
),
];
for (name, request) in &answers {
push_message(format!("{name}.request"), "request", request);
@ -200,6 +233,8 @@ fn local_vectors() -> Vec<Vector> {
let mut shuffled = manifest.clone();
shuffled.chunks = [3, 0, 5, 1, 4, 2].iter().map(|&i| manifest.chunks[i].clone()).collect();
push_manifest("manifest_out_of_order", &shuffled);
let tagged = decode_manifest(&manifest_with_metadata(&manifest, &[("zeta", "1"), ("alpha", "2"), ("mid", "3")]));
push_manifest("manifest_metadata_order", &tagged);
push_manifest("manifest_empty_body", &pack(&[]).0);
vectors
}
@ -479,7 +514,7 @@ fn answer_serves_found_false_with_the_reference_error_spelling() {
.expect("paired response");
assert_eq!(&wire(&answer), expected_bytes, "{name}: answer differs from the reference's");
let (_, found, _, payload, error) = response_of(&answer);
if name == "answer_found" {
if ["answer_found", "answer_blank_record_key", "answer_double_prefix"].contains(&name) {
assert!(found && error.is_empty() && !payload.is_empty());
} else {
assert!(!found && payload.is_empty(), "{name}");
@ -487,28 +522,7 @@ fn answer_serves_found_false_with_the_reference_error_spelling() {
}
judged += 1;
}
assert_eq!(judged, 4);
}
#[test]
fn content_messages_refuse_what_the_reference_never_produces() {
let refused = |message: CultNetMessage| assert!(encode_cultnet_message_for_wire(&message, SCHEMA_V0).is_err(), "{message:?}");
refused(request("", "abc", "", 1));
refused(request("m", "sha256: ", "", 1));
refused(request("m", "abc", "", -1));
let response = |found: bool, size: i32, payload: &[u8], error: &str| CultNetMessage::ContentChunkResponse {
message_id: "m".to_string(),
found,
chunk_hash: "abc".to_string(),
size_bytes: size,
payload: payload.to_vec(),
error: error.to_string(),
};
refused(response(true, 3, &[1, 2], ""));
refused(response(true, 2, &[1, 2], "boom"));
refused(response(false, 0, &[1], "boom"));
assert!(encode_cultnet_message_for_wire(&response(true, 2, &[1, 2], ""), SCHEMA_V0).is_ok());
assert!(encode_cultnet_message_for_wire(&response(false, 0, &[], "boom"), SCHEMA_V0).is_ok());
assert_eq!(judged, 13);
}
#[test]
@ -518,3 +532,236 @@ fn gamecult_networking_contract_refuses_content_messages() {
assert!(encode_cultnet_message_for_wire(&message, legacy).is_err());
assert!(decode_cultnet_message_from_slice(&wire(&message), legacy).is_err());
}
// ------------------------------------------------------------------------------------------
// BP-1 fixes
// ------------------------------------------------------------------------------------------
fn chunk_ref(hash: &str, offset: i64, size: i32) -> cultnet_rs::CultMeshCdnChunkRef {
cultnet_rs::CultMeshCdnChunkRef {
chunk_hash: hash.to_string(),
offset,
size_bytes: size,
record_key: String::new(),
}
}
/// A manifest with the given chunk list and stated size, everything else from a real pack.
fn manifest_of(size: i64, chunks: Vec<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}");
}
}
// 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);
}
// 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");
}
}

View file

@ -164,6 +164,16 @@ public sealed class CultMeshContentVectorTests
AddAnswer("answer_not_found", Request("vector-missing", new string('0', 64), string.Empty, 100));
AddAnswer("answer_record_key_disagrees", Request("vector-key", last.ChunkHash, "mesh:cdn:chunk:other", last.SizeBytes));
AddAnswer("answer_size_mismatch", Request("vector-size", last.ChunkHash, key, last.SizeBytes - 1));
// Requests the reference refuses in its handler, never at deserialisation: each is answered.
AddAnswer("answer_blank_message_id", Request(string.Empty, last.ChunkHash, key, last.SizeBytes));
AddAnswer("answer_whitespace_message_id", Request(" ", last.ChunkHash, key, last.SizeBytes));
AddAnswer("answer_blank_hash", Request("vector-blank-hash", string.Empty, string.Empty, 100));
AddAnswer("answer_whitespace_hash", Request("vector-space-hash", " ", string.Empty, 100));
AddAnswer("answer_prefix_only_hash", Request("vector-prefix-hash", "sha256: ", string.Empty, 100));
AddAnswer("answer_negative_size", Request("vector-negative", last.ChunkHash, key, -1));
AddAnswer("answer_record_key_case", Request("vector-key-case", last.ChunkHash, key.ToUpperInvariant(), last.SizeBytes));
AddAnswer("answer_blank_record_key", Request("vector-key-blank", last.ChunkHash, " ", last.SizeBytes));
AddAnswer("answer_double_prefix", Request("vector-double", "sha256:sha256:" + last.ChunkHash, string.Empty, last.SizeBytes));
AddResponse("response_hash_last_digit", new CultMeshContentChunkResponseMessage
{
MessageId = "vector-digit",
@ -184,6 +194,9 @@ public sealed class CultMeshContentVectorTests
var shuffled = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest;
shuffled.Chunks = new[] { 3, 0, 5, 1, 4, 2 }.Select(i => shuffled.Chunks[i]).ToArray();
AddManifest("manifest_out_of_order", shuffled);
var tagged = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest;
tagged.Metadata = new Dictionary<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;
}