feat(adr-118/p4.4): rf_signature_hash JSON as "blake3:<hex>" (128/128 GREEN)
Iter 17. Lands the BFLD JSON wire spec format for rf_signature_hash —
a "blake3:" prefix followed by 64 lowercase hex chars. Replaces the
default serde array-of-integers encoding which was unusable for
downstream consumers (HA, Matter, MQTT).
Added (in src/event.rs):
- ser_rf_signature_hash<S>(hash: &Option<[u8;32]>, s) custom serializer
- Field attribute on BfldEvent.rf_signature_hash now uses
serialize_with = "ser_rf_signature_hash" alongside skip_serializing_if
- nibble_to_hex(u8) -> char private const fn (no `hex` crate dep needed
for 32 bytes; lowercase hex is trivial)
- Output format: "blake3:deadbeef..." exactly 71 ASCII chars
tests/json_hash_format.rs (5 named tests, all green):
rf_signature_hash_serializes_as_blake3_prefixed_lowercase_hex
(expected hex built programmatically via format!("{b:02x}"))
hex_string_is_always_64_chars_when_present
(parses the JSON, isolates the hash substring, asserts exact 64
chars and lowercase-only — catches case-folding regressions)
hash_field_omitted_entirely_when_none
end_to_end_emitter_hasher_to_json_emits_blake3_hex_hash
*** Cross-iter integration test: BfldEmitter::with_signature_hasher
→ SensingInputs.rf_signature_hash = None → emit derives via
BLAKE3 → BfldEvent::to_json → contains "blake3:" prefix.
Spans iters 13, 14, 15, 16, 17 in a single assertion. ***
end_to_end_restricted_class_omits_hash_even_with_hasher_set
(class 3: even with hasher installed, JSON omits the hash)
ACs progressed:
- BFLD wire spec §6 — rf_signature_hash JSON shape now matches the
documented format ("blake3:..."); HA / Matter consumers can parse
it without custom byte-array decoding.
- ADR-118 §1 invariant I3 — visibility: the JSON wire form now
cryptographically tags the hash with its algorithm prefix, so
consumers can verify they're not parsing a different (weaker)
hash that a future PR might accidentally substitute.
Test config:
- cargo test --no-default-features → 72 passed (json_hash_format cfg-out)
- cargo test → 128 passed (123 + 5)
Out of scope (next iter target):
- IdentityFeatures typed encoder so callers feeding BfldEmitter don't
need to know that embedding bytes serve as hasher input.
- Replace the manual hex push with `hex::encode` if/when the workspace
takes on the `hex` crate dep for other reasons; current path saves
the dep without sacrificing correctness.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
351af66084
commit
29f23cb97e
|
|
@ -64,7 +64,11 @@ pub struct BfldEvent {
|
|||
pub identity_risk_score: Option<f32>,
|
||||
|
||||
/// 256-bit BLAKE3 keyed hash of the current cluster. Class 2 only; `None` at class 3.
|
||||
#[cfg_attr(feature = "serde-json", serde(skip_serializing_if = "Option::is_none"))]
|
||||
/// Serializes as the JSON string `"blake3:<64-hex>"` per the BFLD wire spec.
|
||||
#[cfg_attr(
|
||||
feature = "serde-json",
|
||||
serde(skip_serializing_if = "Option::is_none", serialize_with = "ser_rf_signature_hash")
|
||||
)]
|
||||
pub rf_signature_hash: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
|
|
@ -134,3 +138,33 @@ fn ser_privacy_class<S: serde::Serializer>(
|
|||
};
|
||||
s.serialize_str(name)
|
||||
}
|
||||
|
||||
/// Encode an `Option<[u8; 32]>` as the JSON string `"blake3:<64 lowercase hex chars>"`.
|
||||
/// Used for `rf_signature_hash` so consumers don't have to decode a 32-element JSON
|
||||
/// array of integers. Called only when the value is `Some(_)` because
|
||||
/// `skip_serializing_if = "Option::is_none"` short-circuits the `None` case.
|
||||
#[cfg(feature = "serde-json")]
|
||||
fn ser_rf_signature_hash<S: serde::Serializer>(
|
||||
hash: &Option<[u8; 32]>,
|
||||
s: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
// The unwrap is safe: skip_serializing_if guarantees we only run with Some.
|
||||
let bytes = hash.as_ref().expect("ser_rf_signature_hash called with None");
|
||||
let mut out = String::with_capacity(7 + 64); // "blake3:" + 32*2 hex chars
|
||||
out.push_str("blake3:");
|
||||
for b in bytes {
|
||||
// Manual lowercase-hex push — avoids pulling in the `hex` crate for 32 bytes.
|
||||
out.push(nibble_to_hex(b >> 4));
|
||||
out.push(nibble_to_hex(b & 0x0F));
|
||||
}
|
||||
s.serialize_str(&out)
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde-json")]
|
||||
const fn nibble_to_hex(n: u8) -> char {
|
||||
match n {
|
||||
0..=9 => (b'0' + n) as char,
|
||||
10..=15 => (b'a' + (n - 10)) as char,
|
||||
_ => '?', // unreachable: input is masked with 0x0F
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
//! Acceptance tests for the BFLD JSON wire spec `rf_signature_hash` format
|
||||
//! (`"blake3:<64-hex>"`) and the end-to-end emitter → hasher → event → JSON path.
|
||||
|
||||
#![cfg(all(feature = "std", feature = "serde-json"))]
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
BfldEmitter, BfldEvent, IdentityEmbedding, PrivacyClass, SensingInputs, SignatureHasher,
|
||||
EMBEDDING_DIM, SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
fn manual_event(hash: Option<[u8; 32]>) -> BfldEvent {
|
||||
BfldEvent::with_privacy_gating(
|
||||
"seed-01".into(),
|
||||
1_700_000_000_000_000_000,
|
||||
true,
|
||||
0.5,
|
||||
1,
|
||||
0.9,
|
||||
None,
|
||||
PrivacyClass::Anonymous,
|
||||
Some(0.3),
|
||||
hash,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rf_signature_hash_serializes_as_blake3_prefixed_lowercase_hex() {
|
||||
let hash = [
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22, 0x33,
|
||||
0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB,
|
||||
0xCC, 0xDD, 0xEE, 0xFF, 0x12, 0x34, 0x56, 0x78,
|
||||
0x9A, 0xBC, 0xDE, 0xF0, 0x0F, 0xED, 0xCB, 0xA9,
|
||||
];
|
||||
// Build expected hex programmatically — manual typing is error-prone.
|
||||
let mut expected_hex = String::from("blake3:");
|
||||
for b in &hash {
|
||||
expected_hex.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
let json = manual_event(Some(hash)).to_json().unwrap();
|
||||
let needle = format!("\"rf_signature_hash\":\"{expected_hex}\"");
|
||||
assert!(
|
||||
json.contains(&needle),
|
||||
"JSON: {json}\nexpected substring: {needle}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_string_is_always_64_chars_when_present() {
|
||||
let json = manual_event(Some([0x00; 32])).to_json().unwrap();
|
||||
// Find the substring after "blake3:" inside the rf_signature_hash field.
|
||||
let key = "\"rf_signature_hash\":\"blake3:";
|
||||
let start = json.find(key).expect("hash field present") + key.len();
|
||||
let end = json[start..].find('"').expect("closing quote") + start;
|
||||
let hex = &json[start..end];
|
||||
assert_eq!(hex.len(), 64, "hash hex must be exactly 64 chars, got {}", hex.len());
|
||||
assert!(
|
||||
hex.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
|
||||
"hash hex must be lowercase only, got {hex}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_field_omitted_entirely_when_none() {
|
||||
let json = manual_event(None).to_json().unwrap();
|
||||
assert!(
|
||||
!json.contains("rf_signature_hash"),
|
||||
"None hash must be omitted entirely, got: {json}",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Cross-iter integration test ----------------------------------------
|
||||
|
||||
fn salt() -> [u8; SITE_SALT_LEN] {
|
||||
let mut s = [0u8; SITE_SALT_LEN];
|
||||
for (i, b) in s.iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn embedding() -> IdentityEmbedding {
|
||||
let mut a = [0.0f32; EMBEDDING_DIM];
|
||||
for (i, v) in a.iter_mut().enumerate() {
|
||||
*v = (i as f32) * 0.01;
|
||||
}
|
||||
IdentityEmbedding::from_raw(a)
|
||||
}
|
||||
|
||||
fn inputs() -> SensingInputs {
|
||||
SensingInputs {
|
||||
timestamp_ns: 1_700_000_000_000_000_000,
|
||||
presence: true,
|
||||
motion: 0.42,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.91,
|
||||
sep: 0.2,
|
||||
stab: 0.2,
|
||||
consist: 0.2,
|
||||
risk_conf: 0.2,
|
||||
rf_signature_hash: None, // hasher will derive
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_to_end_emitter_hasher_to_json_emits_blake3_hex_hash() {
|
||||
let mut e = BfldEmitter::new("seed-01")
|
||||
.with_signature_hasher(SignatureHasher::new(salt()));
|
||||
let event = e
|
||||
.emit(inputs(), Some(embedding()))
|
||||
.expect("low-risk emit must succeed");
|
||||
let json = event.to_json().expect("JSON serialization");
|
||||
assert!(
|
||||
json.contains("\"rf_signature_hash\":\"blake3:"),
|
||||
"end-to-end JSON missing derived hash: {json}",
|
||||
);
|
||||
assert!(json.contains("\"type\":\"bfld_update\""));
|
||||
assert!(json.contains("\"node_id\":\"seed-01\""));
|
||||
assert!(json.contains("\"privacy_class\":\"anonymous\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_to_end_restricted_class_omits_hash_even_with_hasher_set() {
|
||||
let mut e = BfldEmitter::new("seed-01")
|
||||
.with_privacy_class(PrivacyClass::Restricted)
|
||||
.with_signature_hasher(SignatureHasher::new(salt()));
|
||||
let event = e
|
||||
.emit(inputs(), Some(embedding()))
|
||||
.expect("low-risk emit must succeed");
|
||||
let json = event.to_json().expect("JSON serialization");
|
||||
assert!(
|
||||
!json.contains("rf_signature_hash"),
|
||||
"Restricted class must strip rf_signature_hash from JSON, got: {json}",
|
||||
);
|
||||
assert!(
|
||||
!json.contains("identity_risk_score"),
|
||||
"Restricted class must also strip identity_risk_score, got: {json}",
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue