hecks 3.6.0 → 3.7.0

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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  3. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  5. data/lib/hecks/cache_dir.rb +10 -0
  6. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  7. data/lib/hecks/deploy/oidc.json +10 -0
  8. data/lib/hecks/doors/usage_cache.rb +19 -9
  9. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  10. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  11. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  12. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  13. data/lib/hecks/fuzzing/form_census.rb +28 -52
  14. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  15. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  16. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  17. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  18. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  19. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  20. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  21. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  22. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  23. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  24. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  25. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  26. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  27. data/lib/hecks/fuzzing/mutation.rb +12 -0
  28. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  29. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  30. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  31. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  32. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  33. data/lib/hecks/fuzzing/properties.rb +7 -0
  34. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  35. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  36. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  37. data/lib/hecks/fuzzing/replay.rb +1 -0
  38. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  39. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  40. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  41. data/lib/hecks/fuzzing.rb +3 -0
  42. data/lib/hecks/gate/stages.yml +1 -1
  43. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  44. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  45. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  46. data/lib/hecks/hecks/custodian.behaviors +6 -0
  47. data/lib/hecks/hecks/custodian.bluebook +37 -0
  48. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +3 -0
  50. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  51. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  52. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  53. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  54. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  55. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  56. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  57. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  58. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  59. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  60. data/lib/hecks/projections/deploy/box.rb +14 -2
  61. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  62. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  63. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  64. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  65. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  66. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  67. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  68. data/lib/hecks/projections.rb +2 -0
  69. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  70. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  71. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  72. data/lib/hecks/rust_build/native_build.rb +29 -1
  73. data/lib/hecks/rust_build/wasm.rb +16 -3
  74. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  75. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  76. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  77. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  78. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  79. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  80. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  81. data/lib/hecks/tools/lanes/files.rb +2 -4
  82. data/lib/hecks/tools/lanes/live.rb +3 -4
  83. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  84. data/lib/hecks/tools/lanes.rb +12 -1
  85. data/lib/hecks/tools/mutation_run.rb +73 -0
  86. data/lib/hecks/tools.rb +1 -0
  87. data/lib/hecks/version.rb +1 -1
  88. data/lib/hecks/vocabulary.rb +2 -0
  89. data/prebuilt/945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal +0 -0
  90. data/prebuilt/a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal +0 -0
  91. data/prebuilt/verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json +1 -0
  92. data/qa/settings.yml +1 -0
  93. data/rust/host/Cargo.toml +2 -2
  94. data/rust/host/HECKS_RELEASE +1 -1
  95. data/rust/host/src/api.rs +28 -5
  96. data/rust/host/src/auth.rs +11 -1
  97. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  98. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  99. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  100. data/rust/host/src/boundary_fuzz/ir.rs +78 -0
  101. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  102. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  103. data/rust/host/src/fuzz_support.rs +165 -0
  104. data/rust/host/src/ir.rs +4 -0
  105. data/rust/host/src/main.rs +9 -4
  106. data/rust/host/src/secrets.rs +44 -0
  107. data/rust/host/src/server.rs +6 -1
  108. data/rust/host/src/web.rs +12 -2
  109. data/rust/parser/fuzz/.gitignore +4 -0
  110. data/rust/parser/fuzz/Cargo.lock +89 -0
  111. data/rust/parser/fuzz/Cargo.toml +27 -0
  112. data/rust/parser/fuzz/README.md +19 -0
  113. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  114. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  115. data/rust/parser/src/parse/command.rs +14 -2
  116. data/rust/parser/src/parse/mod.rs +25 -0
  117. data/rust/parser/src/ruby_value.rs +95 -6
  118. data/rust/src/kernel/json.rs +34 -3
  119. metadata +50 -1
data/qa/settings.yml CHANGED
@@ -22,6 +22,7 @@ adversarial_fraction: 0.3
22
22
  guided_generation: true
23
23
  corpus_splice_probability: 0.0
24
24
  favor_rare_verbs: 3
25
+ runtime_coverage_feedback: false
25
26
  self_consistency_checks: true
26
27
  shrink_budget: 200
27
28
 
data/rust/host/Cargo.toml CHANGED
@@ -61,6 +61,6 @@ async-trait = "0.1.92"
61
61
  debug = 0
62
62
 
63
63
  # The tests compile wasm modules through wasmtime's Cranelift, which is far slower unoptimized.
64
- # Optimize dependencies only; the host crate itself stays at opt-level 0.
65
- [profile.dev.package."*"]
64
+ # Optimize dependencies only, and only for cargo test; the host crate and cargo build stay at opt-level 0.
65
+ [profile.test.package."*"]
66
66
  opt-level = 2
@@ -1 +1 @@
1
- 3.6.0
1
+ 3.7.0
data/rust/host/src/api.rs CHANGED
@@ -479,8 +479,13 @@ fn apply_identity(presentation: &Value, aggregate: &Value, args: &mut Value, exi
479
479
  }
