diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..af2fa94 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,64 @@ +--- +# Long-running property and fuzz runs. The gate runs the same property tests at +# the default case count on every push; this is where they get hours-of-CPU +# depth, on a schedule and on demand, so a pull request never waits for them. +# +# A failure leaves the shrunk counterexample in the job log (proptest) or the +# failing input as an artifact (Go). Add it to the corpus as a vector +# (conformance/...) and fix the cause; do not just re-run. + +name: fuzz + +on: + schedule: + - cron: "17 3 * * 1" # Mondays 03:17 UTC + workflow_dispatch: + inputs: + proptest_cases: + description: "PROPTEST_CASES per property" + default: "500000" + go_fuzztime: + description: "go test -fuzztime" + default: "10m" + +permissions: + contents: read + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +jobs: + proptest: + name: property tests (many cases) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Run the property suite + env: + PROPTEST_CASES: ${{ inputs.proptest_cases || '500000' }} + run: cargo test --release -p cic-primitive-engine --test properties + + go-fuzz: + name: go fuzz (FuzzToCanonicalJSON) + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: go + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: Fuzz + run: go test -fuzz=FuzzToCanonicalJSON -fuzztime=${{ inputs.go_fuzztime || '10m' }} ./canonical + - name: Keep the failing input + if: failure() + uses: actions/upload-artifact@v4 + with: + name: go-fuzz-failing-input + path: go/canonical/testdata/fuzz + if-no-files-found: ignore diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 3fc81d7..05f2d78 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -4,6 +4,12 @@ # The conformance suite checks itself as well as the engine: an empty corpus # fails, and a group with no accepted vector fails. Both were verified by # breaking them on purpose. +# +# Beyond the fixed corpora: the Rust property tests (engine/tests/properties.rs) +# run in `cargo test`; the same suite writes a generated corpus that the Go peer +# re-derives (cross-language check below); Go runs with the race detector; and +# both languages publish a coverage summary (informational -- no floor yet, see +# the issue for ratcheting). The long fuzz / high-case-count runs are in fuzz.yml. name: gate @@ -30,10 +36,36 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - components: rustfmt, clippy + components: rustfmt, clippy, llvm-tools-preview + - uses: actions/setup-go@v5 + with: + go-version: "1.25" - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace + # Release profile too: debug-only assertions are compiled out there, and + # a test that passes only because of one (e.g. Map::push, #53) would + # otherwise never be seen failing. + - run: cargo test --workspace --release + - name: Cross-language check on generated input + # Rust generates documents (fixed seed) and records the canonical bytes + # and digest its pipeline derives; Go re-derives both. The Go test fails + # when the corpus is set but missing or empty, so this cannot pass by + # the input silently vanishing. + run: | + CIC_PROP_CORPUS_OUT="$RUNNER_TEMP/corpus.jsonl" CIC_PROP_CORPUS_CASES=20000 \ + cargo test -p cic-primitive-engine --test properties export_cross_language_corpus + test -s "$RUNNER_TEMP/corpus.jsonl" + cd go && CIC_PROP_CORPUS_IN="$RUNNER_TEMP/corpus.jsonl" go test ./canonical -run CrossLanguage -v + - uses: taiki-e/install-action@cargo-llvm-cov + - name: Coverage (Rust, informational) + run: | + { + echo '### Rust coverage (cargo llvm-cov)' + echo '```' + cargo llvm-cov --workspace --summary-only 2>&1 | grep -E '^(Filename|-|cli/|engine/|TOTAL)' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" gate-go: name: gate (go) @@ -49,4 +81,15 @@ jobs: go-version: "1.25" - run: gofmt -l . | tee /dev/stderr | (! read -r) - run: go vet ./... - - run: go test ./... + # -race: the Go peer is plain code today, but the packages are meant to be + # imported by services; the detector is free here and expensive to add + # after the first concurrent caller. + - run: go test -race -coverprofile=cover.out ./... + - name: Coverage (Go, informational) + run: | + { + echo '### Go coverage (statements)' + echo '```' + go tool cover -func=cover.out | tail -n 1 + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/conformance/README.md b/conformance/README.md index c9024f9..e198319 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -53,3 +53,23 @@ integer/float forms at both ordinary and extreme magnitudes, A4's escape table including the characters RFC 8259 doesn't require escaped, a canonicalization-stage rejection) so a future Go peer has the same fixtures to agree with, not just this crate's own unit tests. + +## Generated input, not only written vectors + +The vectors above pin cases someone thought of. Two further checks cover the +rest (#54): + +- `engine/tests/properties.rs` (Rust, `proptest`) checks over generated values + and bytes that canonicalization never panics and writes valid JSON with + sorted, unique keys, ignores insertion order, that the digest is a pure + function of the canonical bytes, that the constructors refuse exactly what + they promise to, and that the reader never panics. It runs in the gate at + the default case count; `fuzz.yml` raises it on a schedule. +- The same suite writes a fixed-seed corpus (`export_cross_language_corpus`) + that `go/canonical/crosslang_test.go` re-derives: for each generated + document the Go peer must produce the same canonical bytes and digest. This + is how the float tie divergence (#57) was found; a counterexample from + either side becomes a vector in `canonicalize/` or `differential/`. +- `go/canonical/fuzz_test.go` is a native Go fuzz target (seeds run in + `go test`; the long run is in `fuzz.yml`). + diff --git a/engine/Cargo.toml b/engine/Cargo.toml index d28d8f2..ae3faa4 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -28,3 +28,12 @@ sha2 = "0.10" num-bigint = "0.4" num-rational = "0.4" num-traits = "0.2" + +[dev-dependencies] +# Property tests (tests/properties.rs): generated Values and inputs, checked +# for the invariants the canonicalizer and comparators promise. serde_json is +# only the independent JSON reader the properties check the output against -- +# it is not used by the engine. +proptest = "1" +serde = "1" +serde_json = "1" diff --git a/engine/tests/properties.rs b/engine/tests/properties.rs new file mode 100644 index 0000000..c4a2fcf --- /dev/null +++ b/engine/tests/properties.rs @@ -0,0 +1,327 @@ +//! Property tests over generated `Value`s and generated input (#54). +//! +//! The corpus-based suites pin cases someone thought of; these check the +//! promises over inputs nobody wrote down: the canonicalizer never panics and +//! writes valid, sorted, unique-key JSON; it does not depend on insertion +//! order; the digest is a pure function of the canonical bytes; the +//! constructors refuse exactly the shapes they promise to refuse; the reader +//! never panics on arbitrary bytes. +//! +//! `export_cross_language_corpus` additionally writes a fixed-seed corpus for +//! the Go peer (`go/canonical/crosslang_test.go`) so Rust and Go are compared +//! on generated input, not only on the hand-written differential vectors. +//! +//! Case count: `PROPTEST_CASES` (the scheduled workflow raises it). + +use cic_primitive_engine::reader::{self, BigIntValue, Map, Value}; +use cic_primitive_engine::{compare, digest, to_canonical_json, CompareType, Stage}; +use proptest::prelude::*; +use proptest::strategy::ValueTree; +use proptest::test_runner::{Config, TestRunner}; +use serde::de::{Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use std::collections::BTreeMap; +use std::fmt; +use std::io::Write as _; + +// ---------------------------------------------------------------- generators + +/// Text for a BigInt: mostly digit runs, sometimes arbitrary garbage. It goes +/// through the real constructor, so garbage only becomes a BigInt if the +/// constructor accepts it -- a constructor that accepted too much would let it +/// through and the canonical-form properties below would fail. (Rejected text +/// maps to `0` instead of being filtered out, which keeps proptest's reject +/// budget out of play at large case counts.) +fn arb_bigint() -> impl Strategy { + prop_oneof![3 => "[+-]?[0-9]{1,40}", 1 => any::()].prop_map(|s| { + Value::BigInt( + BigIntValue::try_new(s) + .unwrap_or_else(|_| BigIntValue::try_new("0").expect("0 is a digit run")), + ) + }) +} + +fn arb_leaf() -> impl Strategy { + prop_oneof![ + Just(Value::Null), + any::().prop_map(Value::Bool), + any::().prop_map(Value::Int), + arb_bigint(), + // Every finite class (no NaN / infinity: those are a separate, + // documented rejection, covered by the corpus). + (prop::num::f64::POSITIVE + | prop::num::f64::NEGATIVE + | prop::num::f64::NORMAL + | prop::num::f64::SUBNORMAL + | prop::num::f64::ZERO) + .prop_map(Value::Float), + any::().prop_map(Value::Str), + ] +} + +/// A finite value tree. Map keys come from a `BTreeMap`, so they are unique; +/// the uniqueness guarantee itself is tested separately. +fn arb_value() -> impl Strategy { + arb_leaf().prop_recursive(4, 48, 6, |inner| { + prop_oneof![ + prop::collection::vec(inner.clone(), 0..5).prop_map(Value::Seq), + prop::collection::btree_map(any::(), inner, 0..5).prop_map(|m| { + Value::Map(Map::from_pairs(m).expect("BTreeMap keys are unique")) + }), + ] + }) +} + +/// The same tree with every mapping's insertion order changed (reversed, then +/// rotated), so canonical output must not depend on it. +fn reorder(v: &Value) -> Value { + match v { + Value::Seq(items) => Value::Seq(items.iter().map(reorder).collect()), + Value::Map(m) => { + let mut pairs: Vec<(String, Value)> = + m.iter().map(|(k, v)| (k.to_string(), reorder(v))).collect(); + pairs.reverse(); + if !pairs.is_empty() { + pairs.rotate_left(1); + } + Value::Map(Map::from_pairs(pairs).expect("same unique keys")) + } + other => other.clone(), + } +} + +/// A document holding `v` under one key. A bare top-level scalar is not a +/// meaningful document for the reader (`null`/`~`/empty all read as an empty +/// mapping, by design), so round trips go through a mapping. +fn wrapped(v: &Value) -> Value { + Value::Map(Map::from_pairs([("v".to_string(), v.clone())]).expect("one key")) +} + +/// JSON text for a generated value (strict JSON, so both readers accept it). +fn to_json_text(v: &Value) -> String { + match v { + Value::Null => "null".into(), + Value::Bool(b) => b.to_string(), + Value::Int(i) => i.to_string(), + Value::BigInt(n) => n.canonical(), + Value::Float(f) => format!("{f:?}"), + Value::Str(s) => serde_json::to_string(s).unwrap(), + Value::Seq(items) => format!( + "[{}]", + items.iter().map(to_json_text).collect::>().join(",") + ), + Value::Map(m) => format!( + "{{{}}}", + m.iter() + .map(|(k, v)| format!("{}:{}", serde_json::to_string(k).unwrap(), to_json_text(v))) + .collect::>() + .join(",") + ), + } +} + +// ------------------------------------------------- independent JSON checking + +/// Deserializes any JSON, failing on an object whose keys are not strictly +/// ascending by bytes (which also rules out a repeated key). `serde_json`'s +/// own `Value` would silently drop a duplicate, so it cannot be the check. +struct StrictSorted; + +impl<'de> Deserialize<'de> for StrictSorted { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> Visitor<'de> for V { + type Value = StrictSorted; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("JSON with strictly ascending object keys") + } + fn visit_unit(self) -> Result { + Ok(StrictSorted) + } + fn visit_bool(self, _: bool) -> Result { + Ok(StrictSorted) + } + fn visit_i64(self, _: i64) -> Result { + Ok(StrictSorted) + } + fn visit_u64(self, _: u64) -> Result { + Ok(StrictSorted) + } + fn visit_f64(self, _: f64) -> Result { + Ok(StrictSorted) + } + fn visit_str(self, _: &str) -> Result { + Ok(StrictSorted) + } + fn visit_seq>(self, mut a: A) -> Result { + while a.next_element::()?.is_some() {} + Ok(StrictSorted) + } + fn visit_map>(self, mut a: A) -> Result { + let mut prev: Option = None; + while let Some(k) = a.next_key::()? { + if prev.as_deref().is_some_and(|p| p >= k.as_str()) { + return Err(serde::de::Error::custom(format!( + "key {k:?} is not after {prev:?}" + ))); + } + prev = Some(k); + a.next_value::()?; + } + Ok(StrictSorted) + } + } + d.deserialize_any(V) + } +} + +fn assert_valid_sorted_json(bytes: &[u8]) { + let text = std::str::from_utf8(bytes).expect("canonical output is UTF-8"); + serde_json::from_str::(text) + .unwrap_or_else(|e| panic!("not valid sorted unique-key JSON ({e}): {text}")); + assert!( + !text.contains('\n'), + "a raw newline in canonical output: {text:?}" + ); +} + +// ---------------------------------------------------------------- properties + +proptest! { + #![proptest_config(Config::default())] + + /// Every generated finite value canonicalizes, to valid sorted JSON. + #[test] + fn canonical_output_is_valid_sorted_json(v in arb_value()) { + let bytes = to_canonical_json(&v).expect("a finite value always canonicalizes"); + assert_valid_sorted_json(&bytes); + } + + /// Insertion order of mapping keys never reaches the output. + #[test] + fn canonical_output_ignores_insertion_order(v in arb_value()) { + prop_assert_eq!(to_canonical_json(&v).unwrap(), to_canonical_json(&reorder(&v)).unwrap()); + } + + /// `digest` is `sha256:` + 64 lowercase hex, deterministic, and moves with the input. + #[test] + fn digest_shape_and_sensitivity(a in arb_value(), b in arb_value()) { + let (ca, cb) = (to_canonical_json(&a).unwrap(), to_canonical_json(&b).unwrap()); + let d = digest(&ca); + prop_assert!(d.starts_with("sha256:") && d.len() == 7 + 64); + prop_assert!(d[7..].bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))); + prop_assert_eq!(&d, &digest(&ca)); + prop_assert_eq!(ca == cb, d == digest(&cb)); + } + + /// A value equals itself under the exact comparator, with a verdict. + #[test] + fn exact_compare_is_reflexive(v in arb_value()) { + prop_assert_eq!(compare(&v, &v, CompareType::Exact), (true, true)); + } + + /// A value and its reordered twin are exactly equal. + #[test] + fn exact_compare_ignores_insertion_order(v in arb_value()) { + prop_assert_eq!(compare(&v, &reorder(&v), CompareType::Exact), (true, true)); + } + + /// Whatever text the BigInt constructor accepts has a canonical form that + /// is a JSON integer without leading zeros, and the value canonicalizes to it. + #[test] + fn accepted_bigint_text_has_a_json_integer_form(s in prop_oneof![3 => "[+-]?[0-9]{0,6}", 1 => any::()]) { + if let Ok(n) = BigIntValue::try_new(s.clone()) { + let c = n.canonical(); + let digits = c.strip_prefix('-').unwrap_or(&c); + prop_assert!(!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()), "{s:?} -> {c:?}"); + prop_assert!(digits == "0" || !digits.starts_with('0'), "{s:?} -> {c:?}"); + prop_assert!(c != "-0", "{s:?}"); + prop_assert_eq!(to_canonical_json(&Value::BigInt(n)).unwrap(), c.into_bytes()); + } else { + let digits = s.strip_prefix(['+', '-']).unwrap_or(&s); + prop_assert!(digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()), "{s:?} was refused"); + } + } + + /// `Map::from_pairs` accepts exactly the key lists without a repeat, in order. + #[test] + fn from_pairs_accepts_exactly_unique_keys(keys in prop::collection::vec("[a-d]{0,2}", 0..8)) { + let pairs: Vec<(String, Value)> = keys.iter().cloned().map(|k| (k, Value::Null)).collect(); + let unique = keys.iter().collect::>().len() == keys.len(); + match Map::from_pairs(pairs) { + Ok(m) => { + prop_assert!(unique); + prop_assert_eq!(m.keys(), keys.iter().map(String::as_str).collect::>()); + } + Err(_) => prop_assert!(!unique), + } + } + + /// Arbitrary bytes never panic the reader, and whatever it accepts either + /// canonicalizes or is refused for a non-finite float -- never a panic. + #[test] + fn reader_never_panics_on_arbitrary_bytes(bytes in prop::collection::vec(any::(), 0..200)) { + if let Ok(v) = reader::parse(&bytes, Stage::Read, "$", "input") { + let _ = to_canonical_json(&v); + } + } + + /// Same, over text that looks like YAML (flow/block syntax, anchors, tags, numbers). + #[test] + fn reader_never_panics_on_yaml_shaped_text(text in "[a-z0-9:\\-\\[\\]{},.&*!'\" #~\n]{0,80}") { + if let Ok(v) = reader::parse(text.as_bytes(), Stage::Read, "$", "input") { + if let Ok(bytes) = to_canonical_json(&v) { + assert_valid_sorted_json(&bytes); + } + } + } + + /// The JSON text of a generated value reads back to a value with the same + /// canonical form (the reader and the canonicalizer agree on JSON). + #[test] + fn json_text_round_trips_through_the_reader(v in arb_value()) { + let doc = wrapped(&v); + let text = to_json_text(&doc); + let read = reader::parse(text.as_bytes(), Stage::Read, "$", "input") + .unwrap_or_else(|e| panic!("reader refused {text}: {e}")); + prop_assert_eq!(to_canonical_json(&read).unwrap(), to_canonical_json(&doc).unwrap(), "{}", text); + } +} + +// ------------------------------------------------------- cross-language corpus + +/// Writes `CIC_PROP_CORPUS_OUT` (JSON lines: `input` = JSON text, `canonical`, +/// `digest`) from a fixed seed, for `go/canonical/crosslang_test.go` to +/// re-derive. No-op unless the variable is set. Expected values come from the +/// Rust pipeline as a user runs it: parse the JSON text, canonicalize. +#[test] +fn export_cross_language_corpus() { + let Some(path) = std::env::var_os("CIC_PROP_CORPUS_OUT") else { + return; + }; + let cases: usize = std::env::var("CIC_PROP_CORPUS_CASES") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(2000); + let mut runner = TestRunner::deterministic(); + let strategy = arb_value(); + let mut out = std::io::BufWriter::new(std::fs::File::create(&path).expect("create corpus")); + let mut seen = BTreeMap::new(); + for _ in 0..cases { + let v = wrapped(&strategy.new_tree(&mut runner).expect("generate").current()); + let text = to_json_text(&v); + let read = reader::parse(text.as_bytes(), Stage::Read, "$", "input").expect("reader"); + let canonical = String::from_utf8(to_canonical_json(&read).unwrap()).unwrap(); + let line = serde_json::json!({ + "input": text, + "canonical": canonical, + "digest": digest(canonical.as_bytes()), + }); + seen.insert(canonical, ()); + writeln!(out, "{line}").unwrap(); + } + out.flush().unwrap(); + assert!( + seen.len() > cases / 4, + "generator produced too few distinct cases" + ); +} diff --git a/go/canonical/crosslang_test.go b/go/canonical/crosslang_test.go new file mode 100644 index 0000000..2cb0465 --- /dev/null +++ b/go/canonical/crosslang_test.go @@ -0,0 +1,72 @@ +package canonical + +import ( + "bufio" + "bytes" + "encoding/json" + "os" + "testing" +) + +// The Rust peer's property suite (engine/tests/properties.rs, +// export_cross_language_corpus) writes a fixed-seed corpus of generated +// documents: strict JSON text, plus the canonical bytes and digest the Rust +// pipeline derived from it (reader -> to_canonical_json -> digest). This test +// re-derives both here and compares, so Rust and Go are checked against each +// other on generated input and not only on the hand-written differential +// vectors. +// +// CIC_PROP_CORPUS_IN names the corpus file. The gate sets it; when it is +// unset the test is skipped, so `go test ./...` still works on its own. When +// it is set the corpus must exist and be non-empty: a gate whose input +// silently vanished would pass for the wrong reason. +func TestCrossLanguageGeneratedCorpus(t *testing.T) { + path := os.Getenv("CIC_PROP_CORPUS_IN") + if path == "" { + t.Skip("CIC_PROP_CORPUS_IN not set (the gate generates it with the Rust property suite)") + } + f, err := os.Open(path) + if err != nil { + t.Fatalf("corpus: %v", err) + } + defer f.Close() + + var n int + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1<<20), 1<<26) + for sc.Scan() { + var c struct { + Input string `json:"input"` + Canonical string `json:"canonical"` + Digest string `json:"digest"` + } + if err := json.Unmarshal(sc.Bytes(), &c); err != nil { + t.Fatalf("line %d: %v", n+1, err) + } + n++ + + dec := json.NewDecoder(bytes.NewReader([]byte(c.Input))) + dec.UseNumber() + var v interface{} + if err := dec.Decode(&v); err != nil { + t.Fatalf("line %d: Go cannot read the generated input: %v\n%s", n, err, c.Input) + } + got, err := ToCanonicalJSON(v) + if err != nil { + t.Fatalf("line %d: Go refuses what Rust canonicalized: %v\n%s", n, err, c.Input) + } + if string(got) != c.Canonical { + t.Fatalf("line %d: canonical bytes differ\n input: %s\n rust: %s\n go: %s", n, c.Input, c.Canonical, got) + } + if d := Digest(got); d != c.Digest { + t.Fatalf("line %d: digest differs: rust %s, go %s", n, c.Digest, d) + } + } + if err := sc.Err(); err != nil { + t.Fatal(err) + } + if n == 0 { + t.Fatal("the corpus is empty") + } + t.Logf("%d generated documents agree", n) +} diff --git a/go/canonical/fuzz_test.go b/go/canonical/fuzz_test.go new file mode 100644 index 0000000..d0d097d --- /dev/null +++ b/go/canonical/fuzz_test.go @@ -0,0 +1,51 @@ +package canonical + +import ( + "bytes" + "encoding/json" + "testing" +) + +// FuzzToCanonicalJSON feeds arbitrary bytes through encoding/json (numbers +// kept as text, as the engine's callers do) and into ToCanonicalJSON. Without +// -fuzz the seeds below run as ordinary tests; the scheduled workflow runs the +// fuzzer. Whatever Go canonicalizes must be valid JSON and a fixed point of +// canonicalization -- and nothing may panic. +func FuzzToCanonicalJSON(f *testing.F) { + for _, s := range []string{ + `null`, `true`, `0`, `-0`, `1e400`, `1E-400`, `9223372036854775808`, `-9223372036854775809`, + `0.1`, `100000000000000000000.5`, `"

