cfsolve 0.1.0 → 0.1.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/UPSTREAM.md +1 -1
- data/lib/cfsolve/version.rb +2 -2
- data/vendor/cfsolve/src/challenge/merge.rs +30 -85
- data/vendor/cfsolve/src/stage2/probes/worker_fetch.rs +30 -138
- data/vendor/cfsolve/src/stage2/vocabulary.rs +24 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4828b2b3d60862c05be945e5ae1ca02c96c63889f39ad326759e01702b54d75e
|
|
4
|
+
data.tar.gz: bd34543669e65937407d5670ad0bfdb679cfb0fd2211ac8176de0d1f9d85d563
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d03f438c214dfa30a921c9e015a087155fdf8c74b192b705ce8d338c5e8868d6bebe22c8977f824ac69127a1d687a4b5cd587d071f253d0cc5dddf40e7c3f232
|
|
7
|
+
data.tar.gz: b8eb750a28710676dcdb2671d5fc1168200d6c749981c9418d54a01dd1299facc40a0f0e1144bee16f3d9ece694aafa0b56d7a889b0446cc6f4b4481fc3d5628
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
Update the native engine to rebased upstream PR #5 (`0f97eb7`). Adopt upstream's
|
|
6
|
+
regional worker-host discovery, copied-option recovery, and lower probe-matching
|
|
7
|
+
threshold while retaining the fixes for replay across challenge builds.
|
|
8
|
+
|
|
3
9
|
## 0.1.0
|
|
4
10
|
|
|
5
11
|
Initial Ruby interface based on cfsolve PR #5. Includes isolated native solves,
|
data/UPSTREAM.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
The Rust engine is vendored from [chris124567/cfsolve](https://github.com/chris124567/cfsolve),
|
|
2
2
|
including [PR #5](https://github.com/chris124567/cfsolve/pull/5), at commit
|
|
3
|
-
`
|
|
3
|
+
`0f97eb7ccfea54f445b60a624ad8949beae95e95`.
|
|
4
4
|
|
|
5
5
|
`vendor/cfsolve` is an unmodified archive of that revision. Its three patched
|
|
6
6
|
dependencies are applied by the extension's top-level Cargo manifest. The Ruby
|
data/lib/cfsolve/version.rb
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
//! Recovers how the iframe copies the embedding page's `extraParams` message fields into
|
|
2
2
|
//! randomly named `_cf_chl_opt` options.
|
|
3
3
|
|
|
4
|
-
use std::collections::HashMap;
|
|
5
|
-
|
|
6
4
|
use anyhow::{Context, Result};
|
|
7
5
|
use swc_core::ecma::ast::{
|
|
8
|
-
|
|
9
|
-
SimpleAssignTarget, Stmt, UnaryOp,
|
|
6
|
+
AssignTarget, BinaryOp, CallExpr, Callee, Decl, Expr, FnDecl, SimpleAssignTarget, Stmt, UnaryOp,
|
|
10
7
|
};
|
|
11
8
|
use swc_core::ecma::visit::{Visit, VisitWith};
|
|
12
9
|
|
|
@@ -283,40 +280,37 @@ fn writes(function: &FnDecl) -> Vec<OptionWrite> {
|
|
|
283
280
|
return Vec::new();
|
|
284
281
|
};
|
|
285
282
|
// Some options pass through a local so stored browser state can override them before they
|
|
286
|
-
// are written back.
|
|
287
|
-
let mut staged:
|
|
283
|
+
// are written back.
|
|
284
|
+
let mut staged: Vec<(&str, &str)> = Vec::new();
|
|
288
285
|
let mut writes = Vec::new();
|
|
289
286
|
for statement in &body.stmts {
|
|
290
|
-
if let Stmt::Decl(Decl::Var(declaration)) = statement {
|
|
291
|
-
for binding in &declaration.decls {
|
|
292
|
-
if let Pat::Ident(local) = &binding.name
|
|
293
|
-
&& let Some(value) = &binding.init
|
|
294
|
-
{
|
|
295
|
-
staged.insert(&local.id.sym, expression::option_key(value.unwrap_parens()));
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
287
|
if let Some(write) = sampled(statement, ¶meters) {
|
|
300
288
|
writes.push(write);
|
|
301
289
|
continue;
|
|
302
290
|
}
|
|
291
|
+
if let Stmt::Decl(Decl::Var(declaration)) = statement {
|
|
292
|
+
staged.extend(declaration.decls.iter().filter_map(|declarator| {
|
|
293
|
+
let local = &*declarator.name.as_ident()?.id.sym;
|
|
294
|
+
Some((local, expression::option_key(declarator.init.as_deref()?)?))
|
|
295
|
+
}));
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
303
298
|
let Some(assignment) = expression::assignment(statement) else {
|
|
304
299
|
continue;
|
|
305
300
|
};
|
|
306
|
-
let
|
|
307
|
-
(
|
|
308
|
-
|
|
309
|
-
staged.
|
|
301
|
+
if let AssignTarget::Simple(SimpleAssignTarget::Ident(local)) = &assignment.left
|
|
302
|
+
&& let Some(option) = expression::option_key(&assignment.right)
|
|
303
|
+
{
|
|
304
|
+
staged.push((&local.id.sym, option));
|
|
310
305
|
continue;
|
|
311
306
|
}
|
|
312
307
|
let Some(option) = expression::option_assignment(&assignment.left).map(str::to_owned)
|
|
313
308
|
else {
|
|
314
309
|
continue;
|
|
315
310
|
};
|
|
316
|
-
let (mut value, default) =
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
&& let Some(Some(from)) = staged.get(&*ident.sym)
|
|
311
|
+
let (mut value, default) = read(&assignment.right, ¶meters);
|
|
312
|
+
if let (Written::Other, Expr::Ident(ident)) = (&value, &*assignment.right)
|
|
313
|
+
&& let Some((_, from)) = staged.iter().rfind(|(local, _)| *local == &*ident.sym)
|
|
320
314
|
{
|
|
321
315
|
value = Written::Copied((*from).to_owned());
|
|
322
316
|
}
|
|
@@ -476,78 +470,29 @@ mod tests {
|
|
|
476
470
|
}
|
|
477
471
|
}
|
|
478
472
|
|
|
473
|
+
/// Stored state may override an option copied through a local. Some builds declare the local
|
|
474
|
+
/// with its value; others assign it separately.
|
|
479
475
|
#[test]
|
|
480
|
-
fn
|
|
481
|
-
for
|
|
482
|
-
"var
|
|
483
|
-
"
|
|
484
|
-
"const saved = e._cf_chl_opt.seed;",
|
|
485
|
-
"var unused, saved = (e._cf_chl_opt.seed);",
|
|
486
|
-
"saved = e._cf_chl_opt.seed;",
|
|
487
|
-
"saved = (e._cf_chl_opt.seed);",
|
|
488
|
-
"var saved = e._cf_chl_opt.other; saved = e._cf_chl_opt.seed;",
|
|
489
|
-
"saved = e._cf_chl_opt.other; var saved = e._cf_chl_opt.seed;",
|
|
490
|
-
"var saved = e._cf_chl_opt.seed; var saved;",
|
|
476
|
+
fn recovers_options_copied_through_locals() {
|
|
477
|
+
for staging in [
|
|
478
|
+
"var z8 = e._cf_chl_opt.qBdzw2;",
|
|
479
|
+
"z8 = e._cf_chl_opt.qBdzw2;",
|
|
491
480
|
] {
|
|
492
481
|
let source = SOURCE.replace(
|
|
493
482
|
"e._cf_chl_opt.WbdgY0 = z2;",
|
|
494
483
|
&format!(
|
|
495
|
-
|
|
496
|
-
{initial}
|
|
497
|
-
try {{
|
|
498
|
-
var cached = localStorage.getItem("visitor");
|
|
499
|
-
if (cached) {{ saved = cached; }}
|
|
500
|
-
}} catch (error) {{}}
|
|
501
|
-
e._cf_chl_opt.WbdgY0 = (saved);
|
|
502
|
-
"#
|
|
484
|
+
"{staging} try {{ z8 = localStorage.getItem(`u`) || z8; }} catch (z9) {{}} e._cf_chl_opt.WbdgY0 = z8;"
|
|
503
485
|
),
|
|
504
486
|
);
|
|
505
|
-
let mapping = Mapping::from_javascript(&source).
|
|
506
|
-
let
|
|
507
|
-
.0
|
|
508
|
-
.iter()
|
|
509
|
-
.find(|entry| entry.option == "WbdgY0")
|
|
510
|
-
.unwrap();
|
|
511
|
-
assert_eq!(
|
|
512
|
-
entry.source,
|
|
513
|
-
OptionSource::Copied("seed".into()),
|
|
514
|
-
"{initial}"
|
|
515
|
-
);
|
|
516
|
-
let mut options = Options::from_iter([("seed".into(), json!("synthetic-visitor"))]);
|
|
487
|
+
let mapping = Mapping::from_javascript(&source).expect("modified source parses");
|
|
488
|
+
let copied = OptionSource::Copied("qBdzw2".into());
|
|
517
489
|
assert!(
|
|
518
490
|
mapping
|
|
519
|
-
.
|
|
520
|
-
.
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
#[test]
|
|
527
|
-
fn does_not_reuse_copies_after_overwrites_or_compound_assignments() {
|
|
528
|
-
for body in [
|
|
529
|
-
"saved = undefined; e._cf_chl_opt.WbdgY0 = saved;",
|
|
530
|
-
"var saved = undefined; e._cf_chl_opt.WbdgY0 = saved;",
|
|
531
|
-
"saved += e._cf_chl_opt.other; e._cf_chl_opt.WbdgY0 = saved;",
|
|
532
|
-
"e._cf_chl_opt.WbdgY0 += saved;",
|
|
533
|
-
] {
|
|
534
|
-
let source = SOURCE.replace(
|
|
535
|
-
"e._cf_chl_opt.WbdgY0 = z2;",
|
|
536
|
-
&format!("var saved = e._cf_chl_opt.seed; {body}"),
|
|
537
|
-
);
|
|
538
|
-
let mapping = Mapping::from_javascript(&source).unwrap();
|
|
539
|
-
assert_eq!(
|
|
540
|
-
mapping.0.last().unwrap().source,
|
|
541
|
-
OptionSource::Runtime,
|
|
542
|
-
"{body}"
|
|
543
|
-
);
|
|
544
|
-
let mut options = Options::from_iter([("seed".into(), json!("synthetic-visitor"))]);
|
|
545
|
-
assert_eq!(
|
|
546
|
-
mapping.merge(&mut options, &json!({}), "https://example.com", 1),
|
|
547
|
-
["WbdgY0"],
|
|
548
|
-
"{body}",
|
|
491
|
+
.0
|
|
492
|
+
.iter()
|
|
493
|
+
.any(|entry| entry.option == "WbdgY0" && entry.source == copied),
|
|
494
|
+
"WbdgY0 is not copied from qBdzw2 after {staging}"
|
|
549
495
|
);
|
|
550
|
-
assert!(!options.contains_key("WbdgY0"), "{body}");
|
|
551
496
|
}
|
|
552
497
|
}
|
|
553
498
|
|
|
@@ -7,7 +7,6 @@ use url::Url;
|
|
|
7
7
|
use crate::js::{self, JsObject, Options, json};
|
|
8
8
|
use crate::stage2::analysis::recognize::ProbeView;
|
|
9
9
|
use crate::stage2::reference;
|
|
10
|
-
use crate::{candidates, listing};
|
|
11
10
|
|
|
12
11
|
pub(crate) const OUTPUTS: [&str; 2] = [
|
|
13
12
|
reference::WORKER_FETCH_STATUS,
|
|
@@ -16,11 +15,14 @@ pub(crate) const OUTPUTS: [&str; 2] = [
|
|
|
16
15
|
|
|
17
16
|
/// The response header whose value the probe reports as its token.
|
|
18
17
|
const HEADER: &str = "cf-chl-peek";
|
|
19
|
-
///
|
|
20
|
-
///
|
|
18
|
+
/// Explicit host and path prefix for the worker fetch. Builds may store them as one string or two.
|
|
19
|
+
/// The host's first label varies by region, such as `brunhild` or `hagen`.
|
|
20
|
+
/// [`super::platform::url`] builds URLs relative to the iframe instead.
|
|
21
|
+
const HOST_SUFFIX: &str = ".challenges.cloudflare.com";
|
|
21
22
|
const PATH: &str = "/cdn-cgi/challenge-platform/h/";
|
|
22
23
|
|
|
23
|
-
/// The
|
|
24
|
+
/// The fetched host and the session's options naming the request path's platform segment, ray
|
|
25
|
+
/// and nonce.
|
|
24
26
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
25
27
|
pub struct Fields {
|
|
26
28
|
pub host: String,
|
|
@@ -90,154 +92,44 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
|
|
|
90
92
|
"omit",
|
|
91
93
|
"no-store",
|
|
92
94
|
];
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
host:
|
|
95
|
+
let features = strings.features();
|
|
96
|
+
let host = features.iter().find_map(|text| host(text))?;
|
|
97
|
+
let path = features.iter().any(|text| text.contains(PATH));
|
|
98
|
+
(strings.stores_all(&OUTPUTS) && strings.has_all(&markers) && path).then(|| Fields {
|
|
99
|
+
host: host.to_owned(),
|
|
98
100
|
platform_property: probe.session_name(reference::PLATFORM_OPTION),
|
|
99
101
|
ray_property: probe.session_name(reference::RAY_OPTION),
|
|
100
102
|
nonce_property: probe.session_name(reference::NONCE_OPTION),
|
|
101
103
|
})
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
///
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
let
|
|
108
|
-
.
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
listing::decode_string(text, pool).unwrap_or_else(|_| text.clone())
|
|
112
|
-
} else {
|
|
113
|
-
text.clone()
|
|
114
|
-
}
|
|
115
|
-
})
|
|
116
|
-
.collect();
|
|
117
|
-
let hosts = strings.iter().enumerate().filter_map(|(start, text)| {
|
|
118
|
-
if !text.starts_with("https://") {
|
|
119
|
-
return None;
|
|
120
|
-
}
|
|
121
|
-
let mut candidate = String::new();
|
|
122
|
-
strings[start..].iter().take(3).find_map(|part| {
|
|
123
|
-
candidate.push_str(part);
|
|
124
|
-
let url = Url::parse(&candidate).ok()?;
|
|
125
|
-
let host = url.host_str()?;
|
|
126
|
-
// Require the bare HTTPS endpoint, without credentials or extra URL components.
|
|
127
|
-
(host.ends_with(".challenges.cloudflare.com")
|
|
128
|
-
&& url.as_str() == format!("https://{host}{PATH}"))
|
|
129
|
-
.then(|| host.to_owned())
|
|
130
|
-
})
|
|
131
|
-
});
|
|
132
|
-
candidates::at_most_one_distinct(hosts, "worker fetch host").ok()?
|
|
106
|
+
/// Finds the challenge host in `text`, which may be a full URL or a host after a length prefix.
|
|
107
|
+
fn host(text: &str) -> Option<&str> {
|
|
108
|
+
let suffix = text.find(HOST_SUFFIX)?;
|
|
109
|
+
let start = text[..suffix]
|
|
110
|
+
.rfind(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
|
|
111
|
+
.map_or(0, |index| index + 1);
|
|
112
|
+
(start < suffix).then(|| &text[start..suffix + HOST_SUFFIX.len()])
|
|
133
113
|
}
|
|
134
114
|
|
|
135
115
|
#[cfg(test)]
|
|
136
116
|
mod tests {
|
|
137
|
-
use
|
|
138
|
-
|
|
139
|
-
use super::*;
|
|
140
|
-
use crate::listing::Index;
|
|
141
|
-
use crate::stage2::analysis::recognize::Strings;
|
|
142
|
-
use crate::stage2::records::WriteOrder;
|
|
143
|
-
use crate::stage2::registry::Kind;
|
|
144
|
-
use crate::stage2::vocabulary::Vocabulary;
|
|
145
|
-
use crate::vm::Listing;
|
|
146
|
-
|
|
147
|
-
fn strings(parts: &[&str]) -> Vec<String> {
|
|
148
|
-
parts.iter().map(|part| (*part).to_owned()).collect()
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
#[test]
|
|
152
|
-
fn reads_current_hosts_from_complete_split_and_encoded_urls() {
|
|
153
|
-
for host in ["brunhild", "hagen", "future-worker"] {
|
|
154
|
-
let host = format!("{host}.challenges.cloudflare.com");
|
|
155
|
-
let full = format!("https://{host}{PATH}");
|
|
156
|
-
let encoded = format!("\0{}{host}\0padding", char::from(host.len() as u8));
|
|
157
|
-
for features in [
|
|
158
|
-
strings(&[&full]),
|
|
159
|
-
strings(&[&full, &full]),
|
|
160
|
-
strings(&["https://", &host, PATH]),
|
|
161
|
-
strings(&["https://", &format!("{host}{PATH}")]),
|
|
162
|
-
strings(&["https://", &encoded, PATH]),
|
|
163
|
-
strings(&[&format!("https://{host}"), PATH]),
|
|
164
|
-
] {
|
|
165
|
-
assert_eq!(request_host(&features, ""), Some(host.clone()));
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
#[test]
|
|
171
|
-
fn recognizes_the_session_host_without_rewriting_it_to_the_reference() {
|
|
172
|
-
let reference = &reference::REFERENCE.probes[Kind::WorkerFetch.references()[0] - 1];
|
|
173
|
-
let session: Vec<String> = reference
|
|
174
|
-
.iter()
|
|
175
|
-
.map(|text| text.replace("brunhild.", "current-worker."))
|
|
176
|
-
.collect();
|
|
177
|
-
let listing = Listing::default();
|
|
178
|
-
let index = Index::new(&listing);
|
|
179
|
-
let vocabulary = Vocabulary::infer(&[&session]);
|
|
180
|
-
let empty = BTreeSet::new();
|
|
181
|
-
let outputs = OUTPUTS.map(str::to_owned).into_iter().collect();
|
|
182
|
-
let fields = recognize(&ProbeView {
|
|
183
|
-
index: &index,
|
|
184
|
-
vocabulary: &vocabulary,
|
|
185
|
-
position: 0,
|
|
186
|
-
header: &empty,
|
|
187
|
-
wrapper: &empty,
|
|
188
|
-
pool: "",
|
|
189
|
-
entry: 0,
|
|
190
|
-
session_features: &session,
|
|
191
|
-
writes: &WriteOrder::default(),
|
|
192
|
-
strings: Strings::new(reference, &outputs),
|
|
193
|
-
})
|
|
194
|
-
.unwrap();
|
|
195
|
-
assert_eq!(fields.host, "current-worker.challenges.cloudflare.com");
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
#[test]
|
|
199
|
-
fn reads_pooled_hosts_and_builds_the_current_request() {
|
|
200
|
-
let host = "hagen.challenges.cloudflare.com";
|
|
201
|
-
let pool = format!("\0\0{host}");
|
|
202
|
-
let descriptor = format!("ÿÿ\0\0\0\0\0\0\0\0\0{}", char::from(host.len() as u8));
|
|
203
|
-
let host = request_host(&strings(&["https://", &descriptor, PATH]), &pool).unwrap();
|
|
204
|
-
let fields = Fields {
|
|
205
|
-
host,
|
|
206
|
-
platform_property: "platform".into(),
|
|
207
|
-
ray_property: "ray".into(),
|
|
208
|
-
nonce_property: "nonce".into(),
|
|
209
|
-
};
|
|
210
|
-
let options =
|
|
211
|
-
serde_json::from_str(r#"{"platform":"g","ray":"fresh-ray","nonce":"fresh-nonce"}"#)
|
|
212
|
-
.unwrap();
|
|
213
|
-
assert_eq!(
|
|
214
|
-
fields.url(&options).unwrap().as_str(),
|
|
215
|
-
"https://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/i/fresh-ray/fresh-nonce"
|
|
216
|
-
);
|
|
217
|
-
}
|
|
117
|
+
use super::host;
|
|
218
118
|
|
|
219
119
|
#[test]
|
|
220
|
-
fn
|
|
221
|
-
for
|
|
222
|
-
|
|
223
|
-
vec!["http://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/"],
|
|
224
|
-
vec!["https://hagen.challenges.cloudflare.com/other/"],
|
|
225
|
-
vec![
|
|
226
|
-
"https://hagen.challenges.cloudflare.com.evil.example/cdn-cgi/challenge-platform/h/",
|
|
227
|
-
],
|
|
228
|
-
vec!["https://user@hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/"],
|
|
229
|
-
vec!["https://hagen.challenges.cloudflare.com:8443/cdn-cgi/challenge-platform/h/"],
|
|
230
|
-
vec!["https://", "\0", PATH],
|
|
231
|
-
vec!["https://hagen.challenges.cloudflare.com", "\0", PATH],
|
|
232
|
-
vec!["https://hagen.challenges.cloudflare.com", "ÿÿ\0", PATH],
|
|
233
|
-
vec!["https://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/?extra"],
|
|
234
|
-
vec!["https://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/#extra"],
|
|
235
|
-
vec![
|
|
236
|
-
"https://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/",
|
|
120
|
+
fn finds_regional_hosts_in_urls_and_prefixed_strings() {
|
|
121
|
+
for (text, expected) in [
|
|
122
|
+
(
|
|
237
123
|
"https://brunhild.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/",
|
|
238
|
-
|
|
124
|
+
Some("brunhild.challenges.cloudflare.com"),
|
|
125
|
+
),
|
|
126
|
+
(
|
|
127
|
+
"\0\u{1f}hagen.challenges.cloudflare.com\0\0",
|
|
128
|
+
Some("hagen.challenges.cloudflare.com"),
|
|
129
|
+
),
|
|
130
|
+
("https://.challenges.cloudflare.com", None),
|
|
239
131
|
] {
|
|
240
|
-
assert_eq!(
|
|
132
|
+
assert_eq!(host(text), expected, "{text:?}");
|
|
241
133
|
}
|
|
242
134
|
}
|
|
243
135
|
}
|
|
@@ -484,6 +484,11 @@ fn best_partners<'a>(
|
|
|
484
484
|
best.into_values().map(|(pair, _)| pair).collect()
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
/// The lowest similarity that pairs two probes. Below it, probes share mostly common wrapper
|
|
488
|
+
/// strings: unrelated probes score at most 0.2. The devtools probe scores only about 0.5 against
|
|
489
|
+
/// its reference, and drops to 0.49 in programs that never name `document`.
|
|
490
|
+
const MIN_PAIR_SIMILARITY: f64 = 0.35;
|
|
491
|
+
|
|
487
492
|
/// Pairs probes by descending Jaccard similarity of stable strings, using each reference probe
|
|
488
493
|
/// at most once. Also retains each session probe's closest reference match regardless of
|
|
489
494
|
/// pairing.
|
|
@@ -520,8 +525,7 @@ fn pair_probes<S: AsRef<[String]>>(
|
|
|
520
525
|
let mut used = HashSet::new();
|
|
521
526
|
let mut pairs = vec![None; session.len()];
|
|
522
527
|
for (index, candidate) in pairings {
|
|
523
|
-
|
|
524
|
-
if candidate.similarity < 0.5 {
|
|
528
|
+
if candidate.similarity < MIN_PAIR_SIMILARITY {
|
|
525
529
|
break;
|
|
526
530
|
}
|
|
527
531
|
if pairs[index].is_none() && used.insert(candidate.reference) {
|
|
@@ -901,6 +905,24 @@ mod tests {
|
|
|
901
905
|
}
|
|
902
906
|
}
|
|
903
907
|
|
|
908
|
+
/// A probe that shares only three of seven stable strings with its reference still pairs,
|
|
909
|
+
/// as the devtools probe must when a program never names `document`.
|
|
910
|
+
#[test]
|
|
911
|
+
fn pairs_a_probe_below_half_similarity() {
|
|
912
|
+
let session = [
|
|
913
|
+
strings("debug stack console"),
|
|
914
|
+
strings("fetch then"),
|
|
915
|
+
strings("now floor"),
|
|
916
|
+
];
|
|
917
|
+
let reference = [
|
|
918
|
+
strings("debug stack console document create remove append"),
|
|
919
|
+
strings("fetch then"),
|
|
920
|
+
strings("now floor"),
|
|
921
|
+
];
|
|
922
|
+
let (pairs, _) = pair_probes(&session, &reference);
|
|
923
|
+
assert_eq!(pairs, [Some(1), Some(2), Some(3)]);
|
|
924
|
+
}
|
|
925
|
+
|
|
904
926
|
#[test]
|
|
905
927
|
fn prefers_a_probes_own_pairs_over_the_global_map() {
|
|
906
928
|
// The reference reuses one name where the session has two.
|