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
@@ -378,14 +378,6 @@
378
378
  {
379
379
  "candidate": "candidate:3747697418 1 udp 2113937151 f9501b39-1d57-4eb6-8029-16167a5a330b.local 51498 typ host generation 0 ufrag mtbG network-cost 999",
380
380
  "observed_ms": 1792876992039
381
- },
382
- {
383
- "candidate": "candidate:1648342598 1 udp 1677729535 192.0.2.1 64597 typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag mtbG network-cost 999",
384
- "observed_ms": 1792876992052
385
- },
386
- {
387
- "candidate": "candidate:2007285888 1 udp 1677732095 2001:db8::1 59340 typ srflx raddr :: rport 0 generation 0 ufrag mtbG network-cost 999",
388
- "observed_ms": 1792876992056
389
381
  }
390
382
  ],
391
383
  "data_channel": true,
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cfsolve
4
- VERSION = "0.1.1"
5
- UPSTREAM_REVISION = "0f97eb7ccfea54f445b60a624ad8949beae95e95"
4
+ VERSION = "0.1.2"
5
+ UPSTREAM_REVISION = "1599e9b80b74689587f0e35445185c8174066793"
6
6
  end
@@ -21,41 +21,6 @@ set to the address of a running `capture-proxy`, which records the decoded
21
21
  payloads sent to Cloudflare. Currently only Safari and Chrome(ium) profile
22
22
  types are supported.
23
23
 
24
- Newer builds also rename values embedded in probe answers, namespace properties,
25
- and stylesheet selectors on every session. Rebuild the answers with
26
- `cargo run --release --example build_profile -- capture.json > answers.json`
27
- to include the source evidence needed to refresh those values. Existing answer
28
- files remain readable, but do not contain this additional evidence.
29
-
30
- For builds that vary stylesheet selectors, record the profile browser's CSSOM
31
- formatting once. In the challenge iframe's execution context, save the JSON
32
- result of:
33
-
34
- ```javascript
35
- JSON.stringify(Array.from(document.styleSheets, sheet =>
36
- Array.from(sheet.cssRules, rule => rule.cssText)))
37
- ```
38
-
39
- Pass that file as the second argument to `build_profile`. The answers then store
40
- the stylesheet source alongside its browser formatting. At solve time cfsolve
41
- checks that only randomized names have changed, substitutes the current names,
42
- and recomputes the stylesheet measurements without a browser. If the stylesheet
43
- structure changes, capture the new revision; the solver rejects an unmatched
44
- revision instead of sending an obsolete digest. The `stylesheets` array can hold
45
- multiple captured revisions for the same browser profile. Keep captures and
46
- device profiles private; they are not needed in bug reports or pull requests.
47
-
48
- Replay keeps browser measurements while deriving field names, namespace functions
49
- and trace frames from the current program. Function declaration order and function
50
- counts may change between builds. Trace frames come from the current program;
51
- captured frames are not replayed. Unmapped fields and ambiguous stylesheet
52
- markers are errors, rather than guessed answers.
53
-
54
- The internal `Replay::new` constructor now takes the current `Source` and
55
- `Installed` facts and returns a `Result`. It validates the profile and field
56
- mapping before use; there is no separate unchecked replay constructor. Stylesheet
57
- updates use the same replay path, preserving field order and late callback fields.
58
-
59
24
  ## Building
60
25
 
61
26
  You need the Rust version pinned in `rust-toolchain.toml`, plus CMake, a C/C++
@@ -1,32 +1,34 @@
1
1
  //! Store a real browser's probe answers for a device profile.
2
- //! Usage: build_profile <capture.json> [cssom-rules.json] > answers.json
2
+ //! Usage: build_profile <capture.json> [cssom.json] > answers.json
3
3
  //!
4
4
  //! The capture is a session's `capture.json` from the capture proxy, or what
5
5
  //! `scripts/cdp_capture.mjs` writes: the iframe's HTML, its POSTs with their
6
6
  //! responses, and the plaintext payloads. Each probe's record from the second
