spvirit-tools 0.1.9

PVAccess client/server tools for EPICS
Documentation
use argparse::{ArgumentParser, Store};
use std::fs::File;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;

use spvirit_codec::epics_decode::{PvaPacket, PvaPacketCommand};
use spvirit_codec::spvirit_encode::{encode_header, encode_string_pva};
use spvirit_tools::spvirit_client::client::{encode_create_channel_request, encode_get_request};

#[derive(Debug)]
struct Frame {
    dir: String,
    bytes: Vec<u8>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut dump_file = String::new();
    let mut raw_file = String::new();
    {
        let mut ap = ArgumentParser::new();
        ap.set_description("Compare captured PVA GET frames to local encoders");
        ap.refer(&mut dump_file)
            .add_option(&["--dump-file"], Store, "Hex dump file to parse");
        ap.refer(&mut raw_file).add_option(
            &["--dump-raw"],
            Store,
            "Raw dump file (len+bytes) to parse",
        );
        ap.parse_args_or_exit();
    }

    let frames = if !raw_file.is_empty() {
        parse_raw_dump(&raw_file)?
    } else if !dump_file.is_empty() {
        parse_hex_dump(&dump_file)?
    } else {
        eprintln!("Provide --dump-raw or --dump-file");
        std::process::exit(1);
    };

    let mut create_req: Option<Vec<u8>> = None;
    let mut get_init: Option<Vec<u8>> = None;
    let mut get_data: Option<Vec<u8>> = None;
    let mut validation_req: Option<Vec<u8>> = None;

    for f in frames.iter().filter(|f| f.dir == "C->S") {
        let mut pkt = PvaPacket::new(&f.bytes);
        let cmd = match pkt.decode_payload() {
            Some(c) => c,
            None => continue,
        };
        match cmd {
            PvaPacketCommand::ConnectionValidation(_) => {
                if validation_req.is_none() {
                    validation_req = Some(f.bytes.clone());
                }
            }
            PvaPacketCommand::CreateChannel(payload) => {
                if !payload.channels.is_empty() && create_req.is_none() {
                    create_req = Some(f.bytes.clone());
                }
            }
            PvaPacketCommand::Op(op) => {
                if op.command == 10 && op.subcmd == 0x08 && get_init.is_none() {
                    get_init = Some(f.bytes.clone());
                } else if op.command == 10 && op.subcmd == 0x00 && get_data.is_none() {
                    get_data = Some(f.bytes.clone());
                }
            }
            _ => {}
        }
    }

    let validation_req = validation_req.ok_or("missing VALIDATION")?;
    let create_req = create_req.ok_or("missing CREATE_CHANNEL")?;
    let get_init = get_init.ok_or("missing GET init")?;
    let get_data = get_data.ok_or("missing GET data")?;

    compare_validation(&validation_req)?;
    compare_create_channel(&create_req)?;
    compare_get_request(&get_init, 0x08)?;
    compare_get_request(&get_data, 0x00)?;

    Ok(())
}

fn compare_create_channel(actual: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
    let mut pkt = PvaPacket::new(actual);
    let cmd = pkt.decode_payload().ok_or("decode create")?;
    let (cid, pv) = match cmd {
        PvaPacketCommand::CreateChannel(payload) => {
            let (cid, pv) = payload.channels.first().ok_or("missing channel")?;
            (*cid, pv.clone())
        }
        _ => return Err("unexpected command for create".into()),
    };
    let expected =
        encode_create_channel_request(cid, &pv, pkt.header.version, pkt.header.flags.is_msb);
    compare_bytes("CREATE_CHANNEL", actual, &expected);
    Ok(())
}

fn compare_validation(actual: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
    let mut pkt = PvaPacket::new(actual);
    let cmd = pkt.decode_payload().ok_or("decode validation")?;
    let (authz, user, host) = match cmd {
        PvaPacketCommand::ConnectionValidation(_) => {
            let authz = "ca".to_string();
            let user = std::env::var("PVA_AUTHNZ_USER")
                .or_else(|_| std::env::var("USER"))
                .or_else(|_| std::env::var("LOGNAME"))
                .unwrap_or_else(|_| "unknown".to_string());
            let host = std::env::var("PVA_AUTHNZ_HOST")
                .or_else(|_| std::env::var("HOSTNAME"))
                .or_else(|_| std::env::var("HOST"))
                .unwrap_or_else(|_| "unknown".to_string());
            (authz, user, host)
        }
        _ => return Err("unexpected command for validation".into()),
    };

    let expected = encode_validation_for_compare(
        87_040,
        32_767,
        0,
        &authz,
        &user,
        &host,
        pkt.header.version,
        pkt.header.flags.is_msb,
    );
    compare_bytes("VALIDATION", actual, &expected);
    Ok(())
}

fn compare_get_request(actual: &[u8], subcmd: u8) -> Result<(), Box<dyn std::error::Error>> {
    let mut pkt = PvaPacket::new(actual);
    let cmd = pkt.decode_payload().ok_or("decode get")?;
    let (sid, ioid) = match cmd {
        PvaPacketCommand::Op(op) => (op.sid_or_cid, op.ioid),
        _ => return Err("unexpected command for get".into()),
    };
    let extra: &[u8] = if subcmd == 0x08 {
        &[0xfd, 0x02, 0x00, 0x80, 0x00, 0x00]
    } else {
        &[]
    };
    let expected = encode_get_request(
        sid,
        ioid,
        subcmd,
        extra,
        pkt.header.version,
        pkt.header.flags.is_msb,
    );
    compare_bytes("GET", actual, &expected);
    Ok(())
}

