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
@@ -9,27 +9,26 @@
9
9
  //! plus trace membership and navigation sizes in the environment audit. Live requests, fresh
10
10
  //! WebRTC state, benchmark results, and stacks are computed separately.
11
11
 
12
- use std::collections::{BTreeMap, BTreeSet, HashSet};
12
+ use std::collections::BTreeMap;
13
13
  use std::mem;
14
14
  use std::ops::Range;
15
15
 
16
16
  use anyhow::{Context, Result, ensure};
17
17
  use serde::{Deserialize, Serialize};
18
18
 
19
- use super::analysis;
20
- pub use super::probes::stylesheets::Snapshot as StylesheetSnapshot;
21
- use super::probes::{stylesheets, worker_navigator};
19
+ pub use super::audit_globals::{Globals, Signature};
20
+ use super::probes::worker_navigator;
22
21
  use super::records::{ArrangedFields, Probe, ProbePlan};
23
22
  use super::reference::{
24
23
  ENVIRONMENT_AUDIT_DECODED, ENVIRONMENT_AUDIT_ENCODED, ENVIRONMENT_AUDIT_GROUPS,
25
24
  ENVIRONMENT_AUDIT_REDUCTION, ENVIRONMENT_AUDIT_VALUES, ENVIRONMENT_REFERRER, REFERENCE,
26
- WORKER_NAVIGATOR_RESULT,
25
+ STYLESHEETS_HASH, WORKER_NAVIGATOR_RESULT,
27
26
  };
28
27
  use super::registry::{AUDIT_PROBE, Kind, Operation, Treatment};
29
- pub use super::session_names::CapturedNames;
28
+ pub use super::stylesheets::Stylesheet;
30
29
  use super::trace::Trace;
31
- use super::vocabulary::Vocabulary;
32
- use crate::challenge::installed::Installed;
30
+ use super::vocabulary::{self, Vocabulary};
31
+ use super::{analysis, stylesheets};
33
32
  use crate::http::ResponseMetrics;
34
33
  use crate::js::{JsObject, JsValue, Options, Payload, json};
35
34
  use crate::script::Source;
@@ -43,12 +42,12 @@ pub struct Answers {
43
42
  pub probes: BTreeMap<usize, Stored>,
44
43
  /// The worker navigator, by API property.
45
44
  pub navigator: JsObject,
46
- /// Captured source/CSSOM pairs for stylesheet revisions this profile supports.
45
+ /// Definitions of the captured iframe globals, under the audit's stored names.
46
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
47
+ pub globals: BTreeMap<String, Signature>,
48
+ /// CSSOM measurements, checked against the current CSS before reuse.
47
49
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
48
- pub stylesheets: Vec<StylesheetSnapshot>,
49
- /// Source evidence for names that change between sessions.
50
- #[serde(default, skip_serializing_if = "Option::is_none")]
51
- pub names: Option<CapturedNames>,
50
+ pub stylesheets: Vec<Stylesheet>,
52
51
  }
53
52
 
54
53
  /// One probe's stored record, without its envelope.
@@ -65,17 +64,52 @@ pub struct Stored {
65
64
  }
66
65
 
