Add WASM parser and Vue dissector UI.
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>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user