fn compare_bytes(label: &str, actual: &[u8], expected: &[u8]) {
    if actual == expected {
        println!("{}: OK (len={})", label, actual.len());
        return;
    }
    let min_len = std::cmp::min(actual.len(), expected.len());
    let mut idx = 0usize;
    while idx < min_len && actual[idx] == expected[idx] {
        idx += 1;
    }
    println!(
        "{}: MISMATCH at offset {} (actual len={}, expected len={})",
        label,
        idx,
        actual.len(),
        expected.len()
    );
    if idx < min_len {
        println!(
            "  actual:   {:02x}  expected: {:02x}",
            actual[idx], expected[idx]
        );
    }
}

fn encode_validation_for_compare(
    buffer_size: u32,
    introspection_registry_size: u16,
    qos: u16,
    authz: &str,
    user: &str,
    host: &str,
    version: u8,
    is_be: bool,
) -> Vec<u8> {
    let mut payload = Vec::new();
    payload.extend_from_slice(&if is_be {
        buffer_size.to_be_bytes()
    } else {
        buffer_size.to_le_bytes()
    });
    payload.extend_from_slice(&if is_be {
        introspection_registry_size.to_be_bytes()
    } else {
        introspection_registry_size.to_le_bytes()
    });
    payload.extend_from_slice(&if is_be {
        qos.to_be_bytes()
    } else {
        qos.to_le_bytes()
    });
    payload.extend_from_slice(&encode_string_pva(authz, is_be));
    payload.extend_from_slice(&encode_authnz_blob(user, host));
    let mut out = encode_header(false, is_be, false, version, 1, payload.len() as u32);
    out.extend_from_slice(&payload);
    out
}

fn encode_authnz_blob(user: &str, host: &str) -> Vec<u8> {
    let mut out = Vec::new();
    out.extend_from_slice(&[0xFD, 0x01, 0x00, 0x80, 0x00]);
    out.push(0x02);
    out.push(0x04);
    out.extend_from_slice(b"user");
    out.push(0x60);
    out.push(0x04);
    out.extend_from_slice(b"host");
    out.push(0x60);
    let user_bytes = user.as_bytes();
    let host_bytes = host.as_bytes();
    out.push(user_bytes.len() as u8);
    out.extend_from_slice(user_bytes);
    out.push(host_bytes.len() as u8);
    out.extend_from_slice(host_bytes);
    out
}

fn parse_raw_dump(path: &str) -> Result<Vec<Frame>, Box<dyn std::error::Error>> {
    let mut f = File::open(path)?;
    let mut buf = Vec::new();
    f.read_to_end(&mut buf)?;
    let mut out = Vec::new();
    let mut offset = 0usize;
    while offset + 4 <= buf.len() {
        let len = u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap()) as usize;
        offset += 4;
        if offset + len > buf.len() {
            break;
        }
        let bytes = buf[offset..offset + len].to_vec();
        offset += len;
        out.push(Frame {
            dir: String::new(),
            bytes,
        });
    }
    // Raw dump doesn't include direction. Infer by command flags (server bit).
    for f in &mut out {
        let pkt = PvaPacket::new(&f.bytes);
        let is_server = pkt.header.flags.is_server;
        f.dir = if is_server { "S->C" } else { "C->S" }.to_string();
    }
    Ok(out)
}

fn parse_hex_dump(path: &str) -> Result<Vec<Frame>, Box<dyn std::error::Error>> {
    let file = File::open(Path::new(path))?;
    let reader = BufReader::new(file);
    let mut frames = Vec::new();
    let mut current_dir = String::new();
    let mut current_bytes: Vec<u8> = Vec::new();
    for line in reader.lines() {
        let line = line?;
        if line.starts_with("C->S") || line.starts_with("S->C") {
            if !current_bytes.is_empty() {
                frames.push(Frame {
                    dir: current_dir.clone(),
                    bytes: current_bytes.clone(),
                });
                current_bytes.clear();
            }
            current_dir = if line.starts_with("C->S") {
                "C->S"
            } else {
                "S->C"
            }
            .to_string();
            continue;
        }
        let trimmed = line.trim();
        if trimmed.is_empty() {
            if !current_bytes.is_empty() {
                frames.push(Frame {
                    dir: current_dir.clone(),
                    bytes: current_bytes.clone(),
                });
                current_bytes.clear();
            }
            continue;
        }
        let mut parts = trimmed.split_whitespace().collect::<Vec<_>>();
        if parts.is_empty() {
            continue;
        }
        // Drop offset token (eg "0000")
        if parts[0].len() == 4 {
            parts.remove(0);
        }
        for p in parts {
            if p.len() != 2 {
                continue;
            }
            if let Ok(b) = u8::from_str_radix(p, 16) {
                current_bytes.push(b);
            }
        }
    }
    if !current_bytes.is_empty() {
        frames.push(Frame {
            dir: current_dir,
            bytes: current_bytes,
        });
    }
    Ok(frames)
}