67
66
  impl Answers {
67
+ /// Attaches browser CSSOM measurements only after reproducing this capture's reported hash.
68
+ pub fn measure_stylesheets(
69
+ &mut self,
70
+ source: &Source,
71
+ program: &[u8],
72
+ html: &str,
73
+ templates: Vec<Stylesheet>,
74
+ ) -> Result<()> {
75
+ let analysis = analysis::analyze(source, program)?;
76
+ let (probe, excluded) = analysis
77
+ .plan()
78
+ .probes()
79
+ .iter()
80
+ .find_map(|probe| Some((probe, stylesheets::excluded_selector(probe)?)))
81
+ .context("the capture has no stylesheet probe")?;
82
+ let excluded = excluded?;
83
+ let partner = analysis
84
+ .plan()
85
+ .vocabulary()
86
+ .partner(probe.position())
87
+ .context("the stylesheet probe has no reference partner")?;
88
+ let stored = self
89
+ .probes
90
+ .get(&partner)
91
+ .context("no stored stylesheet answer")?;
92
+ let mut fields = JsObject::new();
93
+ stylesheets::patch(html, &templates, excluded, &mut fields)?;
94
+ ensure!(
95
+ fields.get(STYLESHEETS_HASH) == stored.fields.get(STYLESHEETS_HASH),
96
+ "CSSOM measurements do not reproduce the captured stylesheet hash"
97
+ );
98
+ self.stylesheets = templates;
99
+ Ok(())
100
+ }
101
+
68
102
  /// Builds stored answers from a captured iframe script, probe program, and second payload.
69
103
  pub fn capture(source: &Source, program: &[u8], payload: &Payload) -> Result<Self> {
70
104
  let analysis = analysis::analyze(source, program)?;
71
105
  let plan = analysis.plan();
72
106
  let vocabulary = plan.vocabulary();
107
+ let globals = Globals::capture(source)?;
73
108
  let envelope = &plan.record_fields;
74
109
  let envelope_names = envelope.names();
75
110
  let is_envelope = |name: &String| envelope_names.contains(&name.as_str());
76
111
  let mut answers = Self {
77
112
  reference: REFERENCE.digest.clone(),
78
- names: Some(CapturedNames::capture(source, plan)?),
79
113
  ..Self::default()
80
114
  };
81
115
  for probe in plan.probes() {
@@ -125,6 +159,10 @@ impl Answers {
125
159
  vocabulary.rewrite_to_reference(position, &mut fields);
126
160
  // Replay refreshes an audit's navigation sizes and trace by reference name.
127
161
  if operation.family() == Kind::EnvironmentAudit {
162
+ for (name, signature) in &globals.0 {
163
+ let name = vocabulary.to_reference(position, name).to_owned();
164
+ answers.globals.insert(name, signature.clone());
165
+ }
128
166
  ensure!(
129
167
  fields.contains_key(ENVIRONMENT_AUDIT_GROUPS)
130
168
  && fields.contains_key(ENVIRONMENT_AUDIT_VALUES),
@@ -158,144 +196,33 @@ impl Answers {
158
196
  /// Stored answers matched to one session's probes.
159
197
  pub struct Replay<'answers> {
160
198
  answers: &'answers Answers,
161
- vocabulary: Vocabulary,
162
- refresh: Option<super::session_names::Refresh>,
163
- plan: &'answers ProbePlan,
199
+ vocabulary: &'answers Vocabulary,
164
200
  }
165
201
 
166
202
  impl<'answers> Replay<'answers> {
167
- /// Matches and validates stored answers, preparing names for the current program.
168
- pub fn new(
169
- answers: &'answers Answers,
170
- plan: &'answers ProbePlan,
171
- source: &Source,
172
- installed: &Installed,
173
- ) -> Result<Self> {
174
- answers.check()?;
175
- let mut vocabulary = plan.vocabulary().clone();
176
- let mut common: HashSet<&str> = plan
177
- .probes()
178
- .first()
179
- .into_iter()
180
- .flat_map(|probe| probe.features.iter().map(String::as_str))
181
- .collect();
182
- for probe in plan.probes() {
183
- common.retain(|name| probe.features.iter().any(|text| text == name));
184
- }
185
- for probe in plan.probes() {
186
- if let Some(partner) = vocabulary.partner(probe.position())
187
- && let Some(stored) = answers.probes.get(&partner)
188
- {
189
- let wanted: Vec<String> = stored.fields.keys().cloned().collect();
190
- vocabulary.refine_outputs(
191
- probe.position(),
192
- &probe.features,
193
- &probe.writes.fields,
194
- &probe.writes.internal,
195
- &common,
196
- &wanted,
197
- );
198
- }
199
- }
200
- let refresh = answers
201
- .names
202
- .as_ref()
203
- .map(|names| super::session_names::Refresh::new(names, source, plan, installed))
204
- .transpose()?;
205
- let replay = Self {
203
+ /// Matches `answers` to `plan`'s probes using the plan's vocabulary.
204
+ #[must_use]
205
+ pub fn new(answers: &'answers Answers, plan: &'answers ProbePlan) -> Self {
206
+ Self {
206
207
  answers,
207
- vocabulary,
208
- refresh,
209
- plan,
210
- };
211
- replay.validate()?;
212
- Ok(replay)
213
- }
214
-
215
- fn validate(&self) -> Result<()> {
216
- let mut failures = Vec::new();
217
- for probe in self.plan.probes() {
218
- let Some(partner) = self.vocabulary.partner(probe.position()) else {
219
- continue;
220
- };
221
- let Some(stored) = self.answers.probes.get(&partner) else {
222
- continue;
223
- };
224
- if probe.writes.fields.is_empty() {
225
- continue;
226
- }
227
- let missing: Vec<&String> =
228
- stored
229
- .fields
230
- .keys()
231
- .filter(|name| {
232
- !probe.writes.fields.iter().any(|field| {
233
- field == self.vocabulary.to_session(probe.position(), name)
234
- })
235
- })
236
- .collect();
237
- if !missing.is_empty() {
238
- failures.push(format!("probe {}: {:?}", probe.index, missing));
239
- }
240
- if probe
241
- .expanded_features
242
- .iter()
243
- .any(|name| super::vocabulary::hashed_name(name))
244
- {
245
- let mut names = BTreeSet::new();
246
- fn collect<'a>(value: &'a JsValue, names: &mut BTreeSet<&'a str>) {
247
- match value {
248
- JsValue::String(text) => {
249
- names.insert(text);
250
- }
251
- JsValue::Object(object) => {
252
- for (key, value) in object {
253
- names.insert(key);
254
- collect(value, names);
255
- }
256
- }
257
- JsValue::Array(array) => {
258
- for value in array {
259
- collect(value, names);
260
- }
261
- }
262
- _ => {}
263
- }
264
- }
265
- for (key, value) in &stored.fields {
266
- names.insert(key.as_str());
267
- collect(value, &mut names);
268
- }
269
- let reference = &super::reference::EXPANDED_REFERENCE.probes[partner - 1];
270
- names.retain(|name| {
271
- super::vocabulary::is_name(name)
272
- && reference.iter().any(|text| text == name)
273
- && !probe.expanded_features.iter().any(|text| text == name)
274
- && self.vocabulary.to_session(probe.position(), name) == *name
275
- });
276
- if !names.is_empty() {
277
- failures.push(format!("probe {} nested values: {:?}", probe.index, names));
278
- }
279
- }
208
+ vocabulary: plan.vocabulary(),
280
209
  }
281
- ensure!(
282
- failures.is_empty(),
283
- "unmapped stored result fields: {}",
284
- failures.join("; ")
285
- );
286
- Ok(())
287
210
  }
288
211
 
289
212
  /// Finds environment-audit groups that report window dimensions and position.
290
213
  #[must_use]
291
- pub fn audit_groups<'r>(&self, records: &'r JsObject) -> Option<&'r JsObject> {
292
- let position = (0..self.plan.probes().len())
214
+ pub fn audit_groups<'r>(
215
+ &self,
216
+ plan: &ProbePlan,
217
+ records: &'r JsObject,
218
+ ) -> Option<&'r JsObject> {
219
+ let position = (0..plan.probes().len())
293
220
  .find(|&position| self.vocabulary.partner(position) == Some(AUDIT_PROBE))?;
294
221
  let groups = self
295
222
  .vocabulary
296
223
  .to_session(position, ENVIRONMENT_AUDIT_GROUPS);
297
224
  records
298
- .get(&self.plan.probes()[position].index.to_string())?
225
+ .get(&plan.probes()[position].index.to_string())?
299
226
  .get(groups)?
300
227
  .as_object()
301
228
  }
@@ -330,33 +257,20 @@ impl<'answers> Replay<'answers> {
330
257
  let partner = self.vocabulary.partner(position).with_context(missing)?;
331
258
  let stored = self.answers.probes.get(&partner).with_context(missing)?;
332
259
  let mut fields = stored.fields.clone();
333
- session.patch(probe, &mut fields)?;
334
- if Kind::Stylesheets.references().contains(&partner) && !self.answers.stylesheets.is_empty()
260
+ let measured = &self.answers.stylesheets;
261
+ if !measured.is_empty()
262
+ && let Some(excluded) = stylesheets::excluded_selector(probe)
335
263
  {
336
- stylesheets::refresh(
337
- &self.answers.stylesheets,
338
- probe,
339
- session.iframe_html,
340
- &mut fields,
341
- )?;
342
- }
343
- // Profiles without source evidence retain the original trace-only refresh.
344
- if self.refresh.is_none()
345
- && let Some(groups) = fields
346
- .get_mut(ENVIRONMENT_AUDIT_GROUPS)
347
- .and_then(JsValue::as_object_mut)
348
- {
349
- let trace = session
350
- .trace
351
- .as_ref()
352
- .context("the environment audit needs the iframe's trace")?;
353
- refresh_trace(trace, groups, &probe.features, session.controller)?;
264
+ stylesheets::patch(session.iframe_html, measured, excluded?, &mut fields)?;
354
265
  }
266
+ session.patch(probe, &mut fields, &self.answers.globals)?;
355
267
  let mut arranged = ArrangedFields::split(fields, &stored.late);
356
268
  for fields in [&mut arranged.early, &mut arranged.late] {
357
269
  self.vocabulary.rewrite_to_session(position, fields);
358
- if let Some(refresh) = &self.refresh {
359
- refresh.apply(probe, &self.vocabulary, fields)?;
270
+ for (name, value) in &probe.replay_literals {
271
+ if let Some(field) = fields.get_mut(name) {
272
+ *field = value.clone();
273
+ }
360
274
  }
361
275
  }
362
276
  Ok(arranged)
@@ -365,10 +279,10 @@ impl<'answers> Replay<'answers> {
365
279
 
366
280
  /// Current-session values needed to refresh stored records.
367
281
  pub struct Session<'session> {
282
+ /// Current iframe HTML, including its inline styles.
283
+ pub iframe_html: &'session str,
368
284
  /// The iframe's URL, which records report as the referrer, base URI and stack locations.
369
285
  pub iframe_url: &'session str,
370
- /// Current iframe stylesheets, used to refresh captured CSSOM measurements.
371
- pub iframe_html: &'session str,
372
286
  /// `document.lastModified`, as the browser formats it.
373
287
  pub modified: String,
374
288
  /// Iframe `document.referrer`, set by the embedding page's policy or empty when absent.
@@ -381,18 +295,36 @@ pub struct Session<'session> {
381
295
  pub trace: Option<Trace>,
382
296
  /// The returned program's own trace frame.
383
297
  pub controller: Option<&'session str>,
298
+ /// Definitions in the current iframe, used to translate audited globals.
299
+ pub globals: &'session Globals,
384
300
  }
385
301
 
386
302
  impl Session<'_> {
387
303
  /// Patches a stored record while it still uses reference names: document identity, the
388
- /// referrer, and the environment audit's navigation measurements.
389
- fn patch(&self, probe: &Probe, fields: &mut JsObject) -> Result<()> {
304
+ /// referrer, and the environment audit's trace membership and navigation measurements.
305
+ fn patch(
306
+ &self,
307
+ probe: &Probe,
308
+ fields: &mut JsObject,
309
+ recorded: &BTreeMap<String, Signature>,
310
+ ) -> Result<()> {
390
311
  for value in fields.values_mut() {
391
312
  refresh_transplant(value, self.iframe_url, &self.modified);
392
313
  }
393
314
  if let Some(referrer) = fields.get_mut(ENVIRONMENT_REFERRER) {
394
315
  *referrer = json!(self.referrer);
395
316
  }
317
+ if let Some(groups) = fields
318
+ .get_mut(ENVIRONMENT_AUDIT_GROUPS)
319
+ .and_then(JsValue::as_object_mut)
320
+ {
321
+ let trace = self
322
+ .trace
323
+ .as_ref()
324
+ .context("the environment audit needs the iframe's trace")?;
325
+ refresh_globals(groups, &self.globals.mapping(recorded), self.globals);
326
+ refresh_trace(trace, groups, &probe.features, self.controller)?;
327
+ }
396
328
  if let Some(values) = fields
397
329
  .get_mut(ENVIRONMENT_AUDIT_VALUES)
398
330
  .and_then(JsValue::as_object_mut)
@@ -497,28 +429,47 @@ fn refresh_string(text: &mut String, iframe_url: &str, modified: &str) {
497
429
  }
498
430
  }
499
431
 
500
- /// Moves the trace global to the audit group matching this session's trace frames. Uses the
432
+ /// Substitutes this session's globals and removes unmatched names and their empty groups.
433
+ fn refresh_globals(groups: &mut JsObject, mapping: &BTreeMap<String, String>, globals: &Globals) {
434
+ for (key, members) in mem::take(groups) {
435
+ let JsValue::Array(members) = members else {
436
+ groups.insert(key, members);
437
+ continue;
438
+ };
439
+ if members.is_empty() {
440
+ group(groups, key);
441
+ continue;
442
+ }
443
+ for member in members {
444
+ let global = member
445
+ .as_str()
446
+ .and_then(|name| name.strip_prefix("o."))
447
+ .filter(|name| vocabulary::is_hex_name(name));
448
+ let Some(global) = global else {
449
+ group(groups, key.clone()).push(member);
450
+ continue;
451
+ };
452
+ let Some(current) = mapping.get(global) else {
453
+ continue;
454
+ };
455
+ let current_key = globals.0.get(current).and_then(Signature::audit_value);
456
+ let current_key = current_key.unwrap_or(&key).to_owned();
457
+ group(groups, current_key).push(json!(format!("o.{current}")));
458
+ }
459
+ }
460
+ }
461
+
462
+ /// Places this session's trace global in the audit group matching its trace frames. Uses the
501
463
  /// recovered controller frame as the second root, or the iframe callback frame if none was
502
464
  /// recovered.
503
- pub(crate) fn refresh_trace(
465
+ fn refresh_trace(
504
466
  trace: &Trace,
505
467
  groups: &mut JsObject,
506
468
  features: &[String],
507
469
  controller: Option<&str>,
508
470
  ) -> Result<()> {
509
471
  let member = JsValue::String(format!("o.{}", trace.global));
510
- let traced =
511
- |names: &JsValue| matches!(names, JsValue::Array(names) if names.contains(&member));
512
- if !groups.values().any(traced) {
513
- return Ok(());
514
- }
515
- let entered = features
516
- .windows(2)
517
- .find(|pair| pair[1] == trace.replace)
518
- .map(|pair| pair[0].as_str())
519
- .context("environment audit has no session trace entry")?;
520
- let second = controller.unwrap_or(&trace.returned_root);
521
- let key = format!("{},{},{}", trace.initial_root, second, entered);
472
+ let key = trace.audit_key(features, controller)?;
522
473
  for (old_key, mut names) in mem::take(groups) {
523
474
  let members = names
524
475
  .as_array_mut()
@@ -532,6 +483,11 @@ pub(crate) fn refresh_trace(
532
483
  group(groups, key.clone()).push(member.clone());
533
484
  }
534
485
  }
486
+ // Recorded answers name another session's trace global, which is dropped beforehand.
487
+ let traced = group(groups, key);
488
+ if !traced.contains(&member) {
489
+ traced.push(member);
490
+ }
535
491
  Ok(())
536
492
  }
537
493
 
@@ -578,93 +534,55 @@ fn refresh_transplant(value: &mut JsValue, iframe_url: &str, modified: &str) {
578
534
  #[cfg(test)]
579
535
  mod tests {
580
536
  use super::*;
537
+ use crate::challenge::installed::Installed;
581
538
  use crate::js;
582
539
 
583
540
  #[test]
584
- fn stylesheet_refresh_preserves_field_order_and_late_callbacks() {
585
- use super::super::reference::{STYLESHEET_DIGEST, STYLESHEET_MARKER};
586
- let probes: Vec<_> = REFERENCE.probes.iter().enumerate().map(|(position, features)| {
587
- json!({"index":position + 1, "token":"test", "input":{}, "features":features})
588
- }).collect();
589
- let mut plan: ProbePlan = js::from_value(json!({
590
- "record_fields":{"counter":"counter", "token":"token", "input":"input",
591
- "status":"status", "message":"message", "started":"started",
592
- "finished":"finished", "duration":"duration"},
593
- "payload":{"template":{"fields":{}}, "published":[], "footer":{}, "pending":null},
594
- "probes":probes
541
+ fn audit_globals_keep_their_groups_under_current_names() {
542
+ let mut groups: JsObject = js::from_value(json!({
543
+ "N": ["o._111111111111111111111111", "o.alert"],
544
+ "o": ["o._222222222222222222222222"],
545
+ "empty": []
595
546
  }))
596
547
  .unwrap();
597
- let position = Kind::Stylesheets.references()[0] - 1;
598
- // The embedded reference already contains its marker; use that selector in both revisions.
599
- let marker = plan.probes[position]
600
- .features
601
- .iter()
602
- .find(|text| text.starts_with('.'))
603
- .unwrap()
604
- .clone();
605
- let html = format!("<style>.Abcd1{{color:red}}</style><style>{marker}{{margin:0}}</style>");
606
- let snapshot = StylesheetSnapshot::capture(
607
- &html,
608
- vec![
609
- vec![".Abcd1 { color: red; }".into()],
610
- vec![format!("{marker} {{ margin: 0px; }}")],
611
- ],
612
- )
613
- .unwrap();
614
- let fields = JsObject::from_iter([
615
- ("unchanged".into(), json!(true)),
616
- (STYLESHEET_DIGEST.into(), json!("stale")),
617
- (STYLESHEET_MARKER.into(), json!("stale")),
618
- ]);
619
- plan.probes[position].writes.fields = fields.keys().cloned().collect();
620
- let mut answers = Answers {
621
- reference: REFERENCE.digest.clone(),
622
- probes: BTreeMap::from([(
623
- position + 1,
624
- Stored {
625
- duration: None,
626
- fields,
627
- late: vec![STYLESHEET_MARKER.into()],
628
- },
548
+ refresh_globals(
549
+ &mut groups,
550
+ &BTreeMap::from([(
551
+ "_111111111111111111111111".into(),
552
+ "_aaaaaaaaaaaaaaaaaaaaaaaa".into(),
629
553
  )]),
630
- stylesheets: vec![snapshot],
631
- ..Answers::default()
632
- };
633
- let source = Source::try_new("var root = this || self;").unwrap();
634
- let installed = Installed::default();
635
- let replay = Replay::new(&answers, &plan, &source, &installed).unwrap();
636
- let navigation = ResponseMetrics::default();
637
- let options = Options::new();
638
- let mut session = Session {
639
- iframe_url: CURRENT,
640
- iframe_html: &html,
641
- modified: "01/01/2000 00:00:00".into(),
642
- referrer: "https://example.com/",
643
- navigation: &navigation,
644
- options: &options,
645
- trace: None,
646
- controller: None,
647
- };
648
- let result = replay.answer(&plan.probes[position], &session).unwrap();
649
- assert_eq!(
650
- result.early.keys().map(String::as_str).collect::<Vec<_>>(),
651
- ["unchanged", STYLESHEET_DIGEST]
554
+ &Globals::default(),
652
555
  );
653
- assert_eq!(result.late.len(), 1);
654
556
  assert_eq!(
655
- result.late[STYLESHEET_MARKER],
656
- json!(format!("{marker} {{ margin: 0px; }}1"))
557
+ json!(groups),
558
+ json!({
559
+ "N": ["o._aaaaaaaaaaaaaaaaaaaaaaaa", "o.alert"], "empty": []
560
+ })
657
561
  );
658
- assert_ne!(result.early[STYLESHEET_DIGEST], json!("stale"));
562
+ }
563
+
564
+ #[test]
565
+ fn installed_globals_refresh_their_values_and_split_shared_groups() {
566
+ let installed = |name: &str, value: &str| {
567
+ Globals::installed(&Installed {
568
+ audit: BTreeMap::from([(name.into(), value.into())]),
569
+ ..Default::default()
570
+ })
571
+ };
572
+ let globals = installed("_aaaaaaaaaaaaaaaaaaaaaaaa", "_bbbbbbbbbbbbbbbbbbbbbbbb");
573
+ let recorded = installed("_111111111111111111111111", "_222222222222222222222222");
574
+ let mut groups: JsObject = js::from_value(json!({
575
+ "_222222222222222222222222": ["o._111111111111111111111111", "other"]
576
+ }))
577
+ .unwrap();
578
+ refresh_globals(&mut groups, &globals.mapping(&recorded.0), &globals);
659
579
  assert_eq!(
660
- answers.probes[&(position + 1)].fields[STYLESHEET_DIGEST],
661
- json!("stale")
580
+ json!(groups),
581
+ json!({
582
+ "_bbbbbbbbbbbbbbbbbbbbbbbb": ["o._aaaaaaaaaaaaaaaaaaaaaaaa"],
583
+ "_222222222222222222222222": ["other"]
584
+ })
662
585
  );
663
- session.iframe_html = "<style>changed{color:blue}</style>";
664
- assert!(replay.answer(&plan.probes[position], &session).is_err());
665
- drop(replay);
666
- answers.reference = "wrong-reference".into();
667
- assert!(Replay::new(&answers, &plan, &source, &installed).is_err());
668
586
  }
669
587
 
670
588
  #[test]
@@ -697,6 +615,16 @@ mod tests {
697
615
  r#"{"old-root,old-response,old-probe":["other"],"fresh-root,controller-frame,fresh-probe":["o.trace"],"T":["enabled"],"empty":[]}"#
698
616
  );
699
617
  assert!(refreshed(&[], None).is_err());
618
+ let packed = [
619
+ "_111111111111111111111111".into(),
620
+ "replaceTrace".into(),
621
+ "fresh-probe$frame".into(),
622
+ ];
623
+ assert!(
624
+ refreshed(&packed, None)
625
+ .unwrap()
626
+ .contains("fresh-response,fresh-probe$frame")
627
+ );
700
628
  }
701
629
 
702
630
  const CURRENT: &str = "https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/turnstile/f/av0/rch/y3vzw/0x4AAAAAAB8gDxRzm2UASWbL/light/fbE/new/normal?lang=auto";