Skip to main content

copybook_codec/
determinism.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Determinism validation for COBOL copybook encoding and decoding operations.
3#![allow(clippy::missing_inline_in_public_items)]
4//!
5//! This module verifies that encode/decode operations produce identical outputs
6//! across repeated runs with the same schema, data, and options.
7
8use crate::lib_api::{decode_record, encode_record};
9use crate::options::{DecodeOptions, EncodeOptions};
10use copybook_core::{Error, ErrorCode, Result, Schema};
11use copybook_rdw::RdwHeader;
12
13/// Default cap used when collecting byte-level differences.
14pub const DEFAULT_MAX_DIFFS: usize = 100;
15
16/// Hex-encoded BLAKE3 digest length in characters.
17pub const BLAKE3_HEX_LEN: usize = 64;
18
19/// Mode of determinism checking (decode-only, encode-only, or full round-trip).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum DeterminismMode {
23    /// Check that decoding the same binary data twice produces identical JSON.
24    DecodeOnly,
25    /// Check that encoding the same JSON twice produces identical binary data.
26    EncodeOnly,
27    /// Check that decode→encode→decode produces identical JSON.
28    RoundTrip,
29}
30
31/// Details about a byte difference found during determinism checking.
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct ByteDiff {
34    /// Byte offset where the difference was found.
35    pub offset: usize,
36    /// Byte value from the first run.
37    pub round1_byte: u8,
38    /// Byte value from the second run.
39    pub round2_byte: u8,
40}
41
42/// Result of a determinism check operation.
43#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct DeterminismResult {
45    /// The mode of checking that was performed.
46    pub mode: DeterminismMode,
47    /// BLAKE3 hash of the first run's output.
48    pub round1_hash: String,
49    /// BLAKE3 hash of the second run's output.
50    pub round2_hash: String,
51    /// Whether the two runs produced identical outputs.
52    pub is_deterministic: bool,
53    /// If non-deterministic, details of the byte differences.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub byte_differences: Option<Vec<ByteDiff>>,
56}
57
58impl DeterminismResult {
59    /// Returns true if both runs produced identical outputs.
60    #[must_use]
61    #[inline]
62    pub fn passed(&self) -> bool {
63        self.is_deterministic
64    }
65
66    /// Returns the number of byte differences found (0 if deterministic).
67    #[must_use]
68    #[inline]
69    pub fn diff_count(&self) -> usize {
70        self.byte_differences.as_ref().map_or(0, Vec::len)
71    }
72}
73
74/// Compute a lowercase hex BLAKE3 hash for a byte slice.
75#[must_use]
76#[inline]
77pub fn blake3_hex(data: &[u8]) -> String {
78    blake3::hash(data).to_hex().to_string()
79}
80
81/// Compare two byte slices and build a determinism result with the default diff limit.
82#[must_use]
83#[inline]
84pub fn compare_outputs(mode: DeterminismMode, round1: &[u8], round2: &[u8]) -> DeterminismResult {
85    compare_outputs_with_limit(mode, round1, round2, DEFAULT_MAX_DIFFS)
86}
87
88/// Compare two byte slices and build a determinism result with an explicit diff limit.
89#[must_use]
90pub fn compare_outputs_with_limit(
91    mode: DeterminismMode,
92    round1: &[u8],
93    round2: &[u8],
94    max_diffs: usize,
95) -> DeterminismResult {
96    let hash1 = blake3::hash(round1);
97    let hash2 = blake3::hash(round2);
98    let is_deterministic = hash1 == hash2;
99
100    DeterminismResult {
101        mode,
102        round1_hash: hash1.to_hex().to_string(),
103        round2_hash: hash2.to_hex().to_string(),
104        is_deterministic,
105        byte_differences: if is_deterministic {
106            None
107        } else {
108            Some(find_byte_differences_with_limit(round1, round2, max_diffs))
109        },
110    }
111}
112
113/// Find byte-level differences between two slices using [`DEFAULT_MAX_DIFFS`] entries at most.
114#[must_use]
115#[inline]
116pub fn find_byte_differences(round1: &[u8], round2: &[u8]) -> Vec<ByteDiff> {
117    find_byte_differences_with_limit(round1, round2, DEFAULT_MAX_DIFFS)
118}
119
120/// Find byte-level differences between two slices with an explicit limit.
121///
122/// If the inputs have different lengths, offsets past the shorter input are
123/// reported with `0` substituted for the missing byte. A tail difference can
124/// therefore contain equal byte values when the longer input contains `0`.
125#[must_use]
126pub fn find_byte_differences_with_limit(
127    round1: &[u8],
128    round2: &[u8],
129    max_diffs: usize,
130) -> Vec<ByteDiff> {
131    if max_diffs == 0 {
132        return Vec::new();
133    }
134
135    let min_len = round1.len().min(round2.len());
136    let max_len = round1.len().max(round2.len());
137    let mut diffs = Vec::with_capacity(max_diffs.min(max_len));
138
139    for (offset, (&byte_a, &byte_b)) in round1.iter().zip(round2.iter()).enumerate() {
140        if byte_a != byte_b {
141            diffs.push(ByteDiff {
142                offset,
143                round1_byte: byte_a,
144                round2_byte: byte_b,
145            });
146            if diffs.len() >= max_diffs {
147                return diffs;
148            }
149        }
150    }
151
152    if round1.len() != round2.len() {
153        for offset in min_len..max_len {
154            let byte_a = round1.get(offset).copied().unwrap_or(0);
155            let byte_b = round2.get(offset).copied().unwrap_or(0);
156            diffs.push(ByteDiff {
157                offset,
158                round1_byte: byte_a,
159                round2_byte: byte_b,
160            });
161            if diffs.len() >= max_diffs {
162                return diffs;
163            }
164        }
165    }
166
167    diffs
168}
169
170fn serialize_json(value: &serde_json::Value, context: &str) -> Result<Vec<u8>> {
171    serde_json::to_vec(value).map_err(|e| {
172        Error::new(
173            ErrorCode::CBKC201_JSON_WRITE_ERROR,
174            format!("Failed to serialize {context}: {e}"),
175        )
176    })
177}
178
179/// Check that decoding the same binary data twice produces identical JSON output.
180///
181/// # Errors
182///
183/// Returns an error if decoding or JSON serialization fails.
184#[inline]
185#[must_use = "Handle the Result or propagate the error"]
186pub fn check_decode_determinism(
187    schema: &Schema,
188    data: &[u8],
189    options: &DecodeOptions,
190) -> Result<DeterminismResult> {
191    let payload = payload_for_format(data, options.format)?;
192    let value1 = decode_record(schema, payload, options)?;
193    let value2 = decode_record(schema, payload, options)?;
194
195    let json1 = serialize_json(&value1, "first decode result")?;
196    let json2 = serialize_json(&value2, "second decode result")?;
197
198    Ok(compare_outputs(DeterminismMode::DecodeOnly, &json1, &json2))
199}
200
201/// Check that encoding the same JSON twice produces identical binary output.
202///
203/// # Errors
204///
205/// Returns an error if encoding fails.
206#[inline]
207#[must_use = "Handle the Result or propagate the error"]
208pub fn check_encode_determinism(
209    schema: &Schema,
210    json_data: &serde_json::Value,
211    options: &EncodeOptions,
212) -> Result<DeterminismResult> {
213    let binary1 = encode_record(schema, json_data, options)?;
214    let binary2 = encode_record(schema, json_data, options)?;
215
216    Ok(compare_outputs(
217        DeterminismMode::EncodeOnly,
218        &binary1,
219        &binary2,
220    ))
221}
222
223/// Check full round-trip determinism: decode->encode->decode.
224///
225/// # Errors
226///
227/// Returns an error if any decode/encode or JSON serialization step fails.
228#[inline]
229#[must_use = "Handle the Result or propagate the error"]
230pub fn check_round_trip_determinism(
231    schema: &Schema,
232    data: &[u8],
233    decode_opts: &DecodeOptions,
234    encode_opts: &EncodeOptions,
235) -> Result<DeterminismResult> {
236    let decoded_payload = payload_for_format(data, decode_opts.format)?;
237    let json1 = decode_record(schema, decoded_payload, decode_opts)?;
238    let binary = encode_record(schema, &json1, encode_opts)?;
239    let encoded_payload = payload_for_format(&binary, decode_opts.format)?;
240    let json2 = decode_record(schema, encoded_payload, decode_opts)?;
241
242    let serialized1 = serialize_json(&json1, "first round-trip decode result")?;
243    let serialized2 = serialize_json(&json2, "second round-trip decode result")?;
244
245    Ok(compare_outputs(
246        DeterminismMode::RoundTrip,
247        &serialized1,
248        &serialized2,
249    ))
250}
251
252#[inline]
253fn payload_for_format(data: &[u8], format: crate::options::RecordFormat) -> Result<&[u8]> {
254    if format != crate::options::RecordFormat::RDW {
255        return Ok(data);
256    }
257
258    if data.len() < copybook_rdw::RDW_HEADER_LEN {
259        return Err(Error::new(
260            ErrorCode::CBKF221_RDW_UNDERFLOW,
261            "RDW data is shorter than the 4-byte RDW header",
262        ));
263    }
264
265    let header_slice = data.get(..copybook_rdw::RDW_HEADER_LEN).ok_or_else(|| {
266        Error::new(
267            ErrorCode::CBKF221_RDW_UNDERFLOW,
268            "RDW data is shorter than the 4-byte RDW header",
269        )
270    })?;
271
272    let header_bytes: [u8; copybook_rdw::RDW_HEADER_LEN] =
273        header_slice.try_into().map_err(|_| {
274            Error::new(
275                ErrorCode::CBKF221_RDW_UNDERFLOW,
276                "RDW header must be exactly 4 bytes",
277            )
278        })?;
279
280    let header = RdwHeader::from_bytes(header_bytes);
281
282    let payload_len = usize::from(header.length());
283    let expected_len = copybook_rdw::RDW_HEADER_LEN.saturating_add(payload_len);
284    if data.len() != expected_len {
285        return Err(Error::new(
286            ErrorCode::CBKF221_RDW_UNDERFLOW,
287            format!(
288                "RDW payload mismatch: expected {expected_len} bytes, got {}",
289                data.len()
290            ),
291        ));
292    }
293
294    Ok(&data[copybook_rdw::RDW_HEADER_LEN..expected_len])
295}
296
297#[cfg(test)]
298#[allow(clippy::expect_used)]
299#[allow(clippy::unwrap_used)]
300mod tests {
301    use super::*;
302    use crate::options::{Codepage, RecordFormat};
303    use copybook_core::parse_copybook;
304
305    fn decode_opts() -> DecodeOptions {
306        DecodeOptions::new().with_codepage(Codepage::CP037)
307    }
308
309    fn encode_opts() -> EncodeOptions {
310        EncodeOptions::new()
311            .with_codepage(Codepage::CP037)
312            .with_format(RecordFormat::Fixed)
313    }
314
315    #[test]
316    fn decode_deterministic_for_display_schema() {
317        let copybook = r"
318            01 RECORD.
319               05 FIELD-A PIC X(10).
320        ";
321        let schema = parse_copybook(copybook).expect("parse copybook");
322
323        let data: Vec<u8> = vec![0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xD1];
324
325        let result =
326            check_decode_determinism(&schema, &data, &decode_opts()).expect("determinism check");
327
328        assert!(
329            result.is_deterministic,
330            "Expected deterministic decode for DISPLAY-only schema"
331        );
332        assert_eq!(result.mode, DeterminismMode::DecodeOnly);
333        assert!(result.byte_differences.is_none());
334        assert_eq!(result.diff_count(), 0);
335        assert!(result.passed());
336    }
337
338    #[test]
339    fn decode_deterministic_for_comp3_schema() {
340        let copybook = r"
341            01 RECORD.
342               05 AMOUNT PIC S9(7)V99 COMP-3.
343        ";
344        let schema = parse_copybook(copybook).expect("parse copybook");
345
346        let data = vec![0x12, 0x34, 0x56, 0x78, 0x9C];
347
348        let result =
349            check_decode_determinism(&schema, &data, &decode_opts()).expect("determinism check");
350
351        assert!(
352            result.is_deterministic,
353            "Expected deterministic decode for COMP-3 schema"
354        );
355        assert!(result.passed());
356    }
357
358    #[test]
359    fn encode_deterministic_for_display_schema() {
360        let copybook = r"
361            01 RECORD.
362               05 FIELD-A PIC X(5).
363        ";
364        let schema = parse_copybook(copybook).expect("parse copybook");
365        let json = serde_json::json!({"FIELD-A": "HELLO"});
366
367        let result =
368            check_encode_determinism(&schema, &json, &encode_opts()).expect("determinism check");
369
370        assert!(
371            result.is_deterministic,
372            "Expected deterministic encode for DISPLAY-only schema"
373        );
374        assert_eq!(result.mode, DeterminismMode::EncodeOnly);
375        assert!(result.byte_differences.is_none());
376    }
377
378    #[test]
379    fn round_trip_deterministic() {
380        let copybook = r"
381            01 RECORD.
382               05 NAME PIC X(10).
383               05 AGE  PIC 9(3).
384        ";
385        let schema = parse_copybook(copybook).expect("parse copybook");
386
387        let data: Vec<u8> = vec![
388            0xD1, 0xD6, 0xC8, 0xD5, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0xF1, 0xF2, 0xF3,
389        ];
390
391        let result = check_round_trip_determinism(&schema, &data, &decode_opts(), &encode_opts())
392            .expect("round-trip check");
393
394        assert!(result.is_deterministic, "Expected deterministic round-trip");
395        assert_eq!(result.mode, DeterminismMode::RoundTrip);
396    }
397
398    #[test]
399    fn detect_json_serialization_nondeterminism() {
400        let json1 = serde_json::json!({"FIELD": "VALUE1"});
401        let json2 = serde_json::json!({"FIELD": "VALUE2"});
402
403        let bytes1 = serde_json::to_vec(&json1).expect("serialize json1");
404        let bytes2 = serde_json::to_vec(&json2).expect("serialize json2");
405
406        let result = compare_outputs(DeterminismMode::DecodeOnly, &bytes1, &bytes2);
407        assert!(!result.is_deterministic);
408        assert!(result.diff_count() > 0);
409    }
410
411    #[test]
412    fn primitive_comparison_reports_bounded_differences() {
413        let result =
414            compare_outputs_with_limit(DeterminismMode::EncodeOnly, b"ABCDEF", b"ABxDEy", 1);
415
416        assert!(!result.passed());
417        assert_eq!(result.diff_count(), 1);
418        assert_eq!(
419            result.byte_differences.as_ref().expect("diffs")[0].offset,
420            2
421        );
422    }
423
424    #[test]
425    fn primitive_hash_and_result_serde_are_stable() {
426        let hash = blake3_hex(b"copybook");
427        let result = compare_outputs(DeterminismMode::RoundTrip, b"copybook", b"copybook");
428
429        assert_eq!(hash.len(), BLAKE3_HEX_LEN);
430        assert_eq!(result.round1_hash, hash);
431        assert!(result.byte_differences.is_none());
432
433        let json = serde_json::to_string(&result).expect("serialize determinism result");
434        let decoded: DeterminismResult =
435            serde_json::from_str(&json).expect("deserialize determinism result");
436        assert_eq!(decoded, result);
437    }
438
439    #[test]
440    fn primitive_length_difference_documents_zero_padding() {
441        let diffs = find_byte_differences(b"ABC", b"ABC\0");
442
443        assert_eq!(diffs.len(), 1);
444        assert_eq!(diffs[0].offset, 3);
445        assert_eq!(diffs[0].round1_byte, 0);
446        assert_eq!(diffs[0].round2_byte, 0);
447    }
448
449    #[test]
450    fn decode_error_propagates_correctly() {
451        let copybook = r"
452            01 RECORD.
453               05 AMOUNT PIC S9(7)V99 COMP-3.
454        ";
455        let schema = parse_copybook(copybook).expect("parse copybook");
456
457        let truncated_data = vec![0x12, 0x34];
458
459        let result = check_decode_determinism(&schema, &truncated_data, &decode_opts());
460
461        assert!(
462            result.is_err(),
463            "Should return error for truncated COMP-3 data"
464        );
465    }
466
467    #[test]
468    fn encode_error_propagates_correctly() {
469        let copybook = r"
470            01 RECORD.
471               05 FIELD PIC 9(5).
472        ";
473        let schema = parse_copybook(copybook).expect("parse copybook");
474
475        let invalid_json = serde_json::json!({"FIELD": "NOT_A_NUMBER"});
476
477        let result = check_encode_determinism(&schema, &invalid_json, &encode_opts());
478
479        assert!(
480            result.is_err(),
481            "Should return error for type mismatch in encoding"
482        );
483    }
484
485    #[test]
486    fn round_trip_error_propagates() {
487        let copybook = r"
488            01 RECORD.
489               05 AMOUNT PIC S9(7)V99 COMP-3.
490        ";
491        let schema = parse_copybook(copybook).expect("parse copybook");
492
493        let bad_data = vec![0x12, 0x34];
494
495        let result =
496            check_round_trip_determinism(&schema, &bad_data, &decode_opts(), &encode_opts());
497
498        assert!(
499            result.is_err(),
500            "Should return error for truncated data in round-trip"
501        );
502    }
503
504    #[test]
505    fn insufficient_data_handling_is_stable() {
506        let copybook = r"
507            01 RECORD.
508               05 FIELD PIC X(5).
509        ";
510        let schema = parse_copybook(copybook).expect("parse copybook");
511
512        let insufficient_data = vec![0x40, 0x40, 0x40];
513
514        let result = check_decode_determinism(&schema, &insufficient_data, &decode_opts());
515
516        if let Ok(det_result) = result {
517            assert!(
518                det_result.is_deterministic,
519                "If insufficient data is handled, it must be deterministic"
520            );
521        }
522    }
523}