diff --git a/cli/src/main.rs b/cli/src/main.rs index 5c327dc9..071dad8e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -250,6 +250,7 @@ async fn create_execution_context( if let Some(config_path) = args.config_path() { let config = FastlyConfig::from_file(config_path)?; let backends = config.backends(); + let device_detection = config.device_detection(); let geolocation = config.geolocation(); let dictionaries = config.dictionaries(); let object_stores = config.object_stores(); @@ -258,6 +259,7 @@ async fn create_execution_context( ctx = ctx .with_backends(backends.clone()) + .with_device_detection(device_detection.clone()) .with_geolocation(geolocation.clone()) .with_dictionaries(dictionaries.clone()) .with_object_stores(object_stores.clone()) diff --git a/cli/tests/integration/common.rs b/cli/tests/integration/common.rs index 2b6d42dd..b1a4b498 100644 --- a/cli/tests/integration/common.rs +++ b/cli/tests/integration/common.rs @@ -11,7 +11,9 @@ use tracing_subscriber::filter::EnvFilter; use viceroy_lib::config::UnknownImportBehavior; use viceroy_lib::{ body::Body, - config::{Dictionaries, FastlyConfig, Geolocation, ObjectStores, SecretStores}, + config::{ + DeviceDetection, Dictionaries, FastlyConfig, Geolocation, ObjectStores, SecretStores, + }, ExecuteCtx, ProfilingStrategy, ViceroyService, }; @@ -49,6 +51,7 @@ pub type TestResult = Result<(), Error>; pub struct Test { module_path: PathBuf, backends: TestBackends, + device_detection: DeviceDetection, dictionaries: Dictionaries, geolocation: Geolocation, object_stores: ObjectStores, @@ -68,6 +71,7 @@ impl Test { Self { module_path, backends: TestBackends::new(), + device_detection: DeviceDetection::new(), dictionaries: Dictionaries::new(), geolocation: Geolocation::new(), object_stores: ObjectStores::new(), @@ -87,6 +91,7 @@ impl Test { Self { module_path, backends: TestBackends::new(), + device_detection: DeviceDetection::new(), dictionaries: Dictionaries::new(), geolocation: Geolocation::new(), object_stores: ObjectStores::new(), @@ -103,6 +108,7 @@ impl Test { let config = fastly_toml.parse::()?; Ok(Self { backends: TestBackends::from_backend_configs(config.backends()), + device_detection: config.device_detection().to_owned(), dictionaries: config.dictionaries().to_owned(), geolocation: config.geolocation().to_owned(), object_stores: config.object_stores().to_owned(), @@ -279,6 +285,7 @@ impl Test { )? .with_backends(self.backends.backend_configs().await) .with_dictionaries(self.dictionaries.clone()) + .with_device_detection(self.device_detection.clone()) .with_geolocation(self.geolocation.clone()) .with_object_stores(self.object_stores.clone()) .with_secret_stores(self.secret_stores.clone()) diff --git a/cli/tests/integration/device_detection_lookup.rs b/cli/tests/integration/device_detection_lookup.rs new file mode 100644 index 00000000..d3cb4084 --- /dev/null +++ b/cli/tests/integration/device_detection_lookup.rs @@ -0,0 +1,66 @@ +// use crate::common::{Test, TestResult}; +// use hyper::{body::to_bytes, StatusCode}; + +// #[tokio::test(flavor = "multi_thread")] +// async fn json_device_detection_lookup_works() -> TestResult { +// const FASTLY_TOML: &str = r#" +// name = "json-device-detection-lookup" +// description = "json device detection lookup test" +// authors = ["Test User "] +// language = "rust" +// [local_server] +// [local_server.device_detection] +// file = "../test-fixtures/data/device-detection-mapping.json" +// format = "json" +// "#; + +// let resp = Test::using_fixture("device-detection-lookup.wasm") +// .using_fastly_toml(FASTLY_TOML)? +// .against_empty() +// .await?; + +// assert_eq!(resp.status(), StatusCode::OK); +// assert!(to_bytes(resp.into_body()) +// .await +// .expect("can read body") +// .to_vec() +// .is_empty()); + +// Ok(()) +// } + +// #[tokio::test(flavor = "multi_thread")] +// async fn inline_toml_device_detection_lookup_works() -> TestResult { +// const FASTLY_TOML: &str = r#" +// name = "inline-toml-device-detection-lookup" +// description = "inline toml device detection lookup test" +// authors = ["Test User "] +// language = "rust" +// [local_server] +// [local_server.device_detection] +// format = "inline-toml" +// [local_server.device_detection.user_agents] +// [local_server.device_detection.user_agents."Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20100101 Firefox/10.0 [FBAN/FBIOS;FBAV/8.0.0.28.18;FBBV/1665515;FBDV/iPhone4,1;FBMD/iPhone;FBSN/iPhone OS;FBSV/7.0.4;FBSS/2; FBCR/Telekom.de;FBID/phone;FBLC/de_DE;FBOP/5]"] +// user_agent = {} +// os = {} +// device = {name = "iPhone", brand = "Apple", model = "iPhone4,1", hwtype = "Mobile Phone", is_ereader = false, is_gameconsole = false, is_mediaplayer = false, is_mobile = true, is_smarttv = false, is_tablet = false, is_tvplayer = false, is_desktop = false, is_touchscreen = true } +// [local_server.device_detection.user_agents."ghosts-app/1.0.2.1 (ASUSTeK COMPUTER INC.; X550CC; Windows 8 (X86); en)"] +// user_agent = {} +// os = {} +// device = {name = "Asus TeK", brand = "Asus", model = "TeK", is_desktop = false } +// "#; + +// let resp = Test::using_fixture("device-detection-lookup.wasm") +// .using_fastly_toml(FASTLY_TOML)? +// .against_empty() +// .await?; + +// assert_eq!(resp.status(), StatusCode::OK); +// assert!(to_bytes(resp.into_body()) +// .await +// .expect("can read body") +// .to_vec() +// .is_empty()); + +// Ok(()) +// } diff --git a/cli/tests/integration/main.rs b/cli/tests/integration/main.rs index d8020f22..95a3eab9 100644 --- a/cli/tests/integration/main.rs +++ b/cli/tests/integration/main.rs @@ -2,6 +2,7 @@ mod async_io; mod body; mod client_certs; mod common; +mod device_detection_lookup; mod dictionary_lookup; mod downstream_req; mod env_vars; diff --git a/lib/compute-at-edge-abi/compute-at-edge.witx b/lib/compute-at-edge-abi/compute-at-edge.witx index 05409d55..953562c9 100644 --- a/lib/compute-at-edge-abi/compute-at-edge.witx +++ b/lib/compute-at-edge-abi/compute-at-edge.witx @@ -629,6 +629,17 @@ ) ) +(module $fastly_device_detection + (@interface func (export "lookup") + (param $user_agent string) + + (param $buf (@witx pointer (@witx char8))) + (param $buf_len (@witx usize)) + (param $nwritten_out (@witx pointer (@witx usize))) + (result $err (expected (error $fastly_status))) + ) +) + (module $fastly_object_store (@interface func (export "open") (param $name string) diff --git a/lib/src/config.rs b/lib/src/config.rs index 04fe9760..370c7a24 100644 --- a/lib/src/config.rs +++ b/lib/src/config.rs @@ -33,6 +33,11 @@ pub use self::backends::{Backend, ClientCertError, ClientCertInfo}; pub type Backends = HashMap>; +/// Types and deserializers for device detection configuration settings. +mod device_detection; + +pub use self::device_detection::DeviceDetection; + /// Types and deserializers for geolocation configuration settings. mod geolocation; @@ -85,6 +90,12 @@ impl FastlyConfig { &self.local_server.backends.0 } + /// Get the device detection configuration. + pub fn device_detection(&self) -> &DeviceDetection { + &self.local_server.device_detection + } + + /// Get the geolocation configuration. pub fn geolocation(&self) -> &Geolocation { &self.local_server.geolocation } @@ -182,6 +193,7 @@ impl TryInto for TomlFastlyConfig { #[derive(Clone, Debug, Default)] pub struct LocalServerConfig { backends: BackendsConfig, + device_detection: DeviceDetection, geolocation: Geolocation, dictionaries: DictionariesConfig, object_stores: ObjectStoreConfig, @@ -201,6 +213,7 @@ pub enum ExperimentalModule { #[derive(Deserialize)] struct RawLocalServerConfig { backends: Option, + device_detection: Option
, geolocation: Option
, #[serde(alias = "config_stores")] dictionaries: Option
, @@ -214,6 +227,7 @@ impl TryInto for RawLocalServerConfig { fn try_into(self) -> Result { let Self { backends, + device_detection, geolocation, dictionaries, object_stores, @@ -224,6 +238,11 @@ impl TryInto for RawLocalServerConfig { } else { BackendsConfig::default() }; + let device_detection = if let Some(device_detection) = device_detection { + device_detection.try_into()? + } else { + DeviceDetection::default() + }; let geolocation = if let Some(geolocation) = geolocation { geolocation.try_into()? } else { @@ -247,6 +266,7 @@ impl TryInto for RawLocalServerConfig { Ok(LocalServerConfig { backends, + device_detection, geolocation, dictionaries, object_stores, diff --git a/lib/src/config/device_detection.rs b/lib/src/config/device_detection.rs new file mode 100644 index 00000000..ab4662f0 --- /dev/null +++ b/lib/src/config/device_detection.rs @@ -0,0 +1,259 @@ +use { + crate::error::DeviceDetectionConfigError, + serde_json::{Map, Value as SerdeValue}, + std::{collections::HashMap, fs, iter::FromIterator, path::Path, path::PathBuf}, +}; + +#[derive(Clone, Debug, Default)] +pub struct DeviceDetection { + mapping: DeviceDetectionMapping, +} + +#[derive(Clone, Debug)] +pub enum DeviceDetectionMapping { + Empty, + InlineToml { + user_agents: HashMap, + }, + Json { + file: PathBuf, + }, +} + +#[derive(Clone, Debug)] +pub struct DeviceDetectionData { + data: Map, +} + +impl DeviceDetection { + pub fn new() -> Self { + Self::default() + } + + pub fn lookup(&self, user_agent: &str) -> Option { + self.mapping.get(user_agent).or(None) + } +} + +mod deserialization { + use serde_json::{Map, Number}; + + use { + super::{DeviceDetection, DeviceDetectionData, DeviceDetectionMapping}, + crate::error::{DeviceDetectionConfigError, FastlyConfigError}, + serde_json::Value as SerdeValue, + std::path::PathBuf, + std::{collections::HashMap, convert::TryFrom}, + toml::value::{Table, Value}, + }; + + impl TryFrom
for DeviceDetection { + type Error = FastlyConfigError; + + fn try_from(toml: Table) -> Result { + fn process_config( + mut toml: Table, + ) -> Result { + let mapping = match toml.remove("format") { + Some(Value::String(value)) => match value.as_str() { + "inline-toml" => process_inline_toml_dictionary(&mut toml)?, + "json" => process_json_entries(&mut toml)?, + "" => return Err(DeviceDetectionConfigError::EmptyFormatEntry), + format => { + return Err( + DeviceDetectionConfigError::InvalidDeviceDetectionMappingFormat( + format.to_string(), + ), + ) + } + }, + Some(_) => return Err(DeviceDetectionConfigError::InvalidFormatEntry), + None => DeviceDetectionMapping::Empty, + }; + + Ok(DeviceDetection { mapping }) + } + + process_config(toml).map_err(|err| { + FastlyConfigError::InvalidDeviceDetectionDefinition { + name: "device_detection_mapping".to_string(), + err, + } + }) + } + } + + fn process_inline_toml_dictionary( + toml: &mut Table, + ) -> Result { + fn convert_value_to_json(value: Value) -> Option { + match value { + Value::String(value) => Some(SerdeValue::String(value)), + Value::Integer(value) => Number::try_from(value).ok().map(SerdeValue::Number), + Value::Float(value) => Number::from_f64(value).map(SerdeValue::Number), + Value::Boolean(value) => Some(SerdeValue::Bool(value)), + Value::Table(value) => { + let mut map = Map::new(); + for (k, v) in value { + map.insert(k, convert_value_to_json(v)?); + } + Some(SerdeValue::Object(map)) + } + _ => None, + } + } + + // Take the `user_agents` field from the provided TOML table. + let toml = match toml + .remove("user_agents") + .ok_or(DeviceDetectionConfigError::MissingUserAgents)? + { + Value::Table(table) => table, + _ => return Err(DeviceDetectionConfigError::InvalidUserAgentsType), + }; + + let mut user_agents = HashMap::::with_capacity(toml.len()); + + for (user_agent, value) in toml { + let user_agent = user_agent.to_string(); + let table = value + .as_table() + .ok_or(DeviceDetectionConfigError::InvalidInlineEntryType)? + .to_owned(); + + let mut device_detection_data = DeviceDetectionData::new(); + + for (field, value) in table { + let value = convert_value_to_json(value) + .ok_or(DeviceDetectionConfigError::InvalidInlineEntryType)?; + device_detection_data.insert(field, value); + } + + user_agents.insert(user_agent, device_detection_data); + } + + Ok(DeviceDetectionMapping::InlineToml { user_agents }) + } + + fn process_json_entries( + toml: &mut Table, + ) -> Result { + let file: PathBuf = match toml + .remove("file") + .ok_or(DeviceDetectionConfigError::MissingFile)? + { + Value::String(file) => { + if file.is_empty() { + return Err(DeviceDetectionConfigError::EmptyFileEntry); + } else { + file.into() + } + } + _ => return Err(DeviceDetectionConfigError::InvalidFileEntry), + }; + + DeviceDetectionMapping::read_json_contents(&file)?; + + Ok(DeviceDetectionMapping::Json { file }) + } +} + +impl Default for DeviceDetectionMapping { + fn default() -> Self { + Self::Empty + } +} + +impl DeviceDetectionMapping { + pub fn get(&self, user_agent: &str) -> Option { + match self { + Self::Empty => None, + Self::InlineToml { user_agents } => user_agents + .get(user_agent) + .map(|device_detection_data| device_detection_data.to_owned()), + Self::Json { file } => Self::read_json_contents(file) + .ok() + .map(|user_agents| { + user_agents + .get(user_agent) + .map(|device_detection_data| device_detection_data.to_owned()) + }) + .unwrap(), + } + } + + pub fn read_json_contents( + file: &Path, + ) -> Result, DeviceDetectionConfigError> { + let data = fs::read_to_string(file).map_err(DeviceDetectionConfigError::IoError)?; + + // Deserialize the contents of the given JSON file. + let json = match serde_json::from_str(&data) + .map_err(|_| DeviceDetectionConfigError::DeviceDetectionFileWrongFormat)? + { + // Check that we were given an object. + serde_json::Value::Object(obj) => obj, + _ => { + return Err(DeviceDetectionConfigError::DeviceDetectionFileWrongFormat); + } + }; + + let mut user_agents = HashMap::::with_capacity(json.len()); + + for (user_agent, value) in json { + let user_agent = user_agent.to_string(); + let table = value + .as_object() + .ok_or(DeviceDetectionConfigError::InvalidInlineEntryType)? + .to_owned(); + + let device_detection_data = DeviceDetectionData::from(&table); + + user_agents.insert(user_agent, device_detection_data); + } + + Ok(user_agents) + } +} + +impl Default for DeviceDetectionData { + fn default() -> Self { + Self::from(HashMap::new()) + } +} + +impl From> for DeviceDetectionData { + fn from(value: HashMap<&str, SerdeValue>) -> Self { + let entries = value + .iter() + .map(|(&field, value)| (field.to_string(), value.to_owned())); + + Self { + data: Map::from_iter(entries), + } + } +} + +impl From<&Map> for DeviceDetectionData { + fn from(data: &Map) -> Self { + Self { + data: data.to_owned(), + } + } +} + +impl DeviceDetectionData { + pub fn new() -> Self { + Self { data: Map::new() } + } + + pub fn insert(&mut self, field: String, value: SerdeValue) { + self.data.insert(field, value); + } +} + +impl ToString for DeviceDetectionData { + fn to_string(&self) -> String { + serde_json::to_string(&self.data).unwrap_or_else(|_| "".to_string()) + } +} diff --git a/lib/src/config/unit_tests.rs b/lib/src/config/unit_tests.rs index e4bb4e89..8f35c397 100644 --- a/lib/src/config/unit_tests.rs +++ b/lib/src/config/unit_tests.rs @@ -847,6 +847,38 @@ mod inline_toml_dictionary_config_tests { } } +/// Unit tests for Device Detection mapping in the `local_server` section of a `fastly.toml` package manifest. +/// +/// These tests check that we deserialize and validate the Device Detection mappings section of +/// the TOML data properly regardless of the format. +mod device_detection_config_tests { + use { + super::read_local_server_config, + crate::error::{ + DeviceDetectionConfigError, FastlyConfigError::InvalidDeviceDetectionDefinition, + }, + }; + + /// Check that Device Detection definitions have a valid `format`. + #[test] + fn device_detection_has_a_valid_format() { + use DeviceDetectionConfigError::InvalidDeviceDetectionMappingFormat; + let invalid_format_field = r#" + [device_detection] + format = "foo" + [device_detection.user_agent."Test User-Agent"] + hwtype = "Test" + "#; + match read_local_server_config(invalid_format_field) { + Err(InvalidDeviceDetectionDefinition { + err: InvalidDeviceDetectionMappingFormat(format), + .. + }) if format == "foo" => {} + res => panic!("unexpected result: {:?}", res), + } + } +} + /// Unit tests for Geolocation mapping in the `local_server` section of a `fastly.toml` package manifest. /// /// These tests check that we deserialize and validate the Geolocation mappings section of diff --git a/lib/src/error.rs b/lib/src/error.rs index 9d726149..81ecabcf 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -89,6 +89,9 @@ pub enum Error { #[error(transparent)] DictionaryError(#[from] crate::wiggle_abi::DictionaryError), + #[error(transparent)] + DeviceDetectionError(#[from] crate::wiggle_abi::DeviceDetectionError), + #[error(transparent)] GeolocationError(#[from] crate::wiggle_abi::GeolocationError), @@ -180,6 +183,7 @@ impl Error { Error::GuestError(e) => Self::guest_error_fastly_status(e), // We delegate to some error types' own implementation of `to_fastly_status`. Error::DictionaryError(e) => e.to_fastly_status(), + Error::DeviceDetectionError(e) => e.to_fastly_status(), Error::GeolocationError(e) => e.to_fastly_status(), Error::ObjectStoreError(e) => e.into(), Error::SecretStoreError(e) => e.into(), @@ -331,6 +335,13 @@ pub enum FastlyConfigError { err: std::io::Error, }, + #[error("invalid configuration for '{name}': {err}")] + InvalidDeviceDetectionDefinition { + name: String, + #[source] + err: DeviceDetectionConfigError, + }, + #[error("invalid configuration for '{name}': {err}")] InvalidGeolocationDefinition { name: String, @@ -495,6 +506,58 @@ pub enum DictionaryConfigError { DictionaryFileWrongFormat, } +/// Errors that may occur while validating device detection configurations. +#[derive(Debug, thiserror::Error)] +pub enum DeviceDetectionConfigError { + /// An I/O error that occured while reading the file. + #[error(transparent)] + IoError(std::io::Error), + + #[error("definition was not provided as a TOML table")] + InvalidEntryType, + + #[error("missing 'file' field")] + MissingFile, + + #[error("'file' field is empty")] + EmptyFileEntry, + + #[error("missing 'user_agents' field")] + MissingUserAgents, + + #[error("inline device detection value was not a string")] + InvalidInlineEntryType, + + #[error("'file' field was not a string")] + InvalidFileEntry, + + #[error("'user_agents' was not provided as a TOML table")] + InvalidUserAgentsType, + + #[error("unrecognized key '{0}'")] + UnrecognizedKey(String), + + #[error("missing 'format' field")] + MissingFormat, + + #[error("'format' field was not a string")] + InvalidFormatEntry, + + #[error("'{0}' is not a valid format for the device detection mapping. Supported format(s) are: 'inline-toml', 'json'.")] + InvalidDeviceDetectionMappingFormat(String), + + #[error( + "The file is of the wrong format. The file is expected to contain a single JSON Object" + )] + DeviceDetectionFileWrongFormat, + + #[error("'format' field is empty")] + EmptyFormatEntry, + + #[error("Item value under key named '{key}' is of the wrong format. The value is expected to be a JSON String")] + DeviceDetectionItemValueWrongFormat { key: String }, +} + /// Errors that may occur while validating geolocation configurations. #[derive(Debug, thiserror::Error)] pub enum GeolocationConfigError { diff --git a/lib/src/execute.rs b/lib/src/execute.rs index 23707e12..bfbec0fe 100644 --- a/lib/src/execute.rs +++ b/lib/src/execute.rs @@ -9,7 +9,7 @@ use crate::config::UnknownImportBehavior; use { crate::{ body::Body, - config::{Backends, Dictionaries, ExperimentalModule, Geolocation}, + config::{Backends, DeviceDetection, Dictionaries, ExperimentalModule, Geolocation}, downstream::prepare_request, error::ExecutionError, linking::{create_store, link_host_functions, WasmCtx}, @@ -51,6 +51,8 @@ pub struct ExecuteCtx { module: Module, /// The backends for this execution. backends: Arc, + /// The device detection mappings for this execution. + device_detection: Arc, /// The geolocation mappings for this execution. geolocation: Arc, /// Preloaded TLS certificates and configuration @@ -118,6 +120,7 @@ impl ExecuteCtx { instance_pre: Arc::new(instance_pre), module, backends: Arc::new(Backends::default()), + device_detection: Arc::new(DeviceDetection::default()), geolocation: Arc::new(Geolocation::default()), tls_config: TlsConfig::new()?, dictionaries: Arc::new(Dictionaries::default()), @@ -149,6 +152,17 @@ impl ExecuteCtx { self } + /// Get the device detection mappings for this execution context. + pub fn device_detection(&self) -> &DeviceDetection { + &self.device_detection + } + + /// Set the device detection mappings for this execution context. + pub fn with_device_detection(mut self, device_detection: DeviceDetection) -> Self { + self.device_detection = Arc::new(device_detection); + self + } + /// Get the geolocation mappings for this execution context. pub fn geolocation(&self) -> &Geolocation { &self.geolocation @@ -321,6 +335,7 @@ impl ExecuteCtx { sender, remote, self.backends.clone(), + self.device_detection.clone(), self.geolocation.clone(), self.tls_config.clone(), self.dictionaries.clone(), @@ -415,6 +430,7 @@ impl ExecuteCtx { sender, remote, self.backends.clone(), + self.device_detection.clone(), self.geolocation.clone(), self.tls_config.clone(), self.dictionaries.clone(), diff --git a/lib/src/linking.rs b/lib/src/linking.rs index 40cfa832..ea4eb616 100644 --- a/lib/src/linking.rs +++ b/lib/src/linking.rs @@ -154,6 +154,7 @@ pub fn link_host_functions( wiggle_abi::fastly_cache::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_config_store::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_dictionary::add_to_linker(linker, WasmCtx::session)?; + wiggle_abi::fastly_device_detection::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_geo::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_http_body::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_http_req::add_to_linker(linker, WasmCtx::session)?; diff --git a/lib/src/session.rs b/lib/src/session.rs index 554beacc..708bf29a 100644 --- a/lib/src/session.rs +++ b/lib/src/session.rs @@ -9,7 +9,10 @@ use { self::downstream::DownstreamResponse, crate::{ body::Body, - config::{Backend, Backends, Dictionaries, Dictionary, DictionaryName, Geolocation}, + config::{ + Backend, Backends, DeviceDetection, Dictionaries, Dictionary, DictionaryName, + Geolocation, + }, error::{Error, HandleError}, logging::LogEndpoint, object_store::{ObjectKey, ObjectStoreError, ObjectStoreKey, ObjectStores}, @@ -71,6 +74,10 @@ pub struct Session { /// /// Populated prior to guest execution, and never modified. backends: Arc, + /// The Device Detection configured for this execution. + /// + /// Populated prior to guest execution, and never modified. + device_detection: Arc, /// The Geolocations configured for this execution. /// /// Populated prior to guest execution, and never modified. @@ -128,6 +135,7 @@ impl Session { resp_sender: Sender>, client_ip: IpAddr, backends: Arc, + device_detection: Arc, geolocation: Arc, tls_config: TlsConfig, dictionaries: Arc, @@ -156,6 +164,7 @@ impl Session { log_endpoints: PrimaryMap::new(), log_endpoints_by_name: HashMap::new(), backends, + device_detection, geolocation, dynamic_backends: Backends::default(), tls_config, @@ -580,6 +589,14 @@ impl Session { &self.tls_config } + // ----- Device Detection API ----- + + pub fn device_detection_lookup(&self, user_agent: &str) -> Option { + self.device_detection + .lookup(user_agent) + .map(|data| data.to_string()) + } + // ----- Dictionaries API ----- /// Look up a dictionary-handle by name. diff --git a/lib/src/wiggle_abi.rs b/lib/src/wiggle_abi.rs index b22ad93f..8e1bee88 100644 --- a/lib/src/wiggle_abi.rs +++ b/lib/src/wiggle_abi.rs @@ -13,6 +13,7 @@ pub use self::dictionary_impl::DictionaryError; pub use self::secret_store_impl::SecretStoreError; +pub use self::device_detection_impl::DeviceDetectionError; pub use self::geo_impl::GeolocationError; use { @@ -52,6 +53,7 @@ mod backend_impl; mod body_impl; mod cache; mod config_store; +mod device_detection_impl; mod dictionary_impl; mod entity; mod fastly_purge_impl; diff --git a/lib/src/wiggle_abi/device_detection_impl.rs b/lib/src/wiggle_abi/device_detection_impl.rs new file mode 100644 index 00000000..287f46a5 --- /dev/null +++ b/lib/src/wiggle_abi/device_detection_impl.rs @@ -0,0 +1,64 @@ +//! fastly_device_detection` hostcall implementations. + +use crate::error::Error; +use crate::wiggle_abi::{fastly_device_detection::FastlyDeviceDetection, FastlyStatus, Session}; +use std::convert::TryFrom; +use wiggle::GuestPtr; + +#[derive(Debug, thiserror::Error)] +pub enum DeviceDetectionError { + /// Device detection data for given user_agent not found. + #[error("No device detection data: {0}")] + NoDeviceDetectionData(String), +} + +impl DeviceDetectionError { + /// Convert to an error code representation suitable for passing across the ABI boundary. + pub fn to_fastly_status(&self) -> FastlyStatus { + use DeviceDetectionError::*; + match self { + NoDeviceDetectionData(_) => FastlyStatus::None, + } + } +} + +impl FastlyDeviceDetection for Session { + fn lookup( + &mut self, + user_agent: &GuestPtr, + buf: &GuestPtr, + buf_len: u32, + nwritten_out: &GuestPtr, + ) -> Result<(), Error> { + let user_agent_slice = user_agent + .as_bytes() + .as_slice()? + .ok_or(Error::SharedMemory)?; + let user_agent_str = std::str::from_utf8(&user_agent_slice)?; + + let result = self + .device_detection_lookup(user_agent_str) + .ok_or_else(|| { + DeviceDetectionError::NoDeviceDetectionData(user_agent_str.to_string()) + })?; + + if result.len() > buf_len as usize { + return Err(Error::BufferLengthError { + buf: "device_detection_lookup", + len: "device_detection_lookup_max_len", + }); + } + + let result_len = + u32::try_from(result.len()).expect("smaller than buf_len means it must fit"); + + let mut buf_ptr = buf + .as_array(result_len) + .as_slice_mut()? + .ok_or(Error::SharedMemory)?; + + buf_ptr.copy_from_slice(result.as_bytes()); + nwritten_out.write(result_len)?; + Ok(()) + } +} diff --git a/test-fixtures/data/device-detection-mapping.json b/test-fixtures/data/device-detection-mapping.json new file mode 100644 index 00000000..87415960 --- /dev/null +++ b/test-fixtures/data/device-detection-mapping.json @@ -0,0 +1,31 @@ +{ + "Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20100101 Firefox/10.0 [FBAN/FBIOS;FBAV/8.0.0.28.18;FBBV/1665515;FBDV/iPhone4,1;FBMD/iPhone;FBSN/iPhone OS;FBSV/7.0.4;FBSS/2; FBCR/Telekom.de;FBID/phone;FBLC/de_DE;FBOP/5]": { + "user_agent": {}, + "os": {}, + "device": { + "name": "iPhone", + "brand": "Apple", + "model": "iPhone4,1", + "hwtype": "Mobile Phone", + "is_ereader": false, + "is_gameconsole": false, + "is_mediaplayer": false, + "is_mobile": true, + "is_smarttv": false, + "is_tablet": false, + "is_tvplayer": false, + "is_desktop": false, + "is_touchscreen": true + } + }, + "ghosts-app/1.0.2.1 (ASUSTeK COMPUTER INC.; X550CC; Windows 8 (X86); en)": { + "user_agent": {}, + "os": {}, + "device": { + "name": "Asus TeK", + "brand": "Asus", + "model": "TeK", + "is_desktop": false + } + } +} \ No newline at end of file diff --git a/test-fixtures/src/bin/device-detection-lookup.rs b/test-fixtures/src/bin/device-detection-lookup.rs new file mode 100644 index 00000000..25e4fa0f --- /dev/null +++ b/test-fixtures/src/bin/device-detection-lookup.rs @@ -0,0 +1,37 @@ +// //! A guest program to test that Device Detection lookups work properly. + +// use fastly::device_detection::lookup; + +fn main() { + // let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20100101 Firefox/10.0 [FBAN/FBIOS;FBAV/8.0.0.28.18;FBBV/1665515;FBDV/iPhone4,1;FBMD/iPhone;FBSN/iPhone OS;FBSV/7.0.4;FBSS/2; FBCR/Telekom.de;FBID/phone;FBLC/de_DE;FBOP/5]"; + // let device = lookup(&ua).unwrap(); + // assert_eq!(device.device_name(), Some("iPhone")); + // assert_eq!(device.brand(), Some("Apple")); + // assert_eq!(device.model(), Some("iPhone4,1")); + // assert_eq!(device.hwtype(), Some("Mobile Phone")); + // assert_eq!(device.is_ereader(), Some(false)); + // assert_eq!(device.is_gameconsole(), Some(false)); + // assert_eq!(device.is_mediaplayer(), Some(false)); + // assert_eq!(device.is_mobile(), Some(true)); + // assert_eq!(device.is_smarttv(), Some(false)); + // assert_eq!(device.is_tablet(), Some(false)); + // assert_eq!(device.is_tvplayer(), Some(false)); + // assert_eq!(device.is_desktop(), Some(false)); + // assert_eq!(device.is_touchscreen(), Some(true)); + + // let ua = "ghosts-app/1.0.2.1 (ASUSTeK COMPUTER INC.; X550CC; Windows 8 (X86); en)"; + // let device = lookup(&ua).unwrap(); + // assert_eq!(device.device_name(), Some("Asus TeK")); + // assert_eq!(device.brand(), Some("Asus")); + // assert_eq!(device.model(), Some("TeK")); + // assert_eq!(device.hwtype(), None); + // assert_eq!(device.is_ereader(), None); + // assert_eq!(device.is_gameconsole(), None); + // assert_eq!(device.is_mediaplayer(), None); + // assert_eq!(device.is_mobile(), None); + // assert_eq!(device.is_smarttv(), None); + // assert_eq!(device.is_tablet(), None); + // assert_eq!(device.is_tvplayer(), None); + // assert_eq!(device.is_desktop(), Some(false)); + // assert_eq!(device.is_touchscreen(), None); +} diff --git a/test-fixtures/src/bin/geolocation-lookup-default.rs b/test-fixtures/src/bin/geolocation-lookup-default.rs index 97bea371..c65053d1 100644 --- a/test-fixtures/src/bin/geolocation-lookup-default.rs +++ b/test-fixtures/src/bin/geolocation-lookup-default.rs @@ -57,4 +57,4 @@ fn main() { assert_eq!(geo_v6.region(), Some("CA")); // commented out because the below line fails both in Viceroy and Compute. // assert_eq!(geo_v6.utc_offset(), Some(UtcOffset::from_hms(-7, 0, 0).unwrap()); -} \ No newline at end of file +} diff --git a/test-fixtures/src/bin/geolocation-lookup.rs b/test-fixtures/src/bin/geolocation-lookup.rs index 7eb6a262..3d615a03 100644 --- a/test-fixtures/src/bin/geolocation-lookup.rs +++ b/test-fixtures/src/bin/geolocation-lookup.rs @@ -38,4 +38,4 @@ fn main() { let geo_v6 = geo_lookup(client_ip_v6).unwrap(); assert_eq!(geo_v6.as_name(), "Fastly Test IPv6"); assert_eq!(geo_v6.city(), "Test City IPv6"); -} \ No newline at end of file +} diff --git a/test-fixtures/src/bin/grpc.rs b/test-fixtures/src/bin/grpc.rs index d69ac3ca..4e54a95c 100644 --- a/test-fixtures/src/bin/grpc.rs +++ b/test-fixtures/src/bin/grpc.rs @@ -1,5 +1,5 @@ -use fastly::{Backend, Error, Request}; use fastly::experimental::GrpcBackend; +use fastly::{Backend, Error, Request}; use std::str::FromStr; /// Pass everything from the downstream request through to the backend, then pass everything back @@ -7,7 +7,7 @@ use std::str::FromStr; fn main() -> Result<(), Error> { let client_req = Request::from_client(); let Some(port_str) = client_req.get_header_str("Port") else { - panic!("Couldn't find out what port to use!"); + panic!("Couldn't find out what port to use!"); }; let port = u16::from_str(port_str).unwrap(); diff --git a/test-fixtures/src/bin/mutual-tls.rs b/test-fixtures/src/bin/mutual-tls.rs index 8e30395c..379f44f1 100644 --- a/test-fixtures/src/bin/mutual-tls.rs +++ b/test-fixtures/src/bin/mutual-tls.rs @@ -1,7 +1,7 @@ -use base64::engine::{Engine, general_purpose}; -use fastly::{Backend, Error, Request, Response}; +use base64::engine::{general_purpose, Engine}; use fastly::http::StatusCode; use fastly::secret_store::Secret; +use fastly::{Backend, Error, Request, Response}; use std::str::FromStr; /// Pass everything from the downstream request through to the backend, then pass everything back @@ -13,7 +13,7 @@ fn main() -> Result<(), Error> { let key_secret = Secret::from_bytes(key_bytes.to_vec()).expect("can inject key"); let Some(port_str) = client_req.get_header_str("Port") else { - panic!("Couldn't find out what port to use!"); + panic!("Couldn't find out what port to use!"); }; let port = u16::from_str(port_str).unwrap(); diff --git a/test-fixtures/src/bin/secret-store.rs b/test-fixtures/src/bin/secret-store.rs index 04ef8570..33644fd8 100644 --- a/test-fixtures/src/bin/secret-store.rs +++ b/test-fixtures/src/bin/secret-store.rs @@ -1,7 +1,7 @@ //! A guest program to test that secret store works properly. -use fastly::SecretStore; use fastly::secret_store::Secret; +use fastly::SecretStore; fn main() { // Check we can't get a store that does not exist