diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /testing/geckodriver/src/capabilities.rs | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/geckodriver/src/capabilities.rs')
-rw-r--r-- | testing/geckodriver/src/capabilities.rs | 1417 |
1 files changed, 1417 insertions, 0 deletions
diff --git a/testing/geckodriver/src/capabilities.rs b/testing/geckodriver/src/capabilities.rs new file mode 100644 index 0000000000..eda75ad62b --- /dev/null +++ b/testing/geckodriver/src/capabilities.rs @@ -0,0 +1,1417 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::command::LogOptions; +use crate::logging::Level; +use crate::marionette::MarionetteSettings; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use mozdevice::AndroidStorageInput; +use mozprofile::preferences::Pref; +use mozprofile::profile::Profile; +use mozrunner::firefox_args::{get_arg_value, parse_args, Arg}; +use mozrunner::runner::platform::firefox_default_path; +use mozversion::{self, firefox_binary_version, firefox_version, Version}; +use regex::bytes::Regex; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::default::Default; +use std::ffi::OsString; +use std::fmt::{self, Display}; +use std::fs; +use std::io; +use std::io::BufWriter; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::str::{self, FromStr}; +use webdriver::capabilities::{BrowserCapabilities, Capabilities}; +use webdriver::error::{ErrorStatus, WebDriverError, WebDriverResult}; + +#[derive(Clone, Debug)] +enum VersionError { + VersionError(mozversion::Error), + MissingBinary, +} + +impl From<mozversion::Error> for VersionError { + fn from(err: mozversion::Error) -> VersionError { + VersionError::VersionError(err) + } +} + +impl Display for VersionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + VersionError::VersionError(ref x) => x.fmt(f), + VersionError::MissingBinary => "No binary provided".fmt(f), + } + } +} + +impl From<VersionError> for WebDriverError { + fn from(err: VersionError) -> WebDriverError { + WebDriverError::new(ErrorStatus::SessionNotCreated, err.to_string()) + } +} + +/// Provides matching of `moz:firefoxOptions` and resolutionnized of which Firefox +/// binary to use. +/// +/// `FirefoxCapabilities` is constructed with the fallback binary, should +/// `moz:firefoxOptions` not contain a binary entry. This may either be the +/// system Firefox installation or an override, for example given to the +/// `--binary` flag of geckodriver. +pub struct FirefoxCapabilities<'a> { + pub chosen_binary: Option<PathBuf>, + fallback_binary: Option<&'a PathBuf>, + version_cache: BTreeMap<PathBuf, Result<Version, VersionError>>, +} + +impl<'a> FirefoxCapabilities<'a> { + pub fn new(fallback_binary: Option<&'a PathBuf>) -> FirefoxCapabilities<'a> { + FirefoxCapabilities { + chosen_binary: None, + fallback_binary, + version_cache: BTreeMap::new(), + } + } + + fn set_binary(&mut self, capabilities: &Map<String, Value>) { + self.chosen_binary = capabilities + .get("moz:firefoxOptions") + .and_then(|x| x.get("binary")) + .and_then(|x| x.as_str()) + .map(PathBuf::from) + .or_else(|| self.fallback_binary.cloned()) + .or_else(firefox_default_path); + } + + fn version(&mut self, binary: Option<&Path>) -> Result<Version, VersionError> { + if let Some(binary) = binary { + if let Some(cache_value) = self.version_cache.get(binary) { + return cache_value.clone(); + } + let rv = self + .version_from_ini(binary) + .or_else(|_| self.version_from_binary(binary)); + if let Ok(ref version) = rv { + debug!("Found version {}", version); + } else { + debug!("Failed to get binary version"); + } + self.version_cache.insert(binary.to_path_buf(), rv.clone()); + rv + } else { + Err(VersionError::MissingBinary) + } + } + + fn version_from_ini(&self, binary: &Path) -> Result<Version, VersionError> { + debug!("Trying to read firefox version from ini files"); + let version = firefox_version(binary)?; + if let Some(version_string) = version.version_string { + Version::from_str(&version_string).map_err(|err| err.into()) + } else { + Err(VersionError::VersionError( + mozversion::Error::MetadataError("Missing version string".into()), + )) + } + } + + fn version_from_binary(&self, binary: &Path) -> Result<Version, VersionError> { + debug!("Trying to read firefox version from binary"); + Ok(firefox_binary_version(binary)?) + } +} + +impl<'a> BrowserCapabilities for FirefoxCapabilities<'a> { + fn init(&mut self, capabilities: &Capabilities) { + self.set_binary(capabilities); + } + + fn browser_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { + Ok(Some("firefox".into())) + } + + fn browser_version(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { + let binary = self.chosen_binary.clone(); + self.version(binary.as_ref().map(|x| x.as_ref())) + .map_err(|err| err.into()) + .map(|x| Some(x.to_string())) + } + + fn platform_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { + Ok(if cfg!(target_os = "windows") { + Some("windows".into()) + } else if cfg!(target_os = "macos") { + Some("mac".into()) + } else if cfg!(target_os = "linux") { + Some("linux".into()) + } else { + None + }) + } + + fn accept_insecure_certs(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn accept_proxy(&mut self, _: &Capabilities, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn set_window_rect(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn compare_browser_version( + &mut self, + version: &str, + comparison: &str, + ) -> WebDriverResult<bool> { + Version::from_str(version) + .map_err(VersionError::from)? + .matches(comparison) + .map_err(|err| VersionError::from(err).into()) + } + + fn strict_file_interactability(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn web_socket_url(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn validate_custom(&mut self, name: &str, value: &Value) -> WebDriverResult<()> { + if !name.starts_with("moz:") { + return Ok(()); + } + match name { + "moz:firefoxOptions" => { + let data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "moz:firefoxOptions is not an object" + ); + for (key, value) in data.iter() { + match &**key { + "androidActivity" + | "androidDeviceSerial" + | "androidPackage" + | "profile" => { + if !value.is_string() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} is not a string", &**key), + )); + } + } + "androidIntentArguments" | "args" => { + if !try_opt!( + value.as_array(), + ErrorStatus::InvalidArgument, + format!("{} is not an array", &**key) + ) + .iter() + .all(|value| value.is_string()) + { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} entry is not a string", &**key), + )); + } + } + "binary" => { + if let Some(binary) = value.as_str() { + if !data.contains_key("androidPackage") + && self.version(Some(Path::new(binary))).is_err() + { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} is not a Firefox executable", &**key), + )); + } + } else { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} is not a string", &**key), + )); + } + } + "env" => { + let env_data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "env value is not an object" + ); + if !env_data.values().all(Value::is_string) { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Environment values were not all strings", + )); + } + } + "log" => { + let log_data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "log value is not an object" + ); + for (log_key, log_value) in log_data.iter() { + match &**log_key { + "level" => { + let level = try_opt!( + log_value.as_str(), + ErrorStatus::InvalidArgument, + "log level is not a string" + ); + if Level::from_str(level).is_err() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Not a valid log level: {}", level), + )); + } + } + x => { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Invalid log field {}", x), + )) + } + } + } + } + "prefs" => { + let prefs_data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "prefs value is not an object" + ); + let is_pref_value_type = |x: &Value| { + x.is_string() || x.is_i64() || x.is_u64() || x.is_boolean() + }; + if !prefs_data.values().all(is_pref_value_type) { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Preference values not all string or integer or boolean", + )); + } + } + x => { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Invalid moz:firefoxOptions field {}", x), + )) + } + } + } + } + "moz:useNonSpecCompliantPointerOrigin" => { + warn!("You are using the deprecated vendor specific capability 'moz:useNonSpecCompliantPointerOrigin', which will be removed in Firefox 116."); + if !value.is_boolean() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:useNonSpecCompliantPointerOrigin is not a boolean", + )); + } + } + "moz:webdriverClick" => { + if !value.is_boolean() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:webdriverClick is not a boolean", + )); + } + } + "moz:debuggerAddress" => { + if !value.is_boolean() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:debuggerAddress is not a boolean", + )); + } + } + _ => { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Unrecognised option {}", name), + )) + } + } + Ok(()) + } + + fn accept_custom(&mut self, _: &str, _: &Value, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } +} + +/// Android-specific options in the `moz:firefoxOptions` struct. +/// These map to "androidCamelCase", following [chromedriver's Android-specific +/// Capabilities](http://chromedriver.chromium.org/getting-started/getting-started---android). +#[derive(Default, Clone, Debug, PartialEq)] +pub struct AndroidOptions { + pub activity: Option<String>, + pub device_serial: Option<String>, + pub intent_arguments: Option<Vec<String>>, + pub package: String, + pub storage: AndroidStorageInput, +} + +impl AndroidOptions { + pub fn new(package: String, storage: AndroidStorageInput) -> AndroidOptions { + AndroidOptions { + package, + storage, + ..Default::default() + } + } +} + +#[derive(Debug, PartialEq)] +pub enum ProfileType { + Path(Profile), + Named, + Temporary, +} + +impl Default for ProfileType { + fn default() -> Self { + ProfileType::Temporary + } +} + +/// Rust representation of `moz:firefoxOptions`. +/// +/// Calling `FirefoxOptions::from_capabilities(binary, capabilities)` causes +/// the encoded profile, the binary arguments, log settings, and additional +/// preferences to be checked and unmarshaled from the `moz:firefoxOptions` +/// JSON Object into a Rust representation. +#[derive(Default, Debug)] +pub struct FirefoxOptions { + pub binary: Option<PathBuf>, + pub profile: ProfileType, + pub args: Option<Vec<String>>, + pub env: Option<Vec<(String, String)>>, + pub log: LogOptions, + pub prefs: Vec<(String, Pref)>, + pub android: Option<AndroidOptions>, + pub use_websocket: bool, +} + +impl FirefoxOptions { + pub fn new() -> FirefoxOptions { + Default::default() + } + + pub(crate) fn from_capabilities( + binary_path: Option<PathBuf>, + settings: &MarionetteSettings, + matched: &mut Capabilities, + ) -> WebDriverResult<FirefoxOptions> { + let mut rv = FirefoxOptions::new(); + rv.binary = binary_path; + + if let Some(json) = matched.remove("moz:firefoxOptions") { + let options = json.as_object().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "'moz:firefoxOptions' \ + capability is not an object", + ) + })?; + + if options.get("androidPackage").is_some() && options.get("binary").is_some() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidPackage and binary are mutual exclusive", + )); + } + + rv.android = FirefoxOptions::load_android(settings.android_storage, options)?; + rv.args = FirefoxOptions::load_args(options)?; + rv.env = FirefoxOptions::load_env(options)?; + rv.log = FirefoxOptions::load_log(options)?; + rv.prefs = FirefoxOptions::load_prefs(options)?; + if let Some(profile) = + FirefoxOptions::load_profile(settings.profile_root.as_deref(), options)? + { + rv.profile = ProfileType::Path(profile); + } + } + + if let Some(args) = rv.args.as_ref() { + let os_args = parse_args(args.iter().map(OsString::from).collect::<Vec<_>>().iter()); + + if let Some(path) = get_arg_value(os_args.iter(), Arg::Profile) { + if let ProfileType::Path(_) = rv.profile { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Can't provide both a --profile argument and a profile", + )); + } + let path_buf = PathBuf::from(path); + rv.profile = ProfileType::Path(Profile::new_from_path(&path_buf)?); + } + + if get_arg_value(os_args.iter(), Arg::NamedProfile).is_some() { + if let ProfileType::Path(_) = rv.profile { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Can't provide both a -P argument and a profile", + )); + } + // See bug 1757720 + warn!("Firefox was configured to use a named profile (`-P <name>`). \ + Support for named profiles will be removed in a future geckodriver release. \ + Please instead use the `--profile <path>` Firefox argument to start with an existing profile"); + rv.profile = ProfileType::Named; + } + + // Block these Firefox command line arguments that should not be settable + // via session capabilities. + if let Some(arg) = os_args + .iter() + .filter_map(|(opt_arg, _)| opt_arg.as_ref()) + .find(|arg| { + matches!( + arg, + Arg::Marionette + | Arg::RemoteAllowHosts + | Arg::RemoteAllowOrigins + | Arg::RemoteDebuggingPort + ) + }) + { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Argument {} can't be set via capabilities", arg), + )); + }; + } + + let has_web_socket_url = matched + .get("webSocketUrl") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + + let has_debugger_address = matched + .remove("moz:debuggerAddress") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + + // Set a command line provided port for the Remote Agent for now. + // It needs to be the same on the host and the Android device. + if has_web_socket_url || has_debugger_address { + rv.use_websocket = true; + + // Bug 1722863: Setting of command line arguments would be + // better suited in the individual Browser implementations. + let mut remote_args = Vec::new(); + remote_args.push("--remote-debugging-port".to_owned()); + remote_args.push(settings.websocket_port.to_string()); + + // Handle additional hosts for WebDriver BiDi WebSocket connections + if !settings.allow_hosts.is_empty() { + remote_args.push("--remote-allow-hosts".to_owned()); + remote_args.push( + settings + .allow_hosts + .iter() + .map(|host| host.to_string()) + .collect::<Vec<String>>() + .join(","), + ); + } + + // Handle additional origins for WebDriver BiDi WebSocket connections + if !settings.allow_origins.is_empty() { + remote_args.push("--remote-allow-origins".to_owned()); + remote_args.push( + settings + .allow_origins + .iter() + .map(|origin| origin.to_string()) + .collect::<Vec<String>>() + .join(","), + ); + } + + if let Some(ref mut args) = rv.args { + args.append(&mut remote_args); + } else { + rv.args = Some(remote_args); + } + } + + Ok(rv) + } + + fn load_profile( + profile_root: Option<&Path>, + options: &Capabilities, + ) -> WebDriverResult<Option<Profile>> { + if let Some(profile_json) = options.get("profile") { + let profile_base64 = profile_json.as_str().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Profile is not a string") + })?; + let profile_zip = &*BASE64_STANDARD.decode(profile_base64)?; + + // Create an emtpy profile directory + let profile = Profile::new(profile_root)?; + unzip_buffer( + profile_zip, + profile + .temp_dir + .as_ref() + .expect("Profile doesn't have a path") + .path(), + )?; + + Ok(Some(profile)) + } else { + Ok(None) + } + } + + fn load_args(options: &Capabilities) -> WebDriverResult<Option<Vec<String>>> { + if let Some(args_json) = options.get("args") { + let args_array = args_json.as_array().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Arguments were not an array") + })?; + let args = args_array + .iter() + .map(|x| x.as_str().map(|x| x.to_owned())) + .collect::<Option<Vec<String>>>() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Arguments entries were not all strings", + ) + })?; + + Ok(Some(args)) + } else { + Ok(None) + } + } + + pub fn load_env(options: &Capabilities) -> WebDriverResult<Option<Vec<(String, String)>>> { + if let Some(env_data) = options.get("env") { + let env = env_data.as_object().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Env was not an object") + })?; + let mut rv = Vec::with_capacity(env.len()); + for (key, value) in env.iter() { + rv.push(( + key.clone(), + value + .as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Env value is not a string", + ) + })? + .to_string(), + )); + } + Ok(Some(rv)) + } else { + Ok(None) + } + } + + fn load_log(options: &Capabilities) -> WebDriverResult<LogOptions> { + if let Some(json) = options.get("log") { + let log = json.as_object().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Log section is not an object") + })?; + + let level = match log.get("level") { + Some(json) => { + let s = json.as_str().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Log level is not a string", + ) + })?; + Some(Level::from_str(s).ok().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Log level is unknown") + })?) + } + None => None, + }; + + Ok(LogOptions { level }) + } else { + Ok(Default::default()) + } + } + + pub fn load_prefs(options: &Capabilities) -> WebDriverResult<Vec<(String, Pref)>> { + if let Some(prefs_data) = options.get("prefs") { + let prefs = prefs_data.as_object().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Prefs were not an object") + })?; + let mut rv = Vec::with_capacity(prefs.len()); + for (key, value) in prefs.iter() { + rv.push((key.clone(), pref_from_json(value)?)); + } + Ok(rv) + } else { + Ok(vec![]) + } + } + + pub fn load_android( + storage: AndroidStorageInput, + options: &Capabilities, + ) -> WebDriverResult<Option<AndroidOptions>> { + if let Some(package_json) = options.get("androidPackage") { + let package = package_json + .as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidPackage is not a string", + ) + })? + .to_owned(); + + // https://developer.android.com/studio/build/application-id + let package_regexp = + Regex::new(r#"^([a-zA-Z][a-zA-Z0-9_]*\.){1,}([a-zA-Z][a-zA-Z0-9_]*)$"#).unwrap(); + if !package_regexp.is_match(package.as_bytes()) { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Not a valid androidPackage name", + )); + } + + let mut android = AndroidOptions::new(package.clone(), storage); + + android.activity = match options.get("androidActivity") { + Some(json) => { + let activity = json + .as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidActivity is not a string", + ) + })? + .to_owned(); + + if activity.contains('/') { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidActivity should not contain '/", + )); + } + + Some(activity) + } + None => { + match package.as_str() { + "org.mozilla.firefox" + | "org.mozilla.firefox_beta" + | "org.mozilla.fenix" + | "org.mozilla.fenix.debug" + | "org.mozilla.reference.browser" => { + Some("org.mozilla.fenix.IntentReceiverActivity".to_string()) + } + "org.mozilla.focus" + | "org.mozilla.focus.debug" + | "org.mozilla.klar" + | "org.mozilla.klar.debug" => { + Some("org.mozilla.focus.activity.IntentReceiverActivity".to_string()) + } + // For all other applications fallback to auto-detection. + _ => None, + } + } + }; + + android.device_serial = match options.get("androidDeviceSerial") { + Some(json) => Some( + json.as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidDeviceSerial is not a string", + ) + })? + .to_owned(), + ), + None => None, + }; + + android.intent_arguments = match options.get("androidIntentArguments") { + Some(json) => { + let args_array = json.as_array().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidIntentArguments is not an array", + ) + })?; + let args = args_array + .iter() + .map(|x| x.as_str().map(|x| x.to_owned())) + .collect::<Option<Vec<String>>>() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidIntentArguments entries are not all strings", + ) + })?; + + Some(args) + } + None => { + // All GeckoView based applications support this view, + // and allow to open a blank page in a Gecko window. + Some(vec![ + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + "about:blank".to_string(), + ]) + } + }; + + Ok(Some(android)) + } else { + Ok(None) + } + } +} + +fn pref_from_json(value: &Value) -> WebDriverResult<Pref> { + match *value { + Value::String(ref x) => Ok(Pref::new(x.clone())), + Value::Number(ref x) => Ok(Pref::new(x.as_i64().unwrap())), + Value::Bool(x) => Ok(Pref::new(x)), + _ => Err(WebDriverError::new( + ErrorStatus::UnknownError, + "Could not convert pref value to string, boolean, or integer", + )), + } +} + +fn unzip_buffer(buf: &[u8], dest_dir: &Path) -> WebDriverResult<()> { + let reader = Cursor::new(buf); + let mut zip = zip::ZipArchive::new(reader) + .map_err(|_| WebDriverError::new(ErrorStatus::UnknownError, "Failed to unzip profile"))?; + + for i in 0..zip.len() { + let mut file = zip.by_index(i).map_err(|_| { + WebDriverError::new( + ErrorStatus::UnknownError, + "Processing profile zip file failed", + ) + })?; + let unzip_path = { + let name = file.name(); + let is_dir = name.ends_with('/'); + let rel_path = Path::new(name); + let dest_path = dest_dir.join(rel_path); + + { + let create_dir = if is_dir { + Some(dest_path.as_path()) + } else { + dest_path.parent() + }; + if let Some(dir) = create_dir { + if !dir.exists() { + debug!("Creating profile directory tree {}", dir.to_string_lossy()); + fs::create_dir_all(dir)?; + } + } + } + + if is_dir { + None + } else { + Some(dest_path) + } + }; + + if let Some(unzip_path) = unzip_path { + debug!("Extracting profile to {}", unzip_path.to_string_lossy()); + let dest = fs::File::create(unzip_path)?; + if file.size() > 0 { + let mut writer = BufWriter::new(dest); + io::copy(&mut file, &mut writer)?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + extern crate mozprofile; + + use self::mozprofile::preferences::Pref; + use super::*; + use serde_json::{json, Map, Value}; + use std::fs::File; + use std::io::Read; + use url::{Host, Url}; + use webdriver::capabilities::Capabilities; + + fn example_profile() -> Value { + let mut profile_data = Vec::with_capacity(1024); + let mut profile = File::open("src/tests/profile.zip").unwrap(); + profile.read_to_end(&mut profile_data).unwrap(); + Value::String(BASE64_STANDARD.encode(&profile_data)) + } + + fn make_options( + firefox_opts: Capabilities, + marionette_settings: Option<MarionetteSettings>, + ) -> WebDriverResult<FirefoxOptions> { + let mut caps = Capabilities::new(); + caps.insert("moz:firefoxOptions".into(), Value::Object(firefox_opts)); + + FirefoxOptions::from_capabilities(None, &marionette_settings.unwrap_or_default(), &mut caps) + } + + #[test] + fn fx_options_default() { + let opts: FirefoxOptions = Default::default(); + assert_eq!(opts.android, None); + assert_eq!(opts.args, None); + assert_eq!(opts.binary, None); + assert_eq!(opts.log, LogOptions { level: None }); + assert_eq!(opts.prefs, vec![]); + // Profile doesn't support PartialEq + // assert_eq!(opts.profile, None); + } + + #[test] + fn fx_options_from_capabilities_no_binary_and_empty_caps() { + let mut caps = Capabilities::new(); + + let marionette_settings = Default::default(); + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("valid firefox options"); + assert_eq!(opts.android, None); + assert_eq!(opts.args, None); + assert_eq!(opts.binary, None); + assert_eq!(opts.log, LogOptions { level: None }); + assert_eq!(opts.prefs, vec![]); + } + + #[test] + fn fx_options_from_capabilities_with_binary_and_caps() { + let mut caps = Capabilities::new(); + caps.insert( + "moz:firefoxOptions".into(), + Value::Object(Capabilities::new()), + ); + + let binary = PathBuf::from("foo"); + let marionette_settings = Default::default(); + + let opts = FirefoxOptions::from_capabilities( + Some(binary.clone()), + &marionette_settings, + &mut caps, + ) + .expect("valid firefox options"); + assert_eq!(opts.android, None); + assert_eq!(opts.args, None); + assert_eq!(opts.binary, Some(binary)); + assert_eq!(opts.log, LogOptions { level: None }); + assert_eq!(opts.prefs, vec![]); + } + + #[test] + fn fx_options_from_capabilities_with_blocked_firefox_arguments() { + let blocked_args = vec![ + "--marionette", + "--remote-allow-hosts", + "--remote-allow-origins", + "--remote-debugging-port", + ]; + + for arg in blocked_args { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!([arg])); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + } + + #[test] + fn fx_options_from_capabilities_with_websocket_url_not_set() { + let mut caps = Capabilities::new(); + + let marionette_settings = Default::default(); + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_websocket_url_false() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(false)); + + let marionette_settings = Default::default(); + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_websocket_url_true() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(true)); + + let settings = MarionetteSettings { + websocket_port: 1234, + ..Default::default() + }; + let opts = FirefoxOptions::from_capabilities(None, &settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-debugging-port".to_owned())); + assert_eq!(iter.next(), Some(&"1234".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_websocket_and_allow_hosts() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(true)); + + let mut marionette_settings: MarionetteSettings = Default::default(); + marionette_settings.allow_hosts = vec![ + Host::parse("foo").expect("host"), + Host::parse("bar").expect("host"), + ]; + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-allow-hosts".to_owned())); + assert_eq!(iter.next(), Some(&"foo,bar".to_owned())); + assert!(!iter.any(|arg| arg == &"--remote-allow-origins".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_websocket_and_allow_origins() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(true)); + + let mut marionette_settings: MarionetteSettings = Default::default(); + marionette_settings.allow_origins = vec![ + Url::parse("http://foo/").expect("url"), + Url::parse("http://bar/").expect("url"), + ]; + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-allow-origins".to_owned())); + assert_eq!(iter.next(), Some(&"http://foo/,http://bar/".to_owned())); + assert!(!iter.any(|arg| arg == &"--remote-allow-hosts".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_not_set() { + let caps = Capabilities::new(); + + let opts = make_options(caps, None).expect("valid firefox options"); + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_false() { + let mut caps = Capabilities::new(); + caps.insert("moz:debuggerAddress".into(), json!(false)); + + let opts = make_options(caps, None).expect("valid firefox options"); + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_true() { + let mut caps = Capabilities::new(); + caps.insert("moz:debuggerAddress".into(), json!(true)); + + let settings = MarionetteSettings { + websocket_port: 1234, + ..Default::default() + }; + let opts = FirefoxOptions::from_capabilities(None, &settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-debugging-port".to_owned())); + assert_eq!(iter.next(), Some(&"1234".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_invalid_caps() { + let mut caps = Capabilities::new(); + caps.insert("moz:firefoxOptions".into(), json!(42)); + + let marionette_settings = Default::default(); + FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect_err("Firefox options need to be of type object"); + } + + #[test] + fn fx_options_android_package_and_binary() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo")); + firefox_opts.insert("binary".into(), json!("bar")); + + make_options(firefox_opts, None) + .expect_err("androidPackage and binary are mutual exclusive"); + } + + #[test] + fn fx_options_android_no_package() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidAvtivity".into(), json!("foo")); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android, None); + } + + #[test] + fn fx_options_android_package_valid_value() { + for value in ["foo.bar", "foo.bar.cheese.is.good", "Foo.Bar_9"].iter() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(value)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().package, value.to_string()); + } + } + + #[test] + fn fx_options_android_package_invalid_type() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(42)); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_package_invalid_value() { + for value in ["../foo", "\\foo\n", "foo", "_foo", "0foo"].iter() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(value)); + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + } + + #[test] + fn fx_options_android_activity_default_known_apps() { + let packages = vec![ + "org.mozilla.firefox", + "org.mozilla.firefox_beta", + "org.mozilla.fenix", + "org.mozilla.fenix.debug", + "org.mozilla.focus", + "org.mozilla.focus.debug", + "org.mozilla.klar", + "org.mozilla.klar.debug", + "org.mozilla.reference.browser", + ]; + + for package in packages { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(package)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert!(opts + .android + .unwrap() + .activity + .unwrap() + .contains("IntentReceiverActivity")); + } + } + + #[test] + fn fx_options_android_activity_default_unknown_apps() { + let packages = vec!["org.mozilla.geckoview_example", "com.some.other.app"]; + + for package in packages { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(package)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().activity, None); + } + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert( + "androidPackage".into(), + json!("org.mozilla.geckoview_example"), + ); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().activity, None); + } + + #[test] + fn fx_options_android_activity_override() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!("foo")); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().activity, Some("foo".to_string())); + } + + #[test] + fn fx_options_android_activity_invalid_type() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!(42)); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_activity_invalid_value() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!("foo.bar/cheese")); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_device_serial() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidDeviceSerial".into(), json!("cheese")); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!( + opts.android.unwrap().device_serial, + Some("cheese".to_string()) + ); + } + + #[test] + fn fx_options_android_device_serial_invalid() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidDeviceSerial".into(), json!(42)); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_intent_arguments_defaults() { + let packages = vec![ + "org.mozilla.firefox", + "org.mozilla.firefox_beta", + "org.mozilla.fenix", + "org.mozilla.fenix.debug", + "org.mozilla.geckoview_example", + "org.mozilla.reference.browser", + "com.some.other.app", + ]; + + for package in packages { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(package)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!( + opts.android.unwrap().intent_arguments, + Some(vec![ + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + "about:blank".to_string(), + ]) + ); + } + } + + #[test] + fn fx_options_android_intent_arguments_override() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", "ipsum"])); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!( + opts.android.unwrap().intent_arguments, + Some(vec!["lorem".to_string(), "ipsum".to_string()]) + ); + } + + #[test] + fn fx_options_android_intent_arguments_no_array() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidIntentArguments".into(), json!(42)); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_intent_arguments_invalid_value() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", 42])); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_env() { + let mut env: Map<String, Value> = Map::new(); + env.insert("TEST_KEY_A".into(), Value::String("test_value_a".into())); + env.insert("TEST_KEY_B".into(), Value::String("test_value_b".into())); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("env".into(), env.into()); + + let mut opts = make_options(firefox_opts, None).expect("valid firefox options"); + for sorted in opts.env.iter_mut() { + sorted.sort() + } + assert_eq!( + opts.env, + Some(vec![ + ("TEST_KEY_A".into(), "test_value_a".into()), + ("TEST_KEY_B".into(), "test_value_b".into()), + ]) + ); + } + + #[test] + fn fx_options_env_invalid_container() { + let env = Value::Number(1.into()); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("env".into(), env); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_env_invalid_value() { + let mut env: Map<String, Value> = Map::new(); + env.insert("TEST_KEY".into(), Value::Number(1.into())); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("env".into(), env.into()); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn test_profile() { + let encoded_profile = example_profile(); + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("profile".into(), encoded_profile); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + let mut profile = match opts.profile { + ProfileType::Path(profile) => profile, + _ => panic!("Expected ProfileType::Path"), + }; + let prefs = profile.user_prefs().expect("valid preferences"); + + println!("{:#?}", prefs.prefs); + + assert_eq!( + prefs.get("startup.homepage_welcome_url"), + Some(&Pref::new("data:text/html,PASS")) + ); + } + + #[test] + fn fx_options_args_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["--profile", "foo"])); + + let options = make_options(firefox_opts, None).expect("Valid args"); + assert!(matches!(options.profile, ProfileType::Path(_))); + } + + #[test] + fn fx_options_args_named_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["-P", "foo"])); + + let options = make_options(firefox_opts, None).expect("Valid args"); + assert!(matches!(options.profile, ProfileType::Named)); + } + + #[test] + fn fx_options_args_no_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["--headless"])); + + let options = make_options(firefox_opts, None).expect("Valid args"); + assert!(matches!(options.profile, ProfileType::Temporary)); + } + + #[test] + fn fx_options_args_profile_and_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["--profile", "foo"])); + firefox_opts.insert("profile".into(), json!("foo")); + + make_options(firefox_opts, None).expect_err("Invalid args"); + } + + #[test] + fn fx_options_args_p_and_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["-P"])); + firefox_opts.insert("profile".into(), json!("foo")); + + make_options(firefox_opts, None).expect_err("Invalid args"); + } +} |