Skip to main content

synx_core/
binary.rs

1//! SYNX Binary Format (.synxb) — compact binary serialization with string interning.
2//!
3//! Layout:
4//! ```text
5//! HEADER       5 bytes magic "SYNXB"
6//!              1 byte  version (currently 1)
7//!              1 byte  flags   (bit 0: active, bit 1: locked,
8//!                               bit 2: has_metadata, bit 3: resolved,
9//!                               bit 4: tool, bit 5: schema, bit 6: llm)
10//! STRING_TABLE varint count
11//!              for each: varint len + UTF-8 bytes
12//! VALUE_TREE   Root Value (recursive, strings as table indices)
13//! [METADATA]   if flag bit 2 set
14//! [INCLUDES]   if flag bit 2 set
15//! ```
16//!
17//! Type tags (1 byte):
18//!   0x00 Null
19//!   0x01 Bool(false)
20//!   0x02 Bool(true)
21//!   0x03 Int       + zigzag varint
22//!   0x04 Float     + 8 bytes LE
23//!   0x05 String    + varint string_table_index
24//!   0x06 Array     + varint count + values
25//!   0x07 Object    + varint count + (varint key_index + value) pairs
26//!   0x08 Secret    + varint string_table_index
27
28use crate::value::{
29    Constraints, IncludeDirective, Meta, MetaMap, Mode, ParseResult, Value,
30};
31use std::collections::HashMap;
32
33const MAGIC: &[u8; 5] = b"SYNXB";
34const FORMAT_VERSION: u8 = 1;
35
36/// Upper bound on the decompressed payload a `.synxb` decode will materialise.
37///
38/// safety: `decompile` receives an untrusted byte slice. The declared
39/// uncompressed size is a `u32` (up to 4 GiB) and the deflate stream can expand
40/// far beyond its own length (a "zip bomb"), so decompression MUST be bounded
41/// rather than trusting the header. 256 MiB comfortably exceeds any tree a
42/// 16 MiB `.synx` source (SYNX §3) produces while refusing the pathological case.
43const MAX_DECODED_BYTES: usize = 256 * 1024 * 1024;
44
45/// Depth limit for the recursive value decoder. Mirrors the parser's
46/// `MAX_PARSE_NESTING_DEPTH` headroom (a hostile `.synxb` can nest ARRAY/OBJECT
47/// tags arbitrarily even though `parse` caps text at 128) so decoding a crafted
48/// container cannot overflow the stack.
49const MAX_DECODE_DEPTH: usize = 1024;
50
51/// Bound a `Vec::with_capacity` request taken from untrusted input.
52///
53/// safety: element counts are read as varints straight from the file. A crafted
54/// count of `u64::MAX` would abort the process inside `with_capacity` before a
55/// single element is read. Every element costs at least one byte on the wire, so
56/// the count can never exceed the bytes that remain; clamp to that.
57fn safe_capacity(count: usize, remaining: usize) -> usize {
58    count.min(remaining)
59}
60
61const FLAG_ACTIVE: u8 = 0b0000_0001;
62const FLAG_LOCKED: u8 = 0b0000_0010;
63const FLAG_HAS_META: u8 = 0b0000_0100;
64const FLAG_RESOLVED: u8 = 0b0000_1000;
65const FLAG_TOOL: u8 = 0b0001_0000;
66const FLAG_SCHEMA: u8 = 0b0010_0000;
67const FLAG_LLM: u8 = 0b0100_0000;
68
69const TAG_NULL: u8 = 0x00;
70const TAG_FALSE: u8 = 0x01;
71const TAG_TRUE: u8 = 0x02;
72const TAG_INT: u8 = 0x03;
73const TAG_FLOAT: u8 = 0x04;
74const TAG_STRING: u8 = 0x05;
75const TAG_ARRAY: u8 = 0x06;
76const TAG_OBJECT: u8 = 0x07;
77const TAG_SECRET: u8 = 0x08;
78
79// ─── Varint Encoding (LEB128 unsigned) ───────────────────────────────────────
80
81fn encode_varint(out: &mut Vec<u8>, mut val: u64) {
82    loop {
83        let byte = (val & 0x7F) as u8;
84        val >>= 7;
85        if val == 0 {
86            out.push(byte);
87            return;
88        }
89        out.push(byte | 0x80);
90    }
91}
92
93fn decode_varint(data: &[u8], pos: &mut usize) -> Result<u64, String> {
94    let mut result: u64 = 0;
95    let mut shift = 0u32;
96    loop {
97        if *pos >= data.len() {
98            return Err("unexpected end of data in varint".into());
99        }
100        let byte = data[*pos];
101        *pos += 1;
102        result |= ((byte & 0x7F) as u64) << shift;
103        if byte & 0x80 == 0 {
104            return Ok(result);
105        }
106        shift += 7;
107        if shift >= 64 {
108            return Err("varint overflow".into());
109        }
110    }
111}
112
113// Zigzag encode i64 → u64
114fn zigzag_encode(n: i64) -> u64 {
115    ((n << 1) ^ (n >> 63)) as u64
116}
117
118// Zigzag decode u64 → i64
119fn zigzag_decode(n: u64) -> i64 {
120    ((n >> 1) as i64) ^ (-((n & 1) as i64))
121}
122
123// ─── String Table ────────────────────────────────────────────────────────────
124
125struct StringTable {
126    strings: Vec<String>,
127    index: HashMap<String, u32>,
128}
129
130impl StringTable {
131    fn new() -> Self {
132        Self { strings: Vec::new(), index: HashMap::new() }
133    }
134
135    fn intern(&mut self, s: &str) -> u32 {
136        if let Some(&idx) = self.index.get(s) {
137            return idx;
138        }
139        let idx = self.strings.len() as u32;
140        self.strings.push(s.to_string());
141        self.index.insert(s.to_string(), idx);
142        idx
143    }
144
145    fn collect_value(&mut self, val: &Value) {
146        match val {
147            Value::String(s) | Value::Secret(s) => { self.intern(s); }
148            Value::Array(arr) => {
149                for item in arr { self.collect_value(item); }
150            }
151            Value::Object(map) => {
152                for (key, val) in map {
153                    self.intern(key);
154                    self.collect_value(val);
155                }
156            }
157            _ => {}
158        }
159    }
160
161    fn collect_metadata(&mut self, metadata: &HashMap<String, MetaMap>) {
162        for (path, meta_map) in metadata {
163            self.intern(path);
164            for (key, meta) in meta_map {
165                self.intern(key);
166                for m in &meta.markers { self.intern(m); }
167                for a in &meta.args { self.intern(a); }
168                if let Some(ref th) = meta.type_hint { self.intern(th); }
169                if let Some(ref c) = meta.constraints {
170                    if let Some(ref tn) = c.type_name { self.intern(tn); }
171                    if let Some(ref pat) = c.pattern { self.intern(pat); }
172                    if let Some(ref ev) = c.enum_values {
173                        for v in ev { self.intern(v); }
174                    }
175                }
176            }
177        }
178    }
179
180    fn collect_includes(&mut self, includes: &[IncludeDirective]) {
181        for inc in includes {
182            self.intern(&inc.path);
183            self.intern(&inc.alias);
184        }
185    }
186
187    fn encode(&self, out: &mut Vec<u8>) {
188        encode_varint(out, self.strings.len() as u64);
189        for s in &self.strings {
190            encode_varint(out, s.len() as u64);
191            out.extend_from_slice(s.as_bytes());
192        }
193    }
194}
195
196struct StringTableReader {
197    strings: Vec<String>,
198}
199
200impl StringTableReader {
201    fn decode(data: &[u8], pos: &mut usize) -> Result<Self, String> {
202        let count = decode_varint(data, pos)? as usize;
203        let mut strings = Vec::with_capacity(safe_capacity(count, data.len().saturating_sub(*pos)));
204        for _ in 0..count {
205            let len = decode_varint(data, pos)? as usize;
206            if *pos + len > data.len() {
207                return Err("unexpected end of data in string table".into());
208            }
209            let s = std::str::from_utf8(&data[*pos..*pos + len])
210                .map_err(|e| format!("invalid UTF-8 in string table: {}", e))?
211                .to_string();
212            *pos += len;
213            strings.push(s);
214        }
215        Ok(Self { strings })
216    }
217
218    fn get(&self, idx: u32) -> Result<&str, String> {
219        self.strings.get(idx as usize)
220            .map(|s| s.as_str())
221            .ok_or_else(|| format!("string index {} out of bounds (size {})", idx, self.strings.len()))
222    }
223}
224
225// ─── Value encoding (with string table) ─────────────────────────────────────
226
227fn encode_value(out: &mut Vec<u8>, val: &Value, st: &StringTable) {
228    match val {
229        Value::Null => out.push(TAG_NULL),
230        Value::Bool(false) => out.push(TAG_FALSE),
231        Value::Bool(true) => out.push(TAG_TRUE),
232        Value::Int(n) => {
233            out.push(TAG_INT);
234            encode_varint(out, zigzag_encode(*n));
235        }
236        Value::Float(f) => {
237            out.push(TAG_FLOAT);
238            out.extend_from_slice(&f.to_le_bytes());
239        }
240        Value::String(s) => {
241            out.push(TAG_STRING);
242            encode_varint(out, st.index[s] as u64);
243        }
244        Value::Array(arr) => {
245            out.push(TAG_ARRAY);
246            encode_varint(out, arr.len() as u64);
247            for item in arr {
248                encode_value(out, item, st);
249            }
250        }
251        Value::Object(map) => {
252            out.push(TAG_OBJECT);
253            let mut entries: Vec<(&str, &Value)> =
254                map.iter().map(|(k, v)| (k.as_str(), v)).collect();
255            entries.sort_unstable_by_key(|(k, _)| *k);
256            encode_varint(out, entries.len() as u64);
257            for (key, val) in entries {
258                encode_varint(out, st.index[key] as u64);
259                encode_value(out, val, st);
260            }
261        }
262        Value::Secret(s) => {
263            out.push(TAG_SECRET);
264            encode_varint(out, st.index[s] as u64);
265        }
266    }
267}
268
269fn decode_value(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<Value, String> {
270    decode_value_depth(data, pos, st, 0)
271}
272
273fn decode_value_depth(data: &[u8], pos: &mut usize, st: &StringTableReader, depth: usize) -> Result<Value, String> {
274    if depth > MAX_DECODE_DEPTH {
275        return Err(format!("nesting exceeds {} — refusing to decode", MAX_DECODE_DEPTH));
276    }
277    if *pos >= data.len() {
278        return Err("unexpected end of data".into());
279    }
280    let tag = data[*pos];
281    *pos += 1;
282    match tag {
283        TAG_NULL => Ok(Value::Null),
284        TAG_FALSE => Ok(Value::Bool(false)),
285        TAG_TRUE => Ok(Value::Bool(true)),
286        TAG_INT => {
287            let raw = decode_varint(data, pos)?;
288            Ok(Value::Int(zigzag_decode(raw)))
289        }
290        TAG_FLOAT => {
291            if *pos + 8 > data.len() {
292                return Err("unexpected end of data in float".into());
293            }
294            let bytes: [u8; 8] = data[*pos..*pos + 8]
295                .try_into()
296                .map_err(|_| "float decode error")?;
297            *pos += 8;
298            Ok(Value::Float(f64::from_le_bytes(bytes)))
299        }
300        TAG_STRING => {
301            let idx = decode_varint(data, pos)? as u32;
302            Ok(Value::String(st.get(idx)?.to_string()))
303        }
304        TAG_ARRAY => {
305            let count = decode_varint(data, pos)? as usize;
306            let mut arr = Vec::with_capacity(safe_capacity(count, data.len().saturating_sub(*pos)));
307            for _ in 0..count {
308                arr.push(decode_value_depth(data, pos, st, depth + 1)?);
309            }
310            Ok(Value::Array(arr))
311        }
312        TAG_OBJECT => {
313            let count = decode_varint(data, pos)? as usize;
314            let mut map = HashMap::with_capacity(safe_capacity(count, data.len().saturating_sub(*pos)));
315            for _ in 0..count {
316                let key_idx = decode_varint(data, pos)? as u32;
317                let key = st.get(key_idx)?.to_string();
318                let val = decode_value_depth(data, pos, st, depth + 1)?;
319                map.insert(key, val);
320            }
321            Ok(Value::Object(map))
322        }
323        TAG_SECRET => {
324            let idx = decode_varint(data, pos)? as u32;
325            Ok(Value::Secret(st.get(idx)?.to_string()))
326        }
327        _ => Err(format!("unknown type tag: 0x{:02x}", tag)),
328    }
329}
330
331// ─── Metadata encoding ──────────────────────────────────────────────────────
332
333fn encode_constraints(out: &mut Vec<u8>, c: &Constraints, st: &StringTable) {
334    let mut bits: u8 = 0;
335    if c.min.is_some() { bits |= 0x01; }
336    if c.max.is_some() { bits |= 0x02; }
337    if c.type_name.is_some() { bits |= 0x04; }
338    if c.required { bits |= 0x08; }
339    if c.readonly { bits |= 0x10; }
340    if c.pattern.is_some() { bits |= 0x20; }
341    if c.enum_values.is_some() { bits |= 0x40; }
342    out.push(bits);
343
344    if let Some(min) = c.min { out.extend_from_slice(&min.to_le_bytes()); }
345    if let Some(max) = c.max { out.extend_from_slice(&max.to_le_bytes()); }
346    if let Some(ref tn) = c.type_name { encode_varint(out, st.index[tn] as u64); }
347    if let Some(ref pat) = c.pattern { encode_varint(out, st.index[pat] as u64); }
348    if let Some(ref ev) = c.enum_values {
349        encode_varint(out, ev.len() as u64);
350        for v in ev { encode_varint(out, st.index[v] as u64); }
351    }
352}
353
354fn decode_constraints(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<Constraints, String> {
355    if *pos >= data.len() {
356        return Err("unexpected end of data in constraints".into());
357    }
358    let bits = data[*pos];
359    *pos += 1;
360    let mut c = Constraints::default();
361
362    if bits & 0x01 != 0 {
363        if *pos + 8 > data.len() { return Err("truncated min".into()); }
364        let bytes: [u8; 8] = data[*pos..*pos + 8].try_into().map_err(|_| "min decode")?;
365        c.min = Some(f64::from_le_bytes(bytes));
366        *pos += 8;
367    }
368    if bits & 0x02 != 0 {
369        if *pos + 8 > data.len() { return Err("truncated max".into()); }
370        let bytes: [u8; 8] = data[*pos..*pos + 8].try_into().map_err(|_| "max decode")?;
371        c.max = Some(f64::from_le_bytes(bytes));
372        *pos += 8;
373    }
374    if bits & 0x04 != 0 {
375        let idx = decode_varint(data, pos)? as u32;
376        c.type_name = Some(st.get(idx)?.to_string());
377    }
378    if bits & 0x08 != 0 { c.required = true; }
379    if bits & 0x10 != 0 { c.readonly = true; }
380    if bits & 0x20 != 0 {
381        let idx = decode_varint(data, pos)? as u32;
382        c.pattern = Some(st.get(idx)?.to_string());
383    }
384    if bits & 0x40 != 0 {
385        let count = decode_varint(data, pos)? as usize;
386        let mut vals = Vec::with_capacity(safe_capacity(count, data.len().saturating_sub(*pos)));
387        for _ in 0..count {
388            let idx = decode_varint(data, pos)? as u32;
389            vals.push(st.get(idx)?.to_string());
390        }
391        c.enum_values = Some(vals);
392    }
393    Ok(c)
394}
395
396fn encode_meta(out: &mut Vec<u8>, meta: &Meta, st: &StringTable) {
397    encode_varint(out, meta.markers.len() as u64);
398    for m in &meta.markers { encode_varint(out, st.index[m] as u64); }
399    encode_varint(out, meta.args.len() as u64);
400    for a in &meta.args { encode_varint(out, st.index[a] as u64); }
401    if let Some(ref th) = meta.type_hint {
402        out.push(1);
403        encode_varint(out, st.index[th] as u64);
404    } else {
405        out.push(0);
406    }
407    if let Some(ref c) = meta.constraints {
408        out.push(1);
409        encode_constraints(out, c, st);
410    } else {
411        out.push(0);
412    }
413}
414
415fn decode_meta(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<Meta, String> {
416    let marker_count = decode_varint(data, pos)? as usize;
417    let mut markers = Vec::with_capacity(safe_capacity(marker_count, data.len().saturating_sub(*pos)));
418    for _ in 0..marker_count {
419        let idx = decode_varint(data, pos)? as u32;
420        markers.push(st.get(idx)?.to_string());
421    }
422
423    let arg_count = decode_varint(data, pos)? as usize;
424    let mut args = Vec::with_capacity(safe_capacity(arg_count, data.len().saturating_sub(*pos)));
425    for _ in 0..arg_count {
426        let idx = decode_varint(data, pos)? as u32;
427        args.push(st.get(idx)?.to_string());
428    }
429
430    if *pos >= data.len() { return Err("unexpected end in meta".into()); }
431    let has_th = data[*pos];
432    *pos += 1;
433    let type_hint = if has_th != 0 {
434        let idx = decode_varint(data, pos)? as u32;
435        Some(st.get(idx)?.to_string())
436    } else { None };
437
438    if *pos >= data.len() { return Err("unexpected end in meta".into()); }
439    let has_c = data[*pos];
440    *pos += 1;
441    let constraints = if has_c != 0 { Some(decode_constraints(data, pos, st)?) } else { None };
442
443    Ok(Meta { markers, args, type_hint, constraints })
444}
445
446fn encode_metadata(out: &mut Vec<u8>, metadata: &HashMap<String, MetaMap>, st: &StringTable) {
447    let mut outer_keys: Vec<&str> = metadata.keys().map(|k| k.as_str()).collect();
448    outer_keys.sort_unstable();
449    encode_varint(out, outer_keys.len() as u64);
450    for key_path in outer_keys {
451        encode_varint(out, st.index[key_path] as u64);
452        let meta_map = &metadata[key_path];
453        let mut inner_keys: Vec<&str> = meta_map.keys().map(|k| k.as_str()).collect();
454        inner_keys.sort_unstable();
455        encode_varint(out, inner_keys.len() as u64);
456        for field_key in inner_keys {
457            encode_varint(out, st.index[field_key] as u64);
458            encode_meta(out, &meta_map[field_key], st);
459        }
460    }
461}
462
463fn decode_metadata(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<HashMap<String, MetaMap>, String> {
464    let outer_count = decode_varint(data, pos)? as usize;
465    let mut metadata = HashMap::with_capacity(safe_capacity(outer_count, data.len().saturating_sub(*pos)));
466    for _ in 0..outer_count {
467        let path_idx = decode_varint(data, pos)? as u32;
468        let key_path = st.get(path_idx)?.to_string();
469        let inner_count = decode_varint(data, pos)? as usize;
470        let mut meta_map = HashMap::with_capacity(safe_capacity(inner_count, data.len().saturating_sub(*pos)));
471        for _ in 0..inner_count {
472            let key_idx = decode_varint(data, pos)? as u32;
473            let field_key = st.get(key_idx)?.to_string();
474            let meta = decode_meta(data, pos, st)?;
475            meta_map.insert(field_key, meta);
476        }
477        metadata.insert(key_path, meta_map);
478    }
479    Ok(metadata)
480}
481
482fn encode_includes(out: &mut Vec<u8>, includes: &[IncludeDirective], st: &StringTable) {
483    encode_varint(out, includes.len() as u64);
484    for inc in includes {
485        encode_varint(out, st.index[&inc.path] as u64);
486        encode_varint(out, st.index[&inc.alias] as u64);
487    }
488}
489
490fn decode_includes(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<Vec<IncludeDirective>, String> {
491    let count = decode_varint(data, pos)? as usize;
492    let mut includes = Vec::with_capacity(safe_capacity(count, data.len().saturating_sub(*pos)));
493    for _ in 0..count {
494        let path_idx = decode_varint(data, pos)? as u32;
495        let alias_idx = decode_varint(data, pos)? as u32;
496        includes.push(IncludeDirective {
497            path: st.get(path_idx)?.to_string(),
498            alias: st.get(alias_idx)?.to_string(),
499        });
500    }
501    Ok(includes)
502}
503
504// ─── Public API ──────────────────────────────────────────────────────────────
505
506/// Compile a `ParseResult` into compact binary `.synxb` format.
507///
508/// Uses a string interning table so every unique string is stored once,
509/// then deflate-compresses the payload for maximum compactness.
510/// If `resolved` is true, metadata and includes are stripped.
511pub fn compile(result: &ParseResult, resolved: bool) -> Vec<u8> {
512    let mut st = StringTable::new();
513    st.collect_value(&result.root);
514    let has_meta = !resolved && !result.metadata.is_empty();
515    if has_meta {
516        st.collect_metadata(&result.metadata);
517        st.collect_includes(&result.includes);
518    }
519
520    // Build uncompressed payload
521    let mut payload = Vec::with_capacity(1024);
522    st.encode(&mut payload);
523    encode_value(&mut payload, &result.root, &st);
524    if has_meta {
525        encode_metadata(&mut payload, &result.metadata, &st);
526        encode_includes(&mut payload, &result.includes, &st);
527    }
528
529    // Compress payload.
530    //
531    // This is a storage format: favor size over speed. Using level 9 also makes
532    // size-reduction expectations stable across platforms/runner images.
533    let compressed = miniz_oxide::deflate::compress_to_vec(&payload, 9);
534
535    // Build final output: header + compressed data
536    let mut out = Vec::with_capacity(7 + 4 + compressed.len());
537
538    // Header (always uncompressed for magic detection)
539    out.extend_from_slice(MAGIC);
540    out.push(FORMAT_VERSION);
541
542    let mut flags: u8 = 0;
543    if result.mode == Mode::Active { flags |= FLAG_ACTIVE; }
544    if result.locked { flags |= FLAG_LOCKED; }
545    if has_meta { flags |= FLAG_HAS_META; }
546    if resolved { flags |= FLAG_RESOLVED; }
547    if result.tool { flags |= FLAG_TOOL; }
548    if result.schema { flags |= FLAG_SCHEMA; }
549    if result.llm { flags |= FLAG_LLM; }
550    out.push(flags);
551
552    // Uncompressed size (for pre-allocation on decode)
553    out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
554
555    // Compressed payload
556    out.extend_from_slice(&compressed);
557
558    out
559}
560
561/// Decompile a `.synxb` binary back into a `ParseResult`.
562pub fn decompile(data: &[u8]) -> Result<ParseResult, String> {
563    if data.len() < 11 {
564        return Err("file too small for .synxb header".into());
565    }
566    if &data[0..5] != MAGIC {
567        return Err("invalid .synxb magic (expected SYNXB)".into());
568    }
569    let version = data[5];
570    if version != FORMAT_VERSION {
571        return Err(format!("unsupported .synxb version: {} (expected {})", version, FORMAT_VERSION));
572    }
573    let flags = data[6];
574
575    // Read uncompressed size
576    let uncomp_size = u32::from_le_bytes(
577        data[7..11].try_into().map_err(|_| "failed to read size")?
578    ) as usize;
579
580    // safety: reject an oversized declared size before allocating, then bound
581    // the actual inflate so a small crafted stream cannot expand into gigabytes
582    // (a decompression bomb) — the length check alone runs only *after* the
583    // buffer has already grown.
584    if uncomp_size > MAX_DECODED_BYTES {
585        return Err(format!("declared size {} exceeds limit {}", uncomp_size, MAX_DECODED_BYTES));
586    }
587    let payload = miniz_oxide::inflate::decompress_to_vec_with_limit(&data[11..], MAX_DECODED_BYTES)
588        .map_err(|e| format!("decompression failed: {:?}", e))?;
589    if payload.len() != uncomp_size {
590        return Err(format!("size mismatch: expected {}, got {}", uncomp_size, payload.len()));
591    }
592
593    let mut pos = 0;
594
595    // String table
596    let st = StringTableReader::decode(&payload, &mut pos)?;
597
598    // Root value
599    let root = decode_value(&payload, &mut pos, &st)?;
600
601    let mode = if flags & FLAG_ACTIVE != 0 { Mode::Active } else { Mode::Static };
602    let locked = flags & FLAG_LOCKED != 0;
603    let tool = flags & FLAG_TOOL != 0;
604    let schema = flags & FLAG_SCHEMA != 0;
605    let llm = flags & FLAG_LLM != 0;
606
607    let (metadata, includes) = if flags & FLAG_HAS_META != 0 {
608        let meta = decode_metadata(&payload, &mut pos, &st)?;
609        let inc = decode_includes(&payload, &mut pos, &st)?;
610        (meta, inc)
611    } else {
612        (HashMap::new(), Vec::new())
613    };
614
615    Ok(ParseResult {
616        root,
617        mode,
618        locked,
619        tool,
620        schema,
621        llm,
622        metadata,
623        includes,
624        uses: Vec::new(),
625        // A `.synxb` container carries an already-parsed tree; nothing is
626        // dropped on the way back out.
627        truncated: false,
628    })
629}
630
631/// Check if data starts with the `.synxb` magic bytes.
632pub fn is_synxb(data: &[u8]) -> bool {
633    data.len() >= 5 && &data[0..5] == MAGIC
634}
635
636// ─── Tests ───────────────────────────────────────────────────────────────────
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn test_varint_roundtrip() {
644        for &val in &[0u64, 1, 127, 128, 300, 16383, 16384, u64::MAX >> 1] {
645            let mut buf = Vec::new();
646            encode_varint(&mut buf, val);
647            let mut pos = 0;
648            let decoded = decode_varint(&buf, &mut pos).unwrap();
649            assert_eq!(val, decoded, "varint roundtrip failed for {}", val);
650        }
651    }
652
653    #[test]
654    fn test_zigzag_roundtrip() {
655        for &val in &[0i64, 1, -1, 42, -42, i64::MAX, i64::MIN] {
656            let encoded = zigzag_encode(val);
657            let decoded = zigzag_decode(encoded);
658            assert_eq!(val, decoded, "zigzag roundtrip failed for {}", val);
659        }
660    }
661
662    #[test]
663    fn test_compile_decompile_static() {
664        let mut root = HashMap::new();
665        root.insert("name".to_string(), Value::String("Test".into()));
666        root.insert("port".to_string(), Value::Int(8080));
667
668        let result = ParseResult {
669            root: Value::Object(root),
670            mode: Mode::Static,
671            locked: false,
672            tool: false,
673            schema: false,
674            llm: false,
675            metadata: HashMap::new(),
676            includes: Vec::new(),
677            uses: Vec::new(),
678            truncated: false,
679        };
680
681        let binary = compile(&result, false);
682        assert!(is_synxb(&binary));
683
684        let restored = decompile(&binary).unwrap();
685        assert_eq!(restored.root, result.root);
686        assert_eq!(restored.mode, Mode::Static);
687        assert!(!restored.locked);
688    }
689
690    #[test]
691    fn test_compile_decompile_active_with_metadata() {
692        let mut root = HashMap::new();
693        root.insert("host".to_string(), Value::String("0.0.0.0".into()));
694        root.insert("port".to_string(), Value::Int(3000));
695
696        let mut meta_map = HashMap::new();
697        meta_map.insert("port".to_string(), Meta {
698            markers: vec!["env".to_string()],
699            args: vec!["default".to_string(), "3000".to_string()],
700            type_hint: Some("int".to_string()),
701            constraints: Some(Constraints {
702                min: Some(1.0),
703                max: Some(65535.0),
704                required: true,
705                ..Default::default()
706            }),
707        });
708
709        let mut metadata = HashMap::new();
710        metadata.insert(String::new(), meta_map);
711
712        let includes = vec![IncludeDirective {
713            path: "./base.synx".to_string(),
714            alias: "base".to_string(),
715        }];
716
717        let result = ParseResult {
718            root: Value::Object(root),
719            mode: Mode::Active,
720            locked: true,
721            tool: false,
722            schema: false,
723            llm: false,
724            metadata,
725        includes,
726        uses: Vec::new(),
727        truncated: false,
728    };
729
730        let binary = compile(&result, false);
731        let restored = decompile(&binary).unwrap();
732
733        assert_eq!(restored.root, result.root);
734        assert_eq!(restored.mode, Mode::Active);
735        assert!(restored.locked);
736        assert_eq!(restored.metadata.len(), 1);
737        let rm = &restored.metadata[""];
738        assert_eq!(rm["port"].markers, vec!["env"]);
739        assert_eq!(rm["port"].args, vec!["default", "3000"]);
740        assert_eq!(rm["port"].type_hint, Some("int".to_string()));
741        let c = rm["port"].constraints.as_ref().unwrap();
742        assert_eq!(c.min, Some(1.0));
743        assert_eq!(c.max, Some(65535.0));
744        assert!(c.required);
745        assert_eq!(restored.includes.len(), 1);
746        assert_eq!(restored.includes[0].path, "./base.synx");
747    }
748
749    #[test]
750    fn test_compile_resolved_strips_metadata() {
751        let mut root = HashMap::new();
752        root.insert("val".to_string(), Value::Int(42));
753
754        let mut meta_map = HashMap::new();
755        meta_map.insert("val".to_string(), Meta {
756            markers: vec!["calc".to_string()],
757            args: Vec::new(),
758            type_hint: None,
759            constraints: None,
760        });
761        let mut metadata = HashMap::new();
762        metadata.insert(String::new(), meta_map);
763
764        let result = ParseResult {
765            root: Value::Object(root),
766            mode: Mode::Active,
767            locked: false,
768            tool: false,
769            schema: false,
770            llm: false,
771            metadata,
772            includes: Vec::new(),
773            uses: Vec::new(),
774            truncated: false,
775        };
776
777        let binary = compile(&result, true);
778        let restored = decompile(&binary).unwrap();
779
780        assert_eq!(restored.root, result.root);
781        assert!(restored.metadata.is_empty());
782        assert!(restored.includes.is_empty());
783    }
784
785    #[test]
786    fn test_is_synxb() {
787        assert!(is_synxb(b"SYNXB\x01\x00"));
788        assert!(!is_synxb(b"JSON{"));
789        assert!(!is_synxb(b"SYN"));
790    }
791
792    #[test]
793    fn test_invalid_magic() {
794        let err = decompile(b"WRONG\x01\x00\x00\x00\x00\x00").unwrap_err();
795        assert!(err.contains("invalid .synxb magic"));
796    }
797
798    #[test]
799    fn test_invalid_version() {
800        let err = decompile(b"SYNXB\xFF\x00\x00\x00\x00\x00").unwrap_err();
801        assert!(err.contains("unsupported .synxb version"));
802    }
803
804    #[test]
805    fn test_nested_object_roundtrip() {
806        let mut inner = HashMap::new();
807        inner.insert("host".to_string(), Value::String("localhost".into()));
808        inner.insert("port".to_string(), Value::Int(5432));
809
810        let mut root = HashMap::new();
811        root.insert("name".to_string(), Value::String("app".into()));
812        root.insert("database".to_string(), Value::Object(inner));
813        root.insert("tags".to_string(), Value::Array(vec![
814            Value::String("prod".into()),
815            Value::String("v2".into()),
816        ]));
817
818        let result = ParseResult {
819            root: Value::Object(root),
820            mode: Mode::Static,
821            locked: false,
822            tool: false,
823            schema: false,
824            llm: false,
825            metadata: HashMap::new(),
826            includes: Vec::new(),
827            uses: Vec::new(),
828            truncated: false,
829        };
830
831        let binary = compile(&result, false);
832        let restored = decompile(&binary).unwrap();
833        assert_eq!(restored.root, result.root);
834    }
835
836    #[test]
837    fn test_full_roundtrip_parse_compile_decompile() {
838        let synx_text = "name TotalWario\nversion 3.0.0\nport 8080\ndebug false\n";
839        let parsed = crate::parse(synx_text);
840        let binary = compile(&parsed, false);
841
842        let restored = decompile(&binary).unwrap();
843        assert_eq!(restored.root, parsed.root);
844        assert_eq!(restored.mode, parsed.mode);
845    }
846
847    #[test]
848    fn test_large_config_size_reduction() {
849        let synx_text = include_str!("../../../benchmarks/config.synx");
850        let parsed = crate::parse(synx_text);
851        let binary = compile(&parsed, false);
852        let ratio = binary.len() as f64 / synx_text.len() as f64;
853        // Compression ratios can vary slightly across platforms/toolchains.
854        // Keep this test as a regression guard (binary should be *meaningfully* smaller),
855        // without making CI brittle.
856        assert!(
857            ratio < 0.65,
858            "binary should be at least 35% smaller: {} bytes vs {} bytes (ratio {:.2})",
859            binary.len(), synx_text.len(), ratio
860        );
861    }
862
863    #[test]
864    fn test_large_config_full_roundtrip() {
865        let synx_text = include_str!("../../../benchmarks/config.synx");
866        let parsed = crate::parse(synx_text);
867        let binary = compile(&parsed, false);
868        let restored = decompile(&binary).unwrap();
869        assert_eq!(restored.root, parsed.root);
870        assert_eq!(restored.mode, parsed.mode);
871    }
872
873    #[test]
874    fn test_constraints_full_roundtrip() {
875        let c_orig = Constraints {
876            min: Some(0.0),
877            max: Some(100.0),
878            type_name: Some("int".to_string()),
879            required: true,
880            readonly: true,
881            pattern: Some(r"^\d+$".to_string()),
882            enum_values: Some(vec!["a".into(), "b".into(), "c".into()]),
883        };
884
885        let mut meta_map = HashMap::new();
886        meta_map.insert("field".to_string(), Meta {
887            markers: Vec::new(),
888            args: Vec::new(),
889            type_hint: None,
890            constraints: Some(c_orig.clone()),
891        });
892        let mut metadata = HashMap::new();
893        metadata.insert(String::new(), meta_map);
894
895        let mut root = HashMap::new();
896        root.insert("field".to_string(), Value::Int(42));
897
898        let result = ParseResult {
899            root: Value::Object(root),
900            mode: Mode::Active,
901            locked: false,
902            tool: false,
903            schema: false,
904            llm: false,
905            metadata,
906            includes: Vec::new(),
907            uses: Vec::new(),
908            truncated: false,
909        };
910
911        let binary = compile(&result, false);
912        let restored = decompile(&binary).unwrap();
913        let rm = &restored.metadata[""];
914        let c = rm["field"].constraints.as_ref().unwrap();
915        assert_eq!(c.min, c_orig.min);
916        assert_eq!(c.max, c_orig.max);
917        assert_eq!(c.type_name, c_orig.type_name);
918        assert_eq!(c.required, c_orig.required);
919        assert_eq!(c.readonly, c_orig.readonly);
920        assert_eq!(c.pattern, c_orig.pattern);
921        assert_eq!(c.enum_values, c_orig.enum_values);
922    }
923
924    #[test]
925    fn test_all_value_types() {
926        let mut map = HashMap::new();
927        map.insert("null_val".to_string(), Value::Null);
928        map.insert("bool_t".to_string(), Value::Bool(true));
929        map.insert("bool_f".to_string(), Value::Bool(false));
930        map.insert("int_pos".to_string(), Value::Int(42));
931        map.insert("int_neg".to_string(), Value::Int(-100));
932        map.insert("int_zero".to_string(), Value::Int(0));
933        map.insert("float_val".to_string(), Value::Float(3.14));
934        map.insert("string_val".to_string(), Value::String("hello world".into()));
935        map.insert("secret_val".to_string(), Value::Secret("s3cr3t".into()));
936        map.insert("array_val".to_string(), Value::Array(vec![
937            Value::Int(1), Value::String("two".into()), Value::Null,
938        ]));
939
940        let result = ParseResult {
941            root: Value::Object(map),
942            mode: Mode::Static,
943            locked: false,
944            tool: false,
945            schema: false,
946            llm: false,
947            metadata: HashMap::new(),
948            includes: Vec::new(),
949            uses: Vec::new(),
950            truncated: false,
951        };
952
953        let binary = compile(&result, false);
954        let restored = decompile(&binary).unwrap();
955        assert_eq!(restored.root, result.root);
956    }
957
958    #[test]
959    fn test_empty_object() {
960        let result = ParseResult {
961            root: Value::Object(HashMap::new()),
962            mode: Mode::Static,
963            locked: false,
964            tool: false,
965            schema: false,
966            llm: false,
967            metadata: HashMap::new(),
968            includes: Vec::new(),
969            uses: Vec::new(),
970            truncated: false,
971        };
972
973        let binary = compile(&result, false);
974        let restored = decompile(&binary).unwrap();
975        assert_eq!(restored.root, result.root);
976    }
977
978    #[test]
979    fn test_llm_flag_roundtrip() {
980        let mut root = HashMap::new();
981        root.insert("task".to_string(), Value::String("ping".into()));
982        let result = ParseResult {
983            root: Value::Object(root),
984            mode: Mode::Static,
985            locked: false,
986            tool: false,
987            schema: false,
988            llm: true,
989            metadata: HashMap::new(),
990            includes: Vec::new(),
991            uses: Vec::new(),
992            truncated: false,
993        };
994        let binary = compile(&result, false);
995        let restored = decompile(&binary).unwrap();
996        assert!(restored.llm);
997        assert_eq!(restored.root, result.root);
998    }
999
1000    #[test]
1001    fn test_synx_api_compile_decompile() {
1002        use crate::Synx;
1003
1004        let text = "name Wario\nport 8080\ndebug false\n";
1005        let binary = Synx::compile(text, false);
1006        assert!(Synx::is_synxb(&binary));
1007
1008        let decompiled = Synx::decompile(&binary).unwrap();
1009        let original = crate::parse(text);
1010        let reparsed = crate::parse(&decompiled);
1011        assert_eq!(original.root, reparsed.root);
1012    }
1013
1014    // ── Hardening against hostile `.synxb` input (decompile is a decoder for
1015    //    untrusted bytes; none of these may panic, OOM, or overflow the stack) ──
1016
1017    /// Build a minimal valid `.synxb` header wrapping a raw (already-deflated) body.
1018    fn wrap_synxb(uncomp_size: u32, flags: u8, deflated: &[u8]) -> Vec<u8> {
1019        let mut out = Vec::new();
1020        out.extend_from_slice(MAGIC);
1021        out.push(FORMAT_VERSION);
1022        out.push(flags);
1023        out.extend_from_slice(&uncomp_size.to_le_bytes());
1024        out.extend_from_slice(deflated);
1025        out
1026    }
1027
1028    #[test]
1029    fn test_decompile_rejects_oversized_declared_size() {
1030        // Declared uncompressed size beyond the cap must be refused before any
1031        // large allocation, regardless of the (here trivial) body.
1032        let bomb = wrap_synxb(u32::MAX, 0, &[]);
1033        assert!(decompile(&bomb).is_err());
1034    }
1035
1036    #[test]
1037    fn test_decompile_bounds_decompression() {
1038        // A tiny deflate stream that expands past the cap must error, not
1039        // materialise gigabytes. 4 MiB of zeros compresses to a few KiB.
1040        let payload = vec![0u8; 4 * 1024 * 1024];
1041        let deflated = miniz_oxide::deflate::compress_to_vec(&payload, 9);
1042        // Under-declare the size; the with_limit inflate still refuses to grow
1043        // past MAX_DECODED_BYTES if a crafted stream tried to.
1044        let framed = wrap_synxb(payload.len() as u32, 0, &deflated);
1045        // Valid here (4 MiB < cap) — it must decode without panicking…
1046        let _ = decompile(&framed);
1047        // …and a stream whose declared size exceeds the cap is rejected.
1048        let lie = wrap_synxb((MAX_DECODED_BYTES + 1) as u32, 0, &deflated);
1049        assert!(decompile(&lie).is_err());
1050    }
1051
1052    #[test]
1053    fn test_decompile_huge_count_does_not_abort() {
1054        // A string-table count of u64::MAX must produce a clean Err, not abort
1055        // the process inside `with_capacity`.
1056        let mut payload = Vec::new();
1057        encode_varint(&mut payload, u64::MAX); // string table count
1058        let deflated = miniz_oxide::deflate::compress_to_vec(&payload, 9);
1059        let framed = wrap_synxb(payload.len() as u32, 0, &deflated);
1060        assert!(decompile(&framed).is_err());
1061    }
1062
1063    #[test]
1064    fn test_decompile_deep_nesting_does_not_overflow_stack() {
1065        // Value tree of nothing but ARRAY tags, far past the depth cap.
1066        let mut payload = Vec::new();
1067        encode_varint(&mut payload, 0); // empty string table
1068        for _ in 0..(MAX_DECODE_DEPTH + 500) {
1069            payload.push(TAG_ARRAY);
1070            encode_varint(&mut payload, 1); // one child
1071        }
1072        payload.push(TAG_NULL);
1073        let deflated = miniz_oxide::deflate::compress_to_vec(&payload, 9);
1074        let framed = wrap_synxb(payload.len() as u32, 0, &deflated);
1075        assert!(decompile(&framed).is_err());
1076    }
1077
1078    #[test]
1079    fn test_decompile_string_index_out_of_bounds_errors() {
1080        // Empty string table, then a STRING value referencing index 5.
1081        let mut payload = Vec::new();
1082        encode_varint(&mut payload, 0);
1083        payload.push(TAG_STRING);
1084        encode_varint(&mut payload, 5);
1085        let deflated = miniz_oxide::deflate::compress_to_vec(&payload, 9);
1086        let framed = wrap_synxb(payload.len() as u32, 0, &deflated);
1087        assert!(decompile(&framed).is_err());
1088    }
1089
1090    #[test]
1091    fn test_binary_roundtrip_large_int() {
1092        // Values beyond 2^53 exercise the full i64 zigzag path.
1093        let mut root = HashMap::new();
1094        root.insert("big".to_string(), Value::Int(9_000_000_000_000_000_007));
1095        root.insert("neg".to_string(), Value::Int(-9_000_000_000_000_000_007));
1096        let result = ParseResult {
1097            root: Value::Object(root), mode: Mode::Static, locked: false, tool: false,
1098            schema: false, llm: false, metadata: HashMap::new(), includes: Vec::new(),
1099            uses: Vec::new(), truncated: false,
1100        };
1101        let restored = decompile(&compile(&result, false)).unwrap();
1102        assert_eq!(restored.root, result.root);
1103    }
1104}