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,165 @@
1
+ //! Shared pieces of the boundary fuzz tests that sit beside `api`, `server`, `web`, `auth`,
2
+ //! `expr_json` and `ir`: a seeded generator, a guard that fails on a panic or a hang, and
3
+ //! generators for hostile text and JSON. Std-only, so a failing case is named by its seed alone.
4
+ //!
5
+ //! Run more seeds with `HOST_FUZZ_SEEDS=2000 cargo test fuzz`.
6
+
7
+ use serde_json::{json, Map, Value};
8
+ use std::sync::mpsc;
9
+ use std::time::Duration;
10
+
11
+ /// xorshift64*: no dependency, and the seed is the whole reproduction.
12
+ pub(crate) struct Rng(u64);
13
+
14
+ impl Rng {
15
+ pub(crate) fn new(seed: u64) -> Self {
16
+ Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
17
+ }
18
+
19
+ pub(crate) fn next(&mut self) -> u64 {
20
+ self.0 ^= self.0 >> 12;
21
+ self.0 ^= self.0 << 25;
22
+ self.0 ^= self.0 >> 27;
23
+ self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
24
+ }
25
+
26
+ pub(crate) fn below(&mut self, n: usize) -> usize {
27
+ (self.next() % n.max(1) as u64) as usize
28
+ }
29
+
30
+ pub(crate) fn chance(&mut self, one_in: usize) -> bool {
31
+ self.below(one_in) == 0
32
+ }
33
+
34
+ pub(crate) fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
35
+ &items[self.below(items.len())]
36
+ }
37
+ }
38
+
39
+ /// How many seeds each fuzz test runs.
40
+ pub(crate) fn seeds() -> u64 {
41
+ std::env::var("HOST_FUZZ_SEEDS").ok().and_then(|v| v.parse().ok()).unwrap_or(200)
42
+ }
43
+
44
+ /// The most a single guarded case may take before it counts as a hang.
45
+ pub(crate) const LIMIT: Duration = Duration::from_secs(20);
46
+
47
+ /// Runs `f` on a thread with a 2 MiB stack (a tokio worker's default) and returns its answer.
48
+ /// A panic or a missed `LIMIT` fails the test and names `label`; a stack overflow aborts the
49
+ /// whole binary, which fails it too.
50
+ pub(crate) fn guarded<T: Send + 'static>(label: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
51
+ let (tx, rx) = mpsc::channel();
52
+ let handle = std::thread::Builder::new()
53
+ .stack_size(2 * 1024 * 1024)
54
+ .spawn(move || {
55
+ let _ = tx.send(f());
56
+ })
57
+ .expect("spawn a guarded thread");
58
+ match rx.recv_timeout(LIMIT) {
59
+ Ok(value) => {
60
+ handle.join().expect("guarded thread joined");
61
+ value
62
+ }
63
+ Err(mpsc::RecvTimeoutError::Timeout) => panic!("{label}: no answer within {LIMIT:?} (hang)"),
64
+ Err(mpsc::RecvTimeoutError::Disconnected) => {
65
+ let panic = handle.join().expect_err("a guarded thread that sent nothing must have panicked");
66
+ let message = panic.downcast_ref::<String>().cloned().or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()));
67
+ panic!("{label}: panicked: {}", message.unwrap_or_default())
68
+ }
69
+ }
70
+ }
71
+
72
+ /// Pieces that break naive text handling: multi-byte characters next to ASCII delimiters (the
73
+ /// shape that slices through a char boundary), control characters, NUL, bidi marks, astral
74
+ /// planes, combining marks, percent signs, and every JSON/URL/cookie delimiter.
75
+ const PIECES: &[&str] = &[
76
+ "a", "Z", "0", "9", " ", "\"", "'", "\\", "/", "%", "%4", "%41", "%zz", "%+1", "+", "=", "&", ";", "?", "#", ".", "..", "-", "_", ":", ",", "{", "}", "[", "]",
77
+ "\n", "\t", "\r", "\u{0}", "\u{1f}", "\u{7f}", "é", "ü", "日本", "🍕", "\u{10FFFF}", "\u{200B}", "\u{FEFF}", "\u{202E}", "\u{FFFD}", "e\u{301}", "__", "::",
78
+ "<script>", "../", "%0d%0a", "id", "name", "value", "sort", "direction", "desc",
79
+ ];
80
+
81
+ pub(crate) fn random_text(rng: &mut Rng) -> String {
82
+ (0..rng.below(10)).map(|_| *rng.pick(PIECES)).collect()
83
+ }
84
+
85
+ /// Edge-case numbers a JSON consumer is likely to mishandle, as JSON values.
86
+ pub(crate) fn edge_number(rng: &mut Rng) -> Value {
87
+ let edges: Vec<Value> = vec![
88
+ json!(0),
89
+ json!(-1),
90
+ json!(1),
91
+ json!(i64::MAX),
92
+ json!(i64::MIN),
93
+ json!(u64::MAX),
94
+ json!(0.0),
95
+ json!(-0.0),
96
+ json!(0.1),
97
+ json!(1e308),
98
+ json!(-1e308),
99
+ json!(5e-324),
100
+ json!(1.7976931348623157e308),
101
+ json!(9007199254740993_i64),
102
+ ];
103
+ rng.pick(&edges).clone()
104
+ }
105
+
106
+ /// A JSON value of any shape, to a bounded depth, favoring the wrong type for whatever reads it.
107
+ pub(crate) fn random_value(rng: &mut Rng, depth: usize) -> Value {
108
+ let leaf_only = depth >= 3;
109
+ match rng.below(if leaf_only { 6 } else { 8 }) {
110
+ 0 => Value::Null,
111
+ 1 => Value::Bool(rng.chance(2)),
112
+ 2 => edge_number(rng),
113
+ 3 | 4 => Value::String(random_text(rng)),
114
+ 5 => json!(rng.next() as i64 >> rng.below(63)),
115
+ 6 => Value::Array((0..rng.below(4)).map(|_| random_value(rng, depth + 1)).collect()),
116
+ _ => {
117
+ let mut map = Map::new();
118
+ for _ in 0..rng.below(4) {
119
+ map.insert(random_text(rng), random_value(rng, depth + 1));
120
+ }
121
+ Value::Object(map)
122
+ }
123
+ }
124
+ }
125
+
126
+ /// `text` with a few random edits, on char boundaries.
127
+ pub(crate) fn mutate_text(rng: &mut Rng, text: &str) -> String {
128
+ let mut chars: Vec<char> = text.chars().collect();
129
+ const JUNK: &[char] = &['{', '}', '[', ']', '"', '\\', ',', ':', '-', '+', '.', 'e', '0', '9', 'n', 't', ' ', '\u{0}', '\n', '%', 'é', '🍕'];
130
+ for _ in 0..1 + rng.below(3) {
131
+ if chars.is_empty() {
132
+ chars.push(*rng.pick(JUNK));
133
+ continue;
134
+ }
135
+ let at = rng.below(chars.len());
136
+ match rng.below(4) {
137
+ 0 => {
138
+ chars.remove(at);
139
+ }
140
+ 1 => chars.insert(at, *rng.pick(JUNK)),
141
+ 2 => chars[at] = *rng.pick(JUNK),
142
+ _ => chars.truncate(at),
143
+ }
144
+ }
145
+ chars.into_iter().collect()
146
+ }
147
+
148
+ /// Raw bytes, valid UTF-8 or not: random, mutated JSON, truncated mid-character, or hostile.
149
+ pub(crate) fn random_bytes(rng: &mut Rng) -> Vec<u8> {
150
+ match rng.below(6) {
151
+ 0 => (0..rng.below(64)).map(|_| rng.next() as u8).collect(),
152
+ 1 => {
153
+ let valid = random_value(rng, 0).to_string();
154
+ mutate_text(rng, &valid).into_bytes()
155
+ }
156
+ 2 => {
157
+ let mut bytes = random_value(rng, 0).to_string().into_bytes();
158
+ bytes.extend_from_slice(&[0xff, 0xfe, 0xc0, 0x80, 0xf0, 0x9f]);
159
+ bytes
160
+ }
161
+ 3 => "{\"a\":\"🍕\"}".as_bytes()[..rng.below(10)].to_vec(),
162
+ 4 => random_text(rng).into_bytes(),
163
+ _ => random_value(rng, 0).to_string().into_bytes(),
164
+ }
165
+ }
data/rust/host/src/ir.rs CHANGED
@@ -132,300 +132,6 @@ pub fn identity_provider(domain_ir: &Value) -> Option<IdentityProvider> {
132
132
  })
