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,
@@ -0,0 +1,42 @@
1
+ // Measure CSSOM serialization offline, using only a saved capture's inline styles.
2
+ // Usage: node scripts/capture_cssom.mjs <capture.json> <cssom.json>
3
+ // Pass cssom.json as build_profile's optional second argument. The builder verifies
4
+ // the measurements against the hash the captured browser actually reported.
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { execFileSync } from 'node:child_process';
9
+
10
+ const [input, output] = process.argv.slice(2);
11
+ if (!input || !output) throw new Error('Supply capture.json and output cssom.json');
12
+ const capture = JSON.parse(fs.readFileSync(input, 'utf8'));
13
+ const styles = [...capture.iframeHtml.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)].map(match => match[1]);
14
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-cssom-'));
15
+ try {
16
+ const encoded = Buffer.from(JSON.stringify(styles)).toString('base64');
17
+ const document = `<!doctype html><meta charset="utf-8"><pre id="out"></pre><script>
18
+ const sources = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob('${encoded}'), c => c.charCodeAt(0))));
19
+ const result = sources.map(source => {
20
+ const style = document.createElement('style');
21
+ style.textContent = source;
22
+ document.head.append(style);
23
+ return { source, rules: Array.from(style.sheet.cssRules, rule => rule.cssText) };
24
+ });
25
+ document.getElementById('out').textContent = JSON.stringify(result);
26
+ </script>`;
27
+ const file = path.join(directory, 'styles.html');
28
+ fs.writeFileSync(file, document);
29
+ const dom = execFileSync(process.env.CHROMIUM || 'chromium', [
30
+ '--headless=new', '--no-sandbox', '--disable-gpu', '--disable-background-networking',
31
+ '--host-resolver-rules=MAP * ~NOTFOUND', `--user-data-dir=${path.join(directory, 'profile')}`,
32
+ '--dump-dom', `file://${file}`,
33
+ ], { encoding: 'utf8', timeout: 30000, maxBuffer: 4 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
34
+ const found = dom.match(/<pre id="out">([\s\S]*?)<\/pre>/);
35
+ if (!found) throw new Error('Chromium did not return CSSOM measurements');
36
+ const decoded = found[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
37
+ const result = JSON.parse(decoded);
38
+ if (result.length !== styles.length) throw new Error('CSSOM measurement count differs');
39
+ fs.writeFileSync(output, JSON.stringify(result, null, 2) + '\n');
40
+ } finally {
41
+ fs.rmSync(directory, { recursive: true, force: true });
42
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Measure saved iframe styles in another browser without replaying a challenge.
3
+
4
+ Usage: serve_cssom.py <output-directory> <capture.json>... [--port 8766]
5
+ Open the printed LAN URL in the browser being profiled.
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ import re
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from pathlib import Path
13
+
14
+
15
+ PAGE = b'''<!doctype html><meta charset="utf-8">
16
+ <meta name="viewport" content="width=device-width,initial-scale=1">
17
+ <title>Safari stylesheet measurement</title>
18
+ <body style="font:18px system-ui;padding:24px"><h1>Stylesheet measurement</h1>
19
+ <p id="status">Measuring this browser...</p>
20
+ <script>
21
+ (async () => {
22
+ const captures = await (await fetch('/styles.json')).json();
23
+ const results = [];
24
+ for (const capture of captures) {
25
+ const frame = document.createElement('iframe');
26
+ frame.style.display = 'none';
27
+ document.body.append(frame);
28
+ const doc = frame.contentDocument;
29
+ const sheets = capture.sources.map(source => {
30
+ const style = doc.createElement('style');
31
+ style.textContent = source;
32
+ doc.head.append(style);
33
+ return {source, rules: Array.from(style.sheet.cssRules, rule => rule.cssText)};
34
+ });
35
+ results.push({name: capture.name, sheets});
36
+ frame.remove();
37
+ }
38
+ const response = await fetch('/measurements', {
39
+ method: 'POST', headers: {'Content-Type': 'application/json'},
40
+ body: JSON.stringify({userAgent: navigator.userAgent, results})
41
+ });
42
+ if (!response.ok) throw new Error(await response.text());
43
+ document.getElementById('status').textContent = 'Saved successfully. You can close this page and tell me it is done.';
44
+ })().catch(error => document.getElementById('status').textContent = 'Measurement failed: ' + error.message);
45
+ </script>'''
46
+
47
+
48
+ def main():
49
+ parser = argparse.ArgumentParser(description=__doc__)
50
+ parser.add_argument('output', type=Path)
51
+ parser.add_argument('captures', nargs='+', type=Path)
52
+ parser.add_argument('--port', type=int, default=8766)
53
+ arguments = parser.parse_args()
54
+ arguments.output.mkdir(parents=True, exist_ok=True)
55
+ captures = []
56
+ for index, path in enumerate(arguments.captures):
57
+ capture = json.loads(path.read_text())
58
+ sources = re.findall(r'<style\b[^>]*>([\s\S]*?)</style\s*>', capture['iframeHtml'], re.I)
59
+ if not sources:
60
+ raise ValueError(f'No stylesheets in {path}')
61
+ captures.append({'name': f'{index:02d}-{path.parent.name}', 'sources': sources})
62
+ expected = {capture['name']: capture['sources'] for capture in captures}
63
+
64
+ class Handler(BaseHTTPRequestHandler):
65
+ def reply(self, status, content, content_type='text/plain'):
66
+ self.send_response(status)
67
+ self.send_header('Content-Type', content_type)
68
+ self.send_header('Content-Length', str(len(content)))
69
+ self.send_header('Cache-Control', 'no-store')
70
+ self.end_headers()
71
+ self.wfile.write(content)
72
+
73
+ def do_GET(self):
74
+ if self.path == '/':
75
+ self.reply(200, PAGE, 'text/html; charset=utf-8')
76
+ elif self.path == '/styles.json':
77
+ self.reply(200, json.dumps(captures).encode(), 'application/json')
78
+ else:
79
+ self.reply(404, b'Not found')
80
+
81
+ def do_POST(self):
82
+ if self.path != '/measurements':
83
+ return self.reply(404, b'Not found')
84
+ try:
85
+ length = int(self.headers.get('Content-Length', '0'))
86
+ if not 0 < length < 4_000_000:
87
+ raise ValueError('Invalid measurement size')
88
+ measured = json.loads(self.rfile.read(length))
89
+ if {result['name'] for result in measured['results']} != set(expected):
90
+ raise ValueError('Unexpected capture names')
91
+ for result in measured['results']:
92
+ if [sheet['source'] for sheet in result['sheets']] != expected[result['name']]:
93
+ raise ValueError('Stylesheet sources differ')
94
+ if not all(isinstance(rule, str) for sheet in result['sheets'] for rule in sheet['rules']):
95
+ raise ValueError('Invalid CSS rules')
96
+ # Write only after the entire measurement has validated.
97
+ for result in measured['results']:
98
+ (arguments.output / f"{result['name']}.json").write_text(json.dumps(result['sheets'], indent=2) + '\n')
99
+ (arguments.output / 'browser.json').write_text(json.dumps({'userAgent': measured['userAgent']}, indent=2) + '\n')
100
+ print(f"Saved {len(measured['results'])} captures from {measured['userAgent']}", flush=True)
101
+ self.reply(200, b'Saved')
102
+ except (ValueError, KeyError, TypeError) as error:
103
+ self.reply(400, str(error).encode())
104
+
105
+ server = ThreadingHTTPServer(('0.0.0.0', arguments.port), Handler)
106
+ print(f'Listening on port {arguments.port}; open this computer\'s LAN address in Safari.', flush=True)
107
+ server.serve_forever()
108
+
109
+
110
+ if __name__ == '__main__':
111
+ main()
@@ -13,7 +13,6 @@
13
13
  mod headers;
14
14
 
15
15
  use std::collections::BTreeMap;
16
- use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
17
16
 
18
17
  use anyhow::{Context, Result, bail};
19
18
  use serde::Serialize;
@@ -33,6 +32,7 @@ use crate::js::{self, JsObject, JsValue, Payload};
33
32
  use crate::script::Source;
34
33
  use crate::stage2::analysis;
35
34
  use crate::stage2::benchmark::WorkerCosts;
35
+ use crate::stage2::probes::ice;
36
36
  use crate::stage2::probes::media::Measurement;
37
37
  use crate::stage2::registry::{Kind, Operation};
38
38
 
@@ -247,7 +247,7 @@ impl Reported {
247
247
  plan.vocabulary()
248
248
  .rewrite_to_reference(probe.position(), &mut observed);
249
249
  match fields.observed(&observed, started) {
250
- Ok(media) => self.media = Some(without_addresses(media)),
250
+ Ok(media) => self.media = Some(host_candidates_only(media)),
251
251
  Err(error) => problems.push(error.context("reading the media probe")),
252
252
  }
253
253
  }
@@ -373,23 +373,12 @@ fn reported<'payload>(
373
373
  }
374
374
  }
375
375
 
376
- /// Replaces recorded IP addresses with documentation addresses so shared profiles do not reveal the
377
- /// capture network. These addresses are never sent: [`crate::stage2::probes::ice::refresh`]
378
- /// generates new names for host candidates.
379
- fn without_addresses(mut media: Measurement) -> Measurement {
376
+ /// Keeps only host candidates, which name the capture machine by mDNS name, so shared profiles do
377
+ /// not reveal its public address. [`crate::stage2::probes::ice::refresh`] reports only these.
378
+ fn host_candidates_only(mut media: Measurement) -> Measurement {
380
379
  if let Measurement::Observed { snapshot, .. } = &mut media {
381
- for candidate in &mut snapshot.connection.published_candidates {
382
- let Some(line) = candidate.candidate.as_str() else {
383
- continue;
384
- };
385
- let tokens = line.split(' ').map(|token| match token.parse::<IpAddr>() {
386
- Ok(address) if address.is_unspecified() => token.to_owned(),
387
- Ok(IpAddr::V4(_)) => Ipv4Addr::new(192, 0, 2, 1).to_string(),
388
- Ok(IpAddr::V6(_)) => Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1).to_string(),
389
- Err(_) => token.to_owned(),
390
- });
391
- candidate.candidate = JsValue::String(tokens.collect::<Vec<String>>().join(" "));
392
- }
380
+ let candidates = &mut snapshot.connection.published_candidates;
381
+ candidates.retain(|candidate| candidate.candidate.as_str().is_some_and(ice::is_host));
393
382
  }
394
383
  media
395
384
  }
@@ -40,6 +40,7 @@ struct CapturedPayload {
40
40
  }
41
41
 
42
42
  impl CapturedSession {
43
+ /// The iframe document as received, including its stylesheets.
43
44
  #[must_use]
44
45
  pub fn iframe_html(&self) -> &str {
45
46
  &self.iframe_html
@@ -51,18 +52,46 @@ impl CapturedSession {
51
52
 
52
53
  /// Returns the deobfuscated inline script from the iframe.
53
54
  pub fn source(&self) -> Result<Source> {
54
- let javascript = challenge::inline_javascript(&self.iframe_html)?;
55
- Source::try_new(&deobfuscate::deobfuscate(javascript)?.text)
55
+ Source::try_new(&deobfuscate::deobfuscate(self.javascript()?)?.text)
56
+ }
57
+
58
+ /// Like [`Self::source`], but reads the `iframe.deobfuscated.js` that `capture_session`
59
+ /// writes beside `capture_path` when there is one.
60
+ pub fn source_beside(&self, capture_path: &Path) -> Result<Source> {
61
+ let deobfuscated = capture_path.with_file_name("iframe.deobfuscated.js");
62
+ if deobfuscated.exists() {
63
+ Source::try_new(&fs::read_to_string(deobfuscated)?)
64
+ } else {
65
+ self.source()
66
+ }
56
67
  }
57
68
 
58
69
  /// Returns the probe program from the first POST response, unmasked using the ray ID in the
59
70
  /// URL.
60
71
  pub fn program(&self) -> Result<Vec<u8>> {
61
- let post = self.posts.first().context("the capture has no POST")?;
62
- let url = Url::parse(&post.url)?;
63
- let encoded =
64
- challenge::decode_response(post.response_body.as_bytes(), challenge::ray(&url)?)?;
65
- Ok(STANDARD.decode(encoded)?)
72
+ self.programs()?
73
+ .into_iter()
74
+ .next()
75
+ .context("the capture has no POST")
76
+ }
77
+
78
+ /// Returns the program each POST response carries, in order, such as the probe program and
79
+ /// then the checkbox program.
80
+ pub fn programs(&self) -> Result<Vec<Vec<u8>>> {
81
+ self.posts
82
+ .iter()
83
+ .map(|post| {
84
+ let url = Url::parse(&post.url)?;
85
+ let ray = challenge::ray(&url)?;
86
+ let encoded = challenge::decode_response(post.response_body.as_bytes(), ray)?;
87
+ Ok(STANDARD.decode(encoded)?)
88
+ })
89
+ .collect()
90
+ }
91
+
92
+ /// Returns the iframe's inline script, before deobfuscation.
93
+ pub fn javascript(&self) -> Result<String> {
94
+ challenge::inline_javascript(&self.iframe_html)
66
95
  }
67
96
 
68
97
  /// Returns the payload built before probes run.
@@ -78,6 +107,12 @@ impl CapturedSession {
78
107
  .find(|payload| payload.contains_key("1"))
79
108
  .context("the capture has no payload with probe records")
80
109
  }
110
+
111
+ /// Returns the last payload, such as the checkbox interaction's.
112
+ #[must_use]
113
+ pub fn last_payload(&self) -> Option<&Payload> {
114
+ self.payloads.last().map(|payload| &payload.payload)
115
+ }
81
116
  }
82
117
 
83
118
  /// Traffic and complete sessions from one proxy run directory.
@@ -0,0 +1,131 @@
1
+ //! Audit-only recovery includes initialization's timer callbacks. It does not change the
2
+ //! options, key material, or globals used by the payload encoder.
3
+
4
+ use std::rc::Rc;
5
+
6
+ use swc_core::ecma::ast::BinaryOp;
7
+
8
+ use super::{Held, Installed};
9
+ use crate::js::{JsValue, json};
10
+ use crate::listing::fold::{Frame, Known};
11
+ use crate::stage2::vocabulary;
12
+ use crate::vm::{Effect, Listing, Term};
13
+
14
+ pub(super) fn read(listing: &Listing, installed: &mut Installed) {
15
+ let mut frame = Frame::<Held>::default();
16
+ for (&pc, instruction) in &listing.instructions {
17
+ for path in &instruction.paths {
18
+ frame.begin();
19
+ frame.follow(pc, &path.effects, evaluate, |frame, effect| {
20
+ // The key factory is declared by evaluating a literal function definition,
21
+ // after a Trusted Types wrapper has returned that same source string.
22
+ for argument in effect.call().map_or(&[][..], |(_, arguments)| arguments) {
23
+ if let Some(known) = frame.resolve(argument)
24
+ && let Some(text) = known.text()
25
+ && let Some(function) = text.strip_prefix("function ")
26
+ && let Some((name, _)) = function.split_once('(')
27
+ && vocabulary::is_hex_name(name)
28
+ {
29
+ installed.audit.insert(name.into(), "N".into());
30
+ installed
31
+ .audit_roles
32
+ .insert(name.into(), "function-factory".into());
33
+ }
34
+ }
35
+ let Effect::SetProperty { object, key, value } = effect else {
36
+ return;
37
+ };
38
+ let (Some(object), Some(key)) = (frame.resolve(object), frame.resolve(key)) else {
39
+ return;
40
+ };
41
+ let value = frame.resolve(value);
42
+ if let Known::Ext(Held::AuditArray(array)) = &object
43
+ && let Known::Literal(JsValue::Number(index)) = &key
44
+ && *index >= 0.0
45
+ && index.fract() == 0.0
46
+ {
47
+ let native = if let Some(Known::Ext(Held::NativeFunction(name))) = &value {
48
+ Some(format!("function {name}() {{ [native code] }}"))
49
+ } else {
50
+ None
51
+ };
52
+ array.borrow_mut().insert(*index as usize, native);
53
+ }
54
+ if !matches!(object, Known::Ext(Held::Window)) {
55
+ return;
56
+ }
57
+ let Some(name) = key.text().filter(|name| vocabulary::is_hex_name(name)) else {
58
+ return;
59
+ };
60
+ let (role, audit) = match value {
61
+ Some(Known::Ext(Held::AuditArray(array))) => {
62
+ let members: Option<Vec<String>> =
63
+ array.borrow().values().cloned().collect();
64
+ let Some(members) = members else {
65
+ return;
66
+ };
67
+ let role = if members.is_empty() {
68
+ "empty-array"
69
+ } else {
70
+ "native-array"
71
+ };
72
+ (role, members.join(","))
73
+ }
74
+ Some(Known::Ext(Held::GpuPromise)) => ("gpu-adapter", "o".into()),
75
+ Some(Known::Ext(Held::ClockDelta)) => ("clock-delta", String::new()),
76
+ _ => return,
77
+ };
78
+ installed.audit.insert(name.into(), audit);
79
+ installed.audit_roles.insert(name.into(), role.into());
80
+ });
81
+ }
82
+ }
83
+ }
84
+
85
+ fn evaluate(frame: &Frame<Held>, value: &Term) -> Option<Known<Held>> {
86
+ let string = |term: &Term| match frame.resolve(term) {
87
+ Some(Known::Literal(JsValue::String(text))) => Some(text),
88
+ _ => None,
89
+ };
90
+ match value {
91
+ Term::Array(elements) if elements.is_empty() => {
92
+ return Some(Known::Ext(Held::AuditArray(Rc::default())));
93
+ }
94
+ Term::Get(_, key) => {
95
+ if let Some(name @ ("querySelector" | "querySelectorAll")) =
96
+ frame.resolve(key).as_ref().and_then(Known::text)
97
+ {
98
+ return Some(Known::Ext(Held::NativeFunction(name.into())));
99
+ }
100
+ }
101
+ Term::Call {
102
+ callee, arguments, ..
103
+ } => {
104
+ if let Term::Get(_, method) = &**callee {
105
+ match frame.resolve(method).as_ref().and_then(Known::text) {
106
+ Some("now") => return Some(Known::Ext(Held::Clock)),
107
+ Some("requestAdapter") => return Some(Known::Ext(Held::GpuPromise)),
108
+ _ => {}
109
+ }
110
+ }
111
+ if let [argument] = arguments.as_slice()
112
+ && let Some(text) = string(argument)
113
+ && text.starts_with("function ")
114
+ {
115
+ return Some(Known::Literal(json!(text)));
116
+ }
117
+ }
118
+ Term::Binary(BinaryOp::Sub, _, right)
119
+ if matches!(frame.resolve(right), Some(Known::Ext(Held::Clock))) =>
120
+ {
121
+ return Some(Known::Ext(Held::ClockDelta));
122
+ }
123
+ Term::Binary(BinaryOp::Add, left, right) => {
124
+ if let (Some(left), Some(right)) = (string(left), string(right)) {
125
+ return Some(Known::Literal(json!(format!("{left}{right}"))));
126
+ }
127
+ }
128
+ _ => {}
129
+ }
130
+ super::evaluate(frame, value)
131
+ }