480
480
  Some("sequence") => {
481
481
  let prefix = rule.get("prefix").and_then(|v| v.as_str()).unwrap_or("");
482
- let pad = rule.get("pad").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
483
- Some(format!("{prefix}{:0>pad$}", existing + 1))
482
+ let pad = rule.get("pad").and_then(|v| v.as_u64()).unwrap_or(3);
483
+ // `pad` rides in as free-form config JSON, and a width like 10^15 would have the
484
+ // formatter allocate that many bytes: refuse it rather than abort the process.
485
+ if pad > MAX_IDENTITY_PAD {
486
+ return Err(internal_error(&format!("the identity pad {pad} for {name} is wider than the {MAX_IDENTITY_PAD} this host will mint")));
487
+ }
488
+ Some(format!("{prefix}{:0>pad$}", existing + 1, pad = pad as usize))
484
489
  }
485
490
  // `port` delegates to a Ruby adapter this host has no runtime for
486
491
  // (ADR 0007); refusing names the real reason rather than
@@ -499,6 +504,9 @@ fn apply_identity(presentation: &Value, aggregate: &Value, args: &mut Value, exi
499
504
  Ok(())
500
505
  }
501
506
 
507
+ /// The widest zero-padded `sequence` identity `apply_identity` will mint.
508
+ const MAX_IDENTITY_PAD: u64 = 64;
509
+
502
510
  /// The wire key an identity value has to be wrapped in — whatever the
503
511
  /// target value object's own single attribute is actually called. This
504
512
  /// domain always spells it "value"; nothing here assumes that.
@@ -727,10 +735,21 @@ fn compare_sort_keys(left: Option<&Value>, right: Option<&Value>, descending: bo
727
735
  }
728
736
  }
729
737
 
738
+ /// Numbers, then strings, then anything else (all equal, so the id breaks the tie). A column can
739
+ /// hold mixed types, and `sort_by` panics on a comparison that is not a total order: comparing a
740
+ /// number with a string as `""` made `5`, `""` and `3` each equal to a neighbour but not to each other.
730
741
  fn compare_values(left: &Value, right: &Value) -> Ordering {
731
- match (left.as_f64(), right.as_f64()) {
732
- (Some(left), Some(right)) => left.partial_cmp(&right).unwrap_or(Ordering::Equal),
733
- _ => left.as_str().unwrap_or("").cmp(right.as_str().unwrap_or("")),
742
+ fn rank(value: &Value) -> u8 {
743
+ match value {
744
+ Value::Number(_) => 0,
745
+ Value::String(_) => 1,
746
+ _ => 2,
747
+ }
748
+ }
749
+ match (left, right) {
750
+ (Value::Number(a), Value::Number(b)) => a.as_f64().unwrap_or(0.0).total_cmp(&b.as_f64().unwrap_or(0.0)),
751
+ (Value::String(a), Value::String(b)) => a.cmp(b),
752
+ _ => rank(left).cmp(&rank(right)),
734
753
  }
735
754
  }
736
755
 
@@ -1594,3 +1613,7 @@ mod tests {
1594
1613
  );
1595
1614
  }
1596
1615
  }
1616
+
1617
+ #[cfg(test)]
1618
+ #[path = "boundary_fuzz/api.rs"]
1619
+ mod boundary_fuzz;
@@ -882,13 +882,19 @@ fn sign(secret: &str, payload: &str) -> String {
882
882
 
883
883
  fn verify_sig(secret: &str, token: &str) -> Option<String> {
884
884
  let (payload, sig) = token.rsplit_once('.')?;
885
- if sign(secret, payload) == sig {
885
+ if constant_time_eq(sign(secret, payload).as_bytes(), sig.as_bytes()) {
886
886
  Some(payload.to_string())
887
887
  } else {
888
888
  None
889
889
  }
890
890
  }
891
891
 
892
+ /// Compares two signatures without stopping at the first differing byte, so how long a forged
893
+ /// cookie takes to refuse does not tell the caller how many leading characters were right.
894
+ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
895
+ a.len() == b.len() && a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
896
+ }
897
+
892
898
  fn hex_encode(bytes: &[u8]) -> String {
893
899
  bytes.iter().map(|b| format!("{b:02x}")).collect()
894
900
  }
@@ -1341,3 +1347,7 @@ mod tests {
1341
1347
  assert_eq!(state["role"]["value"], "Admin", "a lower ordinal must never move the snapshot backward");
1342
1348
  }
1343
1349
  }