133
133
  }
134
134
 
135
- /// The chapter that answers the guest newsletter signup, read from
136
- /// `ir.json`'s own `newsletter` key. `None` when nothing provides it.
137
- #[derive(Debug, Clone, PartialEq)]
138
- pub struct NewsletterProvider {
139
- /// Chapter name, e.g. `Newsletter`.
140
- pub provider: String,
141
- /// Qualified subscribe command, e.g. `Newsletter::Subscriber.Subscribe`.
142
- pub subscribe: String,
143
- /// Qualified add-name command, e.g. `Newsletter::Subscriber.AddName`.
144
- pub add_name: String,
145
- /// Qualified confirm command, e.g. `Newsletter::Subscriber.Confirm`.
146
- pub confirm: String,
147
- /// Qualified unsubscribe command, e.g. `Newsletter::Subscriber.Unsubscribe`.
148
- pub unsubscribe: String,
149
- /// Qualified subscribing aggregate, e.g. `Newsletter::Subscriber`.
150
- pub aggregate: String,
151
- }
152
-
153
- impl NewsletterProvider {
154
- /// The prefix every subscriber's key in a `dispatch::read` `instances`
155
- /// map starts with, e.g. `Newsletter::Subscriber#`.
156
- pub fn instance_prefix(&self) -> String {
157
- format!("{}#", self.aggregate)
158
- }
159
- }
160
-
161
- /// Whether the domain's own `aggregate`'s `command` declares an argument
162
- /// named `field` — so a route only sends an argument the command declares.
163
- pub fn command_declares(domain_ir: &Value, aggregate: &str, command: &str, field: &str) -> bool {
164
- let named = |value: &Value, name: &str| value.get("name").and_then(|n| n.as_str()) == Some(name);
165
- let Some(aggregates) = domain_ir.get("aggregates").and_then(|a| a.as_array()) else {
166
- return false;
167
- };
168
- aggregates
169
- .iter()
170
- .filter(|a| named(a, aggregate))
171
- .filter_map(|a| a.get("commands").and_then(|c| c.as_array()))
172
- .flatten()
173
- .filter(|c| named(c, command))
174
- .filter_map(|c| c.get("attributes").and_then(|a| a.as_array()))
175
- .flatten()
176
- .any(|attribute| named(attribute, field))
177
- }
178
-
179
- pub fn newsletter_provider(domain_ir: &Value) -> Option<NewsletterProvider> {
180
- let fact = domain_ir.get("newsletter")?;
181
- Some(NewsletterProvider {
182
- provider: fact.get("provider")?.as_str()?.to_string(),
183
- subscribe: fact.get("subscribe")?.as_str()?.to_string(),
184
- add_name: fact.get("add_name")?.as_str()?.to_string(),
185
- confirm: fact.get("confirm")?.as_str()?.to_string(),
186
- unsubscribe: fact.get("unsubscribe")?.as_str()?.to_string(),
187
- aggregate: fact.get("aggregate")?.as_str()?.to_string(),
188
- })
189
- }
190
-
191
- /// The chapter that declares `provides "newsletter_issues"`, read from
192
- /// `ir.json`'s own `newsletter_issues` key. Omitted when nothing declares it.
193
- #[derive(Debug, Clone, PartialEq)]
194
- pub struct NewsletterIssuesProvider {
195
- /// Qualified send command, e.g. `Newsletter::Issue.Send`.
196
- pub send_issue: String,
197
- /// Qualified delivery-record command, e.g. `Newsletter::Delivery.Record`.
198
- pub record_delivery: String,
199
- /// Qualified issue aggregate, e.g. `Newsletter::Issue`.
200
- pub issue_aggregate: String,
201
- }
202
-
203
- impl NewsletterIssuesProvider {
204
- /// The prefix every issue's key in a `dispatch::read` `instances` map
205
- /// starts with, e.g. `Newsletter::Issue#`.
206
- pub fn issue_prefix(&self) -> String {
207
- format!("{}#", self.issue_aggregate)
208
- }
209
- }
210
-
211
- /// The declared issue-sending verbs, or `None` when the IR has no
212
- /// `newsletter_issues` key.
213
- pub fn newsletter_issues_provider(domain_ir: &Value) -> Option<NewsletterIssuesProvider> {
214
- let fact = domain_ir.get("newsletter_issues")?;
215
- Some(NewsletterIssuesProvider {
216
- send_issue: fact.get("send_issue")?.as_str()?.to_string(),
217
- record_delivery: fact.get("record_delivery")?.as_str()?.to_string(),
218
- issue_aggregate: fact.get("issue_aggregate")?.as_str()?.to_string(),
219
- })
220
- }
221
-
222
-
223
- /// The chapter that takes payments, read from `ir.json`'s own `payments`
224
- /// key. `None` when nothing provides it.
225
- #[derive(Debug, Clone, PartialEq)]
226
- pub struct PaymentsProvider {
227
- /// Chapter name, e.g. `Payments`.
228
- pub provider: String,
229
- /// Qualified initiate command, e.g. `Payments::Payment.Initiate`.
230
- pub initiate: String,
231
- /// Qualified port operation the processor's success report dispatches,
232
- /// e.g. `Payments::Payment.PaymentGateway.Succeeded`.
233
- pub succeeded: String,
234
- /// Qualified port operation the processor's failure report dispatches,
235
- /// e.g. `Payments::Payment.PaymentGateway.Failed`.
236
- pub failed: String,
237
- /// Qualified paying aggregate, e.g. `Payments::Payment`.
238
- pub aggregate: String,
239
- }
240
-
241
- impl PaymentsProvider {
242
- /// The prefix every payment's key in a `dispatch::read` `instances` map
243
- /// starts with, e.g. `Payments::Payment#`.
244
- pub fn instance_prefix(&self) -> String {
245
- format!("{}#", self.aggregate)
246
- }
247
- }
248
-
249
- pub fn payments_provider(domain_ir: &Value) -> Option<PaymentsProvider> {
250
- let fact = domain_ir.get("payments")?;
251
- Some(PaymentsProvider {
252
- provider: fact.get("provider")?.as_str()?.to_string(),
253
- initiate: fact.get("initiate")?.as_str()?.to_string(),
254
- succeeded: fact.get("succeeded")?.as_str()?.to_string(),
255
- failed: fact.get("failed")?.as_str()?.to_string(),
256
- aggregate: fact.get("aggregate")?.as_str()?.to_string(),
257
- })
258
- }
259
-
260
- /// The chapter that answers scheduling sessions and taking registrations,
261
- /// read from `ir.json`'s own `registrations` key. `None` when nothing provides it.
262
- #[derive(Debug, Clone, PartialEq)]
263
- pub struct RegistrationsProvider {
264
- /// Chapter name, e.g. `Studio`.
265
- pub provider: String,
266
- /// Qualified schedule command, e.g. `Studio::Event.Schedule`.
267
- pub schedule: String,
268
- /// Qualified request command, e.g. `Studio::Registration.Request`.
269
- pub request: String,
270
- /// Qualified event aggregate, e.g. `Studio::Event`.
271
- pub event_aggregate: String,
272
- /// Qualified registration aggregate, e.g. `Studio::Registration`.
273
- pub registration_aggregate: String,
274
- }
275
-
276
- impl RegistrationsProvider {
277
- /// The conventional names for a domain that declares no
278
- /// `provides "registrations"` — `Event`/`Registration` under the domain itself.
279
- pub fn conventional(domain: &str) -> Self {
280
- Self {
281
- provider: domain.to_string(),
282
- schedule: format!("{domain}::Event.Schedule"),
283
- request: format!("{domain}::Registration.Request"),
284
- event_aggregate: format!("{domain}::Event"),
285
- registration_aggregate: format!("{domain}::Registration"),
286
- }
287
- }
288
-
289
- /// The prefix every event's key in a `dispatch::read` `instances` map
290
- /// starts with, e.g. `Studio::Event#`.
291
- pub fn event_prefix(&self) -> String {
292
- format!("{}#", self.event_aggregate)
293
- }
294
-
295
- /// The prefix every registration's key starts with, e.g.
296
- /// `Studio::Registration#`.
297
- pub fn registration_prefix(&self) -> String {
298
- format!("{}#", self.registration_aggregate)
299
- }
300
-
301
- /// The request command's own aggregate and command name without the
302
- /// chapter qualifier, e.g. `("Registration", "Request")`, as `command_declares` expects.
303
- pub fn request_target(&self) -> Option<(&str, &str)> {
304
- self.request.rsplit("::").next()?.split_once('.')
305
- }
306
- }
307
-
308
- pub fn registrations_provider(domain_ir: &Value) -> Option<RegistrationsProvider> {
309
- let fact = domain_ir.get("registrations")?;
310
- Some(RegistrationsProvider {
311
- provider: fact.get("provider")?.as_str()?.to_string(),
312
- schedule: fact.get("schedule")?.as_str()?.to_string(),
313
- request: fact.get("request")?.as_str()?.to_string(),
314
- event_aggregate: fact.get("event_aggregate")?.as_str()?.to_string(),
315
- registration_aggregate: fact.get("registration_aggregate")?.as_str()?.to_string(),
316
- })
317
- }
318
-
319
- /// The registrations binding of the IR this host loaded, or the
320
- /// conventional names for `domain` when the IR declares none.
321
- pub fn registrations_binding(domain: &str) -> RegistrationsProvider {
322
- ir().and_then(registrations_provider).unwrap_or_else(|| RegistrationsProvider::conventional(domain))
323
- }
324
-
325
- /// One command of the payment-processor connection, by the role it plays —
326
- /// resolved through `PaymentConnectionProvider::verb`.
327
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
328
- pub enum ConnectionVerb {
329
- Connect,
330
- Reconnect,
331
- Disconnect,
332
- Suspend,
333
- Resume,
334
- Enable,
335
- Disable,
336
- }
337
-
338
- /// The chapter that owns the business's payment-processor connection, read
339
- /// from `ir.json`'s own `payment_connection` key. `None` when nothing provides it.
340
- #[derive(Debug, Clone, PartialEq)]
341
- pub struct PaymentConnectionProvider {
342
- /// Chapter name, e.g. `Studio`.
343
- pub provider: String,
344
- pub connect: String,
345
- pub reconnect: String,
346
- pub disconnect: String,
347
- pub suspend: String,
348
- pub resume: String,
349
- pub enable: String,
350
- pub disable: String,
351
- /// Qualified connection aggregate, e.g. `Studio::PaymentConnection`.
352
- pub aggregate: String,
353
- }
354
-
355
- impl PaymentConnectionProvider {
356
- /// The conventional names for a domain that declares no
357
- /// `provides "payment_connection"` — `PaymentConnection` under the domain itself.
358
- pub fn conventional(domain: &str) -> Self {
359
- let qualified = |command: &str| format!("{domain}::PaymentConnection.{command}");
360
- Self {
361
- provider: domain.to_string(),
362
- connect: qualified("Connect"),
363
- reconnect: qualified("Reconnect"),
364
- disconnect: qualified("Disconnect"),
365
- suspend: qualified("Suspend"),
366
- resume: qualified("Resume"),
367
- enable: qualified("EnablePayments"),
368
- disable: qualified("DisablePayments"),
369
- aggregate: format!("{domain}::PaymentConnection"),
370
- }
371
- }
372
-
373
- /// The prefix every connection's key in a `dispatch::read` `instances`
374
- /// map starts with, e.g. `Studio::PaymentConnection#`.
375
- pub fn instance_prefix(&self) -> String {
376
- format!("{}#", self.aggregate)
377
- }
378
-
379
- /// The qualified command that plays `which`.
380
- pub fn verb(&self, which: ConnectionVerb) -> &str {
381
- match which {
382
- ConnectionVerb::Connect => &self.connect,
383
- ConnectionVerb::Reconnect => &self.reconnect,
384
- ConnectionVerb::Disconnect => &self.disconnect,
385
- ConnectionVerb::Suspend => &self.suspend,
386
- ConnectionVerb::Resume => &self.resume,
387
- ConnectionVerb::Enable => &self.enable,
388
- ConnectionVerb::Disable => &self.disable,
389
- }
390
- }
391
- }
392
-
393
- pub fn payment_connection_provider(domain_ir: &Value) -> Option<PaymentConnectionProvider> {
394
- let fact = domain_ir.get("payment_connection")?;
395
- let text = |key: &str| Some(fact.get(key)?.as_str()?.to_string());
396
- Some(PaymentConnectionProvider {
397
- provider: text("provider")?,
398
- connect: text("connect")?,
399
- reconnect: text("reconnect")?,
400
- disconnect: text("disconnect")?,
401
- suspend: text("suspend")?,
402
- resume: text("resume")?,
403
- enable: text("enable")?,
404
- disable: text("disable")?,
405
- aggregate: text("aggregate")?,
406
- })
407
- }
408
-
409
- /// The payment-connection binding of the IR this host loaded, or the
410
- /// conventional names for `domain` when the IR declares none.
411
- pub fn payment_connection_binding(domain: &str) -> PaymentConnectionProvider {
412
- ir().and_then(payment_connection_provider).unwrap_or_else(|| PaymentConnectionProvider::conventional(domain))
413
- }
414
-
415
- /// The checkout fixture's own payments binding, read from its committed
416
- /// `ir.json` — what the checkout routes' tests dispatch against.
417
- #[cfg(test)]
418
- pub fn fixture_ir() -> Value {
419
- let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/generated/checkout_fixture/ir.json");
420
- serde_json::from_str(&std::fs::read_to_string(path).expect("checkout_fixture ir.json")).expect("valid json")
421
- }
422
-
423
- /// The checkout fixture's own payments binding.
424
- #[cfg(test)]
425
- pub fn fixture_payments() -> PaymentsProvider {
426
- payments_provider(&fixture_ir()).expect("the checkout fixture provides payments")
427
- }
428
-
429
135
  /// Refuses at boot rather than silently building a second, disjoint history
