cfsolve 0.1.1 → 0.1.2

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +10 -9
  4. data/UPSTREAM.md +6 -6
  5. data/lib/cfsolve/profiles/safari-26-ios/answers.json +2562 -1887
  6. data/lib/cfsolve/profiles/safari-26-ios/challenge-page-answers.json +1873 -1198
  7. data/lib/cfsolve/profiles/safari-26-ios.json +0 -8
  8. data/lib/cfsolve/version.rb +2 -2
  9. data/vendor/cfsolve/README.md +0 -35
  10. data/vendor/cfsolve/examples/build_profile.rs +17 -15
  11. data/vendor/cfsolve/examples/capture_session.rs +37 -0
  12. data/vendor/cfsolve/examples/check_answers.rs +66 -0
  13. data/vendor/cfsolve/examples/compare_session.rs +190 -0
  14. data/vendor/cfsolve/examples/stage2_snapshot.rs +4 -1
  15. data/vendor/cfsolve/profiles/safari-26-ios/answers.json +2562 -1887
  16. data/vendor/cfsolve/profiles/safari-26-ios/challenge-page-answers.json +1873 -1198
  17. data/vendor/cfsolve/profiles/safari-26-ios.json +0 -8
  18. data/vendor/cfsolve/scripts/capture_cssom.mjs +42 -0
  19. data/vendor/cfsolve/scripts/serve_cssom.py +111 -0
  20. data/vendor/cfsolve/src/capture/profile.rs +7 -18
  21. data/vendor/cfsolve/src/capture/recorded.rs +42 -7
  22. data/vendor/cfsolve/src/challenge/installed/audit.rs +131 -0
  23. data/vendor/cfsolve/src/challenge/installed.rs +122 -271
  24. data/vendor/cfsolve/src/challenge/payload.rs +107 -5
  25. data/vendor/cfsolve/src/challenge/state.rs +98 -16
  26. data/vendor/cfsolve/src/deobfuscate/encoded_strings.rs +3 -2
  27. data/vendor/cfsolve/src/deobfuscate/helper_objects.rs +17 -32
  28. data/vendor/cfsolve/src/deobfuscate/register_arrays.rs +18 -5
  29. data/vendor/cfsolve/src/embedding_page/extra_params.rs +0 -14
  30. data/vendor/cfsolve/src/embedding_page/page.rs +28 -2
  31. data/vendor/cfsolve/src/http/client.rs +11 -0
  32. data/vendor/cfsolve/src/js/ast.rs +12 -3
  33. data/vendor/cfsolve/src/js/objects.rs +9 -2
  34. data/vendor/cfsolve/src/listing/fold.rs +0 -83
  35. data/vendor/cfsolve/src/listing/query.rs +25 -244
  36. data/vendor/cfsolve/src/listing.rs +0 -1
  37. data/vendor/cfsolve/src/session/initial.rs +14 -5
  38. data/vendor/cfsolve/src/session/probes.rs +160 -125
  39. data/vendor/cfsolve/src/session/timing.rs +58 -53
  40. data/vendor/cfsolve/src/session/widget.rs +5 -6
  41. data/vendor/cfsolve/src/stage2/analysis/analyze.rs +50 -33
  42. data/vendor/cfsolve/src/stage2/analysis/discovery.rs +61 -66
  43. data/vendor/cfsolve/src/stage2/analysis/envelope.rs +31 -21
  44. data/vendor/cfsolve/src/stage2/analysis/recognize.rs +3 -5
  45. data/vendor/cfsolve/src/stage2/analysis.rs +0 -1
  46. data/vendor/cfsolve/src/stage2/audit_globals.rs +310 -0
  47. data/vendor/cfsolve/src/stage2/benchmark.rs +5 -8
  48. data/vendor/cfsolve/src/stage2/call_stack.rs +72 -16
  49. data/vendor/cfsolve/src/stage2/interaction/native.rs +2 -1
  50. data/vendor/cfsolve/src/stage2/interaction/plan.rs +0 -3
  51. data/vendor/cfsolve/src/stage2/payload/recover.rs +54 -23
  52. data/vendor/cfsolve/src/stage2/probes/ice.rs +11 -10
  53. data/vendor/cfsolve/src/stage2/probes/media.rs +47 -33
  54. data/vendor/cfsolve/src/stage2/probes/performance.rs +1 -0
  55. data/vendor/cfsolve/src/stage2/probes/rects.rs +12 -17
  56. data/vendor/cfsolve/src/stage2/probes/stack.rs +50 -103
  57. data/vendor/cfsolve/src/stage2/probes/websocket_echo.rs +17 -86
  58. data/vendor/cfsolve/src/stage2/probes.rs +0 -1
  59. data/vendor/cfsolve/src/stage2/records.rs +4 -13
  60. data/vendor/cfsolve/src/stage2/reference.json +1 -1
  61. data/vendor/cfsolve/src/stage2/reference.rs +98 -100
  62. data/vendor/cfsolve/src/stage2/registry.rs +41 -34
  63. data/vendor/cfsolve/src/stage2/replay.rs +182 -254
  64. data/vendor/cfsolve/src/stage2/stylesheets.rs +176 -0
  65. data/vendor/cfsolve/src/stage2/trace.rs +18 -0
  66. data/vendor/cfsolve/src/stage2/vocabulary.rs +111 -401
  67. data/vendor/cfsolve/src/stage2.rs +2 -1
  68. data/vendor/cfsolve/src/vm/disassemble.rs +39 -10
  69. data/vendor/cfsolve/src/vm/interpret/access.rs +5 -21
  70. data/vendor/cfsolve/src/vm/interpret/builtin.rs +6 -53
  71. data/vendor/cfsolve/src/vm/interpret/call.rs +86 -9
  72. data/vendor/cfsolve/src/vm/interpret/compile.rs +626 -0
  73. data/vendor/cfsolve/src/vm/interpret/copies.rs +232 -120
  74. data/vendor/cfsolve/src/vm/interpret/evaluate/tests.rs +96 -8
  75. data/vendor/cfsolve/src/vm/interpret/evaluate.rs +219 -92
  76. data/vendor/cfsolve/src/vm/interpret/explore.rs +155 -39
  77. data/vendor/cfsolve/src/vm/interpret/frozen.rs +171 -0
  78. data/vendor/cfsolve/src/vm/interpret/intrinsic.rs +350 -0
  79. data/vendor/cfsolve/src/vm/interpret/plan.rs +71 -44
  80. data/vendor/cfsolve/src/vm/interpret/run.rs +202 -13
  81. data/vendor/cfsolve/src/vm/interpret.rs +7 -2
  82. data/vendor/cfsolve/src/vm/machine.rs +6 -2
  83. data/vendor/cfsolve/src/vm/packed_names.rs +307 -0
  84. data/vendor/cfsolve/src/vm/session.rs +36 -14
  85. data/vendor/cfsolve/src/vm/value.rs +13 -0
  86. data/vendor/cfsolve/src/vm.rs +1 -0
  87. data/vendor/cfsolve/vendor/btls-sys/src/lib.rs +3 -0
  88. metadata +13 -6
  89. data/vendor/cfsolve/src/listing/normalize.rs +0 -264
  90. data/vendor/cfsolve/src/stage2/analysis/outputs.rs +0 -327
  91. data/vendor/cfsolve/src/stage2/probes/stylesheets.rs +0 -249
  92. data/vendor/cfsolve/src/stage2/reference-expanded.json +0 -42
  93. data/vendor/cfsolve/src/stage2/session_names.rs +0 -470
