@feltdb/core 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands.js +4 -1
- package/dist/cli/provisioning-neutrality.js +79 -0
- package/dist/collection.d.ts +43 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +192 -22
- package/dist/create/create.js +25 -21
- package/dist/create/managed-account.js +11 -0
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +165 -0
- package/dist/create/server-source/Cargo.toml +9 -0
- package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
- package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
- package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
- package/dist/create/server-source/crates/feltdb/src/equality_index.rs +595 -0
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
- package/dist/create/server-source/crates/feltdb/src/lib.rs +1050 -117
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
- package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +90 -72
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
- package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +305 -6
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1514 -537
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
- package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +442 -0
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +234 -0
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +947 -0
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1032 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +2 -1
- package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +800 -34
- package/dist/db.d.ts +33 -34
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +74 -20
- package/dist/deployment.d.ts +30 -0
- package/dist/deployment.d.ts.map +1 -0
- package/dist/deployment.js +130 -0
- package/dist/embedded-transaction.d.ts +22 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +51 -5
- package/dist/feltdb.d.ts +14 -2
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.js +1 -1
- package/dist/http-client.d.ts +14 -0
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +23 -5
- package/dist/http-db.d.ts +119 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +346 -31
- package/dist/index-core.d.ts +2 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +35 -21
- package/dist/managed-recovery.d.ts +192 -0
- package/dist/managed-recovery.d.ts.map +1 -0
- package/dist/managed-recovery.js +242 -0
- package/dist/memory-db.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-DaNwCLRX.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
- package/dist/studio-app/assets/index-j8IlhNqJ.js +29 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +30 -0
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +41 -0
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workload.d.ts +2 -1
- package/dist/workload.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
- package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
//! Conformance, determinism and capability proofs for the
|
|
2
|
+
//! `feltdb.state.conflict` deterministic contract.
|
|
3
|
+
//!
|
|
4
|
+
//! The third specimen of the pattern proved for `feltdb.trigger.match` and
|
|
5
|
+
//! `feltdb.state.diff`, and the first that reasons over competing changes:
|
|
6
|
+
//!
|
|
7
|
+
//! ```text
|
|
8
|
+
//! Contract semantics are authoritative.
|
|
9
|
+
//! Runtime implementation is replaceable.
|
|
10
|
+
//!
|
|
11
|
+
//! NativeClassify(base, left, right) == WasmClassify(base, left, right)
|
|
12
|
+
//! ```
|
|
13
|
+
//!
|
|
14
|
+
//! The corpus in `tests/fixtures/state_conflict_contract_corpus.json` is committed
|
|
15
|
+
//! rather than generated, and carries the canonical output each case must
|
|
16
|
+
//! produce. Three comparisons run over it:
|
|
17
|
+
//!
|
|
18
|
+
//! 1. native output == committed expectation (runs everywhere);
|
|
19
|
+
//! 2. WASM output == committed expectation (needs `wasm32-unknown-unknown`);
|
|
20
|
+
//! 3. native output == WASM output, byte for byte.
|
|
21
|
+
//!
|
|
22
|
+
//! The first makes the suite meaningful where the WASM toolchain is absent: a
|
|
23
|
+
//! change in the native classifier's semantics is caught by the same bytes the
|
|
24
|
+
//! WASM implementation was measured against.
|
|
25
|
+
//!
|
|
26
|
+
//! Beyond conformance, this suite asserts the *semantic invariants* the
|
|
27
|
+
//! classification must satisfy — see `classification_invariants_hold` — because
|
|
28
|
+
//! a contract over competing changes can be reproduced faithfully and still be
|
|
29
|
+
//! meaningless if the relation itself is not pinned.
|
|
30
|
+
|
|
31
|
+
use feltdb::state_conflict_contract::{
|
|
32
|
+
canonical_input_bytes, evaluate_canonical, StateConflictInput, STATE_CONFLICT_CONTRACT_ID,
|
|
33
|
+
STATE_CONFLICT_CONTRACT_VERSION,
|
|
34
|
+
};
|
|
35
|
+
use feltdb::state_model::{ConflictClass, ConflictClassification};
|
|
36
|
+
use serde::Deserialize;
|
|
37
|
+
use serde_json::Value;
|
|
38
|
+
use std::path::{Path, PathBuf};
|
|
39
|
+
use std::process::Command;
|
|
40
|
+
use std::sync::OnceLock;
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Corpus
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
#[derive(Debug, Deserialize)]
|
|
47
|
+
struct Corpus {
|
|
48
|
+
corpus_version: u32,
|
|
49
|
+
contract: String,
|
|
50
|
+
contract_version: u32,
|
|
51
|
+
#[allow(dead_code)]
|
|
52
|
+
about: String,
|
|
53
|
+
cases: Vec<Case>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[derive(Debug, Deserialize)]
|
|
57
|
+
struct Case {
|
|
58
|
+
name: String,
|
|
59
|
+
#[allow(dead_code)]
|
|
60
|
+
why: String,
|
|
61
|
+
/// A well-formed contract input, canonically encoded by the harness.
|
|
62
|
+
#[serde(default)]
|
|
63
|
+
input: Option<StateConflictInput>,
|
|
64
|
+
/// Bytes fed to the contract verbatim, for non-canonical and malformed
|
|
65
|
+
/// encodings that a typed value cannot express.
|
|
66
|
+
#[serde(default)]
|
|
67
|
+
raw_input: Option<String>,
|
|
68
|
+
/// The canonical encoding of `input`, recorded so that every consumer of
|
|
69
|
+
/// the corpus presents the same bytes.
|
|
70
|
+
///
|
|
71
|
+
/// A consumer in another language cannot always reproduce it: JavaScript
|
|
72
|
+
/// has one number type, so it cannot encode the difference between `42` and
|
|
73
|
+
/// `42.0` that this contract's value equality depends on.
|
|
74
|
+
#[serde(default)]
|
|
75
|
+
canonical_input: Option<String>,
|
|
76
|
+
/// The canonical output both implementations must produce.
|
|
77
|
+
#[serde(default)]
|
|
78
|
+
expected_output: Option<String>,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
impl Case {
|
|
82
|
+
/// The exact bytes this case presents to a contract implementation.
|
|
83
|
+
fn bytes(&self) -> Vec<u8> {
|
|
84
|
+
match (&self.input, &self.raw_input) {
|
|
85
|
+
(Some(input), None) => canonical_input_bytes(input),
|
|
86
|
+
(None, Some(raw)) => raw.as_bytes().to_vec(),
|
|
87
|
+
_ => panic!(
|
|
88
|
+
"case {} must have exactly one of input/raw_input",
|
|
89
|
+
self.name
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn expected(&self) -> &str {
|
|
95
|
+
self.expected_output.as_deref().unwrap_or_else(|| {
|
|
96
|
+
panic!(
|
|
97
|
+
"case {} has no expected_output; regenerate the corpus with \
|
|
98
|
+
FELTDB_STATE_CONFLICT_CONTRACT_BLESS=1",
|
|
99
|
+
self.name
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn corpus_path() -> PathBuf {
|
|
106
|
+
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/state_conflict_contract_corpus.json")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fn corpus() -> &'static Corpus {
|
|
110
|
+
static CORPUS: OnceLock<Corpus> = OnceLock::new();
|
|
111
|
+
CORPUS.get_or_init(|| {
|
|
112
|
+
let bytes = std::fs::read(corpus_path()).expect("conformance corpus is committed");
|
|
113
|
+
serde_json::from_slice(&bytes).expect("conformance corpus is well formed")
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[test]
|
|
118
|
+
fn corpus_addresses_this_contract_at_this_version() {
|
|
119
|
+
let corpus = corpus();
|
|
120
|
+
assert_eq!(corpus.corpus_version, 1);
|
|
121
|
+
assert_eq!(corpus.contract, STATE_CONFLICT_CONTRACT_ID);
|
|
122
|
+
assert_eq!(corpus.contract_version, STATE_CONFLICT_CONTRACT_VERSION);
|
|
123
|
+
assert!(
|
|
124
|
+
corpus.cases.len() >= 150,
|
|
125
|
+
"the corpus is the proof; it should not shrink silently"
|
|
126
|
+
);
|
|
127
|
+
let mut names: Vec<&str> = corpus.cases.iter().map(|case| case.name.as_str()).collect();
|
|
128
|
+
names.sort_unstable();
|
|
129
|
+
let unique = names.len();
|
|
130
|
+
names.dedup();
|
|
131
|
+
assert_eq!(unique, names.len(), "case names identify cases");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Every required category of behaviour is represented by at least one case.
|
|
135
|
+
///
|
|
136
|
+
/// A guard against the corpus quietly losing a dimension it was built to
|
|
137
|
+
/// cover, not a substitute for reading it.
|
|
138
|
+
#[test]
|
|
139
|
+
fn corpus_covers_every_required_category() {
|
|
140
|
+
let names: Vec<&str> = corpus().cases.iter().map(|c| c.name.as_str()).collect();
|
|
141
|
+
for required in [
|
|
142
|
+
// baseline
|
|
143
|
+
"all_identical",
|
|
144
|
+
"left_changed_right_unchanged",
|
|
145
|
+
"right_changed_left_unchanged",
|
|
146
|
+
"both_changed_identically",
|
|
147
|
+
"both_changed_differently",
|
|
148
|
+
// independent
|
|
149
|
+
"different_top_level_fields",
|
|
150
|
+
"different_nested_fields",
|
|
151
|
+
"different_sibling_subtrees",
|
|
152
|
+
"multiple_independent_changes",
|
|
153
|
+
"array_different_indexes",
|
|
154
|
+
// the path relation matrix
|
|
155
|
+
"relation_same_disagreeing",
|
|
156
|
+
"relation_same_agreeing",
|
|
157
|
+
"relation_ancestor_replaced_scalar",
|
|
158
|
+
"relation_ancestor_removed",
|
|
159
|
+
"relation_ancestor_two_levels",
|
|
160
|
+
"relation_descendant_replaced_scalar",
|
|
161
|
+
"relation_descendant_removed",
|
|
162
|
+
"relation_disjoint_siblings",
|
|
163
|
+
"relation_disjoint_string_prefix",
|
|
164
|
+
"relation_disjoint_key_against_index",
|
|
165
|
+
"relation_overlap_alongside_independent",
|
|
166
|
+
"relation_parent_object_to_array",
|
|
167
|
+
"relation_array_same_index",
|
|
168
|
+
"relation_array_different_indexes",
|
|
169
|
+
"relation_array_element_removed_vs_edited_inside",
|
|
170
|
+
"relation_array_prepend_vs_index_edit",
|
|
171
|
+
// overlapping
|
|
172
|
+
"same_path_different_values",
|
|
173
|
+
"parent_changed_vs_child_changed",
|
|
174
|
+
"child_changed_vs_parent_changed",
|
|
175
|
+
"addition_vs_addition_different",
|
|
176
|
+
"removal_vs_modification",
|
|
177
|
+
"object_to_scalar_vs_scalar_change",
|
|
178
|
+
"object_vs_array_at_same_path",
|
|
179
|
+
"scalar_to_object_vs_scalar_to_array",
|
|
180
|
+
// null and absence
|
|
181
|
+
"missing_vs_null",
|
|
182
|
+
"null_vs_value",
|
|
183
|
+
"value_vs_null",
|
|
184
|
+
"missing_vs_value_one_side",
|
|
185
|
+
"removal_vs_null_insertion",
|
|
186
|
+
// numbers
|
|
187
|
+
"integer_vs_float_same_magnitude",
|
|
188
|
+
"numeric_replacement_conflict",
|
|
189
|
+
"large_values",
|
|
190
|
+
"zero_vs_float_zero",
|
|
191
|
+
// arrays
|
|
192
|
+
"array_same_index_both_sides",
|
|
193
|
+
"array_append_both_sides_same",
|
|
194
|
+
"array_removal_one_side",
|
|
195
|
+
"array_removal_both_sides",
|
|
196
|
+
"array_prepend_one_side",
|
|
197
|
+
"array_reorder_vs_edit",
|
|
198
|
+
"array_nested_change_each_side",
|
|
199
|
+
// deep
|
|
200
|
+
"deeply_nested_independent_edits",
|
|
201
|
+
"many_simultaneous_conflicts",
|
|
202
|
+
"mixed_severities_in_one_result",
|
|
203
|
+
// empty
|
|
204
|
+
"empty_to_populated_both_sides",
|
|
205
|
+
"populated_to_empty_both_sides",
|
|
206
|
+
"empty_object_vs_empty_array",
|
|
207
|
+
"empty_object_vs_scalar",
|
|
208
|
+
"empty_array_vs_scalar",
|
|
209
|
+
// unparseable content
|
|
210
|
+
"unparseable_base",
|
|
211
|
+
"unparseable_all_three",
|
|
212
|
+
// invalid envelopes
|
|
213
|
+
"malformed_not_json",
|
|
214
|
+
"malformed_duplicate_envelope_member",
|
|
215
|
+
"malformed_state_is_not_a_string",
|
|
216
|
+
] {
|
|
217
|
+
assert!(
|
|
218
|
+
names.contains(&required),
|
|
219
|
+
"corpus lost coverage: {required}"
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// The committed canonical bytes are what this crate's encoder produces.
|
|
225
|
+
///
|
|
226
|
+
/// Consumers outside Rust read `canonical_input` rather than re-canonicalizing,
|
|
227
|
+
/// so this is the check that keeps those bytes honest: if canonicalization
|
|
228
|
+
/// changed, every other reader of the corpus would silently be testing an
|
|
229
|
+
/// encoding FeltDB no longer emits.
|
|
230
|
+
#[test]
|
|
231
|
+
fn committed_canonical_bytes_match_the_encoder() {
|
|
232
|
+
for case in &corpus().cases {
|
|
233
|
+
match (&case.input, &case.raw_input) {
|
|
234
|
+
(Some(input), None) => assert_eq!(
|
|
235
|
+
case.canonical_input.as_deref(),
|
|
236
|
+
Some(
|
|
237
|
+
String::from_utf8(canonical_input_bytes(input))
|
|
238
|
+
.unwrap()
|
|
239
|
+
.as_str()
|
|
240
|
+
),
|
|
241
|
+
"{} has a stale canonical_input",
|
|
242
|
+
case.name
|
|
243
|
+
),
|
|
244
|
+
(None, Some(_)) => assert!(
|
|
245
|
+
case.canonical_input.is_none(),
|
|
246
|
+
"{} is a raw case and must not also record canonical_input",
|
|
247
|
+
case.name
|
|
248
|
+
),
|
|
249
|
+
_ => panic!(
|
|
250
|
+
"case {} must have exactly one of input/raw_input",
|
|
251
|
+
case.name
|
|
252
|
+
),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// WASM host
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
const WASM_CRATE: &str = "crates/feltdb-state-conflict-contract-wasm";
|
|
262
|
+
const WASM_ARTIFACT: &str =
|
|
263
|
+
"target/wasm32-unknown-unknown/release/feltdb_state_conflict_contract_wasm.wasm";
|
|
264
|
+
|
|
265
|
+
fn repository_root() -> &'static Path {
|
|
266
|
+
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
267
|
+
.parent()
|
|
268
|
+
.and_then(Path::parent)
|
|
269
|
+
.expect("the crate lives at <root>/crates/feltdb")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// Whether this machine can compile the contract to WebAssembly at all.
|
|
273
|
+
fn wasm_target_installed() -> bool {
|
|
274
|
+
// `rustc --print target-libdir` succeeds for any target rustc *knows*, and
|
|
275
|
+
// prints where the standard library would live if it were installed. It
|
|
276
|
+
// does not report whether it actually is. Asking only about the exit status
|
|
277
|
+
// therefore reports every known target as installed, and the skip below
|
|
278
|
+
// never fires: a machine without `wasm32-unknown-unknown` fails the build
|
|
279
|
+
// and every WASM test with it, rather than skipping loudly as intended.
|
|
280
|
+
//
|
|
281
|
+
// The directory's existence is the fact worth having.
|
|
282
|
+
Command::new("rustc")
|
|
283
|
+
.args([
|
|
284
|
+
"--print",
|
|
285
|
+
"target-libdir",
|
|
286
|
+
"--target",
|
|
287
|
+
"wasm32-unknown-unknown",
|
|
288
|
+
])
|
|
289
|
+
.output()
|
|
290
|
+
.ok()
|
|
291
|
+
.filter(|output| output.status.success())
|
|
292
|
+
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
293
|
+
.is_some_and(|libdir| Path::new(&libdir).is_dir())
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// Compiles a WASM contract crate and returns the module bytes.
|
|
297
|
+
///
|
|
298
|
+
/// A build failure is a defect and panics. A missing `wasm32-unknown-unknown`
|
|
299
|
+
/// target is an environment fact, reported by the caller as a skip.
|
|
300
|
+
fn build_module(manifest_dir: &Path) -> Vec<u8> {
|
|
301
|
+
let output = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
|
|
302
|
+
.args(["build", "--release", "--target", "wasm32-unknown-unknown"])
|
|
303
|
+
.current_dir(manifest_dir)
|
|
304
|
+
// The contract crate is its own workspace; inheriting the outer
|
|
305
|
+
// build's target directory or flags would defeat that.
|
|
306
|
+
.env_remove("CARGO_TARGET_DIR")
|
|
307
|
+
.env_remove("RUSTFLAGS")
|
|
308
|
+
.output()
|
|
309
|
+
.expect("cargo is runnable");
|
|
310
|
+
assert!(
|
|
311
|
+
output.status.success(),
|
|
312
|
+
"building the WASM contract at {} failed:\n{}",
|
|
313
|
+
manifest_dir.display(),
|
|
314
|
+
String::from_utf8_lossy(&output.stderr)
|
|
315
|
+
);
|
|
316
|
+
std::fs::read(manifest_dir.join(WASM_ARTIFACT)).expect("the build emits one module")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/// The committed WASM contract, compiled once for the whole suite.
|
|
320
|
+
fn contract_module() -> Option<&'static [u8]> {
|
|
321
|
+
static MODULE: OnceLock<Option<Vec<u8>>> = OnceLock::new();
|
|
322
|
+
MODULE
|
|
323
|
+
.get_or_init(|| {
|
|
324
|
+
if let Ok(path) = std::env::var("FELTDB_STATE_CONFLICT_CONTRACT_WASM") {
|
|
325
|
+
return Some(
|
|
326
|
+
std::fs::read(path).expect("FELTDB_STATE_CONFLICT_CONTRACT_WASM is readable"),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if !wasm_target_installed() {
|
|
330
|
+
return None;
|
|
331
|
+
}
|
|
332
|
+
Some(build_module(&repository_root().join(WASM_CRATE)))
|
|
333
|
+
})
|
|
334
|
+
.as_deref()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/// Announces a skip loudly, so an absent toolchain is never a silent pass.
|
|
338
|
+
fn skipped(test: &str) {
|
|
339
|
+
eprintln!(
|
|
340
|
+
"SKIPPED {test}: the wasm32-unknown-unknown target is not installed, so the WASM \
|
|
341
|
+
contract implementation could not be built. Native conformance against the committed \
|
|
342
|
+
expectations still ran. Install the target, or set FELTDB_STATE_CONFLICT_CONTRACT_WASM to a \
|
|
343
|
+
prebuilt module, to execute the cross-runtime proof."
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/// One instantiation of the WASM contract.
|
|
348
|
+
///
|
|
349
|
+
/// The linker is empty and stays empty: the module declares no imports, so
|
|
350
|
+
/// there is nothing to satisfy and nothing to grant.
|
|
351
|
+
struct WasmContract {
|
|
352
|
+
store: wasmi::Store<()>,
|
|
353
|
+
memory: wasmi::Memory,
|
|
354
|
+
alloc: wasmi::TypedFunc<u32, u32>,
|
|
355
|
+
evaluate: wasmi::TypedFunc<(u32, u32), u64>,
|
|
356
|
+
free: wasmi::TypedFunc<(u32, u32), ()>,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
impl WasmContract {
|
|
360
|
+
fn instantiate(bytes: &[u8]) -> Self {
|
|
361
|
+
let engine = wasmi::Engine::default();
|
|
362
|
+
let module = wasmi::Module::new(&engine, bytes).expect("the module validates");
|
|
363
|
+
let mut store = wasmi::Store::new(&engine, ());
|
|
364
|
+
let instance = wasmi::Linker::<()>::new(&engine)
|
|
365
|
+
.instantiate_and_start(&mut store, &module)
|
|
366
|
+
.expect("an empty linker satisfies a module with no imports");
|
|
367
|
+
Self {
|
|
368
|
+
memory: instance
|
|
369
|
+
.get_memory(&store, "memory")
|
|
370
|
+
.expect("the module exports its memory"),
|
|
371
|
+
alloc: instance
|
|
372
|
+
.get_typed_func(&store, "contract_alloc")
|
|
373
|
+
.expect("contract_alloc"),
|
|
374
|
+
evaluate: instance
|
|
375
|
+
.get_typed_func(&store, "contract_evaluate")
|
|
376
|
+
.expect("contract_evaluate"),
|
|
377
|
+
free: instance
|
|
378
|
+
.get_typed_func(&store, "contract_free")
|
|
379
|
+
.expect("contract_free"),
|
|
380
|
+
store,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
fn version(bytes: &[u8]) -> u32 {
|
|
385
|
+
let engine = wasmi::Engine::default();
|
|
386
|
+
let module = wasmi::Module::new(&engine, bytes).unwrap();
|
|
387
|
+
let mut store = wasmi::Store::new(&engine, ());
|
|
388
|
+
let instance = wasmi::Linker::<()>::new(&engine)
|
|
389
|
+
.instantiate_and_start(&mut store, &module)
|
|
390
|
+
.unwrap();
|
|
391
|
+
instance
|
|
392
|
+
.get_typed_func::<(), u32>(&store, "contract_version")
|
|
393
|
+
.expect("contract_version")
|
|
394
|
+
.call(&mut store, ())
|
|
395
|
+
.expect("contract_version is total")
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
fn evaluate(&mut self, input: &[u8]) -> Vec<u8> {
|
|
399
|
+
let len = input.len() as u32;
|
|
400
|
+
let ptr = self.alloc.call(&mut self.store, len).expect("allocation");
|
|
401
|
+
self.memory
|
|
402
|
+
.write(&mut self.store, ptr as usize, input)
|
|
403
|
+
.expect("input fits in linear memory");
|
|
404
|
+
let packed = self
|
|
405
|
+
.evaluate
|
|
406
|
+
.call(&mut self.store, (ptr, len))
|
|
407
|
+
.expect("the contract never traps on any input");
|
|
408
|
+
let out_ptr = (packed >> 32) as usize;
|
|
409
|
+
let out_len = (packed & 0xffff_ffff) as usize;
|
|
410
|
+
let mut output = vec![0u8; out_len];
|
|
411
|
+
self.memory
|
|
412
|
+
.read(&self.store, out_ptr, &mut output)
|
|
413
|
+
.expect("the output buffer is inside linear memory");
|
|
414
|
+
self.free.call(&mut self.store, (ptr, len)).expect("free");
|
|
415
|
+
self.free
|
|
416
|
+
.call(&mut self.store, (out_ptr as u32, out_len as u32))
|
|
417
|
+
.expect("free");
|
|
418
|
+
output
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// Conformance
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
/// Native evaluation reproduces the committed canonical output for every case.
|
|
427
|
+
#[test]
|
|
428
|
+
fn native_matches_the_committed_expectations() {
|
|
429
|
+
let mut divergent = Vec::new();
|
|
430
|
+
for case in &corpus().cases {
|
|
431
|
+
let produced = String::from_utf8(evaluate_canonical(&case.bytes())).unwrap();
|
|
432
|
+
if produced != case.expected() {
|
|
433
|
+
divergent.push(format!(
|
|
434
|
+
"{}\n expected: {}\n native: {}",
|
|
435
|
+
case.name,
|
|
436
|
+
case.expected(),
|
|
437
|
+
produced
|
|
438
|
+
));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
assert!(
|
|
442
|
+
divergent.is_empty(),
|
|
443
|
+
"native contract diverged from the committed expectations:\n{}",
|
|
444
|
+
divergent.join("\n")
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/// The proof: one corpus, two implementations, byte-identical canonical output.
|
|
449
|
+
#[test]
|
|
450
|
+
fn native_and_wasm_agree_on_every_fixture() {
|
|
451
|
+
let Some(bytes) = contract_module() else {
|
|
452
|
+
return skipped("native_and_wasm_agree_on_every_fixture");
|
|
453
|
+
};
|
|
454
|
+
let mut wasm = WasmContract::instantiate(bytes);
|
|
455
|
+
|
|
456
|
+
println!(
|
|
457
|
+
"{:<48} {:<9} {:<9} {}",
|
|
458
|
+
"fixture", "native", "wasm", "equal"
|
|
459
|
+
);
|
|
460
|
+
let mut divergent = Vec::new();
|
|
461
|
+
for case in &corpus().cases {
|
|
462
|
+
let input = case.bytes();
|
|
463
|
+
let native = evaluate_canonical(&input);
|
|
464
|
+
let produced = wasm.evaluate(&input);
|
|
465
|
+
let equal = native == produced;
|
|
466
|
+
println!(
|
|
467
|
+
"{:<44} {:<16} {:<16} {}",
|
|
468
|
+
case.name,
|
|
469
|
+
summarize(&native),
|
|
470
|
+
summarize(&produced),
|
|
471
|
+
if equal { "yes" } else { "NO" }
|
|
472
|
+
);
|
|
473
|
+
if !equal {
|
|
474
|
+
divergent.push(format!(
|
|
475
|
+
"{}\n input: {}\n native: {}\n wasm: {}",
|
|
476
|
+
case.name,
|
|
477
|
+
String::from_utf8_lossy(&input),
|
|
478
|
+
String::from_utf8_lossy(&native),
|
|
479
|
+
String::from_utf8_lossy(&produced)
|
|
480
|
+
));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
assert!(
|
|
484
|
+
divergent.is_empty(),
|
|
485
|
+
"native and WASM contract implementations diverged:\n{}",
|
|
486
|
+
divergent.join("\n")
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/// The WASM implementation reproduces the committed canonical output too.
|
|
491
|
+
///
|
|
492
|
+
/// Separate from the pairwise comparison on purpose: two implementations can
|
|
493
|
+
/// agree with each other while both having moved.
|
|
494
|
+
#[test]
|
|
495
|
+
fn wasm_matches_the_committed_expectations() {
|
|
496
|
+
let Some(bytes) = contract_module() else {
|
|
497
|
+
return skipped("wasm_matches_the_committed_expectations");
|
|
498
|
+
};
|
|
499
|
+
let mut wasm = WasmContract::instantiate(bytes);
|
|
500
|
+
for case in &corpus().cases {
|
|
501
|
+
assert_eq!(
|
|
502
|
+
String::from_utf8(wasm.evaluate(&case.bytes())).unwrap(),
|
|
503
|
+
case.expected(),
|
|
504
|
+
"WASM contract diverged from the committed expectation for {}",
|
|
505
|
+
case.name
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/// Both implementations answer for the same contract at the same version.
|
|
511
|
+
#[test]
|
|
512
|
+
fn both_implementations_declare_the_same_contract_version() {
|
|
513
|
+
let Some(bytes) = contract_module() else {
|
|
514
|
+
return skipped("both_implementations_declare_the_same_contract_version");
|
|
515
|
+
};
|
|
516
|
+
assert_eq!(
|
|
517
|
+
WasmContract::version(bytes),
|
|
518
|
+
STATE_CONFLICT_CONTRACT_VERSION
|
|
519
|
+
);
|
|
520
|
+
for case in &corpus().cases {
|
|
521
|
+
let output: Value = serde_json::from_slice(&evaluate_canonical(&case.bytes())).unwrap();
|
|
522
|
+
assert_eq!(output["contract"], STATE_CONFLICT_CONTRACT_ID);
|
|
523
|
+
assert_eq!(output["version"], STATE_CONFLICT_CONTRACT_VERSION);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/// The native adapter adds nothing to the existing implementation.
|
|
528
|
+
///
|
|
529
|
+
/// The contract must be the classification FeltDB already computes, not a
|
|
530
|
+
/// second one that happens to agree. This runs both entry points over the whole
|
|
531
|
+
/// corpus, constructing revisions the way the adapter does.
|
|
532
|
+
#[test]
|
|
533
|
+
fn the_contract_is_the_existing_classifier() {
|
|
534
|
+
use feltdb::state_conflict_contract::classify_states;
|
|
535
|
+
use feltdb::state_model::{ConflictClassification, StateId, StateRevision};
|
|
536
|
+
|
|
537
|
+
let revision = |content: &str| StateRevision {
|
|
538
|
+
id: StateId::compute(content),
|
|
539
|
+
resource: String::new(),
|
|
540
|
+
content: content.to_string(),
|
|
541
|
+
content_id: StateId::compute(content),
|
|
542
|
+
parent_id: None,
|
|
543
|
+
sequence: 0,
|
|
544
|
+
authority: String::new(),
|
|
545
|
+
timestamp_ms: 0,
|
|
546
|
+
metadata: Default::default(),
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
let mut checked = 0;
|
|
550
|
+
for case in &corpus().cases {
|
|
551
|
+
let Some(input) = case.input.as_ref() else {
|
|
552
|
+
continue;
|
|
553
|
+
};
|
|
554
|
+
assert_eq!(
|
|
555
|
+
serde_json::to_string(&classify_states(&input.base, &input.left, &input.right))
|
|
556
|
+
.unwrap(),
|
|
557
|
+
serde_json::to_string(&ConflictClassification::classify(
|
|
558
|
+
&revision(&input.base),
|
|
559
|
+
&revision(&input.left),
|
|
560
|
+
&revision(&input.right)
|
|
561
|
+
))
|
|
562
|
+
.unwrap(),
|
|
563
|
+
"the contract adapter changed the result for {}",
|
|
564
|
+
case.name
|
|
565
|
+
);
|
|
566
|
+
checked += 1;
|
|
567
|
+
}
|
|
568
|
+
assert!(checked > 80, "most of the corpus is typed input");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/// A short rendering of an output, for the printed conformance table.
|
|
572
|
+
fn summarize(output: &[u8]) -> String {
|
|
573
|
+
let parsed: Value = match serde_json::from_slice(output) {
|
|
574
|
+
Ok(value) => value,
|
|
575
|
+
Err(_) => return "?".into(),
|
|
576
|
+
};
|
|
577
|
+
match parsed["classification"]["path_conflicts"].as_array() {
|
|
578
|
+
Some(paths) => format!(
|
|
579
|
+
"{}/{}",
|
|
580
|
+
parsed["classification"]["overall"].as_str().unwrap_or("?"),
|
|
581
|
+
paths.len()
|
|
582
|
+
),
|
|
583
|
+
None => "error".into(),
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// Float rendering
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
/// The WASM float writer reproduces `serde_json`'s, over far more values than
|
|
592
|
+
/// the corpus can hold.
|
|
593
|
+
///
|
|
594
|
+
/// This is the riskiest part of the second implementation. The contract's
|
|
595
|
+
/// output embeds whole input values, so the WASM module has to render doubles
|
|
596
|
+
/// exactly as `ryu` does — and neither Rust's `Display` nor its `Debug` will
|
|
597
|
+
/// do it: they disagree with `ryu` on presentation (`1e16` against `1e+16`)
|
|
598
|
+
/// and, on a fraction of values, on which shortest representation to pick.
|
|
599
|
+
///
|
|
600
|
+
/// The sample is restricted to doubles `serde_json` parses back from its own
|
|
601
|
+
/// output, for the reason
|
|
602
|
+
/// [`serde_json_does_not_round_trip_every_double`] records: outside that set
|
|
603
|
+
/// the two implementations disagree about which double the bytes denote,
|
|
604
|
+
/// before any rendering happens, and this test would be measuring that instead.
|
|
605
|
+
///
|
|
606
|
+
/// One diff carries the whole array as a single changed value, so this drives
|
|
607
|
+
/// the real module over the whole sample in a handful of calls.
|
|
608
|
+
#[test]
|
|
609
|
+
fn wasm_renders_floats_exactly_as_serde_json_does() {
|
|
610
|
+
let Some(bytes) = contract_module() else {
|
|
611
|
+
return skipped("wasm_renders_floats_exactly_as_serde_json_does");
|
|
612
|
+
};
|
|
613
|
+
let mut wasm = WasmContract::instantiate(bytes);
|
|
614
|
+
|
|
615
|
+
let mut sample: Vec<f64> = vec![
|
|
616
|
+
0.0,
|
|
617
|
+
-0.0,
|
|
618
|
+
1.0,
|
|
619
|
+
-1.0,
|
|
620
|
+
100.0,
|
|
621
|
+
0.5,
|
|
622
|
+
0.125,
|
|
623
|
+
3.141592653589793,
|
|
624
|
+
2.718281828459045,
|
|
625
|
+
1e15,
|
|
626
|
+
1e16,
|
|
627
|
+
1e17,
|
|
628
|
+
1e21,
|
|
629
|
+
1e100,
|
|
630
|
+
1e308,
|
|
631
|
+
1e-1,
|
|
632
|
+
1e-4,
|
|
633
|
+
1e-5,
|
|
634
|
+
1e-6,
|
|
635
|
+
1e-7,
|
|
636
|
+
1e-100,
|
|
637
|
+
5e-324,
|
|
638
|
+
f64::MAX,
|
|
639
|
+
f64::MIN,
|
|
640
|
+
f64::MIN_POSITIVE,
|
|
641
|
+
0.1,
|
|
642
|
+
0.2,
|
|
643
|
+
0.30000000000000004,
|
|
644
|
+
9007199254740992.0,
|
|
645
|
+
];
|
|
646
|
+
// A deterministic sweep over bit patterns, and over decimals of the kind
|
|
647
|
+
// records actually hold.
|
|
648
|
+
let mut seed: u64 = 0x243F6A8885A308D3;
|
|
649
|
+
for _ in 0..12_000 {
|
|
650
|
+
seed = seed
|
|
651
|
+
.wrapping_mul(6364136223846793005)
|
|
652
|
+
.wrapping_add(1442695040888963407);
|
|
653
|
+
let value = f64::from_bits(seed);
|
|
654
|
+
if value.is_finite() {
|
|
655
|
+
sample.push(value);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
for step in -6_000i64..6_000 {
|
|
659
|
+
sample.push(step as f64 / 1000.0);
|
|
660
|
+
}
|
|
661
|
+
let offered = sample.len();
|
|
662
|
+
sample.retain(|value| serde_json_round_trips(*value));
|
|
663
|
+
let checked = sample.len();
|
|
664
|
+
assert!(
|
|
665
|
+
checked > 12_000,
|
|
666
|
+
"the sample should still be large after excluding what serde_json cannot round trip"
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
for chunk in sample.chunks(4_000) {
|
|
670
|
+
// The base holds the whole sample and both branches replace it with 0,
|
|
671
|
+
// so the array is emitted once as `base_value` and the sides converge.
|
|
672
|
+
let base = serde_json::to_string(&Value::Array(
|
|
673
|
+
chunk.iter().map(|value| Value::from(*value)).collect(),
|
|
674
|
+
))
|
|
675
|
+
.unwrap();
|
|
676
|
+
let input = canonical_input_bytes(&StateConflictInput::new(
|
|
677
|
+
base,
|
|
678
|
+
"0".to_string(),
|
|
679
|
+
"0".to_string(),
|
|
680
|
+
));
|
|
681
|
+
let native = evaluate_canonical(&input);
|
|
682
|
+
let produced = wasm.evaluate(&input);
|
|
683
|
+
if native != produced {
|
|
684
|
+
// Report the first differing value rather than a 100 KB diff.
|
|
685
|
+
let native_text = String::from_utf8_lossy(&native);
|
|
686
|
+
let wasm_text = String::from_utf8_lossy(&produced);
|
|
687
|
+
let at = native_text
|
|
688
|
+
.char_indices()
|
|
689
|
+
.zip(wasm_text.char_indices())
|
|
690
|
+
.find(|((_, a), (_, b))| a != b)
|
|
691
|
+
.map(|((index, _), _)| index)
|
|
692
|
+
.unwrap_or(0);
|
|
693
|
+
let from = at.saturating_sub(40);
|
|
694
|
+
panic!(
|
|
695
|
+
"the WASM float writer diverged from serde_json:\n native: …{}…\n wasm: …{}…",
|
|
696
|
+
&native_text[from..(at + 40).min(native_text.len())],
|
|
697
|
+
&wasm_text[from..(at + 40).min(wasm_text.len())]
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
println!("float rendering agrees on {checked} of {offered} sampled doubles");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/// Whether `serde_json` reads back the double it itself wrote.
|
|
705
|
+
fn serde_json_round_trips(value: f64) -> bool {
|
|
706
|
+
let text = serde_json::to_string(&Value::from(value)).expect("finite");
|
|
707
|
+
serde_json::from_str::<Value>(&text)
|
|
708
|
+
.ok()
|
|
709
|
+
.and_then(|parsed| parsed.as_f64())
|
|
710
|
+
.is_some_and(|parsed| parsed.to_bits() == value.to_bits())
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/// `serde_json` does not parse back every double it writes, and that bounds
|
|
714
|
+
/// what this contract can claim.
|
|
715
|
+
///
|
|
716
|
+
/// `serde_json`'s float parser is accurate to within one unit in the last
|
|
717
|
+
/// place but is not correctly rounded, which is its documented default: exact
|
|
718
|
+
/// round-tripping is the opt-in `float_roundtrip` feature, at roughly twice the
|
|
719
|
+
/// parsing cost. The WASM implementation uses `core`'s `dec2flt`, which *is*
|
|
720
|
+
/// correctly rounded, so for the affected literals the two implementations
|
|
721
|
+
/// disagree about which double the bytes denote — before either renders
|
|
722
|
+
/// anything.
|
|
723
|
+
///
|
|
724
|
+
/// This test exists so that boundary is measured and visible rather than
|
|
725
|
+
/// assumed. It asserts only that short, ordinary decimals — the numbers real
|
|
726
|
+
/// records hold — are unaffected. It deliberately does not assert a rate: that
|
|
727
|
+
/// is a property of the dependency and may change when it is upgraded.
|
|
728
|
+
#[test]
|
|
729
|
+
fn serde_json_does_not_round_trip_every_double() {
|
|
730
|
+
let mut lost = 0usize;
|
|
731
|
+
let mut total = 0usize;
|
|
732
|
+
let mut example = None;
|
|
733
|
+
let mut seed: u64 = 0x243F6A8885A308D3;
|
|
734
|
+
for _ in 0..200_000 {
|
|
735
|
+
seed = seed
|
|
736
|
+
.wrapping_mul(6364136223846793005)
|
|
737
|
+
.wrapping_add(1442695040888963407);
|
|
738
|
+
let value = f64::from_bits(seed);
|
|
739
|
+
if !value.is_finite() {
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
total += 1;
|
|
743
|
+
if !serde_json_round_trips(value) {
|
|
744
|
+
lost += 1;
|
|
745
|
+
if example.is_none() {
|
|
746
|
+
example = Some(serde_json::to_string(&Value::from(value)).unwrap());
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
println!(
|
|
751
|
+
"serde_json {} of {total} random doubles do not survive its own \
|
|
752
|
+
serialize/parse round trip (example: {})",
|
|
753
|
+
lost,
|
|
754
|
+
example.as_deref().unwrap_or("none")
|
|
755
|
+
);
|
|
756
|
+
assert!(
|
|
757
|
+
lost > 0,
|
|
758
|
+
"if serde_json now round trips every double, the restriction on the float rendering \
|
|
759
|
+
test and the contract's stated input domain can both be removed"
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
// The values records actually hold are unaffected, which is what makes the
|
|
763
|
+
// contract useful despite the restriction.
|
|
764
|
+
for ordinary in [
|
|
765
|
+
0.0, -0.0, 0.5, 0.125, 1.0, 2.5, 42.0, 99.99, 1e-5, 1e15, 1e16, 1e-6, 3.14, 2.718,
|
|
766
|
+
1234.5678, -0.001, 100.0, 0.1, 0.2, 0.3,
|
|
767
|
+
] {
|
|
768
|
+
assert!(
|
|
769
|
+
serde_json_round_trips(ordinary),
|
|
770
|
+
"an ordinary decimal must round trip: {ordinary}"
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
// Determinism
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
/// The same input, in the same instance, produces the same output every time.
|
|
780
|
+
#[test]
|
|
781
|
+
fn wasm_is_deterministic_across_repeated_calls() {
|
|
782
|
+
let Some(bytes) = contract_module() else {
|
|
783
|
+
return skipped("wasm_is_deterministic_across_repeated_calls");
|
|
784
|
+
};
|
|
785
|
+
let mut wasm = WasmContract::instantiate(bytes);
|
|
786
|
+
for case in &corpus().cases {
|
|
787
|
+
let input = case.bytes();
|
|
788
|
+
let first = wasm.evaluate(&input);
|
|
789
|
+
for round in 1..16 {
|
|
790
|
+
assert_eq!(
|
|
791
|
+
wasm.evaluate(&input),
|
|
792
|
+
first,
|
|
793
|
+
"{} changed answer on repeat {round} within one instance",
|
|
794
|
+
case.name
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/// Repeated native evaluation is also stable.
|
|
801
|
+
///
|
|
802
|
+
/// Cheap, and it forecloses the reading in which only the WASM side was ever
|
|
803
|
+
/// at risk of varying.
|
|
804
|
+
#[test]
|
|
805
|
+
fn native_is_deterministic_across_repeated_calls() {
|
|
806
|
+
for case in &corpus().cases {
|
|
807
|
+
let input = case.bytes();
|
|
808
|
+
let first = evaluate_canonical(&input);
|
|
809
|
+
for round in 1..16 {
|
|
810
|
+
assert_eq!(
|
|
811
|
+
evaluate_canonical(&input),
|
|
812
|
+
first,
|
|
813
|
+
"{} changed answer on native repeat {round}",
|
|
814
|
+
case.name
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/// The same input produces the same output in a fresh instance.
|
|
821
|
+
///
|
|
822
|
+
/// Each instance gets its own `Store`, so its linear memory, allocator state
|
|
823
|
+
/// and call history all start over. An implementation that carried state
|
|
824
|
+
/// between diffs would show it here.
|
|
825
|
+
#[test]
|
|
826
|
+
fn wasm_is_deterministic_across_fresh_instances() {
|
|
827
|
+
let Some(bytes) = contract_module() else {
|
|
828
|
+
return skipped("wasm_is_deterministic_across_fresh_instances");
|
|
829
|
+
};
|
|
830
|
+
let mut baseline = WasmContract::instantiate(bytes);
|
|
831
|
+
let expected: Vec<Vec<u8>> = corpus()
|
|
832
|
+
.cases
|
|
833
|
+
.iter()
|
|
834
|
+
.map(|case| baseline.evaluate(&case.bytes()))
|
|
835
|
+
.collect();
|
|
836
|
+
|
|
837
|
+
for round in 0..4 {
|
|
838
|
+
let mut fresh = WasmContract::instantiate(bytes);
|
|
839
|
+
for (case, want) in corpus().cases.iter().zip(&expected) {
|
|
840
|
+
assert_eq!(
|
|
841
|
+
&fresh.evaluate(&case.bytes()),
|
|
842
|
+
want,
|
|
843
|
+
"{} changed answer in fresh instance {round}",
|
|
844
|
+
case.name
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/// Order of evaluation does not affect any answer.
|
|
851
|
+
#[test]
|
|
852
|
+
fn wasm_answers_do_not_depend_on_evaluation_order() {
|
|
853
|
+
let Some(bytes) = contract_module() else {
|
|
854
|
+
return skipped("wasm_answers_do_not_depend_on_evaluation_order");
|
|
855
|
+
};
|
|
856
|
+
let mut forwards = WasmContract::instantiate(bytes);
|
|
857
|
+
let ordered: Vec<Vec<u8>> = corpus()
|
|
858
|
+
.cases
|
|
859
|
+
.iter()
|
|
860
|
+
.map(|case| forwards.evaluate(&case.bytes()))
|
|
861
|
+
.collect();
|
|
862
|
+
|
|
863
|
+
let mut backwards = WasmContract::instantiate(bytes);
|
|
864
|
+
for (index, case) in corpus().cases.iter().enumerate().rev() {
|
|
865
|
+
assert_eq!(
|
|
866
|
+
backwards.evaluate(&case.bytes()),
|
|
867
|
+
ordered[index],
|
|
868
|
+
"{} answered differently when the corpus ran in reverse",
|
|
869
|
+
case.name
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/// The same input produces the same output in a separate OS process.
|
|
875
|
+
#[test]
|
|
876
|
+
fn wasm_is_deterministic_across_process_boundaries() {
|
|
877
|
+
let Some(bytes) = contract_module() else {
|
|
878
|
+
return skipped("wasm_is_deterministic_across_process_boundaries");
|
|
879
|
+
};
|
|
880
|
+
let mut here = WasmContract::instantiate(bytes);
|
|
881
|
+
let mine: Vec<Vec<u8>> = corpus()
|
|
882
|
+
.cases
|
|
883
|
+
.iter()
|
|
884
|
+
.map(|case| here.evaluate(&case.bytes()))
|
|
885
|
+
.collect();
|
|
886
|
+
|
|
887
|
+
let executable = std::env::current_exe().expect("the test binary has a path");
|
|
888
|
+
let output = Command::new(executable)
|
|
889
|
+
.args([
|
|
890
|
+
"--exact",
|
|
891
|
+
"wasm_is_deterministic_across_fresh_instances",
|
|
892
|
+
"--nocapture",
|
|
893
|
+
])
|
|
894
|
+
.output()
|
|
895
|
+
.expect("the test binary re-runs");
|
|
896
|
+
assert!(
|
|
897
|
+
output.status.success(),
|
|
898
|
+
"the corpus did not reproduce in a separate process:\n{}",
|
|
899
|
+
String::from_utf8_lossy(&output.stdout)
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
for (case, produced) in corpus().cases.iter().zip(&mine) {
|
|
903
|
+
assert_eq!(String::from_utf8_lossy(produced), case.expected());
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
// Runtime restrictions
|
|
909
|
+
// ---------------------------------------------------------------------------
|
|
910
|
+
|
|
911
|
+
/// The module imports nothing.
|
|
912
|
+
///
|
|
913
|
+
/// This is the whole capability argument and it is deliberately narrow. A
|
|
914
|
+
/// WebAssembly module reaches the outside world only through its imports. With
|
|
915
|
+
/// an empty import section there is no clock, no filesystem handle, no socket,
|
|
916
|
+
/// no entropy source and no FeltDB authority state in scope — not because
|
|
917
|
+
/// access was denied at call time, but because no such function is linked. It
|
|
918
|
+
/// says nothing about hostile modules, which this suite does not test.
|
|
919
|
+
#[test]
|
|
920
|
+
fn wasm_module_declares_no_imports() {
|
|
921
|
+
let Some(bytes) = contract_module() else {
|
|
922
|
+
return skipped("wasm_module_declares_no_imports");
|
|
923
|
+
};
|
|
924
|
+
let engine = wasmi::Engine::default();
|
|
925
|
+
let module = wasmi::Module::new(&engine, bytes).expect("the module validates");
|
|
926
|
+
let imports: Vec<String> = module
|
|
927
|
+
.imports()
|
|
928
|
+
.map(|import: wasmi::ImportType| format!("{}::{}", import.module(), import.name()))
|
|
929
|
+
.collect();
|
|
930
|
+
assert!(
|
|
931
|
+
imports.is_empty(),
|
|
932
|
+
"the contract module must import nothing; it imports {imports:?}"
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/// An empty linker instantiates the module.
|
|
937
|
+
#[test]
|
|
938
|
+
fn wasm_module_instantiates_with_no_host_functions() {
|
|
939
|
+
let Some(bytes) = contract_module() else {
|
|
940
|
+
return skipped("wasm_module_instantiates_with_no_host_functions");
|
|
941
|
+
};
|
|
942
|
+
let engine = wasmi::Engine::default();
|
|
943
|
+
let module = wasmi::Module::new(&engine, bytes).unwrap();
|
|
944
|
+
let mut store = wasmi::Store::new(&engine, ());
|
|
945
|
+
wasmi::Linker::<()>::new(&engine)
|
|
946
|
+
.instantiate_and_start(&mut store, &module)
|
|
947
|
+
.expect("no host function is required");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/// The module exports the contract ABI and nothing that could act on FeltDB.
|
|
951
|
+
#[test]
|
|
952
|
+
fn wasm_module_exports_only_the_contract_abi() {
|
|
953
|
+
let Some(bytes) = contract_module() else {
|
|
954
|
+
return skipped("wasm_module_exports_only_the_contract_abi");
|
|
955
|
+
};
|
|
956
|
+
let engine = wasmi::Engine::default();
|
|
957
|
+
let module = wasmi::Module::new(&engine, bytes).unwrap();
|
|
958
|
+
let mut functions: Vec<String> = module
|
|
959
|
+
.exports()
|
|
960
|
+
.filter(|export| export.ty().func().is_some())
|
|
961
|
+
.map(|export| export.name().to_string())
|
|
962
|
+
.collect();
|
|
963
|
+
functions.sort();
|
|
964
|
+
assert_eq!(
|
|
965
|
+
functions,
|
|
966
|
+
vec![
|
|
967
|
+
"contract_alloc".to_string(),
|
|
968
|
+
"contract_evaluate".to_string(),
|
|
969
|
+
"contract_free".to_string(),
|
|
970
|
+
"contract_version".to_string(),
|
|
971
|
+
]
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/// WASI is not present, under any of the names a runtime would look for.
|
|
976
|
+
#[test]
|
|
977
|
+
fn wasm_module_requires_no_wasi() {
|
|
978
|
+
let Some(bytes) = contract_module() else {
|
|
979
|
+
return skipped("wasm_module_requires_no_wasi");
|
|
980
|
+
};
|
|
981
|
+
let text = String::from_utf8_lossy(bytes);
|
|
982
|
+
for forbidden in [
|
|
983
|
+
"wasi_snapshot_preview1",
|
|
984
|
+
"wasi_unstable",
|
|
985
|
+
"clock_time_get",
|
|
986
|
+
"random_get",
|
|
987
|
+
"fd_read",
|
|
988
|
+
"fd_write",
|
|
989
|
+
"sock_recv",
|
|
990
|
+
"path_open",
|
|
991
|
+
] {
|
|
992
|
+
assert!(
|
|
993
|
+
!text.contains(forbidden),
|
|
994
|
+
"the contract module names {forbidden}, which it has no reason to"
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/// The contract never traps, on any corpus input.
|
|
1000
|
+
#[test]
|
|
1001
|
+
fn wasm_contract_returns_a_result_for_every_input_including_malformed() {
|
|
1002
|
+
let Some(bytes) = contract_module() else {
|
|
1003
|
+
return skipped("wasm_contract_returns_a_result_for_every_input_including_malformed");
|
|
1004
|
+
};
|
|
1005
|
+
let mut wasm = WasmContract::instantiate(bytes);
|
|
1006
|
+
for case in &corpus().cases {
|
|
1007
|
+
let output = wasm.evaluate(&case.bytes());
|
|
1008
|
+
let parsed: Value =
|
|
1009
|
+
serde_json::from_slice(&output).expect("every output is canonical JSON");
|
|
1010
|
+
assert_eq!(parsed["contract"], STATE_CONFLICT_CONTRACT_ID);
|
|
1011
|
+
assert!(
|
|
1012
|
+
parsed.get("classification").is_some() ^ parsed.get("error").is_some(),
|
|
1013
|
+
"{} produced neither or both of classification and error",
|
|
1014
|
+
case.name
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
// Inputs no fixture would contain, to show trap-freedom is not a property
|
|
1018
|
+
// of the corpus. Nesting past the shared recursion bound is included.
|
|
1019
|
+
for hostile in [
|
|
1020
|
+
vec![0u8; 0],
|
|
1021
|
+
vec![0xff; 64],
|
|
1022
|
+
b"{".repeat(4096),
|
|
1023
|
+
b"[".repeat(4096),
|
|
1024
|
+
vec![b'"'; 1024],
|
|
1025
|
+
// Nesting past the shared recursion bound, inside state content.
|
|
1026
|
+
format!(
|
|
1027
|
+
r#"{{"base":{},"contract":"feltdb.state.conflict","left":"1","right":"1","version":1}}"#,
|
|
1028
|
+
serde_json::to_string(&format!("{}{}", "[".repeat(300), "]".repeat(300))).unwrap()
|
|
1029
|
+
)
|
|
1030
|
+
.into_bytes(),
|
|
1031
|
+
] {
|
|
1032
|
+
let output = wasm.evaluate(&hostile);
|
|
1033
|
+
assert!(serde_json::from_slice::<Value>(&output).is_ok());
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ---------------------------------------------------------------------------
|
|
1038
|
+
// Mutation: does the suite actually detect divergence?
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
|
|
1041
|
+
/// A deliberate change to the WASM implementation's source.
|
|
1042
|
+
struct Mutation {
|
|
1043
|
+
name: &'static str,
|
|
1044
|
+
file: &'static str,
|
|
1045
|
+
from: &'static str,
|
|
1046
|
+
to: &'static str,
|
|
1047
|
+
/// Whether the corpus must catch it.
|
|
1048
|
+
///
|
|
1049
|
+
/// Some mutations are expected *not* to be caught, and each such
|
|
1050
|
+
/// expectation is part of the contract rather than a gap. `why_invisible`
|
|
1051
|
+
/// says which reason applies.
|
|
1052
|
+
detected: bool,
|
|
1053
|
+
/// For a mutation expected to go uncaught, why it cannot be observed
|
|
1054
|
+
/// through this contract. Empty for mutations that must be caught.
|
|
1055
|
+
why_invisible: &'static str,
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const MUTATIONS: &[Mutation] = &[
|
|
1059
|
+
// --- the overlap relation, which this contract version establishes ---
|
|
1060
|
+
Mutation {
|
|
1061
|
+
name: "ancestor overlap is not detected",
|
|
1062
|
+
file: "src/conflict.rs",
|
|
1063
|
+
from: " for depth in 0..path.len() {",
|
|
1064
|
+
to: " for depth in 0..0 {",
|
|
1065
|
+
detected: true,
|
|
1066
|
+
why_invisible: "",
|
|
1067
|
+
},
|
|
1068
|
+
Mutation {
|
|
1069
|
+
name: "descendant overlap is not detected",
|
|
1070
|
+
file: "src/conflict.rs",
|
|
1071
|
+
from: " changes\n .get(after)\n .is_some_and(|change| path_starts_with(&change.path, path))",
|
|
1072
|
+
to: " changes\n .get(after)\n .is_some_and(|_| false)",
|
|
1073
|
+
detected: true,
|
|
1074
|
+
why_invisible: "",
|
|
1075
|
+
},
|
|
1076
|
+
Mutation {
|
|
1077
|
+
name: "overlap is checked against the branch's own changes",
|
|
1078
|
+
file: "src/conflict.rs",
|
|
1079
|
+
from: " if overlapping_change_exists(&right_changes, &path) {",
|
|
1080
|
+
to: " if overlapping_change_exists(&left_changes, &path) {",
|
|
1081
|
+
detected: true,
|
|
1082
|
+
why_invisible: "",
|
|
1083
|
+
},
|
|
1084
|
+
Mutation {
|
|
1085
|
+
name: "path components are compared by length alone",
|
|
1086
|
+
file: "src/conflict.rs",
|
|
1087
|
+
from: " candidate.len() >= prefix.len() && candidate[..prefix.len()] == *prefix",
|
|
1088
|
+
to: " candidate.len() >= prefix.len()",
|
|
1089
|
+
detected: true,
|
|
1090
|
+
why_invisible: "",
|
|
1091
|
+
},
|
|
1092
|
+
Mutation {
|
|
1093
|
+
name: "only the first path component is compared",
|
|
1094
|
+
file: "src/conflict.rs",
|
|
1095
|
+
from: " candidate.len() >= prefix.len() && candidate[..prefix.len()] == *prefix",
|
|
1096
|
+
to: " candidate.len() >= prefix.len()\n && (prefix.is_empty() || candidate[0] == prefix[0])",
|
|
1097
|
+
detected: true,
|
|
1098
|
+
why_invisible: "",
|
|
1099
|
+
},
|
|
1100
|
+
Mutation {
|
|
1101
|
+
name: "every overlap is reported as convergent rather than conflicting",
|
|
1102
|
+
file: "src/conflict.rs",
|
|
1103
|
+
from: " if overlapping_change_exists(&right_changes, &path) {\n has_conflict = true;\n ConflictClass::Conflict",
|
|
1104
|
+
to: " if overlapping_change_exists(&right_changes, &path) {\n has_convergence = true;\n ConflictClass::Convergent",
|
|
1105
|
+
detected: true,
|
|
1106
|
+
why_invisible: "",
|
|
1107
|
+
},
|
|
1108
|
+
// --- base_value, corrected in this version ---------------------------
|
|
1109
|
+
Mutation {
|
|
1110
|
+
name: "base_value is read from the left branch rather than the base",
|
|
1111
|
+
file: "src/conflict.rs",
|
|
1112
|
+
from: " base_value: resolve_path(base, &path).cloned(),",
|
|
1113
|
+
to: " base_value: resolve_path(left, &path).cloned(),",
|
|
1114
|
+
detected: true,
|
|
1115
|
+
why_invisible: "",
|
|
1116
|
+
},
|
|
1117
|
+
Mutation {
|
|
1118
|
+
name: "base_value is dropped",
|
|
1119
|
+
file: "src/conflict.rs",
|
|
1120
|
+
from: " base_value: resolve_path(base, &path).cloned(),",
|
|
1121
|
+
to: " base_value: None,",
|
|
1122
|
+
detected: true,
|
|
1123
|
+
why_invisible: "",
|
|
1124
|
+
},
|
|
1125
|
+
Mutation {
|
|
1126
|
+
name: "base_value resolves array elements as object members",
|
|
1127
|
+
file: "src/conflict.rs",
|
|
1128
|
+
from: " PathComponent::Index(index) => match current {\n Json::Array(items) => items.get(*index)?,\n _ => return None,\n },",
|
|
1129
|
+
to: " PathComponent::Index(_) => return None,",
|
|
1130
|
+
detected: true,
|
|
1131
|
+
why_invisible: "",
|
|
1132
|
+
},
|
|
1133
|
+
// --- the classification relation itself ------------------------------
|
|
1134
|
+
Mutation {
|
|
1135
|
+
name: "disagreeing edits are classified as independent",
|
|
1136
|
+
file: "src/conflict.rs",
|
|
1137
|
+
from: " } else {\n has_conflict = true;\n ConflictClass::Conflict\n }",
|
|
1138
|
+
to: " } else {\n ConflictClass::Independent\n }",
|
|
1139
|
+
detected: true,
|
|
1140
|
+
why_invisible: "",
|
|
1141
|
+
},
|
|
1142
|
+
Mutation {
|
|
1143
|
+
name: "agreeing edits are classified as conflicting",
|
|
1144
|
+
file: "src/conflict.rs",
|
|
1145
|
+
from: " if one.kind == two.kind && optional_equals(&one.new_value, &two.new_value) {",
|
|
1146
|
+
to: " if false {",
|
|
1147
|
+
detected: true,
|
|
1148
|
+
why_invisible: "",
|
|
1149
|
+
},
|
|
1150
|
+
Mutation {
|
|
1151
|
+
name: "convergence ignores the new value",
|
|
1152
|
+
file: "src/conflict.rs",
|
|
1153
|
+
from: " if one.kind == two.kind && optional_equals(&one.new_value, &two.new_value) {",
|
|
1154
|
+
to: " if one.kind == two.kind {",
|
|
1155
|
+
detected: true,
|
|
1156
|
+
why_invisible: "",
|
|
1157
|
+
},
|
|
1158
|
+
Mutation {
|
|
1159
|
+
name: "a disjoint one-sided change is classified as convergent",
|
|
1160
|
+
file: "src/conflict.rs",
|
|
1161
|
+
from: " } else {\n ConflictClass::Independent\n }\n }\n (None, Some(_)) => {",
|
|
1162
|
+
to: " } else {\n ConflictClass::Convergent\n }\n }\n (None, Some(_)) => {",
|
|
1163
|
+
detected: true,
|
|
1164
|
+
why_invisible: "",
|
|
1165
|
+
},
|
|
1166
|
+
Mutation {
|
|
1167
|
+
name: "the two branches are recorded on the wrong sides",
|
|
1168
|
+
file: "src/conflict.rs",
|
|
1169
|
+
from: " left_value: left_change.and_then(|change: &Change| change.new_value.clone()),\n right_value: right_change.and_then(|change: &Change| change.new_value.clone()),",
|
|
1170
|
+
to: " left_value: right_change.and_then(|change: &Change| change.new_value.clone()),\n right_value: left_change.and_then(|change: &Change| change.new_value.clone()),",
|
|
1171
|
+
detected: true,
|
|
1172
|
+
why_invisible: "",
|
|
1173
|
+
},
|
|
1174
|
+
Mutation {
|
|
1175
|
+
name: "overall severity prefers convergence over conflict",
|
|
1176
|
+
file: "src/conflict.rs",
|
|
1177
|
+
from: " let overall = if has_conflict {\n ConflictClass::Conflict\n } else if has_convergence {",
|
|
1178
|
+
to: " let overall = if has_convergence {\n ConflictClass::Convergent\n } else if has_conflict {",
|
|
1179
|
+
detected: true,
|
|
1180
|
+
why_invisible: "",
|
|
1181
|
+
},
|
|
1182
|
+
Mutation {
|
|
1183
|
+
name: "overall ignores convergence entirely",
|
|
1184
|
+
file: "src/conflict.rs",
|
|
1185
|
+
from: " } else if has_convergence {\n ConflictClass::Convergent\n } else {",
|
|
1186
|
+
to: " } else if false {\n ConflictClass::Convergent\n } else {",
|
|
1187
|
+
detected: true,
|
|
1188
|
+
why_invisible: "",
|
|
1189
|
+
},
|
|
1190
|
+
// --- the diff the classification is defined over ---------------------
|
|
1191
|
+
Mutation {
|
|
1192
|
+
name: "a null state and a missing member are diffed alike",
|
|
1193
|
+
file: "src/diff.rs",
|
|
1194
|
+
from: " (Json::Null, Json::Null) => {}",
|
|
1195
|
+
to: " (Json::Null, Json::Null) if false => {}",
|
|
1196
|
+
detected: true,
|
|
1197
|
+
why_invisible: "",
|
|
1198
|
+
},
|
|
1199
|
+
Mutation {
|
|
1200
|
+
name: "array indexes are off by one",
|
|
1201
|
+
file: "src/diff.rs",
|
|
1202
|
+
from: " for (index, (old_value, new_value)) in a.iter().zip(b.iter()).enumerate() {\n path.push(PathComponent::Index(index));",
|
|
1203
|
+
to: " for (index, (old_value, new_value)) in a.iter().zip(b.iter()).enumerate() {\n path.push(PathComponent::Index(index + 1));",
|
|
1204
|
+
detected: true,
|
|
1205
|
+
why_invisible: "",
|
|
1206
|
+
},
|
|
1207
|
+
Mutation {
|
|
1208
|
+
name: "integers and floats compare equal",
|
|
1209
|
+
file: "src/diff.rs",
|
|
1210
|
+
from: " (Json::Float(a), Json::Float(b)) if a == b => {}",
|
|
1211
|
+
to: " (Json::Float(a), Json::Float(b)) if a == b => {}\n (Json::PosInt(a), Json::Float(b)) if *a as f64 == *b => {}\n (Json::Float(a), Json::PosInt(b)) if *a == *b as f64 => {}",
|
|
1212
|
+
detected: true,
|
|
1213
|
+
why_invisible: "",
|
|
1214
|
+
},
|
|
1215
|
+
Mutation {
|
|
1216
|
+
name: "unparseable state content is an error rather than JSON null",
|
|
1217
|
+
file: "src/lib.rs",
|
|
1218
|
+
from: " .unwrap_or(json::Json::Null)",
|
|
1219
|
+
to: " .unwrap_or(json::Json::Bool(false))",
|
|
1220
|
+
detected: true,
|
|
1221
|
+
why_invisible: "",
|
|
1222
|
+
},
|
|
1223
|
+
// --- mutations that must NOT be caught -------------------------------
|
|
1224
|
+
Mutation {
|
|
1225
|
+
name: "object additions are diffed in reverse order before sorting",
|
|
1226
|
+
file: "src/diff.rs",
|
|
1227
|
+
from: " for (key, new_value) in b {\n if old.get(key).is_none() {",
|
|
1228
|
+
to: " for (key, new_value) in b.iter().rev() {\n if old.get(key).is_none() {",
|
|
1229
|
+
detected: false,
|
|
1230
|
+
why_invisible: "the diff sorts its own result, so the order additions were emitted in \
|
|
1231
|
+
cannot survive into either diff or into the classification",
|
|
1232
|
+
},
|
|
1233
|
+
Mutation {
|
|
1234
|
+
name: "ancestor paths are searched from the deepest prefix first",
|
|
1235
|
+
file: "src/conflict.rs",
|
|
1236
|
+
from: " for depth in 0..path.len() {",
|
|
1237
|
+
to: " for depth in (0..path.len()).rev() {",
|
|
1238
|
+
detected: false,
|
|
1239
|
+
why_invisible: "the ancestor search only asks whether any prefix is present, so the \
|
|
1240
|
+
order it visits them in cannot change the answer",
|
|
1241
|
+
},
|
|
1242
|
+
Mutation {
|
|
1243
|
+
name: "convergence ignores the change kind",
|
|
1244
|
+
file: "src/conflict.rs",
|
|
1245
|
+
from: " if one.kind == two.kind && optional_equals(&one.new_value, &two.new_value) {",
|
|
1246
|
+
to: " if optional_equals(&one.new_value, &two.new_value) {",
|
|
1247
|
+
detected: false,
|
|
1248
|
+
why_invisible: "the kind comparison is redundant: both diffs share a base, so at a path \
|
|
1249
|
+
both branches touched the kinds match or the new values already differ",
|
|
1250
|
+
},
|
|
1251
|
+
];
|
|
1252
|
+
|
|
1253
|
+
/// Every mutation expected to go uncaught explains itself.
|
|
1254
|
+
#[test]
|
|
1255
|
+
fn uncaught_mutations_record_why_they_are_invisible() {
|
|
1256
|
+
for mutation in MUTATIONS {
|
|
1257
|
+
assert_eq!(
|
|
1258
|
+
mutation.detected,
|
|
1259
|
+
mutation.why_invisible.is_empty(),
|
|
1260
|
+
"mutation {:?}: a mutation is either expected to be caught, or must say why it \
|
|
1261
|
+
cannot be",
|
|
1262
|
+
mutation.name
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/// Every deliberate mutation of the WASM contract lands where it should.
|
|
1268
|
+
///
|
|
1269
|
+
/// Without this the conformance suite proves only that two implementations
|
|
1270
|
+
/// agree, not that agreement was ever in question. Each mutant is built from a
|
|
1271
|
+
/// copy of the real source with one edit applied. Twelve must fail against the
|
|
1272
|
+
/// committed expectations; one must not, because it changes an order the
|
|
1273
|
+
/// contract deliberately does not make semantic.
|
|
1274
|
+
#[test]
|
|
1275
|
+
fn deliberate_wasm_mutations_land_where_they_should() {
|
|
1276
|
+
if contract_module().is_none() {
|
|
1277
|
+
return skipped("deliberate_wasm_mutations_land_where_they_should");
|
|
1278
|
+
}
|
|
1279
|
+
let source = repository_root().join(WASM_CRATE);
|
|
1280
|
+
let scratch = tempfile::tempdir().expect("a scratch directory");
|
|
1281
|
+
|
|
1282
|
+
let mut wrong = Vec::new();
|
|
1283
|
+
for mutation in MUTATIONS {
|
|
1284
|
+
let root = scratch.path().join(mutation.name.replace(' ', "-"));
|
|
1285
|
+
copy_crate(&source, &root);
|
|
1286
|
+
let target = root.join(mutation.file);
|
|
1287
|
+
let original = std::fs::read_to_string(&target).expect("the mutated file exists");
|
|
1288
|
+
assert!(
|
|
1289
|
+
original.matches(mutation.from).count() == 1,
|
|
1290
|
+
"mutation {:?} no longer applies: its anchor is not present exactly once in {}. \
|
|
1291
|
+
The mutation suite must be updated with the implementation it mutates.",
|
|
1292
|
+
mutation.name,
|
|
1293
|
+
mutation.file
|
|
1294
|
+
);
|
|
1295
|
+
std::fs::write(&target, original.replace(mutation.from, mutation.to)).unwrap();
|
|
1296
|
+
|
|
1297
|
+
let mut mutant = WasmContract::instantiate(&build_module(&root));
|
|
1298
|
+
let caught: Vec<&str> = corpus()
|
|
1299
|
+
.cases
|
|
1300
|
+
.iter()
|
|
1301
|
+
.filter(|case| {
|
|
1302
|
+
String::from_utf8_lossy(&mutant.evaluate(&case.bytes())) != case.expected()
|
|
1303
|
+
})
|
|
1304
|
+
.map(|case| case.name.as_str())
|
|
1305
|
+
.collect();
|
|
1306
|
+
|
|
1307
|
+
match (mutation.detected, caught.first()) {
|
|
1308
|
+
(true, Some(first)) => println!(
|
|
1309
|
+
"mutation {:?} caught by {} fixtures, first: {first}",
|
|
1310
|
+
mutation.name,
|
|
1311
|
+
caught.len()
|
|
1312
|
+
),
|
|
1313
|
+
(false, None) => println!(
|
|
1314
|
+
"mutation {:?} correctly NOT caught: {}",
|
|
1315
|
+
mutation.name, mutation.why_invisible
|
|
1316
|
+
),
|
|
1317
|
+
(true, None) => wrong.push(format!(
|
|
1318
|
+
"{:?} was not caught, but the corpus should distinguish it",
|
|
1319
|
+
mutation.name
|
|
1320
|
+
)),
|
|
1321
|
+
(false, Some(_)) => wrong.push(format!(
|
|
1322
|
+
"{:?} was caught by {} fixtures, but it was expected to be invisible because {}. \
|
|
1323
|
+
Either the implementation or that reasoning has changed, and both the mutation \
|
|
1324
|
+
expectation and the contract documentation need revisiting.",
|
|
1325
|
+
mutation.name,
|
|
1326
|
+
caught.len(),
|
|
1327
|
+
mutation.why_invisible
|
|
1328
|
+
)),
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
assert!(
|
|
1332
|
+
wrong.is_empty(),
|
|
1333
|
+
"mutation outcomes were wrong:\n{}",
|
|
1334
|
+
wrong.join("\n")
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/// Copies the contract crate's source, without its build directory.
|
|
1339
|
+
fn copy_crate(from: &Path, to: &Path) {
|
|
1340
|
+
std::fs::create_dir_all(to.join("src")).unwrap();
|
|
1341
|
+
std::fs::copy(from.join("Cargo.toml"), to.join("Cargo.toml")).unwrap();
|
|
1342
|
+
for entry in std::fs::read_dir(from.join("src")).unwrap() {
|
|
1343
|
+
let entry = entry.unwrap();
|
|
1344
|
+
std::fs::copy(entry.path(), to.join("src").join(entry.file_name())).unwrap();
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/// Mutating the input encoding is detected where the encoding is semantically
|
|
1349
|
+
/// load-bearing, and not where it is not.
|
|
1350
|
+
///
|
|
1351
|
+
/// - Reordering members changes nothing, because a JSON object denotes a map
|
|
1352
|
+
/// and both implementations decode it to one. The suite must *not* flag it.
|
|
1353
|
+
/// - Swapping the two states changes the diff's direction, which is entirely
|
|
1354
|
+
/// semantic. The suite must flag it.
|
|
1355
|
+
/// - Dropping the contract identity turns every case into an input error.
|
|
1356
|
+
#[test]
|
|
1357
|
+
fn input_encoding_mutations_are_detected_where_they_matter() {
|
|
1358
|
+
let typed: Vec<(&Case, Value)> = corpus()
|
|
1359
|
+
.cases
|
|
1360
|
+
.iter()
|
|
1361
|
+
.filter_map(|case| {
|
|
1362
|
+
let input = case.input.as_ref()?;
|
|
1363
|
+
Some((case, serde_json::to_value(input).unwrap()))
|
|
1364
|
+
})
|
|
1365
|
+
.collect();
|
|
1366
|
+
assert!(typed.len() > 70, "most of the corpus is typed input");
|
|
1367
|
+
|
|
1368
|
+
// 1. Member order, which carries no meaning.
|
|
1369
|
+
for (case, encoded) in &typed {
|
|
1370
|
+
let produced =
|
|
1371
|
+
String::from_utf8(evaluate_canonical(encode_reversed(encoded).as_bytes())).unwrap();
|
|
1372
|
+
assert_eq!(
|
|
1373
|
+
produced,
|
|
1374
|
+
case.expected(),
|
|
1375
|
+
"{} changed answer under a reordered encoding; object member order is not part of \
|
|
1376
|
+
this contract's semantics",
|
|
1377
|
+
case.name
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// 2. Which state is the base, which carries all of it.
|
|
1382
|
+
let mut base_matters = 0;
|
|
1383
|
+
for (case, encoded) in &typed {
|
|
1384
|
+
let mut rotated = encoded.clone();
|
|
1385
|
+
let object = rotated.as_object_mut().unwrap();
|
|
1386
|
+
let base = object["base"].clone();
|
|
1387
|
+
let left = object["left"].clone();
|
|
1388
|
+
object["base"] = left;
|
|
1389
|
+
object["left"] = base;
|
|
1390
|
+
let produced = String::from_utf8(evaluate_canonical(
|
|
1391
|
+
serde_json::to_vec(&rotated).unwrap().as_slice(),
|
|
1392
|
+
))
|
|
1393
|
+
.unwrap();
|
|
1394
|
+
if produced != case.expected() {
|
|
1395
|
+
base_matters += 1;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
assert!(
|
|
1399
|
+
base_matters > 40,
|
|
1400
|
+
"the ancestor is not interchangeable with a branch; exchanging them should change most \
|
|
1401
|
+
results, but it changed only {base_matters}"
|
|
1402
|
+
);
|
|
1403
|
+
|
|
1404
|
+
// 3. Contract identity dropped.
|
|
1405
|
+
for (case, encoded) in typed.iter().take(1) {
|
|
1406
|
+
let mut mutated = encoded.clone();
|
|
1407
|
+
mutated.as_object_mut().unwrap().remove("contract");
|
|
1408
|
+
let produced = String::from_utf8(evaluate_canonical(
|
|
1409
|
+
serde_json::to_vec(&mutated).unwrap().as_slice(),
|
|
1410
|
+
))
|
|
1411
|
+
.unwrap();
|
|
1412
|
+
assert_ne!(produced, case.expected());
|
|
1413
|
+
assert!(produced.contains("CONTRACT_INPUT_INVALID"));
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/// Encodes a value with every object's members in reverse sorted order.
|
|
1418
|
+
///
|
|
1419
|
+
/// `serde_json::Map` re-sorts on insert, so a non-canonical member order has to
|
|
1420
|
+
/// be produced as text rather than as a value.
|
|
1421
|
+
fn encode_reversed(value: &Value) -> String {
|
|
1422
|
+
match value {
|
|
1423
|
+
Value::Object(members) => {
|
|
1424
|
+
let mut parts: Vec<String> = members
|
|
1425
|
+
.iter()
|
|
1426
|
+
.map(|(key, value)| {
|
|
1427
|
+
format!(
|
|
1428
|
+
"{}:{}",
|
|
1429
|
+
serde_json::to_string(key).unwrap(),
|
|
1430
|
+
encode_reversed(value)
|
|
1431
|
+
)
|
|
1432
|
+
})
|
|
1433
|
+
.collect();
|
|
1434
|
+
parts.reverse();
|
|
1435
|
+
format!("{{{}}}", parts.join(","))
|
|
1436
|
+
}
|
|
1437
|
+
Value::Array(items) => format!(
|
|
1438
|
+
"[{}]",
|
|
1439
|
+
items
|
|
1440
|
+
.iter()
|
|
1441
|
+
.map(encode_reversed)
|
|
1442
|
+
.collect::<Vec<_>>()
|
|
1443
|
+
.join(",")
|
|
1444
|
+
),
|
|
1445
|
+
other => serde_json::to_string(other).unwrap(),
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// ---------------------------------------------------------------------------
|
|
1450
|
+
// Semantic invariants
|
|
1451
|
+
// ---------------------------------------------------------------------------
|
|
1452
|
+
|
|
1453
|
+
/// The three states of every typed fixture, for invariant checks.
|
|
1454
|
+
fn triples() -> Vec<(&'static str, String, String, String)> {
|
|
1455
|
+
corpus()
|
|
1456
|
+
.cases
|
|
1457
|
+
.iter()
|
|
1458
|
+
.filter_map(|case| {
|
|
1459
|
+
let input = case.input.as_ref()?;
|
|
1460
|
+
Some((
|
|
1461
|
+
Box::leak(case.name.clone().into_boxed_str()) as &str,
|
|
1462
|
+
input.base.clone(),
|
|
1463
|
+
input.left.clone(),
|
|
1464
|
+
input.right.clone(),
|
|
1465
|
+
))
|
|
1466
|
+
})
|
|
1467
|
+
.collect()
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
fn classify(base: &str, left: &str, right: &str) -> ConflictClassification {
|
|
1471
|
+
feltdb::state_conflict_contract::classify_states(base, left, right)
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/// The relation's own laws, over every state in the corpus.
|
|
1475
|
+
///
|
|
1476
|
+
/// Conformance proves two implementations agree. These prove the thing they
|
|
1477
|
+
/// agree on is the relation the contract describes, which is a different claim
|
|
1478
|
+
/// and the one that would survive replacing both implementations.
|
|
1479
|
+
#[test]
|
|
1480
|
+
fn classification_invariants_hold() {
|
|
1481
|
+
for (name, base, left, right) in triples() {
|
|
1482
|
+
// Nothing changed anywhere: no paths, and Independent overall. There is
|
|
1483
|
+
// no fourth "no change" class.
|
|
1484
|
+
for state in [&base, &left, &right] {
|
|
1485
|
+
let same = classify(state, state, state);
|
|
1486
|
+
assert!(
|
|
1487
|
+
same.path_conflicts.is_empty(),
|
|
1488
|
+
"{name}: identical states must produce no paths"
|
|
1489
|
+
);
|
|
1490
|
+
assert_eq!(
|
|
1491
|
+
same.overall,
|
|
1492
|
+
ConflictClass::Independent,
|
|
1493
|
+
"{name}: identical states are Independent"
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// One side unchanged: every path is Independent, both ways round.
|
|
1498
|
+
for (one, two) in [(&left, &base), (&base, &right)] {
|
|
1499
|
+
let single = classify(&base, one, two);
|
|
1500
|
+
assert!(
|
|
1501
|
+
single
|
|
1502
|
+
.path_conflicts
|
|
1503
|
+
.iter()
|
|
1504
|
+
.all(|entry| entry.classification == ConflictClass::Independent),
|
|
1505
|
+
"{name}: a one-sided change cannot collide with anything"
|
|
1506
|
+
);
|
|
1507
|
+
assert_eq!(single.overall, ConflictClass::Independent, "{name}");
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// Both sides landing on the same state: every path converges, and the
|
|
1511
|
+
// overall class is Convergent unless nothing changed at all.
|
|
1512
|
+
for state in [&left, &right] {
|
|
1513
|
+
let agreeing = classify(&base, state, state);
|
|
1514
|
+
assert!(
|
|
1515
|
+
agreeing
|
|
1516
|
+
.path_conflicts
|
|
1517
|
+
.iter()
|
|
1518
|
+
.all(|entry| entry.classification == ConflictClass::Convergent),
|
|
1519
|
+
"{name}: agreeing branches converge at every path"
|
|
1520
|
+
);
|
|
1521
|
+
let expected = if agreeing.path_conflicts.is_empty() {
|
|
1522
|
+
ConflictClass::Independent
|
|
1523
|
+
} else {
|
|
1524
|
+
ConflictClass::Convergent
|
|
1525
|
+
};
|
|
1526
|
+
assert_eq!(agreeing.overall, expected, "{name}");
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// `overall` is the maximum severity present, never anything else.
|
|
1530
|
+
let full = classify(&base, &left, &right);
|
|
1531
|
+
let expected = if full
|
|
1532
|
+
.path_conflicts
|
|
1533
|
+
.iter()
|
|
1534
|
+
.any(|entry| entry.classification == ConflictClass::Conflict)
|
|
1535
|
+
{
|
|
1536
|
+
ConflictClass::Conflict
|
|
1537
|
+
} else if full
|
|
1538
|
+
.path_conflicts
|
|
1539
|
+
.iter()
|
|
1540
|
+
.any(|entry| entry.classification == ConflictClass::Convergent)
|
|
1541
|
+
{
|
|
1542
|
+
ConflictClass::Convergent
|
|
1543
|
+
} else {
|
|
1544
|
+
ConflictClass::Independent
|
|
1545
|
+
};
|
|
1546
|
+
assert_eq!(
|
|
1547
|
+
full.overall, expected,
|
|
1548
|
+
"{name}: overall must be the maximum severity present"
|
|
1549
|
+
);
|
|
1550
|
+
|
|
1551
|
+
// Paths are unique and sorted, which is what makes the ordering total.
|
|
1552
|
+
let paths: Vec<_> = full
|
|
1553
|
+
.path_conflicts
|
|
1554
|
+
.iter()
|
|
1555
|
+
.map(|entry| entry.path.clone())
|
|
1556
|
+
.collect();
|
|
1557
|
+
let mut sorted = paths.clone();
|
|
1558
|
+
sorted.sort();
|
|
1559
|
+
assert_eq!(paths, sorted, "{name}: paths are emitted in sorted order");
|
|
1560
|
+
sorted.dedup();
|
|
1561
|
+
assert_eq!(paths.len(), sorted.len(), "{name}: paths are unique");
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/// At a path both branches touched, the change kinds match or the new values
|
|
1566
|
+
/// already differ.
|
|
1567
|
+
///
|
|
1568
|
+
/// This is why `one.kind == two.kind` can never decide convergence, and so why
|
|
1569
|
+
/// relabelling a change kind is invisible to this contract. Both diffs are taken
|
|
1570
|
+
/// from the same base, so whether a path resolves in the base — which is what
|
|
1571
|
+
/// separates `Added` from `Changed` and `Removed` — is a property of the base
|
|
1572
|
+
/// alone and cannot differ between them. The remaining pair, `Removed` against
|
|
1573
|
+
/// `Changed`, always disagrees on the new value, because a removal has none.
|
|
1574
|
+
///
|
|
1575
|
+
/// Asserted over the corpus rather than argued alone: if the diff ever produces
|
|
1576
|
+
/// a counterexample, the kind comparison becomes load-bearing and the mutation
|
|
1577
|
+
/// expectations recorded above must change with it.
|
|
1578
|
+
#[test]
|
|
1579
|
+
fn the_change_kind_comparison_is_never_decisive() {
|
|
1580
|
+
use feltdb::state_model::SemanticDiff;
|
|
1581
|
+
|
|
1582
|
+
let mut both_touched = 0;
|
|
1583
|
+
for (name, base, left, right) in triples() {
|
|
1584
|
+
let base_value: Value = serde_json::from_str(&base).unwrap_or(Value::Null);
|
|
1585
|
+
let left_value: Value = serde_json::from_str(&left).unwrap_or(Value::Null);
|
|
1586
|
+
let right_value: Value = serde_json::from_str(&right).unwrap_or(Value::Null);
|
|
1587
|
+
let left_diff = SemanticDiff::compute(&base_value, &left_value);
|
|
1588
|
+
let right_diff = SemanticDiff::compute(&base_value, &right_value);
|
|
1589
|
+
|
|
1590
|
+
for one in &left_diff.changes {
|
|
1591
|
+
let Some(two) = right_diff
|
|
1592
|
+
.changes
|
|
1593
|
+
.iter()
|
|
1594
|
+
.find(|other| other.path == one.path)
|
|
1595
|
+
else {
|
|
1596
|
+
continue;
|
|
1597
|
+
};
|
|
1598
|
+
both_touched += 1;
|
|
1599
|
+
assert!(
|
|
1600
|
+
one.kind == two.kind || one.new_value != two.new_value,
|
|
1601
|
+
"{name}: the kind comparison decided the outcome at {:?}, which the contract \
|
|
1602
|
+
documentation says cannot happen",
|
|
1603
|
+
one.path
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
assert!(
|
|
1608
|
+
both_touched > 30,
|
|
1609
|
+
"the corpus must actually exercise paths both branches touched"
|
|
1610
|
+
);
|
|
1611
|
+
println!("{both_touched} paths were touched by both branches");
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/// Swapping the branches has the documented relationship.
|
|
1615
|
+
///
|
|
1616
|
+
/// `overall`, the path set and each path's `classification` are symmetric, and
|
|
1617
|
+
/// `left_value` and `right_value` exchange. `base_value` is now **also**
|
|
1618
|
+
/// symmetric, because it is read from the base state rather than from the left
|
|
1619
|
+
/// branch's change: version 1 reported it as `null` for a path only the right
|
|
1620
|
+
/// branch touched, which made the result side-dependent in a way nothing
|
|
1621
|
+
/// wanted. The only side-specific information left is the branch values, which
|
|
1622
|
+
/// is the information that ought to be side-specific.
|
|
1623
|
+
#[test]
|
|
1624
|
+
fn swapping_the_branches_exchanges_only_the_branch_values() {
|
|
1625
|
+
let mut exchanged = 0;
|
|
1626
|
+
for (name, base, left, right) in triples() {
|
|
1627
|
+
let forward = classify(&base, &left, &right);
|
|
1628
|
+
let reversed = classify(&base, &right, &left);
|
|
1629
|
+
|
|
1630
|
+
assert_eq!(
|
|
1631
|
+
forward.overall, reversed.overall,
|
|
1632
|
+
"{name}: the overall class is symmetric"
|
|
1633
|
+
);
|
|
1634
|
+
assert_eq!(
|
|
1635
|
+
forward.path_conflicts.len(),
|
|
1636
|
+
reversed.path_conflicts.len(),
|
|
1637
|
+
"{name}: the same paths are reported either way round"
|
|
1638
|
+
);
|
|
1639
|
+
for (one, two) in forward.path_conflicts.iter().zip(&reversed.path_conflicts) {
|
|
1640
|
+
assert_eq!(one.path, two.path, "{name}: paths are symmetric");
|
|
1641
|
+
assert_eq!(
|
|
1642
|
+
one.classification, two.classification,
|
|
1643
|
+
"{name}: per-path classification is symmetric"
|
|
1644
|
+
);
|
|
1645
|
+
assert_eq!(
|
|
1646
|
+
one.base_value, two.base_value,
|
|
1647
|
+
"{name}: base_value is the ancestor's value, so it does not depend on which \
|
|
1648
|
+
branch is called left"
|
|
1649
|
+
);
|
|
1650
|
+
assert_eq!(
|
|
1651
|
+
one.left_value, two.right_value,
|
|
1652
|
+
"{name}: the branches exchange sides"
|
|
1653
|
+
);
|
|
1654
|
+
assert_eq!(
|
|
1655
|
+
one.right_value, two.left_value,
|
|
1656
|
+
"{name}: the branches exchange sides"
|
|
1657
|
+
);
|
|
1658
|
+
if one.left_value != one.right_value {
|
|
1659
|
+
exchanged += 1;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
assert!(
|
|
1664
|
+
exchanged > 40,
|
|
1665
|
+
"the corpus must exercise paths where the two branches hold different values, or the \
|
|
1666
|
+
exchange assertion above proves nothing"
|
|
1667
|
+
);
|
|
1668
|
+
println!("{exchanged} paths held different values on the two branches");
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/// An overlapping pair can never be convergent, and the corpus says so.
|
|
1672
|
+
///
|
|
1673
|
+
/// For a descendant path to exist the base must hold a container there. A change
|
|
1674
|
+
/// reported *at* the ancestor path means that branch no longer holds a container
|
|
1675
|
+
/// of that kind, while the branch editing inside it still does — so the two
|
|
1676
|
+
/// branches' values at the ancestor path always differ. This asserts the
|
|
1677
|
+
/// premise directly rather than trusting the argument.
|
|
1678
|
+
#[test]
|
|
1679
|
+
fn an_overlapping_pair_never_agrees_at_the_ancestor_path() {
|
|
1680
|
+
use feltdb::state_model::{path_relation, resolve_path, PathRelation, SemanticDiff};
|
|
1681
|
+
|
|
1682
|
+
let mut overlaps = 0;
|
|
1683
|
+
for (name, base, left, right) in triples() {
|
|
1684
|
+
let base_value: Value = serde_json::from_str(&base).unwrap_or(Value::Null);
|
|
1685
|
+
let left_value: Value = serde_json::from_str(&left).unwrap_or(Value::Null);
|
|
1686
|
+
let right_value: Value = serde_json::from_str(&right).unwrap_or(Value::Null);
|
|
1687
|
+
let left_diff = SemanticDiff::compute(&base_value, &left_value);
|
|
1688
|
+
let right_diff = SemanticDiff::compute(&base_value, &right_value);
|
|
1689
|
+
|
|
1690
|
+
for one in &left_diff.changes {
|
|
1691
|
+
for two in &right_diff.changes {
|
|
1692
|
+
let ancestor = match path_relation(&one.path, &two.path) {
|
|
1693
|
+
PathRelation::Ancestor => &one.path,
|
|
1694
|
+
PathRelation::Descendant => &two.path,
|
|
1695
|
+
_ => continue,
|
|
1696
|
+
};
|
|
1697
|
+
overlaps += 1;
|
|
1698
|
+
assert_ne!(
|
|
1699
|
+
resolve_path(&left_value, ancestor),
|
|
1700
|
+
resolve_path(&right_value, ancestor),
|
|
1701
|
+
"{name}: the branches agree at an overlapping ancestor path {ancestor:?}, \
|
|
1702
|
+
which the contract documentation says cannot happen"
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
assert!(
|
|
1708
|
+
overlaps > 20,
|
|
1709
|
+
"the corpus must actually exercise overlapping path pairs"
|
|
1710
|
+
);
|
|
1711
|
+
println!("{overlaps} overlapping path pairs were exercised");
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/// Every entry's `base_value` is the base state's value at that path.
|
|
1715
|
+
///
|
|
1716
|
+
/// Version 1 read it from the left branch's change, so a path only the right
|
|
1717
|
+
/// branch touched reported `null` even where the base held a value. Downstream
|
|
1718
|
+
/// reconciliation needs the ancestor's value at a conflicting path to decide
|
|
1719
|
+
/// anything, so this is corrected rather than documented as a limitation.
|
|
1720
|
+
#[test]
|
|
1721
|
+
fn base_value_is_the_base_states_value_at_the_path() {
|
|
1722
|
+
use feltdb::state_model::resolve_path;
|
|
1723
|
+
|
|
1724
|
+
let mut right_only_with_a_base_value = 0;
|
|
1725
|
+
for (name, base, left, right) in triples() {
|
|
1726
|
+
let base_value: Value = serde_json::from_str(&base).unwrap_or(Value::Null);
|
|
1727
|
+
let output = classify(&base, &left, &right);
|
|
1728
|
+
for entry in &output.path_conflicts {
|
|
1729
|
+
assert_eq!(
|
|
1730
|
+
entry.base_value.as_ref(),
|
|
1731
|
+
resolve_path(&base_value, &entry.path),
|
|
1732
|
+
"{name}: base_value must be the base state's value at {:?}",
|
|
1733
|
+
entry.path
|
|
1734
|
+
);
|
|
1735
|
+
if entry.left_value.is_none()
|
|
1736
|
+
&& entry.right_value.is_some()
|
|
1737
|
+
&& entry.base_value.is_some()
|
|
1738
|
+
{
|
|
1739
|
+
right_only_with_a_base_value += 1;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
assert!(
|
|
1744
|
+
right_only_with_a_base_value > 0,
|
|
1745
|
+
"the corpus must exercise a right-only change over a base value, which is exactly the \
|
|
1746
|
+
case version 1 reported as null"
|
|
1747
|
+
);
|
|
1748
|
+
println!("{right_only_with_a_base_value} right-only entries carry a base value version 1 lost");
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// ---------------------------------------------------------------------------
|
|
1752
|
+
// Corpus maintenance
|
|
1753
|
+
// ---------------------------------------------------------------------------
|
|
1754
|
+
|
|
1755
|
+
/// Records the canonical output of every case, after both implementations agree.
|
|
1756
|
+
///
|
|
1757
|
+
/// Ignored by default: it writes the corpus. Run it deliberately with
|
|
1758
|
+
/// `FELTDB_STATE_CONFLICT_CONTRACT_BLESS=1 cargo test -p feltdb --test
|
|
1759
|
+
/// state_conflict_contract_conformance -- bless --ignored`. It refuses to record
|
|
1760
|
+
/// anything unless the WASM implementation is available and agrees, so an
|
|
1761
|
+
/// expectation can never be blessed from one implementation alone.
|
|
1762
|
+
#[test]
|
|
1763
|
+
#[ignore = "rewrites the committed corpus"]
|
|
1764
|
+
fn bless() {
|
|
1765
|
+
assert_eq!(
|
|
1766
|
+
std::env::var("FELTDB_STATE_CONFLICT_CONTRACT_BLESS").as_deref(),
|
|
1767
|
+
Ok("1"),
|
|
1768
|
+
"set FELTDB_STATE_CONFLICT_CONTRACT_BLESS=1 to rewrite the corpus"
|
|
1769
|
+
);
|
|
1770
|
+
let bytes = contract_module().expect(
|
|
1771
|
+
"blessing requires the WASM implementation: an expectation recorded from the native \
|
|
1772
|
+
implementation alone would prove nothing",
|
|
1773
|
+
);
|
|
1774
|
+
let mut wasm = WasmContract::instantiate(bytes);
|
|
1775
|
+
|
|
1776
|
+
let mut document: Value =
|
|
1777
|
+
serde_json::from_slice(&std::fs::read(corpus_path()).unwrap()).unwrap();
|
|
1778
|
+
for (index, case) in corpus().cases.iter().enumerate() {
|
|
1779
|
+
let input = case.bytes();
|
|
1780
|
+
let native = evaluate_canonical(&input);
|
|
1781
|
+
let produced = wasm.evaluate(&input);
|
|
1782
|
+
assert_eq!(
|
|
1783
|
+
String::from_utf8_lossy(&native),
|
|
1784
|
+
String::from_utf8_lossy(&produced),
|
|
1785
|
+
"refusing to record {}: the implementations disagree",
|
|
1786
|
+
case.name
|
|
1787
|
+
);
|
|
1788
|
+
if case.input.is_some() {
|
|
1789
|
+
document["cases"][index]["canonical_input"] =
|
|
1790
|
+
Value::String(String::from_utf8(input).unwrap());
|
|
1791
|
+
}
|
|
1792
|
+
document["cases"][index]["expected_output"] =
|
|
1793
|
+
Value::String(String::from_utf8(native).unwrap());
|
|
1794
|
+
}
|
|
1795
|
+
let mut text = serde_json::to_string_pretty(&document).unwrap();
|
|
1796
|
+
text.push('\n');
|
|
1797
|
+
std::fs::write(corpus_path(), text).unwrap();
|
|
1798
|
+
println!("recorded {} expectations", corpus().cases.len());
|
|
1799
|
+
}
|