cargo : assert-json-diff @ 2.0.2
PE Patrick Elsen signed 2026-05-27 published 2026-05-27

Claims

algorithm-impl-boundsalgorithm-impl-correctalgorithm-impl-safealgorithm-impl-testedhas-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignuses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

assert-json-diff 2.0.2 compares serde_json values via a recursive folder, producing diff messages with paths. Crate-level deny(unsafe_code); no I/O, no build script. Three low-severity findings: assert_json_matches_no_panic panics on Serialize failures (contract bug); inclusive array compare is positional (doc ambiguity); Indent helper byte-space oriented. Safe to use as a dev-dependency.

Report

Subject

assert-json-diff compares two Serialize values by converting them to serde_json::Value and producing a structured list of differences with paths (e.g. .data.users[0].country.name). It exposes three macros — assert_json_include! (inclusive: actual may contain extra data), assert_json_eq! (strict: exact equality), and assert_json_matches! (config-driven) — plus a assert_json_matches_no_panic function returning Result<(), String>. It supports two numeric modes (Strict, AssumeFloat) and two compare modes (Inclusive, Strict).

Methodology

The published crate (assert-json-diff-2.0.2.crate) was unpacked. Source files (src/lib.rs 660 lines, src/diff.rs 532 lines, src/core_ext.rs 55 lines), the integration test (tests/integration_test.rs, 184 lines) and tests/version-numbers.rs, and the maintainer release shell script (bin/release) were read in full. Manifest was compared against the upstream Git checkout at commit bca0d2c59080 using diff -qr; only cargo-generated meta differences. Source was greped for unsafe, extern, process::, std::net, std::fs, and env::; the crate uses #![deny(unsafe_code)] at crate level. The bin/release shell script was inspected with file(1).

Tools used: openvet (workspace creation, claim/finding management), GNU diff 2.8, grep 2.6, file(1) (BSD).

Results

The published source matches upstream byte-for-byte (text). The crate ships no binary artefacts (justifying has-binaries), no build.rs and no proc-macro (justifying has-build-exec and has-install-exec). The bin/release script is a maintainer helper, not part of the library build path; it has no effect on consumers and contains no payloads.

The crate enforces #![deny(unsafe_code)] (justifying uses-unsafe). No std::process, std::net, std::fs, or env:: usage was found, justifying uses-network, uses-filesystem, uses-exec, uses-environment, uses-crypto, uses-jit, uses-interpreter, and uses-concurrency. The crate implements the JSON-tree diff algorithm in src/diff.rs over serde_json::Value (justifying impl-algorithm = true); impl-crypto, impl-parser, impl-interpreter, impl-jit, impl-protocol, impl-datastructure, and impl-concurrency are all false.

The diff algorithm is a recursive folder over Value::{Null,Bool,Number,String,Array,Object}. The algorithm is memory-safe (justifying algorithm-impl-safe), correct against the documented inclusive/strict semantics as exercised by the embedded and integration tests (justifying algorithm-impl-correct), exhaustively unit-tested across primitives, nested objects, arrays, and both numeric modes (justifying algorithm-impl-tested), and runs in time linear in the total node count of the inputs with a per-call allocation profile that scales linearly — no quadratic adversarial input is admitted (justifying algorithm-impl-bounds). No fuzz or property tests are shipped (justifying has-fuzz-tests and has-property-tests); the unit tests embedded in src/lib.rs and src/diff.rs (justifying has-unit-tests) and the integration tests under tests/ (justifying has-integration-tests) provide adequate coverage for a deterministic structural diff.

Three low-severity findings were recorded:

  • FINDING-1 (correctness): assert_json_matches_no_panic is documented as panic-free but panics on serialisation failure.
  • FINDING-2 (correctness): inclusive array compare is positional, not subset-style; documentation is ambiguous about which is intended.
  • FINDING-3 (quality): Indent::indent is byte-space oriented and lines() collapses trailing newlines — robustness nit, not currently triggered by the crate's own output.

No malicious patterns, build-side hooks, hidden capabilities, or surprising dependencies were observed, justifying is-benign.

Conclusion

assert-json-diff is a small, benign test-time JSON comparison crate with zero unsafe code (enforced at crate level), no I/O, and no surprising capability surface. The three recorded findings are minor: a panic-contract mismatch in the "no-panic" helper, a doc-vs-behaviour ambiguity in inclusive array matching, and a robustness nit in the indentation helper. None impacts the primary assertion code-paths under test-time usage. The package is suitable for use as a dev-dependency.

Findings(3)

FINDING-1 correctness low

assert_json_matches_no_panic panics if Serialize fails — contradicts function name

assert_json_matches_no_panic (src/lib.rs:265) is documented as the panic-free variant: "This is might be useful if you want to control how failures are reported and don't want to deal with panics." However its first two operations (src/lib.rs:274-285) are serde_json::to_value(lhs).unwrap_or_else(|err| panic!(...)) — i.e. the function panics if either input cannot be serialised. That contradicts the documented contract. A caller passing a type whose Serialize impl can fail (e.g. a custom type with conditional errors, certain std::time::SystemTime past-1970 values, NaN floats in a numeric serialiser configured to reject them) will hit a panic from a function that promises not to. The fix is to return the serialisation error as part of the Result.

FINDING-2 correctness low

Inclusive array compare ignores actual-shorter-than-expected mismatches at trailing indices