@@ -0,0 +1,310 @@
1
+ //! Pairs iframe globals by their definitions, independently of randomized identifiers.
2
+
3
+ use std::collections::{BTreeMap, BTreeSet};
4
+
5
+ use anyhow::Result;
6
+ use serde::{Deserialize, Serialize};
7
+ use swc_core::ecma::ast::{AssignExpr, AssignTarget, Expr, Lit, MemberExpr, SimpleAssignTarget};
8
+ use swc_core::ecma::visit::{Visit, VisitWith};
9
+ use url::Url;
10
+
11
+ use super::vocabulary;
12
+ use crate::challenge::installed::Installed;
13
+ use crate::challenge::program::PreparedSession;
14
+ use crate::js::ast;
15
+ use crate::script::Source;
16
+
17
+ /// A bag of syntax and browser API features. Local variable names, statement order, numeric
18
+ /// constants, and session names do not participate in matching.
19
+ #[derive(Clone, Debug, Default, Deserialize, Serialize)]
20
+ pub struct Signature {
21
+ kind: String,
22
+ features: BTreeMap<String, usize>,
23
+ #[serde(default, skip_serializing_if = "Option::is_none")]
24
+ value: Option<String>,
25
+ }
26
+
27
+ /// Signatures of the iframe's session-named globals, by name.
28
+ #[derive(Default)]
29
+ pub struct Globals(pub BTreeMap<String, Signature>);
30
+
31
+ impl Globals {
32
+ /// Signatures of the hex-named globals the initial VM program installs.
33
+ pub(crate) fn installed(installed: &Installed) -> Self {
34
+ let mut globals = Self::default();
35
+ for (name, value) in &installed.audit {
36
+ if !vocabulary::is_hex_name(name) {
37
+ continue;
38
+ }
39
+ let role = installed.audit_roles.get(name).map(String::as_str);
40
+ let feature = match role {
41
+ Some(role) => format!("role:{role}"),
42
+ None if vocabulary::is_hex_name(value) => "session-name".into(),
43
+ None => format!("value:{value}"),
44
+ };
45
+ let signature = Signature {
46
+ kind: "installed".into(),
47
+ features: BTreeMap::from([(feature, 1)]),
48
+ // Native function strings use each engine's whitespace and formatting.
49
+ // Preserve the captured classification instead of imposing Chromium's.
50
+ value: (!matches!(role, Some("clock-delta" | "native-array")))
51
+ .then(|| value.clone()),
52
+ };
53
+ globals.0.insert(name.clone(), signature);
54
+ }
55
+ globals
56
+ }
57
+
58
+ /// Recovers both script definitions and globals installed by the initial VM program.
59
+ pub fn capture(source: &Source) -> Result<Self> {
60
+ let prepared =
61
+ PreparedSession::try_new(source, &Url::parse("https://challenges.cloudflare.com/")?)?;
62
+ let mut globals = Self::installed(&prepared.program().installed);
63
+ globals.0.extend(Self::recover(source).0);
64
+ Ok(globals)
65
+ }
66
+
67
+ /// Signatures of the hex-named globals the iframe script assigns on `window` or an alias.
68
+ pub(crate) fn recover(source: &Source) -> Self {
69
+ let mut aliases = Aliases(BTreeSet::from(["window".to_owned()]));
70
+ source.parsed().script.visit_with(&mut aliases);
71
+ let mut definitions = Definitions {
72
+ aliases: aliases.0,
73
+ globals: Self::default(),
74
+ };
75
+ source.parsed().script.visit_with(&mut definitions);
76
+ definitions.globals
77
+ }
78
+
79
+ /// Accepts only a unique best candidate in both directions. Ambiguous definitions are
80
+ /// omitted rather than assigning an unrelated global the recorded value.
81
+ pub fn mapping(&self, recorded: &BTreeMap<String, Signature>) -> BTreeMap<String, String> {
82
+ let mut scores = Vec::new();
83
+ for (old, signature) in recorded {
84
+ for (new, candidate) in &self.0 {
85
+ let score = signature.similarity(candidate);
86
+ if score >= 0.9 {
87
+ scores.push((old, new, score));
88
+ }
89
+ }
90
+ }
91
+ scores
92
+ .iter()
93
+ .filter(|(old, new, score)| {
94
+ !scores.iter().any(|(other_old, other_new, other_score)| {
95
+ (other_old == old && other_new != new || other_new == new && other_old != old)
96
+ && other_score >= score
97
+ })
98
+ })
99
+ .map(|(old, new, _)| ((*old).clone(), (*new).clone()))
100
+ .collect()
101
+ }
102
+ }
103
+
104
+ impl Signature {
105
+ /// A global's initial audit value when recovered from VM initialization.
106
+ #[must_use]
107
+ pub fn audit_value(&self) -> Option<&str> {
108
+ self.value.as_deref()
109
+ }
110
+
111
+ /// The startup timer's measured delta varies between otherwise equivalent sessions.
112
+ #[must_use]
113
+ pub fn is_elapsed_time(&self) -> bool {
114
+ self.features.contains_key("role:clock-delta")
115
+ }
116
+
117
+ fn similarity(&self, other: &Self) -> f64 {
118
+ if self.kind != other.kind {
119
+ return 0.0;
120
+ }
121
+ let mut intersection = 0;
122
+ let mut union = 0;
123
+ for key in self
124
+ .features
125
+ .keys()
126
+ .chain(other.features.keys())
127
+ .collect::<BTreeSet<_>>()
128
+ {
129
+ let a = self.features.get(key).copied().unwrap_or_default();
130
+ let b = other.features.get(key).copied().unwrap_or_default();
131
+ intersection += a.min(b);
132
+ union += a.max(b);
133
+ }
134
+ let structural = intersection as f64 / union.max(1) as f64;
135
+ let anchors = |signature: &Self| {
136
+ signature
137
+ .features
138
+ .keys()
139
+ .filter(|key| key.starts_with("property:") || key.starts_with("string:"))
140
+ .cloned()
141
+ .collect::<BTreeSet<_>>()
142
+ };
143
+ let own_anchors = anchors(self);
144
+ if self.kind == "function" && own_anchors.len() >= 2 && own_anchors == anchors(other) {
145
+ // Deobfuscation can duplicate reads and move local declarations while preserving
146
+ // the APIs and fixed strings that identify the helper.
147
+ structural.max(0.9 + 0.1 * structural)
148
+ } else {
149
+ structural
150
+ }
151
+ }
152
+
153
+ fn feature(&mut self, feature: String) {
154
+ *self.features.entry(feature).or_default() += 1;
155
+ }
156
+ }
157
+
158
+ struct Aliases(BTreeSet<String>);
159
+
160
+ impl Visit for Aliases {
161
+ fn visit_member_expr(&mut self, member: &MemberExpr) {
162
+ if ast::member_str(&member.prop) == Some("_cf_chl_opt")
163
+ && let Expr::Ident(alias) = member.obj.unwrap_parens()
164
+ {
165
+ self.0.insert(alias.sym.to_string());
166
+ }
167
+ member.visit_children_with(self);
168
+ }
169
+ }
170
+
171
+ struct Definitions {
172
+ aliases: BTreeSet<String>,
173
+ globals: Globals,
174
+ }
175
+
176
+ impl Visit for Definitions {
177
+ fn visit_assign_expr(&mut self, assignment: &AssignExpr) {
178
+ if let AssignTarget::Simple(SimpleAssignTarget::Member(member)) = &assignment.left
179
+ && let Expr::Ident(alias) = member.obj.unwrap_parens()
180
+ && self.aliases.contains(alias.sym.as_ref())
181
+ && let Some(name) = ast::member_str(&member.prop)
182
+ && vocabulary::is_hex_name(name)
183
+ {
184
+ let signature = self.globals.0.entry(name.to_owned()).or_default();
185
+ if signature.kind.is_empty() {
186
+ signature.kind = match assignment.right.unwrap_parens() {
187
+ Expr::Fn(_) | Expr::Arrow(_) => "function",
188
+ Expr::Object(_) => "object",
189
+ Expr::Array(_) => "array",
190
+ _ => "value",
191
+ }
192
+ .into();
193
+ }
194
+ assignment.right.visit_with(signature);
195
+ }
196
+ assignment.visit_children_with(self);
197
+ }
198
+ }
199
+
200
+ impl Visit for Signature {
201
+ fn visit_expr(&mut self, expression: &Expr) {
202
+ let feature = match expression {
203
+ Expr::Bin(value) => format!("binary:{:?}", value.op),
204
+ Expr::Unary(value) => format!("unary:{:?}", value.op),
205
+ Expr::Call(_) => "call".into(),
206
+ Expr::New(_) => "new".into(),
207
+ Expr::Cond(_) => "conditional".into(),
208
+ Expr::Fn(_) | Expr::Arrow(_) => "function".into(),
209
+ Expr::Array(_) => "array".into(),
210
+ Expr::Object(_) => "object".into(),
211
+ Expr::Assign(value) => format!("assign:{:?}", value.op),
212
+ Expr::Lit(Lit::Bool(value)) => format!("bool:{}", value.value),
213
+ _ => "expression".into(),
214
+ };
215
+ self.feature(feature);
216
+ if let Some(text) = ast::static_str(expression)
217
+ && !vocabulary::is_hex_name(text)
218
+ && text.len() <= 64
219
+ && text
220
+ .chars()
221
+ .all(|character| character.is_ascii_alphabetic() || " ._-/".contains(character))
222
+ {
223
+ self.feature(format!("string:{text}"));
224
+ }
225
+ expression.visit_children_with(self);
226
+ }
227
+
228
+ fn visit_member_expr(&mut self, member: &MemberExpr) {
229
+ let randomized = |name: &str| {
230
+ vocabulary::is_hex_name(name)
231
+ || name.strip_prefix("_0x").is_some_and(|suffix| {
232
+ !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
233
+ })
234
+ };
235
+ if let Some(name) = ast::member_str(&member.prop)
236
+ && !randomized(name)
237
+ {
238
+ self.feature(format!("property:{name}"));
239
+ }
240
+ member.visit_children_with(self);
241
+ }
242
+ }
243
+
244
+ #[cfg(test)]
245
+ mod tests {
246
+ use super::*;
247
+
248
+ fn globals(script: &str) -> Globals {
249
+ Globals::recover(&Source::try_new(script).unwrap())
250
+ }
251
+
252
+ #[test]
253
+ fn pairs_definitions_across_aliases_and_local_names() {
254
+ let old = globals(
255
+ r#"
256
+ var x = window; x._cf_chl_opt;
257
+ window._111111111111111111111111 = function(a) { return a.createHTML(); };
258
+ x._222222222222222222222222 = function(b) { return b.createScript(); };
259
+ "#,
260
+ );
261
+ let new = globals(
262
+ r#"
263
+ var y = window; y._cf_chl_opt;
264
+ y._aaaaaaaaaaaaaaaaaaaaaaaa = function(c) { return c.createScript(); };
265
+ window._bbbbbbbbbbbbbbbbbbbbbbbb = function(d) { return d.createHTML(); };
266
+ "#,
267
+ );
268
+ let mapping = new.mapping(&old.0);
269
+ assert_eq!(mapping.len(), 2);
270
+ assert_eq!(
271
+ mapping["_111111111111111111111111"],
272
+ "_bbbbbbbbbbbbbbbbbbbbbbbb"
273
+ );
274
+ assert_eq!(
275
+ mapping["_222222222222222222222222"],
276
+ "_aaaaaaaaaaaaaaaaaaaaaaaa"
277
+ );
278
+ }
279
+
280
+ #[test]
281
+ fn ambiguous_or_different_definitions_are_omitted() {
282
+ let old = globals("window._111111111111111111111111 = function(a) { return a; };");
283
+ let duplicates = globals(
284
+ r#"
285
+ window._aaaaaaaaaaaaaaaaaaaaaaaa = function(b) { return b; };
286
+ window._bbbbbbbbbbbbbbbbbbbbbbbb = function(c) { return c; };
287
+ "#,
288
+ );
289
+ assert!(duplicates.mapping(&old.0).is_empty());
290
+ let different = globals("window._aaaaaaaaaaaaaaaaaaaaaaaa = { value: true };");
291
+ assert!(different.mapping(&old.0).is_empty());
292
+ }
293
+
294
+ #[test]
295
+ fn injected_operator_helpers_do_not_change_api_identity() {
296
+ let old = globals(
297
+ "window._111111111111111111111111 = \
298
+ function(a) { return a.getOwnPropertyNames().concat(a.keys()); };",
299
+ );
300
+ let new = globals(
301
+ "window._aaaaaaaaaaaaaaaaaaaaaaaa = function(b) { \
302
+ var h = {_0xabc: function(x) { return x; }}; h._0xabc(b); \
303
+ return b.getOwnPropertyNames().concat(b.keys()); };",
304
+ );
305
+ assert_eq!(
306
+ new.mapping(&old.0)["_111111111111111111111111"],
307
+ "_aaaaaaaaaaaaaaaaaaaaaaaa"
308
+ );
309
+ }
310
+ }
@@ -163,15 +163,11 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
163
163
  });
