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