Skip to main content

variable_wire/
lib.rs

1use std::collections::HashSet;
2
3use std::collections::BTreeMap;
4
5use thiserror::Error;
6use variable_core::ast::{Value, VarFile};
7
8pub const MAGIC: [u8; 4] = *b"VARB";
9pub const VERSION_MAJOR: u8 = 1;
10pub const VERSION_MINOR: u8 = 0;
11pub const SECTION_METADATA: u16 = 0x0001;
12pub const SECTION_FEATURE_OVERRIDES: u16 = 0x0002;
13
14pub const DEFAULT_MAX_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
15pub const DEFAULT_MAX_STRING_BYTES: usize = 1024 * 1024;
16pub const DEFAULT_MAX_SOURCE_BYTES: usize = 1024 * 1024;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SnapshotMetadata {
20    pub schema_revision: u64,
21    pub manifest_revision: u64,
22    pub generated_at_unix_ms: u64,
23    pub source: Option<String>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub struct Snapshot {
28    pub metadata: SnapshotMetadata,
29    pub features: Vec<FeatureSnapshot>,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct FeatureSnapshot {
34    pub feature_id: u32,
35    pub variables: Vec<VariableSnapshot>,
36}
37
38#[derive(Debug, Clone, PartialEq)]
39pub struct VariableSnapshot {
40    pub variable_id: u32,
41    pub value: Value,
42}
43
44#[derive(Debug, Clone, Copy)]
45pub struct EncodeOptions {
46    pub max_payload_bytes: usize,
47    pub max_string_bytes: usize,
48    pub max_source_bytes: usize,
49}
50
51impl Default for EncodeOptions {
52    fn default() -> Self {
53        Self {
54            max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES,
55            max_string_bytes: DEFAULT_MAX_STRING_BYTES,
56            max_source_bytes: DEFAULT_MAX_SOURCE_BYTES,
57        }
58    }
59}
60
61#[derive(Debug, Clone, Copy)]
62pub struct DecodeOptions {
63    pub max_payload_bytes: usize,
64    pub max_string_bytes: usize,
65    pub max_source_bytes: usize,
66}
67
68impl Default for DecodeOptions {
69    fn default() -> Self {
70        Self {
71            max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES,
72            max_string_bytes: DEFAULT_MAX_STRING_BYTES,
73            max_source_bytes: DEFAULT_MAX_SOURCE_BYTES,
74        }
75    }
76}
77
78#[derive(Debug, Error, PartialEq, Eq)]
79pub enum EncodeError {
80    #[error("source metadata exceeds max size: {len} > {max}")]
81    SourceTooLarge { len: usize, max: usize },
82    #[error(
83        "string value exceeds max size for feature {feature_id} variable {variable_id}: {len} > {max}"
84    )]
85    StringTooLarge {
86        feature_id: u32,
87        variable_id: u32,
88        len: usize,
89        max: usize,
90    },
91    #[error("payload exceeds max size: {len} > {max}")]
92    PayloadTooLarge { len: usize, max: usize },
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum DiagnosticSeverity {
97    Info,
98    Warning,
99    Error,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum DiagnosticKind {
104    UnsupportedVersion,
105    MalformedEnvelope,
106    TruncatedSection,
107    LimitExceeded,
108    UnknownSectionType,
109    DuplicateFeatureId,
110    DuplicateVariableId,
111    UnknownValueType,
112    InvalidBooleanEncoding,
113    InvalidNumberEncoding,
114    InvalidUtf8String,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct DecodeDiagnostic {
119    pub kind: DiagnosticKind,
120    pub severity: DiagnosticSeverity,
121    pub message: String,
122}
123
124#[derive(Debug, Clone, PartialEq)]
125pub struct DecodeReport {
126    pub snapshot: Option<Snapshot>,
127    pub diagnostics: Vec<DecodeDiagnostic>,
128}
129
130pub fn snapshot_from_var_file(var_file: &VarFile, metadata: SnapshotMetadata) -> Snapshot {
131    // Build a map of struct definitions for resolving struct-typed variable defaults
132    let struct_defs: std::collections::HashMap<&str, &variable_core::ast::StructDef> = var_file
133        .structs
134        .iter()
135        .map(|s| (s.name.as_str(), s))
136        .collect();
137
138    let mut features: Vec<FeatureSnapshot> = var_file
139        .features
140        .iter()
141        .map(|feature| FeatureSnapshot {
142            feature_id: feature.id,
143            variables: feature
144                .variables
145                .iter()
146                .map(|variable| {
147                    let value = resolve_default_value(&variable.default, &struct_defs);
148                    VariableSnapshot {
149                        variable_id: variable.id,
150                        value,
151                    }
152                })
153                .collect(),
154        })
155        .collect();
156
157    for feature in &mut features {
158        feature
159            .variables
160            .sort_by_key(|variable| variable.variable_id);
161    }
162    features.sort_by_key(|feature| feature.feature_id);
163
164    Snapshot { metadata, features }
165}
166
167/// Resolve a default value, merging struct field defaults from the struct definition
168/// with any overrides provided in the struct literal.
169fn resolve_default_value(
170    value: &Value,
171    struct_defs: &std::collections::HashMap<&str, &variable_core::ast::StructDef>,
172) -> Value {
173    match value {
174        Value::Struct {
175            struct_name,
176            fields,
177        } => {
178            if let Some(struct_def) = struct_defs.get(struct_name.as_str()) {
179                let mut resolved_fields = BTreeMap::new();
180                for field in &struct_def.fields {
181                    let field_value = fields
182                        .get(&field.name)
183                        .cloned()
184                        .unwrap_or_else(|| field.default.clone());
185                    resolved_fields.insert(field.name.clone(), field_value);
186                }
187                Value::Struct {
188                    struct_name: struct_name.clone(),
189                    fields: resolved_fields,
190                }
191            } else {
192                value.clone()
193            }
194        }
195        other => other.clone(),
196    }
197}
198
199pub fn encode_var_file_defaults(
200    var_file: &VarFile,
201    metadata: SnapshotMetadata,
202) -> Result<Vec<u8>, EncodeError> {
203    let snapshot = snapshot_from_var_file(var_file, metadata);
204    encode_snapshot(&snapshot)
205}
206
207pub fn encode_snapshot(snapshot: &Snapshot) -> Result<Vec<u8>, EncodeError> {
208    encode_snapshot_with_options(snapshot, EncodeOptions::default())
209}
210
211pub fn encode_snapshot_with_options(
212    snapshot: &Snapshot,
213    options: EncodeOptions,
214) -> Result<Vec<u8>, EncodeError> {
215    let metadata_payload = encode_metadata_payload(&snapshot.metadata, &options)?;
216
217    let mut features = snapshot.features.clone();
218    features.sort_by_key(|feature| feature.feature_id);
219    for feature in &mut features {
220        feature
221            .variables
222            .sort_by_key(|variable| variable.variable_id);
223    }
224    let overrides_payload = encode_overrides_payload(&features, &options)?;
225
226    let mut out = Vec::with_capacity(12 + 8 + metadata_payload.len() + 8 + overrides_payload.len());
227
228    out.extend_from_slice(&MAGIC);
229    out.push(VERSION_MAJOR);
230    out.push(VERSION_MINOR);
231    push_u16(&mut out, 0);
232    push_u32(&mut out, 2);
233
234    push_section(&mut out, SECTION_METADATA, &metadata_payload)?;
235    push_section(&mut out, SECTION_FEATURE_OVERRIDES, &overrides_payload)?;
236
237    if out.len() > options.max_payload_bytes {
238        return Err(EncodeError::PayloadTooLarge {
239            len: out.len(),
240            max: options.max_payload_bytes,
241        });
242    }
243
244    Ok(out)
245}
246
247pub fn decode_snapshot(input: &[u8]) -> DecodeReport {
248    decode_snapshot_with_options(input, DecodeOptions::default())
249}
250
251pub fn decode_snapshot_with_options(input: &[u8], options: DecodeOptions) -> DecodeReport {
252    let mut diagnostics = Vec::new();
253
254    if input.len() > options.max_payload_bytes {
255        push_diag(
256            &mut diagnostics,
257            DiagnosticKind::LimitExceeded,
258            DiagnosticSeverity::Error,
259            format!(
260                "payload exceeds max size: {} > {}",
261                input.len(),
262                options.max_payload_bytes
263            ),
264        );
265        return DecodeReport {
266            snapshot: None,
267            diagnostics,
268        };
269    }
270
271    let mut cursor = Cursor::new(input);
272
273    let Some(magic) = cursor.read_exact(4) else {
274        push_diag(
275            &mut diagnostics,
276            DiagnosticKind::MalformedEnvelope,
277            DiagnosticSeverity::Error,
278            "payload is too short to contain header".to_string(),
279        );
280        return DecodeReport {
281            snapshot: None,
282            diagnostics,
283        };
284    };
285
286    if magic != MAGIC {
287        push_diag(
288            &mut diagnostics,
289            DiagnosticKind::MalformedEnvelope,
290            DiagnosticSeverity::Error,
291            "invalid magic bytes".to_string(),
292        );
293        return DecodeReport {
294            snapshot: None,
295            diagnostics,
296        };
297    }
298
299    let Some(version_major) = cursor.read_u8() else {
300        push_diag(
301            &mut diagnostics,
302            DiagnosticKind::MalformedEnvelope,
303            DiagnosticSeverity::Error,
304            "missing version_major".to_string(),
305        );
306        return DecodeReport {
307            snapshot: None,
308            diagnostics,
309        };
310    };
311
312    let _version_minor = match cursor.read_u8() {
313        Some(value) => value,
314        None => {
315            push_diag(
316                &mut diagnostics,
317                DiagnosticKind::MalformedEnvelope,
318                DiagnosticSeverity::Error,
319                "missing version_minor".to_string(),
320            );
321            return DecodeReport {
322                snapshot: None,
323                diagnostics,
324            };
325        }
326    };
327
328    if version_major != VERSION_MAJOR {
329        push_diag(
330            &mut diagnostics,
331            DiagnosticKind::UnsupportedVersion,
332            DiagnosticSeverity::Error,
333            format!(
334                "unsupported major version: {} (expected {})",
335                version_major, VERSION_MAJOR
336            ),
337        );
338        return DecodeReport {
339            snapshot: None,
340            diagnostics,
341        };
342    }
343
344    let Some(_flags) = cursor.read_u16() else {
345        push_diag(
346            &mut diagnostics,
347            DiagnosticKind::MalformedEnvelope,
348            DiagnosticSeverity::Error,
349            "missing flags".to_string(),
350        );
351        return DecodeReport {
352            snapshot: None,
353            diagnostics,
354        };
355    };
356
357    let Some(section_count) = cursor.read_u32() else {
358        push_diag(
359            &mut diagnostics,
360            DiagnosticKind::MalformedEnvelope,
361            DiagnosticSeverity::Error,
362            "missing section_count".to_string(),
363        );
364        return DecodeReport {
365            snapshot: None,
366            diagnostics,
367        };
368    };
369
370    let mut metadata: Option<SnapshotMetadata> = None;
371    let mut features: Option<Vec<FeatureSnapshot>> = None;
372
373    for _ in 0..section_count {
374        let Some(section_type) = cursor.read_u16() else {
375            push_diag(
376                &mut diagnostics,
377                DiagnosticKind::TruncatedSection,
378                DiagnosticSeverity::Error,
379                "truncated section header (type)".to_string(),
380            );
381            return DecodeReport {
382                snapshot: None,
383                diagnostics,
384            };
385        };
386
387        let Some(reserved) = cursor.read_u16() else {
388            push_diag(
389                &mut diagnostics,
390                DiagnosticKind::TruncatedSection,
391                DiagnosticSeverity::Error,
392                "truncated section header (reserved)".to_string(),
393            );
394            return DecodeReport {
395                snapshot: None,
396                diagnostics,
397            };
398        };
399
400        if reserved != 0 {
401            push_diag(
402                &mut diagnostics,
403                DiagnosticKind::MalformedEnvelope,
404                DiagnosticSeverity::Warning,
405                format!("section {} has non-zero reserved field", section_type),
406            );
407        }
408
409        let Some(section_len) = cursor.read_u32() else {
410            push_diag(
411                &mut diagnostics,
412                DiagnosticKind::TruncatedSection,
413                DiagnosticSeverity::Error,
414                "truncated section header (length)".to_string(),
415            );
416            return DecodeReport {
417                snapshot: None,
418                diagnostics,
419            };
420        };
421
422        let section_len = section_len as usize;
423        let Some(payload) = cursor.read_exact(section_len) else {
424            push_diag(
425                &mut diagnostics,
426                DiagnosticKind::TruncatedSection,
427                DiagnosticSeverity::Error,
428                format!("section {} truncated", section_type),
429            );
430            return DecodeReport {
431                snapshot: None,
432                diagnostics,
433            };
434        };
435
436        match section_type {
437            SECTION_METADATA => {
438                if metadata.is_some() {
439                    push_diag(
440                        &mut diagnostics,
441                        DiagnosticKind::MalformedEnvelope,
442                        DiagnosticSeverity::Warning,
443                        "duplicate metadata section; ignoring later section".to_string(),
444                    );
445                    continue;
446                }
447                metadata = decode_metadata(payload, &options, &mut diagnostics);
448            }
449            SECTION_FEATURE_OVERRIDES => {
450                if features.is_some() {
451                    push_diag(
452                        &mut diagnostics,
453                        DiagnosticKind::MalformedEnvelope,
454                        DiagnosticSeverity::Warning,
455                        "duplicate overrides section; ignoring later section".to_string(),
456                    );
457                    continue;
458                }
459                features = decode_overrides(payload, &options, &mut diagnostics);
460            }
461            unknown => {
462                push_diag(
463                    &mut diagnostics,
464                    DiagnosticKind::UnknownSectionType,
465                    DiagnosticSeverity::Info,
466                    format!("unknown section type {} skipped", unknown),
467                );
468            }
469        }
470    }
471
472    if cursor.remaining() > 0 {
473        push_diag(
474            &mut diagnostics,
475            DiagnosticKind::MalformedEnvelope,
476            DiagnosticSeverity::Warning,
477            "trailing bytes after section list".to_string(),
478        );
479    }
480
481    let snapshot = match (metadata, features) {
482        (Some(metadata), Some(features)) => Some(Snapshot { metadata, features }),
483        _ => {
484            push_diag(
485                &mut diagnostics,
486                DiagnosticKind::MalformedEnvelope,
487                DiagnosticSeverity::Error,
488                "missing required section(s)".to_string(),
489            );
490            None
491        }
492    };
493
494    DecodeReport {
495        snapshot,
496        diagnostics,
497    }
498}
499
500fn encode_metadata_payload(
501    metadata: &SnapshotMetadata,
502    options: &EncodeOptions,
503) -> Result<Vec<u8>, EncodeError> {
504    let source_bytes = metadata.source.as_deref().unwrap_or("").as_bytes();
505    if source_bytes.len() > options.max_source_bytes {
506        return Err(EncodeError::SourceTooLarge {
507            len: source_bytes.len(),
508            max: options.max_source_bytes,
509        });
510    }
511
512    let mut payload = Vec::with_capacity(8 + 8 + 8 + 4 + source_bytes.len());
513    push_u64(&mut payload, metadata.schema_revision);
514    push_u64(&mut payload, metadata.manifest_revision);
515    push_u64(&mut payload, metadata.generated_at_unix_ms);
516    push_u32(
517        &mut payload,
518        checked_u32_len(source_bytes.len(), options.max_payload_bytes)?,
519    );
520    payload.extend_from_slice(source_bytes);
521    Ok(payload)
522}
523
524fn encode_overrides_payload(
525    features: &[FeatureSnapshot],
526    options: &EncodeOptions,
527) -> Result<Vec<u8>, EncodeError> {
528    let mut payload = Vec::new();
529    push_u32(
530        &mut payload,
531        checked_u32_len(features.len(), options.max_payload_bytes)?,
532    );
533
534    for feature in features {
535        push_u32(&mut payload, feature.feature_id);
536        push_u32(
537            &mut payload,
538            checked_u32_len(feature.variables.len(), options.max_payload_bytes)?,
539        );
540
541        for variable in &feature.variables {
542            push_u32(&mut payload, variable.variable_id);
543
544            match &variable.value {
545                Value::Boolean(value) => {
546                    payload.push(1);
547                    payload.extend_from_slice(&[0, 0, 0]);
548                    push_u32(&mut payload, 1);
549                    payload.push(u8::from(*value));
550                }
551                Value::Float(value) => {
552                    payload.push(2);
553                    payload.extend_from_slice(&[0, 0, 0]);
554                    push_u32(&mut payload, 8);
555                    payload.extend_from_slice(&value.to_le_bytes());
556                }
557                Value::String(value) => {
558                    let bytes = value.as_bytes();
559                    if bytes.len() > options.max_string_bytes {
560                        return Err(EncodeError::StringTooLarge {
561                            feature_id: feature.feature_id,
562                            variable_id: variable.variable_id,
563                            len: bytes.len(),
564                            max: options.max_string_bytes,
565                        });
566                    }
567
568                    payload.push(3);
569                    payload.extend_from_slice(&[0, 0, 0]);
570                    push_u32(
571                        &mut payload,
572                        checked_u32_len(bytes.len(), options.max_payload_bytes)?,
573                    );
574                    payload.extend_from_slice(bytes);
575                }
576                Value::Integer(value) => {
577                    payload.push(4);
578                    payload.extend_from_slice(&[0, 0, 0]);
579                    push_u32(&mut payload, 8);
580                    payload.extend_from_slice(&value.to_le_bytes());
581                }
582                Value::Struct { fields, .. } => {
583                    payload.push(5);
584                    payload.extend_from_slice(&[0, 0, 0]);
585                    // Encode struct value: field_count (u32) + each field (id_placeholder u32 + value)
586                    let struct_payload = encode_struct_value_payload(
587                        feature.feature_id,
588                        variable.variable_id,
589                        fields,
590                        options,
591                    )?;
592                    push_u32(
593                        &mut payload,
594                        checked_u32_len(struct_payload.len(), options.max_payload_bytes)?,
595                    );
596                    payload.extend_from_slice(&struct_payload);
597                }
598            }
599        }
600    }
601
602    Ok(payload)
603}
604
605fn encode_struct_value_payload(
606    feature_id: u32,
607    variable_id: u32,
608    fields: &BTreeMap<String, Value>,
609    options: &EncodeOptions,
610) -> Result<Vec<u8>, EncodeError> {
611    let mut payload = Vec::new();
612    push_u32(
613        &mut payload,
614        checked_u32_len(fields.len(), options.max_payload_bytes)?,
615    );
616
617    // Fields are sorted by name (BTreeMap guarantees this).
618    // We encode the field name as a length-prefixed string so decoders
619    // can read struct values without a schema.
620    for (name, value) in fields {
621        let name_bytes = name.as_bytes();
622        push_u32(
623            &mut payload,
624            checked_u32_len(name_bytes.len(), options.max_payload_bytes)?,
625        );
626        payload.extend_from_slice(name_bytes);
627
628        match value {
629            Value::Boolean(v) => {
630                payload.push(1);
631                payload.extend_from_slice(&[0, 0, 0]);
632                push_u32(&mut payload, 1);
633                payload.push(u8::from(*v));
634            }
635            Value::Float(v) => {
636                payload.push(2);
637                payload.extend_from_slice(&[0, 0, 0]);
638                push_u32(&mut payload, 8);
639                payload.extend_from_slice(&v.to_le_bytes());
640            }
641            Value::String(v) => {
642                let bytes = v.as_bytes();
643                if bytes.len() > options.max_string_bytes {
644                    return Err(EncodeError::StringTooLarge {
645                        feature_id,
646                        variable_id,
647                        len: bytes.len(),
648                        max: options.max_string_bytes,
649                    });
650                }
651                payload.push(3);
652                payload.extend_from_slice(&[0, 0, 0]);
653                push_u32(
654                    &mut payload,
655                    checked_u32_len(bytes.len(), options.max_payload_bytes)?,
656                );
657                payload.extend_from_slice(bytes);
658            }
659            Value::Integer(v) => {
660                payload.push(4);
661                payload.extend_from_slice(&[0, 0, 0]);
662                push_u32(&mut payload, 8);
663                payload.extend_from_slice(&v.to_le_bytes());
664            }
665            Value::Struct { .. } => {
666                // Nested structs are not supported in v1
667                return Err(EncodeError::PayloadTooLarge { len: 0, max: 0 });
668            }
669        }
670    }
671
672    Ok(payload)
673}
674
675fn push_section(out: &mut Vec<u8>, section_type: u16, payload: &[u8]) -> Result<(), EncodeError> {
676    if payload.len() > u32::MAX as usize {
677        return Err(EncodeError::PayloadTooLarge {
678            len: payload.len(),
679            max: u32::MAX as usize,
680        });
681    }
682
683    push_u16(out, section_type);
684    push_u16(out, 0);
685    push_u32(out, payload.len() as u32);
686    out.extend_from_slice(payload);
687    Ok(())
688}
689
690fn checked_u32_len(len: usize, max_payload_bytes: usize) -> Result<u32, EncodeError> {
691    if len > u32::MAX as usize {
692        return Err(EncodeError::PayloadTooLarge {
693            len,
694            max: max_payload_bytes,
695        });
696    }
697    Ok(len as u32)
698}
699
700fn decode_metadata(
701    payload: &[u8],
702    options: &DecodeOptions,
703    diagnostics: &mut Vec<DecodeDiagnostic>,
704) -> Option<SnapshotMetadata> {
705    let mut cursor = Cursor::new(payload);
706    macro_rules! read_or_diag {
707        ($expr:expr, $message:expr) => {
708            match $expr {
709                Some(value) => value,
710                None => {
711                    push_diag(
712                        diagnostics,
713                        DiagnosticKind::TruncatedSection,
714                        DiagnosticSeverity::Error,
715                        $message.to_string(),
716                    );
717                    return None;
718                }
719            }
720        };
721    }
722
723    let schema_revision = read_or_diag!(
724        cursor.read_u64(),
725        "metadata section missing schema_revision"
726    );
727    let manifest_revision = read_or_diag!(
728        cursor.read_u64(),
729        "metadata section missing manifest_revision"
730    );
731    let generated_at_unix_ms = read_or_diag!(
732        cursor.read_u64(),
733        "metadata section missing generated_at_unix_ms"
734    );
735    let source_len =
736        read_or_diag!(cursor.read_u32(), "metadata section missing source_len") as usize;
737    let source_bytes = read_or_diag!(
738        cursor.read_exact(source_len),
739        "metadata section has truncated source bytes"
740    );
741
742    let source = if source_len == 0 {
743        None
744    } else if source_len > options.max_source_bytes {
745        push_diag(
746            diagnostics,
747            DiagnosticKind::LimitExceeded,
748            DiagnosticSeverity::Warning,
749            format!(
750                "metadata source exceeds max size: {} > {}",
751                source_len, options.max_source_bytes
752            ),
753        );
754        None
755    } else {
756        match String::from_utf8(source_bytes.to_vec()) {
757            Ok(value) => Some(value),
758            Err(_) => {
759                push_diag(
760                    diagnostics,
761                    DiagnosticKind::InvalidUtf8String,
762                    DiagnosticSeverity::Warning,
763                    "metadata source is not valid UTF-8".to_string(),
764                );
765                None
766            }
767        }
768    };
769
770    if cursor.remaining() > 0 {
771        push_diag(
772            diagnostics,
773            DiagnosticKind::MalformedEnvelope,
774            DiagnosticSeverity::Warning,
775            "metadata section has trailing bytes".to_string(),
776        );
777    }
778
779    Some(SnapshotMetadata {
780        schema_revision,
781        manifest_revision,
782        generated_at_unix_ms,
783        source,
784    })
785}
786
787fn decode_overrides(
788    payload: &[u8],
789    options: &DecodeOptions,
790    diagnostics: &mut Vec<DecodeDiagnostic>,
791) -> Option<Vec<FeatureSnapshot>> {
792    let mut cursor = Cursor::new(payload);
793    macro_rules! read_or_diag {
794        ($expr:expr, $message:expr) => {
795            match $expr {
796                Some(value) => value,
797                None => {
798                    push_diag(
799                        diagnostics,
800                        DiagnosticKind::TruncatedSection,
801                        DiagnosticSeverity::Error,
802                        $message.to_string(),
803                    );
804                    return None;
805                }
806            }
807        };
808    }
809
810    let feature_count = read_or_diag!(cursor.read_u32(), "overrides section missing feature_count");
811    let mut features = Vec::new();
812    let mut seen_feature_ids = HashSet::new();
813
814    for _ in 0..feature_count {
815        let feature_id = read_or_diag!(cursor.read_u32(), "overrides section missing feature_id");
816        let variable_count = read_or_diag!(
817            cursor.read_u32(),
818            "overrides section missing variable_count"
819        );
820
821        let duplicate_feature = !seen_feature_ids.insert(feature_id);
822        if duplicate_feature {
823            push_diag(
824                diagnostics,
825                DiagnosticKind::DuplicateFeatureId,
826                DiagnosticSeverity::Warning,
827                format!("duplicate feature id {} in payload", feature_id),
828            );
829        }
830
831        let mut variables = Vec::new();
832        let mut seen_variable_ids = HashSet::new();
833
834        for _ in 0..variable_count {
835            let variable_id =
836                read_or_diag!(cursor.read_u32(), "overrides section missing variable_id");
837            let value_type =
838                read_or_diag!(cursor.read_u8(), "overrides section missing value_type");
839            let reserved = read_or_diag!(
840                cursor.read_exact(3),
841                "overrides section missing variable reserved bytes"
842            );
843            if reserved != [0, 0, 0] {
844                push_diag(
845                    diagnostics,
846                    DiagnosticKind::MalformedEnvelope,
847                    DiagnosticSeverity::Warning,
848                    format!(
849                        "variable {} in feature {} has non-zero reserved bytes",
850                        variable_id, feature_id
851                    ),
852                );
853            }
854
855            let value_len =
856                read_or_diag!(cursor.read_u32(), "overrides section missing value_len") as usize;
857            let value_bytes = read_or_diag!(
858                cursor.read_exact(value_len),
859                "overrides section has truncated value bytes"
860            );
861
862            if !seen_variable_ids.insert(variable_id) {
863                push_diag(
864                    diagnostics,
865                    DiagnosticKind::DuplicateVariableId,
866                    DiagnosticSeverity::Warning,
867                    format!(
868                        "duplicate variable id {} in feature {}",
869                        variable_id, feature_id
870                    ),
871                );
872                continue;
873            }
874
875            let Some(value) = decode_value(
876                feature_id,
877                variable_id,
878                value_type,
879                value_len,
880                value_bytes,
881                options,
882                diagnostics,
883            ) else {
884                continue;
885            };
886
887            variables.push(VariableSnapshot { variable_id, value });
888        }
889
890        if !duplicate_feature {
891            variables.sort_by_key(|variable| variable.variable_id);
892            features.push(FeatureSnapshot {
893                feature_id,
894                variables,
895            });
896        }
897    }
898
899    if cursor.remaining() > 0 {
900        push_diag(
901            diagnostics,
902            DiagnosticKind::MalformedEnvelope,
903            DiagnosticSeverity::Warning,
904            "overrides section has trailing bytes".to_string(),
905        );
906    }
907
908    features.sort_by_key(|feature| feature.feature_id);
909    Some(features)
910}
911
912fn decode_value(
913    feature_id: u32,
914    variable_id: u32,
915    value_type: u8,
916    value_len: usize,
917    value_bytes: &[u8],
918    options: &DecodeOptions,
919    diagnostics: &mut Vec<DecodeDiagnostic>,
920) -> Option<Value> {
921    match value_type {
922        1 => {
923            if value_len != 1 {
924                push_diag(
925                    diagnostics,
926                    DiagnosticKind::InvalidBooleanEncoding,
927                    DiagnosticSeverity::Warning,
928                    format!(
929                        "invalid boolean length {} for feature {} variable {}",
930                        value_len, feature_id, variable_id
931                    ),
932                );
933                return None;
934            }
935
936            match value_bytes[0] {
937                0 => Some(Value::Boolean(false)),
938                1 => Some(Value::Boolean(true)),
939                other => {
940                    push_diag(
941                        diagnostics,
942                        DiagnosticKind::InvalidBooleanEncoding,
943                        DiagnosticSeverity::Warning,
944                        format!(
945                            "invalid boolean byte {} for feature {} variable {}",
946                            other, feature_id, variable_id
947                        ),
948                    );
949                    None
950                }
951            }
952        }
953        2 => {
954            if value_len != 8 {
955                push_diag(
956                    diagnostics,
957                    DiagnosticKind::InvalidNumberEncoding,
958                    DiagnosticSeverity::Warning,
959                    format!(
960                        "invalid float length {} for feature {} variable {}",
961                        value_len, feature_id, variable_id
962                    ),
963                );
964                return None;
965            }
966
967            let mut bytes = [0u8; 8];
968            bytes.copy_from_slice(value_bytes);
969            Some(Value::Float(f64::from_le_bytes(bytes)))
970        }
971        3 => {
972            if value_len > options.max_string_bytes {
973                push_diag(
974                    diagnostics,
975                    DiagnosticKind::LimitExceeded,
976                    DiagnosticSeverity::Warning,
977                    format!(
978                        "string value exceeds max size for feature {} variable {}: {} > {}",
979                        feature_id, variable_id, value_len, options.max_string_bytes
980                    ),
981                );
982                return None;
983            }
984
985            match String::from_utf8(value_bytes.to_vec()) {
986                Ok(value) => Some(Value::String(value)),
987                Err(_) => {
988                    push_diag(
989                        diagnostics,
990                        DiagnosticKind::InvalidUtf8String,
991                        DiagnosticSeverity::Warning,
992                        format!(
993                            "string value is not valid UTF-8 for feature {} variable {}",
994                            feature_id, variable_id
995                        ),
996                    );
997                    None
998                }
999            }
1000        }
1001        4 => {
1002            if value_len != 8 {
1003                push_diag(
1004                    diagnostics,
1005                    DiagnosticKind::InvalidNumberEncoding,
1006                    DiagnosticSeverity::Warning,
1007                    format!(
1008                        "invalid integer length {} for feature {} variable {}",
1009                        value_len, feature_id, variable_id
1010                    ),
1011                );
1012                return None;
1013            }
1014
1015            let mut bytes = [0u8; 8];
1016            bytes.copy_from_slice(value_bytes);
1017            Some(Value::Integer(i64::from_le_bytes(bytes)))
1018        }
1019        5 => {
1020            // Struct value
1021            let mut cursor = Cursor::new(value_bytes);
1022            let Some(field_count) = cursor.read_u32() else {
1023                push_diag(
1024                    diagnostics,
1025                    DiagnosticKind::TruncatedSection,
1026                    DiagnosticSeverity::Warning,
1027                    format!(
1028                        "struct value missing field_count for feature {} variable {}",
1029                        feature_id, variable_id
1030                    ),
1031                );
1032                return None;
1033            };
1034
1035            let mut fields = BTreeMap::new();
1036            for _ in 0..field_count {
1037                let Some(name_len) = cursor.read_u32() else {
1038                    push_diag(
1039                        diagnostics,
1040                        DiagnosticKind::TruncatedSection,
1041                        DiagnosticSeverity::Warning,
1042                        format!(
1043                            "struct field name_len truncated for feature {} variable {}",
1044                            feature_id, variable_id
1045                        ),
1046                    );
1047                    return None;
1048                };
1049                let Some(name_bytes) = cursor.read_exact(name_len as usize) else {
1050                    push_diag(
1051                        diagnostics,
1052                        DiagnosticKind::TruncatedSection,
1053                        DiagnosticSeverity::Warning,
1054                        format!(
1055                            "struct field name truncated for feature {} variable {}",
1056                            feature_id, variable_id
1057                        ),
1058                    );
1059                    return None;
1060                };
1061                let field_name = match String::from_utf8(name_bytes.to_vec()) {
1062                    Ok(s) => s,
1063                    Err(_) => {
1064                        push_diag(
1065                            diagnostics,
1066                            DiagnosticKind::InvalidUtf8String,
1067                            DiagnosticSeverity::Warning,
1068                            format!(
1069                                "struct field name is not valid UTF-8 for feature {} variable {}",
1070                                feature_id, variable_id
1071                            ),
1072                        );
1073                        return None;
1074                    }
1075                };
1076
1077                let Some(field_value_type) = cursor.read_u8() else {
1078                    push_diag(
1079                        diagnostics,
1080                        DiagnosticKind::TruncatedSection,
1081                        DiagnosticSeverity::Warning,
1082                        format!(
1083                            "struct field value_type truncated for feature {} variable {}",
1084                            feature_id, variable_id
1085                        ),
1086                    );
1087                    return None;
1088                };
1089                let Some(_reserved) = cursor.read_exact(3) else {
1090                    push_diag(
1091                        diagnostics,
1092                        DiagnosticKind::TruncatedSection,
1093                        DiagnosticSeverity::Warning,
1094                        format!(
1095                            "struct field reserved truncated for feature {} variable {}",
1096                            feature_id, variable_id
1097                        ),
1098                    );
1099                    return None;
1100                };
1101                let Some(field_value_len) = cursor.read_u32() else {
1102                    push_diag(
1103                        diagnostics,
1104                        DiagnosticKind::TruncatedSection,
1105                        DiagnosticSeverity::Warning,
1106                        format!(
1107                            "struct field value_len truncated for feature {} variable {}",
1108                            feature_id, variable_id
1109                        ),
1110                    );
1111                    return None;
1112                };
1113                let Some(field_value_bytes) = cursor.read_exact(field_value_len as usize) else {
1114                    push_diag(
1115                        diagnostics,
1116                        DiagnosticKind::TruncatedSection,
1117                        DiagnosticSeverity::Warning,
1118                        format!(
1119                            "struct field value bytes truncated for feature {} variable {}",
1120                            feature_id, variable_id
1121                        ),
1122                    );
1123                    return None;
1124                };
1125
1126                let Some(field_value) = decode_value(
1127                    feature_id,
1128                    variable_id,
1129                    field_value_type,
1130                    field_value_len as usize,
1131                    field_value_bytes,
1132                    options,
1133                    diagnostics,
1134                ) else {
1135                    continue;
1136                };
1137
1138                fields.insert(field_name, field_value);
1139            }
1140
1141            Some(Value::Struct {
1142                struct_name: String::new(), // Name not in binary; resolved via schema
1143                fields,
1144            })
1145        }
1146        other => {
1147            push_diag(
1148                diagnostics,
1149                DiagnosticKind::UnknownValueType,
1150                DiagnosticSeverity::Warning,
1151                format!(
1152                    "unknown value type {} for feature {} variable {}",
1153                    other, feature_id, variable_id
1154                ),
1155            );
1156            None
1157        }
1158    }
1159}
1160
1161fn push_diag(
1162    diagnostics: &mut Vec<DecodeDiagnostic>,
1163    kind: DiagnosticKind,
1164    severity: DiagnosticSeverity,
1165    message: String,
1166) {
1167    diagnostics.push(DecodeDiagnostic {
1168        kind,
1169        severity,
1170        message,
1171    });
1172}
1173
1174fn push_u16(out: &mut Vec<u8>, value: u16) {
1175    out.extend_from_slice(&value.to_le_bytes());
1176}
1177
1178fn push_u32(out: &mut Vec<u8>, value: u32) {
1179    out.extend_from_slice(&value.to_le_bytes());
1180}
1181
1182fn push_u64(out: &mut Vec<u8>, value: u64) {
1183    out.extend_from_slice(&value.to_le_bytes());
1184}
1185
1186struct Cursor<'a> {
1187    input: &'a [u8],
1188    pos: usize,
1189}
1190
1191impl<'a> Cursor<'a> {
1192    fn new(input: &'a [u8]) -> Self {
1193        Self { input, pos: 0 }
1194    }
1195
1196    fn remaining(&self) -> usize {
1197        self.input.len().saturating_sub(self.pos)
1198    }
1199
1200    fn read_exact(&mut self, len: usize) -> Option<&'a [u8]> {
1201        if self.remaining() < len {
1202            return None;
1203        }
1204
1205        let end = self.pos + len;
1206        let value = &self.input[self.pos..end];
1207        self.pos = end;
1208        Some(value)
1209    }
1210
1211    fn read_u8(&mut self) -> Option<u8> {
1212        self.read_exact(1).map(|bytes| bytes[0])
1213    }
1214
1215    fn read_u16(&mut self) -> Option<u16> {
1216        let bytes = self.read_exact(2)?;
1217        let mut array = [0u8; 2];
1218        array.copy_from_slice(bytes);
1219        Some(u16::from_le_bytes(array))
1220    }
1221
1222    fn read_u32(&mut self) -> Option<u32> {
1223        let bytes = self.read_exact(4)?;
1224        let mut array = [0u8; 4];
1225        array.copy_from_slice(bytes);
1226        Some(u32::from_le_bytes(array))
1227    }
1228
1229    fn read_u64(&mut self) -> Option<u64> {
1230        let bytes = self.read_exact(8)?;
1231        let mut array = [0u8; 8];
1232        array.copy_from_slice(bytes);
1233        Some(u64::from_le_bytes(array))
1234    }
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240    use variable_core::parse_and_validate;
1241
1242    #[test]
1243    fn encode_decode_roundtrip_snapshot() {
1244        let snapshot = Snapshot {
1245            metadata: SnapshotMetadata {
1246                schema_revision: 7,
1247                manifest_revision: 11,
1248                generated_at_unix_ms: 123,
1249                source: Some("test".to_string()),
1250            },
1251            features: vec![
1252                FeatureSnapshot {
1253                    feature_id: 2,
1254                    variables: vec![
1255                        VariableSnapshot {
1256                            variable_id: 2,
1257                            value: Value::String("hello".to_string()),
1258                        },
1259                        VariableSnapshot {
1260                            variable_id: 1,
1261                            value: Value::Boolean(true),
1262                        },
1263                    ],
1264                },
1265                FeatureSnapshot {
1266                    feature_id: 1,
1267                    variables: vec![VariableSnapshot {
1268                        variable_id: 1,
1269                        value: Value::Integer(42),
1270                    }],
1271                },
1272            ],
1273        };
1274
1275        let encoded = encode_snapshot(&snapshot).unwrap();
1276        let report = decode_snapshot(&encoded);
1277
1278        assert!(report.diagnostics.is_empty());
1279        let decoded = report.snapshot.unwrap();
1280
1281        // Canonical ordering is enforced by the encoder.
1282        assert_eq!(decoded.features[0].feature_id, 1);
1283        assert_eq!(decoded.features[1].feature_id, 2);
1284        assert_eq!(decoded.features[1].variables[0].variable_id, 1);
1285        assert_eq!(decoded.features[1].variables[1].variable_id, 2);
1286    }
1287
1288    #[test]
1289    fn encode_var_file_defaults_roundtrip() {
1290        let source = r#"1: Feature Checkout = {
1291    1: enabled Boolean = true
1292    2: max_items Integer = 50
1293    3: header_text String = "Complete your purchase"
1294    4: discount_rate Float = 0.15
1295}
1296
12972: Feature Search = {
1298    1: enabled Boolean = false
1299    2: max_results Integer = 10
1300    3: placeholder String = "Search..."
1301    4: boost_factor Float = 1.5
1302}"#;
1303
1304        let var_file = parse_and_validate(source).unwrap();
1305        let metadata = SnapshotMetadata {
1306            schema_revision: 1,
1307            manifest_revision: 2,
1308            generated_at_unix_ms: 3,
1309            source: Some("fixture".to_string()),
1310        };
1311
1312        let encoded = encode_var_file_defaults(&var_file, metadata).unwrap();
1313        let report = decode_snapshot(&encoded);
1314
1315        assert!(report.diagnostics.is_empty());
1316        let decoded = report.snapshot.unwrap();
1317
1318        assert_eq!(decoded.features.len(), 2);
1319        assert_eq!(decoded.features[0].feature_id, 1);
1320        assert_eq!(decoded.features[0].variables.len(), 4);
1321        assert_eq!(decoded.features[1].feature_id, 2);
1322        assert_eq!(
1323            decoded.features[1].variables[0].value,
1324            Value::Boolean(false)
1325        );
1326        assert_eq!(decoded.features[0].variables[3].value, Value::Float(0.15));
1327        assert_eq!(decoded.features[1].variables[3].value, Value::Float(1.5));
1328    }
1329
1330    #[test]
1331    fn decode_skips_unknown_section() {
1332        let snapshot = Snapshot {
1333            metadata: SnapshotMetadata {
1334                schema_revision: 1,
1335                manifest_revision: 1,
1336                generated_at_unix_ms: 1,
1337                source: None,
1338            },
1339            features: vec![FeatureSnapshot {
1340                feature_id: 1,
1341                variables: vec![VariableSnapshot {
1342                    variable_id: 1,
1343                    value: Value::Boolean(true),
1344                }],
1345            }],
1346        };
1347
1348        let mut encoded = encode_snapshot(&snapshot).unwrap();
1349
1350        // Increase section_count from 2 to 3 and append unknown section payload.
1351        encoded[8..12].copy_from_slice(&3u32.to_le_bytes());
1352        encoded.extend_from_slice(&0x9000u16.to_le_bytes());
1353        encoded.extend_from_slice(&0u16.to_le_bytes());
1354        encoded.extend_from_slice(&4u32.to_le_bytes());
1355        encoded.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
1356
1357        let report = decode_snapshot(&encoded);
1358
1359        assert!(report.snapshot.is_some());
1360        assert!(
1361            report
1362                .diagnostics
1363                .iter()
1364                .any(|diagnostic| diagnostic.kind == DiagnosticKind::UnknownSectionType)
1365        );
1366    }
1367
1368    #[test]
1369    fn decode_rejects_invalid_magic() {
1370        let report = decode_snapshot(b"NOPE");
1371        assert!(report.snapshot.is_none());
1372        assert!(
1373            report
1374                .diagnostics
1375                .iter()
1376                .any(|diagnostic| diagnostic.kind == DiagnosticKind::MalformedEnvelope)
1377        );
1378    }
1379
1380    #[test]
1381    fn decode_reports_invalid_boolean_value() {
1382        let snapshot = Snapshot {
1383            metadata: SnapshotMetadata {
1384                schema_revision: 1,
1385                manifest_revision: 1,
1386                generated_at_unix_ms: 1,
1387                source: None,
1388            },
1389            features: vec![FeatureSnapshot {
1390                feature_id: 1,
1391                variables: vec![VariableSnapshot {
1392                    variable_id: 1,
1393                    value: Value::Boolean(true),
1394                }],
1395            }],
1396        };
1397
1398        let mut encoded = encode_snapshot(&snapshot).unwrap();
1399        let last = encoded.len() - 1;
1400        encoded[last] = 2; // invalid bool byte
1401
1402        let report = decode_snapshot(&encoded);
1403        let decoded = report.snapshot.unwrap();
1404
1405        assert_eq!(decoded.features[0].variables.len(), 0);
1406        assert!(
1407            report
1408                .diagnostics
1409                .iter()
1410                .any(|diagnostic| diagnostic.kind == DiagnosticKind::InvalidBooleanEncoding)
1411        );
1412    }
1413
1414    #[test]
1415    fn encode_enforces_source_size_limit() {
1416        let snapshot = Snapshot {
1417            metadata: SnapshotMetadata {
1418                schema_revision: 1,
1419                manifest_revision: 1,
1420                generated_at_unix_ms: 1,
1421                source: Some("abc".to_string()),
1422            },
1423            features: Vec::new(),
1424        };
1425
1426        let err = encode_snapshot_with_options(
1427            &snapshot,
1428            EncodeOptions {
1429                max_source_bytes: 2,
1430                ..EncodeOptions::default()
1431            },
1432        )
1433        .unwrap_err();
1434
1435        assert_eq!(err, EncodeError::SourceTooLarge { len: 3, max: 2 });
1436    }
1437
1438    #[test]
1439    fn decode_respects_string_size_limit() {
1440        let snapshot = Snapshot {
1441            metadata: SnapshotMetadata {
1442                schema_revision: 1,
1443                manifest_revision: 1,
1444                generated_at_unix_ms: 1,
1445                source: None,
1446            },
1447            features: vec![FeatureSnapshot {
1448                feature_id: 1,
1449                variables: vec![VariableSnapshot {
1450                    variable_id: 1,
1451                    value: Value::String("abc".to_string()),
1452                }],
1453            }],
1454        };
1455
1456        let encoded = encode_snapshot(&snapshot).unwrap();
1457        let report = decode_snapshot_with_options(
1458            &encoded,
1459            DecodeOptions {
1460                max_string_bytes: 2,
1461                ..DecodeOptions::default()
1462            },
1463        );
1464
1465        let decoded = report.snapshot.unwrap();
1466        assert!(decoded.features[0].variables.is_empty());
1467        assert!(
1468            report
1469                .diagnostics
1470                .iter()
1471                .any(|diagnostic| diagnostic.kind == DiagnosticKind::LimitExceeded)
1472        );
1473    }
1474}