164
164
  }
165
165
  }
166
- let source = strings
166
+ // Programs that decode strings in place also hold the source as a descriptor.
167
+ let parsed = strings
167
168
  .iter()
168
- .find(|text| text.contains("WebAssembly.instantiate"))?;
169
- let parsed = ast::parse_javascript("benchmark worker", source)
170
- .or_else(|_| {
171
- let decoded = listing::decode_string(source, "")?;
172
- ast::parse_javascript("benchmark worker", &decoded)
173
- })
174
- .ok()?;
169
+ .filter(|text| text.contains("WebAssembly.instantiate"))
170
+ .find_map(|source| ast::parse_javascript("benchmark worker", source).ok())?;
175
171
  let mut worker = Worker::default();
176
172
  parsed.script.visit_with(&mut worker);
177
173
  // Only the kernels the recorded speeds measure are supported. Field names are recovered
@@ -204,6 +200,7 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
204
200
  .find(|items| items.len() == order.len() && *items != order)?;
205
201
  // The worker's source begins `var plan={<order>:[...],<iterations>:[...],`,
206
202
  // and the record takes the same two names.
203
+ // Hex names start with `_`.
207
204
  let identifier = |text: &str| {
208
205
  !text.is_empty()
209
206
  && text
@@ -153,14 +153,23 @@ pub struct MethodCall {
153
153
  pub arity: usize,
154
154
  }
155
155
 
156
- /// Call path to `new Error()` in the stack probe. The listener enters the probe function, which
157
- /// calls a closure that constructs the error.
156
+ /// Call path to `new Error()` in the stack probe. The probe function calls a closure that
157
+ /// constructs the error.
158
158
  #[derive(Clone, Copy, Debug, Deserialize, Serialize)]
159
159
  pub struct ProbeCalls {
160
160
  /// The opcode that constructs `new Error()`.
161
161
  pub construct: u8,
162
- /// How many parameters the probe function declares.
163
- pub arity: usize,
162
+ }
163
+
164
+ /// The callback that last resumed the VM before the stack probe, whose listener is the probe's
165
+ /// outermost frame. Chrome and Safari captures show both; a Worker message is more common.
166
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
167
+ pub enum Resumption {
168
+ /// A timer, whose callback receives no arguments. V8 leaves its listener anonymous.
169
+ Timer,
170
+ /// A Worker's message event, which passes the event. V8 names the listener by its
171
+ /// receiver, `Worker.<anonymous>`.
172
+ WorkerMessage,
164
173
  }
165
174
 
166
175
  /// Recovers callback frames for a VM function with `arity` parameters and error-construction
@@ -179,15 +188,24 @@ pub fn recover(source: &str, cases: &CaseOpcodes, construct: u8, arity: usize) -
179
188
  })
