Skip to main content

variable_wire/
lib.rs

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