hecks 3.6.0 → 3.9.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
  3. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  5. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  6. data/lib/hecks/bluebook/meta_validator.rb +4 -2
  7. data/lib/hecks/cache_dir.rb +10 -0
  8. data/lib/hecks/corpus/routes.rb +1 -1
  9. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  10. data/lib/hecks/deploy/oidc.json +10 -0
  11. data/lib/hecks/doors/usage_cache.rb +19 -9
  12. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  13. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  14. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  15. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  16. data/lib/hecks/fuzzing/form_census.rb +28 -52
  17. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  18. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  19. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  20. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  21. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  22. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  23. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  24. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  25. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  26. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  27. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  28. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  29. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  30. data/lib/hecks/fuzzing/mutation.rb +12 -0
  31. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  32. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  33. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  34. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  35. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  36. data/lib/hecks/fuzzing/properties.rb +7 -0
  37. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  38. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  39. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  40. data/lib/hecks/fuzzing/replay.rb +1 -0
  41. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  42. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  43. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  44. data/lib/hecks/fuzzing.rb +3 -0
  45. data/lib/hecks/gate/stages.yml +12 -2
  46. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  47. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  48. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  49. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  50. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  51. data/lib/hecks/hecks/custodian.behaviors +6 -0
  52. data/lib/hecks/hecks/custodian.bluebook +37 -0
  53. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  54. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
  55. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  56. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  57. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  58. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  59. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  60. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  61. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  62. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  63. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  64. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  65. data/lib/hecks/projections/deploy/box.rb +14 -2
  66. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  67. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  68. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  69. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  70. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  71. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  72. data/lib/hecks/projections/site/probe.rb +131 -0
  73. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  74. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  75. data/lib/hecks/projections.rb +2 -0
  76. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  77. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  78. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  79. data/lib/hecks/runtime/value/validation.rb +34 -3
  80. data/lib/hecks/rust_build/native_build.rb +29 -1
  81. data/lib/hecks/rust_build/wasm.rb +16 -3
  82. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  83. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  84. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  85. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  86. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  87. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  88. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  89. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  90. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  91. data/lib/hecks/tools/lanes/files.rb +2 -4
  92. data/lib/hecks/tools/lanes/live.rb +3 -4
  93. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  94. data/lib/hecks/tools/lanes.rb +12 -1
  95. data/lib/hecks/tools/mutation_run.rb +73 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +3 -1
  99. data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
  100. data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
  101. data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
  102. data/qa/settings.yml +1 -0
  103. data/rust/codegen/src/fielded.rs +3 -3
  104. data/rust/codegen/src/json_codec.rs +2 -2
  105. data/rust/codegen/src/naming.rs +20 -0
  106. data/rust/host/Cargo.toml +6 -2
  107. data/rust/host/HECKS_RELEASE +1 -1
  108. data/rust/host/src/api.rs +28 -5
  109. data/rust/host/src/auth.rs +11 -1
  110. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  111. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  112. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  113. data/rust/host/src/boundary_fuzz/ir.rs +79 -0
  114. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  115. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  116. data/rust/host/src/commerce_ir.rs +459 -0
  117. data/rust/host/src/extension.rs +100 -0
  118. data/rust/host/src/fuzz_support.rs +165 -0
  119. data/rust/host/src/ir.rs +4 -448
  120. data/rust/host/src/journal.rs +1 -1
  121. data/rust/host/src/lib.rs +39 -0
  122. data/rust/host/src/main.rs +10 -44
  123. data/rust/host/src/mint.rs +1 -1
  124. data/rust/host/src/payments/tests.rs +8 -8
  125. data/rust/host/src/payments.rs +10 -10
  126. data/rust/host/src/rate_limit.rs +27 -41
  127. data/rust/host/src/secrets.rs +44 -0
  128. data/rust/host/src/server.rs +6 -1
  129. data/rust/host/src/web/commerce.rs +113 -0
  130. data/rust/host/src/web/newsletter.rs +2 -1
  131. data/rust/host/src/web/newsletter_send.rs +1 -1
  132. data/rust/host/src/web/registration_receipt.rs +2 -2
  133. data/rust/host/src/web/registrations.rs +49 -49
  134. data/rust/host/src/web/resend_webhook.rs +299 -0
  135. data/rust/host/src/web.rs +55 -49
  136. data/rust/parser/fuzz/.gitignore +4 -0
  137. data/rust/parser/fuzz/Cargo.lock +89 -0
  138. data/rust/parser/fuzz/Cargo.toml +27 -0
  139. data/rust/parser/fuzz/README.md +19 -0
  140. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  141. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  142. data/rust/parser/src/parse/command.rs +14 -2
  143. data/rust/parser/src/parse/mod.rs +25 -0
  144. data/rust/parser/src/ruby_value.rs +95 -6
  145. data/rust/src/kernel/json.rs +34 -3
  146. metadata +58 -2
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
 
