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 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 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; 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}