Allow opening an IPA bundle file directly

This commit is contained in:
Nikita Strygin
2023-02-05 05:06:45 +03:00
committed by hikari_no_yume
parent 1e887c6488
commit 32d12cc697
7 changed files with 457 additions and 84 deletions

56
Cargo.lock generated
View File

@@ -8,6 +8,12 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046"
[[package]]
name = "adler"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "ansi_term"
version = "0.12.1"
@@ -191,6 +197,24 @@ dependencies = [
"cc",
]
[[package]]
name = "crc32fast"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f"
dependencies = [
"cfg-if",
]
[[package]]
name = "csv"
version = "1.1.6"
@@ -213,6 +237,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "flate2"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "getopts"
version = "0.2.21"
@@ -340,6 +374,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "miniz_oxide"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa"
dependencies = [
"adler",
]
[[package]]
name = "once_cell"
version = "1.17.0"
@@ -616,6 +659,7 @@ dependencies = [
"touchHLE_gl_bindings",
"touchHLE_openal_soft_wrapper",
"touchHLE_stb_image_wrapper",
"zip",
]
[[package]]
@@ -719,3 +763,15 @@ name = "xml-rs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3"
[[package]]
name = "zip"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0445d0fbc924bb93539b4316c11afb121ea39296f99a3c4c9edad09e3658cdef"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
"flate2",
]

View File

@@ -30,6 +30,7 @@ caf = "0.1.0"
hound = "3.5.0"
mach_object = "0.1.17"
plist = "1.3.1"
zip = { version = "0.6.4", default-features = false, features = ["deflate"] }
rusttype = "0.9.3"
# sdl2 crates pinned at 0.35.1 because static linking seems to be broken for
# 0.35.2 on macOS (build errors about undefined symbols for

View File

