use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use avirn_core::{async_trait, Adapter, AvirnError, Confidence, Finding, Input};
use futures::stream::{self, StreamExt};
use reqwest::header::{self, HeaderMap};
use serde::{Deserialize, Serialize};
const DEFAULT_CATALOG: &str = include_str!("../rules/username_sites.json");
fn default_exists_code() -> u16 {
200
}
fn default_missing_code() -> u16 {
404
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SiteRule {
pub name: String,
pub category: String,
pub profile_url: String,
#[serde(default)]
pub check_url: Option<String>,
#[serde(default = "default_exists_code")]
pub exists_code: u16,
#[serde(default = "default_missing_code")]
pub missing_code: u16,
#[serde(default)]
pub exists_string: Option<String>,
#[serde(default)]
pub missing_string: Option<String>,
#[serde(default)]
pub nsfw: bool,
}
impl SiteRule {
fn profile_for(&self, username: &str) -> String {
self.profile_url.replace("{}", username)
}
fn check_for(&self, username: &str) -> String {
self.check_url
.as_deref()
.unwrap_or(&self.profile_url)
.replace("{}", username)
}
fn needs_body(&self) -> bool {
self.exists_string.is_some() || self.missing_string.is_some()
}
}
#[derive(Debug, Clone, Deserialize)]
struct CatalogFile {
#[serde(default)]
sites: Vec<SiteRule>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Catalog {
pub sites: Vec<SiteRule>,
}
impl Catalog {
pub fn load() -> Result<Self, String> {
let base: CatalogFile = serde_json::from_str(DEFAULT_CATALOG)
.map_err(|e| format!("built-in username catalog is invalid: {e}"))?;
let mut by_name: BTreeMap<String, SiteRule> = BTreeMap::new();
for site in base.sites {
by_name.insert(site.name.clone(), site);
}
if let Ok(dir) = std::env::var("AVIRN_RULES_DIR") {
let dir = PathBuf::from(dir);
if dir.is_dir() {
let mut entries: Vec<_> = std::fs::read_dir(&dir)
.map_err(|e| format!("cannot read AVIRN_RULES_DIR {}: {e}", dir.display()))?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("username_sites") && n.ends_with(".json"))
.unwrap_or(false)
})
.collect();
entries.sort();
for path in entries {
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let extra: CatalogFile = serde_json::from_str(&text)
.map_err(|e| format!("invalid site catalog {}: {e}", path.display()))?;
for site in extra.sites {
by_name.insert(site.name.clone(), site);
}
}
}
}
Ok(Catalog {
sites: by_name.into_values().collect(),
})
}
pub fn by_category(&self) -> CatalogView {
let mut map: BTreeMap<String, Vec<CatalogSite>> = BTreeMap::new();
for s in &self.sites {
map.entry(s.category.clone()).or_default().push(CatalogSite {
name: s.name.clone(),
profile_url: s.profile_url.clone(),
nsfw: s.nsfw,
});
}
let categories = map
.into_iter()
.map(|(category, mut sites)| {
sites.sort_by(|a, b| a.name.cmp(&b.name));
CategoryView {
count: sites.len(),
category,
sites,
}
})
.collect::<Vec<_>>();
CatalogView {
total: self.sites.len(),
categories,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CatalogSite {
pub name: String,
pub profile_url: String,
pub nsfw: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct CategoryView {
pub category: String,
pub count: usize,
pub sites: Vec<CatalogSite>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CatalogView {
pub total: usize,
pub categories: Vec<CategoryView>,
}
enum Outcome {
Found,
NotFound,
Unknown(String),
}
fn control_handle(username: &str) -> String {
let mut out = String::with_capacity(username.len() + 3);
for ch in username.chars() {
let c = match ch {
'a'..='z' => (((ch as u8 - b'a' + 13) % 26) + b'a') as char,
'A'..='Z' => (((ch as u8 - b'A' + 13) % 26) + b'A') as char,
'0'..='9' => (((ch as u8 - b'0' + 5) % 10) + b'0') as char,
other => other,
};
out.push(c);
}
if out == username {
out.push_str("q7z");
}
while out.len() < 6 {
out.push_str("q7z");
}
out
}
enum Decision {
Found,
NotFound,
Skipped(String),
}
fn decide(real: Outcome, control: Outcome) -> Decision {
match (real, control) {
(Outcome::Unknown(r), _) => Decision::Skipped(format!("unreachable: {r}")),
(_, Outcome::Found) => {
Decision::Skipped("unreliable: reports any handle as present".to_string())
}
(_, Outcome::Unknown(r)) => Decision::Skipped(format!("could not calibrate: {r}")),
(Outcome::Found, Outcome::NotFound) => Decision::Found,
(Outcome::NotFound, Outcome::NotFound) => Decision::NotFound,
}
}
pub struct UsernameSites {
catalog: Arc<Catalog>,
concurrency: usize,
timeout: Duration,
}
impl UsernameSites {
pub fn new(catalog: Arc<Catalog>) -> Self {
let concurrency = std::env::var("AVIRN_USERNAME_CONCURRENCY")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&n| n > 0)
.unwrap_or(24);
let timeout = std::env::var("AVIRN_USERNAME_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&n| n > 0)
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(8));
Self {
catalog,
concurrency,
timeout,
}
}
pub fn catalog(&self) -> Arc<Catalog> {
self.catalog.clone()
}
async fn probe(client: &reqwest::Client, site: &SiteRule, username: &str) -> Outcome {
let url = site.check_for(username);
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => return Outcome::Unknown(e.to_string()),
};
let status = resp.status().as_u16();
if site.needs_body() {
let body = match resp.text().await {
Ok(b) => b,
Err(e) => return Outcome::Unknown(e.to_string()),
};
if let Some(miss) = &site.missing_string {
return if body.contains(miss) {
Outcome::NotFound
} else if (200..300).contains(&status) {
Outcome::Found
} else {
Outcome::Unknown(format!("status {status}"))
};
}
if let Some(exist) = &site.exists_string {
return if body.contains(exist) {
Outcome::Found
} else {
Outcome::NotFound
};
}
}
if status == site.exists_code {
Outcome::Found
} else if status == site.missing_code {
Outcome::NotFound
} else {
Outcome::Unknown(format!("status {status}"))
}
}
}
#[async_trait]
impl Adapter for UsernameSites {
fn name(&self) -> &'static str {
"username_sites"
}
fn accepts(&self, input: &Input) -> bool {
matches!(input, Input::Username(_))
}
async fn check(&self, input: &Input) -> Result<Vec<Finding>, AvirnError> {
let username = input.value().to_string();
if username.trim().is_empty() {
return Err(AvirnError::InvalidInput("empty username".into()));
}
let mut headers = HeaderMap::new();
headers.insert(
header::USER_AGENT,
"Mozilla/5.0 (compatible; avirn/0.1; +https://avirn.io)"
.parse()
.unwrap(),
);
headers.insert(header::ACCEPT, "text/html,application/json".parse().unwrap());
let client = reqwest::Client::builder()
.timeout(self.timeout)
.redirect(reqwest::redirect::Policy::limited(4))
.default_headers(headers)
.build()
.map_err(|e| AvirnError::Network(e.to_string()))?;
let sites = self.catalog.sites.clone();
let total = sites.len();
let control = control_handle(&username);
let results = stream::iter(sites.into_iter())
.map(|site| {
let client = client.clone();
let user = username.clone();
let control = control.clone();
async move {
let (real, control) = tokio::join!(
Self::probe(&client, &site, &user),
Self::probe(&client, &site, &control),
);
(site, decide(real, control))
}
})
.buffer_unordered(self.concurrency)
.collect::<Vec<_>>()
.await;
let mut findings = Vec::new();
let mut unknown: Vec<String> = Vec::new();
let mut found_count = 0usize;
for (site, decision) in results {
match decision {
Decision::Found => {
found_count += 1;
findings.push(Finding {
source_type: site.name.clone(),
identifier: username.clone(),
url: Some(site.profile_for(&username)),
evidence: format!(
"Account exists on {} ({}).",
site.name, site.category
),
confidence: Confidence::Confirmed,
observed_at: Some(chrono::Utc::now()),
raw: Some(serde_json::json!({
"platform": site.name,
"category": site.category,
"nsfw": site.nsfw,
})),
});
}
Decision::NotFound => {}
Decision::Skipped(reason) => unknown.push(format!("{} ({reason})", site.name)),
}
}
let checked = total - unknown.len();
let mut summary = format!(
"Searched {checked} of {total} platforms; found {found_count} account(s)."
);
if !unknown.is_empty() {
summary.push_str(&format!(
" {} could not be searched (blocked, unreachable, or an unreliable signal): {}.",
unknown.len(),
unknown.join(", ")
));
}
findings.push(Finding {
source_type: "username_sites".to_string(),
identifier: username.clone(),
url: None,
evidence: summary,
confidence: Confidence::Inconclusive,
observed_at: Some(chrono::Utc::now()),
raw: Some(serde_json::json!({
"category": "coverage",
"checked": checked,
"total": total,
"found": found_count,
"skipped": unknown,
})),
});
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_catalog_parses_and_has_categories() {
let cat = Catalog::load().expect("catalog loads");
assert!(cat.sites.len() >= 30, "expected a substantial catalog");
let view = cat.by_category();
assert_eq!(view.total, cat.sites.len());
let cats: Vec<&str> = view.categories.iter().map(|c| c.category.as_str()).collect();
for want in ["developer", "social", "gaming", "professional"] {
assert!(cats.contains(&want), "missing category {want}");
}
}
#[test]
fn every_site_has_a_username_placeholder() {
let cat = Catalog::load().unwrap();
for s in &cat.sites {
assert!(
s.profile_url.contains("{}"),
"{} profile_url missing placeholder",
s.name
);
if let Some(c) = &s.check_url {
assert!(c.contains("{}"), "{} check_url missing placeholder", s.name);
}
}
}
#[test]
fn control_handle_matches_length_and_charset() {
let c = control_handle("torvalds");
assert_eq!(c.len(), "torvalds".len());
assert_ne!(c, "torvalds");
assert!(c.chars().all(|ch| ch.is_ascii_lowercase()));
let c2 = control_handle("john.doe99");
assert_eq!(c2.len(), "john.doe99".len());
assert!(c2.contains('.'));
assert_ne!(c2, "john.doe99");
assert!(control_handle("ab").len() >= 6);
}
#[test]
fn calibration_rejects_soft_404_false_positives() {
assert!(matches!(
decide(Outcome::Found, Outcome::Found),
Decision::Skipped(_)
));
assert!(matches!(
decide(Outcome::Found, Outcome::NotFound),
Decision::Found
));
assert!(matches!(
decide(Outcome::NotFound, Outcome::NotFound),
Decision::NotFound
));
assert!(matches!(
decide(Outcome::Unknown("timeout".into()), Outcome::NotFound),
Decision::Skipped(_)
));
assert!(matches!(
decide(Outcome::Found, Outcome::Unknown("429".into())),
Decision::Skipped(_)
));
}
#[test]
fn profile_and_check_urls_substitute() {
let s = SiteRule {
name: "GitHub".into(),
category: "developer".into(),
profile_url: "https://github.com/{}".into(),
check_url: None,
exists_code: 200,
missing_code: 404,
exists_string: None,
missing_string: None,
nsfw: false,
};
assert_eq!(s.profile_for("torvalds"), "https://github.com/torvalds");
assert_eq!(s.check_for("torvalds"), "https://github.com/torvalds");
assert!(!s.needs_body());
}
}