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
@@ -5,17 +5,21 @@
5
5
  //! globals include a build marker, a selector log, and functions built from constants, such as
6
6
  //! the key transform's `window[name] = factory(secret)`.
7
7
 
8
+ use std::cell::RefCell;
8
9
  use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
10
+ use std::rc::Rc;
9
11
 
10
12
  use anyhow::Result;
11
- use indexmap::IndexMap;
13
+ use swc_core::ecma::ast::BinaryOp;
12
14
 
13
15
  use crate::js::{JsObject, JsValue, Options, Window, json};
14
- use crate::listing::fold::{Frame, Known, constant};
16
+ use crate::listing::fold::{Frame, Known};
15
17
  use crate::listing::query;
16
18
  use crate::script::Source;
17
19
  use crate::vm::{Effect, Exit, Listing, Term};
18
20
 
21
+ mod audit;
22
+
19
23
  /// Values tracked beyond literals while recovering installed options.
20
24
  #[derive(Clone)]
21
25
  enum Held {
@@ -24,7 +28,13 @@ enum Held {
24
28
  Window,
25
29
  /// The result of a call with one string-constant argument.
26
30
  Built(String),
27
- Function,
31
+ /// An array the audit fills by index, holding native function sources where known.
32
+ AuditArray(Rc<RefCell<BTreeMap<usize, Option<String>>>>),
33
+ NativeFunction(String),
34
+ /// The result of a `now()` call.
35
+ Clock,
36
+ ClockDelta,
37
+ GpuPromise,
28
38
  }
29
39
 
30
40
  /// Options and globals left by the initial program.
@@ -37,21 +47,16 @@ pub struct Installed {
37
47
  pub window: Window,
38
48
  /// Window globals created by calls with one string constant, mapped to that constant.
39
49
  pub built: BTreeMap<String, String>,
40
- /// Global value classes, in installation order, for matching captured namespace properties.
41
- pub(crate) audit: IndexMap<String, String>,
42
- /// Global factories called to create installed functions.
43
- pub(crate) factories: BTreeSet<String>,
50
+ /// Initial global values as classified by the environment audit.
51
+ pub audit: BTreeMap<String, String>,
52
+ /// Semantic roles for globals whose values alone do not identify their definitions.
53
+ pub audit_roles: BTreeMap<String, String>,
44
54
  }
45
55
 
46
56
  /// Recovers values installed by the initial `program`, decoded from its program runner call.
47
57
  pub fn read(source: &Source, program: &[u8]) -> Result<Installed> {
48
- let mut listing = source.disassemble(program)?;
49
- crate::listing::normalize::constants(&mut listing);
50
- Ok(read_listing(&listing))
51
- }
52
-
53
- fn read_listing(listing: &Listing) -> Installed {
54
- let initialization = initialization(listing);
58
+ let listing = source.disassemble(program)?;
59
+ let initialization = initialization(&listing);
55
60
  let mut frame: Frame<Held> = Frame::default();
56
61
  let mut objects: HashMap<u32, JsObject> = HashMap::new();
57
62
  let mut installed = Installed::default();
@@ -65,31 +70,27 @@ fn read_listing(listing: &Listing) -> Installed {
65
70
  let Effect::SetProperty { object, key, value } = effect else {
66
71
  return;
67
72
  };
68
- let (Some(object), Some(Known::Literal(JsValue::String(key)))) =
69
- (frame.resolve(object), frame.resolve(key))
70
- else {
73
+ let (Some(object), Some(Known::Literal(JsValue::String(key))), Some(value)) = (
74
+ frame.resolve(object),
75
+ frame.resolve(key),
76
+ frame.resolve(value),
77
+ ) else {
71
78
  return;
72
79
  };
73
- let value = evaluate(frame, value);
74
80
  if matches!(object, Known::Ext(Held::Window)) {
75
- let group = match &value {
76
- Some(Known::Object(_)) => "o".to_owned(),
77
- Some(Known::Ext(Held::Function | Held::Built(_))) => "N".to_owned(),
78
- Some(Known::Ext(Held::EmptyArray)) => "".to_owned(),
79
- Some(Known::Literal(JsValue::String(value))) => value.clone(),
80
- Some(Known::Literal(JsValue::Number(value))) => value.to_string(),
81
- Some(Known::Literal(JsValue::Bool(value))) => {
82
- if *value { "T" } else { "F" }.to_owned()
83
- }
84
- _ => "?".to_owned(),
81
+ let audit = match &value {
82
+ Known::Literal(JsValue::String(text)) => Some(text.clone()),
83
+ Known::Literal(JsValue::Number(number)) => Some(number.to_string()),
84
+ Known::Literal(JsValue::Bool(true)) => Some("T".into()),
85
+ Known::Literal(JsValue::Bool(false)) => Some("F".into()),
86
+ Known::Object(_) => Some("o".into()),
87
+ Known::Ext(Held::Built(_)) => Some("N".into()),
88
+ _ => None,
85
89
  };
86
- if group != "?" || !installed.audit.contains_key(&key) {
87
- installed.audit.insert(key.clone(), group);
90
+ if let Some(audit) = audit {
91
+ installed.audit.insert(key.clone(), audit);
88
92
  }
89
93
  }
90
- let Some(value) = value else {
91
- return;
92
- };
93
94
  match (object, value) {
94
95
  // The encoder skips function members, such as `toJSON`.
95
96
  (Known::Object(id), Known::Literal(value)) => {
@@ -116,77 +117,79 @@ fn read_listing(listing: &Listing) -> Installed {
116
117
  });
117
118
  }
118
119
  }
119
- let index = crate::listing::Index::new(listing);
120
- for store in index.stores() {
121
- let Some((at, Term::Get(_, key))) = index.dereference(store.pc, &Term::Reg(store.object))
122
- else {
123
- continue;
124
- };
125
- if index.text(at, &key).as_deref() != Some("window") {
126
- continue;
127
- }
128
- let Some((_, value)) = index.dereference(store.pc, &store.value) else {
129
- continue;
130
- };
131
- let kind = match value {
132
- Term::Closure { .. } => "N",
133
- Term::Object(_) => "o",
134
- Term::Array(_) => "",
135
- Term::Binary(_, _, _) | Term::Unary(_, _) => "number",
136
- Term::Call { .. } => "call",
137
- Term::Get(_, _) => "get",
138
- _ => "?",
139
- };
140
- if installed
141
- .audit
142
- .get(&store.key)
143
- .is_none_or(|value| value == "?")
144
- {
145
- installed.audit.insert(store.key.clone(), kind.into());
146
- }
147
- }
148
- for (pc, effect) in index.effects_in(..) {
149
- effect.visit_terms(&mut |term| {
150
- if let Term::Call {
151
- callee, arguments, ..
152
- } = term
153
- && let [argument] = arguments.as_slice()
154
- && let Some(value) = index.text(pc, argument)
155
- && installed.built.values().any(|secret| secret == &value)
156
- && let Some(factory) = index.member(pc, callee)
157
- && matches!(factory.object, Term::Reg(global) if Some(global) == index.global())
158
- {
159
- installed.factories.insert(factory.key);
160
- }
161
- });
162
- }
163
- installed
120
+ audit::read(&listing, &mut installed);
121
+ Ok(installed)
164
122
  }
165
123
 
166
124
  /// The value an assignment writes. Recognizes calls with one string constant, such as the key
167
- /// transform `factory(secret)`.
125
+ /// transform `factory(secret)`, and folds the arithmetic and string methods that unpack
126
+ /// length-prefixed names.
168
127
  fn evaluate(frame: &Frame<Held>, value: &Term) -> Option<Known<Held>> {
169
- if let Some(value) = constant(frame, value) {
170
- return Some(Known::Literal(value));
171
- }
172
128
  Some(match value {
173
- Term::Closure { .. } => Known::Ext(Held::Function),
174
129
  Term::Array(elements) if elements.is_empty() => Known::Ext(Held::EmptyArray),
175
130
  Term::Get(_, key) => match frame.resolve(key)?.text()? {
176
131
  "_cf_chl_opt" => Known::Ext(Held::Options),
177
132
  "window" => Known::Ext(Held::Window),
178
133
  _ => return None,
179
134
  },
180
- Term::Call { arguments, .. } => {
135
+ Term::Call {
136
+ callee,
137
+ this,
138
+ arguments,
139
+ } => {
140
+ if this.is_none()
141
+ && let Some(folded) = string_method(frame, callee, arguments)
142
+ {
143
+ return Some(folded);
144
+ }
181
145
  let [argument] = arguments.as_slice() else {
182
146
  return None;
183
147
  };
184
148
  Known::Ext(Held::Built(frame.resolve(argument)?.text()?.to_owned()))
185
149
  }
150
+ Term::Binary(BinaryOp::Add, left, right) => {
151
+ Known::Literal(json!(number(frame, left)? + number(frame, right)?))
152
+ }
153
+ Term::Binary(BinaryOp::Mul, left, right) => {
154
+ Known::Literal(json!(number(frame, left)? * number(frame, right)?))
155
+ }
186
156
  value => frame.resolve(value)?,
187
157
  })
188
158
  }
189
159
 
160
+ /// Folds `charCodeAt` and `slice` on a known string. Some programs store names as
161
+ /// length-prefixed constants padded with NULs and unpack them with
162
+ /// `text.slice(2, 2 + 256 * text.charCodeAt(0) + text.charCodeAt(1))`.
163
+ fn string_method(frame: &Frame<Held>, callee: &Term, arguments: &[Term]) -> Option<Known<Held>> {
164
+ let Term::Get(receiver, method) = callee else {
165
+ return None;
166
+ };
167
+ let text: Vec<u16> = frame.resolve(receiver)?.text()?.encode_utf16().collect();
168
+ let index = |argument: &Term| {
169
+ let index = number(frame, argument)?;
170
+ // Negative indexes count from the end; the unpacking never uses them.
171
+ (index >= 0.0 && index.fract() == 0.0).then_some(index as usize)
172
+ };
173
+ Some(match (frame.resolve(method)?.text()?, arguments) {
174
+ ("charCodeAt", [position]) => {
175
+ Known::Literal(json!(f64::from(*text.get(index(position)?)?)))
176
+ }
177
+ ("slice", [start, end]) => {
178
+ let end = index(end)?.min(text.len());
179
+ let start = index(start)?.min(end);
180
+ Known::Literal(json!(String::from_utf16(&text[start..end]).ok()?))
181
+ }
182
+ _ => return None,
183
+ })
184
+ }
185
+
186
+ fn number(frame: &Frame<Held>, term: &Term) -> Option<f64> {
187
+ match frame.resolve(term)? {
188
+ Known::Literal(value) => value.as_f64(),
189
+ _ => None,
190
+ }
191
+ }
192
+
190
193
  /// Finds startup instructions reachable from the entry, the returned function called by the
191
194
  /// iframe, and their callees. Excludes exception handlers and callbacks that are installed
192
195
  /// without being called.
@@ -261,198 +264,46 @@ fn return_register(listing: &Listing) -> Option<u8> {
261
264
  #[cfg(test)]
262
265
  mod tests {
263
266
  use super::*;
264
- use crate::vm::{Constant, Instruction, Path, Position};
265
- use swc_core::ecma::ast::BinaryOp;
266
-
267
- fn text(value: &str) -> Term {
268
- Term::Const(Constant::String(value.into()))
269
- }
270
-
271
- fn number(value: f64) -> Term {
272
- Term::Const(Constant::Number(value))
273
- }
267
+ use crate::vm::Constant;
274
268
 
275
- fn method(object: Term, name: &str, arguments: Vec<Term>) -> Term {
276
- Term::Call {
277
- callee: Box::new(Term::Get(Box::new(object), Box::new(text(name)))),
269
+ #[test]
270
+ fn unpacks_length_prefixed_names() {
271
+ let mut frame: Frame<Held> = Frame::default();
272
+ frame.set(1, Some(Known::Literal(json!("\0\u{3}abc\0\0\0"))));
273
+ let register = |register| Box::new(Term::Reg(register));
274
+ let number = |value| Term::Const(Constant::Number(value));
275
+ let method = |name: &str, arguments| Term::Call {
276
+ callee: Box::new(Term::Get(
277
+ register(1),
278
+ Box::new(Term::Const(Constant::String(name.into()))),
279
+ )),
278
280
  this: None,
279
281
  arguments,
280
- }
281
- }
282
-
283
- #[test]
284
- fn records_the_global_factory_that_built_an_installed_function() {
285
- let effects = vec![
286
- Effect::Set(
287
- 1,
288
- Term::Get(Box::new(Term::Reg(99)), Box::new(text("window"))),
289
- ),
290
- Effect::Set(
291
- 2,
292
- method(Term::Reg(99), "factory", vec![text("synthetic-secret")]),
293
- ),
294
- Effect::SetProperty {
295
- object: Box::new(Term::Reg(1)),
296
- key: Box::new(text("transform")),
297
- value: Box::new(Term::Reg(2)),
298
- },
299
- Effect::Set(
300
- 3,
301
- method(Term::Reg(99), "unrelated", vec![text("different-value")]),
302
- ),
303
- ];
304
- let mut listing = Listing::default();
305
- listing.functions.push(Position::new(0, 0));
306
- let count = effects.len() as u32;
307
- for (pc, effect) in (0..).zip(effects) {
308
- listing.instructions.insert(
309
- pc,
310
- Instruction {
311
- pc,
312
- key: 0,
313
- opcode: 0,
314
- paths: vec![Path {
315
- conditions: vec![],
316
- effects: vec![effect],
317
- exit: if pc + 1 == count {
318
- Exit::Return
319
- } else {
320
- Exit::Next { pc: pc + 1, key: 0 }
321
- },
322
- }],
323
- },
324
- );
325
- }
326
- let installed = read_listing(&listing);
327
- assert_eq!(installed.factories, BTreeSet::from(["factory".into()]));
328
- assert_eq!(
329
- installed.built,
330
- BTreeMap::from([("transform".into(), "synthetic-secret".into())])
331
- );
332
- }
333
-
334
- #[test]
335
- fn recovers_a_key_transform_with_an_inline_decoded_global_name() {
336
- let effects = vec![
337
- Effect::Set(
338
- 1,
339
- Term::Get(
340
- Box::new(Term::Global("self".into())),
341
- Box::new(text("window")),
342
- ),
343
- ),
344
- Effect::Set(2, text("\0\u{9}fresh_key\0padding")),
345
- Effect::Set(3, method(Term::Reg(2), "charCodeAt", vec![number(0.0)])),
346
- Effect::Set(4, method(Term::Reg(2), "charCodeAt", vec![number(1.0)])),
347
- Effect::Set(
348
- 3,
349
- Term::Binary(
350
- BinaryOp::Mul,
351
- Box::new(Term::Reg(3)),
352
- Box::new(number(256.0)),
353
- ),
354
- ),
355
- Effect::Set(
356
- 3,
357
- Term::Binary(
358
- BinaryOp::Add,
359
- Box::new(Term::Reg(3)),
360
- Box::new(Term::Reg(4)),
361
- ),
362
- ),
363
- Effect::Set(
364
- 3,
365
- Term::Binary(BinaryOp::Add, Box::new(Term::Reg(3)), Box::new(number(2.0))),
366
- ),
367
- Effect::Set(
368
- 2,
369
- method(Term::Reg(2), "slice", vec![number(2.0), Term::Reg(3)]),
370
- ),
371
- Effect::Set(
372
- 5,
373
- Term::Call {
374
- callee: Box::new(Term::Reg(99)),
375
- this: None,
376
- arguments: vec![text("synthetic-secret")],
377
- },
378
- ),
379
- Effect::SetProperty {
380
- object: Box::new(Term::Reg(1)),
381
- key: Box::new(Term::Reg(2)),
382
- value: Box::new(Term::Reg(5)),
383
- },
384
- ];
385
- let mut listing = Listing::default();
386
- listing.functions.push(Position::new(0, 0));
387
- listing.instructions.insert(
388
- 0,
389
- Instruction {
390
- pc: 0,
391
- key: 0,
392
- opcode: 0,
393
- paths: vec![Path {
394
- conditions: vec![],
395
- effects,
396
- exit: Exit::Return,
397
- }],
398
- },
282
+ };
283
+ let mut set = |target, term: Term| {
284
+ let value = evaluate(&frame, &term);
285
+ frame.set(target, value);
286
+ };
287
+ // `text.slice(2, 256 * text.charCodeAt(0) + text.charCodeAt(1) + 2)`
288
+ set(2, method("charCodeAt", vec![number(0.0)]));
289
+ set(3, method("charCodeAt", vec![number(1.0)]));
290
+ set(
291
+ 4,
292
+ Term::Binary(BinaryOp::Mul, Box::new(number(256.0)), register(2)),
399
293
  );
400
- assert_eq!(
401
- read_listing(&listing).built,
402
- BTreeMap::from([("fresh_key".into(), "synthetic-secret".into())])
294
+ set(4, Term::Binary(BinaryOp::Add, register(4), register(3)));
295
+ set(
296
+ 4,
297
+ Term::Binary(BinaryOp::Add, register(4), Box::new(number(2.0))),
403
298
  );
404
- }
299
+ set(5, method("slice", vec![number(2.0), Term::Reg(4)]));
300
+ assert_eq!(frame.register(5).and_then(Known::text), Some("abc"));
405
301
 
406
- #[test]
407
- fn constant_string_operations_use_javascript_utf16_indexes() {
408
- let frame: Frame<Held> = Frame::default();
409
- assert_eq!(
410
- constant(
411
- &frame,
412
- &method(text("a😀z"), "charCodeAt", vec![number(1.0)])
413
- ),
414
- Some(JsValue::Number(0xd83d as f64))
415
- );
416
- assert_eq!(
417
- constant(
418
- &frame,
419
- &method(text("a😀z"), "slice", vec![number(1.0), number(3.0)])
420
- ),
421
- Some(json!("😀"))
422
- );
423
- assert_eq!(
424
- constant(
425
- &frame,
426
- &method(text("abcdef"), "slice", vec![number(-3.0), number(99.0)])
427
- ),
428
- Some(json!("def"))
429
- );
430
- assert_eq!(
431
- constant(
432
- &frame,
433
- &method(text("abc"), "slice", vec![number(2.0), number(1.0)])
434
- ),
435
- Some(json!(""))
436
- );
437
- assert_eq!(
438
- constant(
439
- &frame,
440
- &method(text("a😀z"), "slice", vec![number(1.0), number(2.0)])
441
- ),
442
- None
443
- );
444
- let outside = constant(
445
- &frame,
446
- &method(text("abc"), "charCodeAt", vec![number(-1.0)]),
447
- )
448
- .unwrap();
449
- assert!(outside.as_f64().unwrap().is_nan());
450
- assert!(
451
- constant(
452
- &frame,
453
- &method(Term::Reg(99), "slice", vec![number(0.0), number(2.0)])
454
- )
455
- .is_none()
456
- );
302
+ // A call with one string argument still builds a global.
303
+ let built = evaluate(&frame, &method("factory", vec![Term::Reg(5)]));
304
+ assert!(matches!(built, Some(Known::Ext(Held::Built(secret))) if secret == "abc"));
305
+ // Out-of-range and negative indexes stay unknown.
306
+ assert!(evaluate(&frame, &method("charCodeAt", vec![number(9.0)])).is_none());
307
+ assert!(evaluate(&frame, &method("slice", vec![number(-1.0), number(2.0)])).is_none());
457
308
  }
458
309
  }
@@ -1,12 +1,15 @@
1
1
  //! Builds the plaintext first challenge payload by evaluating the template's field expressions
2
2
  //! against session options and the script's namespace.
3
3
 
4
+ use std::sync::LazyLock;
5
+
4
6
  use anyhow::{Context, Result, ensure};
7
+ use regex::Regex;
5
8
  use swc_core::ecma::ast::{BinaryOp, Expr};
6
9
  use swc_core::ecma::codegen;
7
10
 
8
11
  use crate::challenge::expression;
9
- use crate::challenge::state::{Difference, ScriptState};
12
+ use crate::challenge::state::{Difference, Getter, ScriptState};
10
13
  use crate::js::{JsValue, Options, Payload, Window, ast, json};
11
14
  use crate::random_id;
12
15
 
@@ -41,8 +44,7 @@ pub(crate) fn infer_globals(
41
44
  (None, Shape::Called) => script
42
45
  .getters
43
46
  .get(name)
44
- .and_then(|option| options.get(option))
45
- .cloned()
47
+ .and_then(|getter| called(getter, options))
46
48
  .unwrap_or_else(|| json!([])),
47
49
  // No interaction has been recorded, so the collection is empty.
48
50
  (None, Shape::Stringified) => json!([]),
@@ -66,6 +68,66 @@ pub(crate) fn infer_globals(
66
68
  globals
67
69
  }
68
70
 
71
+ /// The value `getter` returns: its option, with each render sample's stack split at page URLs
72
+ /// if the getter does so.
73
+ fn called(getter: &Getter, options: &Options) -> Option<JsValue> {
74
+ let mut value = options.get(&getter.option)?.clone();
75
+ if let Some(origin) = &getter.page_origin
76
+ && let JsValue::Array(samples) = &mut value
77
+ {
78
+ let origin = options
79
+ .get(origin)
80
+ .and_then(JsValue::as_str)
81
+ .unwrap_or_default();
82
+ for sample in samples {
83
+ if let JsValue::Object(sample) = sample
84
+ && let Some(stack) = sample.get("s").and_then(JsValue::as_str)
85
+ {
86
+ let split = split_page_urls(stack, origin);
87
+ sample.insert("s".to_owned(), split);
88
+ }
89
+ }
90
+ }
91
+ Some(value)
92
+ }
93
+
94
+ /// Splits `stack` as the iframe's sample getter does. Each URL on `origin`, less a trailing
95
+ /// `:line` or `:line:column`, becomes the index of that URL in a list of at most 64. Returns
96
+ /// `[parts, urls]`, where `parts` alternates text and indices and ends with text.
97
+ fn split_page_urls(stack: &str, origin: &str) -> JsValue {
98
+ const MAX_URLS: usize = 64;
99
+ static URL: LazyLock<Regex> =
100
+ LazyLock::new(|| Regex::new(r#"https?://[^\s)"'<>]+"#).expect("the URL pattern is valid"));
101
+ static POSITION: LazyLock<Regex> = LazyLock::new(|| {
102
+ Regex::new(r":[0-9]+(?::[0-9]+)?$").expect("the position pattern is valid")
103
+ });
104
+ let mut parts = Vec::new();
105
+ let mut urls: Vec<&str> = Vec::new();
106
+ let mut copied = 0;
107
+ for found in URL.find_iter(stack) {
108
+ let url = found.as_str();
109
+ let url = POSITION
110
+ .find(url)
111
+ .map_or(url, |position| &url[..position.start()]);
112
+ if url != origin && !url.starts_with(&format!("{origin}/")) {
113
+ continue;
114
+ }
115
+ let index = match urls.iter().position(|known| *known == url) {
116
+ Some(index) => index,
117
+ None if urls.len() >= MAX_URLS => continue,
118
+ None => {
119
+ urls.push(url);
120
+ urls.len() - 1
121
+ }
122
+ };
123
+ parts.push(json!(&stack[copied..found.start()]));
124
+ parts.push(json!(index));
125
+ copied = found.start() + url.len();
126
+ }
127
+ parts.push(json!(&stack[copied..]));
128
+ json!([parts, urls])
129
+ }
130
+
69
131
  #[derive(Debug, Clone)]
70
132
  pub struct Field {
71
133
  pub name: String,
@@ -89,7 +151,10 @@ impl Field {
89
151
  #[must_use]
90
152
  pub(crate) fn option<'a>(&'a self, script: &'a ScriptState) -> Option<&'a str> {
91
153
  expression::option_key(&self.expression).or_else(|| match self.global()? {
92
- (name, Shape::Called) => script.getters.get(name).map(String::as_str),
154
+ (name, Shape::Called) => script
155
+ .getters
156
+ .get(name)
157
+ .map(|getter| getter.option.as_str()),
93
158
  _ => None,
94
159
  })
95
160
  }
@@ -199,6 +264,37 @@ mod tests {
199
264
  use crate::challenge::state::Elapsed;
200
265
  use crate::challenge::tally::Tally;
201
266
 
267
+ /// Page URLs become indices into a list of distinct URLs; other hosts stay in the text.
268
+ #[test]
269
+ fn splits_stacks_at_page_urls() {
270
+ let stack = "Error\n at f (https://captchatest.book.bi/app.js:1:20)\n at g \
271
+ (https://challenges.cloudflare.com/turnstile/v0/api.js:2:7)\n at h \
272
+ (https://captchatest.book.bi/app.js:3:4)";
273
+ assert_eq!(
274
+ split_page_urls(stack, "https://captchatest.book.bi"),
275
+ json!([
276
+ [
277
+ "Error\n at f (",
278
+ 0,
279
+ ":1:20)\n at g (https://challenges.cloudflare.com/turnstile/v0/api.js:2:7)\n at h (",
280
+ 0,
281
+ ":3:4)"
282
+ ],
283
+ ["https://captchatest.book.bi/app.js"]
284
+ ])
285
+ );
286
+ // As in the captured sessions, a stack without page URLs stays whole.
287
+ assert_eq!(
288
+ split_page_urls(stack, "https://example.com"),
289
+ json!([[stack], []])
290
+ );
291
+ assert_eq!(split_page_urls(stack, ""), json!([[stack], []]));
292
+ assert_eq!(
293
+ split_page_urls("at https://a.b:443/x.js:2:7", "https://a.b:443"),
294
+ json!([["at ", 0, ":2:7"], ["https://a.b:443/x.js"]])
295
+ );
296
+ }
297
+
202
298
  fn field(name: &str, text: &str) -> Field {
203
299
  Field {
204
300
  name: name.into(),
@@ -313,7 +409,13 @@ mod tests {
313
409
  global: "tally".into(),
314
410
  counters: vec!["a".into()],
315
411
  }),
316
- getters: BTreeMap::from([("getter".into(), "opt".into())]),
412
+ getters: BTreeMap::from([(
413
+ "getter".into(),
414
+ Getter {
415
+ option: "opt".into(),
416
+ page_origin: None,
417
+ },
418
+ )]),
317
419
  ..ScriptState::default()
318
420
  };
319
421
  let options = Options::from_iter([("opt".to_owned(), json!(["x"]))]);