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
@@ -1,13 +1,12 @@
1
1
  //! Refreshes recorded ICE connection state while preserving its format and timing.
2
2
  //!
3
- //! Reports recorded host candidates, matching a network that blocks UDP. Does not query STUN
4
- //! servers, since they would report this machine's address instead of the proxy's.
3
+ //! Reports recorded host candidates only, matching a network that blocks UDP to STUN servers.
5
4
  //!
6
5
  //! The probe reuses a connection opened earlier. Chrome's recorded startup connection shares
7
6
  //! one mDNS name among host candidates; WebKit's uses a separate name for each. Keeps candidate
8
7
  //! lines, timing offsets, shared names and foundations, and offer codec and attribute order.
9
8
  //! Generates a fresh session ID, credentials, certificate fingerprint, mDNS names, foundations,
10
- //! and ports.
9
+ //! and host ports.
11
10
 
12
11
  use std::collections::HashMap;
13
12
 
@@ -24,8 +23,9 @@ fn tokens(line: &str) -> Vec<String> {
24
23
  line.split(' ').map(str::to_owned).collect()
25
24
  }
26
25
 
27
- fn is_host(tokens: &[String]) -> bool {
28
- tokens.get(7).is_some_and(|kind| kind == "host")
26
+ /// Whether the candidate `line` has type `host` rather than, say, `srflx` (server-reflexive).
27
+ pub(crate) fn is_host(line: &str) -> bool {
28
+ line.split(' ').nth(7) == Some("host")
29
29
  }
30
30
 
31
31
  /// Session identity shared by candidates and SDP.
@@ -82,6 +82,7 @@ impl Identity {
82
82
 
83
83
  /// Replaces `previous`'s connection state with a fresh one, keeping its capabilities and timing.
84
84
  /// Candidates that shared an mDNS name or a foundation in the recording share the fresh one.
85
+ /// Candidates other than host candidates are left out.
85
86
  pub(crate) fn refresh(previous: Measurement) -> Result<Measurement> {
86
87
  let Measurement::Observed {
87
88
  preparation,
@@ -109,17 +110,17 @@ pub(crate) fn refresh(previous: Measurement) -> Result<Measurement> {
109
110
  .candidate
110
111
  .as_str()
111
112
  .context("a recorded media candidate is not a line")?;
112
- let tokens = tokens(line);
113
- if !is_host(&tokens) {
113
+ if !is_host(line) {
114
114
  continue;
115
115
  }
116
- let address = names
116
+ let tokens = tokens(line);
117
+ let name = names
117
118
  .entry(tokens[4].clone())
118
119
  .or_insert_with(|| format!("{}.local", uuid()));
119
120
  let foundation = *foundations
120
121
  .entry(tokens[0].clone())
121
122
  .or_insert_with(|| u32::from_le_bytes(random()));
122
- let candidate = identity.candidate(&tokens, foundation, address, port());
123
+ let candidate = identity.candidate(&tokens, foundation, name, port());
123
124
  let offset = media::delta(recorded_candidate.observed_ms, recorded_started_ms)?;
124
125
  candidates.push(Candidate {
125
126
  candidate: json!(candidate),
@@ -248,7 +249,7 @@ mod tests {
248
249
  assert_eq!(offsets, [-100, -83]);
249
250
  assert_eq!(snapshot.finished_ms - snapshot.started_ms, 154);
250
251
  for tokens in &lines {
251
- assert!(is_host(tokens) && tokens[4].ends_with(".local"));
252
+ assert!(tokens[7] == "host" && tokens[4].ends_with(".local"));
252
253
  assert!(!HOST.contains(&tokens[4]) && !OTHER_HOST.contains(&tokens[4]));
253
254
  }
254
255
  assert_ne!(lines[0][4], lines[1][4], "WebKit names each host candidate");
@@ -349,6 +349,15 @@ impl Fields {
349
349
  }
350
350
 
351
351
  fn read(&self, record: &JsObject, started_ms: i64) -> Result<Measurement> {
352
+ /// The four-entry support array, deserialized from a JSON array in field order.
353
+ #[derive(Deserialize)]
354
+ struct Support {
355
+ audio_support: Vec<i8>,
356
+ video_support: Vec<i8>,
357
+ audio_decoding: Option<Vec<i32>>,
358
+ video_decoding: Option<Vec<i32>>,
359
+ }
360
+
352
361
  let field = |name: &str| {
353
362
  record
354
363
  .get(name)
@@ -374,12 +383,12 @@ impl Fields {
374
383
  .collect::<Result<_>>()?;
375
384
  // Read MediaSource support bits followed by decoding bits or null, for audio first and then
376
385
  // video.
377
- let (audio_support, video_support, audio_decoding, video_decoding): (
378
- Vec<i8>,
379
- Vec<i8>,
380
- Option<Vec<i32>>,
381
- Option<Vec<i32>>,
382
- ) = js::from_value(field(MEDIA_SUPPORT)?.clone())
386
+ let Support {
387
+ audio_support,
388
+ video_support,
389
+ audio_decoding,
390
+ video_decoding,
391
+ } = js::from_value(field(MEDIA_SUPPORT)?.clone())
383
392
  .context("the media support is not four entries")?;
384
393
  let reported = [
385
394
  (&self.audio_types, &audio_decoding),
@@ -514,34 +523,39 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Box<Fields>> {
514
523
  .iter()
515
524
  .find(|text| text.starts_with(prefix) && text.contains(codec))
516
525
  };
517
- let audio = find("audio/mp4;", "mp4a.40.2")?;
518
- if !audio.contains('A') {
519
- let arrays = probe.index.string_arrays(probe.entry);
520
- let list = |prefix: &str, codec: &str| {
521
- crate::candidates::exactly_one(
522
- arrays.iter().filter(|items| {
523
- items.first().is_some_and(|text| text.starts_with(prefix))
524
- && items.iter().any(|text| text.contains(codec))
525
- }),
526
- "media list",
527
- )
528
- .ok()
529
- .cloned()
530
- };
531
- return Some(Box::new(Fields {
532
- audio_types: list("audio/mp4;", "mp4a.40.2")?,
533
- video_types: list("video/mp4;", "avc1.42E01E")?,
534
- excluded_types: list("audio/mp4;", "theora")?,
535
- }));
536
- }
537
- let video = find("video/mp4;", "avc1.42E01E")?;
538
- let excluded = find("audio/mp4;", "theora")?;
539
526
  let types = |list: &str| -> Vec<String> { list.split('A').map(str::to_owned).collect() };
540
- Some(Box::new(Fields {
541
- audio_types: types(audio),
542
- video_types: types(video),
543
- excluded_types: types(excluded),
544
- }))
527
+ let joined = || {
528
+ Some(Box::new(Fields {
529
+ audio_types: types(find("audio/mp4;", "mp4a.40.2")?),
530
+ video_types: types(find("video/mp4;", "avc1.42E01E")?),
531
+ excluded_types: types(find("audio/mp4;", "theora")?),
532
+ }))
533
+ };
534
+ joined().or_else(|| {
535
+ // Builds with hex names list each type: the audio types, the video types, then the
536
+ // excluded types, which mix both.
537
+ let is_media = |text: &str| text.starts_with("audio/") || text.starts_with("video/");
538
+ let run: Vec<String> = decoded
539
+ .iter()
540
+ .skip_while(|text| !is_media(text))
541
+ .take_while(|text| is_media(text))
542
+ .map(|text| text.to_string())
543
+ .collect();
544
+ let leading = |list: &[String], prefix: &str| {
545
+ list.iter()
546
+ .take_while(|text| text.starts_with(prefix))
547
+ .count()
548
+ };
549
+ let (audio, rest) = run.split_at(leading(&run, "audio/"));
550
+ let (video, excluded) = rest.split_at(leading(rest, "video/"));
551
+ (!audio.is_empty() && !video.is_empty() && !excluded.is_empty()).then(|| {
552
+ Box::new(Fields {
553
+ audio_types: audio.to_vec(),
554
+ video_types: video.to_vec(),
555
+ excluded_types: excluded.to_vec(),
556
+ })
557
+ })
558
+ })
545
559
  }
546
560
 
547
561
  #[cfg(test)]
@@ -57,6 +57,7 @@ impl Measurement {
57
57
  /// loads, and this probe's mark at `marked_ms`. Reports times at 0.1 ms resolution relative
58
58
  /// to iframe navigation.
59
59
  #[must_use]
60
+ #[expect(clippy::too_many_arguments)]
60
61
  pub(crate) fn observed(
61
62
  plan: &ProbePlan,
62
63
  index: usize,
@@ -4,6 +4,7 @@ use percent_encoding::percent_decode_str;
4
4
  use serde::{Deserialize, Serialize};
5
5
 
6
6
  use crate::stage2::analysis::recognize::ProbeView;
7
+ use crate::stage2::vocabulary;
7
8
 
8
9
  /// Number of strings from the opening `<style>#` to the closing button, including fragments
9
10
  /// interleaved with 27 ID references.
@@ -42,24 +43,8 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
42
43
  {
43
44
  return None;
44
45
  }
45
- let arrays = probe
46
- .index
47
- .string_arrays(probe.entry)
48
- .into_iter()
49
- .filter(|items| {
50
- items.len() == 10
51
- && items.iter().all(|text| {
52
- !text.is_empty()
53
- && text
54
- .bytes()
55
- .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
56
- })
57
- });
58
- if let Ok(ids) = crate::candidates::exactly_one(arrays, "rectangle IDs") {
59
- return Some(Fields { ids });
60
- }
61
46
  // Match `list.split(separator)`, including lists decoded from percent encoding first.
62
- (2..strings.len()).find_map(|at| {
47
+ let split = (2..strings.len()).find_map(|at| {
63
48
  let separator = &strings[at - 1];
64
49
  if strings[at] != "split" || separator.is_empty() {
65
50
  return None;
@@ -75,5 +60,15 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
75
60
  .iter()
76
61
  .all(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_alphanumeric()));
77
62
  valid.then_some(Fields { ids })
63
+ });
64
+ // Builds with hex names store the IDs as hex names in list order: the last ten of the last
65
+ // run of at least ten before the markup. An option read may precede them in the run.
66
+ split.or_else(|| {
67
+ let run = strings[..start]
68
+ .chunk_by(|left, right| vocabulary::is_hex_name(left) == vocabulary::is_hex_name(right))
69
+ .rfind(|run| run.len() >= 10 && vocabulary::is_hex_name(&run[0]))?;
70
+ Some(Fields {
71
+ ids: run[run.len() - 10..].to_vec(),
72
+ })
78
73
  })
79
74
  }
@@ -10,6 +10,7 @@ use crate::js::{JsObject, json};
10
10
  use crate::listing::Index;
11
11
  use crate::stage2::analysis::recognize::ProbeView;
12
12
  use crate::stage2::call_stack::ProbeCalls;
13
+ use crate::stage2::vocabulary;
13
14
  use crate::vm::Term;
14
15
 
15
16
  #[derive(Clone, Debug, Deserialize, Serialize)]
@@ -29,7 +30,7 @@ impl Fields {
29
30
 
30
31
  /// Finds the closure called by the probe function at `entry` to construct `new Error()`.
31
32
  fn calls(index: &Index<'_>, entry: u32) -> Option<ProbeCalls> {
32
- let construct = index
33
+ index
33
34
  .effects_in(index.function_range(entry)?)
34
35
  .find_map(|(pc, effect)| {
35
36
  let (callee, _) = effect.call()?;
@@ -37,11 +38,8 @@ fn calls(index: &Index<'_>, entry: u32) -> Option<ProbeCalls> {
37
38
  return None;
38
39
  };
39
40
  construction(index, closure)
40
- })?;
41
- Some(ProbeCalls {
42
- construct,
43
- arity: index.arity(entry)?,
44
- })
41
+ })
42
+ .map(|construct| ProbeCalls { construct })
45
43
  }
46
44
 
47
45
  /// The opcode with which the function at `entry` constructs `new Error()`.
@@ -63,66 +61,52 @@ fn construction(index: &Index<'_>, entry: u32) -> Option<u8> {
63
61
  })
64
62
  }
65
63
 
66
- /// A two-byte XOR key that spells `stack` identifies the encoded string table.
67
- fn encoded_names(features: &[String]) -> Option<Vec<String>> {
64
+ /// Finds the output field hidden by base64 encoding and a two-byte XOR key. A name that decodes to
65
+ /// `stack` identifies the key. The output field is the remaining name after excluding `stack` and
66
+ /// the record counter from the header. Builds with hex names encode only `stack`.
67
+ fn encoded_result_field(features: &[String], header: &BTreeSet<String>) -> Option<String> {
68
68
  use base64::Engine;
69
69
  use base64::engine::general_purpose::STANDARD;
70
70
 
71
71
  let decoded: Vec<Vec<u8>> = features
72
72
  .iter()
73
- .filter_map(|text| {
74
- let decoded;
75
- let text = if text.starts_with('\0') {
76
- decoded = crate::listing::decode_string(text, "").ok()?;
77
- &decoded
78
- } else {
79
- text
80
- };
81
- STANDARD.decode(text).ok()
82
- })
83
- .filter(|bytes| (3..=25).contains(&bytes.len()))
73
+ .filter_map(|text| STANDARD.decode(text).ok())
74
+ .filter(|bytes| (3..=12).contains(&bytes.len()))
84
75
  .collect();
85
- let keys: BTreeSet<_> = decoded
76
+ let plain: BTreeSet<String> = features
86
77
  .iter()
87
- .filter(|bytes| bytes.len() == 5)
88
- .filter_map(|candidate| {
89
- let key = [candidate[0] ^ b's', candidate[1] ^ b't'];
90
- candidate
91
- .iter()
92
- .zip(b"stack")
93
- .enumerate()
94
- .all(|(index, (cipher, plain))| cipher ^ key[index % 2] == *plain)
95
- .then_some(key)
96
- })
97
- .collect();
98
- let key = candidates::exactly_one(keys, "stack string key").ok()?;
99
- Some(
100
- decoded
101
- .iter()
102
- .filter_map(|bytes| {
103
- String::from_utf8(
104
- bytes
105
- .iter()
106
- .zip(key.iter().cycle())
107
- .map(|(byte, mask)| byte ^ mask)
108
- .collect(),
109
- )
110
- .ok()
111
- })
112
- .collect(),
113
- )
114
- }
115
-
116
- /// Ignore ordinary base64-looking words and the record counter when identifying the output.
117
- fn encoded_result_field(names: &[String], header: &BTreeSet<String>) -> Option<String> {
118
- let names = names.iter().filter(|name| {
119
- crate::stage2::vocabulary::is_name(name)
120
- && name.as_str() != "stack"
121
- && !header.contains(*name)
122
- });
123
- candidates::at_most_one_distinct(names, "stack result field")
124
- .ok()?
78
+ .filter(|name| vocabulary::is_hex_name(name))
125
79
  .cloned()
80
+ .collect();
81
+ for candidate in decoded.iter().filter(|bytes| bytes.len() == "stack".len()) {
82
+ let key = [candidate[0] ^ b's', candidate[1] ^ b't'];
83
+ let reveal = |bytes: &Vec<u8>| {
84
+ let revealed = bytes
85
+ .iter()
86
+ .zip(key.iter().cycle())
87
+ .map(|(byte, mask)| byte ^ mask);
88
+ String::from_utf8(revealed.collect()).ok()
89
+ };
90
+ if reveal(candidate).as_deref() != Some("stack") {
91
+ continue;
92
+ }
93
+ // Plain strings such as `this` can also be valid base64. Restrict candidates to the
94
+ // four-to-nine-character program names.
95
+ let revealed = decoded.iter().filter_map(reveal).filter(|name| {
96
+ (4..=9).contains(&name.len())
97
+ && name.starts_with(|first: char| first.is_ascii_alphabetic())
98
+ && name
99
+ .chars()
100
+ .all(|character| character.is_ascii_alphanumeric())
101
+ });
102
+ let names = revealed
103
+ .chain(plain.iter().cloned())
104
+ .filter(|name| name != "stack" && !header.contains(name));
105
+ if let Ok(result) = candidates::exactly_one(names, "stack result field") {
106
+ return Some(result);
107
+ }
108
+ }
109
+ None
126
110
  }
127
111
 
128
112
  /// Finds the output field in the string table by excluding the header's record-counter name,
@@ -158,23 +142,12 @@ fn result_field(features: &[String], header: &BTreeSet<String>) -> Option<String
158
142
  }
159
143
 
160
144
  pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
161
- let encoded = encoded_names(probe.session_features);
162
- let calls = calls(probe.index, probe.entry);
163
- let result = result_field(probe.session_features, probe.header)
164
- .or_else(|| encoded_result_field(encoded.as_ref()?, probe.header))
165
- .or_else(|| {
166
- calls.as_ref()?;
167
- encoded.as_ref()?;
168
- let fields = probe.writes.fields.iter().filter(|name| {
169
- crate::stage2::vocabulary::is_name(name)
170
- && !probe.header.contains(*name)
171
- && !probe.wrapper.contains(*name)
172
- });
173
- candidates::exactly_one(fields, "plain stack result")
174
- .ok()
175
- .cloned()
176
- })?;
177
- Some(Fields { result, calls })
145
+ result_field(probe.session_features, probe.header)
146
+ .or_else(|| encoded_result_field(probe.session_features, probe.header))
147
+ .map(|result| Fields {
148
+ result,
149
+ calls: calls(probe.index, probe.entry),
150
+ })
178
151
  }
179
152
 
180
153
  #[cfg(test)]
@@ -215,38 +188,12 @@ mod tests {
215
188
  ],
216
189
  ] {
217
190
  let features = features.map(str::to_owned);
218
- let names = encoded_names(&features).unwrap();
219
191
  assert_eq!(
220
- encoded_result_field(&names, &header).as_deref(),
192
+ encoded_result_field(&features, &header).as_deref(),
221
193
  Some("CJkad9"),
222
194
  "{features:?}"
223
195
  );
224
- assert!(encoded_result_field(&names, &BTreeSet::new()).is_none());
196
+ assert!(encoded_result_field(&features, &BTreeSet::new()).is_none());
225
197
  }
226
198
  }
227
-
228
- #[test]
229
- fn decodes_inline_tables_and_rejects_conflicting_keys() {
230
- use base64::{Engine, engine::general_purpose::STANDARD};
231
- let encode = |text: &str, key: [u8; 2]| {
232
- STANDARD.encode(
233
- text.bytes()
234
- .enumerate()
235
- .map(|(index, byte)| byte ^ key[index % 2])
236
- .collect::<Vec<_>>(),
237
- )
238
- };
239
- let key = [17, 44];
240
- let field = "_0123456789abcdef01234567";
241
- let features = ["stack", field].map(|text| {
242
- let encoded = encode(text, key);
243
- format!("\0{}{encoded}padding", char::from(encoded.len() as u8))
244
- });
245
- let names = encoded_names(&features).unwrap();
246
- assert_eq!(
247
- encoded_result_field(&names, &BTreeSet::new()).as_deref(),
248
- Some(field)
249
- );
250
- assert!(encoded_names(&[encode("stack", key), encode("stack", [18, 45])]).is_none());
251
- }
252
199
  }
@@ -126,13 +126,7 @@ impl Fields {
126
126
  /// or before opening. The close status follows.
127
127
  /// - The platform option precedes the URL suffix `/w`.
128
128
  pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
129
- let mut strings: Vec<String> = probe
130
- .session_features
131
- .iter()
132
- .filter(|text| !matches!(text.as_str(), "Date" | "Math" | "length"))
133
- .cloned()
134
- .collect();
135
- strings.dedup();
129
+ let strings = probe.session_features;
136
130
  let markers = ["WebSocket", "onopen", "onmessage", "onclose", "isFinite"];
137
131
  if !markers
138
132
  .iter()
@@ -147,21 +141,19 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
147
141
  };
148
142
  let before = |sequence: &[&str]| strings.get(find(sequence)?.checked_sub(1)?).cloned();
149
143
 
150
- let start = find(&["readyState", "close"])? + 2;
151
- let candidates: std::collections::BTreeSet<_> = strings[start..]
152
- .windows(7)
153
- .filter(|fields| {
154
- fields[..5]
155
- .iter()
156
- .all(|name| probe.writes.fields.contains(name))
157
- && fields[5..].iter().all(|name| {
158
- super::super::vocabulary::is_name(name) && !probe.writes.fields.contains(name)
159
- })
160
- })
161
- .collect();
162
- let [status, sent, received, opened, _error, complete, incomplete] =
163
- crate::candidates::exactly_one(candidates, "WebSocket summary").ok()?
164
- else {
144
+ // Builds that load builtins such as `length`, `Date` and `Math` in the header omit them
145
+ // here. Their summary follows `readyState` and `close` instead.
146
+ let (summary, statuses) = match find(&["length"]) {
147
+ Some(length) => (length.checked_sub(5)?, length + 1),
148
+ None => {
149
+ let summary = find(&["readyState", "close"])? + 2;
150
+ (summary, summary + 5)
151
+ }
152
+ };
153
+ let [status, sent, received, opened, _error] = strings.get(summary..summary + 5)? else {
154
+ return None;
155
+ };
156
+ let [complete, incomplete] = strings.get(statuses..statuses + 2)? else {
165
157
  return None;
166
158
  };
167
159
  let finite = find(&["isFinite"])?;
@@ -178,11 +170,12 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
178
170
  received: received.clone(),
179
171
  opened: opened.clone(),
180
172
  round_trips: before(&["JSON", "stringify"])?,
181
- elapsed: before(&["String", "now"])?,
173
+ elapsed: before(&["String", "Date", "now"]).or_else(|| before(&["String", "now"]))?,
182
174
  statuses: Statuses {
183
175
  complete: complete.clone(),
184
176
  incomplete: incomplete.clone(),
185
- timed_out: before(&["String", "round"])?,
177
+ timed_out: before(&["String", "Math", "round"])
178
+ .or_else(|| before(&["String", "round"]))?,
186
179
  failed_before_open: failed_before_open.clone(),
187
180
  failed_after_open: failed_after_open.clone(),
188
181
  closed: closed.clone(),
@@ -207,68 +200,6 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
207
200
  mod tests {
208
201
  use super::*;
209
202
 
210
- #[test]
211
- fn recovers_summary_after_string_conversion_reads() {
212
- use crate::{
213
- listing::Index,
214
- stage2::{analysis::recognize::Strings, records::WriteOrder, vocabulary::Vocabulary},
215
- vm::Listing,
216
- };
217
- use std::collections::BTreeSet;
218
-
219
- let mut features: Vec<String> = "WebSocket readyState close Status1 Sent1 String Sent1 Recv1 String Recv1 Open1 Trips1 JSON stringify Elapsed1 String now Error1 Status1 Sent1 Recv1 Open1 Error1 Done1 Short1 send Time1 String round isFinite"
220
- .split_whitespace().map(str::to_owned).collect();
221
- features.push(String::new());
222
- features.extend(
223
- "After1 Before1 Closed1 Route1 /w onopen onmessage onclose"
224
- .split_whitespace()
225
- .map(str::to_owned),
226
- );
227
- let writes = WriteOrder {
228
- fields: "Status1 Sent1 Recv1 Open1 Error1 Trips1 Elapsed1"
229
- .split_whitespace()
230
- .map(str::to_owned)
231
- .collect(),
232
- ..WriteOrder::default()
233
- };
234
- let listing = Listing::default();
235
- let index = Index::new(&listing);
236
- let vocabulary = Vocabulary::infer::<&[String]>(&[]);
237
- let empty = BTreeSet::new();
238
- let recover = |features: &[String]| {
239
- recognize(&ProbeView {
240
- index: &index,
241
- vocabulary: &vocabulary,
242
- position: 0,
243
- header: &empty,
244
- wrapper: &empty,
245
- pool: "",
246
- entry: 0,
247
- session_features: features,
248
- writes: &writes,
249
- strings: Strings::new(features, &empty),
250
- })
251
- };
252
- let fields = recover(&features).unwrap();
253
- assert_eq!(fields.status, "Status1");
254
- assert_eq!(fields.sent, "Sent1");
255
- assert_eq!(fields.received, "Recv1");
256
- assert_eq!(fields.opened, "Open1");
257
- assert_eq!(fields.round_trips, "Trips1");
258
- assert_eq!(fields.elapsed, "Elapsed1");
259
- assert_eq!(fields.statuses.complete, "Done1");
260
- assert_eq!(fields.statuses.incomplete, "Short1");
261
- features.extend(
262
- "Status1 Sent1 Recv1 Open1 Error1 Other1 Other2"
263
- .split_whitespace()
264
- .map(str::to_owned),
265
- );
266
- assert!(
267
- recover(&features).is_none(),
268
- "ambiguous summaries must not be guessed"
269
- );
270
- }
271
-
272
203
  fn fields() -> Fields {
273
204
  Fields {
274
205
  platform: "ppebL7".into(),
@@ -17,7 +17,6 @@ mod platform;
17
17
  pub(crate) mod private_token;
18
18
  pub(super) mod rects;
19
19
  pub(super) mod stack;
20
- pub(crate) mod stylesheets;
21
20
  pub(crate) mod websocket_echo;
22
21
  pub(crate) mod worker_fetch;
23
22
  pub(super) mod worker_navigator;
@@ -124,13 +124,13 @@ pub struct Probe {
124
124
  pub operation: Option<Operation>,
125
125
  /// The strings the probe's collection function uses, in order.
126
126
  pub features: Vec<String>,
127
- /// Collection strings including uses of constants held in registers.
128
- #[serde(default)]
129
- pub expanded_features: Vec<String>,
130
127
  /// Recovered write order used to arrange computed fields. Replayed answers retain the
131
128
  /// browser's recorded order.
132
129
  #[serde(default)]
133
130
  pub writes: WriteOrder,
131
+ /// Session literals recovered from result writes that cannot be replayed unchanged.
132
+ #[serde(default)]
133
+ pub replay_literals: JsObject,
134
134
  }
135
135
 
136
136
  impl Probe {
@@ -144,8 +144,6 @@ impl Probe {
144
144
  /// The order in which a probe's collection function writes its record's fields.
145
145
  #[derive(Clone, Debug, Default, Deserialize, Serialize)]
146
146
  pub struct WriteOrder {
147
- #[serde(default)]
148
- pub internal: Vec<String>,
149
147
  /// Stored names in execution order: the synchronous body first, then its callbacks.
150
148
  pub fields: Vec<String>,
151
149
  /// Fields that only callbacks write after the probe has finished. They follow the clock
@@ -207,14 +205,7 @@ impl ProbePlan {
207
205
  .iter()
208
206
  .map(|probe| probe.features.as_slice())
209
207
  .collect();
210
- let mut vocabulary = Vocabulary::infer(&features);
211
- let expanded: Vec<&[String]> = self
212
- .probes
213
- .iter()
214
- .map(|probe| probe.expanded_features.as_slice())
215
- .collect();
216
- vocabulary.extend_expanded(&expanded);
217
- vocabulary
208
+ Vocabulary::infer(&features)
218
209
  })
219
210
  }
220
211