430
136
  /// nothing but this runtime reads while the real state lives elsewhere.
431
137
  pub fn refuse_unsupported_persistence_adapters(domain_ir: &Value) -> Result<(), String> {
@@ -570,160 +276,6 @@ mod tests {
570
276
  assert!(identity_provider(&serde_json::json!({ "name": "Pizzas" })).is_none());
571
277
  }
572
278
 
573
- #[test]
574
- fn newsletter_provider_reads_the_declared_capability() {
575
- let ir = serde_json::json!({
576
- "name": "Studio",
577
- "newsletter": {
578
- "provider": "Newsletter",
579
- "subscribe": "Newsletter::Subscriber.Subscribe",
580
- "add_name": "Newsletter::Subscriber.AddName",
581
- "confirm": "Newsletter::Subscriber.Confirm",
582
- "unsubscribe": "Newsletter::Subscriber.Unsubscribe",
583
- "aggregate": "Newsletter::Subscriber"
584
- }
585
- });
586
- let provider = newsletter_provider(&ir).expect("should find newsletter");
587
- assert_eq!(provider.provider, "Newsletter");
588
- assert_eq!(provider.subscribe, "Newsletter::Subscriber.Subscribe");
589
- assert_eq!(provider.add_name, "Newsletter::Subscriber.AddName");
590
- assert_eq!(provider.confirm, "Newsletter::Subscriber.Confirm");
591
- assert_eq!(provider.unsubscribe, "Newsletter::Subscriber.Unsubscribe");
592
- assert_eq!(provider.instance_prefix(), "Newsletter::Subscriber#");
593
- assert!(newsletter_provider(&serde_json::json!({ "name": "Pizzas" })).is_none());
594
- }
595
-
596
- #[test]
597
- fn newsletter_issues_provider_reads_the_declared_capability() {
598
- let ir = serde_json::json!({
599
- "name": "Studio",
600
- "newsletter_issues": {
601
- "provider": "Newsletter",
602
- "send_issue": "Newsletter::Issue.Send",
603
- "record_delivery": "Newsletter::Delivery.Record",
604
- "issue_aggregate": "Newsletter::Issue",
605
- "delivery_aggregate": "Newsletter::Delivery"
606
- }
607
- });
608
- let provider = newsletter_issues_provider(&ir).expect("should find newsletter_issues");
609
- assert_eq!(provider.send_issue, "Newsletter::Issue.Send");
610
- assert_eq!(provider.record_delivery, "Newsletter::Delivery.Record");
611
- assert_eq!(provider.issue_prefix(), "Newsletter::Issue#");
612
- assert!(newsletter_issues_provider(&serde_json::json!({ "name": "Pizzas" })).is_none());
613
- }
614
-
615
- #[test]
616
- fn payments_provider_reads_the_declared_capability() {
617
- let ir = serde_json::json!({
618
- "name": "Studio",
619
- "payments": {
620
- "provider": "Payments",
621
- "initiate": "Payments::Payment.Initiate",
622
- "succeeded": "Payments::Payment.PaymentGateway.Succeeded",
623
- "failed": "Payments::Payment.PaymentGateway.Failed",
624
- "aggregate": "Payments::Payment"
625
- }
626
- });
627
- let provider = payments_provider(&ir).expect("should find payments");
628
- assert_eq!(provider.provider, "Payments");
629
- assert_eq!(provider.initiate, "Payments::Payment.Initiate");
630
- assert_eq!(provider.succeeded, "Payments::Payment.PaymentGateway.Succeeded");
631
- assert_eq!(provider.failed, "Payments::Payment.PaymentGateway.Failed");
632
- assert_eq!(provider.instance_prefix(), "Payments::Payment#");
633
- assert!(payments_provider(&serde_json::json!({ "name": "Pizzas" })).is_none());
634
- }
635
-
636
- #[test]
637
- fn registrations_provider_reads_the_declared_capability() {
638
- let provider = registrations_provider(&fixture_ir()).expect("the checkout fixture provides registrations");
639
- assert_eq!(provider.schedule, "CheckoutFixture::Event.Schedule");
640
- assert_eq!(provider.request, "CheckoutFixture::Registration.Request");
641
- assert_eq!(provider.event_prefix(), "CheckoutFixture::Event#");
642
- assert_eq!(provider.registration_prefix(), "CheckoutFixture::Registration#");
643
- assert_eq!(provider.request_target(), Some(("Registration", "Request")));
644
- }
645
-
646
- #[test]
647
- fn the_conventional_registrations_names_match_what_the_fixture_declares() {
648
- assert_eq!(registrations_provider(&fixture_ir()), Some(RegistrationsProvider::conventional("CheckoutFixture")));
649
- }
650
-
651
- #[test]
652
- fn a_declared_registrations_binding_names_whatever_the_chapter_calls_things() {
653
- let ir = serde_json::json!({"registrations": {
654
- "provider": "Bookings",
655
- "schedule": "Bookings::Session.Plan",
656
- "request": "Bookings::Seat.Claim",
657
- "event_aggregate": "Bookings::Session",
658
- "registration_aggregate": "Bookings::Seat"
659
- }});
660
- let provider = registrations_provider(&ir).expect("declared");
661
- assert_eq!(provider.event_prefix(), "Bookings::Session#");
662
- assert_eq!(provider.registration_prefix(), "Bookings::Seat#");
663
- assert_eq!(provider.request_target(), Some(("Seat", "Claim")));
664
- }
665
-
666
- #[test]
667
- fn registrations_provider_is_none_without_the_key() {
668
- assert_eq!(registrations_provider(&serde_json::json!({"name": "Pizzas"})), None);
669
- }
670
-
671
- #[test]
672
- fn registrations_binding_falls_back_to_the_conventional_names_when_no_ir_is_loaded() {
673
- assert_eq!(registrations_binding("Shop"), RegistrationsProvider::conventional("Shop"));
674
- }
675
-
676
- #[test]
677
- fn payment_connection_provider_reads_the_declared_capability() {
678
- let provider = payment_connection_provider(&fixture_ir()).expect("the checkout fixture provides payment_connection");
679
- assert_eq!(provider.verb(ConnectionVerb::Connect), "CheckoutFixture::PaymentConnection.Connect");
680
- assert_eq!(provider.verb(ConnectionVerb::Enable), "CheckoutFixture::PaymentConnection.EnablePayments");
681
- assert_eq!(provider.verb(ConnectionVerb::Disable), "CheckoutFixture::PaymentConnection.DisablePayments");
682
- assert_eq!(provider.instance_prefix(), "CheckoutFixture::PaymentConnection#");
683
- }
684
-
685
- #[test]
686
- fn the_conventional_payment_connection_names_match_what_the_fixture_declares() {
687
- assert_eq!(payment_connection_provider(&fixture_ir()), Some(PaymentConnectionProvider::conventional("CheckoutFixture")));
688
- }
689
-
690
- #[test]
691
- fn a_declared_payment_connection_binding_names_whatever_the_chapter_calls_things() {
692
- let ir = serde_json::json!({"payment_connection": {
693
- "provider": "Billing",
694
- "connect": "Billing::Link.Open", "reconnect": "Billing::Link.Reopen",
695
- "disconnect": "Billing::Link.Close", "suspend": "Billing::Link.Pause",
696
- "resume": "Billing::Link.Unpause", "enable": "Billing::Link.TurnOn",
697
- "disable": "Billing::Link.TurnOff", "aggregate": "Billing::Link"
698
- }});
699
- let provider = payment_connection_provider(&ir).expect("declared");
700
- assert_eq!(provider.verb(ConnectionVerb::Suspend), "Billing::Link.Pause");
701
- assert_eq!(provider.verb(ConnectionVerb::Resume), "Billing::Link.Unpause");
702
- assert_eq!(provider.instance_prefix(), "Billing::Link#");
703
- }
704
-
705
- #[test]
706
- fn payment_connection_binding_falls_back_to_the_conventional_names_when_no_ir_is_loaded() {
707
- assert_eq!(payment_connection_binding("Shop"), PaymentConnectionProvider::conventional("Shop"));
708
- }
709
-
710
- #[test]
711
- fn command_declares_finds_an_argument_on_the_named_command_only() {
712
- let ir = serde_json::json!({
713
- "aggregates": [
714
- {"name": "Registration", "commands": [
715
- {"name": "Request", "attributes": [{"name": "email"}, {"name": "news_signup"}]},
716
- {"name": "Cancel", "attributes": [{"name": "reason"}]}
717
- ]},
718
- {"name": "Event", "commands": [{"name": "Request", "attributes": [{"name": "slug"}]}]}
719
- ]
720
- });
721
- assert!(command_declares(&ir, "Registration", "Request", "news_signup"));
722
- assert!(!command_declares(&ir, "Registration", "Cancel", "news_signup"));
723
- assert!(!command_declares(&ir, "Event", "Request", "news_signup"));
724
- assert!(!command_declares(&ir, "Registration", "Request", "phone"));
725
- assert!(!command_declares(&serde_json::json!({"name": "Pizzas"}), "Registration", "Request", "news_signup"));
726
- }
727
279
 
728
280
  #[test]
729
281
  fn refuse_unsupported_persistence_adapters_passes_a_domain_with_no_persistence_key() {
@@ -731,3 +283,7 @@ mod tests {
731
283
  assert!(refuse_unsupported_persistence_adapters(&ir).is_ok());
732
284
  }
733
285
  }
286
+
287
+ #[cfg(test)]
288
+ #[path = "boundary_fuzz/ir.rs"]
289
+ mod boundary_fuzz;
@@ -334,7 +334,7 @@ fn storage_name(qualified_aggregate: &str) -> String {
334
334
 
335
335
  /// Quotes a Postgres identifier `PG::Connection.quote_ident`-style —
336
336
  /// needed since table names can't be bound as query parameters.
337
- pub(crate) fn quote_ident(name: &str) -> String {
337
+ pub fn quote_ident(name: &str) -> String {
338
338
  format!("\"{}\"", name.replace('"', "\"\""))
339
339
  }
340
340
 
@@ -0,0 +1,39 @@
1
+ //! The host as a library: the modules the `bootstrap` binary boots, plus the harness binaries' shared code.
2
+ //! Commerce (payments, checkout, newsletter, registrations, mail) plugs in through [`extension::HostExtension`].
3
+
4
+ pub mod api;
5
+ pub mod approval;
6
+ pub mod auth;
7
+ pub mod checkout;
8
+ pub mod commerce_ir;
9
+ pub mod dispatch;
10
+ pub mod expr_json;
11
+ pub mod extension;
12
+ pub mod field_hints;
13
+ #[cfg(test)]
14
+ pub mod fuzz_support;
15
+ #[cfg(test)]
16
+ #[path = "boundary_fuzz/expr_json.rs"]
17
+ pub mod expr_json_fuzz;
18
+ pub mod ir;
19
+ pub mod journal;
20
+ pub mod lambda_client;
21
+ pub mod log;
22
+ pub mod mint;
23
+ pub mod needs;
24
+ pub mod payments;
25
+ pub mod presentation;
26
+ pub mod presentation_write;
27
+ pub mod query_step;
28
+ pub mod rate_limit;
29
+ pub mod reference_transform;
30
+ pub mod reference_validate;
31
+ pub mod resend;
32
+ pub mod secrets;
33
+ pub mod server;
34
+ pub mod storage_shape;
35
+ #[cfg(test)]
36
+ pub mod test_pg;
37
+ pub mod ui_schema;
38
+ pub mod wasm_runner;
39
+ pub mod web;