Ship a working HexPigeon: typed binary parsing in Rust/WASM, a three-pane frontend, and unit tests covering the field types. Co-authored-by: Cursor <cursoragent@cursor.com>
298 lines
10 KiB
Rust
298 lines
10 KiB
Rust
use hex_pigeon_parser::checksum::{compute_crc16, compute_crc32};
|
|
use hex_pigeon_parser::{
|
|
parse_fields, CountSpec, Endian, FieldDef, FieldKind, FlagDef, ParsedValue, Schema,
|
|
};
|
|
|
|
fn field(name: &str, kind: FieldKind) -> FieldDef {
|
|
FieldDef {
|
|
name: name.to_string(),
|
|
kind,
|
|
endian: None,
|
|
description: None,
|
|
flags: None,
|
|
length: None,
|
|
checksum_range: None,
|
|
count: None,
|
|
fields: None,
|
|
}
|
|
}
|
|
|
|
fn field_len(name: &str, kind: FieldKind, length: usize) -> FieldDef {
|
|
let mut f = field(name, kind);
|
|
f.length = Some(length);
|
|
f
|
|
}
|
|
|
|
#[test]
|
|
fn schema_deserializes_from_json() {
|
|
let json = r#"{
|
|
"name": "Test",
|
|
"endian": "little",
|
|
"fields": [{ "name": "id", "type": "uint8" }]
|
|
}"#;
|
|
let schema: Schema = serde_json::from_str(json).expect("schema JSON");
|
|
assert_eq!(schema.name.as_deref(), Some("Test"));
|
|
assert_eq!(schema.endian, Endian::Little);
|
|
assert_eq!(schema.fields.len(), 1);
|
|
assert_eq!(schema.fields[0].kind, FieldKind::Uint8);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_integer_types_big_endian() {
|
|
let data = [
|
|
0x12,
|
|
0x12, 0x34,
|
|
0x12, 0x34, 0x56, 0x78,
|
|
0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0,
|
|
0xFE,
|
|
0xFF, 0xF0,
|
|
0x80, 0x00, 0x00, 0x01,
|
|
];
|
|
let fields = vec![
|
|
field("u8", FieldKind::Uint8),
|
|
field("u16", FieldKind::Uint16),
|
|
field("u32", FieldKind::Uint32),
|
|
field("u64", FieldKind::Uint64),
|
|
field("i8", FieldKind::Int8),
|
|
field("i16", FieldKind::Int16),
|
|
field("i32", FieldKind::Int32),
|
|
];
|
|
let result = parse_fields(&data, &fields).expect("parse");
|
|
|
|
assert_eq!(result.consumed_bytes, 22);
|
|
assert!(matches!(result.fields[0].value, ParsedValue::Uint(0x12)));
|
|
assert!(matches!(result.fields[1].value, ParsedValue::Uint(0x1234)));
|
|
assert!(matches!(result.fields[2].value, ParsedValue::Uint(0x12345678)));
|
|
assert!(matches!(result.fields[3].value, ParsedValue::Uint(0x123456789ABCDEF0)));
|
|
assert!(matches!(result.fields[4].value, ParsedValue::Int(-2)));
|
|
assert!(matches!(result.fields[5].value, ParsedValue::Int(-16)));
|
|
assert!(matches!(result.fields[6].value, ParsedValue::Int(-2147483647)));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_integer_types_little_endian() {
|
|
let data = [0x34, 0x12];
|
|
let mut f = field("u16", FieldKind::Uint16);
|
|
f.endian = Some(Endian::Little);
|
|
let result = parse_fields(&data, &[f]).expect("parse");
|
|
assert!(matches!(result.fields[0].value, ParsedValue::Uint(0x1234)));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_float_types() {
|
|
let f32_bits = 1.0f32.to_bits();
|
|
let f64_bits = 2.5f64.to_bits();
|
|
let mut data = Vec::new();
|
|
data.extend_from_slice(&f32_bits.to_be_bytes());
|
|
data.extend_from_slice(&f64_bits.to_be_bytes());
|
|
|
|
let fields = vec![field("f32", FieldKind::Float32), field("f64", FieldKind::Float64)];
|
|
let result = parse_fields(&data, &fields).expect("parse");
|
|
|
|
match &result.fields[0].value {
|
|
ParsedValue::Float(v) => assert!((v - 1.0).abs() < f64::EPSILON),
|
|
other => panic!("expected float32, got {other:?}"),
|
|
}
|
|
match &result.fields[1].value {
|
|
ParsedValue::Float(v) => assert!((v - 2.5).abs() < f64::EPSILON),
|
|
other => panic!("expected float64, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_bitflags() {
|
|
let mut f8 = field("flags8", FieldKind::Bitflags8);
|
|
f8.flags = Some(vec![
|
|
FlagDef {
|
|
bit: 0,
|
|
name: "A".into(),
|
|
description: None,
|
|
},
|
|
FlagDef {
|
|
bit: 3,
|
|
name: "D".into(),
|
|
description: None,
|
|
},
|
|
]);
|
|
let mut f16 = field("flags16", FieldKind::Bitflags16);
|
|
f16.flags = Some(vec![FlagDef {
|
|
bit: 1,
|
|
name: "B".into(),
|
|
description: None,
|
|
}]);
|
|
|
|
let result = parse_fields(&[0b0000_0101, 0x00, 0b0000_0010], &[f8, f16]).expect("parse");
|
|
|
|
match &result.fields[0].value {
|
|
ParsedValue::Flags(flags) => {
|
|
assert!(flags.iter().find(|f| f.name == "A").unwrap().set);
|
|
assert!(!flags.iter().find(|f| f.name == "D").unwrap().set);
|
|
}
|
|
other => panic!("expected flags8, got {other:?}"),
|
|
}
|
|
match &result.fields[1].value {
|
|
ParsedValue::Flags(flags) => assert!(flags[0].set),
|
|
other => panic!("expected flags16, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_bytes_padding_and_strings() {
|
|
let data = [
|
|
0xAA, 0xBB, 0xCC, // bytes[3]
|
|
0x00, 0x00, // padding[2]
|
|
b'H', b'i', 0x00, 0x00, // string_fixed[4] -> "Hi"
|
|
0x03, b'F', b'O', b'O', // string_lp8
|
|
0x00, 0x02, b'B', b'R', // string_lp16 big-endian length
|
|
];
|
|
|
|
let fields = vec![
|
|
field_len("raw", FieldKind::Bytes, 3),
|
|
field_len("pad", FieldKind::Padding, 2),
|
|
field_len("fixed", FieldKind::StringFixed, 4),
|
|
field("lp8", FieldKind::StringLp8),
|
|
field("lp16", FieldKind::StringLp16),
|
|
];
|
|
let result = parse_fields(&data, &fields).expect("parse");
|
|
|
|
assert!(matches!(&result.fields[0].value, ParsedValue::Bytes(v) if v == &[0xAA, 0xBB, 0xCC]));
|
|
assert!(matches!(result.fields[1].value, ParsedValue::None));
|
|
assert!(matches!(&result.fields[2].value, ParsedValue::Str(s) if s == "Hi"));
|
|
assert!(matches!(&result.fields[3].value, ParsedValue::Str(s) if s == "FOO"));
|
|
assert!(matches!(&result.fields[4].value, ParsedValue::Str(s) if s == "BR"));
|
|
}
|
|
|
|
#[test]
|
|
fn validates_crc16_and_crc32() {
|
|
let payload = [0x01, 0x02, 0x03, 0x04];
|
|
let crc16 = compute_crc16(&payload);
|
|
let crc32 = compute_crc32(&payload);
|
|
|
|
let mut data = payload.to_vec();
|
|
data.extend_from_slice(&crc16.to_be_bytes());
|
|
data.extend_from_slice(&crc32.to_be_bytes());
|
|
|
|
let mut crc16_field = field("c16", FieldKind::Crc16);
|
|
crc16_field.checksum_range = Some([0, 4]);
|
|
let mut crc32_field = field("c32", FieldKind::Crc32);
|
|
crc32_field.checksum_range = Some([0, 4]);
|
|
|
|
let fields = vec![
|
|
field_len("payload", FieldKind::Bytes, 4),
|
|
crc16_field,
|
|
crc32_field,
|
|
];
|
|
let result = parse_fields(&data, &fields).expect("parse");
|
|
assert_eq!(result.fields[1].checksum_valid, Some(true));
|
|
assert_eq!(result.fields[2].checksum_valid, Some(true));
|
|
|
|
let mut bad = data.clone();
|
|
bad[6] ^= 0xFF;
|
|
let mut crc32_bad = field("c32", FieldKind::Crc32);
|
|
crc32_bad.checksum_range = Some([0, 4]);
|
|
let fields = vec![
|
|
field_len("payload", FieldKind::Bytes, 4),
|
|
crc32_bad,
|
|
];
|
|
let result = parse_fields(&bad, &fields).expect("parse");
|
|
assert_eq!(result.fields[1].checksum_valid, Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_repeated_fixed_and_dynamic_count() {
|
|
let data = [0x02, 0x10, 0x20, 0x11, 0x21];
|
|
|
|
let repeated_fixed = FieldDef {
|
|
name: "items".into(),
|
|
kind: FieldKind::Repeated,
|
|
endian: None,
|
|
description: None,
|
|
flags: None,
|
|
length: None,
|
|
checksum_range: None,
|
|
count: Some(CountSpec::Fixed(2)),
|
|
fields: Some(vec![field("val", FieldKind::Uint8)]),
|
|
};
|
|
let result = parse_fields(&[0x10, 0x20, 0x11, 0x21], &[repeated_fixed]).expect("parse");
|
|
let children = result.fields[0].children.as_ref().expect("children");
|
|
assert_eq!(children.len(), 2);
|
|
assert!(matches!(children[0].value, ParsedValue::Uint(0x10)));
|
|
assert!(matches!(children[1].value, ParsedValue::Uint(0x20)));
|
|
|
|
let repeated_dynamic = FieldDef {
|
|
name: "list".into(),
|
|
kind: FieldKind::Repeated,
|
|
endian: None,
|
|
description: None,
|
|
flags: None,
|
|
length: None,
|
|
checksum_range: None,
|
|
count: Some(CountSpec::Field("count".into())),
|
|
fields: Some(vec![field("val", FieldKind::Uint8)]),
|
|
};
|
|
let result = parse_fields(&data, &[field("count", FieldKind::Uint8), repeated_dynamic]).expect("parse");
|
|
let children = result.fields[1].children.as_ref().expect("children");
|
|
assert_eq!(children.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn errors_on_truncated_buffer() {
|
|
let result = parse_fields(&[0x01], &[field("u16", FieldKind::Uint16)]);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Not enough bytes"));
|
|
}
|
|
|
|
#[test]
|
|
fn errors_on_missing_required_field_options() {
|
|
let err = parse_fields(&[0x01], &[field("raw", FieldKind::Bytes)]).unwrap_err();
|
|
assert!(err.contains("requires 'length'"));
|
|
|
|
let repeated = FieldDef {
|
|
name: "r".into(),
|
|
kind: FieldKind::Repeated,
|
|
endian: None,
|
|
description: None,
|
|
flags: None,
|
|
length: None,
|
|
checksum_range: None,
|
|
count: Some(CountSpec::Fixed(1)),
|
|
fields: None,
|
|
};
|
|
let err = parse_fields(&[0x01], &[repeated]).unwrap_err();
|
|
assert!(err.contains("requires 'fields'"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_example_protocol_frame() {
|
|
let header = [
|
|
0xFF, 0x05, b'H', b'e', b'l', b'l', b'o', 0x00, 0x01, 0x02, 0x03, b'A', b'B', b'C',
|
|
b'D',
|
|
];
|
|
let crc32 = compute_crc32(&header[..11]);
|
|
let mut data = header.to_vec();
|
|
data.extend_from_slice(&crc32.to_be_bytes());
|
|
data.extend_from_slice(&[0x00, 0x03]); // flags: ACK + SYN
|
|
|
|
let schema_json = include_str!("../../frontend/src/fixtures/example-schema.json");
|
|
let schema: Schema = serde_json::from_str(schema_json).expect("example schema");
|
|
let result = parse_fields(&data, &schema.fields).expect("example frame");
|
|
|
|
assert_eq!(result.total_bytes, data.len());
|
|
assert_eq!(result.consumed_bytes, data.len());
|
|
assert_eq!(result.fields.len(), 7);
|
|
|
|
assert!(matches!(result.fields[0].value, ParsedValue::Uint(0xFF)));
|
|
assert!(matches!(&result.fields[2].value, ParsedValue::Str(s) if s == "Hello"));
|
|
assert!(matches!(&result.fields[4].value, ParsedValue::Str(s) if s == "ABCD"));
|
|
assert_eq!(result.fields[5].checksum_valid, Some(true));
|
|
|
|
match &result.fields[6].value {
|
|
ParsedValue::Flags(flags) => {
|
|
assert!(flags.iter().find(|f| f.name == "ACK").unwrap().set);
|
|
assert!(flags.iter().find(|f| f.name == "SYN").unwrap().set);
|
|
assert!(!flags.iter().find(|f| f.name == "FIN").unwrap().set);
|
|
}
|
|
other => panic!("expected flags, got {other:?}"),
|
|
}
|
|
}
|