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 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
167fn 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 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 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 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 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(), 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 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 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; 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}