adler_core/site.rs
1//! Site definitions and the multi-signal detection model.
2//!
3//! A site is a target URL plus a list of [`Signal`]s. Each signal is an
4//! independent rule that, when triggered against a response, votes either
5//! for the account existing ([`SignalVerdict::Found`]) or not
6//! ([`SignalVerdict::NotFound`]). Non-triggering signals stay silent
7//! ([`SignalVerdict::Ambiguous`]).
8//!
9//! Aggregation is **negative-priority**: if any signal votes
10//! [`SignalVerdict::NotFound`] the verdict is [`MatchKind::NotFound`];
11//! otherwise if any votes [`SignalVerdict::Found`] it is
12//! [`MatchKind::Found`]; with no votes at all it is
13//! [`MatchKind::Uncertain`].
14//!
15//! A `NotFound` vote wins over a `Found` vote because negative signals are
16//! specific (an exact "user not found" message, a 404, a login redirect)
17//! while a bare `200 OK` is weak positive evidence. This matches how
18//! Sherlock-style detectors work: a site that always returns 200 and only
19//! differentiates via an error string is correctly read as `NotFound` when
20//! that string is present, even though the 200 also satisfies a
21//! `StatusFound` signal.
22
23use std::fmt;
24
25use serde::{Deserialize, Serialize};
26
27use crate::access::AccessPolicy;
28use crate::check::MatchKind;
29use crate::error::{Error, Result};
30use crate::username::Username;
31
32/// One site we can probe for the existence of an account.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Site {
35 /// Human-readable site name. Doubles as the stable filter key
36 /// (case-insensitive) used by CLI `--only` / `--exclude`.
37 pub name: String,
38 /// URL template containing a `{username}` placeholder.
39 pub url: UrlTemplate,
40 /// Ordered list of detection signals. Aggregated per the type-level docs.
41 /// Optional in source JSON when [`Site::engine`] is set — the engine's
42 /// signals are inherited at load time. After
43 /// [`crate::Registry`] resolution this vec is always non-empty (or the
44 /// site fails `validate`).
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub signals: Vec<Signal>,
47 /// One or more usernames known to exist on this site. Consumed by
48 /// `adler doctor` to verify the signal list still reports `Found`
49 /// for a real account. Accepts either a single string or an array
50 /// of strings in JSON; the doctor probes each in declaration order
51 /// and passes the present-check if **any** one of them resolves to
52 /// `Found`. Listing several is defensive — brand accounts or other
53 /// users that the site special-cases (e.g. Instagram's own
54 /// `instagram` account) shouldn't false-fail the whole site.
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub known_present: Option<KnownPresent>,
57 /// Username known to *not* exist on this site (optional). When omitted,
58 /// the doctor generates a random nonsense username instead.
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub known_absent: Option<String>,
61 /// Optional CSS-selector rules for pulling profile fields (name, bio,
62 /// avatar, …) out of a `Found` page. Only applied under `--enrich`.
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub extract: Vec<Extractor>,
65 /// Free-form classification tags for scanning a subset of the registry,
66 /// e.g. `"social"`, `"dev"`, `"region:ru"`. Matched by CLI `--tag`.
67 /// A site with no tags is universal (included unless a `--tag` filter
68 /// excludes it). Conventionally lowercase; `axis:value` is just a naming
69 /// convention, not enforced.
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub tags: Vec<String>,
72 /// Extra HTTP headers to send with the probe (e.g.
73 /// `{"X-IG-App-ID": "936619743392459"}` to unlock Instagram's
74 /// `web_profile_info` endpoint, or a custom `User-Agent`). Browser
75 /// backends apply them via `Network.setExtraHTTPHeaders` before
76 /// navigation; the raw-HTTP path doesn't read this yet.
77 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
78 pub request_headers: std::collections::BTreeMap<String, String>,
79 /// Optional regular expression describing usernames a site will
80 /// accept. When set and the scanned username doesn't match, the
81 /// site is skipped (the outcome is reported as `Uncertain` with
82 /// reason `UsernameNotAllowed`, without issuing any HTTP request).
83 /// Saves work AND avoids the false-positive class where a site
84 /// 404s on illegal usernames in ways our signal can't tell apart
85 /// from a missing account.
86 ///
87 /// Imported from Sherlock's `regexCheck` field; 95+ sites
88 /// upstream carry one (length bounds, character classes, etc.).
89 /// Validation at load time compiles the regex with `regex::Regex`
90 /// — a malformed pattern rejects the site rather than silently
91 /// degrading at scan time.
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub regex_check: Option<String>,
94 /// Name of a shared [`Engine`] this site inherits from (e.g.
95 /// `"Discourse"`, `"vBulletin"`). Forum-software platforms host
96 /// thousands of instances with identical detection signatures;
97 /// defining the signature once on an engine and inheriting it
98 /// keeps the registry small and the cost of a platform-wide
99 /// HTML change one fix instead of hundreds.
100 ///
101 /// At registry-load time the engine fields are merged *under* the
102 /// site's own — anything the site declares explicitly (`signals`,
103 /// `request_headers`, `regex_check`) wins on
104 /// conflict; anything left empty / unset is filled from the
105 /// engine. An `engine: "X"` referring to a non-existent X is a
106 /// load-time error.
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub engine: Option<String>,
109 /// Characters the site silently drops from the username server-side
110 /// before matching — `john.doe` and `johndoe` resolve to the same
111 /// account on a site that lists `strip_bad_char: "."`. We pre-strip
112 /// at probe time so the URL we issue matches the canonical form
113 /// the site uses, avoiding a false `NotFound` on a benign
114 /// punctuation variant. Mirrors `WhatsMyName`'s field of the same
115 /// name; carried verbatim through `scripts/import_whatsmyname.py`.
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub strip_bad_char: Option<String>,
118 /// HTTP method used to probe this site. Defaults to GET — the vast
119 /// majority of sites are GET-probed. A few (Anilist's GraphQL API,
120 /// some Discord/Holopin endpoints) only answer to POST.
121 #[serde(default, skip_serializing_if = "is_default_method")]
122 pub request_method: HttpMethod,
123 /// Request body to send when [`Site::request_method`] is POST. The
124 /// literal `{username}` placeholder is substituted with the probe
125 /// username (same as URL templates). For GraphQL endpoints this
126 /// is typically the JSON `{"query":"...","variables":{"name":"{username}"}}`.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub request_body: Option<String>,
129 /// Specific anti-bot mechanisms the site is known to deploy. A
130 /// richer alternative to the flat `bot-protected` tag — knowing
131 /// *which* protection a site uses lets future routing pick the
132 /// right backend (`Cloudflare` → cloudscraper-style bypass,
133 /// `CfFirewall` → full browser, `UserAuth` → skip, …) instead
134 /// of the all-or-nothing `bot-protected` decision.
135 ///
136 /// Independent of [`Site::tags`]: the existing `bot-protected`
137 /// tag stays as a back-compat shorthand and routes through the
138 /// browser backend exactly as before. When this vector is
139 /// non-empty Adler also treats the site as bot-protected
140 /// regardless of the tag.
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub protection: Vec<ProtectionKind>,
143 /// Disable the site without removing it from the registry.
144 /// Disabled sites are skipped by [`crate::Registry::filter`] —
145 /// they don't get probed, don't appear in `--list-sites`, and
146 /// don't count toward the doctor's tally. Useful for parking
147 /// known-broken entries with a reason comment instead of
148 /// deleting them outright, so a future contributor can re-enable
149 /// the entry by flipping the flag once they've authored a
150 /// working signature.
151 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
152 pub disabled: bool,
153 /// Free-form annotation explaining why a [`Site::disabled`] entry
154 /// was parked. The Rust runtime doesn't act on it — the JSON
155 /// loader, scan path and doctor all just look at `disabled` — but
156 /// downstream tooling (`scripts/doctor_aggregate.py`, ad-hoc
157 /// audits) and human maintainers reading `sites.json` directly
158 /// rely on it to tell categories apart at-a-glance:
159 /// `duplicate of <canonical>`, `Honest Limits: …`, `doctor: 3+
160 /// consecutive structural failures`, etc. Optional; only meaningful
161 /// when `disabled` is also `true`.
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub disabled_reason: Option<String>,
164 /// Canonical-source link for mirror-style sites. When a site is
165 /// a mirror of another (e.g. Nitter ↔ Twitter, Invidious ↔
166 /// `YouTube`), `source` carries the name of the primary site this
167 /// one mirrors. Lets future UX surface "Twitter is offline,
168 /// here's the same account on Nitter" without hand-curated
169 /// linkage. Empty / `None` for canonical sites and sites with
170 /// no known mirror relationship.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub source: Option<String>,
173 /// Approximate popularity rank — lower numbers are more popular.
174 /// Used by `adler --top N` as a rank ceiling (`popularity <= N`),
175 /// useful for fast checks of high-signal targets. Ranks are curated,
176 /// not derived from traffic data: the seed set covers well-known
177 /// OSINT-relevant sites where most users have accounts. Sites
178 /// without a rank are skipped by `--top N`.
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub popularity: Option<u32>,
181 /// Egress requirement for reaching this site — country and/or IP
182 /// type the probe must exit from (see [`AccessPolicy`]). Default
183 /// (empty) means no special routing: the request uses the client's
184 /// default egress. When constrained and no configured egress fits,
185 /// the probe is reported `Uncertain(GeoUnavailable)` rather than
186 /// fetched from the wrong location.
187 #[serde(default, skip_serializing_if = "AccessPolicy::is_default")]
188 pub access: AccessPolicy,
189}
190
191/// A specific anti-bot mechanism a site is known to deploy. Used to
192/// route probes to the right backend (raw HTTP, cloudscraper, full
193/// browser) and to inform users what blocks reliable detection.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "kebab-case")]
196#[non_exhaustive]
197pub enum ProtectionKind {
198 /// Standard Cloudflare WAF — challenge pages, `cf_clearance`
199 /// cookie. Bypassable by cloudscraper-style HTTP-level solvers
200 /// (e.g. `FlareSolverr`) without a full browser.
201 Cloudflare,
202 /// AWS `CloudFront` edge protection. Often UA-strictness only.
203 Cloudfront,
204 /// `DDoS-Guard` (used by some Russian/CIS hosts). Similar
205 /// challenge model to Cloudflare.
206 DdosGuard,
207 /// Cloudflare's JS-challenge ("I am under attack" mode).
208 /// Needs a JS-executing backend.
209 CfJsChallenge,
210 /// Cloudflare's WAF firewall blocking by signature, requiring
211 /// a real browser fingerprint to clear.
212 CfFirewall,
213 /// JA3/JA4 TLS-fingerprint matching (servers that classify the
214 /// client by its TLS handshake shape, not its UA).
215 TlsFingerprint,
216 /// `Anubis` proof-of-work challenge. Used by codeberg + a
217 /// growing number of FOSS projects to discourage scraping.
218 Anubis,
219 /// Generic captcha challenge (hCaptcha, reCAPTCHA, …). Almost
220 /// always blocking — `Uncertain` is the honest answer.
221 Captcha,
222 /// Trivial UA-strictness: rejects unknown User-Agent strings
223 /// but lets through a real-browser UA. Cheapest to bypass.
224 UserAgent,
225 /// Endpoint requires authentication; no anonymous probe path
226 /// exists. Practically unscrapable for OSINT.
227 UserAuth,
228}
229
230/// HTTP method used to probe a site. Only GET and POST are supported.
231#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "UPPERCASE")]
233pub enum HttpMethod {
234 /// Standard GET — the default for ~99% of sites in the registry.
235 #[default]
236 Get,
237 /// POST — for API endpoints that only differentiate accounts via a
238 /// body payload (GraphQL queries, form submissions). Pair with
239 /// [`Site::request_body`].
240 Post,
241}
242
243/// serde's `skip_serializing_if` callback contract requires a
244/// reference, so the by-value lint on a 1-byte type doesn't apply.
245#[allow(clippy::trivially_copy_pass_by_ref)]
246fn is_default_method(m: &HttpMethod) -> bool {
247 matches!(m, HttpMethod::Get)
248}
249
250/// Shared detection signature template for a family of sites that
251/// run the same forum / blog / wiki software (Discourse, vBulletin,
252/// `XenForo`, `MediaWiki`, …). Referenced from [`Site::engine`].
253///
254/// Engines carry the same kinds of fields as a [`Site`] does (just
255/// the inheritable ones — there's no per-engine `url`, that comes
256/// from the site itself). At registry load, the engine's fields
257/// are merged *under* each referring site's own fields: site wins
258/// on conflict.
259#[derive(Debug, Clone, Default, Serialize, Deserialize)]
260#[non_exhaustive]
261pub struct Engine {
262 /// Default detection signals for sites of this family.
263 /// Inherited only when the site itself declares no `signals`.
264 #[serde(default, skip_serializing_if = "Vec::is_empty")]
265 pub signals: Vec<Signal>,
266 /// Default extra HTTP headers (e.g. a User-Agent that the
267 /// platform accepts where the browser default gets blocked).
268 /// Merged with the site's own headers; site wins per-key.
269 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
270 pub request_headers: std::collections::BTreeMap<String, String>,
271 /// Default username-validity regex inherited only when the site
272 /// itself doesn't declare one.
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub regex_check: Option<String>,
275}
276
277impl Engine {
278 /// Compile-check the engine's own constraints — the inheritable
279 /// fields are subject to the same validation as a site's would
280 /// be.
281 ///
282 /// # Errors
283 /// Returns [`Error::InvalidSite`] when the engine name is
284 /// empty, a signal carries an empty marker, or any other
285 /// constraint a [`Site::validate`] would also flag.
286 pub fn validate(&self, name: &str) -> Result<()> {
287 if name.trim().is_empty() {
288 return Err(Error::InvalidSite {
289 reason: "engine name is empty".into(),
290 });
291 }
292 for signal in &self.signals {
293 signal.validate().map_err(|reason| Error::InvalidSite {
294 reason: format!("engine {name:?}: {reason}"),
295 })?;
296 }
297 if let Some(pat) = &self.regex_check {
298 if let Err(err) = regex::Regex::new(pat) {
299 // The Rust `regex` crate refuses look-around for DoS
300 // reasons; some upstream registries (Sherlock, WMN)
301 // ship patterns that need it. Downgraded from WARN to
302 // DEBUG: it's a known structural limit, the probe
303 // path falls back gracefully, and the noise dominated
304 // CLI startup.
305 tracing::debug!(
306 engine = %name, pattern = %pat, error = %err,
307 "engine regex_check did not compile; gate disabled for inheriting sites",
308 );
309 }
310 }
311 Ok(())
312 }
313
314 /// Fill the inheritable empty / unset fields of `site` from
315 /// this engine. Site fields are authoritative: if the site has
316 /// any signals at all, no engine signals are merged in.
317 /// `request_headers` merge per-key (site wins on per-key
318 /// conflict).
319 pub fn merge_into(&self, site: &mut Site) {
320 if site.signals.is_empty() {
321 site.signals.clone_from(&self.signals);
322 }
323 for (k, v) in &self.request_headers {
324 site.request_headers
325 .entry(k.clone())
326 .or_insert_with(|| v.clone());
327 }
328 if site.regex_check.is_none() {
329 site.regex_check.clone_from(&self.regex_check);
330 }
331 }
332}
333
334/// Known-present declaration on a [`Site`].
335///
336/// In JSON this is `untagged`: a plain string `"torvalds"` deserialises
337/// into [`KnownPresent::Single`], an array `["torvalds", "leomessi"]`
338/// into [`KnownPresent::Multiple`]. Serialisation preserves the form
339/// the site was authored with, so single-username entries stay
340/// compact.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(untagged)]
343#[non_exhaustive]
344pub enum KnownPresent {
345 /// Exactly one candidate username.
346 Single(String),
347 /// Two or more candidate usernames. Doctor passes if any resolve
348 /// to `Found`.
349 Multiple(Vec<String>),
350}
351
352impl KnownPresent {
353 /// View all candidate usernames as a slice, in declaration order.
354 /// Always non-empty for `Single`; may be empty for a hand-authored
355 /// `Multiple([])` (validation rejects that).
356 pub fn as_slice(&self) -> &[String] {
357 match self {
358 Self::Single(s) => std::slice::from_ref(s),
359 Self::Multiple(v) => v.as_slice(),
360 }
361 }
362
363 /// Primary candidate — the first declared username. `Single`
364 /// always has one; `Multiple` may be empty if a contributor wrote
365 /// `[]` (caught by [`Site::validate`]).
366 pub fn primary(&self) -> Option<&str> {
367 self.as_slice().first().map(String::as_str)
368 }
369}
370
371impl From<&str> for KnownPresent {
372 fn from(s: &str) -> Self {
373 Self::Single(s.to_owned())
374 }
375}
376
377impl From<String> for KnownPresent {
378 fn from(s: String) -> Self {
379 Self::Single(s)
380 }
381}
382
383/// Upper bound on a site name's length. Names appear in CLI output,
384/// CSV columns, and the validate-sites.yml workflow's run-summary
385/// table — keeping them short avoids both UI breakage and
386/// pathological CI artefacts.
387const NAME_MAX_LEN: usize = 80;
388
389/// True when `name` consists only of characters safe to interpolate
390/// into shell, CSV, and CLI argument contexts. Matches the JSON
391/// Schema pattern `^[\w][\w .()!/+-]*$`.
392fn is_safe_site_name(name: &str) -> bool {
393 let mut chars = name.chars();
394 match chars.next() {
395 Some(c) if c.is_ascii_alphanumeric() || c == '_' => {}
396 _ => return false,
397 }
398 chars.all(|c| {
399 c.is_ascii_alphanumeric()
400 || c == '_'
401 || c == ' '
402 || matches!(c, '.' | '(' | ')' | '!' | '/' | '+' | '-')
403 })
404}
405
406/// A rule for extracting one profile field from a page.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct Extractor {
409 /// Output field name, e.g. `"avatar"`, `"bio"`, `"name"`.
410 pub field: String,
411 /// CSS selector locating the element.
412 pub selector: String,
413 /// Attribute to read (e.g. `"src"`, `"content"`). When omitted, the
414 /// element's trimmed text content is used.
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub attr: Option<String>,
417}
418
419impl Site {
420 /// Render the site URL for a given username.
421 ///
422 /// If the site declares [`strip_bad_char`](Site::strip_bad_char),
423 /// those characters are removed from `username` before
424 /// substitution — so a `john.doe` probe against a site that
425 /// lists `strip_bad_char: "."` actually hits the URL for
426 /// `johndoe`, matching the canonical form the site stores
427 /// internally.
428 pub fn url_for(&self, username: &Username) -> String {
429 self.url.substitute(&self.canonical_username(username))
430 }
431
432 /// Render the username in the canonical form this site expects.
433 ///
434 /// This mirrors [`Site::url_for`] without tying callers to URL
435 /// substitution, so detection signals can compare the response body
436 /// against the same username form that was actually probed.
437 pub(crate) fn canonical_username(&self, username: &Username) -> String {
438 let raw = username.as_str();
439 match self.strip_bad_char.as_deref() {
440 Some(chars) if !chars.is_empty() && raw.chars().any(|c| chars.contains(c)) => {
441 raw.chars().filter(|c| !chars.contains(*c)).collect()
442 }
443 _ => raw.to_owned(),
444 }
445 }
446
447 /// Validate semantic invariants the type system can't enforce
448 /// (empty signals list, empty markers, empty status code sets).
449 pub fn validate(&self) -> Result<()> {
450 if self.name.trim().is_empty() {
451 return Err(Error::InvalidSite {
452 reason: "site name is empty".into(),
453 });
454 }
455 // Site names doubled as shell-interpolation values in the
456 // `validate-sites.yml` PR gate; an unsanitised name like
457 // `Foo"; rm -rf /; #` would have broken out of `"$name"`
458 // quoting and run arbitrary commands on the runner. Both the
459 // JSON Schema and this Rust loader enforce a safe character
460 // class (word chars plus a few visual punctuation marks) at
461 // every entry point.
462 if self.name.len() > NAME_MAX_LEN {
463 return Err(Error::InvalidSite {
464 reason: format!(
465 "site name longer than {NAME_MAX_LEN} chars: {:?}",
466 self.name
467 ),
468 });
469 }
470 if !is_safe_site_name(&self.name) {
471 return Err(Error::InvalidSite {
472 reason: format!(
473 "site name {:?} contains characters outside the allowed \
474 set (word chars, space, `.()!/+-`)",
475 self.name
476 ),
477 });
478 }
479 if self.signals.is_empty() {
480 return Err(Error::InvalidSite {
481 reason: format!("site {:?}: signals list is empty", self.name),
482 });
483 }
484 for signal in &self.signals {
485 signal.validate().map_err(|reason| Error::InvalidSite {
486 reason: format!("site {:?}: {reason}", self.name),
487 })?;
488 }
489 for extractor in &self.extract {
490 if extractor.field.trim().is_empty() {
491 return Err(Error::InvalidSite {
492 reason: format!("site {:?}: extractor has an empty field name", self.name),
493 });
494 }
495 if scraper::Selector::parse(&extractor.selector).is_err() {
496 return Err(Error::InvalidSite {
497 reason: format!(
498 "site {:?}: invalid CSS selector {:?} for field {:?}",
499 self.name, extractor.selector, extractor.field
500 ),
501 });
502 }
503 }
504 if let Some(pat) = &self.regex_check {
505 if let Err(err) = regex::Regex::new(pat) {
506 // Sherlock's regexes occasionally use lookarounds
507 // (e.g. `(?![.-])`), which the Rust `regex` crate
508 // doesn't support — it's a true regular-language
509 // engine for performance + DoS safety. Rather than
510 // reject the whole site over a username-gate the
511 // probe path will simply skip and let the site keep
512 // working at the cost of one wasted probe per
513 // illegal username. Logged at DEBUG (not WARN) — it's
514 // a known structural limit, ~8 sites in the embedded
515 // registry need look-around. The noise dominated CLI
516 // startup; set `ADLER_LOG=debug` to see them again.
517 tracing::debug!(
518 site = %self.name, pattern = %pat, error = %err,
519 "regex_check did not compile; username-gate disabled for this site",
520 );
521 }
522 }
523 if let Some(kp) = &self.known_present {
524 if kp.as_slice().is_empty() {
525 return Err(Error::InvalidSite {
526 reason: format!("site {:?}: known_present is an empty list", self.name),
527 });
528 }
529 for name in kp.as_slice() {
530 if name.trim().is_empty() {
531 return Err(Error::InvalidSite {
532 reason: format!(
533 "site {:?}: known_present contains an empty username",
534 self.name
535 ),
536 });
537 }
538 }
539 }
540 for tag in &self.tags {
541 if tag.trim().is_empty() {
542 return Err(Error::InvalidSite {
543 reason: format!("site {:?}: tag is empty", self.name),
544 });
545 }
546 }
547 Ok(())
548 }
549}
550
551/// URL template containing a `{username}` placeholder.
552///
553/// Validated at construction: must contain the placeholder and start with
554/// `http://` or `https://`.
555#[derive(Debug, Clone, PartialEq, Eq)]
556pub struct UrlTemplate(String);
557
558const PLACEHOLDER: &str = "{username}";
559
560impl UrlTemplate {
561 /// Build a template, validating placeholder and scheme.
562 pub fn new(template: impl Into<String>) -> Result<Self> {
563 let t = template.into();
564 if !t.contains(PLACEHOLDER) {
565 return Err(Error::InvalidSite {
566 reason: format!("url template missing {PLACEHOLDER} placeholder: {t:?}"),
567 });
568 }
569 if !(t.starts_with("http://") || t.starts_with("https://")) {
570 return Err(Error::InvalidSite {
571 reason: format!("url template must start with http(s)://: {t:?}"),
572 });
573 }
574 Ok(Self(t))
575 }
576
577 fn substitute(&self, username: &str) -> String {
578 self.0.replace(PLACEHOLDER, username)
579 }
580
581 /// Borrow the raw template (with placeholder).
582 pub fn as_str(&self) -> &str {
583 &self.0
584 }
585}
586
587impl fmt::Display for UrlTemplate {
588 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
589 f.write_str(&self.0)
590 }
591}
592
593impl Serialize for UrlTemplate {
594 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
595 self.0.serialize(s)
596 }
597}
598
599impl<'de> Deserialize<'de> for UrlTemplate {
600 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
601 let raw = String::deserialize(d)?;
602 Self::new(raw).map_err(serde::de::Error::custom)
603 }
604}
605
606/// A single piece of evidence about whether an account exists.
607///
608/// Signals are tagged in JSON by their `kind`. New variants will land for
609/// Phase 2 length-baseline scoring; the enum is `#[non_exhaustive]` so
610/// adding variants is not a breaking change.
611#[derive(Debug, Clone, Serialize, Deserialize)]
612#[serde(tag = "kind", rename_all = "snake_case")]
613#[non_exhaustive]
614pub enum Signal {
615 /// Votes **`Found`** when the response status is in `codes`.
616 StatusFound {
617 /// Status codes that vote for existence. Must be non-empty.
618 codes: Vec<u16>,
619 },
620 /// Votes **`NotFound`** when the response status is in `codes`.
621 StatusNotFound {
622 /// Status codes that vote for non-existence. Must be non-empty.
623 codes: Vec<u16>,
624 },
625 /// Votes **`Found`** when the response body contains `text`.
626 BodyPresent {
627 /// Substring whose appearance votes for existence. Must be non-empty.
628 text: String,
629 },
630 /// Votes **`Found`** when the response body contains `text` after
631 /// substituting `{username}` with the site's canonical username.
632 BodyUsername {
633 /// Username-confirming body marker. Must be non-empty and must
634 /// contain the literal `{username}` placeholder.
635 text: String,
636 },
637 /// Votes **`Found`** when a JSON response field equals the site's
638 /// canonical username.
639 JsonUsername {
640 /// RFC 6901 JSON Pointer to the username field. Must start with `/`.
641 pointer: String,
642 },
643 /// Votes **`NotFound`** when the response body contains `text`.
644 BodyAbsent {
645 /// Substring whose appearance votes for non-existence (e.g.
646 /// `"Profile not found"`). Must be non-empty.
647 text: String,
648 },
649 /// Votes **`NotFound`** when the final URL (post-redirect) contains
650 /// `fragment`.
651 RedirectAbsent {
652 /// Substring that, when present in the final URL, indicates the
653 /// account is missing (typically `"/login"` or `"/404"`). Must be
654 /// non-empty.
655 fragment: String,
656 },
657}
658
659/// Probe data extracted from an HTTP response, fed to each [`Signal`].
660///
661/// Internal detection plumbing — not part of the public API.
662#[derive(Debug)]
663pub(crate) struct Probe<'a> {
664 /// HTTP status code.
665 pub(crate) status: u16,
666 /// Final URL after redirects.
667 pub(crate) final_url: &'a str,
668 /// Decoded response body. Empty string when no body-using signal is configured.
669 pub(crate) body: &'a str,
670 /// Username in the canonical form used for this site.
671 pub(crate) username: &'a str,
672}
673
674/// What one signal concluded after looking at a probe.
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676pub(crate) enum SignalVerdict {
677 /// This signal votes that the account exists.
678 Found,
679 /// This signal votes that the account does not exist.
680 NotFound,
681 /// This signal had nothing to say (its trigger condition didn't match).
682 Ambiguous,
683}
684
685impl Signal {
686 /// True if this signal needs to inspect the response body. Used by the
687 /// client to skip body reads when no signal requires them.
688 pub(crate) fn needs_body(&self) -> bool {
689 matches!(
690 self,
691 Self::BodyPresent { .. }
692 | Self::BodyUsername { .. }
693 | Self::JsonUsername { .. }
694 | Self::BodyAbsent { .. }
695 )
696 }
697
698 /// Evaluate this signal against a probe and produce a vote.
699 pub(crate) fn evaluate(&self, probe: &Probe<'_>) -> SignalVerdict {
700 match self {
701 Self::StatusFound { codes } => {
702 if codes.contains(&probe.status) {
703 SignalVerdict::Found
704 } else {
705 SignalVerdict::Ambiguous
706 }
707 }
708 Self::StatusNotFound { codes } => {
709 if codes.contains(&probe.status) {
710 SignalVerdict::NotFound
711 } else {
712 SignalVerdict::Ambiguous
713 }
714 }
715 Self::BodyPresent { text } => {
716 if probe.body.contains(text.as_str()) {
717 SignalVerdict::Found
718 } else {
719 SignalVerdict::Ambiguous
720 }
721 }
722 Self::BodyUsername { text } => {
723 if probe
724 .body
725 .contains(render_username_marker(text, probe.username).as_str())
726 {
727 SignalVerdict::Found
728 } else {
729 SignalVerdict::Ambiguous
730 }
731 }
732 Self::JsonUsername { pointer } => {
733 if json_pointer_string_eq(probe.body, pointer, probe.username) {
734 SignalVerdict::Found
735 } else {
736 SignalVerdict::Ambiguous
737 }
738 }
739 Self::BodyAbsent { text } => {
740 if probe.body.contains(text.as_str()) {
741 SignalVerdict::NotFound
742 } else {
743 SignalVerdict::Ambiguous
744 }
745 }
746 Self::RedirectAbsent { fragment } => {
747 if probe.final_url.contains(fragment.as_str()) {
748 SignalVerdict::NotFound
749 } else {
750 SignalVerdict::Ambiguous
751 }
752 }
753 }
754 }
755
756 /// Human-readable description of why this signal fired against `probe`,
757 /// for verdict explainability. Only meaningful for a signal that voted
758 /// (i.e. didn't return [`SignalVerdict::Ambiguous`]); the caller filters.
759 pub(crate) fn describe_match(&self, probe: &Probe<'_>) -> String {
760 match self {
761 Self::StatusFound { .. } => format!("HTTP {} (status_found)", probe.status),
762 Self::StatusNotFound { .. } => format!("HTTP {} (status_not_found)", probe.status),
763 Self::BodyPresent { text } => format!("body contains {text:?} (body_present)"),
764 Self::BodyUsername { text } => format!(
765 "body contains {:?} (body_username)",
766 render_username_marker(text, probe.username)
767 ),
768 Self::JsonUsername { pointer } => {
769 format!(
770 "json pointer {pointer:?} equals {:?} (json_username)",
771 probe.username
772 )
773 }
774 Self::BodyAbsent { text } => format!("body contains {text:?} (body_absent)"),
775 Self::RedirectAbsent { fragment } => {
776 format!("final URL contains {fragment:?} (redirect_absent)")
777 }
778 }
779 }
780
781 /// Whether this signal confirms the concrete username for the current
782 /// probe instead of only reporting a generic positive match.
783 pub(crate) const fn confirms_username(&self) -> bool {
784 matches!(self, Self::BodyUsername { .. } | Self::JsonUsername { .. })
785 }
786
787 fn validate(&self) -> std::result::Result<(), String> {
788 match self {
789 Self::StatusFound { codes } | Self::StatusNotFound { codes } => {
790 if codes.is_empty() {
791 return Err("status signal codes list is empty".into());
792 }
793 }
794 Self::BodyPresent { text } | Self::BodyAbsent { text } => {
795 if text.is_empty() {
796 return Err("body signal text is empty".into());
797 }
798 }
799 Self::BodyUsername { text } => {
800 if text.is_empty() {
801 return Err("body username signal text is empty".into());
802 }
803 if !text.contains(PLACEHOLDER) {
804 return Err(format!(
805 "body username signal text missing {PLACEHOLDER} placeholder"
806 ));
807 }
808 }
809 Self::JsonUsername { pointer } => {
810 if pointer.is_empty() {
811 return Err("json username signal pointer is empty".into());
812 }
813 if !pointer.starts_with('/') {
814 return Err("json username signal pointer must start with '/'".into());
815 }
816 }
817 Self::RedirectAbsent { fragment } => {
818 if fragment.is_empty() {
819 return Err("redirect signal fragment is empty".into());
820 }
821 }
822 }
823 Ok(())
824 }
825}
826
827fn render_username_marker(template: &str, username: &str) -> String {
828 template.replace(PLACEHOLDER, username)
829}
830
831fn json_pointer_string_eq(body: &str, pointer: &str, username: &str) -> bool {
832 let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
833 return false;
834 };
835 value
836 .pointer(pointer)
837 .and_then(serde_json::Value::as_str)
838 .is_some_and(|value| value == username)
839}
840
841/// Aggregate per-signal verdicts into a final [`MatchKind`].
842///
843/// Negative-priority counting: any `NotFound` vote → `NotFound`; otherwise
844/// any `Found` vote → `Found`; no votes at all → `Uncertain`. See the module
845/// docs for why a `NotFound` vote outranks a `Found` vote.
846pub(crate) fn aggregate<I>(verdicts: I) -> MatchKind
847where
848 I: IntoIterator<Item = SignalVerdict>,
849{
850 let mut found = false;
851 let mut not_found = false;
852 for v in verdicts {
853 match v {
854 SignalVerdict::Found => found = true,
855 SignalVerdict::NotFound => not_found = true,
856 SignalVerdict::Ambiguous => {}
857 }
858 }
859 if not_found {
860 MatchKind::NotFound
861 } else if found {
862 MatchKind::Found
863 } else {
864 MatchKind::Uncertain
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 fn site_with(signals: Vec<Signal>) -> Site {
873 Site {
874 name: "Example".into(),
875 url: UrlTemplate::new("https://example.com/{username}").unwrap(),
876 signals,
877 known_present: None,
878 known_absent: None,
879 extract: Vec::new(),
880 tags: Vec::new(),
881 request_headers: std::collections::BTreeMap::new(),
882 regex_check: None,
883 engine: None,
884 strip_bad_char: None,
885 request_method: crate::site::HttpMethod::Get,
886 request_body: None,
887 protection: Vec::new(),
888 disabled: false,
889 disabled_reason: None,
890 source: None,
891 popularity: None,
892 access: crate::AccessPolicy::default(),
893 }
894 }
895
896 #[test]
897 fn url_template_substitutes_placeholder() {
898 let user = Username::new("alice").unwrap();
899 let site = site_with(vec![Signal::StatusFound { codes: vec![200] }]);
900 assert_eq!(site.url_for(&user), "https://example.com/alice");
901 }
902
903 #[test]
904 fn url_for_strips_bad_chars_before_substitution() {
905 let user = Username::new("john.doe").unwrap();
906 let mut site = site_with(vec![Signal::StatusFound { codes: vec![200] }]);
907 site.strip_bad_char = Some(".".into());
908 assert_eq!(site.url_for(&user), "https://example.com/johndoe");
909 }
910
911 #[test]
912 fn url_for_strip_bad_char_noop_when_no_match() {
913 let user = Username::new("alice").unwrap();
914 let mut site = site_with(vec![Signal::StatusFound { codes: vec![200] }]);
915 site.strip_bad_char = Some(".".into());
916 assert_eq!(site.url_for(&user), "https://example.com/alice");
917 }
918
919 #[test]
920 fn canonical_username_matches_url_stripping() {
921 let user = Username::new("john.doe").unwrap();
922 let mut site = site_with(vec![Signal::StatusFound { codes: vec![200] }]);
923 site.strip_bad_char = Some(".".into());
924 assert_eq!(site.canonical_username(&user), "johndoe");
925 }
926
927 #[test]
928 fn url_template_rejects_missing_placeholder() {
929 assert!(UrlTemplate::new("https://example.com/users/").is_err());
930 }
931
932 #[test]
933 fn url_template_rejects_bad_scheme() {
934 assert!(UrlTemplate::new("ftp://example.com/{username}").is_err());
935 }
936
937 #[test]
938 fn validate_requires_non_empty_signals() {
939 let err = site_with(vec![]).validate().unwrap_err();
940 assert!(err.to_string().contains("signals list is empty"));
941 }
942
943 #[test]
944 fn validate_rejects_empty_status_codes() {
945 let err = site_with(vec![Signal::StatusFound { codes: vec![] }])
946 .validate()
947 .unwrap_err();
948 assert!(err.to_string().contains("status signal"));
949 }
950
951 #[test]
952 fn validate_rejects_empty_body_text() {
953 let err = site_with(vec![Signal::BodyAbsent {
954 text: String::new(),
955 }])
956 .validate()
957 .unwrap_err();
958 assert!(err.to_string().contains("body signal"));
959 }
960
961 #[test]
962 fn validate_rejects_bad_body_username_marker() {
963 let err = site_with(vec![Signal::BodyUsername {
964 text: String::new(),
965 }])
966 .validate()
967 .unwrap_err();
968 assert!(err.to_string().contains("body username signal"));
969
970 let err = site_with(vec![Signal::BodyUsername {
971 text: "username".into(),
972 }])
973 .validate()
974 .unwrap_err();
975 assert!(err.to_string().contains("missing {username} placeholder"));
976 }
977
978 #[test]
979 fn validate_rejects_bad_json_username_pointer() {
980 let err = site_with(vec![Signal::JsonUsername {
981 pointer: String::new(),
982 }])
983 .validate()
984 .unwrap_err();
985 assert!(err.to_string().contains("json username signal pointer"));
986
987 let err = site_with(vec![Signal::JsonUsername {
988 pointer: "data/name".into(),
989 }])
990 .validate()
991 .unwrap_err();
992 assert!(
993 err.to_string().contains("must start with '/'"),
994 "unexpected error: {err}"
995 );
996 }
997
998 #[test]
999 fn validate_rejects_empty_redirect_fragment() {
1000 let err = site_with(vec![Signal::RedirectAbsent {
1001 fragment: String::new(),
1002 }])
1003 .validate()
1004 .unwrap_err();
1005 assert!(err.to_string().contains("redirect signal"));
1006 }
1007
1008 #[test]
1009 fn validate_rejects_shell_metacharacters_in_name() {
1010 // The validate-sites.yml workflow used to inject `--only "$name"`
1011 // where `$name` came from PR-controlled sites.json. A name like
1012 // `Foo"; rm -rf /; #` would have broken out of `"..."` quoting
1013 // and executed on the runner. Schema + this loader both enforce
1014 // a safe character class; verify a representative selection of
1015 // dangerous chars is rejected.
1016 for bad in [
1017 "Foo\"; rm -rf /; #",
1018 "Bar$(curl evil.com)",
1019 "Baz`whoami`",
1020 "Qux\\nfoo",
1021 "back\\slash",
1022 "pipe|ish",
1023 "semi;colon",
1024 "amp&and",
1025 "lt<gt>",
1026 ] {
1027 let mut s = site_with(vec![Signal::StatusFound { codes: vec![200] }]);
1028 s.name = bad.into();
1029 let err = s.validate().unwrap_err();
1030 assert!(
1031 err.to_string()
1032 .contains("characters outside the allowed set"),
1033 "expected unsafe-name rejection for {bad:?}, got {err}",
1034 );
1035 }
1036 }
1037
1038 #[test]
1039 fn validate_accepts_real_world_site_names() {
1040 // Cross-check the validation against names we actually ship.
1041 for ok in [
1042 "GitHub",
1043 "Steam Community (User)",
1044 "X / Twitter",
1045 "osu!",
1046 "Eintracht Frankfurt Forum",
1047 "Archive of Our Own",
1048 "Career.habr",
1049 "fl",
1050 "GitLab.com",
1051 "Sbazar.cz",
1052 ] {
1053 let mut s = site_with(vec![Signal::StatusFound { codes: vec![200] }]);
1054 s.name = ok.into();
1055 assert!(s.validate().is_ok(), "expected {ok:?} to validate");
1056 }
1057 }
1058
1059 #[test]
1060 fn validate_rejects_overlong_name() {
1061 let mut s = site_with(vec![Signal::StatusFound { codes: vec![200] }]);
1062 s.name = "A".repeat(100);
1063 let err = s.validate().unwrap_err();
1064 assert!(err.to_string().contains("longer than"));
1065 }
1066
1067 #[test]
1068 fn validate_accepts_well_formed_regex_check() {
1069 let mut s = site_with(vec![Signal::StatusFound { codes: vec![200] }]);
1070 s.regex_check = Some("^[a-zA-Z0-9_-]{3,40}$".into());
1071 assert!(s.validate().is_ok());
1072 }
1073
1074 #[test]
1075 fn validate_tolerates_unsupported_regex_features() {
1076 // Sherlock-imported regexes occasionally use lookarounds
1077 // (e.g. `(?!...)`) that Rust's `regex` crate can't compile —
1078 // those sites should still load, with the username-gate
1079 // silently disabled rather than rejecting the whole site.
1080 let mut s = site_with(vec![Signal::StatusFound { codes: vec![200] }]);
1081 s.regex_check = Some("^(?![.-])[a-zA-Z0-9_.-]{3,20}$".into());
1082 assert!(
1083 s.validate().is_ok(),
1084 "lookaround-bearing regex should warn, not reject the site"
1085 );
1086 }
1087
1088 #[test]
1089 fn signal_status_found_votes_only_on_match() {
1090 let signal = Signal::StatusFound { codes: vec![200] };
1091 let probe = Probe {
1092 status: 200,
1093 final_url: "https://example.com/alice",
1094 body: "",
1095 username: "alice",
1096 };
1097 assert_eq!(signal.evaluate(&probe), SignalVerdict::Found);
1098 let probe = Probe {
1099 status: 404,
1100 ..probe
1101 };
1102 assert_eq!(signal.evaluate(&probe), SignalVerdict::Ambiguous);
1103 }
1104
1105 #[test]
1106 fn signal_status_not_found_votes_only_on_match() {
1107 let signal = Signal::StatusNotFound { codes: vec![404] };
1108 let probe = Probe {
1109 status: 404,
1110 final_url: "",
1111 body: "",
1112 username: "alice",
1113 };
1114 assert_eq!(signal.evaluate(&probe), SignalVerdict::NotFound);
1115 let probe = Probe {
1116 status: 200,
1117 ..probe
1118 };
1119 assert_eq!(signal.evaluate(&probe), SignalVerdict::Ambiguous);
1120 }
1121
1122 #[test]
1123 fn signal_body_absent_votes_not_found_when_text_present() {
1124 let signal = Signal::BodyAbsent {
1125 text: "Profile not found".into(),
1126 };
1127 let probe = Probe {
1128 status: 200,
1129 final_url: "",
1130 body: "<h1>Profile not found</h1>",
1131 username: "alice",
1132 };
1133 assert_eq!(signal.evaluate(&probe), SignalVerdict::NotFound);
1134 let probe = Probe {
1135 body: "<h1>Welcome alice</h1>",
1136 ..probe
1137 };
1138 assert_eq!(signal.evaluate(&probe), SignalVerdict::Ambiguous);
1139 }
1140
1141 #[test]
1142 fn signal_body_username_votes_found_only_for_rendered_username() {
1143 let signal = Signal::BodyUsername {
1144 text: r#""username":"{username}""#.into(),
1145 };
1146 let probe = Probe {
1147 status: 200,
1148 final_url: "",
1149 body: r#"{"username":"johndoe"}"#,
1150 username: "johndoe",
1151 };
1152 assert_eq!(signal.evaluate(&probe), SignalVerdict::Found);
1153
1154 let probe = Probe {
1155 username: "john.doe",
1156 ..probe
1157 };
1158 assert_eq!(signal.evaluate(&probe), SignalVerdict::Ambiguous);
1159 }
1160
1161 #[test]
1162 fn signal_json_username_votes_found_only_for_pointer_string() {
1163 let signal = Signal::JsonUsername {
1164 pointer: "/data/name".into(),
1165 };
1166 let probe = Probe {
1167 status: 200,
1168 final_url: "",
1169 body: r#"{"kind":"t2","data":{"name":"johndoe"}}"#,
1170 username: "johndoe",
1171 };
1172 assert_eq!(signal.evaluate(&probe), SignalVerdict::Found);
1173 assert_eq!(
1174 signal.describe_match(&probe),
1175 r#"json pointer "/data/name" equals "johndoe" (json_username)"#
1176 );
1177
1178 let probe = Probe {
1179 username: "john.doe",
1180 ..probe
1181 };
1182 assert_eq!(signal.evaluate(&probe), SignalVerdict::Ambiguous);
1183
1184 let probe = Probe {
1185 body: r#"{"kind":"t2","data":{"name":42}}"#,
1186 username: "42",
1187 ..probe
1188 };
1189 assert_eq!(signal.evaluate(&probe), SignalVerdict::Ambiguous);
1190
1191 let probe = Probe {
1192 body: "not json",
1193 username: "johndoe",
1194 ..probe
1195 };
1196 assert_eq!(signal.evaluate(&probe), SignalVerdict::Ambiguous);
1197 }
1198
1199 #[test]
1200 fn generic_body_present_does_not_confirm_username() {
1201 assert!(
1202 !Signal::BodyPresent {
1203 text: "username".into()
1204 }
1205 .confirms_username()
1206 );
1207 assert!(
1208 Signal::BodyUsername {
1209 text: "{username}".into()
1210 }
1211 .confirms_username()
1212 );
1213 assert!(
1214 Signal::JsonUsername {
1215 pointer: "/data/name".into()
1216 }
1217 .confirms_username()
1218 );
1219 }
1220
1221 #[test]
1222 fn signal_redirect_absent_inspects_final_url() {
1223 let signal = Signal::RedirectAbsent {
1224 fragment: "/login".into(),
1225 };
1226 let probe = Probe {
1227 status: 200,
1228 final_url: "https://example.com/login?next=/alice",
1229 body: "",
1230 username: "alice",
1231 };
1232 assert_eq!(signal.evaluate(&probe), SignalVerdict::NotFound);
1233 let probe = Probe {
1234 final_url: "https://example.com/alice",
1235 ..probe
1236 };
1237 assert_eq!(signal.evaluate(&probe), SignalVerdict::Ambiguous);
1238 }
1239
1240 #[test]
1241 fn aggregate_found_when_only_found_signals_fire() {
1242 let kind = aggregate([SignalVerdict::Found, SignalVerdict::Ambiguous]);
1243 assert_eq!(kind, MatchKind::Found);
1244 }
1245
1246 #[test]
1247 fn aggregate_not_found_when_only_not_found_signals_fire() {
1248 let kind = aggregate([SignalVerdict::NotFound, SignalVerdict::Ambiguous]);
1249 assert_eq!(kind, MatchKind::NotFound);
1250 }
1251
1252 #[test]
1253 fn aggregate_not_found_wins_over_found() {
1254 // Negative-priority: a NotFound vote outranks a Found vote.
1255 let kind = aggregate([SignalVerdict::Found, SignalVerdict::NotFound]);
1256 assert_eq!(kind, MatchKind::NotFound);
1257 }
1258
1259 #[test]
1260 fn aggregate_uncertain_when_no_signals_fire() {
1261 let kind = aggregate([SignalVerdict::Ambiguous, SignalVerdict::Ambiguous]);
1262 assert_eq!(kind, MatchKind::Uncertain);
1263 }
1264
1265 #[test]
1266 fn aggregate_empty_is_uncertain() {
1267 let kind = aggregate(std::iter::empty());
1268 assert_eq!(kind, MatchKind::Uncertain);
1269 }
1270
1271 #[test]
1272 fn needs_body_is_true_only_for_body_signals() {
1273 assert!(!Signal::StatusFound { codes: vec![200] }.needs_body());
1274 assert!(!Signal::StatusNotFound { codes: vec![404] }.needs_body());
1275 assert!(
1276 !Signal::RedirectAbsent {
1277 fragment: "/login".into()
1278 }
1279 .needs_body()
1280 );
1281 assert!(Signal::BodyPresent { text: "x".into() }.needs_body());
1282 assert!(
1283 Signal::JsonUsername {
1284 pointer: "/data/name".into()
1285 }
1286 .needs_body()
1287 );
1288 assert!(Signal::BodyAbsent { text: "x".into() }.needs_body());
1289 }
1290
1291 #[test]
1292 fn deserializes_signal_list() {
1293 let json = r#"{
1294 "name": "GitHub",
1295 "url": "https://github.com/{username}",
1296 "signals": [
1297 { "kind": "status_found", "codes": [200] },
1298 { "kind": "status_not_found", "codes": [404] }
1299 ]
1300 }"#;
1301 let site: Site = serde_json::from_str(json).unwrap();
1302 assert_eq!(site.name, "GitHub");
1303 assert_eq!(site.signals.len(), 2);
1304 site.validate().unwrap();
1305 }
1306
1307 proptest::proptest! {
1308 /// For any mix of per-signal verdicts, aggregation obeys the
1309 /// negative-priority spec: any NotFound wins; else any Found; else
1310 /// Uncertain.
1311 #[test]
1312 fn aggregate_matches_negative_priority_spec(
1313 votes in proptest::collection::vec(
1314 proptest::prop_oneof![
1315 proptest::strategy::Just(SignalVerdict::Found),
1316 proptest::strategy::Just(SignalVerdict::NotFound),
1317 proptest::strategy::Just(SignalVerdict::Ambiguous),
1318 ],
1319 0..16,
1320 ),
1321 ) {
1322 let kind = aggregate(votes.iter().copied());
1323 let expected = if votes.contains(&SignalVerdict::NotFound) {
1324 MatchKind::NotFound
1325 } else if votes.contains(&SignalVerdict::Found) {
1326 MatchKind::Found
1327 } else {
1328 MatchKind::Uncertain
1329 };
1330 proptest::prop_assert_eq!(kind, expected);
1331 }
1332 }
1333}