blob: 1cbc27316a0d353fead26f7ea89352b11bcd4f76 [file] [edit]
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use crate::config::OS;
use crate::config::OS::WINDOWS;
use crate::{Command, Logger, MACOS, format_one_arg, run_shell_command};
use anyhow::Error;
use anyhow::anyhow;
use apple_flat_package::{ComponentPackageReader, PkgReader};
use bzip2::read::BzDecoder;
use directories::BaseDirs;
use flate2::read::GzDecoder;
use fs_extra::dir::{CopyOptions, move_dir};
use regex::Regex;
#[cfg(windows)]
use std::ffi::OsStr;
use std::fs;
use std::fs::File;
use std::io;
use std::io::{BufReader, Cursor, Read};
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
use std::path::{Path, PathBuf};
#[cfg(windows)]
use std::ptr;
use tar::Archive;
use walkdir::{DirEntry, WalkDir};
#[cfg(windows)]
use winapi::shared::minwindef::LPVOID;
#[cfg(windows)]
use winapi::um::winver::{GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW};
use xz2::read::XzDecoder;
use zip::ZipArchive;
pub const PARSE_ERROR: &str = "Wrong browser/driver version";
const CACHE_FOLDER: &str = ".cache/selenium";
const ZIP: &str = "zip";
const GZ: &str = "gz";
const XML: &str = "xml";
const HTML: &str = "html";
const BZ2: &str = "bz2";
const PKG: &str = "pkg";
const DMG: &str = "dmg";
const EXE: &str = "exe";
const DEB: &str = "deb";
const MSI: &str = "msi";
const XZ: &str = "xz";
const SEVEN_ZIP_HEADER: &[u8; 6] = b"7z\xBC\xAF\x27\x1C";
const UNCOMPRESS_MACOS_ERR_MSG: &str = "{} files are only supported in macOS";
const HDIUTIL_COMMAND: &str = "hdiutil";
#[derive(Hash, Eq, PartialEq, Debug)]
pub struct BrowserPath {
os: OS,
channel: String,
}
impl BrowserPath {
pub fn new(os: OS, channel: &str) -> BrowserPath {
BrowserPath {
os,
channel: channel.to_string(),
}
}
}
// Returns the first `<dir>/<name>` that exists, searched name-major (every dir tried for a
// name before moving to the next name), matching how a browser's own driver walks candidates.
pub fn first_existing_path(dirs: &[&str], names: &[&str]) -> Option<PathBuf> {
for name in names {
for dir in dirs {
let candidate = Path::new(dir).join(name);
if candidate.exists() {
return Some(candidate);
}
}
}
None
}
pub fn create_parent_path_if_not_exists(path: &Path) -> Result<(), Error> {
if let Some(p) = path.parent() {
create_path_if_not_exists(p)?;
}
Ok(())
}
pub fn create_path_if_not_exists(path: &Path) -> Result<(), Error> {
if !path.exists() {
fs::create_dir_all(path)?;
}
Ok(())
}
pub fn check_path_traversal(entry_path: &Path) -> Result<(), Error> {
if entry_path.as_os_str().is_empty()
|| entry_path.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
{
return Err(anyhow!("Unsafe entry (path traversal): {:?}", entry_path));
}
Ok(())
}
pub fn uncompress(
compressed_file: &str,
target: &Path,
log: &Logger,
os: &str,
single_file: Option<String>,
volume: Option<&str>,
) -> Result<(), Error> {
let mut extension = match infer::get_from_path(compressed_file)? {
Some(kind) => kind.extension(),
_ => {
if compressed_file.ends_with(PKG) || compressed_file.ends_with(DMG) {
if MACOS.is(os) {
PKG
} else {
return Err(anyhow!(format_one_arg(UNCOMPRESS_MACOS_ERR_MSG, PKG)));
}
} else {
return Err(anyhow!(format!(
"Format for file {} cannot be inferred",
compressed_file
)));
}
}
};
if compressed_file.ends_with(DMG) {
if MACOS.is(os) {
extension = DMG;
} else {
return Err(anyhow!(format_one_arg(UNCOMPRESS_MACOS_ERR_MSG, DMG)));
}
}
log.trace(format!(
"The detected extension of the compressed file is {}",
extension
));
if extension.eq_ignore_ascii_case(ZIP) {
unzip(compressed_file, target, log, single_file)?
} else if extension.eq_ignore_ascii_case(GZ) {
untargz(compressed_file, target, log)?
} else if extension.eq_ignore_ascii_case(BZ2) {
uncompress_tar(
&mut BzDecoder::new(File::open(compressed_file)?),
target,
log,
)?
} else if extension.eq_ignore_ascii_case(XZ) {
uncompress_tar(
&mut XzDecoder::new(File::open(compressed_file)?),
target,
log,
)?
} else if extension.eq_ignore_ascii_case(PKG) {
uncompress_pkg(compressed_file, target, log)?
} else if extension.eq_ignore_ascii_case(DMG) {
uncompress_dmg(compressed_file, target, log, volume.unwrap_or_default())?
} else if extension.eq_ignore_ascii_case(EXE) {
uncompress_sfx(compressed_file, target, log)?
} else if extension.eq_ignore_ascii_case(DEB) {
uncompress_deb(compressed_file, target, log, volume.unwrap_or_default())?
} else if extension.eq_ignore_ascii_case(MSI) {
install_msi(compressed_file, log, os)?
} else if extension.eq_ignore_ascii_case(XML) || extension.eq_ignore_ascii_case(HTML) {
log.debug(format!(
"Wrong downloaded driver: {}",
fs::read_to_string(compressed_file).unwrap_or_default()
));
return Err(anyhow!(PARSE_ERROR));
} else {
return Err(anyhow!(format!(
"Downloaded file cannot be uncompressed ({} extension)",
extension
)));
}
Ok(())
}
pub fn uncompress_sfx(compressed_file: &str, target: &Path, log: &Logger) -> Result<(), Error> {
let zip_parent = Path::new(compressed_file).parent().unwrap();
log.trace(format!(
"Decompressing {} to {}",
compressed_file,
zip_parent.display()
));
let file_bytes = read_bytes_from_file(compressed_file)?;
let header = find_bytes(&file_bytes, SEVEN_ZIP_HEADER);
let index_7z = header.ok_or(anyhow!("Incorrect SFX (self extracting exe) file"))?;
let file_reader = Cursor::new(&file_bytes[index_7z..]);
sevenz_rust::decompress(file_reader, zip_parent).unwrap();
let zip_parent_str = path_to_string(zip_parent);
let core_str = format!(r"{}\core", zip_parent_str);
move_folder_content(&core_str, target, log)?;
Ok(())
}
pub fn move_folder_content(source: &str, target: &Path, log: &Logger) -> Result<(), Error> {
log.trace(format!(
"Moving files and folders from {} to {}",
source,
target.display()
));
create_parent_path_if_not_exists(target)?;
let mut options = CopyOptions::new();
options.content_only = true;
options.skip_exist = true;
move_dir(source, target, &options)?;
Ok(())
}
const PBZX_MAGIC: [u8; 4] = *b"pbzx";
const XZ_MAGIC: [u8; 6] = [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00];
/// Decode a `pbzx` stream into the raw cpio archive it wraps.
///
/// `pbzx` is Apple's block-based container used for newer `.pkg` Payloads. It
/// consists of a `pbzx` magic, an 8-byte flags field, then a sequence of chunks
/// each prefixed by its big-endian decompressed and compressed sizes. A chunk is
/// xz-compressed unless its bytes are stored verbatim.
fn decode_pbzx(data: &[u8]) -> Result<Vec<u8>, Error> {
let mut cursor = Cursor::new(data);
let mut magic = [0u8; 4];
cursor.read_exact(&mut magic)?;
if magic != PBZX_MAGIC {
return Err(anyhow!("Payload is not a pbzx stream"));
}
// The 8-byte flags field is not needed to walk the chunks.
cursor.read_exact(&mut [0u8; 8])?;
let mut output = Vec::new();
let mut sizes = [0u8; 8];
loop {
match cursor.read_exact(&mut sizes) {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => break,
Err(err) => return Err(err.into()),
}
// Decompressed size is recorded but not required to read the chunk.
cursor.read_exact(&mut sizes)?;
let compressed_size = u64::from_be_bytes(sizes) as usize;
let mut chunk = vec![0u8; compressed_size];
cursor.read_exact(&mut chunk)?;
if chunk.len() >= XZ_MAGIC.len() && chunk[..XZ_MAGIC.len()] == XZ_MAGIC {
XzDecoder::new(&chunk[..]).read_to_end(&mut output)?;
} else {
output.extend_from_slice(&chunk);
}
}
Ok(output)
}
pub fn uncompress_pkg(compressed_file: &str, target: &Path, log: &Logger) -> Result<(), Error> {
let target_path = Path::new(target);
let mut xar = PkgReader::new(File::open(compressed_file)?)?.into_inner();
let payload_path = xar
.files()?
.into_iter()
.map(|(name, _)| name)
.find(|name| name == "Payload" || name.ends_with("/Payload"))
.ok_or(anyhow!("Unable to extract PKG: no Payload found"))?;
let payload = xar
.get_file_data_from_path(&payload_path)?
.ok_or(anyhow!("Unable to extract PKG: empty Payload"))?;
// Newer macOS packages (e.g. Firefox beta) wrap the cpio Payload in the pbzx
// format, which apple-flat-package cannot decode. Unwrap pbzx to the raw cpio
// stream; gzip and uncompressed payloads are handled by payload_reader as-is.
let payload = if payload.len() >= PBZX_MAGIC.len() && payload[..PBZX_MAGIC.len()] == PBZX_MAGIC
{
log.trace("Decoding pbzx-compressed Payload".to_string());
decode_pbzx(&payload)?
} else {
payload
};
let package = ComponentPackageReader::from_file_data(None, None, Some(payload), None)?;
if let Some(mut cpio_reader) = package.payload_reader()? {
while let Some(next) = cpio_reader.next() {
let entry = next?;
let name = entry.name();
check_path_traversal(Path::new(name))?;
let mut file = Vec::new();
cpio_reader.read_to_end(&mut file)?;
let target_path_buf = target_path.join(name);
log.trace(format!("Extracting {}", target_path_buf.display()));
if entry.file_size() != 0 {
let target_path = target_path_buf.as_path();
fs::create_dir_all(target_path.parent().unwrap())?;
fs::write(&target_path_buf, file)?;
// Set permissions
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = entry.mode();
fs::set_permissions(target_path, fs::Permissions::from_mode(mode))?;
}
}
}
}
Ok(())
}
pub fn uncompress_dmg(
compressed_file: &str,
target: &Path,
log: &Logger,
volume: &str,
) -> Result<(), Error> {
let dmg_file_name = Path::new(compressed_file)
.file_name()
.unwrap_or_default()
.to_os_string();
log.debug(format!(
"Mounting {} and copying content to cache",
dmg_file_name.to_str().unwrap_or_default()
));
let mut command = Command::new(
HDIUTIL_COMMAND,
vec![String::from("attach"), compressed_file.to_string()],
);
log.trace(format!("Running command: {}", command.display()));
run_shell_command(command)?;
fs::create_dir_all(target)?;
let target_folder = path_to_string(target);
command = Command::new(
"cp",
vec![
String::from("-R"),
format!("/Volumes/{}/{}.app", volume, volume),
target_folder,
],
);
log.trace(format!("Running command: {}", command.display()));
run_shell_command(command)?;
command = Command::new(
HDIUTIL_COMMAND,
vec![String::from("detach"), format!("/Volumes/{}", volume)],
);
log.trace(format!("Running command: {}", command.display()));
run_shell_command(command)?;
Ok(())
}
pub fn uncompress_deb(
compressed_file: &str,
target: &Path,
log: &Logger,
label: &str,
) -> Result<(), Error> {
let zip_parent = Path::new(compressed_file).parent().unwrap();
log.trace(format!(
"Extracting from {} to {}",
compressed_file,
zip_parent.display()
));
let deb_file = File::open(compressed_file)?;
let mut deb_pkg = debpkg::DebPkg::parse(deb_file)?;
deb_pkg.data()?.unpack(zip_parent)?;
let zip_parent_str = path_to_string(zip_parent);
let opt_edge_str = format!("{}/opt/microsoft/{}", zip_parent_str, label);
// Exception due to bad symbolic link in unstable distributions. For example:
// microsoft-edge -> /opt/microsoft/msedge-beta/microsoft-edge-beta
if !label.eq("msedge") {
let link = format!("{}/microsoft-edge", opt_edge_str);
fs::remove_file(Path::new(&link)).unwrap_or_default();
}
move_folder_content(&opt_edge_str, target, log)?;
Ok(())
}
pub fn install_msi(msi_file: &str, log: &Logger, _os: &str) -> Result<(), Error> {
let msi_file_name = Path::new(msi_file)
.file_name()
.unwrap_or_default()
.to_os_string();
log.debug(format!(
"Installing {}",
msi_file_name.to_str().unwrap_or_default()
));
let command = Command::new(
"msiexec",
vec![
String::from("/i"),
msi_file.to_string(),
String::from("/qn"),
String::from("ALLOWDOWNGRADE=1"),
],
);
log.trace(format!("Running command: {}", command.display()));
run_shell_command(command)?;
Ok(())
}
pub fn untargz(compressed_file: &str, target: &Path, log: &Logger) -> Result<(), Error> {
log.trace(format!(
"Untargz {} to {}",
compressed_file,
target.display()
));
let file = File::open(compressed_file)?;
let tar = GzDecoder::new(&file);
let mut archive = Archive::new(tar);
let parent_path = target
.parent()
.ok_or(anyhow!(format!("Error getting parent of {:?}", file)))?;
if !target.exists() {
archive.unpack(parent_path)?;
}
Ok(())
}
pub fn uncompress_tar(decoder: &mut dyn Read, target: &Path, log: &Logger) -> Result<(), Error> {
log.trace(format!(
"Uncompress compressed tarball to {}",
target.display()
));
let mut buffer: Vec<u8> = Vec::new();
decoder.read_to_end(&mut buffer)?;
let mut archive = Archive::new(Cursor::new(buffer));
for entry in archive.entries()? {
let mut entry_decoder = entry?;
let path = entry_decoder.path()?;
let entry_path: PathBuf = if path.iter().count() > 1 {
path.iter().skip(1).collect()
} else {
path.to_path_buf()
};
check_path_traversal(&entry_path)?;
let entry_target = target.join(entry_path);
fs::create_dir_all(entry_target.parent().unwrap())?;
entry_decoder.unpack(entry_target)?;
}
Ok(())
}
pub fn unzip(
compressed_file: &str,
target: &Path,
log: &Logger,
single_file: Option<String>,
) -> Result<(), Error> {
let file = File::open(compressed_file)?;
let compressed_path = Path::new(compressed_file);
let tmp_path = compressed_path
.parent()
.unwrap_or(compressed_path)
.to_path_buf();
let final_path = if single_file.is_some() {
target.parent().unwrap_or(target).to_path_buf()
} else {
target.to_path_buf()
};
log.trace(format!(
"Unzipping {} to {}",
compressed_file,
final_path.display()
));
let mut zip_archive = ZipArchive::new(file)?;
let mut unzipped_files = 0;
for i in 0..zip_archive.len() {
let mut file = zip_archive.by_index(i)?;
let path: PathBuf = match file.enclosed_name() {
// This logic is required since some zip files (e.g. chromedriver 115+)
// are zipped with a parent folder, while others (e.g. chromedriver 114-)
// are zipped without a parent folder
Some(p) => {
let iter = p.iter();
if iter.to_owned().count() > 1 {
iter.skip(1).collect()
} else {
iter.collect()
}
}
None => continue,
};
if file.name().ends_with('/') {
log.trace(format!("File extracted to {}", tmp_path.display()));
fs::create_dir_all(&tmp_path)?;
} else {
let target_path = tmp_path.join(path.clone());
create_parent_path_if_not_exists(target_path.as_path())?;
let mut outfile = File::create(&target_path)?;
// Set permissions in Unix-like systems
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if single_file.is_some() {
fs::set_permissions(&target_path, fs::Permissions::from_mode(0o755))?;
} else if let Some(mode) = file.unix_mode() {
fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?;
}
}
io::copy(&mut file, &mut outfile)?;
unzipped_files += 1;
log.trace(format!(
"File extracted to {} ({} bytes)",
target_path.display(),
file.size()
));
}
}
if unzipped_files == 0 {
return Err(anyhow!(format!(
"Problem uncompressing zip ({} files extracted)",
unzipped_files
)));
}
fs::remove_file(compressed_path)?;
copy_folder_content(
tmp_path,
final_path,
single_file,
&compressed_path.to_path_buf(),
log,
)?;
Ok(())
}
pub fn copy_folder_content(
source: impl AsRef<Path>,
destination: impl AsRef<Path>,
single_file: Option<String>,
avoid_path: &PathBuf,
log: &Logger,
) -> io::Result<()> {
fs::create_dir_all(&destination)?;
for dir_entry in fs::read_dir(source)? {
let entry = dir_entry?;
let file_type = entry.file_type()?;
let destination_path = destination.as_ref().join(entry.file_name());
if file_type.is_file() {
if entry.path().eq(avoid_path) {
continue;
}
let target_file_name = entry
.file_name()
.to_os_string()
.into_string()
.unwrap_or_default();
if single_file.is_none()
|| (single_file.is_some() && single_file.clone().unwrap().eq(&target_file_name))
{
log.trace(format!(
"Copying {} to {}",
entry.path().display(),
destination_path.display()
));
if !destination_path.exists() {
fs::copy(entry.path(), destination_path)?;
}
}
} else if single_file.is_none() {
copy_folder_content(
entry.path(),
destination_path,
single_file.clone(),
avoid_path,
log,
)?;
}
}
Ok(())
}
pub fn default_cache_folder() -> PathBuf {
if let Some(base_dirs) = BaseDirs::new() {
return Path::new(base_dirs.home_dir())
.join(String::from(CACHE_FOLDER).replace('/', std::path::MAIN_SEPARATOR_STR));
}
PathBuf::new()
}
pub fn compose_driver_path_in_cache(
driver_path: PathBuf,
driver_name: &str,
os: &str,
arch_folder: &str,
driver_version: &str,
) -> PathBuf {
driver_path
.join(driver_name)
.join(arch_folder)
.join(driver_version)
.join(get_driver_filename(driver_name, os))
}
pub fn get_driver_filename(driver_name: &str, os: &str) -> String {
format!("{}{}", driver_name, get_binary_extension(os))
}
pub fn get_binary_extension(os: &str) -> &str {
if WINDOWS.is(os) { ".exe" } else { "" }
}
pub fn parse_version(version_text: String, log: &Logger) -> Result<String, Error> {
if version_text.to_ascii_lowercase().contains("error") {
log.debug(format!("Error parsing version: {}", version_text));
return Err(anyhow!(PARSE_ERROR));
}
let mut parsed_version = "".to_string();
let re_numbers_dots = Regex::new(r"[^\d^.]")?;
let re_versions = Regex::new(r"(?:(\d+)\.)?(?:(\d+)\.)?(?:(\d+)\.\d+)")?;
for token in version_text.split(' ') {
parsed_version = re_numbers_dots.replace_all(token, "").to_string();
if re_versions.is_match(parsed_version.as_str()) {
break;
}
}
if parsed_version.ends_with('.') {
parsed_version = parsed_version[0..parsed_version.len() - 1].to_string();
}
Ok(parsed_version)
}
pub fn path_to_string(path: &Path) -> String {
path.to_path_buf()
.into_os_string()
.into_string()
.unwrap_or_default()
}
pub fn read_bytes_from_file(file_path: &str) -> Result<Vec<u8>, Error> {
let file = File::open(file_path)?;
let mut reader = BufReader::new(file);
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer)?;
Ok(buffer)
}
pub fn find_bytes(buffer: &[u8], bytes: &[u8]) -> Option<usize> {
buffer
.windows(bytes.len())
.position(|window| window == bytes)
}
pub fn collect_files_from_cache<F: Fn(&DirEntry) -> bool>(
cache_path: &PathBuf,
filter: F,
) -> Vec<PathBuf> {
WalkDir::new(cache_path)
.sort_by_file_name()
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
.filter(|entry| filter(entry))
.map(|entry| entry.path().to_owned())
.collect()
}
pub fn find_latest_from_cache<F: Fn(&DirEntry) -> bool>(
cache_path: &PathBuf,
filter: F,
) -> Result<Option<PathBuf>, Error> {
let files_in_cache = collect_files_from_cache(cache_path, filter);
if !files_in_cache.is_empty() {
Ok(Some(files_in_cache.iter().last().unwrap().to_owned()))
} else {
Ok(None)
}
}
pub fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}
#[cfg(not(windows))]
pub fn get_win_file_version(_file_path: &str) -> Option<String> {
None
}
#[cfg(windows)]
pub fn get_win_file_version(file_path: &str) -> Option<String> {
unsafe {
let wide_path: Vec<u16> = OsStr::new(file_path).encode_wide().chain(Some(0)).collect();
let mut dummy = 0;
let size = GetFileVersionInfoSizeW(wide_path.as_ptr(), &mut dummy);
if size == 0 {
return None;
}
let mut buffer: Vec<u8> = Vec::with_capacity(size as usize);
if GetFileVersionInfoW(wide_path.as_ptr(), 0, size, buffer.as_mut_ptr() as LPVOID) == 0 {
return None;
}
buffer.set_len(size as usize);
let mut lang_and_codepage_ptr: LPVOID = ptr::null_mut();
let mut lang_and_codepage_len: u32 = 0;
if VerQueryValueW(
buffer.as_ptr() as LPVOID,
OsStr::new("\\VarFileInfo\\Translation")
.encode_wide()
.chain(Some(0))
.collect::<Vec<u16>>()
.as_ptr(),
&mut lang_and_codepage_ptr,
&mut lang_and_codepage_len,
) == 0
{
return None;
}
if lang_and_codepage_len == 0 {
return None;
}
let lang_and_codepage_slice = std::slice::from_raw_parts(
lang_and_codepage_ptr as *const u16,
lang_and_codepage_len as usize / 2,
);
let lang = lang_and_codepage_slice[0];
let codepage = lang_and_codepage_slice[1];
let query = format!(
"\\StringFileInfo\\{:04x}{:04x}\\ProductVersion",
lang, codepage
);
let query_wide: Vec<u16> = OsStr::new(&query).encode_wide().chain(Some(0)).collect();
let mut product_version_ptr: LPVOID = ptr::null_mut();
let mut product_version_len: u32 = 0;
if VerQueryValueW(
buffer.as_ptr() as LPVOID,
query_wide.as_ptr(),
&mut product_version_ptr,
&mut product_version_len,
) == 0
{
return None;
}
if product_version_ptr.is_null() {
return None;
}
let product_version_slice = std::slice::from_raw_parts(
product_version_ptr as *const u16,
product_version_len as usize,
);
let product_version = String::from_utf16_lossy(product_version_slice);
Some(product_version.trim_end_matches('\0').to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::{PBZX_MAGIC, decode_pbzx};
use std::io::Cursor;
use std::io::Write;
use xz2::write::XzEncoder;
fn xz_compress(data: &[u8]) -> Vec<u8> {
let mut encoder = XzEncoder::new(Vec::new(), 6);
encoder.write_all(data).unwrap();
encoder.finish().unwrap()
}
fn build_pbzx(chunks: &[&[u8]]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&PBZX_MAGIC);
out.extend_from_slice(&0x0100_0000u64.to_be_bytes()); // flags
for chunk in chunks {
// Decompressed size is informational for our decoder; the chunk
// length and content (xz magic or not) drive decoding.
out.extend_from_slice(&(chunk.len() as u64).to_be_bytes());
out.extend_from_slice(&(chunk.len() as u64).to_be_bytes());
out.extend_from_slice(chunk);
}
out
}
#[test]
fn decode_pbzx_reads_stored_chunk() {
let payload = b"raw cpio bytes that are not xz compressed";
let pbzx = build_pbzx(&[payload]);
assert_eq!(decode_pbzx(&pbzx).unwrap(), payload);
}
#[test]
fn decode_pbzx_decompresses_xz_chunk() {
let original = b"070707cpio-like payload contents repeated repeated repeated";
let compressed = xz_compress(original);
let pbzx = build_pbzx(&[&compressed]);
assert_eq!(decode_pbzx(&pbzx).unwrap(), original);
}
#[test]
fn decode_pbzx_concatenates_multiple_chunks() {
let first = b"first-chunk-stored".to_vec();
let second = xz_compress(b"second-chunk-xz-compressed");
let pbzx = build_pbzx(&[&first, &second]);
let mut expected = first.clone();
expected.extend_from_slice(b"second-chunk-xz-compressed");
assert_eq!(decode_pbzx(&pbzx).unwrap(), expected);
}
#[test]
fn decode_pbzx_rejects_non_pbzx_input() {
assert!(decode_pbzx(b"\x1f\x8b\x08not a pbzx stream").is_err());
}
fn build_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut buffer = Vec::new();
for (name, contents) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o644);
header.set_path("browser/file.txt").unwrap();
header.set_cksum();
let mut header_bytes = header.as_bytes().to_vec();
let name_bytes = name.as_bytes();
assert!(name_bytes.len() <= 100, "test tar name too long");
header_bytes[0..100].fill(0);
header_bytes[0..name_bytes.len()].copy_from_slice(name_bytes);
header_bytes[148..156].fill(b' ');
let checksum: u32 = header_bytes.iter().map(|byte| *byte as u32).sum();
let checksum_bytes = format!("{:06o}\0 ", checksum);
header_bytes[148..156].copy_from_slice(checksum_bytes.as_bytes());
buffer.extend_from_slice(&header_bytes);
buffer.extend_from_slice(contents);
let remainder = contents.len() % 512;
if remainder != 0 {
buffer.extend_from_slice(&vec![0u8; 512 - remainder]);
}
}
buffer.extend_from_slice(&[0u8; 1024]);
buffer
}
#[test]
fn check_path_traversal_allows_safe_paths() {
assert!(check_path_traversal(Path::new("browser/file.txt")).is_ok());
}
#[test]
fn check_path_traversal_rejects_empty_path() {
let err = check_path_traversal(Path::new("")).unwrap_err();
assert!(err.to_string().contains("Unsafe entry (path traversal)"));
}
#[test]
fn uncompress_tar_extracts_safe_entry() {
let temp_dir = tempfile::tempdir().unwrap();
let target = temp_dir.path().join("extract");
let tar_data = build_tar(&[("browser/file.txt", b"hello")]);
let mut decoder = Cursor::new(tar_data);
let log = Logger::new();
uncompress_tar(&mut decoder, &target, &log).unwrap();
assert_eq!(fs::read(target.join("file.txt")).unwrap(), b"hello");
}
#[test]
fn uncompress_tar_keeps_single_component_entry() {
let temp_dir = tempfile::tempdir().unwrap();
let target = temp_dir.path().join("extract");
let tar_data = build_tar(&[("file.txt", b"hello")]);
let mut decoder = Cursor::new(tar_data);
let log = Logger::new();
uncompress_tar(&mut decoder, &target, &log).unwrap();
assert_eq!(fs::read(target.join("file.txt")).unwrap(), b"hello");
}
#[test]
fn uncompress_tar_rejects_path_traversal_entry() {
let temp_dir = tempfile::tempdir().unwrap();
let target = temp_dir.path().join("extract");
let escape_path = temp_dir.path().join("escape.txt");
let tar_data = build_tar(&[("browser/../../escape.txt", b"owned")]);
let mut decoder = Cursor::new(tar_data);
let log = Logger::new();
let err = uncompress_tar(&mut decoder, &target, &log).unwrap_err();
assert!(err.to_string().contains("Unsafe entry (path traversal)"));
assert!(!escape_path.exists());
}
}