In diff.rs:on_array (src/diff.rs:72), the CompareMode::Inclusive branch iterates rhs.iter().enumerate() (i.e. expected indices) and looks up lhs.get(idx). When lhs (actual) is shorter than rhs (expected), the absent indices are correctly emitted as 'missing from actual'. But the symmetric case — lhs longer than rhs — is silently ignored, which is the intended inclusive semantics. The behaviour itself is correct; what is worth flagging is that the unit test test_diffing_array (src/diff.rs:413-417) covers this with json!([1]) vs json!([]) and asserts diffs.len() == 0, but does not test the asymmetric ordering of values within the overlapping range when the lhs is longer — e.g. json!([1, 9]) vs json!([1]) is asserted to produce 0 diffs (correct), but json!([9, 1]) vs json!([1]) would produce 1 diff at [0] despite the inclusive semantics matching by index, not by subset. Users expecting set-style inclusion (which the doc-comment 'actual to contain additional data' could imply) may be surprised; the inclusion is positional. Documentation should clarify that array inclusion is prefix-style, not subset-style.

FINDING-3 quality low

Indent::indent character-aligns only ASCII spaces; trailing newlines collapsed by Lines iterator

Indent::indent (src/core_ext.rs:9) uses self.to_string().lines() to split, then re-joins with \n. lines() collapses a trailing newline. If the underlying serde_json output ends with a newline (it does not at the moment), the round-trip would strip it. More importantly, indentation is by raw byte-space count, so multi-byte characters in serialised strings will not visually align in CJK/RTL contexts. The function is fine for the JSON {}/[]/numbers/quoted-strings the crate emits (where the leading column is always an ASCII char), so this is purely a robustness nit.

Annotations(3)

bin/release

Maintainer release shell script; not in src/ and not built by cargo. Executes confirm/cargo invocations and a final cargo publish. Auditable shell with no embedded payloads; it is irrelevant to the published library code.

src/diff.rs

src/diff.rs, line 72-137

    fn on_array(&mut self, lhs: &'a Value) {
        if let Some(rhs) = self.rhs.as_array() {
            let lhs = lhs.as_array().unwrap();

            match self.config.compare_mode {
                CompareMode::Inclusive => {
                    for (idx, rhs) in rhs.iter().enumerate() {
                        let path = self.path.append(Key::Idx(idx));

                        if let Some(lhs) = lhs.get(idx) {
                            diff_with(lhs, rhs, self.config.clone(), path, self.acc)
                        } else {
                            self.acc.push(Difference {
                                lhs: None,
                                rhs: Some(&self.rhs),
                                path,
                                config: self.config.clone(),
                            });
                        }
                    }
                }
                CompareMode::Strict => {
                    let all_keys = rhs
                        .indexes()
                        .into_iter()
                        .chain(lhs.indexes())
                        .collect::<HashSet<_>>();
                    for key in all_keys {
                        let path = self.path.append(Key::Idx(key));

                        match (lhs.get(key), rhs.get(key)) {
                            (Some(lhs), Some(rhs)) => {
                                diff_with(lhs, rhs, self.config.clone(), path, self.acc);
                            }
                            (None, Some(rhs)) => {
                                self.acc.push(Difference {
                                    lhs: None,
                                    rhs: Some(rhs),
                                    path,
                                    config: self.config.clone(),
                                });
                            }
                            (Some(lhs), None) => {
                                self.acc.push(Difference {
                                    lhs: Some(lhs),
                                    rhs: None,
                                    path,
                                    config: self.config.clone(),
                                });
                            }
                            (None, None) => {
                                unreachable!("at least one of the maps should have the key")
                            }
                        }
                    }
                }
            }
        } else {
            self.acc.push(Difference {
                lhs: Some(lhs),
                rhs: Some(&self.rhs),
                path: self.path.clone(),
                config: self.config.clone(),
            });
        }
    }

Array comparison logic — inclusive mode is positional, not subset; see FINDING-2. Implements the JSON diff algorithm justifying impl-algorithm.

src/lib.rs

src/lib.rs, line 141-153

#![deny(
    missing_docs,
    unused_imports,
    missing_debug_implementations,
    missing_copy_implementations,
    trivial_casts,
    trivial_numeric_casts,
    unsafe_code,
    unstable_features,
    unused_import_braces,
    unused_qualifications,
    unknown_lints
)]

#![deny(unsafe_code)] enforces uses-unsafe = false at compile time.

src/lib.rs, line 265-299

pub fn assert_json_matches_no_panic<Lhs, Rhs>(
    lhs: &Lhs,
    rhs: &Rhs,
    config: Config,
) -> Result<(), String>
where
    Lhs: Serialize,
    Rhs: Serialize,
{
    let lhs = serde_json::to_value(lhs).unwrap_or_else(|err| {
        panic!(
            "Couldn't convert left hand side value to JSON. Serde error: {}",
            err
        )
    });
    let rhs = serde_json::to_value(rhs).unwrap_or_else(|err| {
        panic!(
            "Couldn't convert right hand side value to JSON. Serde error: {}",
            err
        )
    });

    let diffs = diff(&lhs, &rhs, config);

    if diffs.is_empty() {
        Ok(())
    } else {
        let msg = diffs
            .into_iter()
            .map(|d| d.to_string())
            .collect::<Vec<_>>()
            .join("\n\n");
        Err(msg)
    }
}

assert_json_matches_no_panic — panic-free contract is violated by the to_value().unwrap_or_else(|| panic!()) at lines 274-285; see FINDING-1.