7
7
  //! payload is stored under the reference build's names; see
8
- //! `cfsolve::stage2::replay`.
8
+ //! `cfsolve::stage2::replay`. An adjacent `iframe.deobfuscated.js` (from
9
+ //! `capture_session`) is used instead of deobfuscating the capture's script.
9
10
 
10
- use std::env;
11
11
  use std::path::Path;
12
+ use std::{env, fs};
12
13
 
13
14
  use anyhow::{Context, Result};
14
15
  use cfsolve::capture::CapturedSession;
15
- use cfsolve::stage2::replay::{Answers, StylesheetSnapshot};
16
+ use cfsolve::stage2::replay::Answers;
16
17
 
17
18
  fn main() -> Result<()> {
18
- let path = env::args().nth(1).context("supply a capture JSON file")?;
19
+ let mut arguments = env::args().skip(1);
20
+ let path = arguments.next().context("supply a capture JSON file")?;
19
21
  let capture = CapturedSession::read(Path::new(&path))?;
20
- let mut answers = Answers::capture(
21
- &capture.source()?,
22
- &capture.program()?,
23
- capture.probe_payload()?,
24
- )?;
25
- if let Some(path) = env::args().nth(2) {
26
- let rules = serde_json::from_slice(&std::fs::read(path)?)?;
27
- answers
28
- .stylesheets
29
- .push(StylesheetSnapshot::capture(capture.iframe_html(), rules)?);
22
+ let source = capture.source_beside(Path::new(&path))?;
23
+ let program = capture.program()?;
24
+ let mut answers = Answers::capture(&source, &program, capture.probe_payload()?)?;
25
+ if let Some(path) = arguments.next() {
26
+ answers.measure_stylesheets(
27
+ &source,
28
+ &program,
29
+ capture.iframe_html(),
30
+ serde_json::from_str(&fs::read_to_string(path)?)?,
31
+ )?;
30
32
  }
31
33
  println!("{}", serde_json::to_string_pretty(&answers)?);
32
34
  Ok(())
@@ -0,0 +1,37 @@
1
+ //! Write a capture as a session directory, for `stage2_snapshot` and other tools that read the
2
+ //! challenge CLI's output.
3
+ //! Usage: capture_session <capture.json> <output-directory>
4
+ //!
5
+ //! Writes `iframe.js`, `iframe.deobfuscated.js`, and each POST response's program as
6
+ //! `response.decoded.txt`, `second-response.decoded.txt` and `third-response.decoded.txt`.
7
+
8
+ use std::path::Path;
9
+ use std::{env, fs};
10
+
11
+ use anyhow::{Context, Result};
12
+ use base64::Engine;
13
+ use base64::engine::general_purpose::STANDARD;
14
+ use cfsolve::capture::CapturedSession;
15
+
16
+ fn main() -> Result<()> {
17
+ let mut arguments = env::args().skip(1);
18
+ let capture = arguments.next().context("supply a capture.json")?;
19
+ let directory = arguments.next().context("supply an output directory")?;
20
+ let capture = CapturedSession::read(Path::new(&capture))?;
21
+ let directory = Path::new(&directory);
22
+ fs::create_dir_all(directory)?;
23
+ let javascript = capture.javascript()?;
24
+ fs::write(directory.join("iframe.js"), &javascript)?;
25
+ fs::write(
26
+ directory.join("iframe.deobfuscated.js"),
27
+ cfsolve::deobfuscate(javascript)?.text,
28
+ )?;
29
+ let names = ["response", "second-response", "third-response"];
30
+ for (name, program) in names.iter().zip(capture.programs()?) {
31
+ fs::write(
32
+ directory.join(format!("{name}.decoded.txt")),
33
+ STANDARD.encode(program),
34
+ )?;
35
+ }
36
+ Ok(())
37
+ }
@@ -0,0 +1,66 @@
1
+ //! Check stored answers against captured sessions of the current build.
2
+ //! Usage: check_answers <answers.json> <capture.json>...
3
+ //!
4
+ //! Translates each replayed probe's stored record into the session's names, as a solve does,
5
+ //! and compares its fields with the record the browser sent. Prints the fields only one side
6
+ //! has; a field the stored record keeps under a name the session lacks means the vocabulary
7
+ //! could not translate it.
8
+
9
+ use std::collections::BTreeSet;
10
+ use std::path::Path;
11
+ use std::{env, fs};
12
+
13
+ use anyhow::{Context, Result};
14
+ use cfsolve::capture::CapturedSession;
15
+ use cfsolve::js::JsValue;
16
+ use cfsolve::stage2::analysis;
17
+ use cfsolve::stage2::replay::Answers;
18
+
19
+ fn main() -> Result<()> {
20
+ let mut arguments = env::args().skip(1);
21
+ let answers = arguments.next().context("supply answers.json")?;
22
+ let answers: Answers = serde_json::from_str(&fs::read_to_string(&answers)?)?;
23
+ let mut mismatched = 0;
24
+ for path in arguments {
25
+ let capture = CapturedSession::read(Path::new(&path))?;
26
+ let analysis = analysis::analyze(&capture.source()?, &capture.program()?)?;
27
+ let plan = analysis.plan();
28
+ let vocabulary = plan.vocabulary();
29
+ let envelope = plan.record_fields.names();
30
+ let payload = capture.probe_payload()?;
31
+ let (mut checked, mut differing) = (0, 0);
32
+ for probe in plan.probes() {
33
+ let position = probe.position();
34
+ let Some(stored) = vocabulary
35
+ .partner(position)
36
+ .and_then(|partner| answers.probes.get(&partner))
37
+ else {
38
+ continue;
39
+ };
40
+ let mut fields = stored.fields.clone();
41
+ vocabulary.rewrite_to_session(position, &mut fields);
42
+ let ours: BTreeSet<&String> = fields.keys().collect();
43
+ let sent: BTreeSet<&String> = payload
44
+ .get(&probe.index.to_string())
45
+ .and_then(JsValue::as_object)
46
+ .into_iter()
47
+ .flat_map(|record| record.keys())
48
+ .filter(|name| !envelope.contains(&name.as_str()))
49
+ .collect();
50
+ checked += 1;
51
+ if ours != sent {
52
+ differing += 1;
53
+ println!(
54
+ "{path} probe {}: only stored {:?}, only sent {:?}",
55
+ probe.index,
56
+ ours.difference(&sent).collect::<Vec<_>>(),
57
+ sent.difference(&ours).collect::<Vec<_>>(),
58
+ );
59
+ }
60
+ }
61
+ println!("{path}: {checked} replayed records, {differing} differ");
62
+ mismatched += differing;
63
+ }
64
+ anyhow::ensure!(mismatched == 0, "{mismatched} records differ");
65
+ Ok(())
66
+ }
@@ -0,0 +1,190 @@
1
+ //! Compare a solve's payloads with a browser capture of the same build.
2
+ //! Usage: compare_session <session-directory> <capture.json> [--values]
3
+ //!
4
+ //! The session directory is one the challenge CLI writes. Probe records are compared in the
5
+ //! reference build's names, by key and JSON type; the other payload fields, whose names the
6
+ //! vocabulary does not cover, by count and type. Timing values are ignored. `--values` also
7
+ //! prints each probe field whose value differs.
8
+
9
+ use std::collections::BTreeMap;
10
+ use std::path::Path;
11
+ use std::{env, fs};
12
+
13
+ use anyhow::{Context, Result};
14
+ use base64::Engine;
15
+ use base64::engine::general_purpose::STANDARD;
16
+ use cfsolve::capture::CapturedSession;
17
+ use cfsolve::js::{JsObject, JsValue};
18
+ use cfsolve::script::Source;
19
+ use cfsolve::stage2::analysis::{self, Analysis};
20
+
21
+ fn main() -> Result<()> {
22
+ let mut arguments = env::args().skip(1);
23
+ let directory = arguments.next().context("supply a session directory")?;
24
+ let capture_path = arguments.next().context("supply a capture.json")?;
25
+ let directory = Path::new(&directory);
26
+ let read = |name: &str| fs::read_to_string(directory.join(name));
27
+ let read_payload = |name: &str| -> Result<JsObject> { Ok(serde_json::from_str(&read(name)?)?) };
28
+
29
+ let ours = analysis::analyze(
30
+ &Source::try_new(&read("iframe.deobfuscated.js")?)?,
31
+ &STANDARD.decode(read("response.decoded.txt")?.trim())?,
32
+ )?;
33
+ let capture = CapturedSession::read(Path::new(&capture_path))?;
34
+ let theirs = analysis::analyze(&capture.source()?, &capture.program()?)?;
35
+
36
+ let our_second = read_payload("second-payload.json")?;
37
+ let their_second: &JsObject = capture.probe_payload()?;
38
+ let our_values = values(&ours, &our_second);
39
+ let their_values = values(&theirs, their_second);
40
+ if env::args().any(|argument| argument == "--values") {
41
+ for (probe, their_record) in &their_values {
42
+ let Some(our_record) = our_values.get(probe) else {
43
+ continue;
44
+ };
45
+ for (name, their_value) in their_record {
46
+ let our_value = our_record.get(name).map_or("<missing>".to_owned(), short);
47
+ let their_value = short(their_value);
48
+ if our_value != their_value {
49
+ println!("{probe} {name}\n ours {our_value}\n theirs {their_value}");
50
+ }
51
+ }
52
+ }
53
+ }
54
+ let our_records = records(&our_values);
55
+ let their_records = records(&their_values);
56
+ let only = |a: &BTreeMap<String, String>, b: &BTreeMap<String, String>| -> Vec<String> {
57
+ a.iter()
58
+ .filter(|(key, kind)| b.get(*key) != Some(*kind))
59
+ .map(|(key, kind)| format!("{key}:{kind}"))
60
+ .collect()
61
+ };
62
+ for (probe, theirs) in &their_records {
63
+ match our_records.get(probe) {
64
+ None => println!("{probe}: only in the capture"),
65
+ Some(ours) if ours != theirs => {
66
+ println!(
67
+ "{probe}: ours {:?} theirs {:?}",
68
+ only(ours, theirs),
69
+ only(theirs, ours)
70
+ );
71
+ }
72
+ Some(_) => {}
73
+ }
74
+ }
75
+ for probe in our_records.keys() {
76
+ if !their_records.contains_key(probe) {
77
+ println!("{probe}: only in ours");
78
+ }
79
+ }
80
+
81
+ println!(
82
+ "second payload top level: {}",
83
+ top_level(&our_second, their_second)
84
+ );
85
+ if let (Ok(ours), Some(theirs)) = (read_payload("third-payload.json"), capture.last_payload()) {
86
+ println!("third payload top level: {}", top_level(&ours, theirs));
87
+ if let (Some(ours), Some(theirs)) = (last_record(&ours), last_record(theirs)) {
88
+ println!(
89
+ "checkbox record: ours {} theirs {}",
90
+ shape(ours),
91
+ shape(theirs)
92
+ );
93
+ }
94
+ }
95
+ if let (Ok(ours), Ok(theirs)) = (read_payload("payload.json"), capture.first_payload()) {
96
+ println!("first payload top level: {}", top_level(&ours, theirs));
97
+ }
98
+ Ok(())
99
+ }
100
+
101
+ /// Each probe's record in reference names, keyed by the reference probe or, for unpaired
102
+ /// probes, the family.
103
+ fn values(analysis: &Analysis, payload: &JsObject) -> BTreeMap<String, JsObject> {
104
+ let plan = analysis.plan();
105
+ let vocabulary = plan.vocabulary();
106
+ let envelope = plan.record_fields.names();
107
+ plan.probes()
108
+ .iter()
109
+ .filter_map(|probe| {
110
+ let mut record = payload.get(&probe.index.to_string())?.as_object()?.clone();
111
+ record.retain(|name, _| !envelope.contains(&name.as_str()));
112
+ vocabulary.rewrite_to_reference(probe.position(), &mut record);
113
+ let key = match (vocabulary.partner(probe.position()), &probe.operation) {
114
+ (Some(reference), _) => format!("reference {reference:02}"),
115
+ (None, Some(operation)) => format!("unpaired {}", operation.kind()),
116
+ (None, None) => "unpaired ?".to_owned(),
117
+ };
118
+ Some((key, record))
119
+ })
120
+ .collect()
121
+ }
122
+
123
+ /// Each record from [`values`] with its fields' JSON types.
124
+ fn records(values: &BTreeMap<String, JsObject>) -> BTreeMap<String, BTreeMap<String, String>> {
125
+ values
126
+ .iter()
127
+ .map(|(probe, record)| {
128
+ let types = record
129
+ .iter()
130
+ .map(|(name, value)| (name.clone(), kind(value)))
131
+ .collect();
132
+ (probe.clone(), types)
133
+ })
134
+ .collect()
135
+ }
136
+
137
+ /// The record under the highest numeric key, which in the third payload is the checkbox's.
138
+ fn last_record(payload: &JsObject) -> Option<&JsObject> {
139
+ payload
140
+ .iter()
141
+ .filter_map(|(key, value)| Some((key.parse::<usize>().ok()?, value)))
142
+ .max_by_key(|(index, _)| *index)
143
+ .and_then(|(_, value)| value.as_object())
144
+ }
145
+
146
+ fn short(value: &JsValue) -> String {
147
+ let text = value.stringify().unwrap_or_else(|| "undefined".into());
148
+ text.chars().take(240).collect()
149
+ }
150
+
151
+ fn kind(value: &JsValue) -> String {
152
+ match value {
153
+ JsValue::Null => "null".into(),
154
+ JsValue::Bool(_) => "bool".into(),
155
+ JsValue::Number(_) => "number".into(),
156
+ JsValue::String(text) if text.is_empty() => "empty".into(),
157
+ JsValue::String(_) => "string".into(),
158
+ JsValue::Array(items) => format!("array{}", items.len()),
159
+ JsValue::Object(object) => format!("object{}", object.len()),
160
+ JsValue::Undefined => "undefined".into(),
161
+ }
162
+ }
163
+
164
+ /// Non-record fields: count and multiset of types on each side.
165
+ fn top_level(ours: &JsObject, theirs: &JsObject) -> String {
166
+ let mut counts: BTreeMap<String, [usize; 2]> = BTreeMap::new();
167
+ for (side, payload) in [ours, theirs].into_iter().enumerate() {
168
+ for (key, value) in payload {
169
+ if key.parse::<usize>().is_err() {
170
+ counts.entry(kind(value)).or_default()[side] += 1;
171
+ }
172
+ }
173
+ }
174
+ let differing: Vec<String> = counts
175
+ .iter()
176
+ .filter(|(_, [ours, theirs])| ours != theirs)
177
+ .map(|(kind, [ours, theirs])| format!("{kind} {ours}/{theirs}"))
178
+ .collect();
179
+ let total = |side: usize| counts.values().map(|count| count[side]).sum::<usize>();
180
+ format!(
181
+ "ours {} theirs {} fields; differing types (ours/theirs): {differing:?}",
182
+ total(0),
183
+ total(1),
184
+ )
185
+ }
186
+
187
+ fn shape(record: &JsObject) -> String {
188
+ let kinds: Vec<String> = record.values().map(kind).collect();
189
+ format!("{} fields {kinds:?}", record.len())
190
+ }
@@ -54,7 +54,10 @@ fn main() -> Result<()> {
54
54
  let frames = fields
55
55
  .calls
56
56
  .context("the stack probe's calls have an unrecognized shape")
57
- .and_then(|calls| call_stack::recover_probe(&original, &cases, calls))
57
+ .and_then(|calls| {
58
+ let resumed = call_stack::Resumption::WorkerMessage;
59
+ call_stack::recover_probe(&original, &cases, calls, resumed)
60
+ })
58
61
  .map(|geometry| {
59
62
  json!({
60
63
  "frames": geometry.frames,