use bytes::Bytes;
use prost::Message;
use reqwest::{Client, Proxy, Response};
use safebrowsing_hash::HashPrefix;
use safebrowsing_proto::{
safebrowsing_proto, ClientInfo, FetchThreatListUpdatesRequest, FetchThreatListUpdatesResponse,
FindFullHashesRequest, FindFullHashesResponse, ThreatEntry, ThreatInfo,
};
use safebrowsing_proto::{
PlatformType as ProtoPlatformType, ThreatEntryType as ProtoThreatEntryType,
ThreatType as ProtoThreatType,
};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::Duration;
use thiserror::Error;
use tracing::{debug, error};
pub const API_BASE_URL: &str = "https://safebrowsing.googleapis.com";
const THREAT_LIST_UPDATES_PATH: &str = "/v4/threatListUpdates:fetch";
const FULL_HASHES_PATH: &str = "/v4/fullHashes:find";
#[derive(Error, Debug)]
pub enum ApiError {
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Authentication error: {0}")]
Authentication(String),
#[error("API quota exceeded")]
QuotaExceeded,
#[error("Rate limited, retry after {retry_after:?}")]
RateLimit { retry_after: Option<Duration> },
#[error("Server unavailable: {0}")]
ServerUnavailable(String),
#[error("HTTP error {status}: {message}")]
HttpStatus { status: u16, message: String },
}
#[derive(Error, Debug)]
pub enum Error {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error: {0}")]
Api(#[from] ApiError),
#[error("Protobuf error: {0}")]
Protobuf(String),
#[error("Configuration error: {0}")]
Configuration(String),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone)]
pub struct ApiConfig {
pub api_key: String,
pub client_id: String,
pub client_version: String,
pub base_url: String,
pub proxy_url: Option<String>,
pub request_timeout: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ThreatDescriptor {
pub threat_type: ThreatType,
pub platform_type: PlatformType,
pub threat_entry_type: ThreatEntryType,
}
impl fmt::Display for ThreatDescriptor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}/{}/{}",
self.threat_type, self.platform_type, self.threat_entry_type
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ThreatType {
Unspecified,
Malware,
SocialEngineering,
UnwantedSoftware,
PotentiallyHarmfulApplication,
}
impl fmt::Display for ThreatType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unspecified => write!(f, "UNSPECIFIED"),
Self::Malware => write!(f, "MALWARE"),
Self::SocialEngineering => write!(f, "SOCIAL_ENGINEERING"),
Self::UnwantedSoftware => write!(f, "UNWANTED_SOFTWARE"),
Self::PotentiallyHarmfulApplication => write!(f, "POTENTIALLY_HARMFUL_APPLICATION"),
}
}
}
impl From<ThreatType> for i32 {
fn from(tt: ThreatType) -> i32 {
match tt {
ThreatType::Unspecified => ProtoThreatType::Unspecified as i32,
ThreatType::Malware => ProtoThreatType::Malware as i32,
ThreatType::SocialEngineering => ProtoThreatType::SocialEngineering as i32,
ThreatType::UnwantedSoftware => ProtoThreatType::UnwantedSoftware as i32,
ThreatType::PotentiallyHarmfulApplication => {
ProtoThreatType::PotentiallyHarmfulApplication as i32
}
}
}
}
impl From<i32> for ThreatType {
fn from(value: i32) -> Self {
match value {
x if x == ProtoThreatType::Malware as i32 => Self::Malware,
x if x == ProtoThreatType::SocialEngineering as i32 => Self::SocialEngineering,
x if x == ProtoThreatType::UnwantedSoftware as i32 => Self::UnwantedSoftware,
x if x == ProtoThreatType::PotentiallyHarmfulApplication as i32 => {
Self::PotentiallyHarmfulApplication
}
_ => Self::Unspecified,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PlatformType {
Unspecified,
Windows,
Linux,
Android,
OSX,
IOS,
AnyPlatform,
AllPlatforms,
Chrome,
}
impl fmt::Display for PlatformType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unspecified => write!(f, "UNSPECIFIED"),
Self::Windows => write!(f, "WINDOWS"),
Self::Linux => write!(f, "LINUX"),
Self::Android => write!(f, "ANDROID"),
Self::OSX => write!(f, "OSX"),
Self::IOS => write!(f, "IOS"),
Self::AnyPlatform => write!(f, "ANY_PLATFORM"),
Self::AllPlatforms => write!(f, "ALL_PLATFORMS"),
Self::Chrome => write!(f, "CHROME"),
}
}
}
impl From<PlatformType> for i32 {
fn from(pt: PlatformType) -> i32 {
match pt {
PlatformType::Unspecified => ProtoPlatformType::Unspecified as i32,
PlatformType::Windows => ProtoPlatformType::Windows as i32,
PlatformType::Linux => ProtoPlatformType::Linux as i32,
PlatformType::Android => ProtoPlatformType::Android as i32,
PlatformType::OSX => ProtoPlatformType::Osx as i32,
PlatformType::IOS => ProtoPlatformType::Ios as i32,
PlatformType::AnyPlatform => ProtoPlatformType::AnyPlatform as i32,
PlatformType::AllPlatforms => ProtoPlatformType::AllPlatforms as i32,
PlatformType::Chrome => ProtoPlatformType::Chrome as i32,
}
}
}
impl From<i32> for PlatformType {
fn from(value: i32) -> Self {
match value {
x if x == ProtoPlatformType::Windows as i32 => Self::Windows,
x if x == ProtoPlatformType::Linux as i32 => Self::Linux,
x if x == ProtoPlatformType::Android as i32 => Self::Android,
x if x == ProtoPlatformType::Osx as i32 => Self::OSX,
x if x == ProtoPlatformType::Ios as i32 => Self::IOS,
x if x == ProtoPlatformType::AnyPlatform as i32 => Self::AnyPlatform,
x if x == ProtoPlatformType::AllPlatforms as i32 => Self::AllPlatforms,
x if x == ProtoPlatformType::Chrome as i32 => Self::Chrome,
_ => Self::Unspecified,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ThreatEntryType {
Unspecified,
Url,
Executable,
IpRange,
}
impl fmt::Display for ThreatEntryType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unspecified => write!(f, "UNSPECIFIED"),
Self::Url => write!(f, "URL"),
Self::Executable => write!(f, "EXECUTABLE"),
Self::IpRange => write!(f, "IP_RANGE"),
}
}
}
impl From<ThreatEntryType> for i32 {
fn from(tet: ThreatEntryType) -> i32 {
match tet {
ThreatEntryType::Unspecified => ProtoThreatEntryType::Unspecified as i32,
ThreatEntryType::Url => ProtoThreatEntryType::Url as i32,
ThreatEntryType::Executable => ProtoThreatEntryType::Executable as i32,
ThreatEntryType::IpRange => ProtoThreatEntryType::IpRange as i32,
}
}
}
impl From<i32> for ThreatEntryType {
fn from(value: i32) -> Self {
match value {
x if x == ProtoThreatEntryType::Url as i32 => Self::Url,
x if x == ProtoThreatEntryType::Executable as i32 => Self::Executable,
x if x == ProtoThreatEntryType::IpRange as i32 => Self::IpRange,
_ => Self::Unspecified,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct URLThreat {
pub pattern: String,
pub threat_descriptor: ThreatDescriptor,
}
impl fmt::Display for URLThreat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.pattern, self.threat_descriptor)
}
}
#[derive(Clone)]
pub struct SafeBrowsingApi {
client: Client,
base_url: String,
api_key: String,
client_info: ClientInfo,
}
impl SafeBrowsingApi {
pub fn new(config: &ApiConfig) -> Result<Self> {
let mut client_builder = Client::builder()
.timeout(config.request_timeout)
.user_agent(format!("{}/{}", config.client_id, config.client_version))
.gzip(true);
if let Some(proxy_url) = &config.proxy_url {
let proxy = Proxy::all(proxy_url)
.map_err(|e| Error::Configuration(format!("Invalid proxy URL: {e}")))?;
client_builder = client_builder.proxy(proxy);
}
let client = client_builder
.build()
.map_err(|e| Error::Configuration(format!("Failed to create HTTP client: {e}")))?;
let client_info = ClientInfo {
client_id: config.client_id.clone(),
client_version: config.client_version.clone(),
};
Ok(Self {
client,
base_url: config.base_url.clone(),
api_key: config.api_key.clone(),
client_info,
})
}
pub async fn fetch_threat_list_update(
&self,
threat_descriptor: &ThreatDescriptor,
client_state: &[u8],
) -> Result<FetchThreatListUpdatesResponse> {
let request = FetchThreatListUpdatesRequest {
client: Some(self.client_info.clone()),
list_update_requests: vec![
safebrowsing_proto::fetch_threat_list_updates_request::ListUpdateRequest {
threat_type: threat_descriptor.threat_type.into(),
platform_type: threat_descriptor.platform_type.into(),
threat_entry_type: threat_descriptor.threat_entry_type.into(),
state: client_state.to_vec().into(),
constraints: Some(
safebrowsing_proto::fetch_threat_list_updates_request::list_update_request::Constraints {
max_update_entries: 0, max_database_entries: 0, region: String::new(),
supported_compressions: vec![
safebrowsing_proto::CompressionType::Raw as i32,
safebrowsing_proto::CompressionType::Rice as i32,
],
},
),
},
],
};
self.post_protobuf(THREAT_LIST_UPDATES_PATH, &request).await
}
pub async fn find_full_hashes(
&self,
hash_prefix: &HashPrefix,
threat_descriptors: &[ThreatDescriptor],
) -> Result<FindFullHashesResponse> {
let threat_entries = vec![ThreatEntry {
hash: Bytes::copy_from_slice(hash_prefix.as_bytes()),
url: String::new(),
}];
let threat_types: Vec<i32> = threat_descriptors
.iter()
.map(|td| td.threat_type.into())
.collect();
let platform_types: Vec<i32> = threat_descriptors
.iter()
.map(|td| td.platform_type.into())
.collect();
let threat_entry_types: Vec<i32> = threat_descriptors
.iter()
.map(|td| td.threat_entry_type.into())
.collect();
let request = FindFullHashesRequest {
client: Some(self.client_info.clone()),
client_states: Vec::new(),
threat_info: Some(ThreatInfo {
threat_types,
platform_types,
threat_entry_types,
threat_entries,
}),
};
self.post_protobuf(FULL_HASHES_PATH, &request).await
}
async fn post_protobuf<T, R>(&self, path: &str, request: &T) -> Result<R>
where
T: Message,
R: Message + Default,
{
let url = format!("{}{}?key={}&alt=proto", self.base_url, path, self.api_key);
let mut buf = Vec::new();
prost::Message::encode(request, &mut buf).map_err(|e| Error::Protobuf(e.to_string()))?;
debug!("Making API request to: {}", url);
debug!("Request size: {} bytes", buf.len());
let response = self
.client
.post(&url)
.header("Content-Type", "application/x-protobuf")
.body(buf)
.send()
.await
.map_err(Error::Http)?;
self.handle_response(response).await
}
async fn handle_response<R>(&self, response: Response) -> Result<R>
where
R: Message + Default,
{
let status = response.status();
let headers = response.headers().clone();
debug!("API response status: {}", status);
if !status.is_success() {
let body = response
.text()
.await
.unwrap_or_else(|_| "Failed to read response body".to_string());
let api_error = match status.as_u16() {
400 => ApiError::BadRequest(body),
401 => ApiError::Authentication("Invalid API key".to_string()),
403 => ApiError::QuotaExceeded,
429 => {
let retry_after = headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs);
ApiError::RateLimit { retry_after }
}
503 => ApiError::ServerUnavailable("Service temporarily unavailable".to_string()),
_ => ApiError::HttpStatus {
status: status.as_u16(),
message: body,
},
};
return Err(Error::Api(api_error));
}
let body = response.bytes().await.map_err(Error::Http)?;
debug!("Response size: {} bytes", body.len());
prost::Message::decode(body).map_err(|e| Error::Protobuf(e.to_string()))
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn client_info(&self) -> &ClientInfo {
&self.client_info
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_threat_descriptor_display() {
let td = ThreatDescriptor {
threat_type: ThreatType::Malware,
platform_type: PlatformType::AnyPlatform,
threat_entry_type: ThreatEntryType::Url,
};
assert_eq!(format!("{td}"), "MALWARE/ANY_PLATFORM/URL");
}
#[test]
fn test_threat_type_conversions() {
assert_eq!(
i32::from(ThreatType::Malware),
safebrowsing_proto::ThreatType::Malware as i32
);
assert_eq!(
ThreatType::from(safebrowsing_proto::ThreatType::Malware as i32),
ThreatType::Malware
);
}
#[test]
fn test_platform_type_conversions() {
assert_eq!(
i32::from(PlatformType::AnyPlatform),
safebrowsing_proto::PlatformType::AnyPlatform as i32
);
assert_eq!(
PlatformType::from(safebrowsing_proto::PlatformType::AnyPlatform as i32),
PlatformType::AnyPlatform
);
}
#[test]
fn test_threat_entry_type_conversions() {
assert_eq!(
i32::from(ThreatEntryType::Url),
safebrowsing_proto::ThreatEntryType::Url as i32
);
assert_eq!(
ThreatEntryType::from(safebrowsing_proto::ThreatEntryType::Url as i32),
ThreatEntryType::Url
);
}
}