liminal-protocol 0.14.1

Shared participant-lifecycle protocol types for liminal
Documentation
use alloc::{boxed::Box, string::String, vec, vec::Vec};

use super::{
    CodecError, ParticipantFrame, ParticipantTransportRejected, ReceiverDirection, ServerValue,
    TransportRejectionReason, decode, encode, encoded_len,
};

const FIXTURE: &str = include_str!("../../../../docs/wire/transport-reasons-6-9.json");

type TestResult = Result<(), CodecError>;

// The fixture's string fields contain no escapes. Refuse a changed shape
// rather than interpreting a different JSON representation as these bytes.
fn strings(field: &str) -> Result<Vec<&'static str>, Box<dyn core::error::Error>> {
    let prefix = alloc::format!("\"{field}\": \"");
    FIXTURE
        .lines()
        .filter_map(|line| line.trim().strip_prefix(prefix.as_str()))
        .map(|value| {
            let value = value.strip_suffix(',').unwrap_or(value);
            let value = value.strip_suffix('"').ok_or("unclosed fixture string")?;
            if value.contains(['\\', '"']) {
                return Err("fixture strings must be literal unescaped bytes".into());
            }
            Ok(value)
        })
        .collect()
}

fn hex_bytes(value: &str) -> Result<Vec<u8>, Box<dyn core::error::Error>> {
    if !value.len().is_multiple_of(2) || !value.is_ascii() {
        return Err("fixture hex is not an even ASCII string".into());
    }
    (0..value.len())
        .step_by(2)
        .map(|offset| u8::from_str_radix(&value[offset..offset + 2], 16).map_err(Into::into))
        .collect()
}

#[test]
fn transport_reasons_6_to_9_match_the_committed_complete_frames()
-> Result<(), Box<dyn core::error::Error>> {
    let names = strings("name")?;
    let texts = strings("text")?;
    let frames = strings("frame_hex")?;
    assert_eq!(
        names,
        [
            "EnrollmentNotPermitted",
            "EnrollmentConversationOutOfScope",
            "CredentialAttachConversationOutOfScope",
            "AccessRefused",
        ]
    );
    assert_eq!(texts.len(), 4);
    assert_eq!(frames.len(), 4);
    let numbers = FIXTURE
        .lines()
        .filter_map(|line| line.trim().strip_prefix("\"reason\": "))
        .map(|value| value.trim_end_matches(',').parse::<u16>())
        .collect::<Result<Vec<_>, _>>()?;
    assert_eq!(numbers, [6, 7, 8, 9]);
    let reasons = [
        TransportRejectionReason::EnrollmentNotPermitted,
        TransportRejectionReason::EnrollmentConversationOutOfScope,
        TransportRejectionReason::CredentialAttachConversationOutOfScope,
        TransportRejectionReason::AccessRefused {
            refusal: String::from(texts[3]),
        },
    ];
    for (index, reason) in reasons.into_iter().enumerate() {
        if index < 3 {
            assert!(
                texts[index].is_empty(),
                "this reason has no text field on the wire"
            );
        } else {
            assert!(
                !texts[index].is_empty(),
                "access refusal must pin real text"
            );
        }
        let frame = ParticipantFrame::ServerValue(ServerValue::ParticipantTransportRejected(
            ParticipantTransportRejected { reason },
        ));
        let expected = hex_bytes(frames[index])?;
        assert_codec_frame(&frame, &expected, names[index])
            .map_err(|error| alloc::format!("CodecError for {}: {error:?}", names[index]))?;
    }
    Ok(())
}

fn assert_codec_frame(frame: &ParticipantFrame, expected: &[u8], name: &str) -> TestResult {
    let mut encoded = vec![0; encoded_len(frame)?];
    let written = encode(frame, &mut encoded)?;
    assert_eq!(written, encoded.len());
    assert_eq!(encoded.as_slice(), expected, "{name} bytes drifted");
    assert_eq!(decode(&encoded, ReceiverDirection::Client)?, *frame);
    Ok(())
}