use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::transport::HttpFetcher;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct CountryCode([u8; 2]);
impl CountryCode {
#[must_use]
pub fn new(s: &str) -> Option<Self> {
let b = s.as_bytes();
if b.len() == 2 && b[0].is_ascii_alphabetic() && b[1].is_ascii_alphabetic() {
Some(Self([b[0].to_ascii_lowercase(), b[1].to_ascii_lowercase()]))
} else {
None
}
}
#[must_use]
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.0).unwrap_or("??")
}
}
impl TryFrom<String> for CountryCode {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(&s).ok_or_else(|| format!("invalid country code: {s:?}"))
}
}
impl From<CountryCode> for String {
fn from(c: CountryCode) -> Self {
c.as_str().to_owned()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum EgressKind {
#[default]
Datacenter,
Residential,
Mobile,
Tor,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EgressSpec {
pub url: String,
#[serde(default)]
pub country: Option<CountryCode>,
#[serde(default)]
pub kind: EgressKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessPolicy {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub geo: Vec<CountryCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_type: Option<EgressKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
}
impl AccessPolicy {
#[must_use]
pub fn is_default(&self) -> bool {
self.geo.is_empty() && self.ip_type.is_none() && self.session.is_none()
}
}
#[derive(Clone, Default)]
pub struct Session {
headers: BTreeMap<String, String>,
}
impl Session {
#[must_use]
pub fn from_headers(headers: BTreeMap<String, String>) -> Self {
Self { headers }
}
pub(crate) fn apply(&self, base: &BTreeMap<String, String>) -> BTreeMap<String, String> {
let mut out = base.clone();
for (k, v) in &self.headers {
out.insert(k.clone(), v.clone());
}
out
}
}
impl fmt::Debug for Session {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Session")
.field("headers", &self.headers.keys().collect::<Vec<_>>())
.finish_non_exhaustive()
}
}
#[derive(Clone, Default, Debug)]
pub struct SessionStore {
sessions: HashMap<String, Session>,
}
impl SessionStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, name: impl Into<String>, session: Session) {
self.sessions.insert(name.into(), session);
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.sessions.len()
}
pub(crate) fn get(&self, name: &str) -> Option<&Session> {
self.sessions.get(name)
}
}
struct EgressEntry {
country: Option<CountryCode>,
kind: EgressKind,
fetcher: Arc<HttpFetcher>,
}
pub(crate) struct EgressPool {
entries: Vec<EgressEntry>,
}
pub(crate) enum EgressChoice {
Default,
Use(Arc<HttpFetcher>),
Unavailable,
}
impl EgressPool {
pub(crate) fn new(entries: Vec<(Option<CountryCode>, EgressKind, Arc<HttpFetcher>)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(country, kind, fetcher)| EgressEntry {
country,
kind,
fetcher,
})
.collect(),
}
}
pub(crate) fn select(&self, policy: &AccessPolicy) -> EgressChoice {
if policy.geo.is_empty() && policy.ip_type.is_none() {
return EgressChoice::Default;
}
let matches: Vec<&EgressEntry> = self
.entries
.iter()
.filter(|e| {
let geo_ok = policy.geo.is_empty()
|| e.country.as_ref().is_some_and(|c| policy.geo.contains(c));
let kind_ok = policy.ip_type.is_none_or(|k| e.kind == k);
geo_ok && kind_ok
})
.collect();
match matches.len() {
0 => EgressChoice::Unavailable,
n => EgressChoice::Use(Arc::clone(&matches[fastrand::usize(0..n)].fetcher)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::HttpFetcher;
fn cc(s: &str) -> CountryCode {
CountryCode::new(s).expect("valid country code")
}
fn dummy_fetcher() -> Arc<HttpFetcher> {
Arc::new(HttpFetcher::new(reqwest::Client::new()))
}
fn pool() -> EgressPool {
EgressPool::new(vec![
(Some(cc("pl")), EgressKind::Residential, dummy_fetcher()),
(Some(cc("de")), EgressKind::Datacenter, dummy_fetcher()),
])
}
#[test]
fn country_code_normalises_and_rejects() {
assert_eq!(CountryCode::new("PL").unwrap().as_str(), "pl");
assert!(CountryCode::new("p").is_none());
assert!(CountryCode::new("pol").is_none());
assert!(CountryCode::new("p1").is_none());
}
#[test]
fn unconstrained_policy_uses_default_egress() {
let choice = pool().select(&AccessPolicy::default());
assert!(matches!(choice, EgressChoice::Default));
}
#[test]
fn geo_match_picks_an_egress() {
let policy = AccessPolicy {
geo: vec![cc("pl")],
ip_type: None,
session: None,
};
assert!(matches!(pool().select(&policy), EgressChoice::Use(_)));
}
#[test]
fn ip_type_match_picks_an_egress() {
let policy = AccessPolicy {
geo: Vec::new(),
ip_type: Some(EgressKind::Datacenter),
session: None,
};
assert!(matches!(pool().select(&policy), EgressChoice::Use(_)));
}
#[test]
fn geo_present_but_wrong_kind_is_unavailable() {
let policy = AccessPolicy {
geo: vec![cc("pl")],
ip_type: Some(EgressKind::Mobile),
session: None,
};
assert!(matches!(pool().select(&policy), EgressChoice::Unavailable));
}
#[test]
fn unknown_geo_is_unavailable() {
let policy = AccessPolicy {
geo: vec![cc("jp")],
ip_type: None,
session: None,
};
assert!(matches!(pool().select(&policy), EgressChoice::Unavailable));
}
#[test]
fn empty_pool_with_constraint_is_unavailable() {
let empty = EgressPool::new(Vec::new());
let policy = AccessPolicy {
geo: vec![cc("pl")],
ip_type: None,
session: None,
};
assert!(matches!(empty.select(&policy), EgressChoice::Unavailable));
}
#[test]
fn session_apply_overrides_base_headers() {
let mut base = BTreeMap::new();
base.insert("X-IG-App-ID".to_string(), "936".to_string());
base.insert("Cookie".to_string(), "old".to_string());
let mut sh = BTreeMap::new();
sh.insert("Cookie".to_string(), "sessionid=real".to_string());
let merged = Session::from_headers(sh).apply(&base);
assert_eq!(merged.get("Cookie").unwrap(), "sessionid=real");
assert_eq!(merged.get("X-IG-App-ID").unwrap(), "936");
}
#[test]
fn session_store_insert_and_lookup() {
let mut store = SessionStore::new();
assert!(store.is_empty());
store.insert("ig", Session::from_headers(BTreeMap::new()));
assert!(!store.is_empty());
assert!(store.get("ig").is_some());
assert!(store.get("missing").is_none());
}
}