bimp-persona 0.0.1

Browser persona configuration and resolved fingerprint values for Bimp.
Documentation
use serde::{Deserialize, Serialize};
use std::{
    env, fs,
    path::{Path, PathBuf},
};

use crate::error::PersonaConfigError;
use crate::fingerprint::*;
use crate::options::*;
use crate::seed::{BrowserPersonaPreset, PersonaSeed};

/// Default persona JSON path used when `BIMP_PERSONA_JSON` is unset.
pub const DEFAULT_PERSONA_JSON: &str = "persona.json";
/// Environment variable that overrides the persona JSON path.
pub const PERSONA_JSON_ENV: &str = "BIMP_PERSONA_JSON";

/// User-provided persona configuration loaded from JSON.
///
/// Unspecified fields inherit the selected preset. Call [`Self::resolve`] to
/// turn optional overrides into a complete [`ResolvedPersona`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersonaConfig {
    #[serde(default)]
    pub preset: BrowserPersonaPreset,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub startup_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seed: Option<PersonaSeed>,
    #[serde(default)]
    pub viewport: Viewport,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub screen: Option<ScreenConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window: Option<WindowConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub graphics: Option<GraphicsConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub navigator: Option<NavigatorConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub css: Option<CssConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub media: Option<MediaConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fonts: Option<FontConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub canvas: Option<CanvasConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub domrect: Option<DomRectConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub engine: Option<EngineConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub locale: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub languages: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub accept_language: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timezone: Option<String>,
}

impl Default for PersonaConfig {
    fn default() -> Self {
        Self {
            preset: BrowserPersonaPreset::ChromeStable,
            startup_url: None,
            seed: None,
            viewport: Viewport::default(),
            screen: None,
            window: None,
            graphics: None,
            navigator: None,
            css: None,
            media: None,
            fonts: None,
            canvas: None,
            domrect: None,
            engine: None,
            locale: None,
            languages: None,
            accept_language: None,
            timezone: None,
        }
    }
}

impl PersonaConfig {
    /// Loads persona configuration from a JSON file.
    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self, PersonaConfigError> {
        let contents = fs::read_to_string(path)?;
        Ok(serde_json::from_str(&contents)?)
    }

    /// Returns the default persona JSON path for this process.
    pub fn default_json_path() -> PathBuf {
        env::var_os(PERSONA_JSON_ENV)
            .map(PathBuf::from)
            .unwrap_or_else(|| PathBuf::from(DEFAULT_PERSONA_JSON))
    }

    /// Loads the default persona JSON file if it exists.
    pub fn load_default_json() -> Result<Option<Self>, PersonaConfigError> {
        let path = Self::default_json_path();
        if !path.exists() {
            return Ok(None);
        }
        Self::from_json_file(path).map(Some)
    }

    /// Resolves this partial configuration into complete fingerprint values.
    pub fn resolve(&self) -> ResolvedPersona {
        let seed = self.seed.clone().unwrap_or_else(|| {
            PersonaSeed::from_stable_input(format!(
                "preset:{:?}:{}x{}@{}",
                self.preset,
                self.viewport.width,
                self.viewport.height,
                self.viewport.device_scale_factor
            ))
        });
        let mut persona = ResolvedPersona::from_preset_and_seed(self.preset, seed);
        persona.startup_url = self
            .startup_url
            .as_ref()
            .map(|value| value.trim().to_string())
            .filter(|value| !value.is_empty());
        persona.viewport = self.viewport.clone();
        persona.screen = ScreenFingerprint::from_viewport(&persona.viewport);
        if let Some(screen) = &self.screen {
            screen.apply_to(&mut persona.screen);
        }
        persona.window = WindowFingerprint::from_viewport(&persona.viewport);
        if let Some(window) = &self.window {
            window.apply_to(&mut persona.window);
        }
        if let Some(graphics) = &self.graphics {
            graphics.apply_to(&mut persona.graphics);
        }
        self.apply_locale_timezone(&mut persona);
        if let Some(navigator) = &self.navigator {
            navigator.apply_to(&mut persona.js);
            if self.accept_language.is_none() && !persona.js.languages.is_empty() {
                persona.network.accept_language = format_accept_language(&persona.js.languages);
            }
        }
        if let Some(css) = &self.css {
            css.apply_to(&mut persona.css);
        }
        if let Some(media) = &self.media {
            media.apply_to(&mut persona.media);
        }
        if let Some(fonts) = &self.fonts {
            fonts.apply_to(&mut persona.fonts);
        }
        if let Some(canvas) = &self.canvas {
            canvas.apply_to(&mut persona.canvas);
        }
        if let Some(domrect) = &self.domrect {
            domrect.apply_to(&mut persona.domrect);
        }
        if let Some(engine) = &self.engine {
            engine.apply_to(&mut persona.engine);
        }
        persona
    }

    fn apply_locale_timezone(&self, persona: &mut ResolvedPersona) {
        let mut languages = self
            .languages
            .clone()
            .unwrap_or_else(|| parse_accept_language(self.accept_language.as_deref()));
        if languages.is_empty()
            && let Some(locale) = self.locale.as_ref().filter(|value| !value.is_empty())
        {
            languages.push(locale.clone());
            if let Some(primary) = primary_language_subtag(locale)
                && primary != *locale
            {
                languages.push(primary);
            }
        }

        if let Some(locale) = self.locale.as_ref().filter(|value| !value.is_empty()) {
            persona.js.language = locale.clone();
            persona.geo.locale = locale.clone();
        } else if let Some(locale) = languages.first() {
            persona.js.language = locale.clone();
            persona.geo.locale = locale.clone();
        }

        if !languages.is_empty() {
            persona.js.languages = languages.clone();
        }

        if let Some(accept_language) = self
            .accept_language
            .as_ref()
            .filter(|value| !value.is_empty())
        {
            persona.network.accept_language = accept_language.clone();
        } else if !persona.js.languages.is_empty() {
            persona.network.accept_language = format_accept_language(&persona.js.languages);
        }

        if let Some(timezone) = self.timezone.as_ref().filter(|value| !value.is_empty()) {
            persona.js.timezone = timezone.clone();
            persona.geo.timezone = timezone.clone();
        }
    }
}

fn primary_language_subtag(locale: &str) -> Option<String> {
    locale
        .split(['-', '_'])
        .next()
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
}

fn parse_accept_language(header: Option<&str>) -> Vec<String> {
    header
        .unwrap_or_default()
        .split(',')
        .filter_map(|entry| {
            entry
                .split(';')
                .next()
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToString::to_string)
        })
        .collect()
}

fn format_accept_language(languages: &[String]) -> String {
    languages
        .iter()
        .enumerate()
        .map(|(index, language)| {
            if index == 0 {
                language.clone()
            } else {
                let quality = (10usize.saturating_sub(index)).max(1);
                format!("{language};q=0.{quality}")
            }
        })
        .collect::<Vec<_>>()
        .join(",")
}