diff options
Diffstat (limited to 'testing/mozbase/rust/mozdevice/src')
-rw-r--r-- | testing/mozbase/rust/mozdevice/src/adb.rs | 38 | ||||
-rw-r--r-- | testing/mozbase/rust/mozdevice/src/lib.rs | 860 | ||||
-rw-r--r-- | testing/mozbase/rust/mozdevice/src/shell.rs | 62 | ||||
-rw-r--r-- | testing/mozbase/rust/mozdevice/src/test.rs | 508 |
4 files changed, 1468 insertions, 0 deletions
diff --git a/testing/mozbase/rust/mozdevice/src/adb.rs b/testing/mozbase/rust/mozdevice/src/adb.rs new file mode 100644 index 0000000000..9d9c91fd07 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/adb.rs @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#[derive(Debug, PartialEq)] +pub enum SyncCommand { + Data, + Dent, + Done, + Fail, + List, + Okay, + Quit, + Recv, + Send, + Stat, +} + +impl SyncCommand { + // Returns the byte serialisation of the protocol status. + pub fn code(&self) -> &'static [u8; 4] { + use self::SyncCommand::*; + match *self { + Data => b"DATA", + Dent => b"DENT", + Done => b"DONE", + Fail => b"FAIL", + List => b"LIST", + Okay => b"OKAY", + Quit => b"QUIT", + Recv => b"RECV", + Send => b"SEND", + Stat => b"STAT", + } + } +} + +pub type DeviceSerial = String; diff --git a/testing/mozbase/rust/mozdevice/src/lib.rs b/testing/mozbase/rust/mozdevice/src/lib.rs new file mode 100644 index 0000000000..f565a6131c --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/lib.rs @@ -0,0 +1,860 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#[macro_use] +extern crate log; +extern crate once_cell; +extern crate regex; +extern crate tempfile; +extern crate walkdir; + +pub mod adb; +pub mod shell; + +#[cfg(test)] +pub mod test; + +use once_cell::sync::Lazy; +use regex::Regex; +use std::collections::BTreeMap; +use std::convert::TryFrom; +use std::fmt; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::iter::FromIterator; +use std::net::TcpStream; +use std::num::{ParseIntError, TryFromIntError}; +use std::path::{Path, PathBuf}; +use std::str::{FromStr, Utf8Error}; +use std::time::{Duration, SystemTime}; +use uuid::Uuid; +use walkdir::WalkDir; + +use crate::adb::{DeviceSerial, SyncCommand}; + +pub type Result<T> = std::result::Result<T, DeviceError>; + +static SYNC_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^A-Za-z0-9_@%+=:,./-]").unwrap()); + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AndroidStorageInput { + Auto, + App, + Internal, + Sdcard, +} + +impl FromStr for AndroidStorageInput { + type Err = DeviceError; + + fn from_str(s: &str) -> Result<Self> { + match s { + "auto" => Ok(AndroidStorageInput::Auto), + "app" => Ok(AndroidStorageInput::App), + "internal" => Ok(AndroidStorageInput::Internal), + "sdcard" => Ok(AndroidStorageInput::Sdcard), + _ => Err(DeviceError::InvalidStorage), + } + } +} + +impl Default for AndroidStorageInput { + fn default() -> Self { + AndroidStorageInput::Auto + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AndroidStorage { + App, + Internal, + Sdcard, +} + +#[derive(Debug)] +pub enum DeviceError { + Adb(String), + FromInt(TryFromIntError), + InvalidStorage, + Io(io::Error), + MissingPackage, + MultipleDevices, + ParseInt(ParseIntError), + UnknownDevice(String), + Utf8(Utf8Error), + WalkDir(walkdir::Error), +} + +impl fmt::Display for DeviceError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + DeviceError::Adb(ref message) => message.fmt(f), + DeviceError::MultipleDevices => write!(f, "Multiple Android devices online"), + DeviceError::UnknownDevice(ref serial) => { + write!(f, "Unknown Android device with serial '{}'", serial) + } + _ => self.to_string().fmt(f), + } + } +} + +impl From<io::Error> for DeviceError { + fn from(value: io::Error) -> DeviceError { + DeviceError::Io(value) + } +} + +impl From<ParseIntError> for DeviceError { + fn from(value: ParseIntError) -> DeviceError { + DeviceError::ParseInt(value) + } +} + +impl From<TryFromIntError> for DeviceError { + fn from(value: TryFromIntError) -> DeviceError { + DeviceError::FromInt(value) + } +} + +impl From<Utf8Error> for DeviceError { + fn from(value: Utf8Error) -> DeviceError { + DeviceError::Utf8(value) + } +} + +impl From<walkdir::Error> for DeviceError { + fn from(value: walkdir::Error) -> DeviceError { + DeviceError::WalkDir(value) + } +} + +fn encode_message(payload: &str) -> Result<String> { + let hex_length = u16::try_from(payload.len()).map(|len| format!("{:0>4X}", len))?; + + Ok(format!("{}{}", hex_length, payload)) +} + +fn parse_device_info(line: &str) -> Option<DeviceInfo> { + // Turn "serial\tdevice key1:value1 key2:value2 ..." into a `DeviceInfo`. + let mut pairs = line.split_whitespace(); + let serial = pairs.next(); + let state = pairs.next(); + if let (Some(serial), Some("device")) = (serial, state) { + let info: BTreeMap<String, String> = pairs + .filter_map(|pair| { + let mut kv = pair.split(':'); + if let (Some(k), Some(v), None) = (kv.next(), kv.next(), kv.next()) { + Some((k.to_owned(), v.to_owned())) + } else { + None + } + }) + .collect(); + + Some(DeviceInfo { + serial: serial.to_owned(), + info, + }) + } else { + None + } +} + +/// Reads the payload length of a host message from the stream. +fn read_length<R: Read>(stream: &mut R) -> Result<usize> { + let mut bytes: [u8; 4] = [0; 4]; + stream.read_exact(&mut bytes)?; + + let response = std::str::from_utf8(&bytes)?; + + Ok(usize::from_str_radix(&response, 16)?) +} + +/// Reads the payload length of a device message from the stream. +fn read_length_little_endian(reader: &mut dyn Read) -> Result<usize> { + let mut bytes: [u8; 4] = [0; 4]; + reader.read_exact(&mut bytes)?; + + let n: usize = (bytes[0] as usize) + + ((bytes[1] as usize) << 8) + + ((bytes[2] as usize) << 16) + + ((bytes[3] as usize) << 24); + + Ok(n) +} + +/// Writes the payload length of a device message to the stream. +fn write_length_little_endian(writer: &mut dyn Write, n: usize) -> Result<usize> { + let mut bytes = [0; 4]; + bytes[0] = (n & 0xFF) as u8; + bytes[1] = ((n >> 8) & 0xFF) as u8; + bytes[2] = ((n >> 16) & 0xFF) as u8; + bytes[3] = ((n >> 24) & 0xFF) as u8; + + writer.write(&bytes[..]).map_err(DeviceError::Io) +} + +fn read_response(stream: &mut TcpStream, has_output: bool, has_length: bool) -> Result<Vec<u8>> { + let mut bytes: [u8; 1024] = [0; 1024]; + + stream.read_exact(&mut bytes[0..4])?; + + if &bytes[0..4] != SyncCommand::Okay.code() { + let n = bytes.len().min(read_length(stream)?); + stream.read_exact(&mut bytes[0..n])?; + + let message = std::str::from_utf8(&bytes[0..n]).map(|s| format!("adb error: {}", s))?; + + return Err(DeviceError::Adb(message)); + } + + let mut response = Vec::new(); + + if has_output { + stream.read_to_end(&mut response)?; + + if response.len() >= 4 && &response[0..4] == SyncCommand::Okay.code() { + // Sometimes the server produces OKAYOKAY. Sometimes there is a transport OKAY and + // then the underlying command OKAY. This is straight from `chromedriver`. + response = response.split_off(4); + } + + if response.len() >= 4 && &response[0..4] == SyncCommand::Fail.code() { + // The server may even produce OKAYFAIL, which means the underlying + // command failed. First split-off the `FAIL` and length of the message. + response = response.split_off(8); + + let message = std::str::from_utf8(&*response).map(|s| format!("adb error: {}", s))?; + + return Err(DeviceError::Adb(message)); + } + + if has_length { + if response.len() >= 4 { + let message = response.split_off(4); + let slice: &mut &[u8] = &mut &*response; + + let n = read_length(slice)?; + warn!( + "adb server response contained hexstring length {} and message length was {} \ + and message was {:?}", + n, + message.len(), + std::str::from_utf8(&message)? + ); + + return Ok(message); + } else { + return Err(DeviceError::Adb(format!( + "adb server response did not contain expected hexstring length: {:?}", + std::str::from_utf8(&response)? + ))); + } + } + } + + Ok(response) +} + +/// Detailed information about an ADB device. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct DeviceInfo { + pub serial: DeviceSerial, + pub info: BTreeMap<String, String>, +} + +/// Represents a connection to an ADB host, which multiplexes the connections to +/// individual devices. +#[derive(Debug)] +pub struct Host { + /// The TCP host to connect to. Defaults to `"localhost"`. + pub host: Option<String>, + /// The TCP port to connect to. Defaults to `5037`. + pub port: Option<u16>, + /// Optional TCP read timeout duration. Defaults to 2s. + pub read_timeout: Option<Duration>, + /// Optional TCP write timeout duration. Defaults to 2s. + pub write_timeout: Option<Duration>, +} + +impl Default for Host { + fn default() -> Host { + Host { + host: Some("localhost".to_string()), + port: Some(5037), + read_timeout: Some(Duration::from_secs(2)), + write_timeout: Some(Duration::from_secs(2)), + } + } +} + +impl Host { + /// Searches for available devices, and selects the one as specified by `device_serial`. + /// + /// If multiple devices are online, and no device has been specified, + /// the `ANDROID_SERIAL` environment variable can be used to select one. + pub fn device_or_default<T: AsRef<str>>( + self, + device_serial: Option<&T>, + storage: AndroidStorageInput, + ) -> Result<Device> { + let serials: Vec<String> = self + .devices::<Vec<_>>()? + .into_iter() + .map(|d| d.serial) + .collect(); + + if let Some(ref serial) = device_serial + .map(|v| v.as_ref().to_owned()) + .or_else(|| std::env::var("ANDROID_SERIAL").ok()) + { + if !serials.contains(serial) { + return Err(DeviceError::UnknownDevice(serial.clone())); + } + + return Device::new(self, serial.to_owned(), storage); + } + + if serials.len() > 1 { + return Err(DeviceError::MultipleDevices); + } + + if let Some(ref serial) = serials.first() { + return Device::new(self, serial.to_owned().to_string(), storage); + } + + Err(DeviceError::Adb("No Android devices are online".to_owned())) + } + + pub fn connect(&self) -> Result<TcpStream> { + let stream = TcpStream::connect(format!( + "{}:{}", + self.host.clone().unwrap_or_else(|| "localhost".to_owned()), + self.port.unwrap_or(5037) + ))?; + stream.set_read_timeout(self.read_timeout)?; + stream.set_write_timeout(self.write_timeout)?; + Ok(stream) + } + + pub fn execute_command( + &self, + command: &str, + has_output: bool, + has_length: bool, + ) -> Result<String> { + let mut stream = self.connect()?; + + stream.write_all(encode_message(command)?.as_bytes())?; + let bytes = read_response(&mut stream, has_output, has_length)?; + // TODO: should we assert no bytes were read? + + let response = std::str::from_utf8(&bytes)?; + + Ok(response.to_owned()) + } + + pub fn execute_host_command( + &self, + host_command: &str, + has_length: bool, + has_output: bool, + ) -> Result<String> { + self.execute_command(&format!("host:{}", host_command), has_output, has_length) + } + + pub fn features<B: FromIterator<String>>(&self) -> Result<B> { + let features = self.execute_host_command("features", true, true)?; + Ok(features.split(',').map(|x| x.to_owned()).collect()) + } + + pub fn devices<B: FromIterator<DeviceInfo>>(&self) -> Result<B> { + let response = self.execute_host_command("devices-l", true, true)?; + + let infos: B = response.lines().filter_map(parse_device_info).collect(); + + Ok(infos) + } +} + +/// Represents an ADB device. +#[derive(Debug)] +pub struct Device { + /// ADB host that controls this device. + pub host: Host, + + /// Serial number uniquely identifying this ADB device. + pub serial: DeviceSerial, + + /// adb running as root + pub adbd_root: bool, + + /// Flag for rooted device + pub is_rooted: bool, + + /// "su 0" command available + pub su_0_root: bool, + + /// "su -c" command available + pub su_c_root: bool, + + pub run_as_package: Option<String>, + + pub storage: AndroidStorage, + + /// Cache intermediate tempfile name used in pushing via run_as. + pub tempfile: PathBuf, +} + +impl Device { + pub fn new(host: Host, serial: DeviceSerial, storage: AndroidStorageInput) -> Result<Device> { + let mut device = Device { + host, + serial, + adbd_root: false, + is_rooted: false, + run_as_package: None, + storage: AndroidStorage::App, + su_c_root: false, + su_0_root: false, + tempfile: PathBuf::from("/data/local/tmp"), + }; + device + .tempfile + .push(Uuid::new_v4().to_hyphenated().to_string()); + + // check for rooted devices + let uid_check = |id: String| id.contains("uid=0"); + device.adbd_root = device + .execute_host_shell_command("id") + .map_or(false, uid_check); + device.su_0_root = device + .execute_host_shell_command("su 0 id") + .map_or(false, uid_check); + device.su_c_root = device + .execute_host_shell_command("su -c id") + .map_or(false, uid_check); + device.is_rooted = device.adbd_root || device.su_0_root || device.su_c_root; + + device.storage = match storage { + AndroidStorageInput::App => AndroidStorage::App, + AndroidStorageInput::Internal => AndroidStorage::Internal, + AndroidStorageInput::Sdcard => AndroidStorage::Sdcard, + AndroidStorageInput::Auto => match device.is_rooted { + true => AndroidStorage::Internal, + false => AndroidStorage::App, + }, + }; + + // Set Permissive=1 if we have root. + if device.is_rooted { + device.execute_host_shell_command("setenforce permissive")?; + } + + Ok(device) + } + + pub fn clear_app_data(&self, package: &str) -> Result<bool> { + self.execute_host_shell_command(&format!("pm clear {}", package)) + .map(|v| v.contains("Success")) + } + + pub fn create_dir(&self, path: &Path) -> Result<()> { + debug!("Creating {}", path.display()); + + let enable_run_as = self.enable_run_as_for_path(&path); + self.execute_host_shell_command_as(&format!("mkdir -p {}", path.display()), enable_run_as)?; + + Ok(()) + } + + pub fn chmod(&self, path: &Path, mask: &str, recursive: bool) -> Result<()> { + let enable_run_as = self.enable_run_as_for_path(&path); + + let recursive = match recursive { + true => " -R", + false => "", + }; + + self.execute_host_shell_command_as( + &format!("chmod {} {} {}", recursive, mask, path.display()), + enable_run_as, + )?; + + Ok(()) + } + + pub fn execute_host_command( + &self, + command: &str, + has_output: bool, + has_length: bool, + ) -> Result<String> { + let mut stream = self.host.connect()?; + + let switch_command = format!("host:transport:{}", self.serial); + debug!("execute_host_command: >> {:?}", &switch_command); + stream.write_all(encode_message(&switch_command)?.as_bytes())?; + let _bytes = read_response(&mut stream, false, false)?; + debug!("execute_host_command: << {:?}", _bytes); + // TODO: should we assert no bytes were read? + + debug!("execute_host_command: >> {:?}", &command); + stream.write_all(encode_message(&command)?.as_bytes())?; + let bytes = read_response(&mut stream, has_output, has_length)?; + + let response = std::str::from_utf8(&bytes)?; + debug!("execute_host_command: << {:?}", response); + + Ok(response.to_owned()) + } + + pub fn enable_run_as_for_path(&self, path: &Path) -> bool { + match &self.run_as_package { + Some(package) => { + let mut p = PathBuf::from("/data/data/"); + p.push(package); + path.starts_with(p) + } + None => false, + } + } + + pub fn execute_host_shell_command(&self, shell_command: &str) -> Result<String> { + self.execute_host_shell_command_as(shell_command, false) + } + + pub fn execute_host_shell_command_as( + &self, + shell_command: &str, + enable_run_as: bool, + ) -> Result<String> { + // We don't want to duplicate su invocations. + if shell_command.starts_with("su") { + return self.execute_host_command(&format!("shell:{}", shell_command), true, false); + } + + let has_outer_quotes = shell_command.starts_with('"') && shell_command.ends_with('"') + || shell_command.starts_with('\'') && shell_command.ends_with('\''); + + if self.adbd_root { + return self.execute_host_command(&format!("shell:{}", shell_command), true, false); + } + + if self.su_0_root { + return self.execute_host_command( + &format!("shell:su 0 {}", shell_command), + true, + false, + ); + } + + if self.su_c_root { + if has_outer_quotes { + return self.execute_host_command( + &format!("shell:su -c {}", shell_command), + true, + false, + ); + } + + if SYNC_REGEX.is_match(shell_command) { + let arg: &str = &shell_command.replace("'", "'\"'\"'")[..]; + return self.execute_host_command(&format!("shell:su -c '{}'", arg), true, false); + } + + return self.execute_host_command( + &format!("shell:su -c \"{}\"", shell_command), + true, + false, + ); + } + + // Execute command as package + if enable_run_as { + let run_as_package = self + .run_as_package + .as_ref() + .ok_or(DeviceError::MissingPackage)?; + + if has_outer_quotes { + return self.execute_host_command( + &format!("shell:run-as {} {}", run_as_package, shell_command), + true, + false, + ); + } + + if SYNC_REGEX.is_match(shell_command) { + let arg: &str = &shell_command.replace("'", "'\"'\"'")[..]; + return self.execute_host_command( + &format!("shell:run-as {} {}", run_as_package, arg), + true, + false, + ); + } + + return self.execute_host_command( + &format!("shell:run-as {} \"{}\"", run_as_package, shell_command), + true, + false, + ); + } + + self.execute_host_command(&format!("shell:{}", shell_command), true, false) + } + + pub fn is_app_installed(&self, package: &str) -> Result<bool> { + self.execute_host_shell_command(&format!("pm path {}", package)) + .map(|v| v.contains("package:")) + } + + pub fn launch<T: AsRef<str>>( + &self, + package: &str, + activity: &str, + am_start_args: &[T], + ) -> Result<bool> { + let mut am_start = format!("am start -W -n {}/{}", package, activity); + + for arg in am_start_args { + am_start.push(' '); + if SYNC_REGEX.is_match(arg.as_ref()) { + am_start.push_str(&format!("\"{}\"", &shell::escape(arg.as_ref()))); + } else { + am_start.push_str(&shell::escape(arg.as_ref())); + }; + } + + self.execute_host_shell_command(&am_start) + .map(|v| v.contains("Complete")) + } + + pub fn force_stop(&self, package: &str) -> Result<()> { + debug!("Force stopping Android package: {}", package); + self.execute_host_shell_command(&format!("am force-stop {}", package)) + .and(Ok(())) + } + + pub fn forward_port(&self, local: u16, remote: u16) -> Result<u16> { + let command = format!( + "host-serial:{}:forward:tcp:{};tcp:{}", + self.serial, local, remote + ); + let response = self.host.execute_command(&command, true, false)?; + + if local == 0 { + Ok(response.parse::<u16>()?) + } else { + Ok(local) + } + } + + pub fn kill_forward_port(&self, local: u16) -> Result<()> { + let command = format!("killforward:tcp:{}", local); + self.execute_host_command(&command, true, false).and(Ok(())) + } + + pub fn kill_forward_all_ports(&self) -> Result<()> { + self.execute_host_command(&"killforward-all".to_owned(), false, false) + .and(Ok(())) + } + + pub fn reverse_port(&self, remote: u16, local: u16) -> Result<u16> { + let command = format!("reverse:forward:tcp:{};tcp:{}", remote, local); + let response = self.execute_host_command(&command, true, false)?; + + if remote == 0 { + Ok(response.parse::<u16>()?) + } else { + Ok(remote) + } + } + + pub fn kill_reverse_port(&self, remote: u16) -> Result<()> { + let command = format!("reverse:killforward:tcp:{}", remote); + self.execute_host_command(&command, true, true).and(Ok(())) + } + + pub fn kill_reverse_all_ports(&self) -> Result<()> { + let command = "reverse:killforward-all".to_owned(); + self.execute_host_command(&command, false, false) + .and(Ok(())) + } + + pub fn path_exists(&self, path: &Path, enable_run_as: bool) -> Result<bool> { + self.execute_host_shell_command_as(format!("ls {}", path.display()).as_str(), enable_run_as) + .map(|path| !path.contains("No such file or directory")) + } + + pub fn push(&self, buffer: &mut dyn Read, dest: &Path, mode: u32) -> Result<()> { + // Implement the ADB protocol to send a file to the device. + // The protocol consists of the following steps: + // * Send "host:transport" command with device serial + // * Send "sync:" command to initialize file transfer + // * Send "SEND" command with name and mode of the file + // * Send "DATA" command one or more times for the file content + // * Send "DONE" command to indicate end of file transfer + + let enable_run_as = self.enable_run_as_for_path(&dest.to_path_buf()); + let dest1 = match enable_run_as { + true => self.tempfile.as_path(), + false => Path::new(dest), + }; + + // If the destination directory does not exist, adb will + // create it and any necessary ancestors however it will not + // set the directory permissions to 0o777. In addition, + // Android 9 (P) has a bug in its push implementation which + // will cause a push which creates directories to fail with + // the error `secure_mkdirs failed: Operation not + // permitted`. We can work around this by creating the + // destination directories prior to the push. Collect the + // ancestors of the destination directory which do not yet + // exist so we can create them and adjust their permissions + // prior to performing the push. + let mut current = dest.parent(); + let mut leaf: Option<&Path> = None; + let mut root: Option<&Path> = None; + + while let Some(path) = current { + if self.path_exists(path, enable_run_as)? { + break; + } + if leaf.is_none() { + leaf = Some(path); + } + root = Some(path); + current = path.parent(); + } + + if let Some(path) = leaf { + self.create_dir(&path)?; + } + + if let Some(path) = root { + self.chmod(&path, "777", true)?; + } + + let mut stream = self.host.connect()?; + + let message = encode_message(&format!("host:transport:{}", self.serial))?; + stream.write_all(message.as_bytes())?; + let _bytes = read_response(&mut stream, false, true)?; + + let message = encode_message("sync:")?; + stream.write_all(message.as_bytes())?; + let _bytes = read_response(&mut stream, false, true)?; + + stream.write_all(SyncCommand::Send.code())?; + let args_ = format!("{},{}", dest1.display(), mode); + let args = args_.as_bytes(); + write_length_little_endian(&mut stream, args.len())?; + stream.write_all(args)?; + + // Use a 32KB buffer to transfer the file contents + // TODO: Maybe adjust to maxdata (256KB) + let mut buf = [0; 32 * 1024]; + + loop { + let len = buffer.read(&mut buf)?; + + if len == 0 { + break; + } + + stream.write_all(SyncCommand::Data.code())?; + write_length_little_endian(&mut stream, len)?; + stream.write_all(&buf[0..len])?; + } + + // https://android.googlesource.com/platform/system/core/+/master/adb/SYNC.TXT#66 + // + // When the file is transferred a sync request "DONE" is sent, where length is set + // to the last modified time for the file. The server responds to this last + // request (but not to chunk requests) with an "OKAY" sync response (length can + // be ignored). + let time: u32 = ((SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)) + .unwrap() + .as_secs() + & 0xFFFF_FFFF) as u32; + + stream.write_all(SyncCommand::Done.code())?; + write_length_little_endian(&mut stream, time as usize)?; + + // Status. + stream.read_exact(&mut buf[0..4])?; + + if &buf[0..4] == SyncCommand::Okay.code() { + if enable_run_as { + // Use cp -a to preserve the permissions set by push. + let result = self.execute_host_shell_command_as( + format!("cp -aR {} {}", dest1.display(), dest.display()).as_str(), + enable_run_as, + ); + if self.remove(dest1).is_err() { + debug!("Failed to remove {}", dest1.display()); + } + result?; + } + Ok(()) + } else if &buf[0..4] == SyncCommand::Fail.code() { + if enable_run_as && self.remove(dest1).is_err() { + debug!("Failed to remove {}", dest1.display()); + } + let n = buf.len().min(read_length_little_endian(&mut stream)?); + + stream.read_exact(&mut buf[0..n])?; + + let message = std::str::from_utf8(&buf[0..n]) + .map(|s| format!("adb error: {}", s)) + .unwrap_or_else(|_| "adb error was not utf-8".into()); + + Err(DeviceError::Adb(message)) + } else { + if self.remove(dest1).is_err() { + debug!("Failed to remove {}", dest1.display()); + } + Err(DeviceError::Adb("FAIL (unknown)".to_owned())) + } + } + + pub fn push_dir(&self, source: &Path, dest_dir: &Path, mode: u32) -> Result<()> { + debug!("Pushing {} to {}", source.display(), dest_dir.display()); + + let walker = WalkDir::new(source).follow_links(false).into_iter(); + + for entry in walker { + let entry = entry?; + let path = entry.path(); + + if !entry.metadata()?.is_file() { + continue; + } + + let mut file = File::open(path)?; + + let mut dest = dest_dir.to_path_buf(); + dest.push( + path.strip_prefix(source) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?, + ); + + self.push(&mut file, &dest, mode)?; + } + + Ok(()) + } + + pub fn remove(&self, path: &Path) -> Result<()> { + debug!("Deleting {}", path.display()); + + self.execute_host_shell_command_as( + &format!("rm -rf {}", path.display()), + self.enable_run_as_for_path(&path), + )?; + + Ok(()) + } +} diff --git a/testing/mozbase/rust/mozdevice/src/shell.rs b/testing/mozbase/rust/mozdevice/src/shell.rs new file mode 100644 index 0000000000..593f790118 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/shell.rs @@ -0,0 +1,62 @@ +// Copyright (c) 2017 Jimmy Cuadra +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +use regex::Regex; + +/// Escapes a string so it will be interpreted as a single word by the UNIX Bourne shell. +/// +/// If the input string is empty, this function returns an empty quoted string. +pub fn escape(input: &str) -> String { + // Stolen from + // https://docs.rs/shellwords/1.0.0/src/shellwords/lib.rs.html#24-37. + // Added space to the pattern to exclude spaces from being escaped + // which can cause problems when combining strings to form a full + // command. + let escape_pattern: Regex = Regex::new(r"([^A-Za-z0-9_\-.,:/@ \n])").unwrap(); + let line_feed: Regex = Regex::new(r"\n").unwrap(); + + if input.is_empty() { + return "''".to_owned(); + } + + let output = &escape_pattern.replace_all(input, "\\$1"); + + line_feed.replace_all(output, "'\n'").to_string() +} + +#[cfg(test)] +mod tests { + use super::escape; + + #[test] + fn empty_escape() { + assert_eq!(escape(""), "''"); + } + + #[test] + fn full_escape() { + assert_eq!(escape("foo '\"' bar"), "foo \\'\\\"\\' bar"); + } + + #[test] + fn escape_multibyte() { + assert_eq!(escape("あい"), "\\あ\\い"); + } +} diff --git a/testing/mozbase/rust/mozdevice/src/test.rs b/testing/mozbase/rust/mozdevice/src/test.rs new file mode 100644 index 0000000000..689b552170 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/test.rs @@ -0,0 +1,508 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::*; + +// Currently the mozdevice API is not safe for multiple requests at the same +// time. It is recommended to run each of the unit tests on its own. Also adb +// specific tests cannot be run in CI yet. To check those locally, also run +// the ignored tests. +// +// Use the following command to accomplish that: +// +// $ cargo test -- --ignored --test-threads=1 + +use crate::{AndroidStorage, AndroidStorageInput}; +use std::collections::BTreeSet; +use std::panic; +use tempfile::{tempdir, TempDir}; + +#[test] +fn read_length_from_valid_string() { + fn test(message: &str) -> Result<usize> { + read_length(&mut io::BufReader::new(message.as_bytes())) + } + + assert_eq!(test("0000").unwrap(), 0); + assert_eq!(test("0001").unwrap(), 1); + assert_eq!(test("000F").unwrap(), 15); + assert_eq!(test("00FF").unwrap(), 255); + assert_eq!(test("0FFF").unwrap(), 4095); + assert_eq!(test("FFFF").unwrap(), 65535); + + assert_eq!(test("FFFF0").unwrap(), 65535); +} + +#[test] +fn read_length_from_invalid_string() { + fn test(message: &str) -> Result<usize> { + read_length(&mut io::BufReader::new(message.as_bytes())) + } + + test("").expect_err("empty string"); + test("G").expect_err("invalid hex character"); + test("-1").expect_err("negative number"); + test("000").expect_err("shorter than 4 bytes"); +} + +#[test] +fn encode_message_with_valid_string() { + assert_eq!(encode_message("").unwrap(), "0000".to_string()); + assert_eq!(encode_message("a").unwrap(), "0001a".to_string()); + assert_eq!( + encode_message(&"a".repeat(15)).unwrap(), + format!("000F{}", "a".repeat(15)) + ); + assert_eq!( + encode_message(&"a".repeat(255)).unwrap(), + format!("00FF{}", "a".repeat(255)) + ); + assert_eq!( + encode_message(&"a".repeat(4095)).unwrap(), + format!("0FFF{}", "a".repeat(4095)) + ); + assert_eq!( + encode_message(&"a".repeat(65535)).unwrap(), + format!("FFFF{}", "a".repeat(65535)) + ); +} + +#[test] +fn encode_message_with_invalid_string() { + encode_message(&"a".repeat(65536)).expect_err("string lengths exceeds 4 bytes"); +} + +fn run_device_test<F>(test: F) +where + F: FnOnce(&Device, &TempDir, &Path) + panic::UnwindSafe, +{ + let host = Host { + ..Default::default() + }; + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Auto) + .expect("device_or_default"); + + let tmp_dir = tempdir().expect("create temp dir"); + let remote_path = Path::new("/data/local/tmp/mozdevice/"); + + let _ = device.remove(remote_path); + + let result = panic::catch_unwind(|| test(&device, &tmp_dir, &remote_path)); + + let _ = device.kill_forward_all_ports(); + // let _ = device.kill_reverse_all_ports(); + + assert!(result.is_ok()) +} + +#[test] +#[ignore] +fn host_features() { + let host = Host { + ..Default::default() + }; + + let set = host.features::<BTreeSet<_>>().expect("to query features"); + assert!(set.contains("cmd")); + assert!(set.contains("shell_v2")); +} + +#[test] +#[ignore] +fn host_devices() { + let host = Host { + ..Default::default() + }; + + let set: BTreeSet<_> = host.devices().expect("to query devices"); + assert_eq!(1, set.len()); +} + +#[test] +#[ignore] +fn host_device_or_default() { + let host = Host { + ..Default::default() + }; + + let devices: Vec<_> = host.devices().expect("to query devices"); + let expected_device = devices.first().expect("found a device"); + + let device = host + .device_or_default::<String>(Some(&expected_device.serial), AndroidStorageInput::App) + .expect("connected device with serial"); + assert_eq!(device.run_as_package, None); + assert_eq!(device.serial, expected_device.serial); + assert!(device.tempfile.starts_with("/data/local/tmp")); +} + +#[test] +#[ignore] +fn host_device_or_default_invalid_serial() { + let host = Host { + ..Default::default() + }; + + host.device_or_default::<String>(Some(&"foobar".to_owned()), AndroidStorageInput::Auto) + .expect_err("invalid serial"); +} + +#[test] +#[ignore] +fn host_device_or_default_no_serial() { + let host = Host { + ..Default::default() + }; + + let devices: Vec<_> = host.devices().expect("to query devices"); + let expected_device = devices.first().expect("found a device"); + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Auto) + .expect("connected device with serial"); + assert_eq!(device.serial, expected_device.serial); +} + +#[test] +#[ignore] +fn host_device_or_default_storage_as_app() { + let host = Host { + ..Default::default() + }; + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::App) + .expect("connected device"); + assert_eq!(device.storage, AndroidStorage::App); +} + +#[test] +#[ignore] +fn host_device_or_default_storage_as_auto() { + let host = Host { + ..Default::default() + }; + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Auto) + .expect("connected device"); + if device.is_rooted { + assert_eq!(device.storage, AndroidStorage::Internal); + } else { + assert_eq!(device.storage, AndroidStorage::App); + } +} + +#[test] +#[ignore] +fn host_device_or_default_storage_as_internal() { + let host = Host { + ..Default::default() + }; + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Internal) + .expect("connected device"); + assert_eq!(device.storage, AndroidStorage::Internal); +} + +#[test] +#[ignore] +fn host_device_or_default_storage_as_sdcard() { + let host = Host { + ..Default::default() + }; + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Sdcard) + .expect("connected device"); + assert_eq!(device.storage, AndroidStorage::Sdcard); +} + +#[test] +#[ignore] +fn device_shell_command() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + assert_eq!( + "Linux\n", + device + .execute_host_shell_command("uname") + .expect("to have shell output") + ); + }); +} + +#[test] +#[ignore] +fn device_forward_port_hardcoded() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + assert_eq!( + 3035, + device + .forward_port(3035, 3036) + .expect("forwarded local port") + ); + // TODO: check with forward --list + }); +} + +// #[test] +// #[ignore] +// TODO: "adb server response to `forward tcp:0 ...` was not a u16: \"000559464\"") +// fn device_forward_port_system_allocated() { +// run_device_test(|device: &Device, _: &TempDir, _: &Path| { +// let local_port = device.forward_port(0, 3037).expect("local_port"); +// assert_ne!(local_port, 0); +// // TODO: check with forward --list +// }); +// } + +#[test] +#[ignore] +fn device_kill_forward_port_no_forwarded_port() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + device + .kill_forward_port(3038) + .expect_err("adb error: listener 'tcp:3038' "); + }); +} + +#[test] +#[ignore] +fn device_kill_forward_port_twice() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + let local_port = device + .forward_port(3039, 3040) + .expect("forwarded local port"); + assert_eq!(local_port, 3039); + // TODO: check with forward --list + device + .kill_forward_port(local_port) + .expect("to remove forwarded port"); + device + .kill_forward_port(local_port) + .expect_err("adb error: listener 'tcp:3039' "); + }); +} + +#[test] +#[ignore] +fn device_kill_forward_all_ports_no_forwarded_port() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + device + .kill_forward_all_ports() + .expect("to not fail for no forwarded ports"); + }); +} + +#[test] +#[ignore] +fn device_kill_forward_all_ports_twice() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + let local_port1 = device + .forward_port(3039, 3040) + .expect("forwarded local port"); + assert_eq!(local_port1, 3039); + let local_port2 = device + .forward_port(3041, 3042) + .expect("forwarded local port"); + assert_eq!(local_port2, 3041); + // TODO: check with forward --list + device + .kill_forward_all_ports() + .expect("to remove all forwarded ports"); + device + .kill_forward_all_ports() + .expect("to not fail for no forwarded ports"); + }); +} + +#[test] +#[ignore] +fn device_reverse_port_hardcoded() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + assert_eq!(4035, device.reverse_port(4035, 4036).expect("remote_port")); + // TODO: check with reverse --list + }); +} + +// #[test] +// #[ignore] +// TODO: No adb response: ParseInt(ParseIntError { kind: Empty }) +// fn device_reverse_port_system_allocated() { +// run_device_test(|device: &Device, _: &TempDir, _: &Path| { +// let reverse_port = device.reverse_port(0, 4037).expect("remote port"); +// assert_ne!(reverse_port, 0); +// // TODO: check with reverse --list +// }); +// } + +#[test] +#[ignore] +fn device_kill_reverse_port_no_reverse_port() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + device + .kill_reverse_port(4038) + .expect_err("listener 'tcp:4038' not found"); + }); +} + +// #[test] +// #[ignore] +// TODO: "adb error: adb server response did not contain expected hexstring length: \"\"" +// fn device_kill_reverse_port_twice() { +// run_device_test(|device: &Device, _: &TempDir, _: &Path| { +// let remote_port = device +// .reverse_port(4039, 4040) +// .expect("reversed local port"); +// assert_eq!(remote_port, 4039); +// // TODO: check with reverse --list +// device +// .kill_reverse_port(remote_port) +// .expect("to remove reverse port"); +// device +// .kill_reverse_port(remote_port) +// .expect_err("listener 'tcp:4039' not found"); +// }); +// } + +#[test] +#[ignore] +fn device_kill_reverse_all_ports_no_reversed_port() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + device + .kill_reverse_all_ports() + .expect("to not fail for no reversed ports"); + }); +} + +#[test] +#[ignore] +fn device_kill_reverse_all_ports_twice() { + run_device_test(|device: &Device, _: &TempDir, _: &Path| { + let local_port1 = device + .forward_port(4039, 4040) + .expect("forwarded local port"); + assert_eq!(local_port1, 4039); + let local_port2 = device + .forward_port(4041, 4042) + .expect("forwarded local port"); + assert_eq!(local_port2, 4041); + // TODO: check with reverse --list + device + .kill_reverse_all_ports() + .expect("to remove all reversed ports"); + device + .kill_reverse_all_ports() + .expect("to not fail for no reversed ports"); + }); +} + +#[test] +#[ignore] +fn device_push() { + run_device_test(|device: &Device, _: &TempDir, remote_root_path: &Path| { + fn adjust_mode(mode: u32) -> u32 { + // Adjust the mode by copying the user permissions to + // group and other as indicated in + // [send_impl](https://android.googlesource.com/platform/system/core/+/master/adb/daemon/file_sync_service.cpp#516). + // This ensures that group and other can both access a + // file if the user can access it. + let mut m = mode & 0o777; + m |= (m >> 3) & 0o070; + m |= (m >> 3) & 0o007; + m + } + + fn get_permissions(mode: u32) -> String { + // Convert the mode integer into the string representation + // of the mode returned by `ls`. This assumes the object is + // a file and not a directory. + let mut perms = vec!["-", "r", "w", "x", "r", "w", "x", "r", "w", "x"]; + let mut bit_pos = 0; + while bit_pos < 9 { + if (1 << bit_pos) & mode == 0 { + perms[9 - bit_pos] = "-" + } + bit_pos += 1; + } + perms.concat() + } + let content = "test"; + let remote_path = remote_root_path.join("foo.bar"); + + let modes = vec![0o421, 0o644, 0o666, 0o777]; + for mode in modes { + let adjusted_mode = adjust_mode(mode); + let adjusted_perms = get_permissions(adjusted_mode); + device + .push( + &mut io::BufReader::new(content.as_bytes()), + &remote_path, + mode, + ) + .expect("file has been pushed"); + + let output = device + .execute_host_shell_command(&format!("ls -l {}", remote_path.display())) + .expect("host shell command for 'ls' to succeed"); + + assert!(output.contains(remote_path.to_str().unwrap())); + assert!(output.starts_with(&adjusted_perms)); + } + + let output = device + .execute_host_shell_command(&format!("ls -ld {}", remote_root_path.display())) + .expect("host shell command for 'ls parent' to succeed"); + + assert!(output.contains(remote_root_path.to_str().unwrap())); + assert!(output.starts_with("drwxrwxrwx")); + + let file_content = device + .execute_host_shell_command(&format!("cat {}", remote_path.display())) + .expect("host shell command for 'cat' to succeed"); + + assert_eq!(file_content, content); + }); +} + +#[test] +#[ignore] +fn device_push_dir() { + run_device_test( + |device: &Device, tmp_dir: &TempDir, remote_root_path: &Path| { + let content = "test"; + + let files = [ + Path::new("foo1.bar"), + Path::new("foo2.bar"), + Path::new("bar/foo3.bar"), + Path::new("bar/more/baz/moar/foo3.bar"), + ]; + + for file in files.iter() { + let path = tmp_dir.path().join(file); + let _ = std::fs::create_dir_all(path.parent().unwrap()); + + let f = File::create(path).expect("to create file"); + let mut f = io::BufWriter::new(f); + f.write_all(content.as_bytes()).expect("to write data"); + } + + device + .push_dir(tmp_dir.path(), &remote_root_path, 0o777) + .expect("to push_dir"); + + for file in files.iter() { + let path = remote_root_path.join(file); + let output = device + .execute_host_shell_command(&format!("ls {}", path.display())) + .expect("host shell command for 'ls' to succeed"); + + assert!(output.contains(path.to_str().unwrap())); + } + }, + ); +} |