180
189
  }
181
190
 
182
- /// Recovers frames for the stack probe. The VM executes closures in its dispatch loop without
183
- /// JavaScript calls, so frames stop at the listener that entered the probe. V8 retains only the
184
- /// innermost ten frames.
185
- pub fn recover_probe(source: &str, cases: &CaseOpcodes, calls: ProbeCalls) -> Result<Geometry> {
191
+ /// Recovers frames for the stack probe, run after `resumed`. The VM executes closures in its
192
+ /// dispatch loop without JavaScript calls, so frames stop at the listener that resumed it. V8
193
+ /// retains only the innermost ten frames.
194
+ pub fn recover_probe(
195
+ source: &str,
196
+ cases: &CaseOpcodes,
197
+ calls: ProbeCalls,
198
+ resumed: Resumption,
199
+ ) -> Result<Geometry> {
186
200
  geometry(source, cases, |machine| {
187
201
  // The probe registers an exception handler, so its instructions dispatch from the
188
202
  // guarded loop.
189
203
  let mut chain = machine.construction(calls.construct, true)?;
190
- chain.frames.extend(machine.listener(calls.arity, "")?);
204
+ let (arity, name) = match resumed {
205
+ Resumption::Timer => (0, ""),
206
+ Resumption::WorkerMessage => (1, "Worker.<anonymous>"),
207
+ };
208
+ chain.frames.extend(machine.listener(arity, name)?);
191
209
  chain.frames.truncate(V8_STACK_LIMIT);
192
210
  Ok(chain)
193
211
  })
@@ -648,6 +666,8 @@ struct Script {
648
666
  /// Array slots containing `Reflect.construct`, indexed by array and slot.
649
667
  construct_slots: Vec<(Id, usize)>,
650
668
  applies: Vec<Id>,
669
+ /// Array slots containing `Reflect.apply`, indexed by array and slot.
670
+ apply_slots: Vec<(Id, usize)>,
651
671
  appliers: Appliers,
652
672
  }
653
673
 
@@ -816,6 +836,7 @@ impl Script {
816
836
  constructs: Vec::new(),
817
837
  construct_slots: Vec::new(),
818
838
  applies: Vec::new(),
839
+ apply_slots: Vec::new(),
819
840
  appliers,
820
841
  };
821
842
  program.visit_with(&mut script);
@@ -858,9 +879,13 @@ impl Script {
858
879
  .target(call)
859
880
  .ok()?;
860
881
  // Compare bindings so a nested function with the same name as a native apply
861
- // alias cannot be mistaken for that alias.
862
- let callee = target.callee.as_ref()?;
863
- if self.function(callee).is_ok() && !self.applies.iter().any(|alias| alias == callee) {
882
+ // alias cannot be mistaken for that alias. Some builds keep the alias in an array slot.
883
+ let native = match (&target.callee, &target.slot) {
884
+ (Some(callee), _) => self.function(callee).is_err() || self.applies.contains(callee),
885
+ (None, Some(slot)) => self.apply_slots.contains(slot),
886
+ (None, None) => false,
887
+ };
888
+ if !native {
864
889
  return None;
865
890
  }
866
891
  let [first, second, ..] = handed(call, &target)?[..] else {
@@ -888,13 +913,16 @@ impl Visit for Script {
888
913
  (Some(name), Some("apply")) => self.applies.push(name.to_id()),
889
914
  _ => {}
890
915
  }
891
- if method == Some("construct")
892
- && let AssignTarget::Simple(SimpleAssignTarget::Member(slot)) = &assign.left
916
+ if let AssignTarget::Simple(SimpleAssignTarget::Member(slot)) = &assign.left
893
917
  && let Some(array) = slot.obj.as_ident()
894
918
  && let Some(index) = slot.prop.as_computed()
895
919
  && let Some(index) = self.appliers.number(&index.expr).and_then(ast::array_index)
896
920
  {
897
- self.construct_slots.push((array.to_id(), index));
921
+ match method {
922
+ Some("construct") => self.construct_slots.push((array.to_id(), index)),
923
+ Some("apply") => self.apply_slots.push((array.to_id(), index)),
924
+ _ => {}
925
+ }
898
926
  }
899
927
  }
900
928
  assign.visit_children_with(self);
@@ -1355,7 +1383,11 @@ impl<'script> Scope<'script> {
1355
1383
  let constructor = name.starts_with(char::is_uppercase);
1356
1384
  let mut scope = Self {
1357
1385
  appliers,
1358
- prefix: constructor.then(|| format!("{name}.")).unwrap_or_default(),
1386
+ prefix: if constructor {
1387
+ format!("{name}.")
1388
+ } else {
1389
+ String::new()
1390
+ },
1359
1391
  forwarders: Forwarders::default(),
1360
1392
  assigned: HashMap::new(),
1361
1393
  aliases: HashMap::new(),
@@ -1857,6 +1889,30 @@ mod tests {
1857
1889
  assert!(script.method_forwarder(&binding(&script, "S")).is_none());
1858
1890
  }
1859
1891
 
1892
+ /// Checks method forwarders that call a `Reflect.apply` alias kept in an array slot, directly
1893
+ /// or through a selector helper. Other slots are not apply aliases.
1894
+ #[test]
1895
+ fn method_forwarders_may_apply_through_an_array_slot() {
1896
+ let source = "var G=[];G[2]=Reflect.apply;G[3]=function(){};\
1897
+ function W(s,f,a,b,c){switch(s^1){case 0:return f(a,b,c)}}\
1898
+ function D(f,r,a){return(0,G[2])(f,r,[a])}\
1899
+ function H(f,r,a,b){return W(1,G[2],f,r,[a,b])}\
1900
+ function O(f,r,a){return(0,G[3])(f,r,[a])}";
1901
+ let (script, _) = script(source);
1902
+ let cases: [(&str, &[&str]); 2] = [("D", &["D"]), ("H", &["W", "H"])];
1903
+ for (forwarder, expected) in cases {
1904
+ let frames = script
1905
+ .method_forwarder(&binding(&script, forwarder))
1906
+ .expect("`G[2]` holds `Reflect.apply`, so the call is a method forwarder");
1907
+ let names: Vec<&str> = frames.iter().map(|call| call.name.v8.as_str()).collect();
1908
+ assert_eq!(names, expected, "frames of forwarder {forwarder}");
1909
+ }
1910
+ assert!(
1911
+ script.method_forwarder(&binding(&script, "O")).is_none(),
1912
+ "`G[3]` holds a plain function, not `Reflect.apply`"
1913
+ );
1914
+ }
1915
+
1860
1916
  #[test]
1861
1917
  fn same_named_functions_resolve_to_the_called_binding() {
1862
1918
  let (script, _) = script(
@@ -140,7 +140,7 @@ pub(crate) fn measure(
140
140
  "bubbles":true, "button":0, "buttons":0, "cancelable":true,
141
141
  "clientX":client_x, "clientY":client_y, "composed":true, "ctrlKey":false,
142
142
  "defaultPrevented":false, "detail":1, "eventPhase":2, "height":1,
143
- "isPrimary":is_primary, "isTrusted":true, "layerX":(client_x-x).round(), "layerY":(client_y-y).round(),
143
+ "isPrimary":is_primary, "isTrusted":true, "layerX":(client_x-x).floor(), "layerY":(client_y-y).floor(),
144
144
  }));
145
145
  event.extend(object(json!({
146
146
  "movementX":0, "movementY":0, "metaKey":false,
@@ -250,6 +250,7 @@ mod tests {
250
250
  assert_eq!(event["screenX"], 101.0);
251
251
  assert_eq!(event["screenY"], 145.0);
252
252
  assert_eq!(event["offsetY"], 12.0);
253
+ assert_eq!(event["layerY"], 11.0);
253
254
  assert_eq!(raw["sources"]["derived"]["relative_y"], 11.0);
254
255
  assert_eq!(event["timeStamp"], 123.0);
255
256
  assert_eq!(raw["sources"]["element"]["clientWidth"], 168.0);
@@ -98,9 +98,6 @@ impl Plan {
98
98
 
99
99
  /// Analyzes a returned checkbox program's listing.
100
100
  pub fn recover(listing: &Listing) -> Result<Plan> {
101
- let mut normalized = listing.clone();
102
- crate::listing::normalize::constants(&mut normalized);
103
- let listing = &normalized;
104
101
  ensure!(
105
102
  listing.failures.is_empty(),
106
103
  "checkbox program has undecoded instructions"
@@ -180,7 +180,8 @@ pub(in crate::stage2) struct Recovered<'listing> {
180
180
  }
181
181
 
182
182
  /// Recovers the payload and any checkbox record. Tries unique header names as record-counter
183
- /// candidates. Uses `program` to identify errors.
183
+ /// candidates, then, for programs whose header holds no names, the fields published objects
184
+ /// start at zero. Uses `program` to identify errors.
184
185
  pub(in crate::stage2) fn recover_unique<'listing>(
185
186
  index: &Index<'listing>,
186
187
  program: &str,
@@ -191,23 +192,18 @@ pub(in crate::stage2) fn recover_unique<'listing>(
191
192
  let scan = scan(index, pool);
192
193
  let mut layouts = Vec::new();
193
194
  let mut failure = None;
194
- let mut counters: BTreeSet<&String> = index.header().values().collect();
195
- counters.extend(
196
- index
197
- .stores()
198
- .iter()
199
- .filter(|store| {
200
- matches!(
201
- index.dereference(store.pc, &store.value),
202
- Some((_, Term::Binary(BinaryOp::Add, _, _)))
203
- )
204
- })
205
- .map(|store| &store.key),
206
- );
207
- for counter in counters {
208
- match scan.layout(counter) {
209
- Ok(layout) => layouts.push((counter.clone(), layout)),
210
- Err(error) => failure = Some(error),
195
+ for candidates in [
196
+ index.header().values().cloned().collect(),
197
+ scan.zeroed_fields(),
198
+ ] {
199
+ for counter in candidates {
200
+ match scan.layout(&counter) {
201
+ Ok(layout) => layouts.push((counter, layout)),
202
+ Err(error) => failure = Some(error),
203
+ }
204
+ }
205
+ if !layouts.is_empty() {
206
+ break;
211
207
  }
212
208
  }
213
209
  ensure!(layouts.len() <= 1, "ambiguous {program} payload counter");
@@ -243,6 +239,35 @@ pub(in crate::stage2) struct RecordLiteral {
243
239
  pub(in crate::stage2) struct Scan(Vec<FunctionWrites>);
244
240
 
245
241
  impl Scan {
242
+ /// Fields a published object starts at zero that the program also writes to the payload as
243
+ /// a computed value, as it does when it increments the record counter. Other zeroed fields,
244
+ /// such as the probe count, are published as literals.
245
+ fn zeroed_fields(&self) -> Vec<String> {
246
+ let incremented = |field: &str| {
247
+ let mut payload_writes = self.0.iter().flat_map(FunctionWrites::payload_writes);
248
+ payload_writes.any(|write| {
249
+ write.key == field
250
+ && !matches!(write.value, Written::Expression(Expression::Literal { .. }))
251
+ })
252
+ };
253
+ let mut fields: Vec<String> = Vec::new();
254
+ for function in &self.0 {
255
+ let Some(publication) = function.published else {
256
+ continue;
257
+ };
258
+ for write in &function.writes {
259
+ if write.is_zero()
260
+ && write.target == WriteTarget::Object(publication.object)
261
+ && !fields.contains(&write.key)
262
+ && incremented(&write.key)
263
+ {
264
+ fields.push(write.key.clone());
265
+ }
266
+ }
267
+ }
268
+ fields
269
+ }
270
+
246
271
  /// Recovers field sources for the second payload. Identifies the replacement template as the
247
272
  /// object published to the payload cell with its record `counter` initialized to zero.
248
273
  pub(in crate::stage2) fn layout(&self, counter: &str) -> Result<Layout> {
@@ -256,11 +281,9 @@ impl Scan {
256
281
  .filter(|write| write.target == WriteTarget::Object(publication.object))
257
282
  .collect();
258
283
  fields.len() > 20
259
- && fields.iter().any(|write| {
260
- write.key == counter
261
- && matches!(&write.value, Written::Expression(Expression::Literal { value })
262
- if value.as_f64() == Some(0.0))
263
- })
284
+ && fields
285
+ .iter()
286
+ .any(|write| write.key == counter && write.is_zero())
264
287
  };
265
288
  let templates = functions
266
289
  .iter()
@@ -453,6 +476,14 @@ struct Write {
453
476
  value: Written,
454
477
  }
455
478
 
479
+ impl Write {
480
+ /// Whether the write stores a literal zero, as for the record counter in the template.
481
+ fn is_zero(&self) -> bool {
482
+ matches!(&self.value, Written::Expression(Expression::Literal { value })
483
+ if value.as_f64() == Some(0.0))
484
+ }
485
+ }
486
+
456
487
  #[derive(Clone, Copy, PartialEq)]
457
488
  enum WriteTarget {
458
489
  /// A new object, identified by where it was created.