Skip to content

Commit b62bfd3

Browse files
committed
Fix brio encoding/decoding
1 parent 437d7e5 commit b62bfd3

5 files changed

Lines changed: 538 additions & 15 deletions

File tree

src/haystack/encoding/brio.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ pub mod decode;
3737

3838
#[cfg(all(test, feature = "brio-encoding", feature = "brio-decoding"))]
3939
mod haxall_fixtures;
40+
41+
#[cfg(all(test, feature = "brio-encoding", feature = "brio-decoding"))]
42+
mod json_fixture_tests;

src/haystack/encoding/brio/consts.rs

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ use std::sync::OnceLock;
1717
/// Seconds between the Unix epoch (1970-01-01) and the Fantom epoch (2000-01-01).
1818
pub const FANTOM_EPOCH_UNIX_SECS: i64 = 946_684_800;
1919

20+
/// Maximum constant code the encoder may emit, matching Haxall's `BrioConsts.maxSafeCode = 945`.
21+
///
22+
/// Haxall caps its writer at this value so that older peers (which may not recognise
23+
/// constants added in post-3.0.17 versions) are not sent an opaque varint they cannot
24+
/// look up. Codes 946–1002 (3.0.25 and 3.0.27 additions) are recognised during *decoding*
25+
/// but intentionally not emitted during *encoding*.
26+
pub const MAX_SAFE_CONST_CODE: i64 = 945;
27+
2028
/// Canonical string constants, indexed from 0. Index 0 is the empty string.
2129
/// Haxall encodes `""` as `varint(0)` — confirmed by `BrioTest.fan`:
2230
/// `verifyConsts(cp, "", 0)`
@@ -1055,9 +1063,17 @@ fn consts_map() -> &'static HashMap<&'static str, i64> {
10551063

10561064
/// Look up `s` in the constant table.
10571065
///
1058-
/// Returns `Some(index)` (1-based) if found, `None` otherwise.
1066+
/// Returns `Some(index)` if found **and** the index is within [`MAX_SAFE_CONST_CODE`],
1067+
/// otherwise `None` (triggering inline encoding). This mirrors Haxall's
1068+
/// `BrioConsts.encode(val, maxStrCode)` cap so that libhaystack does not emit opaque
1069+
/// constant codes that older Haxall peers cannot resolve.
10591070
pub fn lookup_const(s: &str) -> Option<i64> {
1060-
consts_map().get(s).copied()
1071+
let idx = consts_map().get(s).copied()?;
1072+
if idx <= MAX_SAFE_CONST_CODE {
1073+
Some(idx)
1074+
} else {
1075+
None
1076+
}
10611077
}
10621078

10631079
/// Retrieve the constant string at `idx` (0-based; 0 = `""`).
@@ -1160,13 +1176,28 @@ mod tests {
11601176

11611177
#[test]
11621178
fn test_lookup_roundtrip() {
1163-
// Every entry including index 0 ("") should round-trip.
1179+
// Entries 0..=MAX_SAFE_CONST_CODE should round-trip via lookup_const.
1180+
// Entries above MAX_SAFE_CONST_CODE are decode-only (get_const still works,
1181+
// but lookup_const returns None so they are encoded as inline strings).
11641182
for (i, &s) in CONSTS.iter().enumerate() {
1165-
let idx =
1166-
lookup_const(s).unwrap_or_else(|| panic!("Missing const at index {i}: {s:?}"));
1167-
assert_eq!(idx, i as i64);
1168-
let got = get_const(idx).unwrap();
1169-
assert_eq!(got, s);
1183+
if i as i64 <= MAX_SAFE_CONST_CODE {
1184+
let idx =
1185+
lookup_const(s).unwrap_or_else(|| panic!("Missing const at index {i}: {s:?}"));
1186+
assert_eq!(idx, i as i64);
1187+
let got = get_const(idx).unwrap();
1188+
assert_eq!(got, s);
1189+
} else {
1190+
// Above the safe cap: should NOT be returned by lookup_const.
1191+
assert!(
1192+
lookup_const(s).is_none(),
1193+
"Expected None for index {i} ({s:?}) above MAX_SAFE_CONST_CODE"
1194+
);
1195+
// get_const should still decode them (for streams from Haxall).
1196+
assert!(
1197+
get_const(i as i64).is_some(),
1198+
"get_const({i}) should still work"
1199+
);
1200+
}
11701201
}
11711202
}
11721203
}

src/haystack/encoding/brio/decode.rs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ use crate::haystack::val::{
1313

1414
use super::consts::{FANTOM_EPOCH_UNIX_SECS, get_const};
1515
use super::encode::{
16-
CTRL_COORD, CTRL_DATE, CTRL_DATETIME_I4, CTRL_DATETIME_I8, CTRL_DICT, CTRL_DICT_EMPTY,
17-
CTRL_FALSE, CTRL_GRID, CTRL_LIST, CTRL_LIST_EMPTY, CTRL_MARKER, CTRL_NA, CTRL_NULL,
18-
CTRL_NUMBER_F8, CTRL_NUMBER_I2, CTRL_NUMBER_I4, CTRL_REF_I8, CTRL_REF_STR, CTRL_REMOVE,
19-
CTRL_STR, CTRL_SYMBOL, CTRL_TIME, CTRL_TRUE, CTRL_URI, CTRL_XSTR,
16+
CTRL_BUF, CTRL_COORD, CTRL_DATE, CTRL_DATETIME_I4, CTRL_DATETIME_I8, CTRL_DICT,
17+
CTRL_DICT_EMPTY, CTRL_FALSE, CTRL_GRID, CTRL_LIST, CTRL_LIST_EMPTY, CTRL_MARKER, CTRL_NA,
18+
CTRL_NULL, CTRL_NUMBER_F8, CTRL_NUMBER_I2, CTRL_NUMBER_I4, CTRL_REF_I8, CTRL_REF_STR,
19+
CTRL_REMOVE, CTRL_STR, CTRL_SYMBOL, CTRL_TIME, CTRL_TRUE, CTRL_URI, CTRL_XSTR,
2020
};
2121

2222
// ---------------------------------------------------------------------------
@@ -243,6 +243,24 @@ fn datetime_from_nanos(fantom_nanos: i64, tz: &str) -> Result<DateTime> {
243243
// Private payload decoders (ctrl byte already consumed by caller)
244244
// ---------------------------------------------------------------------------
245245

246+
/// Decode a Haxall binary buffer (`CTRL_BUF`/0x13) payload: `varint(size)` + raw bytes.
247+
///
248+
/// libhaystack has no `Bin` value kind, so the buffer is surfaced as
249+
/// `XStr("Bin", "<lowercase hex>")`, preserving all bytes without data loss and
250+
/// remaining compatible with Haystack's XStr encoding convention for binary data.
251+
fn decode_buf_as_xstr<R: Read>(reader: &mut R) -> Result<XStr> {
252+
let size = decode_varint(reader)?;
253+
if size < 0 {
254+
return Err(Error::Message("Negative Buf size".into()));
255+
}
256+
let mut bytes = vec![0u8; size as usize];
257+
reader
258+
.read_exact(&mut bytes)
259+
.map_err(|e| Error::Message(e.to_string()))?;
260+
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
261+
Ok(XStr::make("Bin", &hex))
262+
}
263+
246264
/// Decode a non-empty Dict payload: `'{' varint(count) (key value)* '}'`
247265
fn decode_dict_payload<R: Read>(reader: &mut R) -> Result<Dict> {
248266
let marker = read_u8(reader)?;
@@ -474,6 +492,7 @@ impl FromBrio for Value {
474492
&decode_str(reader)?,
475493
))),
476494
CTRL_SYMBOL => Ok(Value::from(Symbol::make(&decode_str(reader)?))),
495+
CTRL_BUF => decode_buf_as_xstr(reader).map(Value::from),
477496
CTRL_DICT_EMPTY => Ok(Value::from(Dict::default())),
478497
CTRL_DICT => decode_dict_payload(reader).map(Value::from),
479498
CTRL_LIST_EMPTY => Ok(Value::from(List::default())),
@@ -501,8 +520,23 @@ pub fn from_brio<R: Read>(reader: &mut R) -> Result<Value> {
501520

502521
fn make_number(v: f64, unit: &str) -> Number {
503522
if unit.is_empty() {
523+
return Number::make(v);
524+
}
525+
// Haxall `BrioReader.consumeUnit` calls `Number.loadUnit(str, checked:false)` which
526+
// returns `null` for unrecognised unit strings (the number becomes unitless). Match
527+
// that semantic when units-db is available. Without units-db, fall back to the
528+
// DEFAULT_UNIT sentinel so unit information is at least structurally preserved.
529+
#[cfg(feature = "units-db")]
530+
{
531+
use crate::units::get_unit;
532+
if let Some(u) = get_unit(unit) {
533+
return Number::make_with_unit(v, u);
534+
}
535+
// Unit string present but not in database → unitless (matches Haxall null).
504536
Number::make(v)
505-
} else {
537+
}
538+
#[cfg(not(feature = "units-db"))]
539+
{
506540
use crate::units::get_unit_or_default;
507541
Number::make_with_unit(v, get_unit_or_default(unit))
508542
}

src/haystack/encoding/brio/encode.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ pub const CTRL_DATETIME_I4: u8 = 0x0f;
3535
pub const CTRL_DATETIME_I8: u8 = 0x10;
3636
pub const CTRL_COORD: u8 = 0x11;
3737
pub const CTRL_XSTR: u8 = 0x12;
38+
/// Binary buffer control byte. Emitted by Haxall's `BrioWriter.writeBuf`; libhaystack
39+
/// does not encode this type (the `Value` model has no Bin variant) but the decoder must
40+
/// consume it gracefully to avoid stream corruption when reading Haxall-produced data.
41+
pub const CTRL_BUF: u8 = 0x13;
3842
pub const CTRL_DICT_EMPTY: u8 = 0x14;
3943
pub const CTRL_DICT: u8 = 0x15;
4044
pub const CTRL_LIST_EMPTY: u8 = 0x16;
@@ -389,12 +393,21 @@ impl ToBrio for XStr {
389393

390394
impl ToBrio for Dict {
391395
fn to_brio<W: Write>(&self, writer: &mut W) -> Result<()> {
392-
if self.is_empty() {
396+
// Haxall BrioWriter.fan skips null-valued tags: they must not appear in
397+
// the count or in the encoded tag/value pairs.
398+
let non_null_count = self
399+
.iter()
400+
.filter(|(_, v)| !matches!(v, Value::Null))
401+
.count();
402+
if non_null_count == 0 {
393403
writer.write_all(&[CTRL_DICT_EMPTY])?;
394404
} else {
395405
writer.write_all(&[CTRL_DICT, b'{'])?;
396-
encode_varint(writer, self.len() as i64)?;
406+
encode_varint(writer, non_null_count as i64)?;
397407
for (key, val) in self.iter() {
408+
if matches!(val, Value::Null) {
409+
continue;
410+
}
398411
encode_str(writer, key)?;
399412
val.to_brio(writer)?;
400413
}

0 commit comments

Comments
 (0)