diff options
Diffstat (limited to '')
18 files changed, 4252 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..159a342633 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mozdevice" +version = "0.3.1" +authors = ["Mozilla"] +description = "Client library for the Android Debug Bridge (adb)" +keywords = ["mozilla", "firefox", "geckoview", "android", "adb"] +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozdevice" +license = "MPL-2.0" +edition = "2018" + +[dependencies] +log = { version = "0.4", features = ["std"] } +once_cell = "1.4.0" +regex = { version = "1", default-features = false, features = ["perf", "std"] } +tempfile = "3" +walkdir = "2" +uuid = { version = "0.8", 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..f565a6131c --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/lib.rs @@ -0,0 +1,860 @@ +/* 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; +extern crate once_cell; +extern crate regex; +extern crate tempfile; +extern crate walkdir; + +pub mod adb; +pub mod shell; + +#[cfg(test)] +pub mod test; + +use once_cell::sync::Lazy; +use regex::Regex; +use std::collections::BTreeMap; +use std::convert::TryFrom; +use std::fmt; +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::{Path, PathBuf}; +use std::str::{FromStr, Utf8Error}; +use std::time::{Duration, SystemTime}; +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)] +pub enum DeviceError { + Adb(String), + FromInt(TryFromIntError), + InvalidStorage, + Io(io::Error), + MissingPackage, + MultipleDevices, + ParseInt(ParseIntError), + UnknownDevice(String), + Utf8(Utf8Error), + WalkDir(walkdir::Error), +} + +impl fmt::Display for DeviceError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + DeviceError::Adb(ref message) => message.fmt(f), + DeviceError::MultipleDevices => write!(f, "Multiple Android devices online"), + DeviceError::UnknownDevice(ref serial) => { + write!(f, "Unknown Android device with serial '{}'", serial) + } + _ => self.to_string().fmt(f), + } + } +} + +impl From<io::Error> for DeviceError { + fn from(value: io::Error) -> DeviceError { + DeviceError::Io(value) + } +} + +impl From<ParseIntError> for DeviceError { + fn from(value: ParseIntError) -> DeviceError { + DeviceError::ParseInt(value) + } +} + +impl From<TryFromIntError> for DeviceError { + fn from(value: TryFromIntError) -> DeviceError { + DeviceError::FromInt(value) + } +} + +impl From<Utf8Error> for DeviceError { + fn from(value: Utf8Error) -> DeviceError { + DeviceError::Utf8(value) + } +} + +impl From<walkdir::Error> for DeviceError { + fn from(value: walkdir::Error) -> DeviceError { + DeviceError::WalkDir(value) + } +} + +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[0..4] != 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.len() >= 4 && &response[0..4] == 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.len() >= 4 && &response[0..4] == 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)?; + warn!( + "adb server response contained hexstring length {} and message length was {} \ + and message was {:?}", + n, + message.len(), + 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: PathBuf, +} + +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: PathBuf::from("/data/local/tmp"), + }; + device + .tempfile + .push(Uuid::new_v4().to_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 => match device.is_rooted { + true => AndroidStorage::Internal, + false => AndroidStorage::App, + }, + }; + + // Set Permissive=1 if we have root. + if device.is_rooted { + device.execute_host_shell_command("setenforce permissive")?; + } + + 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: &Path) -> 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: &Path, 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); + debug!("execute_host_command: >> {:?}", &switch_command); + stream.write_all(encode_message(&switch_command)?.as_bytes())?; + let _bytes = read_response(&mut stream, false, false)?; + debug!("execute_host_command: << {:?}", _bytes); + // TODO: should we assert no bytes were read? + + debug!("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)?; + debug!("execute_host_command: << {:?}", response); + + Ok(response.to_owned()) + } + + pub fn enable_run_as_for_path(&self, path: &Path) -> bool { + match &self.run_as_package { + Some(package) => { + let mut p = PathBuf::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!("killforward:tcp:{}", local); + self.execute_host_command(&command, true, false).and(Ok(())) + } + + pub fn kill_forward_all_ports(&self) -> Result<()> { + self.execute_host_command(&"killforward-all".to_owned(), 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 path_exists(&self, path: &Path, 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 push(&self, buffer: &mut dyn Read, dest: &Path, 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 => Path::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<&Path> = None; + let mut root: Option<&Path> = 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[0..4] == 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() { + debug!("Failed to remove {}", dest1.display()); + } + result?; + } + Ok(()) + } else if &buf[0..4] == SyncCommand::Fail.code() { + if enable_run_as && self.remove(dest1).is_err() { + debug!("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() { + debug!("Failed to remove {}", dest1.display()); + } + Err(DeviceError::Adb("FAIL (unknown)".to_owned())) + } + } + + pub fn push_dir(&self, source: &Path, dest_dir: &Path, 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 mut dest = dest_dir.to_path_buf(); + dest.push( + path.strip_prefix(source) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?, + ); + + self.push(&mut file, &dest, mode)?; + } + + Ok(()) + } + + pub fn remove(&self, path: &Path) -> Result<()> { + debug!("Deleting {}", path.display()); + + self.execute_host_shell_command_as( + &format!("rm -rf {}", path.display()), + self.enable_run_as_for_path(&path), + )?; + + Ok(()) + } +} diff --git a/testing/mozbase/rust/mozdevice/src/shell.rs b/testing/mozbase/rust/mozdevice/src/shell.rs new file mode 100644 index 0000000000..593f790118 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/shell.rs @@ -0,0 +1,62 @@ +// 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(); + let line_feed: Regex = Regex::new(r"\n").unwrap(); + + if input.is_empty() { + return "''".to_owned(); + } + + let output = &escape_pattern.replace_all(input, "\\$1"); + + line_feed.replace_all(output, "'\n'").to_string() +} + +#[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("あい"), "\\あ\\い"); + } +} diff --git a/testing/mozbase/rust/mozdevice/src/test.rs b/testing/mozbase/rust/mozdevice/src/test.rs new file mode 100644 index 0000000000..689b552170 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/test.rs @@ -0,0 +1,508 @@ +/* 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::*; + +// 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::{AndroidStorage, AndroidStorageInput}; +use std::collections::BTreeSet; +use std::panic; +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, &Path) + 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 remote_path = Path::new("/data/local/tmp/mozdevice/"); + + let _ = device.remove(remote_path); + + let result = panic::catch_unwind(|| test(&device, &tmp_dir, &remote_path)); + + 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"); + if device.is_rooted { + assert_eq!(device.storage, AndroidStorage::Internal); + } else { + assert_eq!(device.storage, AndroidStorage::App); + } +} + +#[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, _: &Path| { + 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, _: &Path| { + 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, _: &Path| { +// 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, _: &Path| { + 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, _: &Path| { + 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, _: &Path| { + 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, _: &Path| { + 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, _: &Path| { + 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, _: &Path| { +// 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, _: &Path| { + 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, _: &Path| { +// 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, _: &Path| { + 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, _: &Path| { + 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() { + run_device_test(|device: &Device, _: &TempDir, remote_root_path: &Path| { + 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"); + + 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")); + + 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); + }); +} + +#[test] +#[ignore] +fn device_push_dir() { + run_device_test( + |device: &Device, tmp_dir: &TempDir, remote_root_path: &Path| { + let content = "test"; + + let files = [ + Path::new("foo1.bar"), + Path::new("foo2.bar"), + Path::new("bar/foo3.bar"), + Path::new("bar/more/baz/moar/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 = remote_root_path.join(file); + 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())); + } + }, + ); +} diff --git a/testing/mozbase/rust/mozprofile/Cargo.toml b/testing/mozbase/rust/mozprofile/Cargo.toml new file mode 100644 index 0000000000..facd5cc437 --- /dev/null +++ b/testing/mozbase/rust/mozprofile/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "mozprofile" +version = "0.7.1" +authors = ["Mozilla"] +description = "Library for working with Mozilla profiles." +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozprofile" +license = "MPL-2.0" +edition = "2018" + +[dependencies] +tempfile = "3" diff --git a/testing/mozbase/rust/mozprofile/src/lib.rs b/testing/mozbase/rust/mozprofile/src/lib.rs new file mode 100644 index 0000000000..29aee0bf2a --- /dev/null +++ b/testing/mozbase/rust/mozprofile/src/lib.rs @@ -0,0 +1,206 @@ +#![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(data_e, _), &PrefToken::Error(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); + } + + 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..8ce1df7f9c --- /dev/null +++ b/testing/mozbase/rust/mozprofile/src/prefreader.rs @@ -0,0 +1,1057 @@ +/* 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::ops::Deref; +use std::str; + +impl PrefReaderError { + fn new( + message: &'static str, + 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> { + match self.parent { + None => None, + Some(ref cause) => Some(cause.deref()), + } + } +} + +impl From<io::Error> for PrefReaderError { + fn from(err: io::Error) -> PrefReaderError { + PrefReaderError::new("IOError", 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(&'static str, 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: &'static str, + 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", + 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; + 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 => { + let value = + i64::from_str_radix(buf.borrow(), 10).expect("Integer wasn't parsed as an i64"); + PrefToken::Int(value, position) + } + TokenType::Bool => { + let value = match buf.borrow() { + "true" => true, + "false" => false, + x => panic!(format!("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 >= self.data.len() - 1 { + 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 - 1; + while col_pos > 0 && self.data[col_pos] as char != '\n' { + col_pos -= 1; + } + self.position.column = (self.pos - col_pos as usize - 1) 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", + self.position, + None, + )) + } + }; + Ok(Some(char::from_u32(escape_char).ok_or_else(|| { + PrefReaderError::new("Invalid codepoint decoded from escape", 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", + self.position, + None, + )) + } + } + } + None => { + return Err(PrefReaderError::new( + "Unexpected EOF in escape", + self.position, + None, + )) + } + } + } + if first && value >= 0xD800 && value <= 0xDBFF { + // 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", + 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 && value >= 0xDC00 && value <= 0xDFFF { + return Err(PrefReaderError::new( + "Lone low surrogate", + self.position, + None, + )); + } else if !first && (value < 0xDC00 || value > 0xDFFF) { + return Err(PrefReaderError::new( + "Invalid low surrogate in surrogate pair", + 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(); + } + + 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", + 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 /", + 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", + 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", + 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", + 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", + 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", + 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", + 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", + 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", + 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", + 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, 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(data, pos) => return Err(PrefReaderError::new(data, 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#"\"#, r#"\\"#).replace(r#"""#, 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<'a>(tokenizer: &mut PrefTokenizer<'a>) -> 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", + token.position(), + None, + )); + } + } + let next = skip_comments(tokenizer); + match next { + Some(PrefToken::Paren('(', _)) => ParserState::Key, + _ => { + return Err(PrefReaderError::new( + "Expected open paren", + 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", + token.position(), + None, + )); + } + } + let next = skip_comments(tokenizer); + match next { + Some(PrefToken::Comma(_)) => ParserState::Value, + _ => { + return Err(PrefReaderError::new( + "Expected comma", + 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", + token.position(), + None, + )) + } + } + let next = skip_comments(tokenizer); + match next { + Some(PrefToken::Paren(')', _)) => {} + _ => { + return Err(PrefReaderError::new( + "Expected close paren", + 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", + 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", + 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..a1e1cabd00 --- /dev/null +++ b/testing/mozbase/rust/mozprofile/src/profile.rs @@ -0,0 +1,123 @@ +/* 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 Profile { + pub fn new() -> IoResult<Profile> { + let dir = Builder::new().prefix("rust_mozprofile").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 { + 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..00beaee040 --- /dev/null +++ b/testing/mozbase/rust/mozrunner/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mozrunner" +version = "0.12.1" +authors = ["Mozilla"] +description = "Reliable Firefox process management." +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozrunner" +license = "MPL-2.0" +edition = "2018" + +[dependencies] +log = "0.4" +mozprofile = { path = "../mozprofile", version = "0.7" } +plist = "0.5" + +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.5" + +[target.'cfg(target_os = "macos")'.dependencies] +dirs = "2" + +[[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..610af2c044 --- /dev/null +++ b/testing/mozbase/rust/mozrunner/src/firefox_args.rs @@ -0,0 +1,191 @@ +/* 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 +//! `/foreground`. Elsewhere, including Windows, arguments may be prefixed +//! with both single (`-foreground`) and double (`--foreground`) 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 std::ffi::{OsStr, OsString}; + +use crate::runner::platform; + +fn parse_arg_name<T>(arg: T) -> 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 start = 0; + let mut end = 0; + + 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 != '-' { + start = i; + end = start + 1; + } else { + start = i + 1; + end = start; + } + } else { + end += 1; + if name_end_char(c) { + end -= 1; + break; + } + } + } + + if start > 0 && end > start { + Some(arg_str[start..end].into()) + } else { + None + } +} + +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. + Foreground, + + /// `-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), + + /// Not an argument. + None, +} + +impl<'a> From<&'a OsString> for Arg { + fn from(arg_str: &OsString) -> Arg { + if let Some(basename) = parse_arg_name(arg_str) { + match &*basename { + "profile" => Arg::Profile, + "P" => Arg::NamedProfile, + "ProfileManager" => Arg::ProfileManager, + "foreground" => Arg::Foreground, + "no-remote" => Arg::NoRemote, + _ => Arg::Other(basename), + } + } else { + Arg::None + } + } +} + +#[cfg(test)] +mod tests { + use super::{parse_arg_name, Arg}; + use std::ffi::OsString; + + fn parse(arg: &str, name: Option<&str>) { + let result = parse_arg_name(arg); + assert_eq!(result, name.map(|x| x.to_string())); + } + + #[test] + fn test_parse_arg_name() { + 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_windows() { + parse("/profile", Some("profile")); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn test_parse_arg_name_non_windows() { + parse("/profile", None); + } + + #[test] + fn test_arg_from_osstring() { + 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("--foreground")), Arg::Foreground); + assert_eq!(Arg::from(&OsString::from("-foreground")), Arg::Foreground); + + assert_eq!(Arg::from(&OsString::from("--no-remote")), Arg::NoRemote); + assert_eq!(Arg::from(&OsString::from("-no-remote")), Arg::NoRemote); + } +} 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..215e72696f --- /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::PathBuf; + +#[cfg(unix)] +fn is_executable(path: &PathBuf) -> 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(_: &PathBuf) -> 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: &PathBuf) -> 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..485d94090c --- /dev/null +++ b/testing/mozbase/rust/mozrunner/src/runner.rs @@ -0,0 +1,486 @@ +/* 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, + profile: 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: 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: 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> { + self.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::Other(_) | Arg::None => {} + } + } + if !seen_foreground { + cmd.arg("-foreground"); + } + if !seen_no_remote { + cmd.arg("-no-remote"); + } + if !seen_profile { + cmd.arg("-profile").arg(&self.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 + } + + /// Searches the system path for `firefox`. + pub fn firefox_default_path() -> Option<PathBuf> { + find_binary("firefox") + } + + pub fn arg_prefix_char(c: char) -> bool { + c == '-' + } +} + +#[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(binary_file) = dict.get("CFBundleExecutable") { + if let Value::String(s) = binary_file { + 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(ref 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..9feb9fe93c --- /dev/null +++ b/testing/mozbase/rust/mozversion/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "mozversion" +version = "0.4.1" +authors = ["Mozilla"] +description = "Utility for accessing Firefox version metadata" +keywords = ["mozilla", "firefox"] +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozversion" +license = "MPL-2.0" +edition = "2018" + +[dependencies] +regex = { version = "1", default-features = false, features = ["perf", "std"] } +rust-ini = "0.10" +semver = "0.9" diff --git a/testing/mozbase/rust/mozversion/src/lib.rs b/testing/mozbase/rust/mozversion/src/lib.rs new file mode 100644 index 0000000000..73ca116879 --- /dev/null +++ b/testing/mozbase/rust/mozversion/src/lib.rs @@ -0,0 +1,429 @@ +#![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: vec![], + build: vec![], + } + } + + 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()))?; + + Ok(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(semver::ReqParseError), +} + +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::ReqParseError> for Error { + fn from(err: semver::ReqParseError) -> Error { + Error::SemVerError(err) + } +} + +impl error::Error for Error { + fn cause(&self) -> Option<&dyn error::Error> { + match *self { + Error::SemVerError(ref e) => Some(e), + Error::VersionError(_) | Error::MetadataError(_) => 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" + ); + } +} |