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
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ //! Seeded fuzz of the IR sidecar readers: `ir.json` is generated, but it is read from disk at
2
+ //! boot, so a stale, hand-edited or truncated file must come back as `None` or a refusal, never
3
+ //! a panic.
4
+
5
+ use super::*;
6
+ use crate::commerce_ir::*;
7
+ use crate::fuzz_support::*;
8
+ use serde_json::json;
9
+
10
+ const KEYS: &[&str] = &[
11
+ "name", "aggregates", "lineage", "capable_aggregates", "storage_name", "persistence", "adapter", "authorization", "grant", "assignment_aggregate", "membership", "provider", "aggregate",
12
+ "identity", "register", "link", "resolve", "newsletter", "subscribe", "add_name", "confirm", "unsubscribe", "newsletter_issues", "issue_aggregate", "schedule", "send_issue", "record_delivery",
13
+ "payments", "initiate", "succeeded", "failed", "event_aggregate", "registrations", "registration_aggregate", "request", "payment_connection", "commands", "attributes",
14
+ ];
15
+
16
+ /// An object over the keys the readers look up, with each value the right shape, the wrong
17
+ /// shape, or missing: the mix that finds a reader trusting its input.
18
+ fn random_ir(rng: &mut Rng, depth: usize) -> Value {
19
+ let mut fields = serde_json::Map::new();
20
+ for key in KEYS {
21
+ if rng.chance(3) {
22
+ continue;
23
+ }
24
+ let value = match rng.below(5) {
25
+ 0 if depth < 2 => random_ir(rng, depth + 1),
26
+ 1 => Value::String(random_text(rng)),
27
+ 2 => Value::Array((0..rng.below(2)).map(|_| if depth < 2 { random_ir(rng, depth + 1) } else { Value::Null }).collect()),
28
+ 3 => json!(*rng.pick(&["Postgres", "PostgresEra", "Heki", "Memory", ""])),
29
+ _ => random_value(rng, 2),
30
+ };
31
+ fields.insert(key.to_string(), value);
32
+ }
33
+ Value::Object(fields)
34
+ }
35
+
36
+ #[test]
37
+ fn every_provider_reader_answers_any_ir_shape() {
38
+ for seed in 0..seeds() {
39
+ guarded(&format!("seed {seed}"), move || {
40
+ let mut rng = Rng::new(seed);
41
+ for _ in 0..40 {
42
+ let ir = if rng.chance(8) { random_value(&mut rng, 0) } else { random_ir(&mut rng, 0) };
43
+ let _ = lineage_capable_aggregates(&ir);
44
+ let _ = persistence_adapters(&ir);
45
+ let _ = mirrored_aggregates(&ir);
46
+ let _ = authorization_provider(&ir);
47
+ let _ = membership_provider(&ir);
48
+ let _ = identity_provider(&ir);
49
+ let _ = command_declares(&ir, &random_text(&mut rng), &random_text(&mut rng), &random_text(&mut rng));
50
+ let _ = newsletter_provider(&ir).map(|p| p.instance_prefix());
51
+ let _ = newsletter_issues_provider(&ir).map(|p| p.issue_prefix());
52
+ let _ = payments_provider(&ir).map(|p| p.instance_prefix());
53
+ if let Some(p) = registrations_provider(&ir) {
54
+ let _ = (p.event_prefix(), p.registration_prefix(), p.request_target());
55
+ }
56
+ if let Some(p) = payment_connection_provider(&ir) {
57
+ let _ = p.instance_prefix();
58
+ }
59
+ let _ = registrations_binding(&random_text(&mut rng));
60
+ let _ = payment_connection_binding(&random_text(&mut rng));
61
+ if let Err(message) = refuse_unsupported_persistence_adapters(&ir) {
62
+ assert!(message.contains("rust/host only has a backend"), "seed {seed}: unhelpful refusal {message:?}");
63
+ }
64
+ }
65
+ });
66
+ }
67
+ }
68
+
69
+ #[test]
70
+ fn an_unsupported_persistence_adapter_is_always_refused_at_boot() {
71
+ for adapter in ["Heki", "Memory", "Sqlite", "", "postgres", "Postgres "] {
72
+ let ir = json!({"name": "D", "persistence": {"aggregates": [{"name": "A", "adapter": adapter}]}});
73
+ assert!(refuse_unsupported_persistence_adapters(&ir).is_err(), "{adapter:?}");
74
+ }
75
+ for adapter in ["Postgres", "PostgresEra"] {
76
+ let ir = json!({"name": "D", "persistence": {"aggregates": [{"name": "A", "adapter": adapter}]}});
77
+ assert!(refuse_unsupported_persistence_adapters(&ir).is_ok(), "{adapter:?}");
78
+ }
79
+ }
@@ -0,0 +1,227 @@
1
+ //! Seeded fuzz of the HTTP front door (`admit`, `value_to_response`): hostile bodies, methods, URIs
2
+ //! and headers must be answered, never panic, and a refused request must leave no trace.
3
+ //! Everything here is in-process: `admit` is the whole of what runs before dispatch, so none of
4
+ //! it needs Postgres or wasm.
5
+
6
+ use super::*;
7
+ use crate::fuzz_support::*;
8
+ use serde_json::json;
9
+
10
+ fn limits(pairs: &[(&str, &str)]) -> RateLimits {
11
+ let map: std::collections::HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
12
+ RateLimits::new(crate::rate_limit::Config::from_lookup(|name| map.get(name).cloned()).0)
13
+ }
14
+
15
+ fn peer(text: &str) -> SocketAddr {
16
+ text.parse().unwrap()
17
+ }
18
+
19
+ const PEERS: &[&str] = &["127.0.0.1:5000", "[::1]:5000", "[::ffff:127.0.0.1]:5000", "10.0.0.5:5000", "203.0.113.7:5000", "[2001:db8::1]:5000"];
20
+
21
+ fn random_method(rng: &mut Rng) -> Method {
22
+ let methods = [Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::PATCH, Method::HEAD, Method::OPTIONS, Method::TRACE, Method::CONNECT];
23
+ if rng.chance(8) {
24
+ Method::from_bytes(b"BREW").unwrap()
25
+ } else {
26
+ rng.pick(&methods).clone()
27
+ }
28
+ }
29
+
30
+ /// A URI that parses: a hostile path built from the pieces, or a fixed route the limiter knows.
31
+ fn random_uri(rng: &mut Rng) -> Uri {
32
+ const FIXED: &[&str] = &["/", "/newsletter/subscribers", "/registrations", "/api/me", "/login", "/%00", "/../..", "/a?b=%a", "/x?%", "/a//b", "/a;b", "*"];
33
+ if rng.chance(3) {
34
+ return rng.pick(FIXED).parse().unwrap();
35
+ }
36
+ for _ in 0..8 {
37
+ let path: String = (0..rng.below(4)).map(|_| format!("/{}", rng.pick(PIECES_ASCII))).collect();
38
+ let query = if rng.chance(2) { format!("?{}={}", rng.pick(PIECES_ASCII), rng.pick(PIECES_ASCII)) } else { String::new() };
39
+ if let Ok(uri) = format!("{path}{query}").parse::<Uri>() {
40
+ return uri;
41
+ }
42
+ }
43
+ "/".parse().unwrap()
44
+ }
45
+
46
+ const PIECES_ASCII: &[&str] = &["a", "b", "0", "%41", "%zz", "%", "%00", "..", ".", "-", "_", "id", "api", "me", "newsletter", "subscribers", "x y", "a+b", "=", "&", "a=b"];
47
+
48
+ fn random_headers(rng: &mut Rng) -> HeaderMap {
49
+ const NAMES: &[&str] = &["x-forwarded-for", "cookie", "content-type", "authorization", "host", "x-real-ip", "x-hecks-proxy-auth", "x-anything", "content-length", "set-cookie"];
50
+ let mut headers = HeaderMap::new();
51
+ for _ in 0..rng.below(5) {
52
+ let name = HeaderName::from_static(rng.pick(NAMES));
53
+ let bytes: Vec<u8> = match rng.below(4) {
54
+ 0 => random_text(rng).into_bytes(),
55
+ 1 => (0..rng.below(24)).map(|_| rng.next() as u8).collect(),
56
+ 2 => b"1.2.3.4, 5.6.7.8, ::1, not-an-ip, [::ffff:127.0.0.1]".to_vec(),
57
+ _ => "x".repeat(rng.below(9000)).into_bytes(),
58
+ };
59
+ // Header values are a restricted byte set; the ones the type refuses never reach `admit`.
60
+ if let Ok(value) = HeaderValue::from_bytes(&bytes) {
61
+ headers.append(name, value);
62
+ }
63
+ }
64
+ headers
65
+ }
66
+
67
+ fn status_of(refusal: &Response) -> u16 {
68
+ refusal.status().as_u16()
69
+ }
70
+
71
+ #[test]
72
+ fn admit_answers_every_hostile_request_without_panicking() {
73
+ for seed in 0..seeds() {
74
+ guarded(&format!("seed {seed}"), move || {
75
+ let mut rng = Rng::new(seed);
76
+ let limits = limits(&[]);
77
+ for _ in 0..30 {
78
+ let from = peer(rng.pick(PEERS));
79
+ let method = random_method(&mut rng);
80
+ let uri = random_uri(&mut rng);
81
+ let headers = random_headers(&mut rng);
82
+ let body = Bytes::from(random_bytes(&mut rng));
83
+
84
+ let parsed = if body.is_empty() { Some(json!({})) } else { serde_json::from_slice::<Value>(&body).ok() };
85
+ let answer = admit(&limits, from, &method, &uri, &headers, &body);
86
+ match (parsed, answer) {
87
+ (None, Err(refusal)) => assert_eq!(status_of(&refusal), 400, "seed {seed}: a body that is not JSON is a 400, got {}", status_of(&refusal)),
88
+ (None, Ok(envelope)) => panic!("seed {seed}: admitted a body that is not JSON: {envelope}"),
89
+ (Some(_), Err(refusal)) => assert_eq!(status_of(&refusal), 429, "seed {seed}: valid JSON is refused only by the limiter, got {}", status_of(&refusal)),
90
+ (Some(parsed), Ok(envelope)) => {
91
+ let internal = trusts_internal_dispatch(from) && is_internal_dispatch_shape(&parsed);
92
+ if internal {
93
+ assert_eq!(envelope, parsed, "seed {seed}: a trusted internal body passes through untouched");
94
+ } else {
95
+ assert_eq!(envelope["requestContext"]["http"]["method"], method.as_str(), "seed {seed}");
96
+ assert_eq!(envelope["rawPath"], uri.path(), "seed {seed}");
97
+ assert!(envelope["body"].is_string(), "seed {seed}: the synthesized body is text");
98
+ assert_eq!(envelope["isBase64Encoded"], false, "seed {seed}");
99
+ }
100
+ }
101
+ }
102
+ }
103
+ });
104
+ }
105
+ }
106
+
107
+ #[test]
108
+ fn a_public_peer_can_never_smuggle_in_the_internal_dispatch_protocol() {
109
+ // The internal shapes carry a caller's own `role` with no session check; only loopback may use them.
110
+ for seed in 0..seeds() {
111
+ guarded(&format!("seed {seed}"), move || {
112
+ let mut rng = Rng::new(seed ^ 0xBEEF);
113
+ let limits = limits(&[]);
114
+ for _ in 0..20 {
115
+ let mut internal = serde_json::Map::new();
116
+ for key in ["verb", "role", "actor_id", "args", "read", "query", "to", "with", "requestContext", "cookies"] {
117
+ if rng.chance(2) {
118
+ internal.insert(key.to_string(), random_value(&mut rng, 1));
119
+ }
120
+ }
121
+ let body = Bytes::from(Value::Object(internal).to_string());
122
+ let from = peer(rng.pick(&PEERS[3..]));
123
+ let envelope = admit(&limits, from, &Method::POST, &"/x".parse().unwrap(), &random_headers(&mut rng), &body)
124
+ .unwrap_or_else(|_| panic!("seed {seed}: valid JSON from a public peer is admitted"));
125
+ let keys: std::collections::BTreeSet<&str> = envelope.as_object().unwrap().keys().map(String::as_str).collect();
126
+ let expected: std::collections::BTreeSet<&str> = ["requestContext", "rawPath", "rawQueryString", "headers", "body", "isBase64Encoded"].into();
127
+ assert_eq!(keys, expected, "seed {seed}: a public peer's body must be wrapped as plain request data");
128
+ }
129
+ });
130
+ }
131
+ }
132
+
133
+ #[test]
134
+ fn a_malformed_body_is_refused_before_it_costs_any_rate_limit_budget() {
135
+ let limits = limits(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1")]);
136
+ let from = peer("203.0.113.7:5000");
137
+ let uri: Uri = "/newsletter/subscribers".parse().unwrap();
138
+ for body in ["{", "not json", "{\"a\":", "\u{0}", "[1,", "{\"a\":1}x", "\u{feff}{}"] {
139
+ for _ in 0..10 {
140
+ let refusal = admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &Bytes::from(body.to_string())).err().expect("refused");
141
+ assert_eq!(status_of(&refusal), 400, "{body:?}");
142
+ }
143
+ }
144
+ let spent = |limits: &RateLimits| admit(limits, from, &Method::POST, &uri, &HeaderMap::new(), &Bytes::from("{}")).is_ok();
145
+ assert!(spent(&limits), "the one allowed request still goes through after the refusals");
146
+ assert!(!spent(&limits), "and the next is limited: only the valid request spent budget");
147
+ }
148
+
149
+ #[test]
150
+ fn invalid_utf8_and_oversized_bodies_are_answered() {
151
+ let limits = limits(&[]);
152
+ let from = peer("203.0.113.7:5000");
153
+ let uri: Uri = "/x".parse().unwrap();
154
+ let invalid = Bytes::from(vec![0xff, 0xfe, 0xfd]);
155
+ assert_eq!(status_of(&admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &invalid).err().unwrap()), 400);
156
+
157
+ // A 16 MiB body of valid JSON is admitted as text (a size cap belongs to the listener, not here)
158
+ // and a 16 MiB run of openers is refused by the parser's depth limit rather than overflowing.
159
+ guarded("large bodies", move || {
160
+ let big_string = Bytes::from(format!("\"{}\"", "a".repeat(16 * 1024 * 1024)));
161
+ assert!(admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &big_string).is_ok());
162
+ let openers = Bytes::from("[".repeat(16 * 1024 * 1024));
163
+ let refusal = admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &openers).err().expect("refused");
164
+ assert_eq!(status_of(&refusal), 400);
165
+ let nested = Bytes::from(format!("{}1{}", "[".repeat(100_000), "]".repeat(100_000)));
166
+ assert_eq!(status_of(&admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &nested).err().expect("refused")), 400);
167
+ });
168
+ }
169
+
170
+ #[test]
171
+ fn value_to_response_answers_any_shape_of_handler_output() {
172
+ for seed in 0..seeds() {
173
+ guarded(&format!("seed {seed}"), move || {
174
+ let mut rng = Rng::new(seed ^ 0xFACE);
175
+ for _ in 0..30 {
176
+ let mut value = serde_json::Map::new();
177
+ for key in ["statusCode", "headers", "cookies", "body", "isBase64Encoded", "extra"] {
178
+ if rng.chance(4) {
179
+ continue;
180
+ }
181
+ let field = match key {
182
+ "headers" if rng.chance(2) => {
183
+ let mut map = serde_json::Map::new();
184
+ for _ in 0..rng.below(4) {
185
+ map.insert(random_text(&mut rng), random_value(&mut rng, 2));
186
+ }
187
+ Value::Object(map)
188
+ }
189
+ "cookies" if rng.chance(2) => Value::Array((0..rng.below(4)).map(|_| random_value(&mut rng, 2)).collect()),
190
+ "statusCode" if rng.chance(2) => json!(*rng.pick(&[0u64, 99, 100, 199, 200, 204, 299, 301, 404, 599, 600, 999, 65_536, 65_736, u64::MAX])),
191
+ _ => random_value(&mut rng, 1),
192
+ };
193
+ value.insert(key.to_string(), field);
194
+ }
195
+ let response = value_to_response(Value::Object(value));
196
+ // Whatever came in, what goes out is a real status.
197
+ assert!((100..1000).contains(&status_of(&response)), "seed {seed}");
198
+ }
199
+ let plain = value_to_response(random_value(&mut rng, 0));
200
+ assert!((100..1000).contains(&status_of(&plain)), "seed {seed}");
201
+ });
202
+ }
203
+ }
204
+
205
+ #[test]
206
+ fn an_out_of_range_status_code_is_a_500_not_a_wrapped_status() {
207
+ // `65736 as u16` is 200: a handler bug must not turn into a success.
208
+ for code in [0u64, 99, 1000, 65_536, 65_736, 131_272, u64::MAX] {
209
+ let response = value_to_response(json!({ "statusCode": code, "body": "x" }));
210
+ assert_eq!(status_of(&response), 500, "statusCode {code}");
211
+ }
212
+ assert_eq!(status_of(&value_to_response(json!({ "statusCode": 204 }))), 204);
213
+ }
214
+
215
+ #[test]
216
+ fn control_characters_in_response_headers_and_cookies_are_dropped_not_emitted() {
217
+ let response = value_to_response(json!({
218
+ "statusCode": 200,
219
+ "headers": { "x-ok": "fine", "x-split": "a\r\nSet-Cookie: stolen=1", "bad name": "v" },
220
+ "cookies": ["session=abc; Path=/", "evil=1\r\nX-Injected: yes", "nul=\u{0}"],
221
+ "body": ""
222
+ }));
223
+ assert!(response.headers().get("x-split").is_none());
224
+ assert!(response.headers().get("x-injected").is_none());
225
+ let cookies: Vec<&str> = response.headers().get_all("set-cookie").iter().filter_map(|v| v.to_str().ok()).collect();
226
+ assert_eq!(cookies, vec!["session=abc; Path=/"]);
227
+ }
@@ -0,0 +1,125 @@
1
+ //! Seeded fuzz of the web layer's request decoding: percent-decoding, form and query strings,
2
+ //! cookies, base64 bodies, path shaping and the auth gate. Every one takes text an anonymous
3
+ //! caller controls, and none may panic on it.
4
+
5
+ use super::*;
6
+ use crate::fuzz_support::*;
7
+
8
+ #[test]
9
+ fn percent_decoding_never_slices_through_a_multi_byte_character() {
10
+ // `%a` followed by a 4-byte character puts the two-digit hex window mid-character.
11
+ for text in ["%a🍕", "%🍕", "%é", "a%é", "%a日", "%%a🍕", "%1é1", "🍕%", "🍕%a", "%a", "%", "%%", "%4", "%41", "x%e6%97%a5y", "%e6%97", "%ff%fe"] {
12
+ for plus_as_space in [true, false] {
13
+ let decoded = percent_decode_impl(text, plus_as_space);
14
+ assert!(decoded.len() < text.len() * 4 + 4, "{text:?}");
15
+ }
16
+ }
17
+ assert_eq!(percent_decode("%41%2f%2F+x"), "A// x");
18
+ assert_eq!(percent_decode_impl("a+b", false), "a+b");
19
+ }
20
+
21
+ #[test]
22
+ fn percent_decoding_reads_only_two_hex_digits_never_a_sign() {
23
+ // `u8::from_str_radix` accepts a leading `+`, so `%+1` would decode to byte 0x01.
24
+ assert_eq!(percent_decode_impl("%+1", false), "%+1");
25
+ assert_eq!(percent_decode_impl("%-1", false), "%-1");
26
+ assert_eq!(percent_decode_impl("%4g", false), "%4g");
27
+ assert_eq!(percent_decode_impl("%41", false), "A");
28
+ }
29
+
30
+ #[test]
31
+ fn form_and_query_strings_decode_any_text() {
32
+ for seed in 0..seeds() {
33
+ guarded(&format!("seed {seed}"), move || {
34
+ let mut rng = Rng::new(seed);
35
+ for _ in 0..100 {
36
+ let text = random_text(&mut rng);
37
+ let _ = parse_form(&text);
38
+ let _ = parse_query(&text);
39
+ let _ = percent_decode(&text);
40
+ let _ = auth::urlencode(&text);
41
+ // What the encoder emits, the decoder must read back exactly.
42
+ assert_eq!(percent_decode_impl(&auth::urlencode(&text), false), text, "seed {seed}: {text:?}");
43
+ }
44
+ });
45
+ }
46
+ }
47
+
48
+ #[test]
49
+ fn huge_query_strings_decode_in_linear_time() {
50
+ guarded("huge query", || {
51
+ let pairs = (0..200_000).map(|i| format!("k{i}=%41%e6%97%a5")).collect::<Vec<_>>().join("&");
52
+ assert_eq!(parse_query(&pairs).len(), 200_000);
53
+ let _ = parse_form(&"%".repeat(5_000_000));
54
+ let _ = parse_form(&"%a🍕".repeat(1_000_000));
55
+ });
56
+ }
57
+
58
+ #[test]
59
+ fn cookie_extraction_reads_any_event_shape() {
60
+ for seed in 0..seeds() {
61
+ guarded(&format!("seed {seed}"), move || {
62
+ let mut rng = Rng::new(seed ^ 0xC00C);
63
+ for _ in 0..50 {
64
+ let body = match rng.below(4) {
65
+ 0 => random_value(&mut rng, 0),
66
+ 1 => json!({"cookies": random_value(&mut rng, 2)}),
67
+ 2 => json!({"headers": {"cookie": random_value(&mut rng, 1)}}),
68
+ _ => json!({"cookies": [random_text(&mut rng), random_text(&mut rng)], "headers": {"cookie": random_text(&mut rng)}}),
69
+ };
70
+ let _ = extract_cookies(&body);
71
+ }
72
+ });
73
+ }
74
+ }
75
+
76
+ #[test]
77
+ fn base64_bodies_decode_any_text_without_panicking() {
78
+ for seed in 0..seeds() {
79
+ guarded(&format!("seed {seed}"), move || {
80
+ let mut rng = Rng::new(seed ^ 0xB64);
81
+ for _ in 0..100 {
82
+ let text = random_text(&mut rng);
83
+ let _ = base64_decode(&text);
84
+ let _ = String::from_utf8(base64_decode(&text)).unwrap_or_default();
85
+ }
86
+ });
87
+ }
88
+ assert_eq!(base64_decode("aGk="), b"hi");
89
+ assert!(base64_decode("").is_empty());
90
+ }
91
+
92
+ #[test]
93
+ fn paths_and_origins_shape_any_text() {
94
+ for seed in 0..seeds() {
95
+ guarded(&format!("seed {seed}"), move || {
96
+ let mut rng = Rng::new(seed ^ 0x9A7);
97
+ for _ in 0..100 {
98
+ let path = format!("/{}", random_text(&mut rng));
99
+ let _ = json_shaped(&path);
100
+ let _ = split_format(&path);
101
+ let _ = origin_of(&path);
102
+ let _ = same_origin(&path, &random_text(&mut rng));
103
+ let _ = humanize(&path);
104
+ let _ = esc(&path);
105
+ for authenticated in [true, false] {
106
+ if let Some(refusal) = auth_gate(&path, authenticated) {
107
+ let status = refusal["statusCode"].as_u64().expect("a status");
108
+ assert!(status == 401 || status == 302, "seed {seed}: {path:?} gated with {status}");
109
+ }
110
+ }
111
+ }
112
+ });
113
+ }
114
+ }
115
+
116
+ #[test]
117
+ fn an_unauthenticated_request_is_never_let_past_the_gate_by_a_path_trick() {
118
+ // Only the four sign-in paths are open; near-misses must be gated.
119
+ for path in ["/login/", "/LOGIN", "/login%2f", "//login", "/login/../admin", "/auth/google/", "/auth/google/callback/x", "/logout.json", "/./login", "/login?x", "/login "] {
120
+ assert!(auth_gate(path, false).is_some(), "{path:?} must be gated for an anonymous caller");
121
+ }
122
+ for path in ["/login", "/logout", "/auth/google", "/auth/google/callback"] {
123
+ assert!(auth_gate(path, false).is_none(), "{path:?} is the sign-in path");
124
+ }
125
+ }