@@ -30,7 +30,7 @@ pub fn emit_fielded_flat(exemplar: &Exemplar, struct_name: &str, attributes: &[J
30
30
  let scalar = scalar.unwrap();
31
31
  Some(exemplar.render(
32
32
  "fielded_arm_optional_scalar",
33
- &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident.clone()), ("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, "v").unwrap())],
33
+ &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident.clone()), ("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, &naming::deref_scalar(scalar, "v")).unwrap())],
34
34
  ))
35
35
  } else if optional {
36
36
  if is_fielded_nested(crate::attr::type_name(attr), value_objects_by_name) {
@@ -97,7 +97,7 @@ pub fn emit_fielded_record(exemplar: &Exemplar, aggregate: &Json, value_objects_
97
97
  } else if let Some(scalar) = scalar {
98
98
  Some(exemplar.render(
99
99
  "fielded_arm_optional_scalar",
100
- &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident.clone()), ("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, "v").unwrap())],
100
+ &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident.clone()), ("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, &naming::deref_scalar(scalar, "v")).unwrap())],
101
101
  ))
102
102
  } else if is_fielded_nested(crate::attr::type_name(attr), value_objects_by_name) {
103
103
  Some(exemplar.render("fielded_arm_optional_nested", &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident)]))
@@ -184,7 +184,7 @@ fn items_arms(exemplar: &Exemplar, attributes: &[Json], value_objects_by_name: &
184
184
  let shape = format!("fielded_items_arm_list_{}{}", if optional(attr) { "optional_" } else { "" }, if scalar.is_some() { "scalar" } else { "nested" });
185
185
  let mut subs: Vec<(&str, String)> = vec![("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident)];
186
186
  if let Some(scalar) = scalar {
187
- subs.push(("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, "v").unwrap()));
187
+ subs.push(("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, &naming::deref_scalar(scalar, "v")).unwrap()));
188
188
  }
189
189
  arms.push(format!(" {}", exemplar.render(&shape, &subs)));
190
190
  }
@@ -130,7 +130,7 @@ pub fn list_element_from_json_mapper(struct_name: &str, key: &str, attr: &Json,
130
130
  /// Inverse of `list_element_from_json_mapper`: scalar elements have no `.to_json()` method.
131
131
  pub fn list_element_to_json_expr(attr: &Json) -> String {
132
132
  match naming::effective_scalar_type(crate::attr::type_name(attr)) {
133
- Some(scalar) => scalar_to_json_expr(scalar, "x"),
133
+ Some(scalar) => scalar_to_json_expr(scalar, &naming::deref_scalar(scalar, "x")),
134
134
  None => "x.to_json()".to_string(),
135
135
  }
136
136
  }
@@ -392,7 +392,7 @@ pub fn emit_to_json_flat(exemplar: &Exemplar, struct_name: &str, attributes: &[J
392
392
  } else if list {
393
393
  format!("crate::kernel::Json::Array(self.{ident}.iter().map(|x| {}).collect())", elem_to_json.as_deref().unwrap())
394
394
  } else if field_optional && scalar.is_some() {
395
- format!("self.{ident}.as_ref().map(|v| {}).unwrap_or(crate::kernel::Json::Null)", scalar_to_json_expr(scalar.unwrap(), "v"))
395
+ format!("self.{ident}.as_ref().map(|v| {}).unwrap_or(crate::kernel::Json::Null)", scalar_to_json_expr(scalar.unwrap(), &naming::deref_scalar(scalar.unwrap(), "v")))
396
396
  } else if field_optional {
397
397
  format!("self.{ident}.as_ref().map(|v| v.to_json()).unwrap_or(crate::kernel::Json::Null)")
398
398
  } else if let Some(scalar) = scalar {
@@ -248,6 +248,16 @@ pub fn screaming_snake(name: &str) -> String {
248
248
  out.to_uppercase()
249
249
  }
250
250
 
251
+ /// The expression for a scalar reached through a reference (`v` in `as_ref().map(|v| ..)`, `x` in
252
+ /// `iter().map(|x| ..)`): the copy types are dereferenced, since `Value::Int(v)` takes an `i64`, not an
253
+ /// `&i64`; a `String` is left to the `.clone()` the scalar helpers already apply.
254
+ pub fn deref_scalar(type_name: &str, ident: &str) -> String {
255
+ match type_name {
256
+ "Integer" | "Float" | "TrueClass" | "FalseClass" => format!("*{ident}"),
257
+ _ => ident.to_string(),
258
+ }
259
+ }
260
+
251
261
  pub fn scalar_to_value(type_name: &str, rust_expr: &str) -> Option<String> {
252
262
  match type_name {
253
263
  "String" => Some(format!("Value::Str({rust_expr}.clone())")),
@@ -316,6 +326,16 @@ pub fn ruby_inspect_string(s: &str) -> String {
316
326
  mod tests {
317
327
  use super::*;
318
328
 
329
+ #[test]
330
+ fn a_scalar_reached_through_a_reference_is_dereferenced_when_it_is_a_copy_type() {
331
+ // `self.level.as_ref().map(|v| Field::Value(Value::Int(v)))` does not compile: `v` is `&i64`.
332
+ assert_eq!(scalar_to_value("Integer", &deref_scalar("Integer", "v")).as_deref(), Some("Value::Int(*v)"));
333
+ assert_eq!(scalar_to_value("Float", &deref_scalar("Float", "v")).as_deref(), Some("Value::Float(*v)"));
334
+ assert_eq!(scalar_to_value("TrueClass", &deref_scalar("TrueClass", "v")).as_deref(), Some("Value::Bool(*v)"));
335
+ // A String is cloned, so the reference is left alone.
336
+ assert_eq!(scalar_to_value("String", &deref_scalar("String", "v")).as_deref(), Some("Value::Str(v.clone())"));
337
+ }
338
+
319
339
  #[test]
320
340
  fn refuses_an_aggregate_whose_module_name_is_a_rust_keyword() {
321
341
  let refusal = reserved_name_refusal("shop", "shop", &["Pizza", "Match", "Type"]).expect("refused");
data/rust/host/Cargo.toml CHANGED
@@ -6,6 +6,10 @@ edition = "2021"
6
6
  # Empty table: makes this crate its own workspace root instead of joining `rust`'s.
7
7
  [workspace]
8
8
 
9
+ [lib]
10
+ name = "rust_host"
11
+ path = "src/lib.rs"
12
+
9
13
  [[bin]]
10
14
  name = "bootstrap"
11
15
  path = "src/main.rs"
@@ -61,6 +65,6 @@ async-trait = "0.1.92"
61
65
  debug = 0
62
66
 
63
67
  # 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."*"]
68
+ # Optimize dependencies only, and only for cargo test; the host crate and cargo build stay at opt-level 0.
69
+ [profile.test.package."*"]
66
70
  opt-level = 2
@@ -1 +1 @@
1
- 3.6.0
1
+ 3.9.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
+ }