summaryrefslogtreecommitdiffstats
path: root/testing/mozbase/rust
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--testing/mozbase/rust/mozdevice/Cargo.toml26
-rw-r--r--testing/mozbase/rust/mozdevice/src/adb.rs38
-rw-r--r--testing/mozbase/rust/mozdevice/src/lib.rs1068
-rw-r--r--testing/mozbase/rust/mozdevice/src/shell.rs66
-rw-r--r--testing/mozbase/rust/mozdevice/src/test.rs760
-rw-r--r--testing/mozbase/rust/mozprofile/Cargo.toml15
-rw-r--r--testing/mozbase/rust/mozprofile/fuzz/Cargo.toml25
-rw-r--r--testing/mozbase/rust/mozprofile/fuzz/fuzz_targets/prefreader.rs16
-rw-r--r--testing/mozbase/rust/mozprofile/src/lib.rs241
-rw-r--r--testing/mozbase/rust/mozprofile/src/preferences.rs138
-rw-r--r--testing/mozbase/rust/mozprofile/src/prefreader.rs1064
-rw-r--r--testing/mozbase/rust/mozprofile/src/profile.rs135
-rw-r--r--testing/mozbase/rust/mozrunner/Cargo.toml27
-rw-r--r--testing/mozbase/rust/mozrunner/src/bin/firefox-default-path.rs21
-rw-r--r--testing/mozbase/rust/mozrunner/src/firefox_args.rs384
-rw-r--r--testing/mozbase/rust/mozrunner/src/lib.rs20
-rw-r--r--testing/mozbase/rust/mozrunner/src/path.rs48
-rw-r--r--testing/mozbase/rust/mozrunner/src/runner.rs570
-rw-r--r--testing/mozbase/rust/mozversion/Cargo.toml17
-rw-r--r--testing/mozbase/rust/mozversion/src/lib.rs426
20 files changed, 5105 insertions, 0 deletions
diff --git a/testing/mozbase/rust/mozdevice/Cargo.toml b/testing/mozbase/rust/mozdevice/Cargo.toml
new file mode 100644
index 0000000000..0954b93643
--- /dev/null
+++ b/testing/mozbase/rust/mozdevice/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+edition = "2018"
+name = "mozdevice"
+version = "0.5.1"
+authors = ["Mozilla"]
+description = "Client library for the Android Debug Bridge (adb)"
+keywords = [
+ "adb",
+ "android",
+ "firefox",
+ "geckoview",
+ "mozilla",
+]
+license = "MPL-2.0"
+repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozdevice"
+
+[dependencies]
+log = { version = "0.4", features = ["std"] }
+once_cell = "1.4.0"
+regex = { version = "1", default-features = false, features = ["perf", "std"] }
+tempfile = "3"
+thiserror = "1.0.25"
+walkdir = "2"
+unix_path = "1.0"
+uuid = { version = "1.0", features = ["serde", "v4"] }
+
diff --git a/testing/mozbase/rust/mozdevice/src/adb.rs b/testing/mozbase/rust/mozdevice/src/adb.rs
new file mode 100644
index 0000000000..9d9c91fd07
--- /dev/null
+++ b/testing/mozbase/rust/mozdevice/src/adb.rs
@@ -0,0 +1,38 @@
+/* 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/. */
+
+#[derive(Debug, PartialEq)]
+pub enum SyncCommand {
+ Data,
+ Dent,
+ Done,
+ Fail,
+ List,
+ Okay,
+ Quit,
+ Recv,
+ Send,
+ Stat,
+}
+
+impl SyncCommand {
+ // Returns the byte serialisation of the protocol status.
+ pub fn code(&self) -> &'static [u8; 4] {
+ use self::SyncCommand::*;
+ match *self {
+ Data => b"DATA",
+ Dent => b"DENT",
+ Done => b"DONE",
+ Fail => b"FAIL",
+ List => b"LIST",
+ Okay => b"OKAY",
+ Quit => b"QUIT",
+ Recv => b"RECV",
+ Send => b"SEND",
+ Stat => b"STAT",
+ }
+ }
+}
+
+pub type DeviceSerial = String;
diff --git a/testing/mozbase/rust/mozdevice/src/lib.rs b/testing/mozbase/rust/mozdevice/src/lib.rs
new file mode 100644
index 0000000000..631c4acf2a
--- /dev/null
+++ b/testing/mozbase/rust/mozdevice/src/lib.rs
@@ -0,0 +1,1068 @@
+/* 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/. */
+
+pub mod adb;
+pub mod shell;
+
+#[cfg(test)]
+pub mod test;
+
+use log::{debug, info, trace, warn};
+use once_cell::sync::Lazy;
+use regex::Regex;
+use std::collections::BTreeMap;
+use std::convert::TryFrom;
+use std::fs::File;
+use std::io::{self, Read, Write};
+use std::iter::FromIterator;
+use std::net::TcpStream;
+use std::num::{ParseIntError, TryFromIntError};
+use std::path::{Component, Path};
+use std::str::{FromStr, Utf8Error};
+use std::time::{Duration, SystemTime};
+use thiserror::Error;
+pub use unix_path::{Path as UnixPath, PathBuf as UnixPathBuf};
+use uuid::Uuid;
+use walkdir::WalkDir;
+
+use crate::adb::{DeviceSerial, SyncCommand};
+
+pub type Result<T> = std::result::Result<T, DeviceError>;
+
+static SYNC_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^A-Za-z0-9_@%+=:,./-]").unwrap());
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum AndroidStorageInput {
+ Auto,
+ App,
+ Internal,
+ Sdcard,
+}
+
+impl FromStr for AndroidStorageInput {
+ type Err = DeviceError;
+
+ fn from_str(s: &str) -> Result<Self> {
+ match s {
+ "auto" => Ok(AndroidStorageInput::Auto),
+ "app" => Ok(AndroidStorageInput::App),
+ "internal" => Ok(AndroidStorageInput::Internal),
+ "sdcard" => Ok(AndroidStorageInput::Sdcard),
+ _ => Err(DeviceError::InvalidStorage),
+ }
+ }
+}
+
+impl Default for AndroidStorageInput {
+ fn default() -> Self {
+ AndroidStorageInput::Auto
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum AndroidStorage {
+ App,
+ Internal,
+ Sdcard,
+}
+
+#[derive(Debug, Error)]
+pub enum DeviceError {
+ #[error("{0}")]
+ Adb(String),
+ #[error(transparent)]
+ FromInt(#[from] TryFromIntError),
+ #[error("Invalid storage")]
+ InvalidStorage,
+ #[error(transparent)]
+ Io(#[from] io::Error),
+ #[error("Missing package")]
+ MissingPackage,
+ #[error("Multiple Android devices online")]
+ MultipleDevices,
+ #[error(transparent)]
+ ParseInt(#[from] ParseIntError),
+ #[error("Unknown Android device with serial '{0}'")]
+ UnknownDevice(String),
+ #[error(transparent)]
+ Utf8(#[from] Utf8Error),
+ #[error(transparent)]
+ WalkDir(#[from] walkdir::Error),
+}
+
+fn encode_message(payload: &str) -> Result<String> {
+ let hex_length = u16::try_from(payload.len()).map(|len| format!("{:0>4X}", len))?;
+
+ Ok(format!("{}{}", hex_length, payload))
+}
+
+fn parse_device_info(line: &str) -> Option<DeviceInfo> {
+ // Turn "serial\tdevice key1:value1 key2:value2 ..." into a `DeviceInfo`.
+ let mut pairs = line.split_whitespace();
+ let serial = pairs.next();
+ let state = pairs.next();
+ if let (Some(serial), Some("device")) = (serial, state) {
+ let info: BTreeMap<String, String> = pairs
+ .filter_map(|pair| {
+ let mut kv = pair.split(':');
+ if let (Some(k), Some(v), None) = (kv.next(), kv.next(), kv.next()) {
+ Some((k.to_owned(), v.to_owned()))
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ Some(DeviceInfo {
+ serial: serial.to_owned(),
+ info,
+ })
+ } else {
+ None
+ }
+}
+
+/// Reads the payload length of a host message from the stream.
+fn read_length<R: Read>(stream: &mut R) -> Result<usize> {
+ let mut bytes: [u8; 4] = [0; 4];
+ stream.read_exact(&mut bytes)?;
+
+ let response = std::str::from_utf8(&bytes)?;
+
+ Ok(usize::from_str_radix(response, 16)?)
+}
+
+/// Reads the payload length of a device message from the stream.
+fn read_length_little_endian(reader: &mut dyn Read) -> Result<usize> {
+ let mut bytes: [u8; 4] = [0; 4];
+ reader.read_exact(&mut bytes)?;
+
+ let n: usize = (bytes[0] as usize)
+ + ((bytes[1] as usize) << 8)
+ + ((bytes[2] as usize) << 16)
+ + ((bytes[3] as usize) << 24);
+
+ Ok(n)
+}
+
+/// Writes the payload length of a device message to the stream.
+fn write_length_little_endian(writer: &mut dyn Write, n: usize) -> Result<usize> {
+ let mut bytes = [0; 4];
+ bytes[0] = (n & 0xFF) as u8;
+ bytes[1] = ((n >> 8) & 0xFF) as u8;
+ bytes[2] = ((n >> 16) & 0xFF) as u8;
+ bytes[3] = ((n >> 24) & 0xFF) as u8;
+
+ writer.write(&bytes[..]).map_err(DeviceError::Io)
+}
+
+fn read_response(stream: &mut TcpStream, has_output: bool, has_length: bool) -> Result<Vec<u8>> {
+ let mut bytes: [u8; 1024] = [0; 1024];
+
+ stream.read_exact(&mut bytes[0..4])?;
+
+ if !bytes.starts_with(SyncCommand::Okay.code()) {
+ let n = bytes.len().min(read_length(stream)?);
+ stream.read_exact(&mut bytes[0..n])?;
+
+ let message = std::str::from_utf8(&bytes[0..n]).map(|s| format!("adb error: {}", s))?;
+
+ return Err(DeviceError::Adb(message));
+ }
+
+ let mut response = Vec::new();
+
+ if has_output {
+ stream.read_to_end(&mut response)?;
+
+ if response.starts_with(SyncCommand::Okay.code()) {
+ // Sometimes the server produces OKAYOKAY. Sometimes there is a transport OKAY and
+ // then the underlying command OKAY. This is straight from `chromedriver`.
+ response = response.split_off(4);
+ }
+
+ if response.starts_with(SyncCommand::Fail.code()) {
+ // The server may even produce OKAYFAIL, which means the underlying
+ // command failed. First split-off the `FAIL` and length of the message.
+ response = response.split_off(8);
+
+ let message = std::str::from_utf8(&response).map(|s| format!("adb error: {}", s))?;
+
+ return Err(DeviceError::Adb(message));
+ }
+
+ if has_length {
+ if response.len() >= 4 {
+ let message = response.split_off(4);
+ let slice: &mut &[u8] = &mut &*response;
+
+ let n = read_length(slice)?;
+ if n != message.len() {
+ warn!("adb server response contained hexstring len {} but remaining message length is {}", n, message.len());
+ }
+
+ trace!(
+ "adb server response was {:?}",
+ std::str::from_utf8(&message)?
+ );
+
+ return Ok(message);
+ } else {
+ return Err(DeviceError::Adb(format!(
+ "adb server response did not contain expected hexstring length: {:?}",
+ std::str::from_utf8(&response)?
+ )));
+ }
+ }
+ }
+
+ Ok(response)
+}
+
+/// Detailed information about an ADB device.
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub struct DeviceInfo {
+ pub serial: DeviceSerial,
+ pub info: BTreeMap<String, String>,
+}
+
+/// Represents a connection to an ADB host, which multiplexes the connections to
+/// individual devices.
+#[derive(Debug)]
+pub struct Host {
+ /// The TCP host to connect to. Defaults to `"localhost"`.
+ pub host: Option<String>,
+ /// The TCP port to connect to. Defaults to `5037`.
+ pub port: Option<u16>,
+ /// Optional TCP read timeout duration. Defaults to 2s.
+ pub read_timeout: Option<Duration>,
+ /// Optional TCP write timeout duration. Defaults to 2s.
+ pub write_timeout: Option<Duration>,
+}
+
+impl Default for Host {
+ fn default() -> Host {
+ Host {
+ host: Some("localhost".to_string()),
+ port: Some(5037),
+ read_timeout: Some(Duration::from_secs(2)),
+ write_timeout: Some(Duration::from_secs(2)),
+ }
+ }
+}
+
+impl Host {
+ /// Searches for available devices, and selects the one as specified by `device_serial`.
+ ///
+ /// If multiple devices are online, and no device has been specified,
+ /// the `ANDROID_SERIAL` environment variable can be used to select one.
+ pub fn device_or_default<T: AsRef<str>>(
+ self,
+ device_serial: Option<&T>,
+ storage: AndroidStorageInput,
+ ) -> Result<Device> {
+ let serials: Vec<String> = self
+ .devices::<Vec<_>>()?
+ .into_iter()
+ .map(|d| d.serial)
+ .collect();
+
+ if let Some(ref serial) = device_serial
+ .map(|v| v.as_ref().to_owned())
+ .or_else(|| std::env::var("ANDROID_SERIAL").ok())
+ {
+ if !serials.contains(serial) {
+ return Err(DeviceError::UnknownDevice(serial.clone()));
+ }
+
+ return Device::new(self, serial.to_owned(), storage);
+ }
+
+ if serials.len() > 1 {
+ return Err(DeviceError::MultipleDevices);
+ }
+
+ if let Some(ref serial) = serials.first() {
+ return Device::new(self, serial.to_owned().to_string(), storage);
+ }
+
+ Err(DeviceError::Adb("No Android devices are online".to_owned()))
+ }
+
+ pub fn connect(&self) -> Result<TcpStream> {
+ let stream = TcpStream::connect(format!(
+ "{}:{}",
+ self.host.clone().unwrap_or_else(|| "localhost".to_owned()),
+ self.port.unwrap_or(5037)
+ ))?;
+ stream.set_read_timeout(self.read_timeout)?;
+ stream.set_write_timeout(self.write_timeout)?;
+ Ok(stream)
+ }
+
+ pub fn execute_command(
+ &self,
+ command: &str,
+ has_output: bool,
+ has_length: bool,
+ ) -> Result<String> {
+ let mut stream = self.connect()?;
+
+ stream.write_all(encode_message(command)?.as_bytes())?;
+ let bytes = read_response(&mut stream, has_output, has_length)?;
+ // TODO: should we assert no bytes were read?
+
+ let response = std::str::from_utf8(&bytes)?;
+
+ Ok(response.to_owned())
+ }
+
+ pub fn execute_host_command(
+ &self,
+ host_command: &str,
+ has_length: bool,
+ has_output: bool,
+ ) -> Result<String> {
+ self.execute_command(&format!("host:{}", host_command), has_output, has_length)
+ }
+
+ pub fn features<B: FromIterator<String>>(&self) -> Result<B> {
+ let features = self.execute_host_command("features", true, true)?;
+ Ok(features.split(',').map(|x| x.to_owned()).collect())
+ }
+
+ pub fn devices<B: FromIterator<DeviceInfo>>(&self) -> Result<B> {
+ let response = self.execute_host_command("devices-l", true, true)?;
+
+ let infos: B = response.lines().filter_map(parse_device_info).collect();
+
+ Ok(infos)
+ }
+}
+
+/// Represents an ADB device.
+#[derive(Debug)]
+pub struct Device {
+ /// ADB host that controls this device.
+ pub host: Host,
+
+ /// Serial number uniquely identifying this ADB device.
+ pub serial: DeviceSerial,
+
+ /// adb running as root
+ pub adbd_root: bool,
+
+ /// Flag for rooted device
+ pub is_rooted: bool,
+
+ /// "su 0" command available
+ pub su_0_root: bool,
+
+ /// "su -c" command available
+ pub su_c_root: bool,
+
+ pub run_as_package: Option<String>,
+
+ pub storage: AndroidStorage,
+
+ /// Cache intermediate tempfile name used in pushing via run_as.
+ pub tempfile: UnixPathBuf,
+}
+
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub struct RemoteDirEntry {
+ depth: usize,
+ metadata: RemoteMetadata,
+ name: String,
+}
+
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub enum RemoteMetadata {
+ RemoteFile(RemoteFileMetadata),
+ RemoteDir,
+ RemoteSymlink,
+}
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub struct RemoteFileMetadata {
+ mode: usize,
+ size: usize,
+}
+
+impl Device {
+ pub fn new(host: Host, serial: DeviceSerial, storage: AndroidStorageInput) -> Result<Device> {
+ let mut device = Device {
+ host,
+ serial,
+ adbd_root: false,
+ is_rooted: false,
+ run_as_package: None,
+ storage: AndroidStorage::App,
+ su_c_root: false,
+ su_0_root: false,
+ tempfile: UnixPathBuf::from("/data/local/tmp"),
+ };
+ device
+ .tempfile
+ .push(Uuid::new_v4().as_hyphenated().to_string());
+
+ // check for rooted devices
+ let uid_check = |id: String| id.contains("uid=0");
+ device.adbd_root = device
+ .execute_host_shell_command("id")
+ .map_or(false, uid_check);
+ device.su_0_root = device
+ .execute_host_shell_command("su 0 id")
+ .map_or(false, uid_check);
+ device.su_c_root = device
+ .execute_host_shell_command("su -c id")
+ .map_or(false, uid_check);
+ device.is_rooted = device.adbd_root || device.su_0_root || device.su_c_root;
+
+ device.storage = match storage {
+ AndroidStorageInput::App => AndroidStorage::App,
+ AndroidStorageInput::Internal => AndroidStorage::Internal,
+ AndroidStorageInput::Sdcard => AndroidStorage::Sdcard,
+ AndroidStorageInput::Auto => AndroidStorage::Sdcard,
+ };
+
+ if device.is_rooted {
+ info!("Device is rooted");
+
+ // Set Permissive=1 if we have root.
+ device.execute_host_shell_command("setenforce permissive")?;
+ } else {
+ info!("Device is unrooted");
+ }
+
+ Ok(device)
+ }
+
+ pub fn clear_app_data(&self, package: &str) -> Result<bool> {
+ self.execute_host_shell_command(&format!("pm clear {}", package))
+ .map(|v| v.contains("Success"))
+ }
+
+ pub fn create_dir(&self, path: &UnixPath) -> Result<()> {
+ debug!("Creating {}", path.display());
+
+ let enable_run_as = self.enable_run_as_for_path(path);
+ self.execute_host_shell_command_as(&format!("mkdir -p {}", path.display()), enable_run_as)?;
+
+ Ok(())
+ }
+
+ pub fn chmod(&self, path: &UnixPath, mask: &str, recursive: bool) -> Result<()> {
+ let enable_run_as = self.enable_run_as_for_path(path);
+
+ let recursive = match recursive {
+ true => " -R",
+ false => "",
+ };
+
+ self.execute_host_shell_command_as(
+ &format!("chmod {} {} {}", recursive, mask, path.display()),
+ enable_run_as,
+ )?;
+
+ Ok(())
+ }
+
+ pub fn execute_host_command(
+ &self,
+ command: &str,
+ has_output: bool,
+ has_length: bool,
+ ) -> Result<String> {
+ let mut stream = self.host.connect()?;
+
+ let switch_command = format!("host:transport:{}", self.serial);
+ trace!("execute_host_command: >> {:?}", &switch_command);
+ stream.write_all(encode_message(&switch_command)?.as_bytes())?;
+ let _bytes = read_response(&mut stream, false, false)?;
+ trace!("execute_host_command: << {:?}", _bytes);
+ // TODO: should we assert no bytes were read?
+
+ trace!("execute_host_command: >> {:?}", &command);
+ stream.write_all(encode_message(command)?.as_bytes())?;
+ let bytes = read_response(&mut stream, has_output, has_length)?;
+ let response = std::str::from_utf8(&bytes)?;
+ trace!("execute_host_command: << {:?}", response);
+
+ // Unify new lines by removing possible carriage returns
+ Ok(response.replace("\r\n", "\n"))
+ }
+
+ pub fn enable_run_as_for_path(&self, path: &UnixPath) -> bool {
+ match &self.run_as_package {
+ Some(package) => {
+ let mut p = UnixPathBuf::from("/data/data/");
+ p.push(package);
+ path.starts_with(p)
+ }
+ None => false,
+ }
+ }
+
+ pub fn execute_host_shell_command(&self, shell_command: &str) -> Result<String> {
+ self.execute_host_shell_command_as(shell_command, false)
+ }
+
+ pub fn execute_host_shell_command_as(
+ &self,
+ shell_command: &str,
+ enable_run_as: bool,
+ ) -> Result<String> {
+ // We don't want to duplicate su invocations.
+ if shell_command.starts_with("su") {
+ return self.execute_host_command(&format!("shell:{}", shell_command), true, false);
+ }
+
+ let has_outer_quotes = shell_command.starts_with('"') && shell_command.ends_with('"')
+ || shell_command.starts_with('\'') && shell_command.ends_with('\'');
+
+ if self.adbd_root {
+ return self.execute_host_command(&format!("shell:{}", shell_command), true, false);
+ }
+
+ if self.su_0_root {
+ return self.execute_host_command(
+ &format!("shell:su 0 {}", shell_command),
+ true,
+ false,
+ );
+ }
+
+ if self.su_c_root {
+ if has_outer_quotes {
+ return self.execute_host_command(
+ &format!("shell:su -c {}", shell_command),
+ true,
+ false,
+ );
+ }
+
+ if SYNC_REGEX.is_match(shell_command) {
+ let arg: &str = &shell_command.replace('\'', "'\"'\"'")[..];
+ return self.execute_host_command(&format!("shell:su -c '{}'", arg), true, false);
+ }
+
+ return self.execute_host_command(
+ &format!("shell:su -c \"{}\"", shell_command),
+ true,
+ false,
+ );
+ }
+
+ // Execute command as package
+ if enable_run_as {
+ let run_as_package = self
+ .run_as_package
+ .as_ref()
+ .ok_or(DeviceError::MissingPackage)?;
+
+ if has_outer_quotes {
+ return self.execute_host_command(
+ &format!("shell:run-as {} {}", run_as_package, shell_command),
+ true,
+ false,
+ );
+ }
+
+ if SYNC_REGEX.is_match(shell_command) {
+ let arg: &str = &shell_command.replace('\'', "'\"'\"'")[..];
+ return self.execute_host_command(
+ &format!("shell:run-as {} {}", run_as_package, arg),
+ true,
+ false,
+ );
+ }
+
+ return self.execute_host_command(
+ &format!("shell:run-as {} \"{}\"", run_as_package, shell_command),
+ true,
+ false,
+ );
+ }
+
+ self.execute_host_command(&format!("shell:{}", shell_command), true, false)
+ }
+
+ pub fn is_app_installed(&self, package: &str) -> Result<bool> {
+ self.execute_host_shell_command(&format!("pm path {}", package))
+ .map(|v| v.contains("package:"))
+ }
+
+ pub fn launch<T: AsRef<str>>(
+ &self,
+ package: &str,
+ activity: &str,
+ am_start_args: &[T],
+ ) -> Result<bool> {
+ let mut am_start = format!("am start -W -n {}/{}", package, activity);
+
+ for arg in am_start_args {
+ am_start.push(' ');
+ if SYNC_REGEX.is_match(arg.as_ref()) {
+ am_start.push_str(&format!("\"{}\"", &shell::escape(arg.as_ref())));
+ } else {
+ am_start.push_str(&shell::escape(arg.as_ref()));
+ };
+ }
+
+ self.execute_host_shell_command(&am_start)
+ .map(|v| v.contains("Complete"))
+ }
+
+ pub fn force_stop(&self, package: &str) -> Result<()> {
+ debug!("Force stopping Android package: {}", package);
+ self.execute_host_shell_command(&format!("am force-stop {}", package))
+ .and(Ok(()))
+ }
+
+ pub fn forward_port(&self, local: u16, remote: u16) -> Result<u16> {
+ let command = format!(
+ "host-serial:{}:forward:tcp:{};tcp:{}",
+ self.serial, local, remote
+ );
+ let response = self.host.execute_command(&command, true, false)?;
+
+ if local == 0 {
+ Ok(response.parse::<u16>()?)
+ } else {
+ Ok(local)
+ }
+ }
+
+ pub fn kill_forward_port(&self, local: u16) -> Result<()> {
+ let command = format!("host-serial:{}:killforward:tcp:{}", self.serial, local);
+ self.execute_host_command(&command, true, false).and(Ok(()))
+ }
+
+ pub fn kill_forward_all_ports(&self) -> Result<()> {
+ let command = format!("host-serial:{}:killforward-all", self.serial);
+ self.execute_host_command(&command, false, false)
+ .and(Ok(()))
+ }
+
+ pub fn reverse_port(&self, remote: u16, local: u16) -> Result<u16> {
+ let command = format!("reverse:forward:tcp:{};tcp:{}", remote, local);
+ let response = self.execute_host_command(&command, true, false)?;
+
+ if remote == 0 {
+ Ok(response.parse::<u16>()?)
+ } else {
+ Ok(remote)
+ }
+ }
+
+ pub fn kill_reverse_port(&self, remote: u16) -> Result<()> {
+ let command = format!("reverse:killforward:tcp:{}", remote);
+ self.execute_host_command(&command, true, true).and(Ok(()))
+ }
+
+ pub fn kill_reverse_all_ports(&self) -> Result<()> {
+ let command = "reverse:killforward-all".to_owned();
+ self.execute_host_command(&command, false, false)
+ .and(Ok(()))
+ }
+
+ pub fn list_dir(&self, src: &UnixPath) -> Result<Vec<RemoteDirEntry>> {
+ let src = src.to_path_buf();
+ let mut queue = vec![(src.clone(), 0, "".to_string())];
+
+ let mut listings = Vec::new();
+
+ while let Some((next, depth, prefix)) = queue.pop() {
+ for listing in self.list_dir_flat(&next, depth, prefix)? {
+ if listing.metadata == RemoteMetadata::RemoteDir {
+ let mut child = src.clone();
+ child.push(listing.name.clone());
+ queue.push((child, depth + 1, listing.name.clone()));
+ }
+
+ listings.push(listing);
+ }
+ }
+
+ Ok(listings)
+ }
+
+ fn list_dir_flat(
+ &self,
+ src: &UnixPath,
+ depth: usize,
+ prefix: String,
+ ) -> Result<Vec<RemoteDirEntry>> {
+ // Implement the ADB protocol to list a directory from the device.
+ let mut stream = self.host.connect()?;
+
+ // Send "host:transport" command with device serial
+ let message = encode_message(&format!("host:transport:{}", self.serial))?;
+ stream.write_all(message.as_bytes())?;
+ let _bytes = read_response(&mut stream, false, true)?;
+
+ // Send "sync:" command to initialize file transfer
+ let message = encode_message("sync:")?;
+ stream.write_all(message.as_bytes())?;
+ let _bytes = read_response(&mut stream, false, true)?;
+
+ // Send "LIST" command with name of the directory
+ stream.write_all(SyncCommand::List.code())?;
+ let args_ = format!("{}", src.display());
+ let args = args_.as_bytes();
+ write_length_little_endian(&mut stream, args.len())?;
+ stream.write_all(args)?;
+
+ // Use the maximum 64KB buffer to transfer the file contents.
+ let mut buf = [0; 64 * 1024];
+
+ let mut listings = Vec::new();
+
+ // Read "DENT" command one or more times for the directory entries
+ loop {
+ stream.read_exact(&mut buf[0..4])?;
+
+ if &buf[0..4] == SyncCommand::Dent.code() {
+ // From https://github.com/cstyan/adbDocumentation/blob/6d025b3e4af41be6f93d37f516a8ac7913688623/README.md:
+ //
+ // A four-byte integer representing file mode - first 9 bits of this mode represent
+ // the file permissions, as with chmod mode. Bits 14 to 16 seem to represent the
+ // file type, one of 0b100 (file), 0b010 (directory), 0b101 (symlink)
+ // A four-byte integer representing file size.
+ // A four-byte integer representing last modified time in seconds since Unix Epoch.
+ // A four-byte integer representing file name length.
+ // A utf-8 string representing the file name.
+ let mode = read_length_little_endian(&mut stream)?;
+ let size = read_length_little_endian(&mut stream)?;
+ let _time = read_length_little_endian(&mut stream)?;
+ let name_length = read_length_little_endian(&mut stream)?;
+ stream.read_exact(&mut buf[0..name_length])?;
+
+ let mut name = std::str::from_utf8(&buf[0..name_length])?.to_owned();
+
+ if name == "." || name == ".." {
+ continue;
+ }
+
+ if !prefix.is_empty() {
+ name = format!("{}/{}", prefix, &name);
+ }
+
+ let file_type = (mode >> 13) & 0b111;
+ let metadata = match file_type {
+ 0b010 => RemoteMetadata::RemoteDir,
+ 0b100 => RemoteMetadata::RemoteFile(RemoteFileMetadata {
+ mode: mode & 0b111111111,
+ size,
+ }),
+ 0b101 => RemoteMetadata::RemoteSymlink,
+ _ => return Err(DeviceError::Adb(format!("Invalid file mode {}", file_type))),
+ };
+
+ listings.push(RemoteDirEntry {
+ name,
+ depth,
+ metadata,
+ });
+ } else if &buf[0..4] == SyncCommand::Done.code() {
+ // "DONE" command indicates end of file transfer
+ break;
+ } else if &buf[0..4] == SyncCommand::Fail.code() {
+ let n = buf.len().min(read_length_little_endian(&mut stream)?);
+
+ stream.read_exact(&mut buf[0..n])?;
+
+ let message = std::str::from_utf8(&buf[0..n])
+ .map(|s| format!("adb error: {}", s))
+ .unwrap_or_else(|_| "adb error was not utf-8".into());
+
+ return Err(DeviceError::Adb(message));
+ } else {
+ return Err(DeviceError::Adb("FAIL (unknown)".to_owned()));
+ }
+ }
+
+ Ok(listings)
+ }
+
+ pub fn path_exists(&self, path: &UnixPath, enable_run_as: bool) -> Result<bool> {
+ self.execute_host_shell_command_as(format!("ls {}", path.display()).as_str(), enable_run_as)
+ .map(|path| !path.contains("No such file or directory"))
+ }
+
+ pub fn pull(&self, src: &UnixPath, buffer: &mut dyn Write) -> Result<()> {
+ // Implement the ADB protocol to receive a file from the device.
+ let mut stream = self.host.connect()?;
+
+ // Send "host:transport" command with device serial
+ let message = encode_message(&format!("host:transport:{}", self.serial))?;
+ stream.write_all(message.as_bytes())?;
+ let _bytes = read_response(&mut stream, false, true)?;
+
+ // Send "sync:" command to initialize file transfer
+ let message = encode_message("sync:")?;
+ stream.write_all(message.as_bytes())?;
+ let _bytes = read_response(&mut stream, false, true)?;
+
+ // Send "RECV" command with name of the file
+ stream.write_all(SyncCommand::Recv.code())?;
+ let args_string = format!("{}", src.display());
+ let args = args_string.as_bytes();
+ write_length_little_endian(&mut stream, args.len())?;
+ stream.write_all(args)?;
+
+ // Use the maximum 64KB buffer to transfer the file contents.
+ let mut buf = [0; 64 * 1024];
+
+ // Read "DATA" command one or more times for the file content
+ loop {
+ stream.read_exact(&mut buf[0..4])?;
+
+ if &buf[0..4] == SyncCommand::Data.code() {
+ let len = read_length_little_endian(&mut stream)?;
+ stream.read_exact(&mut buf[0..len])?;
+ buffer.write_all(&buf[0..len])?;
+ } else if &buf[0..4] == SyncCommand::Done.code() {
+ // "DONE" command indicates end of file transfer
+ break;
+ } else if &buf[0..4] == SyncCommand::Fail.code() {
+ let n = buf.len().min(read_length_little_endian(&mut stream)?);
+
+ stream.read_exact(&mut buf[0..n])?;
+
+ let message = std::str::from_utf8(&buf[0..n])
+ .map(|s| format!("adb error: {}", s))
+ .unwrap_or_else(|_| "adb error was not utf-8".into());
+
+ return Err(DeviceError::Adb(message));
+ } else {
+ return Err(DeviceError::Adb("FAIL (unknown)".to_owned()));
+ }
+ }
+
+ Ok(())
+ }
+
+ pub fn pull_dir(&self, src: &UnixPath, dest_dir: &Path) -> Result<()> {
+ let src = src.to_path_buf();
+ let dest_dir = dest_dir.to_path_buf();
+
+ for entry in self.list_dir(&src)? {
+ match entry.metadata {
+ RemoteMetadata::RemoteSymlink => {} // Ignored.
+ RemoteMetadata::RemoteDir => {
+ let mut d = dest_dir.clone();
+ d.push(&entry.name);
+
+ std::fs::create_dir_all(&d)?;
+ }
+ RemoteMetadata::RemoteFile(_) => {
+ let mut s = src.clone();
+ s.push(&entry.name);
+ let mut d = dest_dir.clone();
+ d.push(&entry.name);
+
+ self.pull(&s, &mut File::create(d)?)?;
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ pub fn push(&self, buffer: &mut dyn Read, dest: &UnixPath, mode: u32) -> Result<()> {
+ // Implement the ADB protocol to send a file to the device.
+ // The protocol consists of the following steps:
+ // * Send "host:transport" command with device serial
+ // * Send "sync:" command to initialize file transfer
+ // * Send "SEND" command with name and mode of the file
+ // * Send "DATA" command one or more times for the file content
+ // * Send "DONE" command to indicate end of file transfer
+
+ let enable_run_as = self.enable_run_as_for_path(&dest.to_path_buf());
+ let dest1 = match enable_run_as {
+ true => self.tempfile.as_path(),
+ false => UnixPath::new(dest),
+ };
+
+ // If the destination directory does not exist, adb will
+ // create it and any necessary ancestors however it will not
+ // set the directory permissions to 0o777. In addition,
+ // Android 9 (P) has a bug in its push implementation which
+ // will cause a push which creates directories to fail with
+ // the error `secure_mkdirs failed: Operation not
+ // permitted`. We can work around this by creating the
+ // destination directories prior to the push. Collect the
+ // ancestors of the destination directory which do not yet
+ // exist so we can create them and adjust their permissions
+ // prior to performing the push.
+ let mut current = dest.parent();
+ let mut leaf: Option<&UnixPath> = None;
+ let mut root: Option<&UnixPath> = None;
+
+ while let Some(path) = current {
+ if self.path_exists(path, enable_run_as)? {
+ break;
+ }
+ if leaf.is_none() {
+ leaf = Some(path);
+ }
+ root = Some(path);
+ current = path.parent();
+ }
+
+ if let Some(path) = leaf {
+ self.create_dir(path)?;
+ }
+
+ if let Some(path) = root {
+ self.chmod(path, "777", true)?;
+ }
+
+ let mut stream = self.host.connect()?;
+
+ let message = encode_message(&format!("host:transport:{}", self.serial))?;
+ stream.write_all(message.as_bytes())?;
+ let _bytes = read_response(&mut stream, false, true)?;
+
+ let message = encode_message("sync:")?;
+ stream.write_all(message.as_bytes())?;
+ let _bytes = read_response(&mut stream, false, true)?;
+
+ stream.write_all(SyncCommand::Send.code())?;
+ let args_ = format!("{},{}", dest1.display(), mode);
+ let args = args_.as_bytes();
+ write_length_little_endian(&mut stream, args.len())?;
+ stream.write_all(args)?;
+
+ // Use a 32KB buffer to transfer the file contents
+ // TODO: Maybe adjust to maxdata (256KB)
+ let mut buf = [0; 32 * 1024];
+
+ loop {
+ let len = buffer.read(&mut buf)?;
+
+ if len == 0 {
+ break;
+ }
+
+ stream.write_all(SyncCommand::Data.code())?;
+ write_length_little_endian(&mut stream, len)?;
+ stream.write_all(&buf[0..len])?;
+ }
+
+ // https://android.googlesource.com/platform/system/core/+/master/adb/SYNC.TXT#66
+ //
+ // When the file is transferred a sync request "DONE" is sent, where length is set
+ // to the last modified time for the file. The server responds to this last
+ // request (but not to chunk requests) with an "OKAY" sync response (length can
+ // be ignored).
+ let time: u32 = ((SystemTime::now().duration_since(SystemTime::UNIX_EPOCH))
+ .unwrap()
+ .as_secs()
+ & 0xFFFF_FFFF) as u32;
+
+ stream.write_all(SyncCommand::Done.code())?;
+ write_length_little_endian(&mut stream, time as usize)?;
+
+ // Status.
+ stream.read_exact(&mut buf[0..4])?;
+
+ if buf.starts_with(SyncCommand::Okay.code()) {
+ if enable_run_as {
+ // Use cp -a to preserve the permissions set by push.
+ let result = self.execute_host_shell_command_as(
+ format!("cp -aR {} {}", dest1.display(), dest.display()).as_str(),
+ enable_run_as,
+ );
+ if self.remove(dest1).is_err() {
+ warn!("Failed to remove {}", dest1.display());
+ }
+ result?;
+ }
+ Ok(())
+ } else if buf.starts_with(SyncCommand::Fail.code()) {
+ if enable_run_as && self.remove(dest1).is_err() {
+ warn!("Failed to remove {}", dest1.display());
+ }
+ let n = buf.len().min(read_length_little_endian(&mut stream)?);
+
+ stream.read_exact(&mut buf[0..n])?;
+
+ let message = std::str::from_utf8(&buf[0..n])
+ .map(|s| format!("adb error: {}", s))
+ .unwrap_or_else(|_| "adb error was not utf-8".into());
+
+ Err(DeviceError::Adb(message))
+ } else {
+ if self.remove(dest1).is_err() {
+ warn!("Failed to remove {}", dest1.display());
+ }
+ Err(DeviceError::Adb("FAIL (unknown)".to_owned()))
+ }
+ }
+
+ pub fn push_dir(&self, source: &Path, dest_dir: &UnixPath, mode: u32) -> Result<()> {
+ debug!("Pushing {} to {}", source.display(), dest_dir.display());
+
+ let walker = WalkDir::new(source).follow_links(false).into_iter();
+
+ for entry in walker {
+ let entry = entry?;
+ let path = entry.path();
+
+ if !entry.metadata()?.is_file() {
+ continue;
+ }
+
+ let mut file = File::open(path)?;
+
+ let tail = path
+ .strip_prefix(source)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
+
+ let dest = append_components(dest_dir, tail)?;
+ self.push(&mut file, &dest, mode)?;
+ }
+
+ Ok(())
+ }
+
+ pub fn remove(&self, path: &UnixPath) -> Result<()> {
+ debug!("Deleting {}", path.display());
+
+ self.execute_host_shell_command_as(
+ &format!("rm -rf {}", path.display()),
+ self.enable_run_as_for_path(path),
+ )?;
+
+ Ok(())
+ }
+}
+
+pub(crate) fn append_components(
+ base: &UnixPath,
+ tail: &Path,
+) -> std::result::Result<UnixPathBuf, io::Error> {
+ let mut buf = base.to_path_buf();
+
+ for component in tail.components() {
+ if let Component::Normal(segment) = component {
+ let utf8 = segment.to_str().ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::Other,
+ "Could not represent path segment as UTF-8",
+ )
+ })?;
+ buf.push(utf8);
+ } else {
+ return Err(io::Error::new(
+ io::ErrorKind::Other,
+ "Unexpected path component".to_owned(),
+ ));
+ }
+ }
+
+ Ok(buf)
+}
diff --git a/testing/mozbase/rust/mozdevice/src/shell.rs b/testing/mozbase/rust/mozdevice/src/shell.rs
new file mode 100644
index 0000000000..55a71c41d5
--- /dev/null
+++ b/testing/mozbase/rust/mozdevice/src/shell.rs
@@ -0,0 +1,66 @@
+// Copyright (c) 2017 Jimmy Cuadra
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+use regex::Regex;
+
+/// Escapes a string so it will be interpreted as a single word by the UNIX Bourne shell.
+///
+/// If the input string is empty, this function returns an empty quoted string.
+pub fn escape(input: &str) -> String {
+ // Stolen from
+ // https://docs.rs/shellwords/1.0.0/src/shellwords/lib.rs.html#24-37.
+ // Added space to the pattern to exclude spaces from being escaped
+ // which can cause problems when combining strings to form a full
+ // command.
+ let escape_pattern: Regex = Regex::new(r"([^A-Za-z0-9_\-.,:/@ \n])").unwrap();
+
+ if input.is_empty() {
+ return "''".to_owned();
+ }
+
+ let output = &escape_pattern.replace_all(input, "\\$1");
+
+ output.replace("'\n'", r"\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::escape;
+
+ #[test]
+ fn empty_escape() {
+ assert_eq!(escape(""), "''");
+ }
+
+ #[test]
+ fn full_escape() {
+ assert_eq!(escape("foo '\"' bar"), "foo \\'\\\"\\' bar");
+ }
+
+ #[test]
+ fn escape_multibyte() {
+ assert_eq!(escape("あい"), "\\あ\\い");
+ }
+
+ #[test]
+ fn escape_newline() {
+ assert_eq!(escape(r"'\n'"), "\\\'\\\\n\\\'");
+ }
+}
diff --git a/testing/mozbase/rust/mozdevice/src/test.rs b/testing/mozbase/rust/mozdevice/src/test.rs
new file mode 100644
index 0000000000..b4173e3649
--- /dev/null
+++ b/testing/mozbase/rust/mozdevice/src/test.rs
@@ -0,0 +1,760 @@
+/* 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/. */
+
+// Currently the mozdevice API is not safe for multiple requests at the same
+// time. It is recommended to run each of the unit tests on its own. Also adb
+// specific tests cannot be run in CI yet. To check those locally, also run
+// the ignored tests.
+//
+// Use the following command to accomplish that:
+//
+// $ cargo test -- --ignored --test-threads=1
+
+use crate::*;
+
+use std::collections::BTreeSet;
+use std::panic;
+use std::path::PathBuf;
+use tempfile::{tempdir, TempDir};
+
+#[test]
+fn read_length_from_valid_string() {
+ fn test(message: &str) -> Result<usize> {
+ read_length(&mut io::BufReader::new(message.as_bytes()))
+ }
+
+ assert_eq!(test("0000").unwrap(), 0);
+ assert_eq!(test("0001").unwrap(), 1);
+ assert_eq!(test("000F").unwrap(), 15);
+ assert_eq!(test("00FF").unwrap(), 255);
+ assert_eq!(test("0FFF").unwrap(), 4095);
+ assert_eq!(test("FFFF").unwrap(), 65535);
+
+ assert_eq!(test("FFFF0").unwrap(), 65535);
+}
+
+#[test]
+fn read_length_from_invalid_string() {
+ fn test(message: &str) -> Result<usize> {
+ read_length(&mut io::BufReader::new(message.as_bytes()))
+ }
+
+ test("").expect_err("empty string");
+ test("G").expect_err("invalid hex character");
+ test("-1").expect_err("negative number");
+ test("000").expect_err("shorter than 4 bytes");
+}
+
+#[test]
+fn encode_message_with_valid_string() {
+ assert_eq!(encode_message("").unwrap(), "0000".to_string());
+ assert_eq!(encode_message("a").unwrap(), "0001a".to_string());
+ assert_eq!(
+ encode_message(&"a".repeat(15)).unwrap(),
+ format!("000F{}", "a".repeat(15))
+ );
+ assert_eq!(
+ encode_message(&"a".repeat(255)).unwrap(),
+ format!("00FF{}", "a".repeat(255))
+ );
+ assert_eq!(
+ encode_message(&"a".repeat(4095)).unwrap(),
+ format!("0FFF{}", "a".repeat(4095))
+ );
+ assert_eq!(
+ encode_message(&"a".repeat(65535)).unwrap(),
+ format!("FFFF{}", "a".repeat(65535))
+ );
+}
+
+#[test]
+fn encode_message_with_invalid_string() {
+ encode_message(&"a".repeat(65536)).expect_err("string lengths exceeds 4 bytes");
+}
+
+fn run_device_test<F>(test: F)
+where
+ F: FnOnce(&Device, &TempDir, &UnixPath) + panic::UnwindSafe,
+{
+ let host = Host {
+ ..Default::default()
+ };
+ let device = host
+ .device_or_default::<String>(None, AndroidStorageInput::Auto)
+ .expect("device_or_default");
+
+ let tmp_dir = tempdir().expect("create temp dir");
+ let response = device
+ .execute_host_shell_command("echo $EXTERNAL_STORAGE")
+ .unwrap();
+ let mut test_root = UnixPathBuf::from(response.trim_end_matches('\n'));
+ test_root.push("mozdevice");
+
+ let _ = device.remove(&test_root);
+
+ let result = panic::catch_unwind(|| test(&device, &tmp_dir, &test_root));
+
+ let _ = device.kill_forward_all_ports();
+ // let _ = device.kill_reverse_all_ports();
+
+ assert!(result.is_ok())
+}
+
+#[test]
+#[ignore]
+fn host_features() {
+ let host = Host {
+ ..Default::default()
+ };
+
+ let set = host.features::<BTreeSet<_>>().expect("to query features");
+ assert!(set.contains("cmd"));
+ assert!(set.contains("shell_v2"));
+}
+
+#[test]
+#[ignore]
+fn host_devices() {
+ let host = Host {
+ ..Default::default()
+ };
+
+ let set: BTreeSet<_> = host.devices().expect("to query devices");
+ assert_eq!(1, set.len());
+}
+
+#[test]
+#[ignore]
+fn host_device_or_default() {
+ let host = Host {
+ ..Default::default()
+ };
+
+ let devices: Vec<_> = host.devices().expect("to query devices");
+ let expected_device = devices.first().expect("found a device");
+
+ let device = host
+ .device_or_default::<String>(Some(&expected_device.serial), AndroidStorageInput::App)
+ .expect("connected device with serial");
+ assert_eq!(device.run_as_package, None);
+ assert_eq!(device.serial, expected_device.serial);
+ assert!(device.tempfile.starts_with("/data/local/tmp"));
+}
+
+#[test]
+#[ignore]
+fn host_device_or_default_invalid_serial() {
+ let host = Host {
+ ..Default::default()
+ };
+
+ host.device_or_default::<String>(Some(&"foobar".to_owned()), AndroidStorageInput::Auto)
+ .expect_err("invalid serial");
+}
+
+#[test]
+#[ignore]
+fn host_device_or_default_no_serial() {
+ let host = Host {
+ ..Default::default()
+ };
+
+ let devices: Vec<_> = host.devices().expect("to query devices");
+ let expected_device = devices.first().expect("found a device");
+
+ let device = host
+ .device_or_default::<String>(None, AndroidStorageInput::Auto)
+ .expect("connected device with serial");
+ assert_eq!(device.serial, expected_device.serial);
+}
+
+#[test]
+#[ignore]
+fn host_device_or_default_storage_as_app() {
+ let host = Host {
+ ..Default::default()
+ };
+
+ let device = host
+ .device_or_default::<String>(None, AndroidStorageInput::App)
+ .expect("connected device");
+ assert_eq!(device.storage, AndroidStorage::App);
+}
+
+#[test]
+#[ignore]
+fn host_device_or_default_storage_as_auto() {
+ let host = Host {
+ ..Default::default()
+ };
+
+ let device = host
+ .device_or_default::<String>(None, AndroidStorageInput::Auto)
+ .expect("connected device");
+ assert_eq!(device.storage, AndroidStorage::Sdcard);
+}
+
+#[test]
+#[ignore]
+fn host_device_or_default_storage_as_internal() {
+ let host = Host {
+ ..Default::default()
+ };
+
+ let device = host
+ .device_or_default::<String>(None, AndroidStorageInput::Internal)
+ .expect("connected device");
+ assert_eq!(device.storage, AndroidStorage::Internal);
+}
+
+#[test]
+#[ignore]
+fn host_device_or_default_storage_as_sdcard() {
+ let host = Host {
+ ..Default::default()
+ };
+
+ let device = host
+ .device_or_default::<String>(None, AndroidStorageInput::Sdcard)
+ .expect("connected device");
+ assert_eq!(device.storage, AndroidStorage::Sdcard);
+}
+
+#[test]
+#[ignore]
+fn device_shell_command() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ assert_eq!(
+ "Linux\n",
+ device
+ .execute_host_shell_command("uname")
+ .expect("to have shell output")
+ );
+ });
+}
+
+#[test]
+#[ignore]
+fn device_forward_port_hardcoded() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ assert_eq!(
+ 3035,
+ device
+ .forward_port(3035, 3036)
+ .expect("forwarded local port")
+ );
+ // TODO: check with forward --list
+ });
+}
+
+// #[test]
+// #[ignore]
+// TODO: "adb server response to `forward tcp:0 ...` was not a u16: \"000559464\"")
+// fn device_forward_port_system_allocated() {
+// run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+// let local_port = device.forward_port(0, 3037).expect("local_port");
+// assert_ne!(local_port, 0);
+// // TODO: check with forward --list
+// });
+// }
+
+#[test]
+#[ignore]
+fn device_kill_forward_port_no_forwarded_port() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ device
+ .kill_forward_port(3038)
+ .expect_err("adb error: listener 'tcp:3038' ");
+ });
+}
+
+#[test]
+#[ignore]
+fn device_kill_forward_port_twice() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ let local_port = device
+ .forward_port(3039, 3040)
+ .expect("forwarded local port");
+ assert_eq!(local_port, 3039);
+ // TODO: check with forward --list
+ device
+ .kill_forward_port(local_port)
+ .expect("to remove forwarded port");
+ device
+ .kill_forward_port(local_port)
+ .expect_err("adb error: listener 'tcp:3039' ");
+ });
+}
+
+#[test]
+#[ignore]
+fn device_kill_forward_all_ports_no_forwarded_port() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ device
+ .kill_forward_all_ports()
+ .expect("to not fail for no forwarded ports");
+ });
+}
+
+#[test]
+#[ignore]
+fn device_kill_forward_all_ports_twice() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ let local_port1 = device
+ .forward_port(3039, 3040)
+ .expect("forwarded local port");
+ assert_eq!(local_port1, 3039);
+ let local_port2 = device
+ .forward_port(3041, 3042)
+ .expect("forwarded local port");
+ assert_eq!(local_port2, 3041);
+ // TODO: check with forward --list
+ device
+ .kill_forward_all_ports()
+ .expect("to remove all forwarded ports");
+ device
+ .kill_forward_all_ports()
+ .expect("to not fail for no forwarded ports");
+ });
+}
+
+#[test]
+#[ignore]
+fn device_reverse_port_hardcoded() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ assert_eq!(4035, device.reverse_port(4035, 4036).expect("remote_port"));
+ // TODO: check with reverse --list
+ });
+}
+
+// #[test]
+// #[ignore]
+// TODO: No adb response: ParseInt(ParseIntError { kind: Empty })
+// fn device_reverse_port_system_allocated() {
+// run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+// let reverse_port = device.reverse_port(0, 4037).expect("remote port");
+// assert_ne!(reverse_port, 0);
+// // TODO: check with reverse --list
+// });
+// }
+
+#[test]
+#[ignore]
+fn device_kill_reverse_port_no_reverse_port() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ device
+ .kill_reverse_port(4038)
+ .expect_err("listener 'tcp:4038' not found");
+ });
+}
+
+// #[test]
+// #[ignore]
+// TODO: "adb error: adb server response did not contain expected hexstring length: \"\""
+// fn device_kill_reverse_port_twice() {
+// run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+// let remote_port = device
+// .reverse_port(4039, 4040)
+// .expect("reversed local port");
+// assert_eq!(remote_port, 4039);
+// // TODO: check with reverse --list
+// device
+// .kill_reverse_port(remote_port)
+// .expect("to remove reverse port");
+// device
+// .kill_reverse_port(remote_port)
+// .expect_err("listener 'tcp:4039' not found");
+// });
+// }
+
+#[test]
+#[ignore]
+fn device_kill_reverse_all_ports_no_reversed_port() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ device
+ .kill_reverse_all_ports()
+ .expect("to not fail for no reversed ports");
+ });
+}
+
+#[test]
+#[ignore]
+fn device_kill_reverse_all_ports_twice() {
+ run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| {
+ let local_port1 = device
+ .forward_port(4039, 4040)
+ .expect("forwarded local port");
+ assert_eq!(local_port1, 4039);
+ let local_port2 = device
+ .forward_port(4041, 4042)
+ .expect("forwarded local port");
+ assert_eq!(local_port2, 4041);
+ // TODO: check with reverse --list
+ device
+ .kill_reverse_all_ports()
+ .expect("to remove all reversed ports");
+ device
+ .kill_reverse_all_ports()
+ .expect("to not fail for no reversed ports");
+ });
+}
+
+#[test]
+#[ignore]
+fn device_push_pull_text_file() {
+ run_device_test(
+ |device: &Device, _: &TempDir, remote_root_path: &UnixPath| {
+ let content = "test";
+ let remote_path = remote_root_path.join("foo.txt");
+
+ device
+ .push(
+ &mut io::BufReader::new(content.as_bytes()),
+ &remote_path,
+ 0o777,
+ )
+ .expect("file has been pushed");
+
+ let file_content = device
+ .execute_host_shell_command(&format!("cat {}", remote_path.display()))
+ .expect("host shell command for 'cat' to succeed");
+
+ assert_eq!(file_content, content);
+
+ // And as second step pull it off the device.
+ let mut buffer = Vec::new();
+ device
+ .pull(&remote_path, &mut buffer)
+ .expect("file has been pulled");
+ assert_eq!(buffer, content.as_bytes());
+ },
+ );
+}
+
+#[test]
+#[ignore]
+fn device_push_pull_large_binary_file() {
+ run_device_test(
+ |device: &Device, _: &TempDir, remote_root_path: &UnixPath| {
+ let remote_path = remote_root_path.join("foo.binary");
+
+ let mut content = Vec::new();
+
+ // Needs to be larger than 64kB to test multiple chunks.
+ for i in 0..100000u32 {
+ content.push('0' as u8 + (i % 10) as u8);
+ }
+
+ device
+ .push(
+ &mut std::io::Cursor::new(content.clone()),
+ &remote_path,
+ 0o777,
+ )
+ .expect("large file has been pushed");
+
+ let output = device
+ .execute_host_shell_command(&format!("ls -l {}", remote_path.display()))
+ .expect("host shell command for 'ls' to succeed");
+
+ assert!(output.contains(remote_path.to_str().unwrap()));
+
+ let mut buffer = Vec::new();
+
+ device
+ .pull(&remote_path, &mut buffer)
+ .expect("large binary file has been pulled");
+ assert_eq!(buffer, content);
+ },
+ );
+}
+
+#[test]
+#[ignore]
+fn device_push_permission() {
+ run_device_test(
+ |device: &Device, _: &TempDir, remote_root_path: &UnixPath| {
+ fn adjust_mode(mode: u32) -> u32 {
+ // Adjust the mode by copying the user permissions to
+ // group and other as indicated in
+ // [send_impl](https://android.googlesource.com/platform/system/core/+/master/adb/daemon/file_sync_service.cpp#516).
+ // This ensures that group and other can both access a
+ // file if the user can access it.
+ let mut m = mode & 0o777;
+ m |= (m >> 3) & 0o070;
+ m |= (m >> 3) & 0o007;
+ m
+ }
+
+ fn get_permissions(mode: u32) -> String {
+ // Convert the mode integer into the string representation
+ // of the mode returned by `ls`. This assumes the object is
+ // a file and not a directory.
+ let mut perms = vec!["-", "r", "w", "x", "r", "w", "x", "r", "w", "x"];
+ let mut bit_pos = 0;
+ while bit_pos < 9 {
+ if (1 << bit_pos) & mode == 0 {
+ perms[9 - bit_pos] = "-"
+ }
+ bit_pos += 1;
+ }
+ perms.concat()
+ }
+ let content = "test";
+ let remote_path = remote_root_path.join("foo.bar");
+
+ // First push the file to the device
+ let modes = vec![0o421, 0o644, 0o666, 0o777];
+ for mode in modes {
+ let adjusted_mode = adjust_mode(mode);
+ let adjusted_perms = get_permissions(adjusted_mode);
+ device
+ .push(
+ &mut io::BufReader::new(content.as_bytes()),
+ &remote_path,
+ mode,
+ )
+ .expect("file has been pushed");
+
+ let output = device
+ .execute_host_shell_command(&format!("ls -l {}", remote_path.display()))
+ .expect("host shell command for 'ls' to succeed");
+
+ assert!(output.contains(remote_path.to_str().unwrap()));
+ assert!(output.starts_with(&adjusted_perms));
+ }
+
+ let output = device
+ .execute_host_shell_command(&format!("ls -ld {}", remote_root_path.display()))
+ .expect("host shell command for 'ls parent' to succeed");
+
+ assert!(output.contains(remote_root_path.to_str().unwrap()));
+ assert!(output.starts_with("drwxrwxrwx"));
+ },
+ );
+}
+
+#[test]
+#[ignore]
+fn device_pull_fails_for_missing_file() {
+ run_device_test(
+ |device: &Device, _: &TempDir, remote_root_path: &UnixPath| {
+ let mut buffer = Vec::new();
+
+ device
+ .pull(&remote_root_path.join("missing"), &mut buffer)
+ .expect_err("missing file should not be pulled");
+ },
+ );
+}
+
+#[test]
+#[ignore]
+fn device_push_and_list_dir() {
+ run_device_test(
+ |device: &Device, tmp_dir: &TempDir, remote_root_path: &UnixPath| {
+ let files = ["foo1.bar", "foo2.bar", "bar/foo3.bar", "bar/more/foo3.bar"];
+
+ for file in files.iter() {
+ let path = tmp_dir.path().join(Path::new(file));
+ let _ = std::fs::create_dir_all(path.parent().unwrap());
+
+ let f = File::create(path).expect("to create file");
+ let mut f = io::BufWriter::new(f);
+ f.write_all(file.as_bytes()).expect("to write data");
+ }
+
+ device
+ .push_dir(tmp_dir.path(), &remote_root_path, 0o777)
+ .expect("to push_dir");
+
+ for file in files.iter() {
+ let path = append_components(remote_root_path, Path::new(file)).unwrap();
+ let output = device
+ .execute_host_shell_command(&format!("ls {}", path.display()))
+ .expect("host shell command for 'ls' to succeed");
+
+ assert!(output.contains(path.to_str().unwrap()));
+ }
+
+ let mut listings = device.list_dir(&remote_root_path).expect("to list_dir");
+ listings.sort();
+ assert_eq!(
+ listings,
+ vec![
+ RemoteDirEntry {
+ depth: 0,
+ name: "foo1.bar".to_string(),
+ metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata {
+ mode: 0b110110000,
+ size: 8
+ })
+ },
+ RemoteDirEntry {
+ depth: 0,
+ name: "foo2.bar".to_string(),
+ metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata {
+ mode: 0b110110000,
+ size: 8
+ })
+ },
+ RemoteDirEntry {
+ depth: 0,
+ name: "bar".to_string(),
+ metadata: RemoteMetadata::RemoteDir
+ },
+ RemoteDirEntry {
+ depth: 1,
+ name: "bar/foo3.bar".to_string(),
+ metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata {
+ mode: 0b110110000,
+ size: 12
+ })
+ },
+ RemoteDirEntry {
+ depth: 1,
+ name: "bar/more".to_string(),
+ metadata: RemoteMetadata::RemoteDir
+ },
+ RemoteDirEntry {
+ depth: 2,
+ name: "bar/more/foo3.bar".to_string(),
+ metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata {
+ mode: 0b110110000,
+ size: 17
+ })
+ }
+ ]
+ );
+ },
+ );
+}
+
+#[test]
+#[ignore]
+fn device_push_and_pull_dir() {
+ run_device_test(
+ |device: &Device, tmp_dir: &TempDir, remote_root_path: &UnixPath| {
+ let files = ["foo1.bar", "foo2.bar", "bar/foo3.bar", "bar/more/foo3.bar"];
+
+ let src_dir = tmp_dir.path().join(Path::new("src"));
+ let dest_dir = tmp_dir.path().join(Path::new("src"));
+
+ for file in files.iter() {
+ let path = src_dir.join(Path::new(file));
+ let _ = std::fs::create_dir_all(path.parent().unwrap());
+
+ let f = File::create(path).expect("to create file");
+ let mut f = io::BufWriter::new(f);
+ f.write_all(file.as_bytes()).expect("to write data");
+ }
+
+ device
+ .push_dir(&src_dir, &remote_root_path, 0o777)
+ .expect("to push_dir");
+
+ device
+ .pull_dir(remote_root_path, &dest_dir)
+ .expect("to pull_dir");
+
+ for file in files.iter() {
+ let path = dest_dir.join(Path::new(file));
+ let mut f = File::open(path).expect("to open file");
+ let mut buf = String::new();
+ f.read_to_string(&mut buf).expect("to read content");
+ assert_eq!(buf, *file);
+ }
+ },
+ )
+}
+
+#[test]
+#[ignore]
+fn device_push_and_list_dir_flat() {
+ run_device_test(
+ |device: &Device, tmp_dir: &TempDir, remote_root_path: &UnixPath| {
+ let content = "test";
+
+ let files = [
+ PathBuf::from("foo1.bar"),
+ PathBuf::from("foo2.bar"),
+ PathBuf::from("bar").join("foo3.bar"),
+ ];
+
+ for file in files.iter() {
+ let path = tmp_dir.path().join(&file);
+ let _ = std::fs::create_dir_all(path.parent().unwrap());
+
+ let f = File::create(path).expect("to create file");
+ let mut f = io::BufWriter::new(f);
+ f.write_all(content.as_bytes()).expect("to write data");
+ }
+
+ device
+ .push_dir(tmp_dir.path(), &remote_root_path, 0o777)
+ .expect("to push_dir");
+
+ for file in files.iter() {
+ let path = append_components(remote_root_path, file).unwrap();
+ let output = device
+ .execute_host_shell_command(&format!("ls {}", path.display()))
+ .expect("host shell command for 'ls' to succeed");
+
+ assert!(output.contains(path.to_str().unwrap()));
+ }
+
+ let mut listings = device
+ .list_dir_flat(&remote_root_path, 7, "prefix".to_string())
+ .expect("to list_dir_flat");
+ listings.sort();
+ assert_eq!(
+ listings,
+ vec![
+ RemoteDirEntry {
+ depth: 7,
+ metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata {
+ mode: 0b110110000,
+ size: 4
+ }),
+ name: "prefix/foo1.bar".to_string(),
+ },
+ RemoteDirEntry {
+ depth: 7,
+ metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata {
+ mode: 0b110110000,
+ size: 4
+ }),
+ name: "prefix/foo2.bar".to_string(),
+ },
+ RemoteDirEntry {
+ depth: 7,
+ metadata: RemoteMetadata::RemoteDir,
+ name: "prefix/bar".to_string(),
+ },
+ ]
+ );
+ },
+ );
+}
+
+#[test]
+fn format_own_device_error_types() {
+ assert_eq!(
+ format!("{}", DeviceError::InvalidStorage),
+ "Invalid storage".to_string()
+ );
+ assert_eq!(
+ format!("{}", DeviceError::MissingPackage),
+ "Missing package".to_string()
+ );
+ assert_eq!(
+ format!("{}", DeviceError::MultipleDevices),
+ "Multiple Android devices online".to_string()
+ );
+
+ assert_eq!(
+ format!("{}", DeviceError::Adb("foo".to_string())),
+ "foo".to_string()
+ );
+}
diff --git a/testing/mozbase/rust/mozprofile/Cargo.toml b/testing/mozbase/rust/mozprofile/Cargo.toml
new file mode 100644
index 0000000000..553e9493ac
--- /dev/null
+++ b/testing/mozbase/rust/mozprofile/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+edition = "2018"
+name = "mozprofile"
+version = "0.9.1"
+authors = ["Mozilla"]
+description = "Library for working with Mozilla profiles."
+keywords = [
+ "firefox",
+ "mozilla",
+]
+license = "MPL-2.0"
+repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozprofile"
+
+[dependencies]
+tempfile = "3"
diff --git a/testing/mozbase/rust/mozprofile/fuzz/Cargo.toml b/testing/mozbase/rust/mozprofile/fuzz/Cargo.toml
new file mode 100644
index 0000000000..53e116143c
--- /dev/null
+++ b/testing/mozbase/rust/mozprofile/fuzz/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "mozprofile-fuzz"
+version = "0.0.0"
+authors = ["Automatically generated"]
+publish = false
+edition = "2018"
+
+[package.metadata]
+cargo-fuzz = true
+
+[dependencies]
+libfuzzer-sys = "0.4"
+
+[dependencies.mozprofile]
+path = ".."
+
+# Prevent this from interfering with workspaces
+[workspace]
+members = ["."]
+
+[[bin]]
+name = "prefreader"
+path = "fuzz_targets/prefreader.rs"
+test = false
+doc = false
diff --git a/testing/mozbase/rust/mozprofile/fuzz/fuzz_targets/prefreader.rs b/testing/mozbase/rust/mozprofile/fuzz/fuzz_targets/prefreader.rs
new file mode 100644
index 0000000000..824eb3c31e
--- /dev/null
+++ b/testing/mozbase/rust/mozprofile/fuzz/fuzz_targets/prefreader.rs
@@ -0,0 +1,16 @@
+/* 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/. */
+
+#![no_main]
+use libfuzzer_sys::fuzz_target;
+use std::io::Cursor;
+extern crate mozprofile;
+
+fuzz_target!(|data: &[u8]| {
+ let buf = Vec::new();
+ let mut out = Cursor::new(buf);
+ mozprofile::prefreader::parse(data).map(|parsed| {
+ mozprofile::prefreader::serialize(&parsed, &mut out);
+ });
+});
diff --git a/testing/mozbase/rust/mozprofile/src/lib.rs b/testing/mozbase/rust/mozprofile/src/lib.rs
new file mode 100644
index 0000000000..346f291137
--- /dev/null
+++ b/testing/mozbase/rust/mozprofile/src/lib.rs
@@ -0,0 +1,241 @@
+#![forbid(unsafe_code)]
+/* 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/. */
+
+extern crate tempfile;
+
+pub mod preferences;
+pub mod prefreader;
+pub mod profile;
+
+#[cfg(test)]
+mod test {
+ // use std::fs::File;
+ // use profile::Profile;
+ use crate::preferences::Pref;
+ use crate::prefreader::{parse, serialize, tokenize};
+ use crate::prefreader::{Position, PrefToken};
+ use std::collections::BTreeMap;
+ use std::io::Cursor;
+ use std::str;
+
+ #[test]
+ fn tokenize_simple() {
+ let prefs = " user_pref ( 'example.pref.string', 'value' ) ;\n \
+ pref(\"example.pref.int\", -123); sticky_pref('example.pref.bool',false);";
+
+ let p = Position::new();
+
+ let expected = vec![
+ PrefToken::UserPrefFunction(p),
+ PrefToken::Paren('(', p),
+ PrefToken::String("example.pref.string".into(), p),
+ PrefToken::Comma(p),
+ PrefToken::String("value".into(), p),
+ PrefToken::Paren(')', p),
+ PrefToken::Semicolon(p),
+ PrefToken::PrefFunction(p),
+ PrefToken::Paren('(', p),
+ PrefToken::String("example.pref.int".into(), p),
+ PrefToken::Comma(p),
+ PrefToken::Int(-123, p),
+ PrefToken::Paren(')', p),
+ PrefToken::Semicolon(p),
+ PrefToken::StickyPrefFunction(p),
+ PrefToken::Paren('(', p),
+ PrefToken::String("example.pref.bool".into(), p),
+ PrefToken::Comma(p),
+ PrefToken::Bool(false, p),
+ PrefToken::Paren(')', p),
+ PrefToken::Semicolon(p),
+ ];
+
+ tokenize_test(prefs, &expected);
+ }
+
+ #[test]
+ fn tokenize_comments() {
+ let prefs = "# bash style comment\n /*block comment*/ user_pref/*block comment*/(/*block \
+ comment*/ 'example.pref.string' /*block comment*/,/*block comment*/ \
+ 'value'/*block comment*/ )// line comment";
+
+ let p = Position::new();
+
+ let expected = vec![
+ PrefToken::CommentBashLine(" bash style comment".into(), p),
+ PrefToken::CommentBlock("block comment".into(), p),
+ PrefToken::UserPrefFunction(p),
+ PrefToken::CommentBlock("block comment".into(), p),
+ PrefToken::Paren('(', p),
+ PrefToken::CommentBlock("block comment".into(), p),
+ PrefToken::String("example.pref.string".into(), p),
+ PrefToken::CommentBlock("block comment".into(), p),
+ PrefToken::Comma(p),
+ PrefToken::CommentBlock("block comment".into(), p),
+ PrefToken::String("value".into(), p),
+ PrefToken::CommentBlock("block comment".into(), p),
+ PrefToken::Paren(')', p),
+ PrefToken::CommentLine(" line comment".into(), p),
+ ];
+
+ tokenize_test(prefs, &expected);
+ }
+
+ #[test]
+ fn tokenize_escapes() {
+ let prefs = r#"user_pref('example\x20pref', "\u0020\u2603\uD800\uDC96\"\'\n\r\\\w)"#;
+
+ let p = Position::new();
+
+ let expected = vec![
+ PrefToken::UserPrefFunction(p),
+ PrefToken::Paren('(', p),
+ PrefToken::String("example pref".into(), p),
+ PrefToken::Comma(p),
+ PrefToken::String(" ☃𐂖\"'\n\r\\\\w".into(), p),
+ PrefToken::Paren(')', p),
+ ];
+
+ tokenize_test(prefs, &expected);
+ }
+
+ fn tokenize_test(prefs: &str, expected: &[PrefToken]) {
+ println!("{}\n", prefs);
+
+ for (e, a) in expected.iter().zip(tokenize(prefs.as_bytes())) {
+ let success = match (e, &a) {
+ (&PrefToken::PrefFunction(_), &PrefToken::PrefFunction(_)) => true,
+ (&PrefToken::UserPrefFunction(_), &PrefToken::UserPrefFunction(_)) => true,
+ (&PrefToken::StickyPrefFunction(_), &PrefToken::StickyPrefFunction(_)) => true,
+ (
+ &PrefToken::CommentBlock(ref data_e, _),
+ &PrefToken::CommentBlock(ref data_a, _),
+ ) => data_e == data_a,
+ (
+ &PrefToken::CommentLine(ref data_e, _),
+ &PrefToken::CommentLine(ref data_a, _),
+ ) => data_e == data_a,
+ (
+ &PrefToken::CommentBashLine(ref data_e, _),
+ &PrefToken::CommentBashLine(ref data_a, _),
+ ) => data_e == data_a,
+ (&PrefToken::Paren(data_e, _), &PrefToken::Paren(data_a, _)) => data_e == data_a,
+ (&PrefToken::Semicolon(_), &PrefToken::Semicolon(_)) => true,
+ (&PrefToken::Comma(_), &PrefToken::Comma(_)) => true,
+ (&PrefToken::String(ref data_e, _), &PrefToken::String(ref data_a, _)) => {
+ data_e == data_a
+ }
+ (&PrefToken::Int(data_e, _), &PrefToken::Int(data_a, _)) => data_e == data_a,
+ (&PrefToken::Bool(data_e, _), &PrefToken::Bool(data_a, _)) => data_e == data_a,
+ (&PrefToken::Error(ref data_e, _), &PrefToken::Error(ref data_a, _)) => {
+ *data_e == *data_a
+ }
+ (_, _) => false,
+ };
+ if !success {
+ println!("Expected {:?}, got {:?}", e, a);
+ }
+ assert!(success);
+ }
+ }
+
+ #[test]
+ fn parse_simple() {
+ let input = " user_pref /* block comment */ ( 'example.pref.string', 'value' ) ;\n \
+ pref(\"example.pref.int\", -123); sticky_pref('example.pref.bool',false)";
+
+ let mut expected: BTreeMap<String, Pref> = BTreeMap::new();
+ expected.insert("example.pref.string".into(), Pref::new("value"));
+ expected.insert("example.pref.int".into(), Pref::new(-123));
+ expected.insert("example.pref.bool".into(), Pref::new_sticky(false));
+
+ parse_test(input, expected);
+ }
+
+ #[test]
+ fn parse_escape() {
+ let input = r#"user_pref('example\\pref\"string', 'val\x20ue' )"#;
+
+ let mut expected: BTreeMap<String, Pref> = BTreeMap::new();
+ expected.insert("example\\pref\"string".into(), Pref::new("val ue"));
+
+ parse_test(input, expected);
+ }
+
+ #[test]
+ fn parse_empty() {
+ let inputs = ["", " ", "\n", "\n \n"];
+ for input in inputs {
+ let expected: BTreeMap<String, Pref> = BTreeMap::new();
+ parse_test(input, expected);
+ }
+ }
+
+ #[test]
+ fn parse_newline() {
+ let inputs = vec!["\na", "\n\nfoo"];
+ for input in inputs {
+ assert!(parse(input.as_bytes()).is_err());
+ }
+ }
+
+ #[test]
+ fn parse_minus() {
+ let inputs = ["pref(-", "user_pref(\"example.pref.int\", -);"];
+ for input in inputs {
+ assert!(parse(input.as_bytes()).is_err());
+ }
+ }
+
+ #[test]
+ fn parse_boolean_eof() {
+ let inputs = vec!["pref(true", "pref(false", "pref(false,", "pref(false)"];
+ for input in inputs {
+ assert!(parse(input.as_bytes()).is_err());
+ }
+ }
+
+ fn parse_test(input: &str, expected: BTreeMap<String, Pref>) {
+ match parse(input.as_bytes()) {
+ Ok(ref actual) => {
+ println!("Expected:\n{:?}\nActual\n{:?}", expected, actual);
+ assert_eq!(actual, &expected);
+ }
+ Err(e) => {
+ println!("{}", e);
+ assert!(false)
+ }
+ }
+ }
+
+ #[test]
+ fn serialize_simple() {
+ let input = " user_pref /* block comment */ ( 'example.pref.string', 'value' ) ;\n \
+ pref(\"example.pref.int\", -123); sticky_pref('example.pref.bool',false)";
+ let expected = "sticky_pref(\"example.pref.bool\", false);
+user_pref(\"example.pref.int\", -123);
+user_pref(\"example.pref.string\", \"value\");\n";
+
+ serialize_test(input, expected);
+ }
+
+ #[test]
+ fn serialize_quotes() {
+ let input = r#"user_pref('example\\with"quotes"', '"Value"')"#;
+ let expected = r#"user_pref("example\\with\"quotes\"", "\"Value\"");
+"#;
+
+ serialize_test(input, expected);
+ }
+
+ fn serialize_test(input: &str, expected: &str) {
+ let buf = Vec::with_capacity(expected.len());
+ let mut out = Cursor::new(buf);
+ serialize(&parse(input.as_bytes()).unwrap(), &mut out).unwrap();
+ let data = out.into_inner();
+ let actual = str::from_utf8(&*data).unwrap();
+ println!("Expected:\n{:?}\nActual\n{:?}", expected, actual);
+ assert_eq!(actual, expected);
+ }
+}
diff --git a/testing/mozbase/rust/mozprofile/src/preferences.rs b/testing/mozbase/rust/mozprofile/src/preferences.rs
new file mode 100644
index 0000000000..2489352384
--- /dev/null
+++ b/testing/mozbase/rust/mozprofile/src/preferences.rs
@@ -0,0 +1,138 @@
+/* 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 std::collections::BTreeMap;
+
+pub type Preferences = BTreeMap<String, Pref>;
+
+#[derive(Debug, PartialEq, Clone)]
+pub enum PrefValue {
+ Bool(bool),
+ String(String),
+ Int(i64),
+}
+
+impl From<bool> for PrefValue {
+ fn from(value: bool) -> Self {
+ PrefValue::Bool(value)
+ }
+}
+
+impl From<String> for PrefValue {
+ fn from(value: String) -> Self {
+ PrefValue::String(value)
+ }
+}
+
+impl From<&'static str> for PrefValue {
+ fn from(value: &'static str) -> Self {
+ PrefValue::String(value.into())
+ }
+}
+
+impl From<i8> for PrefValue {
+ fn from(value: i8) -> Self {
+ PrefValue::Int(value.into())
+ }
+}
+
+impl From<u8> for PrefValue {
+ fn from(value: u8) -> Self {
+ PrefValue::Int(value.into())
+ }
+}
+
+impl From<i16> for PrefValue {
+ fn from(value: i16) -> Self {
+ PrefValue::Int(value.into())
+ }
+}
+
+impl From<u16> for PrefValue {
+ fn from(value: u16) -> Self {
+ PrefValue::Int(value.into())
+ }
+}
+
+impl From<i32> for PrefValue {
+ fn from(value: i32) -> Self {
+ PrefValue::Int(value.into())
+ }
+}
+
+impl From<u32> for PrefValue {
+ fn from(value: u32) -> Self {
+ PrefValue::Int(value.into())
+ }
+}
+
+impl From<i64> for PrefValue {
+ fn from(value: i64) -> Self {
+ PrefValue::Int(value)
+ }
+}
+
+// Implementing From<u64> for PrefValue wouldn't be safe
+// because it might overflow.
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct Pref {
+ pub value: PrefValue,
+ pub sticky: bool,
+}
+
+impl Pref {
+ /// Create a new preference with `value`.
+ pub fn new<T>(value: T) -> Pref
+ where
+ T: Into<PrefValue>,
+ {
+ Pref {
+ value: value.into(),
+ sticky: false,
+ }
+ }
+
+ /// Create a new sticky, or locked, preference with `value`.
+ /// These cannot be changed by the user in `about:config`.
+ pub fn new_sticky<T>(value: T) -> Pref
+ where
+ T: Into<PrefValue>,
+ {
+ Pref {
+ value: value.into(),
+ sticky: true,
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::PrefValue;
+
+ #[test]
+ fn test_bool() {
+ assert_eq!(PrefValue::from(true), PrefValue::Bool(true));
+ }
+
+ #[test]
+ fn test_string() {
+ assert_eq!(PrefValue::from("foo"), PrefValue::String("foo".to_string()));
+ assert_eq!(
+ PrefValue::from("foo".to_string()),
+ PrefValue::String("foo".to_string())
+ );
+ }
+
+ #[test]
+ fn test_int() {
+ assert_eq!(PrefValue::from(42i8), PrefValue::Int(42i64));
+ assert_eq!(PrefValue::from(42u8), PrefValue::Int(42i64));
+ assert_eq!(PrefValue::from(42i16), PrefValue::Int(42i64));
+ assert_eq!(PrefValue::from(42u16), PrefValue::Int(42i64));
+ assert_eq!(PrefValue::from(42i32), PrefValue::Int(42i64));
+ assert_eq!(PrefValue::from(42u32), PrefValue::Int(42i64));
+ assert_eq!(PrefValue::from(42i64), PrefValue::Int(42i64));
+ }
+}
diff --git a/testing/mozbase/rust/mozprofile/src/prefreader.rs b/testing/mozbase/rust/mozprofile/src/prefreader.rs
new file mode 100644
index 0000000000..cff3edb617
--- /dev/null
+++ b/testing/mozbase/rust/mozprofile/src/prefreader.rs
@@ -0,0 +1,1064 @@
+/* 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::preferences::{Pref, PrefValue, Preferences};
+use std::borrow::Borrow;
+use std::borrow::Cow;
+use std::char;
+use std::error::Error;
+use std::fmt;
+use std::io::{self, Write};
+use std::iter::Iterator;
+use std::mem;
+use std::str;
+
+impl PrefReaderError {
+ fn new(message: String, position: Position, parent: Option<Box<dyn Error>>) -> PrefReaderError {
+ PrefReaderError {
+ message,
+ position,
+ parent,
+ }
+ }
+}
+
+impl fmt::Display for PrefReaderError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(
+ f,
+ "{} at line {}, column {}",
+ self.message, self.position.line, self.position.column
+ )
+ }
+}
+
+impl Error for PrefReaderError {
+ fn description(&self) -> &str {
+ &self.message
+ }
+
+ fn cause(&self) -> Option<&dyn Error> {
+ self.parent.as_deref()
+ }
+}
+
+impl From<io::Error> for PrefReaderError {
+ fn from(err: io::Error) -> PrefReaderError {
+ PrefReaderError::new("IOError".into(), Position::new(), Some(err.into()))
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+enum TokenizerState {
+ Junk,
+ CommentStart,
+ CommentLine,
+ CommentBlock,
+ FunctionName,
+ AfterFunctionName,
+ FunctionArgs,
+ FunctionArg,
+ DoubleQuotedString,
+ SingleQuotedString,
+ Number,
+ Bool,
+ AfterFunctionArg,
+ AfterFunction,
+ Error,
+}
+
+#[derive(Copy, Clone, Debug, Default, PartialEq)]
+pub struct Position {
+ line: u32,
+ column: u32,
+}
+
+impl Position {
+ pub fn new() -> Position {
+ Position { line: 1, column: 0 }
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum TokenType {
+ None,
+ PrefFunction,
+ UserPrefFunction,
+ StickyPrefFunction,
+ CommentBlock,
+ CommentLine,
+ CommentBashLine,
+ Paren,
+ Semicolon,
+ Comma,
+ String,
+ Int,
+ Bool,
+ Error,
+}
+
+#[derive(Debug, PartialEq)]
+pub enum PrefToken<'a> {
+ PrefFunction(Position),
+ UserPrefFunction(Position),
+ StickyPrefFunction(Position),
+ CommentBlock(Cow<'a, str>, Position),
+ CommentLine(Cow<'a, str>, Position),
+ CommentBashLine(Cow<'a, str>, Position),
+ Paren(char, Position),
+ Semicolon(Position),
+ Comma(Position),
+ String(Cow<'a, str>, Position),
+ Int(i64, Position),
+ Bool(bool, Position),
+ Error(String, Position),
+}
+
+impl<'a> PrefToken<'a> {
+ fn position(&self) -> Position {
+ match *self {
+ PrefToken::PrefFunction(position) => position,
+ PrefToken::UserPrefFunction(position) => position,
+ PrefToken::StickyPrefFunction(position) => position,
+ PrefToken::CommentBlock(_, position) => position,
+ PrefToken::CommentLine(_, position) => position,
+ PrefToken::CommentBashLine(_, position) => position,
+ PrefToken::Paren(_, position) => position,
+ PrefToken::Semicolon(position) => position,
+ PrefToken::Comma(position) => position,
+ PrefToken::String(_, position) => position,
+ PrefToken::Int(_, position) => position,
+ PrefToken::Bool(_, position) => position,
+ PrefToken::Error(_, position) => position,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct PrefReaderError {
+ message: String,
+ position: Position,
+ parent: Option<Box<dyn Error>>,
+}
+
+struct TokenData<'a> {
+ token_type: TokenType,
+ complete: bool,
+ position: Position,
+ data: Cow<'a, str>,
+ start_pos: usize,
+}
+
+impl<'a> TokenData<'a> {
+ fn new(token_type: TokenType, position: Position, start_pos: usize) -> TokenData<'a> {
+ TokenData {
+ token_type,
+ complete: false,
+ position,
+ data: Cow::Borrowed(""),
+ start_pos,
+ }
+ }
+
+ fn start(&mut self, tokenizer: &PrefTokenizer, token_type: TokenType) {
+ self.token_type = token_type;
+ self.position = tokenizer.position;
+ self.start_pos = tokenizer.pos;
+ }
+
+ fn end(&mut self, buf: &'a [u8], end_pos: usize) -> Result<(), PrefReaderError> {
+ self.complete = true;
+ self.add_slice_to_token(buf, end_pos)
+ }
+
+ fn add_slice_to_token(&mut self, buf: &'a [u8], end_pos: usize) -> Result<(), PrefReaderError> {
+ let data = match str::from_utf8(&buf[self.start_pos..end_pos]) {
+ Ok(x) => x,
+ Err(_) => {
+ return Err(PrefReaderError::new(
+ "Could not convert string to utf8".into(),
+ self.position,
+ None,
+ ));
+ }
+ };
+ if self.data != "" {
+ self.data.to_mut().push_str(data)
+ } else {
+ self.data = Cow::Borrowed(data)
+ };
+ Ok(())
+ }
+
+ fn push_char(&mut self, tokenizer: &PrefTokenizer, data: char) {
+ self.data.to_mut().push(data);
+ self.start_pos = tokenizer.pos + 1;
+ }
+}
+
+pub struct PrefTokenizer<'a> {
+ data: &'a [u8],
+ pos: usize,
+ cur: Option<char>,
+ position: Position,
+ state: TokenizerState,
+ next_state: Option<TokenizerState>,
+}
+
+impl<'a> PrefTokenizer<'a> {
+ pub fn new(data: &'a [u8]) -> PrefTokenizer<'a> {
+ PrefTokenizer {
+ data,
+ pos: 0,
+ cur: None,
+ position: Position::new(),
+ state: TokenizerState::Junk,
+ next_state: Some(TokenizerState::FunctionName),
+ }
+ }
+
+ fn make_token(&mut self, token_data: TokenData<'a>) -> PrefToken<'a> {
+ let buf = token_data.data;
+ let position = token_data.position;
+ // Note: the panic! here are for cases where the invalid input is regarded as
+ // a bug in the caller. In cases where `make_token` can legitimately be called
+ // with invalid data we must instead return a PrefToken::Error
+ match token_data.token_type {
+ TokenType::None => panic!("Got a token without a type"),
+ TokenType::PrefFunction => PrefToken::PrefFunction(position),
+ TokenType::UserPrefFunction => PrefToken::UserPrefFunction(position),
+ TokenType::StickyPrefFunction => PrefToken::StickyPrefFunction(position),
+ TokenType::CommentBlock => PrefToken::CommentBlock(buf, position),
+ TokenType::CommentLine => PrefToken::CommentLine(buf, position),
+ TokenType::CommentBashLine => PrefToken::CommentBashLine(buf, position),
+ TokenType::Paren => {
+ if buf.len() != 1 {
+ panic!("Expected a buffer of length one");
+ }
+ PrefToken::Paren(buf.chars().next().unwrap(), position)
+ }
+ TokenType::Semicolon => PrefToken::Semicolon(position),
+ TokenType::Comma => PrefToken::Comma(position),
+ TokenType::String => PrefToken::String(buf, position),
+ TokenType::Int => {
+ return match buf.parse::<i64>() {
+ Ok(value) => PrefToken::Int(value, position),
+ Err(_) => PrefToken::Error(format!("Expected integer, got {}", buf), position),
+ }
+ }
+ TokenType::Bool => {
+ let value = match buf.borrow() {
+ "true" => true,
+ "false" => false,
+ x => panic!("Boolean wasn't 'true' or 'false' (was {})", x),
+ };
+ PrefToken::Bool(value, position)
+ }
+ TokenType::Error => panic!("make_token can't construct errors"),
+ }
+ }
+
+ fn get_char(&mut self) -> Option<char> {
+ if self.pos + 1 >= self.data.len() {
+ self.cur = None;
+ return None;
+ };
+ if self.cur.is_some() {
+ self.pos += 1;
+ }
+ let c = self.data[self.pos] as char;
+ if self.cur == Some('\n') {
+ self.position.line += 1;
+ self.position.column = 0;
+ } else if self.cur.is_some() {
+ self.position.column += 1;
+ };
+ self.cur = Some(c);
+ self.cur
+ }
+
+ fn unget_char(&mut self) -> Option<char> {
+ if self.pos == 0 {
+ self.position.column = 0;
+ self.cur = None
+ } else {
+ self.pos -= 1;
+ let c = self.data[self.pos] as char;
+ if c == '\n' {
+ self.position.line -= 1;
+ let mut col_pos = self.pos;
+ while col_pos > 0 {
+ col_pos -= 1;
+ if self.data[col_pos] as char == '\n' {
+ break;
+ }
+ }
+ self.position.column = (self.pos - col_pos) as u32;
+ } else {
+ self.position.column -= 1;
+ }
+ self.cur = Some(c);
+ }
+ self.cur
+ }
+
+ fn is_space(c: char) -> bool {
+ matches!(c, ' ' | '\t' | '\r' | '\n')
+ }
+
+ fn skip_whitespace(&mut self) -> Option<char> {
+ while let Some(c) = self.cur {
+ if PrefTokenizer::is_space(c) {
+ self.get_char();
+ } else {
+ break;
+ };
+ }
+ self.cur
+ }
+
+ fn consume_escape(&mut self, token_data: &mut TokenData<'a>) -> Result<(), PrefReaderError> {
+ let pos = self.pos;
+ let escaped = self.read_escape()?;
+ if let Some(escape_char) = escaped {
+ token_data.add_slice_to_token(self.data, pos)?;
+ token_data.push_char(self, escape_char);
+ };
+ Ok(())
+ }
+
+ fn read_escape(&mut self) -> Result<Option<char>, PrefReaderError> {
+ let escape_char = match self.get_char() {
+ Some('u') => self.read_hex_escape(4, true)?,
+ Some('x') => self.read_hex_escape(2, true)?,
+ Some('\\') => '\\' as u32,
+ Some('"') => '"' as u32,
+ Some('\'') => '\'' as u32,
+ Some('r') => '\r' as u32,
+ Some('n') => '\n' as u32,
+ Some(_) => return Ok(None),
+ None => {
+ return Err(PrefReaderError::new(
+ "EOF in character escape".into(),
+ self.position,
+ None,
+ ))
+ }
+ };
+ Ok(Some(char::from_u32(escape_char).ok_or_else(|| {
+ PrefReaderError::new(
+ "Invalid codepoint decoded from escape".into(),
+ self.position,
+ None,
+ )
+ })?))
+ }
+
+ fn read_hex_escape(&mut self, hex_chars: isize, first: bool) -> Result<u32, PrefReaderError> {
+ let mut value = 0;
+ for _ in 0..hex_chars {
+ match self.get_char() {
+ Some(x) => {
+ value <<= 4;
+ match x {
+ '0'..='9' => value += x as u32 - '0' as u32,
+ 'a'..='f' => value += x as u32 - 'a' as u32,
+ 'A'..='F' => value += x as u32 - 'A' as u32,
+ _ => {
+ return Err(PrefReaderError::new(
+ "Unexpected character in escape".into(),
+ self.position,
+ None,
+ ))
+ }
+ }
+ }
+ None => {
+ return Err(PrefReaderError::new(
+ "Unexpected EOF in escape".into(),
+ self.position,
+ None,
+ ))
+ }
+ }
+ }
+ if first && (0xD800..=0xDBFF).contains(&value) {
+ // First part of a surrogate pair
+ if self.get_char() != Some('\\') || self.get_char() != Some('u') {
+ return Err(PrefReaderError::new(
+ "Lone high surrogate in surrogate pair".into(),
+ self.position,
+ None,
+ ));
+ }
+ self.unget_char();
+ let high_surrogate = value;
+ let low_surrogate = self.read_hex_escape(4, false)?;
+ let high_value = (high_surrogate - 0xD800) << 10;
+ let low_value = low_surrogate - 0xDC00;
+ value = high_value + low_value + 0x10000;
+ } else if first && (0xDC00..=0xDFFF).contains(&value) {
+ return Err(PrefReaderError::new(
+ "Lone low surrogate".into(),
+ self.position,
+ None,
+ ));
+ } else if !first && !(0xDC00..=0xDFFF).contains(&value) {
+ return Err(PrefReaderError::new(
+ "Invalid low surrogate in surrogate pair".into(),
+ self.position,
+ None,
+ ));
+ }
+ Ok(value)
+ }
+
+ fn get_match(&mut self, target: &str, separators: &str) -> bool {
+ let initial_pos = self.pos;
+ let mut matched = true;
+ for c in target.chars() {
+ if self.cur == Some(c) {
+ self.get_char();
+ } else {
+ matched = false;
+ break;
+ }
+ }
+
+ if !matched {
+ for _ in 0..(self.pos - initial_pos) {
+ self.unget_char();
+ }
+ } else {
+ // Check that the next character is whitespace or a separator
+ if let Some(c) = self.cur {
+ if !(PrefTokenizer::is_space(c) || separators.contains(c) || c == '/') {
+ matched = false;
+ }
+ self.unget_char();
+ }
+ // Otherwise the token was followed by EOF. That's a valid match, but
+ // will presumably cause a parse error later.
+ }
+
+ matched
+ }
+
+ fn next_token(&mut self) -> Result<Option<TokenData<'a>>, PrefReaderError> {
+ let mut token_data = TokenData::new(TokenType::None, Position::new(), 0);
+
+ loop {
+ let mut c = match self.get_char() {
+ Some(x) => x,
+ None => return Ok(None),
+ };
+
+ self.state = match self.state {
+ TokenizerState::Junk => {
+ c = match self.skip_whitespace() {
+ Some(x) => x,
+ None => return Ok(None),
+ };
+ match c {
+ '/' => TokenizerState::CommentStart,
+ '#' => {
+ token_data.start(self, TokenType::CommentBashLine);
+ token_data.start_pos = self.pos + 1;
+ TokenizerState::CommentLine
+ }
+ _ => {
+ self.unget_char();
+ let next = match self.next_state {
+ Some(x) => x,
+ None => {
+ return Err(PrefReaderError::new(
+ "In Junk state without a next state defined".into(),
+ self.position,
+ None,
+ ))
+ }
+ };
+ self.next_state = None;
+ next
+ }
+ }
+ }
+ TokenizerState::CommentStart => match c {
+ '*' => {
+ token_data.start(self, TokenType::CommentBlock);
+ token_data.start_pos = self.pos + 1;
+ TokenizerState::CommentBlock
+ }
+ '/' => {
+ token_data.start(self, TokenType::CommentLine);
+ token_data.start_pos = self.pos + 1;
+ TokenizerState::CommentLine
+ }
+ _ => {
+ return Err(PrefReaderError::new(
+ "Invalid character after /".into(),
+ self.position,
+ None,
+ ))
+ }
+ },
+ TokenizerState::CommentLine => match c {
+ '\n' => {
+ token_data.end(self.data, self.pos)?;
+ TokenizerState::Junk
+ }
+ _ => TokenizerState::CommentLine,
+ },
+ TokenizerState::CommentBlock => match c {
+ '*' => {
+ if self.get_char() == Some('/') {
+ token_data.end(self.data, self.pos - 1)?;
+ TokenizerState::Junk
+ } else {
+ TokenizerState::CommentBlock
+ }
+ }
+ _ => TokenizerState::CommentBlock,
+ },
+ TokenizerState::FunctionName => {
+ let position = self.position;
+ let start_pos = self.pos;
+ match c {
+ 'u' => {
+ if self.get_match("user_pref", "(") {
+ token_data.start(self, TokenType::UserPrefFunction);
+ }
+ }
+ 's' => {
+ if self.get_match("sticky_pref", "(") {
+ token_data.start(self, TokenType::StickyPrefFunction);
+ }
+ }
+ 'p' => {
+ if self.get_match("pref", "(") {
+ token_data.start(self, TokenType::PrefFunction);
+ }
+ }
+ _ => {}
+ };
+ if token_data.token_type == TokenType::None {
+ // We didn't match anything
+ return Err(PrefReaderError::new(
+ "Expected a pref function name".into(),
+ position,
+ None,
+ ));
+ } else {
+ token_data.start_pos = start_pos;
+ token_data.position = position;
+ token_data.end(self.data, self.pos + 1)?;
+ self.next_state = Some(TokenizerState::AfterFunctionName);
+ TokenizerState::Junk
+ }
+ }
+ TokenizerState::AfterFunctionName => match c {
+ '(' => {
+ self.next_state = Some(TokenizerState::FunctionArgs);
+ token_data.start(self, TokenType::Paren);
+ token_data.end(self.data, self.pos + 1)?;
+ self.next_state = Some(TokenizerState::FunctionArgs);
+ TokenizerState::Junk
+ }
+ _ => {
+ return Err(PrefReaderError::new(
+ "Expected an opening paren".into(),
+ self.position,
+ None,
+ ))
+ }
+ },
+ TokenizerState::FunctionArgs => match c {
+ ')' => {
+ token_data.start(self, TokenType::Paren);
+ token_data.end(self.data, self.pos + 1)?;
+ self.next_state = Some(TokenizerState::AfterFunction);
+ TokenizerState::Junk
+ }
+ _ => {
+ self.unget_char();
+ TokenizerState::FunctionArg
+ }
+ },
+ TokenizerState::FunctionArg => match c {
+ '"' => {
+ token_data.start(self, TokenType::String);
+ token_data.start_pos = self.pos + 1;
+ TokenizerState::DoubleQuotedString
+ }
+ '\'' => {
+ token_data.start(self, TokenType::String);
+ token_data.start_pos = self.pos + 1;
+ TokenizerState::SingleQuotedString
+ }
+ 't' | 'f' => {
+ self.unget_char();
+ TokenizerState::Bool
+ }
+ '0'..='9' | '-' | '+' => {
+ token_data.start(self, TokenType::Int);
+ TokenizerState::Number
+ }
+ _ => {
+ return Err(PrefReaderError::new(
+ "Invalid character at start of function argument".into(),
+ self.position,
+ None,
+ ))
+ }
+ },
+ TokenizerState::DoubleQuotedString => match c {
+ '"' => {
+ token_data.end(self.data, self.pos)?;
+ self.next_state = Some(TokenizerState::AfterFunctionArg);
+ TokenizerState::Junk
+ }
+ '\n' => {
+ return Err(PrefReaderError::new(
+ "EOL in double quoted string".into(),
+ self.position,
+ None,
+ ))
+ }
+ '\\' => {
+ self.consume_escape(&mut token_data)?;
+ TokenizerState::DoubleQuotedString
+ }
+ _ => TokenizerState::DoubleQuotedString,
+ },
+ TokenizerState::SingleQuotedString => match c {
+ '\'' => {
+ token_data.end(self.data, self.pos)?;
+ self.next_state = Some(TokenizerState::AfterFunctionArg);
+ TokenizerState::Junk
+ }
+ '\n' => {
+ return Err(PrefReaderError::new(
+ "EOL in single quoted string".into(),
+ self.position,
+ None,
+ ))
+ }
+ '\\' => {
+ self.consume_escape(&mut token_data)?;
+ TokenizerState::SingleQuotedString
+ }
+ _ => TokenizerState::SingleQuotedString,
+ },
+ TokenizerState::Number => match c {
+ '0'..='9' => TokenizerState::Number,
+ ')' | ',' => {
+ token_data.end(self.data, self.pos)?;
+ self.unget_char();
+ self.next_state = Some(TokenizerState::AfterFunctionArg);
+ TokenizerState::Junk
+ }
+ x if PrefTokenizer::is_space(x) => {
+ token_data.end(self.data, self.pos)?;
+ self.next_state = Some(TokenizerState::AfterFunctionArg);
+ TokenizerState::Junk
+ }
+ _ => {
+ return Err(PrefReaderError::new(
+ "Invalid character in number literal".into(),
+ self.position,
+ None,
+ ))
+ }
+ },
+ TokenizerState::Bool => {
+ let start_pos = self.pos;
+ let position = self.position;
+ match c {
+ 't' => {
+ if self.get_match("true", ",)") {
+ token_data.start(self, TokenType::Bool)
+ }
+ }
+ 'f' => {
+ if self.get_match("false", ",)") {
+ token_data.start(self, TokenType::Bool)
+ }
+ }
+ _ => {}
+ };
+ if token_data.token_type == TokenType::None {
+ return Err(PrefReaderError::new(
+ "Unexpected characters in function argument".into(),
+ position,
+ None,
+ ));
+ } else {
+ token_data.start_pos = start_pos;
+ token_data.position = position;
+ token_data.end(self.data, self.pos + 1)?;
+ self.next_state = Some(TokenizerState::AfterFunctionArg);
+ TokenizerState::Junk
+ }
+ }
+ TokenizerState::AfterFunctionArg => match c {
+ ',' => {
+ token_data.start(self, TokenType::Comma);
+ token_data.end(self.data, self.pos + 1)?;
+ self.next_state = Some(TokenizerState::FunctionArg);
+ TokenizerState::Junk
+ }
+ ')' => {
+ token_data.start(self, TokenType::Paren);
+ token_data.end(self.data, self.pos + 1)?;
+ self.next_state = Some(TokenizerState::AfterFunction);
+ TokenizerState::Junk
+ }
+ _ => {
+ return Err(PrefReaderError::new(
+ "Unexpected character after function argument".into(),
+ self.position,
+ None,
+ ))
+ }
+ },
+ TokenizerState::AfterFunction => match c {
+ ';' => {
+ token_data.start(self, TokenType::Semicolon);
+ token_data.end(self.data, self.pos)?;
+ self.next_state = Some(TokenizerState::FunctionName);
+ TokenizerState::Junk
+ }
+ _ => {
+ return Err(PrefReaderError::new(
+ "Unexpected character after function".into(),
+ self.position,
+ None,
+ ))
+ }
+ },
+ TokenizerState::Error => TokenizerState::Error,
+ };
+ if token_data.complete {
+ return Ok(Some(token_data));
+ }
+ }
+ }
+}
+
+impl<'a> Iterator for PrefTokenizer<'a> {
+ type Item = PrefToken<'a>;
+
+ fn next(&mut self) -> Option<PrefToken<'a>> {
+ if let TokenizerState::Error = self.state {
+ return None;
+ }
+ let token_data = match self.next_token() {
+ Err(e) => {
+ self.state = TokenizerState::Error;
+ return Some(PrefToken::Error(e.message.clone(), e.position));
+ }
+ Ok(Some(token_data)) => token_data,
+ Ok(None) => return None,
+ };
+ let token = self.make_token(token_data);
+ Some(token)
+ }
+}
+
+pub fn tokenize(data: &[u8]) -> PrefTokenizer {
+ PrefTokenizer::new(data)
+}
+
+pub fn serialize_token<T: Write>(token: &PrefToken, output: &mut T) -> Result<(), PrefReaderError> {
+ let mut data_buf = String::new();
+
+ let data = match *token {
+ PrefToken::PrefFunction(_) => "pref",
+ PrefToken::UserPrefFunction(_) => "user_pref",
+ PrefToken::StickyPrefFunction(_) => "sticky_pref",
+ PrefToken::CommentBlock(ref data, _) => {
+ data_buf.reserve(data.len() + 4);
+ data_buf.push_str("/*");
+ data_buf.push_str(data.borrow());
+ data_buf.push('*');
+ &*data_buf
+ }
+ PrefToken::CommentLine(ref data, _) => {
+ data_buf.reserve(data.len() + 2);
+ data_buf.push_str("//");
+ data_buf.push_str(data.borrow());
+ &*data_buf
+ }
+ PrefToken::CommentBashLine(ref data, _) => {
+ data_buf.reserve(data.len() + 1);
+ data_buf.push('#');
+ data_buf.push_str(data.borrow());
+ &*data_buf
+ }
+ PrefToken::Paren(data, _) => {
+ data_buf.push(data);
+ &*data_buf
+ }
+ PrefToken::Comma(_) => ",",
+ PrefToken::Semicolon(_) => ";\n",
+ PrefToken::String(ref data, _) => {
+ data_buf.reserve(data.len() + 2);
+ data_buf.push('"');
+ data_buf.push_str(escape_quote(data.borrow()).borrow());
+ data_buf.push('"');
+ &*data_buf
+ }
+ PrefToken::Int(data, _) => {
+ data_buf.push_str(&data.to_string());
+ &*data_buf
+ }
+ PrefToken::Bool(data, _) => {
+ if data {
+ "true"
+ } else {
+ "false"
+ }
+ }
+ PrefToken::Error(ref data, pos) => {
+ return Err(PrefReaderError::new(data.clone(), pos, None))
+ }
+ };
+ output.write_all(data.as_bytes())?;
+ Ok(())
+}
+
+pub fn serialize_tokens<'a, I, W>(tokens: I, output: &mut W) -> Result<(), PrefReaderError>
+where
+ I: Iterator<Item = &'a PrefToken<'a>>,
+ W: Write,
+{
+ for token in tokens {
+ serialize_token(token, output)?;
+ }
+ Ok(())
+}
+
+fn escape_quote(data: &str) -> Cow<str> {
+ // Not very efficient…
+ if data.contains('"') || data.contains('\\') {
+ Cow::Owned(data.replace('\\', r#"\\"#).replace('"', r#"\""#))
+ } else {
+ Cow::Borrowed(data)
+ }
+}
+
+#[derive(Debug, PartialEq)]
+enum ParserState {
+ Function,
+ Key,
+ Value,
+}
+
+struct PrefBuilder {
+ key: Option<String>,
+ value: Option<PrefValue>,
+ sticky: bool,
+}
+
+impl PrefBuilder {
+ fn new() -> PrefBuilder {
+ PrefBuilder {
+ key: None,
+ value: None,
+ sticky: false,
+ }
+ }
+}
+
+fn skip_comments<'a>(tokenizer: &mut PrefTokenizer<'a>) -> Option<PrefToken<'a>> {
+ loop {
+ match tokenizer.next() {
+ Some(PrefToken::CommentBashLine(_, _))
+ | Some(PrefToken::CommentBlock(_, _))
+ | Some(PrefToken::CommentLine(_, _)) => {}
+ Some(x) => return Some(x),
+ None => return None,
+ }
+ }
+}
+
+pub fn parse_tokens(tokenizer: &mut PrefTokenizer<'_>) -> Result<Preferences, PrefReaderError> {
+ let mut state = ParserState::Function;
+ let mut current_pref = PrefBuilder::new();
+ let mut rv = Preferences::new();
+
+ loop {
+ // Not just using a for loop here seems strange, but this restricts the
+ // scope of the borrow
+ let token = {
+ match tokenizer.next() {
+ Some(x) => x,
+ None => break,
+ }
+ };
+ // First deal with comments and errors
+ match token {
+ PrefToken::Error(msg, position) => {
+ return Err(PrefReaderError::new(msg, position, None));
+ }
+ PrefToken::CommentBashLine(_, _)
+ | PrefToken::CommentLine(_, _)
+ | PrefToken::CommentBlock(_, _) => continue,
+ _ => {}
+ }
+ state = match state {
+ ParserState::Function => {
+ match token {
+ PrefToken::PrefFunction(_) => {
+ current_pref.sticky = false;
+ }
+ PrefToken::UserPrefFunction(_) => {
+ current_pref.sticky = false;
+ }
+ PrefToken::StickyPrefFunction(_) => {
+ current_pref.sticky = true;
+ }
+ _ => {
+ return Err(PrefReaderError::new(
+ "Expected pref function".into(),
+ token.position(),
+ None,
+ ));
+ }
+ }
+ let next = skip_comments(tokenizer);
+ match next {
+ Some(PrefToken::Paren('(', _)) => ParserState::Key,
+ _ => {
+ return Err(PrefReaderError::new(
+ "Expected open paren".into(),
+ next.map(|x| x.position()).unwrap_or(tokenizer.position),
+ None,
+ ))
+ }
+ }
+ }
+ ParserState::Key => {
+ match token {
+ PrefToken::String(data, _) => current_pref.key = Some(data.into_owned()),
+ _ => {
+ return Err(PrefReaderError::new(
+ "Expected string".into(),
+ token.position(),
+ None,
+ ));
+ }
+ }
+ let next = skip_comments(tokenizer);
+ match next {
+ Some(PrefToken::Comma(_)) => ParserState::Value,
+ _ => {
+ return Err(PrefReaderError::new(
+ "Expected comma".into(),
+ next.map(|x| x.position()).unwrap_or(tokenizer.position),
+ None,
+ ))
+ }
+ }
+ }
+ ParserState::Value => {
+ match token {
+ PrefToken::String(data, _) => {
+ current_pref.value = Some(PrefValue::String(data.into_owned()))
+ }
+ PrefToken::Int(data, _) => current_pref.value = Some(PrefValue::Int(data)),
+ PrefToken::Bool(data, _) => current_pref.value = Some(PrefValue::Bool(data)),
+ _ => {
+ return Err(PrefReaderError::new(
+ "Expected value".into(),
+ token.position(),
+ None,
+ ))
+ }
+ }
+ let next = skip_comments(tokenizer);
+ match next {
+ Some(PrefToken::Paren(')', _)) => {}
+ _ => {
+ return Err(PrefReaderError::new(
+ "Expected close paren".into(),
+ next.map(|x| x.position()).unwrap_or(tokenizer.position),
+ None,
+ ))
+ }
+ }
+ let next = skip_comments(tokenizer);
+ match next {
+ Some(PrefToken::Semicolon(_)) | None => {}
+ _ => {
+ return Err(PrefReaderError::new(
+ "Expected semicolon".into(),
+ next.map(|x| x.position()).unwrap_or(tokenizer.position),
+ None,
+ ))
+ }
+ }
+ let key = mem::replace(&mut current_pref.key, None);
+ let value = mem::replace(&mut current_pref.value, None);
+ let pref = if current_pref.sticky {
+ Pref::new_sticky(value.unwrap())
+ } else {
+ Pref::new(value.unwrap())
+ };
+ rv.insert(key.unwrap(), pref);
+ current_pref.sticky = false;
+ ParserState::Function
+ }
+ }
+ }
+ match state {
+ ParserState::Key | ParserState::Value => {
+ return Err(PrefReaderError::new(
+ "EOF in middle of function".into(),
+ tokenizer.position,
+ None,
+ ));
+ }
+ _ => {}
+ }
+ Ok(rv)
+}
+
+pub fn serialize<W: Write>(prefs: &Preferences, output: &mut W) -> io::Result<()> {
+ let mut p: Vec<_> = prefs.iter().collect();
+ p.sort_by(|a, b| a.0.cmp(b.0));
+ for &(key, pref) in &p {
+ let func = if pref.sticky {
+ "sticky_pref("
+ } else {
+ "user_pref("
+ }
+ .as_bytes();
+ output.write_all(func)?;
+ output.write_all(b"\"")?;
+ output.write_all(escape_quote(key).as_bytes())?;
+ output.write_all(b"\"")?;
+ output.write_all(b", ")?;
+ match pref.value {
+ PrefValue::Bool(x) => {
+ output.write_all(if x { b"true" } else { b"false" })?;
+ }
+ PrefValue::Int(x) => {
+ output.write_all(x.to_string().as_bytes())?;
+ }
+ PrefValue::String(ref x) => {
+ output.write_all(b"\"")?;
+ output.write_all(escape_quote(x).as_bytes())?;
+ output.write_all(b"\"")?;
+ }
+ };
+ output.write_all(b");\n")?;
+ }
+ Ok(())
+}
+
+pub fn parse(data: &[u8]) -> Result<Preferences, PrefReaderError> {
+ let mut tokenizer = tokenize(data);
+ parse_tokens(&mut tokenizer)
+}
diff --git a/testing/mozbase/rust/mozprofile/src/profile.rs b/testing/mozbase/rust/mozprofile/src/profile.rs
new file mode 100644
index 0000000000..f4037adfa9
--- /dev/null
+++ b/testing/mozbase/rust/mozprofile/src/profile.rs
@@ -0,0 +1,135 @@
+/* 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::preferences::{Pref, Preferences};
+use crate::prefreader::{parse, serialize, PrefReaderError};
+use std::collections::btree_map::Iter;
+use std::fs::File;
+use std::io::prelude::*;
+use std::io::Result as IoResult;
+use std::path::{Path, PathBuf};
+use tempfile::{Builder, TempDir};
+
+#[derive(Debug)]
+pub struct Profile {
+ pub path: PathBuf,
+ pub temp_dir: Option<TempDir>,
+ prefs: Option<PrefFile>,
+ user_prefs: Option<PrefFile>,
+}
+
+impl PartialEq for Profile {
+ fn eq(&self, other: &Profile) -> bool {
+ self.path == other.path
+ }
+}
+
+impl Profile {
+ pub fn new(temp_root: Option<&Path>) -> IoResult<Profile> {
+ let mut dir_builder = Builder::new();
+ dir_builder.prefix("rust_mozprofile");
+ let dir = if let Some(temp_root) = temp_root {
+ dir_builder.tempdir_in(temp_root)
+ } else {
+ dir_builder.tempdir()
+ }?;
+ let path = dir.path().to_path_buf();
+ let temp_dir = Some(dir);
+ Ok(Profile {
+ path,
+ temp_dir,
+ prefs: None,
+ user_prefs: None,
+ })
+ }
+
+ pub fn new_from_path(p: &Path) -> IoResult<Profile> {
+ let path = p.to_path_buf();
+ let temp_dir = None;
+ Ok(Profile {
+ path,
+ temp_dir,
+ prefs: None,
+ user_prefs: None,
+ })
+ }
+
+ pub fn prefs(&mut self) -> Result<&mut PrefFile, PrefReaderError> {
+ if self.prefs.is_none() {
+ let mut pref_path = PathBuf::from(&self.path);
+ pref_path.push("prefs.js");
+ self.prefs = Some(PrefFile::new(pref_path)?)
+ };
+ // This error handling doesn't make much sense
+ Ok(self.prefs.as_mut().unwrap())
+ }
+
+ pub fn user_prefs(&mut self) -> Result<&mut PrefFile, PrefReaderError> {
+ if self.user_prefs.is_none() {
+ let mut pref_path = PathBuf::from(&self.path);
+ pref_path.push("user.js");
+ self.user_prefs = Some(PrefFile::new(pref_path)?)
+ };
+ // This error handling doesn't make much sense
+ Ok(self.user_prefs.as_mut().unwrap())
+ }
+}
+
+#[derive(Debug)]
+pub struct PrefFile {
+ pub path: PathBuf,
+ pub prefs: Preferences,
+}
+
+impl PrefFile {
+ pub fn new(path: PathBuf) -> Result<PrefFile, PrefReaderError> {
+ let prefs = if !path.exists() {
+ Preferences::new()
+ } else {
+ let mut f = File::open(&path)?;
+ let mut buf = String::with_capacity(4096);
+ f.read_to_string(&mut buf)?;
+ parse(buf.as_bytes())?
+ };
+
+ Ok(PrefFile { path, prefs })
+ }
+
+ pub fn write(&self) -> IoResult<()> {
+ let mut f = File::create(&self.path)?;
+ serialize(&self.prefs, &mut f)
+ }
+
+ pub fn insert_slice<K>(&mut self, preferences: &[(K, Pref)])
+ where
+ K: Into<String> + Clone,
+ {
+ for &(ref name, ref value) in preferences.iter() {
+ self.insert((*name).clone(), (*value).clone());
+ }
+ }
+
+ pub fn insert<K>(&mut self, key: K, value: Pref)
+ where
+ K: Into<String>,
+ {
+ self.prefs.insert(key.into(), value);
+ }
+
+ pub fn remove(&mut self, key: &str) -> Option<Pref> {
+ self.prefs.remove(key)
+ }
+
+ pub fn get(&mut self, key: &str) -> Option<&Pref> {
+ self.prefs.get(key)
+ }
+
+ pub fn contains_key(&self, key: &str) -> bool {
+ self.prefs.contains_key(key)
+ }
+
+ pub fn iter(&self) -> Iter<String, Pref> {
+ self.prefs.iter()
+ }
+}
diff --git a/testing/mozbase/rust/mozrunner/Cargo.toml b/testing/mozbase/rust/mozrunner/Cargo.toml
new file mode 100644
index 0000000000..5baa6356fe
--- /dev/null
+++ b/testing/mozbase/rust/mozrunner/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+edition = "2018"
+name = "mozrunner"
+version = "0.15.1"
+authors = ["Mozilla"]
+description = "Reliable Firefox process management."
+keywords = [
+ "firefox",
+ "mozilla",
+ "process-manager",
+]
+license = "MPL-2.0"
+repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozrunner"
+
+[dependencies]
+log = "0.4"
+mozprofile = { path = "../mozprofile", version = "0.9" }
+plist = "1.0"
+
+[target.'cfg(target_os = "windows")'.dependencies]
+winreg = "0.10.1"
+
+[target.'cfg(target_os = "macos")'.dependencies]
+dirs = "4"
+
+[[bin]]
+name = "firefox-default-path"
diff --git a/testing/mozbase/rust/mozrunner/src/bin/firefox-default-path.rs b/testing/mozbase/rust/mozrunner/src/bin/firefox-default-path.rs
new file mode 100644
index 0000000000..94958aac90
--- /dev/null
+++ b/testing/mozbase/rust/mozrunner/src/bin/firefox-default-path.rs
@@ -0,0 +1,21 @@
+/* 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/. */
+
+extern crate mozrunner;
+
+use mozrunner::runner::platform;
+use std::io::Write;
+
+fn main() {
+ let (path, code) = platform::firefox_default_path()
+ .map(|x| (x.to_string_lossy().into_owned(), 0))
+ .unwrap_or(("Firefox binary not found".to_owned(), 1));
+
+ let mut writer: Box<dyn Write> = match code {
+ 0 => Box::new(std::io::stdout()),
+ _ => Box::new(std::io::stderr()),
+ };
+ writeln!(&mut writer, "{}", &*path).unwrap();
+ std::process::exit(code);
+}
diff --git a/testing/mozbase/rust/mozrunner/src/firefox_args.rs b/testing/mozbase/rust/mozrunner/src/firefox_args.rs
new file mode 100644
index 0000000000..49f873f9dc
--- /dev/null
+++ b/testing/mozbase/rust/mozrunner/src/firefox_args.rs
@@ -0,0 +1,384 @@
+/* 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/. */
+
+//! Argument string parsing and matching functions for Firefox.
+//!
+//! Which arguments Firefox accepts and in what style depends on the platform.
+//! On Windows only, arguments can be prefixed with `/` (slash), such as
+//! `/screenshot`. Elsewhere, including Windows, arguments may be prefixed
+//! with both single (`-screenshot`) and double (`--screenshot`) dashes.
+//!
+//! An argument's name is determined by a space or an assignment operator (`=`)
+//! so that for the string `-foo=bar`, `foo` is considered the argument's
+//! basename.
+
+use crate::runner::platform;
+use std::ffi::{OsStr, OsString};
+use std::fmt;
+
+/// Parse an argument string into a name and value
+///
+/// Given an argument like `"--arg=value"` this will split it into
+/// `(Some("arg"), Some("value")). For a case like `"--arg"` it will
+/// return `(Some("arg"), None)` and where the input doesn't look like
+/// an argument e.g. `"value"` it will return `(None, Some("value"))`
+fn parse_arg_name_value<T>(arg: T) -> (Option<String>, Option<String>)
+where
+ T: AsRef<OsStr>,
+{
+ let arg_os_str: &OsStr = arg.as_ref();
+ let arg_str = arg_os_str.to_string_lossy();
+
+ let mut name_start = 0;
+ let mut name_end = 0;
+
+ // Look for an argument name at the start of the
+ // string
+ for (i, c) in arg_str.chars().enumerate() {
+ if i == 0 {
+ if !platform::arg_prefix_char(c) {
+ break;
+ }
+ } else if i == 1 {
+ if name_end_char(c) {
+ break;
+ } else if c != '-' {
+ name_start = i;
+ name_end = name_start + 1;
+ } else {
+ name_start = i + 1;
+ name_end = name_start;
+ }
+ } else {
+ name_end += 1;
+ if name_end_char(c) {
+ name_end -= 1;
+ break;
+ }
+ }
+ }
+
+ let name = if name_start > 0 && name_end > name_start {
+ Some(arg_str[name_start..name_end].into())
+ } else {
+ None
+ };
+
+ // If there are characters in the string after the argument, read
+ // them as the value, excluding the seperator (e.g. "=") if
+ // present.
+ let mut value_start = name_end;
+ let value_end = arg_str.len();
+ let value = if value_start < value_end {
+ if let Some(c) = arg_str[value_start..value_end].chars().next() {
+ if name_end_char(c) {
+ value_start += 1;
+ }
+ }
+ Some(arg_str[value_start..value_end].into())
+ } else {
+ None
+ };
+ (name, value)
+}
+
+fn name_end_char(c: char) -> bool {
+ c == ' ' || c == '='
+}
+
+/// Represents a Firefox command-line argument.
+#[derive(Debug, PartialEq)]
+pub enum Arg {
+ /// `-foreground` ensures application window gets focus, which is not the
+ /// default on macOS. As such Firefox only supports it on MacOS.
+ Foreground,
+
+ /// --marionette enables Marionette in the application which is used
+ /// by WebDriver HTTP.
+ Marionette,
+
+ /// `-no-remote` prevents remote commands to this instance of Firefox, and
+ /// ensure we always start a new instance.
+ NoRemote,
+
+ /// `-P NAME` starts Firefox with a profile with a given name.
+ NamedProfile,
+
+ /// `-profile PATH` starts Firefox with the profile at the specified path.
+ Profile,
+
+ /// `-ProfileManager` starts Firefox with the profile chooser dialogue.
+ ProfileManager,
+
+ /// All other arguments.
+ Other(String),
+
+ /// --remote-allow-hosts contains comma-separated values of the Host header
+ /// to allow for incoming WebSocket requests of the Remote Agent.
+ RemoteAllowHosts,
+
+ /// --remote-allow-origins contains comma-separated values of the Origin header
+ /// to allow for incoming WebSocket requests of the Remote Agent.
+ RemoteAllowOrigins,
+
+ /// --remote-debugging-port enables the Remote Agent in the application
+ /// which is used for the WebDriver BiDi and CDP remote debugging protocols.
+ RemoteDebuggingPort,
+
+ /// Not an argument.
+ None,
+}
+
+impl Arg {
+ pub fn new(name: &str) -> Arg {
+ match name {
+ "foreground" => Arg::Foreground,
+ "marionette" => Arg::Marionette,
+ "no-remote" => Arg::NoRemote,
+ "profile" => Arg::Profile,
+ "P" => Arg::NamedProfile,
+ "ProfileManager" => Arg::ProfileManager,
+ "remote-allow-hosts" => Arg::RemoteAllowHosts,
+ "remote-allow-origins" => Arg::RemoteAllowOrigins,
+ "remote-debugging-port" => Arg::RemoteDebuggingPort,
+ _ => Arg::Other(name.into()),
+ }
+ }
+}
+
+impl<'a> From<&'a OsString> for Arg {
+ fn from(arg_str: &OsString) -> Arg {
+ if let (Some(name), _) = parse_arg_name_value(arg_str) {
+ Arg::new(&name)
+ } else {
+ Arg::None
+ }
+ }
+}
+
+impl fmt::Display for Arg {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&match self {
+ Arg::Foreground => "--foreground".to_string(),
+ Arg::Marionette => "--marionette".to_string(),
+ Arg::NamedProfile => "-P".to_string(),
+ Arg::None => "".to_string(),
+ Arg::NoRemote => "--no-remote".to_string(),
+ Arg::Other(x) => format!("--{}", x),
+ Arg::Profile => "--profile".to_string(),
+ Arg::ProfileManager => "--ProfileManager".to_string(),
+ Arg::RemoteAllowHosts => "--remote-allow-hosts".to_string(),
+ Arg::RemoteAllowOrigins => "--remote-allow-origins".to_string(),
+ Arg::RemoteDebuggingPort => "--remote-debugging-port".to_string(),
+ })
+ }
+}
+
+/// Parse an iterator over arguments into an vector of (name, value)
+/// tuples
+///
+/// Each entry in the input argument will produce a single item in the
+/// output. Because we don't know anything about the specific
+/// arguments, something that doesn't parse as a named argument may
+/// either be the value of a previous named argument, or may be a
+/// positional argument.
+pub fn parse_args<'a>(
+ args: impl Iterator<Item = &'a OsString>,
+) -> Vec<(Option<Arg>, Option<String>)> {
+ args.map(parse_arg_name_value)
+ .map(|(name, value)| {
+ if let Some(arg_name) = name {
+ (Some(Arg::new(&arg_name)), value)
+ } else {
+ (None, value)
+ }
+ })
+ .collect()
+}
+
+/// Given an iterator over all arguments, get the value of an argument
+///
+/// This assumes that the argument takes a single value and that is
+/// either provided as a single argument entry
+/// (e.g. `["--name=value"]`) or as the following argument
+/// (e.g. `["--name", "value"])
+pub fn get_arg_value<'a>(
+ mut parsed_args: impl Iterator<Item = &'a (Option<Arg>, Option<String>)>,
+ arg: Arg,
+) -> Option<String> {
+ let mut found_value = None;
+ for (arg_name, arg_value) in &mut parsed_args {
+ if let (Some(name), value) = (arg_name, arg_value) {
+ if *name == arg {
+ found_value = value.clone();
+ break;
+ }
+ }
+ }
+ if found_value.is_none() {
+ // If there wasn't a value, check if the following argument is a value
+ if let Some((None, value)) = parsed_args.next() {
+ found_value = value.clone();
+ }
+ }
+ found_value
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{get_arg_value, parse_arg_name_value, parse_args, Arg};
+ use std::ffi::OsString;
+
+ fn parse(arg: &str, name: Option<&str>) {
+ let (result, _) = parse_arg_name_value(arg);
+ assert_eq!(result, name.map(|x| x.to_string()));
+ }
+
+ #[test]
+ fn test_parse_arg_name_value() {
+ parse("-p", Some("p"));
+ parse("--p", Some("p"));
+ parse("--profile foo", Some("profile"));
+ parse("--profile", Some("profile"));
+ parse("--", None);
+ parse("", None);
+ parse("-=", None);
+ parse("--=", None);
+ parse("-- foo", None);
+ parse("foo", None);
+ parse("/ foo", None);
+ parse("/- foo", None);
+ parse("/=foo", None);
+ parse("foo", None);
+ parse("-profile", Some("profile"));
+ parse("-profile=foo", Some("profile"));
+ parse("-profile = foo", Some("profile"));
+ parse("-profile abc", Some("profile"));
+ parse("-profile /foo", Some("profile"));
+ }
+
+ #[cfg(target_os = "windows")]
+ #[test]
+ fn test_parse_arg_name_value_windows() {
+ parse("/profile", Some("profile"));
+ }
+
+ #[cfg(not(target_os = "windows"))]
+ #[test]
+ fn test_parse_arg_name_value_non_windows() {
+ parse("/profile", None);
+ }
+
+ #[test]
+ fn test_arg_from_osstring() {
+ assert_eq!(Arg::from(&OsString::from("--foreground")), Arg::Foreground);
+ assert_eq!(Arg::from(&OsString::from("-foreground")), Arg::Foreground);
+
+ assert_eq!(Arg::from(&OsString::from("--marionette")), Arg::Marionette);
+ assert_eq!(Arg::from(&OsString::from("-marionette")), Arg::Marionette);
+
+ assert_eq!(Arg::from(&OsString::from("--no-remote")), Arg::NoRemote);
+ assert_eq!(Arg::from(&OsString::from("-no-remote")), Arg::NoRemote);
+
+ assert_eq!(Arg::from(&OsString::from("-- profile")), Arg::None);
+ assert_eq!(Arg::from(&OsString::from("profile")), Arg::None);
+ assert_eq!(Arg::from(&OsString::from("profile -P")), Arg::None);
+ assert_eq!(
+ Arg::from(&OsString::from("-profiled")),
+ Arg::Other("profiled".into())
+ );
+ assert_eq!(
+ Arg::from(&OsString::from("-PROFILEMANAGER")),
+ Arg::Other("PROFILEMANAGER".into())
+ );
+
+ assert_eq!(Arg::from(&OsString::from("--profile")), Arg::Profile);
+ assert_eq!(Arg::from(&OsString::from("-profile foo")), Arg::Profile);
+
+ assert_eq!(
+ Arg::from(&OsString::from("--ProfileManager")),
+ Arg::ProfileManager
+ );
+ assert_eq!(
+ Arg::from(&OsString::from("-ProfileManager")),
+ Arg::ProfileManager
+ );
+
+ // TODO: -Ptest is valid
+ //assert_eq!(Arg::from(&OsString::from("-Ptest")), Arg::NamedProfile);
+ assert_eq!(Arg::from(&OsString::from("-P")), Arg::NamedProfile);
+ assert_eq!(Arg::from(&OsString::from("-P test")), Arg::NamedProfile);
+
+ assert_eq!(
+ Arg::from(&OsString::from("--remote-debugging-port")),
+ Arg::RemoteDebuggingPort
+ );
+ assert_eq!(
+ Arg::from(&OsString::from("-remote-debugging-port")),
+ Arg::RemoteDebuggingPort
+ );
+ assert_eq!(
+ Arg::from(&OsString::from("--remote-debugging-port 9222")),
+ Arg::RemoteDebuggingPort
+ );
+
+ assert_eq!(
+ Arg::from(&OsString::from("--remote-allow-hosts")),
+ Arg::RemoteAllowHosts
+ );
+ assert_eq!(
+ Arg::from(&OsString::from("-remote-allow-hosts")),
+ Arg::RemoteAllowHosts
+ );
+ assert_eq!(
+ Arg::from(&OsString::from("--remote-allow-hosts 9222")),
+ Arg::RemoteAllowHosts
+ );
+
+ assert_eq!(
+ Arg::from(&OsString::from("--remote-allow-origins")),
+ Arg::RemoteAllowOrigins
+ );
+ assert_eq!(
+ Arg::from(&OsString::from("-remote-allow-origins")),
+ Arg::RemoteAllowOrigins
+ );
+ assert_eq!(
+ Arg::from(&OsString::from("--remote-allow-origins http://foo")),
+ Arg::RemoteAllowOrigins
+ );
+ }
+
+ #[test]
+ fn test_get_arg_value() {
+ let args = vec!["-P", "ProfileName", "--profile=/path/", "--no-remote"]
+ .iter()
+ .map(|x| OsString::from(x))
+ .collect::<Vec<OsString>>();
+ let parsed_args = parse_args(args.iter());
+ assert_eq!(
+ get_arg_value(parsed_args.iter(), Arg::NamedProfile),
+ Some("ProfileName".into())
+ );
+ assert_eq!(
+ get_arg_value(parsed_args.iter(), Arg::Profile),
+ Some("/path/".into())
+ );
+ assert_eq!(get_arg_value(parsed_args.iter(), Arg::NoRemote), None);
+
+ let args = vec!["--profile=", "-P test"]
+ .iter()
+ .map(|x| OsString::from(x))
+ .collect::<Vec<OsString>>();
+ let parsed_args = parse_args(args.iter());
+ assert_eq!(
+ get_arg_value(parsed_args.iter(), Arg::NamedProfile),
+ Some("test".into())
+ );
+ assert_eq!(
+ get_arg_value(parsed_args.iter(), Arg::Profile),
+ Some("".into())
+ );
+ }
+}
diff --git a/testing/mozbase/rust/mozrunner/src/lib.rs b/testing/mozbase/rust/mozrunner/src/lib.rs
new file mode 100644
index 0000000000..5634de11bf
--- /dev/null
+++ b/testing/mozbase/rust/mozrunner/src/lib.rs
@@ -0,0 +1,20 @@
+#![forbid(unsafe_code)]
+/* 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/. */
+
+#[macro_use]
+extern crate log;
+#[cfg(target_os = "macos")]
+extern crate dirs;
+extern crate mozprofile;
+#[cfg(target_os = "macos")]
+extern crate plist;
+#[cfg(target_os = "windows")]
+extern crate winreg;
+
+pub mod firefox_args;
+pub mod path;
+pub mod runner;
+
+pub use crate::runner::platform::firefox_default_path;
diff --git a/testing/mozbase/rust/mozrunner/src/path.rs b/testing/mozbase/rust/mozrunner/src/path.rs
new file mode 100644
index 0000000000..0bf1eda9ed
--- /dev/null
+++ b/testing/mozbase/rust/mozrunner/src/path.rs
@@ -0,0 +1,48 @@
+/* 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/. */
+
+//! Provides utilities for searching the system path.
+
+use std::env;
+use std::path::{Path, PathBuf};
+
+#[cfg(unix)]
+fn is_executable(path: &Path) -> bool {
+ use std::fs;
+ use std::os::unix::fs::PermissionsExt;
+
+ // Permissions are a set of four 4-bit bitflags, represented by a single octal
+ // digit. The lowest bit of each of the last three values represents the
+ // executable permission for all, group and user, repsectively. We assume the
+ // file is executable if any of these are set.
+ match fs::metadata(path).ok() {
+ Some(meta) => meta.permissions().mode() & 0o111 != 0,
+ None => false,
+ }
+}
+
+#[cfg(not(unix))]
+fn is_executable(_: &Path) -> bool {
+ true
+}
+
+/// Determines if the path is an executable binary. That is, if it exists, is
+/// a file, and is executable where applicable.
+pub fn is_binary(path: &Path) -> bool {
+ path.exists() && path.is_file() && is_executable(path)
+}
+
+/// Searches the system path (`PATH`) for an executable binary and returns the
+/// first match, or `None` if not found.
+pub fn find_binary(binary_name: &str) -> Option<PathBuf> {
+ env::var_os("PATH").and_then(|path_env| {
+ for mut path in env::split_paths(&path_env) {
+ path.push(binary_name);
+ if is_binary(&path) {
+ return Some(path);
+ }
+ }
+ None
+ })
+}
diff --git a/testing/mozbase/rust/mozrunner/src/runner.rs b/testing/mozbase/rust/mozrunner/src/runner.rs
new file mode 100644
index 0000000000..b3deafe27b
--- /dev/null
+++ b/testing/mozbase/rust/mozrunner/src/runner.rs
@@ -0,0 +1,570 @@
+/* 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 mozprofile::prefreader::PrefReaderError;
+use mozprofile::profile::Profile;
+use std::collections::HashMap;
+use std::convert::From;
+use std::error::Error;
+use std::ffi::{OsStr, OsString};
+use std::fmt;
+use std::io;
+use std::io::ErrorKind;
+use std::path::{Path, PathBuf};
+use std::process;
+use std::process::{Child, Command, Stdio};
+use std::thread;
+use std::time;
+
+use crate::firefox_args::Arg;
+
+pub trait Runner {
+ type Process;
+
+ fn arg<S>(&mut self, arg: S) -> &mut Self
+ where
+ S: AsRef<OsStr>;
+
+ fn args<I, S>(&mut self, args: I) -> &mut Self
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<OsStr>;
+
+ fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
+ where
+ K: AsRef<OsStr>,
+ V: AsRef<OsStr>;
+
+ fn envs<I, K, V>(&mut self, envs: I) -> &mut Self
+ where
+ I: IntoIterator<Item = (K, V)>,
+ K: AsRef<OsStr>,
+ V: AsRef<OsStr>;
+
+ fn stdout<T>(&mut self, stdout: T) -> &mut Self
+ where
+ T: Into<Stdio>;
+
+ fn stderr<T>(&mut self, stderr: T) -> &mut Self
+ where
+ T: Into<Stdio>;
+
+ fn start(self) -> Result<Self::Process, RunnerError>;
+}
+
+pub trait RunnerProcess {
+ /// Attempts to collect the exit status of the process if it has already exited.
+ ///
+ /// This function will not block the calling thread and will only advisorily check to see if
+ /// the child process has exited or not. If the process has exited then on Unix the process ID
+ /// is reaped. This function is guaranteed to repeatedly return a successful exit status so
+ /// long as the child has already exited.
+ ///
+ /// If the process has exited, then `Ok(Some(status))` is returned. If the exit status is not
+ /// available at this time then `Ok(None)` is returned. If an error occurs, then that error is
+ /// returned.
+ fn try_wait(&mut self) -> io::Result<Option<process::ExitStatus>>;
+
+ /// Waits for the process to exit completely, killing it if it does not stop within `timeout`,
+ /// and returns the status that it exited with.
+ ///
+ /// Firefox' integrated background monitor observes long running threads during shutdown and
+ /// kills these after 63 seconds. If the process fails to exit within the duration of
+ /// `timeout`, it is forcefully killed.
+ ///
+ /// This function will continue to have the same return value after it has been called at least
+ /// once.
+ fn wait(&mut self, timeout: time::Duration) -> io::Result<process::ExitStatus>;
+
+ /// Determine if the process is still running.
+ fn running(&mut self) -> bool;
+
+ /// Forces the process to exit and returns the exit status. This is
+ /// equivalent to sending a SIGKILL on Unix platforms.
+ fn kill(&mut self) -> io::Result<process::ExitStatus>;
+}
+
+#[derive(Debug)]
+pub enum RunnerError {
+ Io(io::Error),
+ PrefReader(PrefReaderError),
+}
+
+impl fmt::Display for RunnerError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match *self {
+ RunnerError::Io(ref err) => match err.kind() {
+ ErrorKind::NotFound => "no such file or directory".fmt(f),
+ _ => err.fmt(f),
+ },
+ RunnerError::PrefReader(ref err) => err.fmt(f),
+ }
+ }
+}
+
+impl Error for RunnerError {
+ fn cause(&self) -> Option<&dyn Error> {
+ Some(match *self {
+ RunnerError::Io(ref err) => err as &dyn Error,
+ RunnerError::PrefReader(ref err) => err as &dyn Error,
+ })
+ }
+}
+
+impl From<io::Error> for RunnerError {
+ fn from(value: io::Error) -> RunnerError {
+ RunnerError::Io(value)
+ }
+}
+
+impl From<PrefReaderError> for RunnerError {
+ fn from(value: PrefReaderError) -> RunnerError {
+ RunnerError::PrefReader(value)
+ }
+}
+
+#[derive(Debug)]
+pub struct FirefoxProcess {
+ process: Child,
+ // The profile field is not directly used, but it is kept to avoid its
+ // Drop removing the (temporary) profile directory.
+ #[allow(dead_code)]
+ profile: Option<Profile>,
+}
+
+impl RunnerProcess for FirefoxProcess {
+ fn try_wait(&mut self) -> io::Result<Option<process::ExitStatus>> {
+ self.process.try_wait()
+ }
+
+ fn wait(&mut self, timeout: time::Duration) -> io::Result<process::ExitStatus> {
+ let start = time::Instant::now();
+ loop {
+ match self.try_wait() {
+ // child has already exited, reap its exit code
+ Ok(Some(status)) => return Ok(status),
+
+ // child still running and timeout elapsed, kill it
+ Ok(None) if start.elapsed() >= timeout => return self.kill(),
+
+ // child still running, let's give it more time
+ Ok(None) => thread::sleep(time::Duration::from_millis(100)),
+
+ Err(e) => return Err(e),
+ }
+ }
+ }
+
+ fn running(&mut self) -> bool {
+ self.try_wait().unwrap().is_none()
+ }
+
+ fn kill(&mut self) -> io::Result<process::ExitStatus> {
+ match self.try_wait() {
+ // child has already exited, reap its exit code
+ Ok(Some(status)) => Ok(status),
+
+ // child still running, kill it
+ Ok(None) => {
+ debug!("Killing process {}", self.process.id());
+ self.process.kill()?;
+ self.process.wait()
+ }
+
+ Err(e) => Err(e),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct FirefoxRunner {
+ path: PathBuf,
+ profile: Option<Profile>,
+ args: Vec<OsString>,
+ envs: HashMap<OsString, OsString>,
+ stdout: Option<Stdio>,
+ stderr: Option<Stdio>,
+}
+
+impl FirefoxRunner {
+ /// Initialise Firefox process runner.
+ ///
+ /// On macOS, `path` can optionally point to an application bundle,
+ /// i.e. _/Applications/Firefox.app_, as well as to an executable program
+ /// such as _/Applications/Firefox.app/Content/MacOS/firefox-bin_.
+ pub fn new(path: &Path, profile: Option<Profile>) -> FirefoxRunner {
+ let mut envs: HashMap<OsString, OsString> = HashMap::new();
+ envs.insert("MOZ_NO_REMOTE".into(), "1".into());
+
+ FirefoxRunner {
+ path: path.to_path_buf(),
+ envs,
+ profile,
+ args: vec![],
+ stdout: None,
+ stderr: None,
+ }
+ }
+}
+
+impl Runner for FirefoxRunner {
+ type Process = FirefoxProcess;
+
+ fn arg<S>(&mut self, arg: S) -> &mut FirefoxRunner
+ where
+ S: AsRef<OsStr>,
+ {
+ self.args.push((&arg).into());
+ self
+ }
+
+ fn args<I, S>(&mut self, args: I) -> &mut FirefoxRunner
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<OsStr>,
+ {
+ for arg in args {
+ self.args.push((&arg).into());
+ }
+ self
+ }
+
+ fn env<K, V>(&mut self, key: K, value: V) -> &mut FirefoxRunner
+ where
+ K: AsRef<OsStr>,
+ V: AsRef<OsStr>,
+ {
+ self.envs.insert((&key).into(), (&value).into());
+ self
+ }
+
+ fn envs<I, K, V>(&mut self, envs: I) -> &mut FirefoxRunner
+ where
+ I: IntoIterator<Item = (K, V)>,
+ K: AsRef<OsStr>,
+ V: AsRef<OsStr>,
+ {
+ for (key, value) in envs {
+ self.envs.insert((&key).into(), (&value).into());
+ }
+ self
+ }
+
+ fn stdout<T>(&mut self, stdout: T) -> &mut Self
+ where
+ T: Into<Stdio>,
+ {
+ self.stdout = Some(stdout.into());
+ self
+ }
+
+ fn stderr<T>(&mut self, stderr: T) -> &mut Self
+ where
+ T: Into<Stdio>,
+ {
+ self.stderr = Some(stderr.into());
+ self
+ }
+
+ fn start(mut self) -> Result<FirefoxProcess, RunnerError> {
+ if let Some(ref mut profile) = self.profile {
+ profile.user_prefs()?.write()?;
+ }
+
+ let stdout = self.stdout.unwrap_or_else(Stdio::inherit);
+ let stderr = self.stderr.unwrap_or_else(Stdio::inherit);
+
+ let binary_path = platform::resolve_binary_path(&mut self.path);
+ let mut cmd = Command::new(binary_path);
+ cmd.args(&self.args[..])
+ .envs(&self.envs)
+ .stdout(stdout)
+ .stderr(stderr);
+
+ let mut seen_foreground = false;
+ let mut seen_no_remote = false;
+ let mut seen_profile = false;
+ for arg in self.args.iter() {
+ match arg.into() {
+ Arg::Foreground => seen_foreground = true,
+ Arg::NoRemote => seen_no_remote = true,
+ Arg::Profile | Arg::NamedProfile | Arg::ProfileManager => seen_profile = true,
+ Arg::Marionette
+ | Arg::None
+ | Arg::Other(_)
+ | Arg::RemoteAllowHosts
+ | Arg::RemoteAllowOrigins
+ | Arg::RemoteDebuggingPort => {}
+ }
+ }
+ // -foreground is only supported on Mac, and shouldn't be passed
+ // to Firefox on other platforms (bug 1720502).
+ if cfg!(target_os = "macos") && !seen_foreground {
+ cmd.arg("-foreground");
+ }
+ if !seen_no_remote {
+ cmd.arg("-no-remote");
+ }
+ if let Some(ref profile) = self.profile {
+ if !seen_profile {
+ cmd.arg("-profile").arg(&profile.path);
+ }
+ }
+
+ info!("Running command: {:?}", cmd);
+ let process = cmd.spawn()?;
+ Ok(FirefoxProcess {
+ process,
+ profile: self.profile,
+ })
+ }
+}
+
+#[cfg(all(not(target_os = "macos"), unix))]
+pub mod platform {
+ use crate::path::find_binary;
+ use std::path::PathBuf;
+
+ pub fn resolve_binary_path(path: &mut PathBuf) -> &PathBuf {
+ path
+ }
+
+ fn running_as_snap() -> bool {
+ std::env::var("SNAP_INSTANCE_NAME")
+ .or_else(|_| {
+ // Compatibility for snapd <= 2.35
+ std::env::var("SNAP_NAME")
+ })
+ .map(|name| !name.is_empty())
+ .unwrap_or(false)
+ }
+
+ /// Searches the system path for `firefox`.
+ pub fn firefox_default_path() -> Option<PathBuf> {
+ if running_as_snap() {
+ return Some(PathBuf::from("/snap/firefox/current/firefox.launcher"));
+ }
+ find_binary("firefox")
+ }
+
+ pub fn arg_prefix_char(c: char) -> bool {
+ c == '-'
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use crate::firefox_default_path;
+ use std::env;
+ use std::ops::Drop;
+ use std::path::PathBuf;
+
+ static SNAP_KEY: &str = "SNAP_INSTANCE_NAME";
+ static SNAP_LEGACY_KEY: &str = "SNAP_NAME";
+
+ struct SnapEnvironment {
+ initial_environment: (Option<String>, Option<String>),
+ }
+
+ impl SnapEnvironment {
+ fn new() -> SnapEnvironment {
+ SnapEnvironment {
+ initial_environment: (env::var(SNAP_KEY).ok(), env::var(SNAP_LEGACY_KEY).ok()),
+ }
+ }
+
+ fn set(&self, value: Option<String>, legacy_value: Option<String>) {
+ fn set_env(key: &str, value: Option<String>) {
+ match value {
+ Some(value) => env::set_var(key, value),
+ None => env::remove_var(key),
+ }
+ }
+ set_env(SNAP_KEY, value);
+ set_env(SNAP_LEGACY_KEY, legacy_value);
+ }
+ }
+
+ impl Drop for SnapEnvironment {
+ fn drop(&mut self) {
+ self.set(
+ self.initial_environment.0.clone(),
+ self.initial_environment.1.clone(),
+ )
+ }
+ }
+
+ #[test]
+ fn test_default_path() {
+ let snap_path = Some(PathBuf::from("/snap/firefox/current/firefox.launcher"));
+
+ let snap_env = SnapEnvironment::new();
+
+ snap_env.set(None, None);
+ assert_ne!(firefox_default_path(), snap_path);
+
+ snap_env.set(Some("value".into()), None);
+ assert_eq!(firefox_default_path(), snap_path);
+
+ snap_env.set(None, Some("value".into()));
+ assert_eq!(firefox_default_path(), snap_path);
+ }
+ }
+}
+
+#[cfg(target_os = "macos")]
+pub mod platform {
+ use crate::path::{find_binary, is_binary};
+ use dirs;
+ use plist::Value;
+ use std::path::PathBuf;
+
+ /// Searches for the binary file inside the path passed as parameter.
+ /// If the binary is not found, the path remains unaltered.
+ /// Else, it gets updated by the new binary path.
+ pub fn resolve_binary_path(path: &mut PathBuf) -> &PathBuf {
+ if path.as_path().is_dir() {
+ let mut info_plist = path.clone();
+ info_plist.push("Contents");
+ info_plist.push("Info.plist");
+ if let Ok(plist) = Value::from_file(&info_plist) {
+ if let Some(dict) = plist.as_dictionary() {
+ if let Some(Value::String(s)) = dict.get("CFBundleExecutable") {
+ path.push("Contents");
+ path.push("MacOS");
+ path.push(s);
+ }
+ }
+ }
+ }
+ path
+ }
+
+ /// Searches the system path for `firefox-bin`, then looks for
+ /// `Applications/Firefox.app/Contents/MacOS/firefox-bin` as well
+ /// as `Applications/Firefox Nightly.app/Contents/MacOS/firefox-bin`
+ /// under both `/` (system root) and the user home directory.
+ pub fn firefox_default_path() -> Option<PathBuf> {
+ if let Some(path) = find_binary("firefox-bin") {
+ return Some(path);
+ }
+
+ let home = dirs::home_dir();
+ for &(prefix_home, trial_path) in [
+ (
+ false,
+ "/Applications/Firefox.app/Contents/MacOS/firefox-bin",
+ ),
+ (true, "Applications/Firefox.app/Contents/MacOS/firefox-bin"),
+ (
+ false,
+ "/Applications/Firefox Nightly.app/Contents/MacOS/firefox-bin",
+ ),
+ (
+ true,
+ "Applications/Firefox Nightly.app/Contents/MacOS/firefox-bin",
+ ),
+ ]
+ .iter()
+ {
+ let path = match (home.as_ref(), prefix_home) {
+ (Some(home_dir), true) => home_dir.join(trial_path),
+ (None, true) => continue,
+ (_, false) => PathBuf::from(trial_path),
+ };
+ if is_binary(&path) {
+ return Some(path);
+ }
+ }
+
+ None
+ }
+
+ pub fn arg_prefix_char(c: char) -> bool {
+ c == '-'
+ }
+}
+
+#[cfg(target_os = "windows")]
+pub mod platform {
+ use crate::path::{find_binary, is_binary};
+ use std::io::Error;
+ use std::path::PathBuf;
+ use winreg::enums::*;
+ use winreg::RegKey;
+
+ pub fn resolve_binary_path(path: &mut PathBuf) -> &PathBuf {
+ path
+ }
+
+ /// Searches the Windows registry, then the system path for `firefox.exe`.
+ ///
+ /// It _does not_ currently check the `HKEY_CURRENT_USER` tree.
+ pub fn firefox_default_path() -> Option<PathBuf> {
+ if let Ok(Some(path)) = firefox_registry_path() {
+ if is_binary(&path) {
+ return Some(path);
+ }
+ };
+ find_binary("firefox.exe")
+ }
+
+ fn firefox_registry_path() -> Result<Option<PathBuf>, Error> {
+ let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
+ for subtree_key in ["SOFTWARE", "SOFTWARE\\WOW6432Node"].iter() {
+ let subtree = hklm.open_subkey_with_flags(subtree_key, KEY_READ)?;
+ let mozilla_org = match subtree.open_subkey_with_flags("mozilla.org\\Mozilla", KEY_READ)
+ {
+ Ok(val) => val,
+ Err(_) => continue,
+ };
+ let current_version: String = mozilla_org.get_value("CurrentVersion")?;
+ let mozilla = subtree.open_subkey_with_flags("Mozilla", KEY_READ)?;
+ for key_res in mozilla.enum_keys() {
+ let key = key_res?;
+ let section_data = mozilla.open_subkey_with_flags(&key, KEY_READ)?;
+ let version: Result<String, _> = section_data.get_value("GeckoVer");
+ if let Ok(ver) = version {
+ if ver == current_version {
+ let mut bin_key = key.to_owned();
+ bin_key.push_str("\\bin");
+ if let Ok(bin_subtree) = mozilla.open_subkey_with_flags(bin_key, KEY_READ) {
+ let path_to_exe: Result<String, _> = bin_subtree.get_value("PathToExe");
+ if let Ok(path_to_exe) = path_to_exe {
+ let path = PathBuf::from(path_to_exe);
+ if is_binary(&path) {
+ return Ok(Some(path));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ Ok(None)
+ }
+
+ pub fn arg_prefix_char(c: char) -> bool {
+ c == '/' || c == '-'
+ }
+}
+
+#[cfg(not(any(unix, target_os = "windows")))]
+pub mod platform {
+ use std::path::PathBuf;
+
+ /// Returns an unaltered path for all operating systems other than macOS.
+ pub fn resolve_binary_path(path: &mut PathBuf) -> &PathBuf {
+ path
+ }
+
+ /// Returns `None` for all other operating systems than Linux, macOS, and
+ /// Windows.
+ pub fn firefox_default_path() -> Option<PathBuf> {
+ None
+ }
+
+ pub fn arg_prefix_char(c: char) -> bool {
+ c == '-'
+ }
+}
diff --git a/testing/mozbase/rust/mozversion/Cargo.toml b/testing/mozbase/rust/mozversion/Cargo.toml
new file mode 100644
index 0000000000..fa6e85ef11
--- /dev/null
+++ b/testing/mozbase/rust/mozversion/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+edition = "2018"
+name = "mozversion"
+version = "0.5.1"
+authors = ["Mozilla"]
+description = "Utility for accessing Firefox version metadata"
+keywords = [
+ "firefox",
+ "mozilla",
+]
+license = "MPL-2.0"
+repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozversion"
+
+[dependencies]
+regex = { version = "1", default-features = false, features = ["perf", "std"] }
+rust-ini = "0.10"
+semver = "1.0"
diff --git a/testing/mozbase/rust/mozversion/src/lib.rs b/testing/mozbase/rust/mozversion/src/lib.rs
new file mode 100644
index 0000000000..75a40fe779
--- /dev/null
+++ b/testing/mozbase/rust/mozversion/src/lib.rs
@@ -0,0 +1,426 @@
+#![forbid(unsafe_code)]
+/* 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/. */
+
+extern crate ini;
+extern crate regex;
+extern crate semver;
+
+use crate::platform::ini_path;
+use ini::Ini;
+use regex::Regex;
+use std::default::Default;
+use std::error;
+use std::fmt::{self, Display, Formatter};
+use std::path::Path;
+use std::process::{Command, Stdio};
+use std::str::{self, FromStr};
+
+/// Details about the version of a Firefox build.
+#[derive(Clone, Default)]
+pub struct AppVersion {
+ /// Unique date-based id for a build
+ pub build_id: Option<String>,
+ /// Channel name
+ pub code_name: Option<String>,
+ /// Version number e.g. 55.0a1
+ pub version_string: Option<String>,
+ /// Url of the respoistory from which the build was made
+ pub source_repository: Option<String>,
+ /// Commit ID of the build
+ pub source_stamp: Option<String>,
+}
+
+impl AppVersion {
+ pub fn new() -> AppVersion {
+ Default::default()
+ }
+
+ fn update_from_application_ini(&mut self, ini_file: &Ini) {
+ if let Some(section) = ini_file.section(Some("App")) {
+ if let Some(build_id) = section.get("BuildID") {
+ self.build_id = Some(build_id.clone());
+ }
+ if let Some(code_name) = section.get("CodeName") {
+ self.code_name = Some(code_name.clone());
+ }
+ if let Some(version) = section.get("Version") {
+ self.version_string = Some(version.clone());
+ }
+ if let Some(source_repository) = section.get("SourceRepository") {
+ self.source_repository = Some(source_repository.clone());
+ }
+ if let Some(source_stamp) = section.get("SourceStamp") {
+ self.source_stamp = Some(source_stamp.clone());
+ }
+ }
+ }
+
+ fn update_from_platform_ini(&mut self, ini_file: &Ini) {
+ if let Some(section) = ini_file.section(Some("Build")) {
+ if let Some(build_id) = section.get("BuildID") {
+ self.build_id = Some(build_id.clone());
+ }
+ if let Some(version) = section.get("Milestone") {
+ self.version_string = Some(version.clone());
+ }
+ if let Some(source_repository) = section.get("SourceRepository") {
+ self.source_repository = Some(source_repository.clone());
+ }
+ if let Some(source_stamp) = section.get("SourceStamp") {
+ self.source_stamp = Some(source_stamp.clone());
+ }
+ }
+ }
+
+ pub fn version(&self) -> Option<Version> {
+ self.version_string
+ .as_ref()
+ .and_then(|x| Version::from_str(x).ok())
+ }
+}
+
+#[derive(Default, Clone)]
+/// Version number information
+pub struct Version {
+ /// Major version number (e.g. 55 in 55.0)
+ pub major: u64,
+ /// Minor version number (e.g. 1 in 55.1)
+ pub minor: u64,
+ /// Patch version number (e.g. 2 in 55.1.2)
+ pub patch: u64,
+ /// Prerelase information (e.g. Some(("a", 1)) in 55.0a1)
+ pub pre: Option<(String, u64)>,
+ /// Is build an ESR build
+ pub esr: bool,
+}
+
+impl Version {
+ fn to_semver(&self) -> semver::Version {
+ // The way the semver crate handles prereleases isn't what we want here
+ // This should be fixed in the long term by implementing our own comparison
+ // operators, but for now just act as if prerelease metadata was missing,
+ // otherwise it is almost impossible to use this with nightly
+ semver::Version {
+ major: self.major,
+ minor: self.minor,
+ patch: self.patch,
+ pre: semver::Prerelease::EMPTY,
+ build: semver::BuildMetadata::EMPTY,
+ }
+ }
+
+ pub fn matches(&self, version_req: &str) -> VersionResult<bool> {
+ let req = semver::VersionReq::parse(version_req)?;
+ Ok(req.matches(&self.to_semver()))
+ }
+}
+
+impl FromStr for Version {
+ type Err = Error;
+
+ fn from_str(version_string: &str) -> VersionResult<Version> {
+ let mut version: Version = Default::default();
+ let version_re = Regex::new(r"^(?P<major>[[:digit:]]+)\.(?P<minor>[[:digit:]]+)(?:\.(?P<patch>[[:digit:]]+))?(?:(?P<esr>esr)|(?P<pre0>[a-z]+)(?P<pre1>[[:digit:]]*))?$").unwrap();
+ if let Some(captures) = version_re.captures(version_string) {
+ match captures
+ .name("major")
+ .and_then(|x| u64::from_str(x.as_str()).ok())
+ {
+ Some(x) => version.major = x,
+ None => return Err(Error::VersionError("No major version number found".into())),
+ }
+ match captures
+ .name("minor")
+ .and_then(|x| u64::from_str(x.as_str()).ok())
+ {
+ Some(x) => version.minor = x,
+ None => return Err(Error::VersionError("No minor version number found".into())),
+ }
+ if let Some(x) = captures
+ .name("patch")
+ .and_then(|x| u64::from_str(x.as_str()).ok())
+ {
+ version.patch = x
+ }
+ if captures.name("esr").is_some() {
+ version.esr = true;
+ }
+ if let Some(pre_0) = captures.name("pre0").map(|x| x.as_str().to_string()) {
+ if captures.name("pre1").is_some() {
+ if let Some(pre_1) = captures
+ .name("pre1")
+ .and_then(|x| u64::from_str(x.as_str()).ok())
+ {
+ version.pre = Some((pre_0, pre_1))
+ } else {
+ return Err(Error::VersionError(
+ "Failed to convert prelease number to u64".into(),
+ ));
+ }
+ } else {
+ return Err(Error::VersionError(
+ "Failed to convert prelease number to u64".into(),
+ ));
+ }
+ }
+ } else {
+ return Err(Error::VersionError(format!(
+ "Failed to parse {} as version string",
+ version_string
+ )));
+ }
+ Ok(version)
+ }
+}
+
+impl Display for Version {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ match self.patch {
+ 0 => write!(f, "{}.{}", self.major, self.minor)?,
+ _ => write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?,
+ }
+ if self.esr {
+ write!(f, "esr")?;
+ }
+ if let Some(ref pre) = self.pre {
+ write!(f, "{}{}", pre.0, pre.1)?;
+ };
+ Ok(())
+ }
+}
+
+/// Determine the version of Firefox using associated metadata files.
+///
+/// Given the path to a Firefox binary, read the associated application.ini
+/// and platform.ini files to extract information about the version of Firefox
+/// at that path.
+pub fn firefox_version(binary: &Path) -> VersionResult<AppVersion> {
+ let mut version = AppVersion::new();
+ let mut updated = false;
+
+ if let Some(dir) = ini_path(binary) {
+ let mut application_ini = dir.clone();
+ application_ini.push("application.ini");
+
+ if Path::exists(&application_ini) {
+ let ini_file = Ini::load_from_file(application_ini).ok();
+ if let Some(ini) = ini_file {
+ updated = true;
+ version.update_from_application_ini(&ini);
+ }
+ }
+
+ let mut platform_ini = dir;
+ platform_ini.push("platform.ini");
+
+ if Path::exists(&platform_ini) {
+ let ini_file = Ini::load_from_file(platform_ini).ok();
+ if let Some(ini) = ini_file {
+ updated = true;
+ version.update_from_platform_ini(&ini);
+ }
+ }
+
+ if !updated {
+ return Err(Error::MetadataError(
+ "Neither platform.ini nor application.ini found".into(),
+ ));
+ }
+ } else {
+ return Err(Error::MetadataError("Invalid binary path".into()));
+ }
+ Ok(version)
+}
+
+/// Determine the version of Firefox by executing the binary.
+///
+/// Given the path to a Firefox binary, run firefox --version and extract the
+/// version string from the output
+pub fn firefox_binary_version(binary: &Path) -> VersionResult<Version> {
+ let output = Command::new(binary)
+ .args(["--version"])
+ .stdout(Stdio::piped())
+ .spawn()
+ .and_then(|child| child.wait_with_output())
+ .ok();
+
+ if let Some(x) = output {
+ let output_str = str::from_utf8(&x.stdout)
+ .map_err(|_| Error::VersionError("Couldn't parse version output as UTF8".into()))?;
+ parse_binary_version(output_str)
+ } else {
+ Err(Error::VersionError("Running binary failed".into()))
+ }
+}
+
+fn parse_binary_version(version_str: &str) -> VersionResult<Version> {
+ let version_regexp = Regex::new(r#"Mozilla Firefox[[:space:]]+(?P<version>.+)"#)
+ .expect("Error parsing version regexp");
+
+ let version_match = version_regexp
+ .captures(version_str)
+ .and_then(|captures| captures.name("version"))
+ .ok_or_else(|| Error::VersionError("--version output didn't match expectations".into()))?;
+
+ Version::from_str(version_match.as_str())
+}
+
+#[derive(Clone, Debug)]
+pub enum Error {
+ /// Error parsing a version string
+ VersionError(String),
+ /// Error reading application metadata
+ MetadataError(String),
+ /// Error processing a string as a semver comparator
+ SemVerError(String),
+}
+
+impl Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match *self {
+ Error::VersionError(ref x) => {
+ "VersionError: ".fmt(f)?;
+ x.fmt(f)
+ }
+ Error::MetadataError(ref x) => {
+ "MetadataError: ".fmt(f)?;
+ x.fmt(f)
+ }
+ Error::SemVerError(ref e) => {
+ "SemVerError: ".fmt(f)?;
+ e.fmt(f)
+ }
+ }
+ }
+}
+
+impl From<semver::Error> for Error {
+ fn from(err: semver::Error) -> Error {
+ Error::SemVerError(err.to_string())
+ }
+}
+
+impl error::Error for Error {
+ fn cause(&self) -> Option<&dyn error::Error> {
+ None
+ }
+}
+
+pub type VersionResult<T> = Result<T, Error>;
+
+#[cfg(target_os = "macos")]
+mod platform {
+ use std::path::{Path, PathBuf};
+
+ pub fn ini_path(binary: &Path) -> Option<PathBuf> {
+ binary
+ .canonicalize()
+ .ok()
+ .as_ref()
+ .and_then(|dir| dir.parent())
+ .and_then(|dir| dir.parent())
+ .map(|dir| dir.join("Resources"))
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+mod platform {
+ use std::path::{Path, PathBuf};
+
+ pub fn ini_path(binary: &Path) -> Option<PathBuf> {
+ binary
+ .canonicalize()
+ .ok()
+ .as_ref()
+ .and_then(|dir| dir.parent())
+ .map(|dir| dir.to_path_buf())
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::{parse_binary_version, Version};
+ use std::str::FromStr;
+
+ fn parse_version(input: &str) -> String {
+ Version::from_str(input).unwrap().to_string()
+ }
+
+ fn compare(version: &str, comparison: &str) -> bool {
+ let v = Version::from_str(version).unwrap();
+ v.matches(comparison).unwrap()
+ }
+
+ #[test]
+ fn test_parser() {
+ assert!(parse_version("50.0a1") == "50.0a1");
+ assert!(parse_version("50.0.1a1") == "50.0.1a1");
+ assert!(parse_version("50.0.0") == "50.0");
+ assert!(parse_version("78.0.11esr") == "78.0.11esr");
+ }
+
+ #[test]
+ fn test_matches() {
+ assert!(compare("50.0", "=50"));
+ assert!(compare("50.1", "=50"));
+ assert!(compare("50.1", "=50.1"));
+ assert!(compare("50.1.1", "=50.1"));
+ assert!(compare("50.0.0", "=50.0.0"));
+ assert!(compare("51.0.0", ">50"));
+ assert!(compare("49.0", "<50"));
+ assert!(compare("50.0", "<50.1"));
+ assert!(compare("50.0.0", "<50.0.1"));
+ assert!(!compare("50.1.0", ">50"));
+ assert!(!compare("50.1.0", "<50"));
+ assert!(compare("50.1.0", ">=50,<51"));
+ assert!(compare("50.0a1", ">49.0"));
+ assert!(compare("50.0a2", "=50"));
+ assert!(compare("78.1.0esr", ">=78"));
+ assert!(compare("78.1.0esr", "<79"));
+ assert!(compare("78.1.11esr", "<79"));
+ // This is the weird one
+ assert!(!compare("50.0a2", ">50.0"));
+ }
+
+ #[test]
+ fn test_binary_parser() {
+ assert!(
+ parse_binary_version("Mozilla Firefox 50.0a1")
+ .unwrap()
+ .to_string()
+ == "50.0a1"
+ );
+ assert!(
+ parse_binary_version("Mozilla Firefox 50.0.1a1")
+ .unwrap()
+ .to_string()
+ == "50.0.1a1"
+ );
+ assert!(
+ parse_binary_version("Mozilla Firefox 50.0.0")
+ .unwrap()
+ .to_string()
+ == "50.0"
+ );
+ assert!(
+ parse_binary_version("Mozilla Firefox 78.0.11esr")
+ .unwrap()
+ .to_string()
+ == "78.0.11esr"
+ );
+ assert!(
+ parse_binary_version("Mozilla Firefox 78.0esr")
+ .unwrap()
+ .to_string()
+ == "78.0esr"
+ );
+ assert!(
+ parse_binary_version("Mozilla Firefox 78.0")
+ .unwrap()
+ .to_string()
+ == "78.0"
+ );
+ }
+}