<>&"`, `"\u0000\u001f"`, `""`, + `[]`, `{}`, `[[],{},[{}]]`, `{"b":1,"a":2,"":3}`, `{"a":{"c":1,"b":[1,2,{"z":0,"y":null}]}}`, + `{"a":1,"a":2}`, `{"é":1,"é":2}`, `[1.0,1,1e0,1E0,0.5e1]`, + } { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, data []byte) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var v interface{} + if err := dec.Decode(&v); err != nil { + return // not JSON: nothing to canonicalize + } + first, err := ToCanonicalJSON(v) + if err != nil { + return // a refusal (e.g. a number with no canonical form) is allowed; a panic is not + } + if !json.Valid(first) { + t.Fatalf("canonical output is not valid JSON: %q (input %q)", first, data) + } + dec2 := json.NewDecoder(bytes.NewReader(first)) + dec2.UseNumber() + var v2 interface{} + if err := dec2.Decode(&v2); err != nil { + t.Fatalf("canonical output does not decode: %v: %q", err, first) + } + second, err := ToCanonicalJSON(v2) + if err != nil { + t.Fatalf("canonical output is not canonicalizable again: %v: %q", err, first) + } + if !bytes.Equal(first, second) { + t.Fatalf("canonicalization is not a fixed point:\n first: %q\nsecond: %q\n input: %q", first, second, data) + } + }) +}