@@ -11,11 +11,10 @@
//! * [Anatomy of an iOS Application Bundle](https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html)
//! * [Bundle Resources](https://developer.apple.com/documentation/bundleresources?language=objc)
use crate::fs::{Fs, GuestPath, GuestPathBuf};
use crate::fs::{BundleData, Fs, GuestPath, GuestPathBuf};
use plist::dictionary::Dictionary;
use plist::Value;
use std::io::Cursor;
use std::path::PathBuf;
#[derive(Debug)]
pub struct Bundle {
@@ -25,32 +24,21 @@ pub struct Bundle {
impl Bundle {
pub fn new_bundle_and_fs_from_host_path(
host_path: PathBuf,
) -> Result<(Bundle, Fs), &'static str> {
if !host_path.is_dir() {
return Err("Bundle path is not a directory");
}
let plist_path = host_path.join("Info.plist");
if !plist_path.is_file() {
return Err("Bundle does not contain an Info.plist file");
}
let plist_bytes =
std::fs::read(plist_path).map_err(|_| "Could not read Info.plist file")?;
mut bundle_data: BundleData,
) -> Result<(Bundle, Fs), String> {
let plist_bytes = bundle_data.read_plist()?;
let plist = Value::from_reader(Cursor::new(plist_bytes))
.map_err(|_| "Could not deserialize plist data")?;
.map_err(|_| "Could not deserialize plist data".to_string())?;
let plist = plist
.into_dictionary()
.ok_or("plist root value is not a dictionary")?;
.ok_or_else(|| "plist root value is not a dictionary".to_string())?;
let bundle_name = plist["CFBundleName"].as_string().unwrap();
let bundle_id = plist["CFBundleIdentifier"].as_string().unwrap();
let (fs, guest_path) = Fs::new(&host_path, format!("{}.app", bundle_name), bundle_id);
let (fs, guest_path) = Fs::new(bundle_data, format!("{bundle_name}.app"), bundle_id);
let bundle = Bundle {
path: guest_path,

218
src/fs.rs
View File

@@ -20,15 +20,25 @@
//! Directories only need a corresponding directory in the host filesystem if
//! they are writeable (i.e. if new files can be created in them).
mod bundle;
pub use bundle::BundleData;
use crate::fs::bundle::{IpaFile, IpaFileRef};
use std::collections::HashMap;
use std::fs::File;
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
#[derive(Debug)]
enum FsNode {
File {
HostFile {
host_path: PathBuf,
writeable: bool,
},
IpaBundleFile {
file: IpaFileRef,
},
Directory {
children: HashMap<String, FsNode>,
writeable: Option<PathBuf>,
@@ -55,7 +65,7 @@ impl FsNode {
if kind.is_file() {
children.insert(
name,
FsNode::File {
FsNode::HostFile {
host_path,
writeable,
},
@@ -91,12 +101,15 @@ impl FsNode {
assert!(children.insert(String::from(name), child).is_none());
self
}
fn file(host_path: PathBuf) -> Self {
FsNode::File {
fn host_file(host_path: PathBuf) -> Self {
FsNode::HostFile {
host_path,
writeable: false,
}
}
fn bundle_zip_file(file: IpaFileRef) -> Self {
FsNode::IpaBundleFile { file }
}
}
/// Like [Path] but for the virtual filesystem.
@@ -104,7 +117,7 @@ impl FsNode {
#[derive(Debug)]
pub struct GuestPath(str);
impl GuestPath {
pub fn new<S: AsRef<str>>(s: &S) -> &GuestPath {
pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> &GuestPath {
unsafe { &*(s.as_ref() as *const str as *const GuestPath) }
}
pub fn as_str(&self) -> &str {
@@ -120,12 +133,24 @@ impl GuestPath {
GuestPathBuf::from(format!("{}/{}", self.as_str(), path.as_ref()))
}
/// Splits the path into a parent path and a file name.
pub fn parent_and_file_name(&self) -> Option<(&GuestPath, &str)> {
// FIXME: this should do the same resolution as `std::path::file_name()`
let (parent_name, file_name) = self.as_str().rsplit_once('/')?;
Some((GuestPath::new(parent_name), file_name))
}
/// Get the final component of the path.
pub fn file_name(&self) -> Option<&str> {
// FIXME: this should do the same resolution as `std::path::file_name()`
let (_, file_name) = self.as_str().rsplit_once('/')?;
let (_, file_name) = self.parent_and_file_name()?;
Some(file_name)
}
/// Get the parent directory of the path.
pub fn parent(&self) -> Option<&GuestPath> {
let (parent_name, _) = self.parent_and_file_name()?;
Some(parent_name)
}
}
impl AsRef<GuestPath> for GuestPath {
fn as_ref(&self) -> &Self {
@@ -142,7 +167,7 @@ impl AsRef<GuestPath> for str {
unsafe { &*(self as *const str as *const GuestPath) }
}
}
impl std::borrow::ToOwned for GuestPath {
impl ToOwned for GuestPath {
type Owned = GuestPathBuf;
fn to_owned(&self) -> GuestPathBuf {
@@ -276,6 +301,68 @@ fn handle_open_err<T>(open_result: std::io::Result<T>, host_path: &Path) -> T {
}
}
/// Like [File] but for the guest filesystem.
#[derive(Debug)]
pub enum GuestFile {
HostFile(File),
IpaBundleFile(IpaFile),
}
impl GuestFile {
fn from_host_file(file: File) -> GuestFile {
GuestFile::HostFile(file)
}
fn from_ipa_file(file: &IpaFileRef) -> GuestFile {
GuestFile::IpaBundleFile(file.open())
}
pub fn sync_all(&self) -> std::io::Result<()> {
match self {
GuestFile::HostFile(file) => file.sync_all(),
GuestFile::IpaBundleFile(_) => Ok(()),
}
}
}
impl Read for GuestFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
GuestFile::HostFile(file) => file.read(buf),
GuestFile::IpaBundleFile(file) => file.read(buf),
}
}
}
impl Write for GuestFile {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
GuestFile::HostFile(file) => file.write(buf),
GuestFile::IpaBundleFile(file) => {
panic!("Attempt to write to a read-only file: {:?}", file)
}
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
GuestFile::HostFile(file) => file.flush(),
GuestFile::IpaBundleFile(file) => {
panic!("Attempt to flush a read-only file: {:?}", file)
}
}
}
}
impl Seek for GuestFile {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
match self {
GuestFile::HostFile(file) => file.seek(pos),
GuestFile::IpaBundleFile(file) => file.seek(pos),
}
}
}
/// The type that owns the guest filesystem and provides accessors for it.
#[derive(Debug)]
pub struct Fs {
@@ -299,7 +386,7 @@ impl Fs {
/// sandbox directory, where documents can be stored. A directory will be
/// created at that path if it does not already exist.
pub fn new(
bundle_host_path: &Path,
app_bundle: BundleData,
bundle_dir_name: String,
bundle_id: &str,
) -> (Fs, GuestPathBuf) {
@@ -325,16 +412,16 @@ impl Fs {
let usr_lib = FsNode::dir()
.with_child(
"libgcc_s.1.dylib",
FsNode::file(dylibs_host_path.join("libgcc_s.1.dylib")),
FsNode::host_file(dylibs_host_path.join("libgcc_s.1.dylib")),
)
.with_child(
// symlink
"libstdc++.6.dylib",
FsNode::file(dylibs_host_path.join("libstdc++.6.0.4.dylib")),
FsNode::host_file(dylibs_host_path.join("libstdc++.6.0.4.dylib")),
)
.with_child(
"libstdc++.6.0.4.dylib",
FsNode::file(dylibs_host_path.join("libstdc++.6.0.4.dylib")),
FsNode::host_file(dylibs_host_path.join("libstdc++.6.0.4.dylib")),
);
let root = FsNode::dir()
@@ -346,13 +433,7 @@ impl Fs {
FAKE_UUID,
FsNode::Directory {
children: HashMap::from([
(
bundle_dir_name,
FsNode::from_host_dir(
bundle_host_path,
/* writeable: */ false,
),
),
(bundle_dir_name, app_bundle.into_fs_node()),
(
"Documents".to_string(),
FsNode::from_host_dir(
@@ -416,42 +497,43 @@ impl Fs {
Some((parent, final_component.to_string()))
}
/// Like [std::path::Path::is_file] but for the guest filesystem.
/// Like [Path::is_file] but for the guest filesystem.
pub fn is_file(&self, path: &GuestPath) -> bool {
matches!(self.lookup_node(path), Some(FsNode::File { .. }))
matches!(
self.lookup_node(path),
Some(FsNode::HostFile { .. } | FsNode::IpaBundleFile { .. })
)
}
/// Like [std::fs::read] but for the guest filesystem.
pub fn read<P: AsRef<GuestPath>>(&self, path: P) -> Result<Vec<u8>, ()> {
let node = self.lookup_node(path.as_ref()).ok_or(())?;
let FsNode::File {
host_path,
writeable: _,
} = node else {
return Err(())
};
Ok(handle_open_err(std::fs::read(host_path), host_path))
let mut file = self.open(path.as_ref())?;
let mut result = Vec::new();
file.read_to_end(&mut result).map_err(|_| ())?;
Ok(result)
}
/// Like [std::fs::File::open] but for the guest filesystem.
/// Like [File::open] but for the guest filesystem.
#[allow(dead_code)]
pub fn open<P: AsRef<GuestPath>>(&self, path: P) -> Result<std::fs::File, ()> {
pub fn open<P: AsRef<GuestPath>>(&self, path: P) -> Result<GuestFile, ()> {
// it would be nice to delegate to self.open_with_options, but currently it wants a mutable reference to self
let node = self.lookup_node(path.as_ref()).ok_or(())?;
let FsNode::File {
host_path,
writeable: _,
} = node else {
return Err(())
};
Ok(handle_open_err(std::fs::File::open(host_path), host_path))
match node {
FsNode::HostFile { host_path, .. } => {
let host_file = handle_open_err(File::open(host_path), host_path);
Ok(GuestFile::from_host_file(host_file))
}
FsNode::IpaBundleFile { file } => Ok(GuestFile::from_ipa_file(file)),
FsNode::Directory { .. } => Err(()),
}
}
/// Like [std::fs::File::options] but for the guest filesystem.
/// Like [File::options] but for the guest filesystem.
pub fn open_with_options<P: AsRef<GuestPath>>(
&mut self,
path: P,
options: GuestOpenOptions,
) -> Result<std::fs::File, ()> {
) -> Result<GuestFile, ()> {
let GuestOpenOptions {
read,
write,
@@ -474,26 +556,38 @@ impl Fs {
// Open an existing file if possible
if let Some(existing_file) = children.get(&new_filename) {
let FsNode::File {
host_path,
writeable,
} = existing_file else {
return Err(());
};
if !writeable && (append || write) {
log!("Warning: attempt to write to read-only file {:?}", path);
return Err(());
match existing_file {
FsNode::HostFile {
host_path,
writeable,
} => {
if !writeable && (append || write) {
log!("Warning: attempt to write to read-only file {:?}", path);
return Err(());
}
let file = handle_open_err(
File::options()
.read(read)
.write(write)
.append(append)
.create(false)
.truncate(truncate)
.open(host_path),
host_path,
);
return Ok(GuestFile::from_host_file(file));
}
FsNode::IpaBundleFile { file } => {
if write || append || truncate {
log!("Warning: attempt to write to read-only file {:?}", path);
return Err(());
}
return Ok(GuestFile::from_ipa_file(file));
}
FsNode::Directory { .. } => {
return Err(());
}
}
return Ok(handle_open_err(
std::fs::File::options()
.read(read)
.write(write)
.append(append)
.create(false)
.truncate(truncate)
.open(host_path),
host_path,
));
};
// Create a new file otherwise
@@ -516,7 +610,7 @@ impl Fs {
let host_path = dir_host_path.join(&new_filename);
let file = handle_open_err(
std::fs::File::options()
File::options()
.read(read)
.write(write)
.append(append)
@@ -532,11 +626,11 @@ impl Fs {
);
children.insert(
new_filename,
FsNode::File {
FsNode::HostFile {
host_path,
writeable: true,
},
);
Ok(file)
Ok(GuestFile::from_host_file(file))
}
}

232
src/fs/bundle.rs Normal file
View File

@@ -0,0 +1,232 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/
//! IPA file format support, allowing it to be used as part of the guest filesystem.
use crate::fs::{FsNode, GuestPath};
use std::cell::RefCell;
use std::fmt::Debug;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use zip::result::ZipError;
use zip::ZipArchive;
/// A helper struct to build an FsNode with files and directories coming in arbitrary order.
/// This is required, because ZIP files are allowed to store entries in arbitrary order.
struct FsNodeBuilder {
root: FsNode,
}
impl FsNodeBuilder {
fn new() -> Self {
Self {
root: FsNode::dir(),
}
}
fn find_or_make_directory(&mut self, path: &GuestPath) -> &mut FsNode {
let mut current = &mut self.root;
for part in path.as_str().split('/') {
if part.is_empty() {
continue;
}
assert_ne!(part, "..", "unexpected .. in path: {path:?}");
let FsNode::Directory { children, .. } = current else {
panic!("expected directory, got {current:?}");
};
let next = children.entry(part.to_string()).or_insert_with(FsNode::dir);
current = next;
}
current
}
fn add_file(&mut self, path: &GuestPath, node: FsNode) {
let (parent_name, file_name) = path.parent_and_file_name().unwrap();
assert_ne!(file_name, "..", "unexpected .. in path: {path:?}");
let dir = self.find_or_make_directory(parent_name);
let FsNode::Directory { children, .. } = dir else {
panic!("expected directory, got {dir:?}");
};
children.insert(file_name.to_string(), node);
}
fn add_directory(&mut self, path: &GuestPath) {
self.find_or_make_directory(path);
}
fn build(self) -> FsNode {
self.root
}
}
/// Represents an open app bundle, either a directory or a zip file.
pub enum BundleData {
HostDirectory(PathBuf),
Zip {
zip: ZipArchive<std::fs::File>,
/// Path to the app bundle inside the zip file.
/// It should be "Payload/<app name>.app" (no trailing slash!).
bundle_path: String,
},
}
impl BundleData {
fn find_bundle_path_in_archive(zip: &mut ZipArchive<std::fs::File>) -> Result<String, String> {
for i in 0..zip.len() {
let file = zip
.by_index(i)
.map_err(|e| format!("Could not open IPA archive entry: {e}"))?;
let path = file.name();
if let Some(name) = path
.strip_prefix("Payload/")
.and_then(|path| path.split_once('/'))
.and_then(|(name, _)| name.strip_suffix(".app"))
{
return Ok(format!("Payload/{name}.app"));
}
}
Err("no app bundle found in the IPA archive".to_string())
}
pub fn open_host_dir(path: &Path) -> Result<BundleData, String> {
Ok(BundleData::HostDirectory(path.to_path_buf()))
}
pub fn open_ipa(path: &Path) -> Result<BundleData, String> {
let file =
std::fs::File::open(path).map_err(|e| format!("Could not open IPA file: {e}"))?;
let mut zip =
ZipArchive::new(file).map_err(|e| format!("Could not open IPA archive: {e}"))?;
let bundle_path = Self::find_bundle_path_in_archive(&mut zip)?;
Ok(BundleData::Zip { zip, bundle_path })
}
pub fn open_any(path: &Path) -> Result<BundleData, String> {
if path.is_file()
&& path
.extension()
.map(|ext| ext.eq_ignore_ascii_case("ipa"))
.unwrap_or(false)
{
Ok(Self::open_ipa(path)?)
} else if path.is_dir() {
Ok(Self::open_host_dir(path)?)
} else {
Err(format!(
"{} is not a directory or an IPA file",
path.display()
))
}
}
pub(super) fn into_fs_node(self) -> FsNode {
match self {
BundleData::HostDirectory(path) => FsNode::from_host_dir(&path, false),
BundleData::Zip { zip, bundle_path } => {
let archive = Rc::new(RefCell::new(zip));
let mut archive_guard = (*archive).borrow_mut();
let mut builder = FsNodeBuilder::new();
for i in 0..archive_guard.len() {
let file = archive_guard.by_index(i).unwrap(); // TODO: report IO error?
let name = file.name();
if let Some(path) = name.strip_prefix(&bundle_path) {
let path = GuestPath::new(path);
if file.is_dir() {
builder.add_directory(path);
} else {
builder.add_file(
path,
FsNode::bundle_zip_file(IpaFileRef {
archive: archive.clone(),
index: i,
}),
);
}
}
}
builder.build()
}
}
}
pub fn read_plist(&mut self) -> Result<Vec<u8>, String> {
match self {
BundleData::HostDirectory(path) => {
std::fs::read(path.join("Info.plist")).map_err(|e| {
format!("Could not read Info.plist from the app bundle directory: {e}")
})
}
BundleData::Zip { zip, bundle_path } => {
let mut file = zip
.by_name(&format!("{bundle_path}/Info.plist"))
.map_err(|e| format!("Could not open Info.plist from the IPA archive: {e}"))?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)
.map_err(|e| format!("Could not read Info.plist from the IPA archive: {e}"))?;
Ok(buf)
}
}
}
}
/// Represents a file inside an IPA bundle that can be opened.
#[derive(Debug)]
pub struct IpaFileRef {
archive: Rc<RefCell<ZipArchive<std::fs::File>>>,
index: usize,
}
impl IpaFileRef {
pub fn open(&self) -> IpaFile {
let mut archive = (*self.archive).borrow_mut();
let mut file = match archive.by_index(self.index) {
Ok(file) => file,
Err(ZipError::Io(e)) => {
// this is a runtime error, which we __probably__ should not bubble up to the guest
panic!("IO error while opening file from IPA bundle: {e}")
}
// anything other than IO error is a bug in the code, we should always have a valid index
Err(e) => panic!("BUG: could not open file from IPA bundle: {e}"),
};
let mut buf = Vec::new();
file.read_to_end(&mut buf).unwrap();
IpaFile {
file: std::io::Cursor::new(buf),
}
}
}
/// Represents an opened file in an IPA bundle.
pub struct IpaFile {
// we need to use a cursor because zip::read::ZipFile doesn't implement Seek
// and, generally, seeking in compressed files is hard to achieve
// the simplest way to do it is to read the whole file into memory
// the target apps should be small enough to fit in memory, right?
file: std::io::Cursor<Vec<u8>>,
}
impl Debug for IpaFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IpaFile")
.field("size", &self.file.get_ref().len())
.finish()
}
}
impl Read for IpaFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.file.read(buf)
}
}
impl std::io::Seek for IpaFile {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.file.seek(pos)
}
}

View File

@@ -28,7 +28,7 @@ struct FILE {
unsafe impl SafeRead for FILE {}
struct FileHostObject {
file: std::fs::File,
file: crate::fs::GuestFile,
}
fn fopen(env: &mut Environment, filename: ConstPtr<u8>, mode: ConstPtr<u8>) -> MutPtr<FILE> {

View File

@@ -282,10 +282,12 @@ impl Environment {
fn new(bundle_path: PathBuf, options: Options) -> Result<Environment, String> {
let startup_time = std::time::Instant::now();
let (bundle, fs) = match bundle::Bundle::new_bundle_and_fs_from_host_path(bundle_path) {
let bundle_data = fs::BundleData::open_any(&bundle_path)
.map_err(|e| format!("Could not open app bundle: {e}"))?;
let (bundle, fs) = match bundle::Bundle::new_bundle_and_fs_from_host_path(bundle_data) {
Ok(bundle) => bundle,
Err(err) => {
return Err(format!("Application bundle error: {}. Check that the path is to a .app directory. If this is a .ipa file, you need to extract it as a ZIP file to get the .app directory.", err));
return Err(format!("Application bundle error: {err}. Check that the path is to an .app directory or an .ipa file."));
}
};