summaryrefslogtreecommitdiffstats
path: root/testing/mozbase/rust/mozdevice/src
diff options
context:
space:
mode:
Diffstat (limited to 'testing/mozbase/rust/mozdevice/src')
-rw-r--r--testing/mozbase/rust/mozdevice/src/adb.rs38
-rw-r--r--testing/mozbase/rust/mozdevice/src/lib.rs860
-rw-r--r--testing/mozbase/rust/mozdevice/src/shell.rs62
-rw-r--r--testing/mozbase/rust/mozdevice/src/test.rs508
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()));
+ }
+ },
+ );
+}