1350
+
1351
+ #[cfg(test)]
1352
+ #[path = "boundary_fuzz/auth.rs"]
1353
+ mod boundary_fuzz;
@@ -0,0 +1,260 @@
1
+ //! Seeded fuzz of the `/api` request-shaping helpers that run on caller-controlled input before
2
+ //! anything reaches the database: body parsing, identity minting, preconditions, sorting and
3
+ //! query-argument shaping. Each must refuse bad input with a structured answer, never panic, and
4
+ //! leave the arguments it was given untouched when it refuses.
5
+
6
+ use super::*;
7
+ use crate::fuzz_support::*;
8
+
9
+ fn envelope_is_structured(response: &Value) {
10
+ let status = response["statusCode"].as_u64().expect("a refusal carries its status");
11
+ assert!((400..600).contains(&status), "a refusal is a 4xx/5xx, got {status}");
12
+ assert_eq!(response["headers"]["content-type"], "application/json");
13
+ let body: Value = serde_json::from_str(response["body"].as_str().expect("a JSON body")).expect("the body is JSON");
14
+ assert!(body["error"].is_string() && body["message"].is_string(), "the refusal names itself: {body}");
15
+ }
16
+
17
+ fn client_aggregate() -> Value {
18
+ json!({
19
+ "name": "Client",
20
+ "identified_by": ["reference.value"],
21
+ "attributes": [
22
+ {"name": "reference", "type": "ClientReference", "list": false, "optional": false},
23
+ {"name": "name", "type": "ClientName", "list": false, "optional": false},
24
+ {"name": "owner", "type": "Reference<Member>", "list": false, "optional": true},
25
+ {"name": "status", "type": "String", "list": false, "optional": true}
26
+ ],
27
+ "value_objects": [
28
+ {"name": "ClientReference", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
29
+ {"name": "ClientName", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []}
30
+ ],
31
+ "entities": [], "commands": [], "queries": [], "lifecycle": {"field": "status"}
32
+ })
33
+ }
34
+
35
+ #[test]
36
+ fn parsed_body_refuses_everything_that_is_not_an_object_with_a_structured_400() {
37
+ for seed in 0..seeds() {
38
+ guarded(&format!("seed {seed}"), move || {
39
+ let mut rng = Rng::new(seed);
40
+ for _ in 0..50 {
41
+ let text = match rng.below(4) {
42
+ 0 => random_text(&mut rng),
43
+ 1 => {
44
+ let valid = random_value(&mut rng, 0).to_string();
45
+ mutate_text(&mut rng, &valid)
46
+ }
47
+ 2 => random_value(&mut rng, 0).to_string(),
48
+ _ => format!("{}{}", " \t\n".repeat(rng.below(3)), random_value(&mut rng, 1)),
49
+ };
50
+ match parsed_body(&text) {
51
+ Ok(body) => {
52
+ assert!(body.is_object(), "seed {seed}: {text:?} parsed to a non-object");
53
+ assert!(text.trim().is_empty() || serde_json::from_str::<Value>(&text).is_ok(), "seed {seed}: {text:?} is not JSON yet was accepted");
54
+ }
55
+ Err(refusal) => {
56
+ assert_eq!(refusal["statusCode"], 400, "seed {seed}: {text:?}");
57
+ envelope_is_structured(&refusal);
58
+ }
59
+ }
60
+ }
61
+ });
62
+ }
63
+ }
64
+
65
+ #[test]
66
+ fn parsed_body_survives_depth_width_and_size() {
67
+ guarded("depth and size", || {
68
+ for text in [
69
+ "[".repeat(1_000_000),
70
+ format!("{}1{}", "[".repeat(100_000), "]".repeat(100_000)),
71
+ format!("{}1{}", "{\"a\":".repeat(100_000), "}".repeat(100_000)),
72
+ format!("[{}]", vec!["1"; 500_000].join(",")),
73
+ format!("{{\"a\":\"{}\"}}", "x".repeat(16 * 1024 * 1024)),
74
+ ] {
75
+ let _ = parsed_body(&text);
76
+ }
77
+ for text in ["[]", "1", "\"s\"", "null", "true", "{", "{\"a\":1}{}", "{} {}", "\u{feff}{}", "{\"a\":NaN}", "{\"a\":1e999}"] {
78
+ if let Err(refusal) = parsed_body(text) {
79
+ assert_eq!(refusal["statusCode"], 400, "{text:?}");
80
+ }
81
+ }
82
+ assert_eq!(parsed_body(" ").unwrap(), json!({}));
83
+ assert!(parsed_body("{\"a\":1,\"a\":2}").unwrap().is_object());
84
+ });
85
+ }
86
+
87
+ #[test]
88
+ fn slugify_always_answers_a_clean_non_empty_slug() {
89
+ for seed in 0..seeds() {
90
+ guarded(&format!("seed {seed}"), move || {
91
+ let mut rng = Rng::new(seed);
92
+ for _ in 0..100 {
93
+ let text = random_text(&mut rng);
94
+ let slug = slugify(&text);
95
+ assert!(!slug.is_empty(), "seed {seed}: {text:?} slugged to nothing");
96
+ assert!(slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), "seed {seed}: {text:?} -> {slug:?}");
97
+ assert!(!slug.starts_with('-') && !slug.ends_with('-') && !slug.contains("--"), "seed {seed}: {text:?} -> {slug:?}");
98
+ }
99
+ });
100
+ }
101
+ }
102
+
103
+ #[test]
104
+ fn identity_minting_survives_any_rule_and_leaves_args_alone_when_it_refuses() {
105
+ for seed in 0..seeds() {
106
+ guarded(&format!("seed {seed}"), move || {
107
+ let mut rng = Rng::new(seed ^ 0x1D);
108
+ let aggregate = client_aggregate();
109
+ for _ in 0..50 {
110
+ let mut rule = serde_json::Map::new();
111
+ for key in ["field", "strategy", "source", "prefix", "pad"] {
112
+ if rng.chance(5) {
113
+ continue;
114
+ }
115
+ let value = match key {
116
+ "field" if rng.chance(2) => json!(*rng.pick(&["reference", "name", "owner", "status", "missing", ""])),
117
+ "strategy" if rng.chance(2) => json!(*rng.pick(&["slug", "sequence", "port", "nope", ""])),
118
+ "source" if rng.chance(2) => json!(*rng.pick(&["name", "reference", "owner", "missing", ""])),
119
+ "pad" if rng.chance(2) => json!(*rng.pick(&[0u64, 1, 3, 64, 65, 1_000, 1_000_000, 1u64 << 40, 1u64 << 62, u64::MAX])),
120
+ _ => random_value(&mut rng, 2),
121
+ };
122
+ rule.insert(key.to_string(), value);
123
+ }
124
+ let presentation = json!({"collections": {"Client": {"identity": Value::Object(rule)}}});
125
+ let mut args = random_value(&mut rng, 0);
126
+ if rng.chance(2) {
127
+ args = json!({"name": random_value(&mut rng, 2)});
128
+ }
129
+ let before = args.clone();
130
+ let existing = *rng.pick(&[0usize, 1, 41, 999_999, usize::MAX - 1]);
131
+ match apply_identity(&presentation, &aggregate, &mut args, existing) {
132
+ Ok(()) => {}
133
+ Err(refusal) => {
134
+ envelope_is_structured(&refusal);
135
+ assert_eq!(args, before, "seed {seed}: a refused identity must not have touched the args");
136
+ }
137
+ }
138
+ }
139
+ });
140
+ }
141
+ }
142
+
143
+ #[test]
144
+ fn an_absurd_sequence_pad_is_refused_rather_than_allocating_it() {
145
+ let aggregate = client_aggregate();
146
+ for pad in [65u64, 1_000_000, 1 << 40, u64::MAX] {
147
+ let presentation = json!({"collections": {"Client": {"identity": {"field": "reference", "strategy": "sequence", "prefix": "C-", "pad": pad}}}});
148
+ let mut args = json!({"name": {"value": "Acme"}});
149
+ let refusal = apply_identity(&presentation, &aggregate, &mut args, 1).expect_err("a pad this wide is refused");
150
+ envelope_is_structured(&refusal);
151
+ assert_eq!(args, json!({"name": {"value": "Acme"}}), "pad {pad}: nothing was minted");
152
+ }
153
+ let presentation = json!({"collections": {"Client": {"identity": {"field": "reference", "strategy": "sequence", "prefix": "C-", "pad": 64}}}});
154
+ let mut args = json!({});
155
+ apply_identity(&presentation, &aggregate, &mut args, 0).expect("the widest allowed pad still mints");
156
+ assert_eq!(args["reference"]["value"].as_str().unwrap().len(), 2 + 64);
157
+ }
158
+
159
+ #[test]
160
+ fn preconditions_survive_any_config_and_instances_shape() {
161
+ for seed in 0..seeds() {
162
+ guarded(&format!("seed {seed}"), move || {
163
+ let mut rng = Rng::new(seed ^ 0xC0);
164
+ for _ in 0..50 {
165
+ let domain = json!({"name": random_text(&mut rng), "aggregates": [client_aggregate(), {"name": "Member", "lifecycle": random_value(&mut rng, 2)}]});
166
+ let aggregate = client_aggregate();
167
+ let rule = if rng.chance(3) {
168
+ random_value(&mut rng, 0)
169
+ } else {
170
+ json!({"field": *rng.pick(&["owner", "name", "status", "missing"]), "state": random_value(&mut rng, 2), "message": random_value(&mut rng, 2)})
171
+ };
172
+ let args = if rng.chance(3) { random_value(&mut rng, 0) } else { json!({"owner": random_value(&mut rng, 2)}) };
173
+ let instances = if rng.chance(3) { random_value(&mut rng, 0) } else { json!({format!("{}::Member#m1", domain_name(&domain)): random_value(&mut rng, 2)}) };
174
+ if let Err(refusal) = check_precondition(&domain, &aggregate, &rule, &args, &instances) {
175
+ envelope_is_structured(&refusal);
176
+ }
177
+ }
178
+ });
179
+ }
180
+ }
181
+
182
+ #[test]
183
+ fn sorting_records_of_mixed_types_never_panics_on_an_inconsistent_order() {
184
+ // `sort_by` may panic when its comparator is not a total order, so a column holding numbers,
185
+ // empty strings, strings, nulls and objects together must still order consistently.
186
+ for seed in 0..seeds() {
187
+ guarded(&format!("seed {seed}"), move || {
188
+ let mut rng = Rng::new(seed ^ 0x50);
189
+ for _ in 0..10 {
190
+ let mut records: Vec<(String, Value)> = (0..20 + rng.below(200))
191
+ .map(|i| {
192
+ let cell = match rng.below(8) {
193
+ 0 => json!(rng.below(5)),
194
+ 1 => json!(rng.below(5) as f64 / 2.0),
195
+ 2 => json!(""),
196
+ 3 => json!(*rng.pick(&["a", "b", "B", "10", "9", "é", "🍕"])),
197
+ 4 => Value::Null,
198
+ 5 => json!({"cents": rng.below(50)}),
199
+ 6 => json!({"value": random_text(&mut rng)}),
200
+ _ => random_value(&mut rng, 2),
201
+ };
202
+ (format!("id{}", i % 17), json!({"col": cell}))
203
+ })
204
+ .collect();
205
+ for descending in [false, true] {
206
+ let spec = Some(SortSpec { field: "col".to_string(), descending });
207
+ sort_records(&mut records, &spec);
208
+ sort_records(&mut records, &None);
209
+ }
210
+ }
211
+ });
212
+ }
213
+ }
214
+
215
+ #[test]
216
+ fn query_arguments_and_sort_params_survive_hostile_input() {
217
+ for seed in 0..seeds() {
218
+ guarded(&format!("seed {seed}"), move || {
219
+ let mut rng = Rng::new(seed ^ 0x9A);
220
+ let aggregate = client_aggregate();
221
+ let domain = json!({"name": "Crm", "aggregates": [aggregate.clone()]});
222
+ for _ in 0..30 {
223
+ let named = if rng.chance(3) {
224
+ random_value(&mut rng, 0)
225
+ } else {
226
+ json!({"attributes": [
227
+ {"name": *rng.pick(&["name", "owner", "reference", "x"]), "type": *rng.pick(&["ClientName", "String", "Reference<Member>", "Nope", "Integer"]), "optional": rng.chance(2)},
228
+ random_value(&mut rng, 2)
229
+ ]})
230
+ };
231
+ let mut params: HashMap<String, String> = HashMap::new();
232
+ for _ in 0..rng.below(5) {
233
+ params.insert(random_text(&mut rng), random_text(&mut rng));
234
+ }
235
+ params.insert("name".to_string(), random_text(&mut rng));
236
+ let _ = query_args_from_params(&aggregate, &named, &params);
237
+
238
+ let sort: HashMap<String, String> = [("sort", random_text(&mut rng)), ("direction", random_text(&mut rng))].into_iter().map(|(k, v)| (k.to_string(), v)).collect();
239
+ let config = random_value(&mut rng, 0);
240
+ let _ = resolve_sort(&domain, &config, "Client", &aggregate, &sort);
241
+ let _ = resolve_sort(&domain, &json!({}), "Client", &aggregate, &HashMap::from([("sort".to_string(), "__state__".to_string())]));
242
+ }
243
+ });
244
+ }
245
+ }
246
+
247
+ #[test]
248
+ fn identity_sources_and_scalars_render_any_json_value() {
249
+ for seed in 0..seeds() {
250
+ guarded(&format!("seed {seed}"), move || {
251
+ let mut rng = Rng::new(seed ^ 0x5C);
252
+ for _ in 0..50 {
253
+ let args = json!({"name": random_value(&mut rng, 0)});
254
+ let _ = dig_source(&args, "name");
255
+ let _ = dig_source(&random_value(&mut rng, 0), &random_text(&mut rng));
256
+ let _ = scalar_to_string(&random_value(&mut rng, 0));
257
+ }
258
+ });
259
+ }
260
+ }
@@ -0,0 +1,119 @@
1
+ //! Seeded fuzz of the signed-cookie and token readers: whatever text arrives in a `Cookie`
2
+ //! header or a query string, it either verifies as exactly what this host signed or yields
3
+ //! nothing. A forged, truncated or tampered token must never read as a session, and none of it
4
+ //! may panic.
5
+
6
+ use super::*;
7
+ use crate::fuzz_support::*;
8
+
9
+ const SECRET: &str = "fuzz-secret";
10
+
11
+ fn session(rng: &mut Rng) -> Session {
12
+ Session { identity_id: random_text(rng), email: random_text(rng), name: random_text(rng), role: if rng.chance(2) { Some(random_text(rng)) } else { None } }
13
+ }
14
+
15
+ #[test]
16
+ fn a_signed_cookie_reads_back_as_the_session_it_was_made_from() {
17
+ for seed in 0..seeds() {
18
+ guarded(&format!("seed {seed}"), move || {
19
+ let mut rng = Rng::new(seed);
20
+ for _ in 0..30 {
21
+ let original = session(&mut rng);
22
+ let cookie = session_cookie(SECRET, &original);
23
+ let read = parse_session_cookie(SECRET, &cookie).unwrap_or_else(|| panic!("seed {seed}: own cookie for {:?} refused", original.email));
24
+ assert_eq!((read.identity_id, read.email, read.name, read.role), (original.identity_id, original.email, original.name, original.role), "seed {seed}");
25
+ assert!(parse_session_cookie("another-secret", &cookie).is_none(), "seed {seed}: a different secret verified the cookie");
26
+ }
27
+ });
28
+ }
29
+ }
30
+
31
+ #[test]
32
+ fn no_arbitrary_or_tampered_token_ever_verifies() {
33
+ for seed in 0..seeds() {
34
+ guarded(&format!("seed {seed}"), move || {
35
+ let mut rng = Rng::new(seed ^ 0x70C);
36
+ let genuine_cookie = session_cookie(SECRET, &session(&mut rng));
37
+ let genuine_account = account_token(SECRET, &random_text(&mut rng), 60);
38
+ let genuine_purpose = purpose_token(SECRET, "reset", json!({"email": random_text(&mut rng)}), 60);
39
+ for _ in 0..40 {
40
+ let junk = match rng.below(4) {
41
+ 0 => random_text(&mut rng),
42
+ 1 => format!("{}.{}", random_text(&mut rng), random_text(&mut rng)),
43
+ 2 => ".".repeat(rng.below(4)),
44
+ _ => String::from_utf8_lossy(&random_bytes(&mut rng)).into_owned(),
45
+ };
46
+ assert!(parse_session_cookie(SECRET, &junk).is_none(), "seed {seed}: {junk:?} read as a session");
47
+ assert!(verify_account_token(SECRET, &junk).is_none(), "seed {seed}: {junk:?} read as an account token");
48
+ assert!(verify_purpose_token(SECRET, "reset", &junk).is_none(), "seed {seed}: {junk:?} read as a purpose token");
49
+
50
+ for (genuine, verifies) in [
51
+ (&genuine_cookie, (|t: &str| parse_session_cookie(SECRET, t).is_some()) as fn(&str) -> bool),
52
+ (&genuine_account, |t: &str| verify_account_token(SECRET, t).is_some()),
53
+ (&genuine_purpose, |t: &str| verify_purpose_token(SECRET, "reset", t).is_some()),
54
+ ] {
55
+ assert!(verifies(genuine), "seed {seed}: the untampered token must verify");
56
+ let tampered = mutate_text(&mut rng, genuine);
57
+ assert!(tampered == *genuine || !verifies(&tampered), "seed {seed}: tampered token {tampered:?} verified");
58
+ }
59
+ }
60
+ });
61
+ }
62
+ }
63
+
64
+ #[test]
65
+ fn a_token_for_one_purpose_never_verifies_for_another_or_as_an_account_token() {
66
+ let token = purpose_token(SECRET, "reset", json!({"email": "a@example.com"}), 60);
67
+ assert!(verify_purpose_token(SECRET, "reset", &token).is_some());
68
+ for other in ["", "RESET", "reset ", "reset:", "invite", "reset:fuzz-secret"] {
69
+ assert!(verify_purpose_token(SECRET, other, &token).is_none(), "purpose {other:?}");
70
+ }
71
+ assert!(verify_account_token(SECRET, &token).is_none());
72
+ assert!(parse_session_cookie(SECRET, &token).is_none());
73
+ }
74
+
75
+ #[test]
76
+ fn an_expired_or_unexpiring_signed_payload_is_not_a_session() {
77
+ let sign_payload = |payload: Value| {
78
+ let encoded = base64_encode(payload.to_string().as_bytes());
79
+ format!("{encoded}.{}", sign(SECRET, &encoded))
80
+ };
81
+ let person = json!({"identity_id": "i", "email": "e", "name": "n"});
82
+ for exp in [json!(0), json!(1), json!(-1), json!(1.5), json!("9999999999"), json!(null), json!(u64::MAX)] {
83
+ let mut payload = person.clone();
84
+ payload["exp"] = exp.clone();
85
+ let verdict = parse_session_cookie(SECRET, &sign_payload(payload)).is_some();
86
+ assert_eq!(verdict, exp == json!(u64::MAX), "exp {exp}");
87
+ }
88
+ assert!(parse_session_cookie(SECRET, &sign_payload(person)).is_none(), "no exp is no session");
89
+ for payload in [json!([]), json!(1), json!("s"), json!(null), json!({"exp": u64::MAX}), json!({"exp": u64::MAX, "identity_id": 1, "email": "e", "name": "n"})] {
90
+ assert!(parse_session_cookie(SECRET, &sign_payload(payload.clone())).is_none(), "{payload}");
91
+ assert!(verify_account_token(SECRET, &sign_payload(payload.clone())).is_none() || payload.get("email").is_some(), "{payload}");
92
+ }
93
+ }
94
+
95
+ #[test]
96
+ fn signature_comparison_checks_every_byte_and_the_length() {
97
+ assert!(constant_time_eq(b"", b""));
98
+ assert!(constant_time_eq(b"abc", b"abc"));
99
+ assert!(!constant_time_eq(b"abc", b"abd"));
100
+ assert!(!constant_time_eq(b"abc", b"xbc"));
101
+ assert!(!constant_time_eq(b"abc", b"ab"));
102
+ assert!(!constant_time_eq(b"ab", b"abc"));
103
+ }
104
+
105
+ #[test]
106
+ fn base64_decoding_and_cookie_names_take_any_text() {
107
+ for seed in 0..seeds() {
108
+ guarded(&format!("seed {seed}"), move || {
109
+ let mut rng = Rng::new(seed ^ 0xBA5E);
110
+ for _ in 0..100 {
111
+ let text = random_text(&mut rng);
112
+ let _ = base64_decode(&text);
113
+ let _ = resolve_account_cookie(Some(&text));
114
+ let bytes = random_bytes(&mut rng);
115
+ assert_eq!(base64_decode(&base64_encode(&bytes)), bytes, "seed {seed}");
116
+ }
117
+ });
118
+ }
119
+ }
@@ -0,0 +1,158 @@
1
+ //! Seeded fuzz of the invariant interpreter: an `ast` node read from the IR sidecar is parsed
2
+ //! and evaluated against a value object's fields, both of which can be wrong in every way
3
+ //! (unknown ops, wrong-typed fields, hostile regexes, overflowing arithmetic). The answer is an
4
+ //! `Ok` or an `Err(String)`, never a panic or a hang.
5
+
6
+ use crate::expr_json::{interpret, parse};
7
+ use crate::fuzz_support::*;
8
+ use serde_json::{json, Value};
9
+
10
+ const BINARY: &[&str] = &["or", "and", "compare", "include", "add", "modulo"];
11
+ const UNARY: &[&str] = &["not", "sign_test", "empty", "to_s", "size", "presence", "assignment", "first", "last", "split", "starts_with", "ends_with", "matches_regex"];
12
+
13
+ const PATTERNS: &[&str] = &[
14
+ "", "^$", "a", "(a*)*b", "(a|aa)+$", "[", "]", "[]", "[^]", "[a-", "(", ")", "(?", "(?P<x>", "\\", "\\d+", "\\D\\W\\S\\H", "\\Z", "[\\d]", "[\\Z]", "\\p{L}+", "\\p{Nope}",
15
+ "a{1000}", "a{1000}{1000}", "(((a{100}){100}){100})", "(?i)ABC", "(?x) a b", "\\x{110000}", "\\u{1F355}", ".*.*.*.*.*.*.*b", "\\b\\B", "(?<n>a)\\k<n>", "(a)\\1", "^*", "$+", "é+", "🍕{2}",
16
+ ];
17
+
18
+ fn leaf(rng: &mut Rng) -> Value {
19
+ match rng.below(9) {
20
+ 0 => json!({"op": "int", "value": edge_number(rng)}),
21
+ 1 => json!({"op": "float", "value": edge_number(rng)}),
22
+ 2 => json!({"op": "str", "value": random_text(rng)}),
23
+ 3 => json!({"op": "bool", "value": rng.chance(2)}),
24
+ 4 => json!({"op": "nil"}),
25
+ 5 => json!({"op": "lookup", "path": (0..1 + rng.below(3)).map(|_| *rng.pick(&["a", "b", "items", "name", "missing", "x", ""])).collect::<Vec<_>>()}),
26
+ 6 => json!({"op": "array", "elements": (0..rng.below(4)).map(|_| json!({"op": "int", "value": rng.below(5)})).collect::<Vec<_>>()}),
27
+ 7 => json!({"op": "int", "value": i64::MAX}),
28
+ _ => json!({"op": "str", "value": ""}),
29
+ }
30
+ }
31
+
32
+ fn node(rng: &mut Rng, depth: usize) -> Value {
33
+ if depth >= 4 || rng.chance(4) {
34
+ return leaf(rng);
35
+ }
36
+ let cmp = || json!({"less_than": true, "equal": true, "negated": false});
37
+ match rng.below(10) {
38
+ 0..=3 => {
39
+ let op = *rng.pick(BINARY);
40
+ let mut value = json!({"op": op, "left": node(rng, depth + 1), "right": node(rng, depth + 1), "receiver": node(rng, depth + 1), "divisor": node(rng, depth + 1), "haystack": node(rng, depth + 1), "needle": node(rng, depth + 1)});
41
+ value["cmp"] = cmp();
42
+ value
43
+ }
44
+ 4..=7 => {
45
+ let op = *rng.pick(UNARY);
46
+ let mut value = json!({"op": op, "receiver": node(rng, depth + 1), "expr": node(rng, depth + 1), "negated": rng.chance(2), "separator": random_text(rng), "substring": random_text(rng), "pattern": *rng.pick(PATTERNS), "flags": *rng.pick(&["", "i", "m", "x", "imx", "z"])});
47
+ value["cmp"] = json!({"less_than": rng.chance(2), "equal": rng.chance(2), "negated": rng.chance(2)});
48
+ value
49
+ }
50
+ 8 => json!({"op": *rng.pick(&["block_predicate", "find"]), "mode": *rng.pick(&["all", "any", "none", "some"]), "receiver": node(rng, depth + 1), "param": *rng.pick(&["i", "a", ""]), "predicate": node(rng, depth + 1), "path": [*rng.pick(&["a", "x"])]}),
51
+ _ => random_value(rng, 1),
52
+ }
53
+ }
54
+
55
+ fn instance(rng: &mut Rng) -> Value {
56
+ let mut fields = serde_json::Map::new();
57
+ for key in ["a", "b", "items", "name", "x"] {
58
+ if !rng.chance(4) {
59
+ fields.insert(key.to_string(), if rng.chance(2) { random_value(rng, 1) } else { json!([{"x": 1}, {"x": "t"}, 3]) });
60
+ }
61
+ }
62
+ Value::Object(fields)
63
+ }
64
+
65
+ #[test]
66
+ fn any_expression_tree_parses_or_refuses_and_evaluates_or_refuses() {
67
+ for seed in 0..seeds() {
68
+ guarded(&format!("seed {seed}"), move || {
69
+ let mut rng = Rng::new(seed);
70
+ for _ in 0..40 {
71
+ let tree = node(&mut rng, 0);
72
+ let Ok(expr) = parse(&tree) else { continue };
73
+ for _ in 0..3 {
74
+ let _ = interpret(&expr, &instance(&mut rng));
75
+ }
76
+ }
77
+ });
78
+ }
79
+ }
80
+
81
+ #[test]
82
+ fn a_damaged_ast_document_is_refused_not_a_panic() {
83
+ for seed in 0..seeds() {
84
+ guarded(&format!("seed {seed}"), move || {
85
+ let mut rng = Rng::new(seed ^ 0xA57);
86
+ for _ in 0..40 {
87
+ let valid = node(&mut rng, 0).to_string();
88
+ let text = mutate_text(&mut rng, &valid);
89
+ if let Ok(tree) = serde_json::from_str::<Value>(&text) {
90
+ if let Ok(expr) = parse(&tree) {
91
+ let _ = interpret(&expr, &instance(&mut rng));
92
+ }
93
+ }
94
+ }
95
+ });
96
+ }
97
+ }
98
+
99
+ #[test]
100
+ fn arbitrary_json_is_never_an_expression() {
101
+ for seed in 0..seeds() {
102
+ guarded(&format!("seed {seed}"), move || {
103
+ let mut rng = Rng::new(seed ^ 0x77);
104
+ for _ in 0..100 {
105
+ let _ = parse(&random_value(&mut rng, 0));
106
+ }
107
+ });
108
+ }
109
+ }
110
+
111
+ #[test]
112
+ fn hostile_regexes_compile_or_refuse_quickly_and_match_without_blowing_up() {
113
+ for pattern in PATTERNS {
114
+ for flags in ["", "i", "mx"] {
115
+ let tree = json!({"op": "matches_regex", "receiver": {"op": "str", "value": "a".repeat(5_000)}, "pattern": pattern, "flags": flags});
116
+ let expr = parse(&tree).expect("a well-formed node");
117
+ guarded(&format!("{pattern:?} {flags:?}"), move || {
118
+ let _ = interpret(&expr, &json!({}));
119
+ });
120
+ }
121
+ }
122
+ }
123
+
124
+ #[test]
125
+ fn integer_arithmetic_at_the_limits_refuses_instead_of_overflowing() {
126
+ let int = |n: i64| json!({"op": "int", "value": n});
127
+ let cases = [
128
+ json!({"op": "add", "left": int(i64::MAX), "right": int(1)}),
129
+ json!({"op": "add", "left": int(i64::MIN), "right": int(-1)}),
130
+ json!({"op": "modulo", "receiver": int(i64::MIN), "divisor": int(-1)}),
131
+ json!({"op": "modulo", "receiver": int(i64::MAX), "divisor": int(i64::MIN)}),
132
+ json!({"op": "modulo", "receiver": int(1), "divisor": int(0)}),
133
+ json!({"op": "modulo", "receiver": {"op": "float", "value": 1.5}, "divisor": {"op": "float", "value": 0.0}}),
134
+ json!({"op": "add", "left": {"op": "float", "value": 1.7976931348623157e308}, "right": {"op": "float", "value": 1.7976931348623157e308}}),
135
+ ];
136
+ for tree in cases {
137
+ let expr = parse(&tree).expect("well-formed");
138
+ let _ = interpret(&expr, &json!({}));
139
+ }
140
+ assert!(interpret(&parse(&cases_add_overflow()).unwrap(), &json!({})).is_err(), "i64::MAX + 1 must refuse");
141
+ }
142
+
143
+ fn cases_add_overflow() -> Value {
144
+ json!({"op": "add", "left": {"op": "int", "value": i64::MAX}, "right": {"op": "int", "value": 1}})
145
+ }
146
+
147
+ #[test]
148
+ fn a_document_nested_past_the_json_limit_never_reaches_the_interpreter() {
149
+ // The IR is read with serde_json, whose recursion limit refuses what would overflow `parse`.
150
+ let nested = format!("{}{{\"op\":\"nil\"}}{}", "{\"op\":\"not\",\"expr\":".repeat(10_000), "}".repeat(10_000));
151
+ assert!(serde_json::from_str::<Value>(&nested).is_err());
152
+ let within = format!("{}{{\"op\":\"nil\"}}{}", "{\"op\":\"not\",\"expr\":".repeat(60), "}".repeat(60));
153
+ guarded("depth 60", move || {
154
+ let tree: Value = serde_json::from_str(&within).expect("within serde's limit");
155
+ let expr = parse(&tree).expect("well-formed");
156
+ let _ = interpret(&expr, &json!({}));
157
+ });
158
+ }