irtt-client 0.1.0

IRTT-compatible Rust client library
Documentation
mod support;

use irtt_client::{
    Client, ClientConfig, ClientError, ClientEvent, ClientTimestamp, NegotiationPolicy,
};
use irtt_proto::{echo_packet_len, Params, TimestampFields};

use support::{
    config_for_params, default_params, run_one_probe, run_one_probe_with_config, server_fill,
    start_open_server, OneProbeRun, ServerObservation,
};

fn open_observation_params(run: &OneProbeRun) -> &Params {
    match &run.observations[0] {
        ServerObservation::Open { params, .. } => params,
        other => panic!("expected Open observation, got {other:?}"),
    }
}

fn echo_observation(run: &OneProbeRun) -> (usize, bool, u64, u32) {
    match run.observations[1] {
        ServerObservation::Echo {
            len,
            hmac,
            token,
            sequence,
        } => (len, hmac, token, sequence),
        ref other => panic!("expected Echo observation, got {other:?}"),
    }
}

// ─── Config mapping ───

#[test]
fn server_fill_none_produces_no_fill_in_open_params() {
    let params = default_params();
    let run = run_one_probe(params, TimestampFields::default());
    assert!(open_observation_params(&run).server_fill.is_none());
}

#[test]
fn server_fill_short_value_maps_into_open_params() {
    let mut params = default_params();
    params.server_fill = server_fill("abc");
    let run = run_one_probe(params, TimestampFields::default());
    assert_eq!(
        open_observation_params(&run)
            .server_fill
            .as_ref()
            .map(|f| f.value.as_str()),
        Some("abc")
    );
}

#[test]
fn server_fill_32_byte_value_maps_into_open_params() {
    let mut params = default_params();
    let fill = "0123456789abcdef0123456789abcdef";
    params.server_fill = server_fill(fill);
    let run = run_one_probe(params, TimestampFields::default());
    assert_eq!(
        open_observation_params(&run)
            .server_fill
            .as_ref()
            .map(|f| f.value.as_str()),
        Some(fill)
    );
}

#[test]
fn server_fill_over_32_bytes_rejected_at_config_boundary() {
    let config = ClientConfig {
        server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
        ..ClientConfig::default()
    };
    assert!(matches!(
        Client::connect(config),
        Err(ClientError::InvalidConfig { .. })
    ));
}

#[test]
fn server_fill_empty_rejected_at_config_boundary() {
    let config = ClientConfig {
        server_fill: Some("".to_owned()),
        ..ClientConfig::default()
    };
    assert!(matches!(
        Client::connect(config),
        Err(ClientError::InvalidConfig { .. })
    ));
}

// ─── Open negotiation ───

#[test]
fn server_fill_negotiated_params_reflect_accepted_value() {
    let mut params = default_params();
    params.server_fill = server_fill("rand");
    let run = run_one_probe(params.clone(), TimestampFields::default());
    assert_eq!(run.negotiated.params.server_fill, params.server_fill);
}

#[test]
fn server_fill_strict_rejects_removed_fill() {
    let mut requested = default_params();
    requested.server_fill = server_fill("rand");

    let mut returned = requested.clone();
    returned.server_fill = None;

    let server = start_open_server(returned, None);
    let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
    assert!(matches!(
        client.open(ClientTimestamp::now()),
        Err(ClientError::NegotiationRejected { .. })
    ));
    server.join();
}

#[test]
fn server_fill_strict_rejects_different_fill_value() {
    let mut requested = default_params();
    requested.server_fill = server_fill("abc");

    let mut returned = requested.clone();
    returned.server_fill = server_fill("xyz");

    let server = start_open_server(returned, None);
    let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
    assert!(matches!(
        client.open(ClientTimestamp::now()),
        Err(ClientError::NegotiationRejected { .. })
    ));
    server.join();
}

#[test]
fn server_fill_strict_rejects_unexpected_fill_from_server() {
    let requested = default_params();

    let mut returned = requested.clone();
    returned.server_fill = server_fill("unexpected");

    let server = start_open_server(returned, None);
    let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
    assert!(matches!(
        client.open(ClientTimestamp::now()),
        Err(ClientError::NegotiationRejected { .. })
    ));
    server.join();
}

#[test]
fn server_fill_loose_allows_server_to_remove_fill() {
    let mut requested = default_params();
    requested.server_fill = server_fill("rand");

    let mut returned = requested.clone();
    returned.server_fill = None;

    let requested_for_config = requested.clone();
    let run = run_one_probe_with_config(returned, TimestampFields::default(), None, |addr| {
        ClientConfig {
            negotiation_policy: NegotiationPolicy::Loose,
            ..config_for_params(addr, &requested_for_config)
        }
    });

    assert_eq!(
        open_observation_params(&run).server_fill,
        requested.server_fill
    );
    assert_eq!(run.negotiated.params.server_fill, None);
}

// ─── Echo behavior ───

#[test]
fn server_fill_echo_reply_decoded_with_fill_configured() {
    let mut params = default_params();
    params.server_fill = server_fill("rand");
    let run = run_one_probe(params, TimestampFields::default());
    assert!(matches!(run.reply, ClientEvent::EchoReply { .. }));
}

// server_fill is a negotiation/config parameter only; it does not affect
// client-observable reply payload layout. The client sends server_fill in
// the open request and expects the server to use it for reply payload
// content, but the client's decode/echo layout logic is independent of
// server_fill. Only the length parameter expands the packet beyond the
// protocol header.
#[test]
fn server_fill_does_not_affect_client_side_packet_layout() {
    let mut params_with_fill = default_params();
    params_with_fill.server_fill = server_fill("rand");

    let params_without_fill = default_params();

    let run_with = run_one_probe(params_with_fill, TimestampFields::default());
    let run_without = run_one_probe(params_without_fill, TimestampFields::default());

    let (echo_len_with, _, _, _) = echo_observation(&run_with);
    let (echo_len_without, _, _, _) = echo_observation(&run_without);
    assert_eq!(echo_len_with, echo_len_without);
    assert_eq!(
        echo_packet_len(false, &run_with.negotiated.params),
        echo_len_with
    );
    assert_eq!(
        echo_packet_len(false, &run_without.negotiated.params),
        echo_len_without
    );
}