lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

mesh.rs (19399B)


      1 use radroots_mesh::{
      2     RADROOTS_MESH_RETICULUM_POLICY_ID, RADROOTS_MESH_UNAVAILABLE_MESSAGE,
      3     RadrootsMeshAdmissionDecision, RadrootsMeshAdmissionInput, RadrootsMeshCompressionPolicy,
      4     RadrootsMeshError, RadrootsMeshFrame, RadrootsMeshFrameType, RadrootsMeshPayload,
      5     RadrootsMeshPayloadPolicy, RadrootsMeshPolicyDenyReason, RadrootsMeshPrivacyClass,
      6     RadrootsMeshScope, decode_mesh_frame_cbor, encode_mesh_frame_cbor,
      7 };
      8 use serde_json::Value;
      9 
     10 fn default_frame() -> RadrootsMeshFrame {
     11     RadrootsMeshFrame::new(
     12         RadrootsMeshFrameType::Hello,
     13         RadrootsMeshScope::Local,
     14         "message-1",
     15         42,
     16         60_000,
     17     )
     18 }
     19 
     20 fn default_encoded_with_replacement(
     21     start: usize,
     22     end: usize,
     23     replacement: impl IntoIterator<Item = u8>,
     24 ) -> Vec<u8> {
     25     let mut encoded = encode_mesh_frame_cbor(&default_frame()).expect("encode default");
     26     encoded.splice(start..end, replacement);
     27     encoded
     28 }
     29 
     30 #[test]
     31 fn default_frame_encodes_as_mesh_frame_v1_cddl_cbor() {
     32     let frame = default_frame();
     33     let encoded = encode_mesh_frame_cbor(&frame).expect("encode frame");
     34 
     35     assert_eq!(
     36         encoded,
     37         [
     38             0xa7, 0x00, 0x01, 0x01, 0x00, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x69,
     39             b'm', b'e', b's', b's', b'a', b'g', b'e', b'-', b'1', 0x04, 0x18, 0x2a, 0x05, 0x19,
     40             0xea, 0x60, 0x06, 0xa0,
     41         ]
     42     );
     43     assert_eq!(
     44         decode_mesh_frame_cbor(&encoded).expect("decode frame"),
     45         frame
     46     );
     47 }
     48 
     49 #[test]
     50 fn all_frame_types_round_trip_with_stable_codes_and_labels() {
     51     let cases = [
     52         (RadrootsMeshFrameType::Hello, 0, "hello"),
     53         (
     54             RadrootsMeshFrameType::EventHeadAnnounce,
     55             1,
     56             "event_head_announce",
     57         ),
     58         (RadrootsMeshFrameType::EventRequest, 2, "event_request"),
     59         (RadrootsMeshFrameType::EventChunk, 3, "event_chunk"),
     60         (RadrootsMeshFrameType::EventAck, 4, "event_ack"),
     61         (RadrootsMeshFrameType::RouteProbe, 5, "route_probe"),
     62     ];
     63 
     64     for (frame_type, code, label) in cases {
     65         let frame = RadrootsMeshFrame::new(
     66             frame_type,
     67             RadrootsMeshScope::Community,
     68             format!("{label}-message"),
     69             code + 1,
     70             1_000,
     71         );
     72         let encoded = encode_mesh_frame_cbor(&frame).expect("encode frame");
     73         let decoded = decode_mesh_frame_cbor(&encoded).expect("decode frame");
     74 
     75         assert_eq!(decoded, frame);
     76         assert_eq!(encode_mesh_frame_cbor(&decoded).expect("reencode"), encoded);
     77         assert_eq!(frame_type.code(), code);
     78         assert_eq!(frame_type.label(), label);
     79     }
     80 }
     81 
     82 #[test]
     83 fn reticulum_policy_has_zero_delivery_budgets_and_disabled_compression() {
     84     let policy = RadrootsMeshPayloadPolicy::reticulum_unavailable();
     85 
     86     assert_eq!(policy.policy_id(), RADROOTS_MESH_RETICULUM_POLICY_ID);
     87     assert_eq!(policy.max_payload_bytes, 0);
     88     assert_eq!(policy.max_frame_bytes, 0);
     89     assert_eq!(policy.compression, RadrootsMeshCompressionPolicy::Disabled);
     90     assert_eq!(policy.compression.label(), "disabled");
     91     assert!(!policy.custom_scopes_enabled);
     92     assert!(!policy.usable_for_delivery());
     93 }
     94 
     95 #[test]
     96 fn reticulum_policy_denies_real_payload_admission_deterministically() {
     97     let policy = RadrootsMeshPayloadPolicy::reticulum_unavailable();
     98     let input = RadrootsMeshAdmissionInput::new(
     99         RadrootsMeshScope::Local,
    100         RadrootsMeshPrivacyClass::PublicEvent,
    101         256,
    102         512,
    103     );
    104     let decision = policy.evaluate(&input);
    105 
    106     assert_eq!(
    107         decision,
    108         RadrootsMeshAdmissionDecision::Denied {
    109             reason: RadrootsMeshPolicyDenyReason::DeliveryUnavailable
    110         }
    111     );
    112     assert_eq!(decision.label(), "denied");
    113     assert_eq!(
    114         decision.deny_reason(),
    115         Some(RadrootsMeshPolicyDenyReason::DeliveryUnavailable)
    116     );
    117     assert_eq!(decision.message(), RADROOTS_MESH_UNAVAILABLE_MESSAGE);
    118     assert!(!decision.usable_for_delivery());
    119     assert_eq!(
    120         RadrootsMeshPrivacyClass::PublicEvent.label(),
    121         "public_event"
    122     );
    123     assert_eq!(
    124         RadrootsMeshPrivacyClass::PrivateEvent.label(),
    125         "private_event"
    126     );
    127     assert_eq!(
    128         RadrootsMeshPolicyDenyReason::DeliveryUnavailable.label(),
    129         "delivery_unavailable"
    130     );
    131 }
    132 
    133 #[test]
    134 fn enabled_policy_evaluates_all_admission_decisions() {
    135     let mut policy = RadrootsMeshPayloadPolicy::reticulum_unavailable();
    136     policy.max_payload_bytes = 10;
    137     assert!(!policy.usable_for_delivery());
    138     policy.max_frame_bytes = 20;
    139     assert!(policy.usable_for_delivery());
    140 
    141     let local = |payload_bytes, frame_bytes| {
    142         RadrootsMeshAdmissionInput::new(
    143             RadrootsMeshScope::Local,
    144             RadrootsMeshPrivacyClass::PublicEvent,
    145             payload_bytes,
    146             frame_bytes,
    147         )
    148     };
    149     let accepted = policy.evaluate(&local(10, 20));
    150     assert_eq!(accepted, RadrootsMeshAdmissionDecision::Accepted);
    151     assert_eq!(accepted.label(), "accepted");
    152     assert_eq!(accepted.deny_reason(), None);
    153     assert_eq!(accepted.message(), "mesh payload delivery is admitted");
    154     assert!(accepted.usable_for_delivery());
    155 
    156     for input in [local(11, 20), local(10, 21)] {
    157         let denied = policy.evaluate(&input);
    158         assert_eq!(
    159             denied.deny_reason(),
    160             Some(RadrootsMeshPolicyDenyReason::PayloadBudgetExceeded)
    161         );
    162     }
    163     assert_eq!(
    164         RadrootsMeshPolicyDenyReason::PayloadBudgetExceeded.label(),
    165         "payload_budget_exceeded"
    166     );
    167     assert_eq!(
    168         RadrootsMeshPolicyDenyReason::PayloadBudgetExceeded.message(),
    169         "mesh payload exceeds the configured delivery budget"
    170     );
    171 
    172     let custom_input = RadrootsMeshAdmissionInput::new(
    173         RadrootsMeshScope::custom("farm-north").unwrap(),
    174         RadrootsMeshPrivacyClass::PrivateEvent,
    175         1,
    176         1,
    177     );
    178     let custom_denied = policy.evaluate(&custom_input);
    179     assert_eq!(
    180         custom_denied.deny_reason(),
    181         Some(RadrootsMeshPolicyDenyReason::CustomScopeUnavailable)
    182     );
    183     assert_eq!(
    184         RadrootsMeshPolicyDenyReason::CustomScopeUnavailable.label(),
    185         "custom_scope_unavailable"
    186     );
    187     assert_eq!(
    188         RadrootsMeshPolicyDenyReason::CustomScopeUnavailable.message(),
    189         "mesh custom scopes are not enabled for the configured policy"
    190     );
    191 
    192     policy.custom_scopes_enabled = true;
    193     assert_eq!(
    194         policy.evaluate(&custom_input),
    195         RadrootsMeshAdmissionDecision::Accepted
    196     );
    197 }
    198 
    199 #[test]
    200 fn custom_scope_has_explicit_namespace() {
    201     let scope = RadrootsMeshScope::custom("farm-north.mesh_1").expect("custom scope");
    202     assert_eq!(scope.label(), "farm-north.mesh_1");
    203     let frame = RadrootsMeshFrame::new(
    204         RadrootsMeshFrameType::RouteProbe,
    205         scope,
    206         "route-probe-1",
    207         10,
    208         1,
    209     );
    210     let encoded = encode_mesh_frame_cbor(&frame).expect("encode custom scope");
    211     let decoded = decode_mesh_frame_cbor(&encoded).expect("decode custom scope");
    212 
    213     assert_eq!(decoded.scope_id.cbor_label(), "custom:farm-north.mesh_1");
    214     assert_eq!(encode_mesh_frame_cbor(&decoded).expect("reencode"), encoded);
    215 }
    216 
    217 #[test]
    218 fn mesh_parsers_and_validation_reject_unknown_empty_or_invalid_values() {
    219     assert_eq!(
    220         RadrootsMeshScope::custom("").expect_err("empty custom scope"),
    221         RadrootsMeshError::EmptyCustomScope
    222     );
    223     for invalid in [
    224         " ",
    225         " farm-north",
    226         "farm-north ",
    227         "farm north",
    228         "farm/north",
    229         "farm:north",
    230         "farm\nnorth",
    231     ] {
    232         assert_eq!(
    233             RadrootsMeshScope::custom(invalid).expect_err("invalid custom scope"),
    234             RadrootsMeshError::InvalidCustomScope
    235         );
    236     }
    237     assert_eq!(
    238         RadrootsMeshScope::parse("custom:").expect_err("empty parsed custom scope"),
    239         RadrootsMeshError::EmptyCustomScope
    240     );
    241     assert_eq!(
    242         RadrootsMeshScope::parse("custom:farm north").expect_err("invalid parsed custom scope"),
    243         RadrootsMeshError::InvalidCustomScope
    244     );
    245     assert_eq!(
    246         RadrootsMeshScope::parse("unscoped").expect_err("unknown scope"),
    247         RadrootsMeshError::UnknownScope
    248     );
    249     assert_eq!(
    250         RadrootsMeshFrameType::parse_code(6).expect_err("unknown frame type"),
    251         RadrootsMeshError::UnknownFrameType
    252     );
    253     assert_eq!(RadrootsMeshScope::Local.label(), "local");
    254     assert_eq!(RadrootsMeshScope::Community.label(), "community");
    255 
    256     let empty_message = RadrootsMeshFrame::new(
    257         RadrootsMeshFrameType::Hello,
    258         RadrootsMeshScope::Local,
    259         " ",
    260         1,
    261         1,
    262     );
    263     assert_eq!(
    264         empty_message.validate().expect_err("empty message id"),
    265         RadrootsMeshError::EmptyMessageId
    266     );
    267 
    268     let zero_ttl = RadrootsMeshFrame::new(
    269         RadrootsMeshFrameType::Hello,
    270         RadrootsMeshScope::Local,
    271         "message-1",
    272         1,
    273         0,
    274     );
    275     assert_eq!(
    276         zero_ttl.validate().expect_err("zero ttl"),
    277         RadrootsMeshError::InvalidTtl
    278     );
    279 
    280     let mut unsupported_version = default_frame();
    281     unsupported_version.version += 1;
    282     assert_eq!(
    283         unsupported_version
    284             .validate()
    285             .expect_err("unsupported version"),
    286         RadrootsMeshError::UnsupportedVersion
    287     );
    288 }
    289 
    290 #[test]
    291 fn mesh_errors_have_stable_display_strings() {
    292     let cases = [
    293         (
    294             RadrootsMeshError::EmptyCustomScope,
    295             "mesh custom scope is empty",
    296         ),
    297         (
    298             RadrootsMeshError::InvalidCustomScope,
    299             "mesh custom scope is invalid",
    300         ),
    301         (
    302             RadrootsMeshError::EmptyMessageId,
    303             "mesh message id is empty",
    304         ),
    305         (RadrootsMeshError::InvalidTtl, "mesh frame TTL is invalid"),
    306         (
    307             RadrootsMeshError::PayloadTransmissionForbidden,
    308             "mesh payload transmission is forbidden",
    309         ),
    310         (RadrootsMeshError::InvalidCbor, "mesh frame CBOR is invalid"),
    311         (
    312             RadrootsMeshError::InvalidUtf8,
    313             "mesh frame text is invalid UTF-8",
    314         ),
    315         (
    316             RadrootsMeshError::UnknownFrameType,
    317             "mesh frame type is unknown",
    318         ),
    319         (RadrootsMeshError::UnknownScope, "mesh scope is unknown"),
    320         (
    321             RadrootsMeshError::UnsupportedVersion,
    322             "mesh frame version is unsupported",
    323         ),
    324     ];
    325 
    326     for (error, message) in cases {
    327         assert_eq!(error.to_string(), message);
    328     }
    329 }
    330 
    331 #[test]
    332 fn payload_transmission_is_forbidden_in_mvp_frames() {
    333     let mut frame = default_frame();
    334     frame.payload = RadrootsMeshPayload::Bytes(vec![1, 2, 3]);
    335 
    336     assert_eq!(
    337         encode_mesh_frame_cbor(&frame).expect_err("payload must fail"),
    338         RadrootsMeshError::PayloadTransmissionForbidden
    339     );
    340 
    341     let mut encoded_payload = encode_mesh_frame_cbor(&default_frame()).expect("encode default");
    342     let payload_offset = encoded_payload.len() - 1;
    343     encoded_payload[payload_offset] = 0x43;
    344     encoded_payload.extend_from_slice(&[1, 2, 3]);
    345     assert_eq!(
    346         decode_mesh_frame_cbor(&encoded_payload).expect_err("decode payload"),
    347         RadrootsMeshError::PayloadTransmissionForbidden
    348     );
    349 }
    350 
    351 #[test]
    352 fn cbor_codec_covers_extended_integer_widths() {
    353     let frame = RadrootsMeshFrame::new(
    354         RadrootsMeshFrameType::EventAck,
    355         RadrootsMeshScope::Community,
    356         "wide-created-at",
    357         u64::MAX,
    358         u64::MAX,
    359     );
    360     let encoded = encode_mesh_frame_cbor(&frame).expect("encode wide frame");
    361     let decoded = decode_mesh_frame_cbor(&encoded).expect("decode wide frame");
    362 
    363     assert_eq!(decoded, frame);
    364 
    365     let frame_32 = RadrootsMeshFrame::new(
    366         RadrootsMeshFrameType::EventAck,
    367         RadrootsMeshScope::Community,
    368         "32-bit-created-at",
    369         0x1_0000,
    370         0x1_0000,
    371     );
    372     let encoded_32 = encode_mesh_frame_cbor(&frame_32).expect("encode 32-bit frame");
    373     assert_eq!(
    374         decode_mesh_frame_cbor(&encoded_32).expect("decode 32-bit frame"),
    375         frame_32
    376     );
    377 }
    378 
    379 #[test]
    380 fn decoder_rejects_noncanonical_cbor_widths() {
    381     let cases = [
    382         (
    383             "over-wide map length",
    384             default_encoded_with_replacement(0, 1, [0xb8, 0x07]),
    385         ),
    386         (
    387             "over-wide key",
    388             default_encoded_with_replacement(1, 2, [0x18, 0x00]),
    389         ),
    390         (
    391             "over-wide version",
    392             default_encoded_with_replacement(2, 3, [0x18, 0x01]),
    393         ),
    394         (
    395             "over-wide frame type",
    396             default_encoded_with_replacement(4, 5, [0x1a, 0x00, 0x00, 0x00, 0x00]),
    397         ),
    398         (
    399             "over-wide text length",
    400             default_encoded_with_replacement(6, 7, [0x78, 0x05]),
    401         ),
    402         (
    403             "over-wide created-at",
    404             default_encoded_with_replacement(24, 26, [0x19, 0x00, 0x2a]),
    405         ),
    406         (
    407             "over-wide ttl",
    408             default_encoded_with_replacement(
    409                 27,
    410                 30,
    411                 [0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xea, 0x60],
    412             ),
    413         ),
    414         (
    415             "over-wide forbidden byte payload length",
    416             default_encoded_with_replacement(31, 32, [0x58, 0x03, 1, 2, 3]),
    417         ),
    418     ];
    419 
    420     for (label, encoded) in cases {
    421         assert_eq!(
    422             decode_mesh_frame_cbor(&encoded).expect_err(label),
    423             RadrootsMeshError::InvalidCbor
    424         );
    425     }
    426 }
    427 
    428 #[test]
    429 fn decoder_rejects_previous_five_field_frame_shape() {
    430     let previous_shape = vec![
    431         0xa5, 0x01, 0x01, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x71, b'p', b'a', b'y',
    432         b'l', b'o', b'a', b'd', b'-', b'f', b'o', b'r', b'b', b'i', b'd', b'd', b'e', b'n', 0x04,
    433         0x80, 0x05, 0xf6,
    434     ];
    435 
    436     assert_eq!(
    437         decode_mesh_frame_cbor(&previous_shape).expect_err("old frame shape"),
    438         RadrootsMeshError::InvalidCbor
    439     );
    440 }
    441 
    442 #[test]
    443 fn decoder_rejects_malformed_cbor_shapes() {
    444     let encoded = encode_mesh_frame_cbor(&default_frame()).expect("encode default");
    445     let invalid_custom_scope = default_encoded_with_replacement(
    446         6,
    447         12,
    448         [
    449             0x70, b'c', b'u', b's', b't', b'o', b'm', b':', b'f', b'a', b'r', b'm', b' ', b'n',
    450             b'o', b'r', b't',
    451         ],
    452     );
    453     assert_eq!(
    454         decode_mesh_frame_cbor(&invalid_custom_scope).expect_err("invalid custom scope"),
    455         RadrootsMeshError::InvalidCustomScope
    456     );
    457 
    458     let mut unsupported_version = encoded.clone();
    459     unsupported_version[2] = 2;
    460     assert_eq!(
    461         decode_mesh_frame_cbor(&unsupported_version).expect_err("unsupported version"),
    462         RadrootsMeshError::UnsupportedVersion
    463     );
    464 
    465     let mut out_of_range_version = encoded.clone();
    466     out_of_range_version.splice(2..3, [0x1a, 0x00, 0x01, 0x00, 0x01]);
    467     assert_eq!(
    468         decode_mesh_frame_cbor(&out_of_range_version).expect_err("out of range version"),
    469         RadrootsMeshError::InvalidCbor
    470     );
    471 
    472     let mut unknown_frame_type = encoded.clone();
    473     unknown_frame_type[4] = 6;
    474     assert_eq!(
    475         decode_mesh_frame_cbor(&unknown_frame_type).expect_err("unknown frame type"),
    476         RadrootsMeshError::UnknownFrameType
    477     );
    478 
    479     let mut zero_ttl = encoded.clone();
    480     zero_ttl.splice(27..30, [0x00]);
    481     assert_eq!(
    482         decode_mesh_frame_cbor(&zero_ttl).expect_err("zero ttl"),
    483         RadrootsMeshError::InvalidTtl
    484     );
    485 
    486     let mut wrong_key_order = encoded.clone();
    487     wrong_key_order[3] = 2;
    488     assert_eq!(
    489         decode_mesh_frame_cbor(&wrong_key_order).expect_err("wrong key order"),
    490         RadrootsMeshError::InvalidCbor
    491     );
    492 
    493     let cases = [
    494         vec![0x80],
    495         vec![0xbc],
    496         vec![0xa6],
    497         vec![0xa7, 0x01],
    498         vec![0xa7, 0x00, 0x01, 0x01, 0x00, 0x02, 0x61, 0xff],
    499         vec![
    500             0xa7, 0x00, 0x01, 0x01, 0x00, 0x02, 0x63, b'b', b'a', b'd', 0x03, 0x69, b'm', b'e',
    501             b's', b's', b'a', b'g', b'e', b'-', b'1', 0x04, 0x18, 0x2a, 0x05, 0x19, 0xea, 0x60,
    502             0x06, 0xa0,
    503         ],
    504         vec![
    505             0xa7, 0x00, 0x01, 0x01, 0x00, 0x02, 0x7b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
    506             0xff,
    507         ],
    508         vec![
    509             0xa7, 0x00, 0x01, 0x01, 0x00, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x60,
    510             0x04, 0x18, 0x2a, 0x05, 0x19, 0xea, 0x60, 0x06, 0xa0,
    511         ],
    512         vec![
    513             0xa7, 0x00, 0x01, 0x01, 0x00, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x69,
    514             b'm', b'e', b's', b's', b'a', b'g', b'e', b'-', b'1', 0x04, 0x18, 0x2a, 0x05, 0x19,
    515             0xea, 0x60, 0x06, 0xa1,
    516         ],
    517         vec![
    518             0xa7, 0x00, 0x01, 0x01, 0x00, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x69,
    519             b'm', b'e', b's', b's', b'a', b'g', b'e', b'-', b'1', 0x04, 0x18, 0x2a, 0x05, 0x19,
    520             0xea, 0x60, 0x06, 0xf6,
    521         ],
    522     ];
    523 
    524     for malformed in cases {
    525         assert!(decode_mesh_frame_cbor(&malformed).is_err());
    526     }
    527 
    528     let mut trailing = encoded;
    529     trailing.push(0x00);
    530     assert_eq!(
    531         decode_mesh_frame_cbor(&trailing).expect_err("trailing byte"),
    532         RadrootsMeshError::InvalidCbor
    533     );
    534 }
    535 
    536 #[test]
    537 fn checked_in_mesh_cbor_vectors_match_decoder_behavior() {
    538     let vectors = include_str!("../../../contracts/conformance/vectors/mesh/frame_cbor.v1.json");
    539     let document: Value = serde_json::from_str(vectors).expect("mesh cbor vector json");
    540     let entries = document
    541         .get("vectors")
    542         .and_then(Value::as_array)
    543         .expect("mesh cbor vectors");
    544 
    545     for entry in entries {
    546         let kind = entry.get("kind").and_then(Value::as_str).expect("kind");
    547         let hex = entry
    548             .get("input")
    549             .and_then(|input| input.get("hex"))
    550             .and_then(Value::as_str)
    551             .expect("input hex");
    552         let bytes = decode_hex(hex);
    553         match kind {
    554             "mesh.frame_cbor.valid" => {
    555                 let frame = decode_mesh_frame_cbor(bytes.as_slice()).expect("mesh frame");
    556                 let expected = entry.get("expected").expect("expected");
    557                 assert_eq!(
    558                     frame.message_id,
    559                     expected
    560                         .get("message_id")
    561                         .and_then(Value::as_str)
    562                         .expect("message id")
    563                 );
    564                 assert_eq!(
    565                     frame.scope_id.label(),
    566                     expected
    567                         .get("scope")
    568                         .and_then(Value::as_str)
    569                         .expect("scope")
    570                 );
    571             }
    572             "mesh.frame_cbor.invalid" => {
    573                 assert!(decode_mesh_frame_cbor(bytes.as_slice()).is_err());
    574             }
    575             other => panic!("unknown mesh cbor vector kind {other}"),
    576         }
    577     }
    578 }
    579 
    580 fn decode_hex(value: &str) -> Vec<u8> {
    581     assert_eq!(value.len() % 2, 0, "hex fixture length must be even");
    582     value
    583         .as_bytes()
    584         .chunks_exact(2)
    585         .map(|chunk| {
    586             let high = hex_nibble(chunk[0]);
    587             let low = hex_nibble(chunk[1]);
    588             (high << 4) | low
    589         })
    590         .collect()
    591 }
    592 
    593 fn hex_nibble(byte: u8) -> u8 {
    594     match byte {
    595         b'0'..=b'9' => byte - b'0',
    596         b'a'..=b'f' => byte - b'a' + 10,
    597         _ => panic!("hex fixture contains non-lowercase-hex byte"),
    598     }
    599 }