use crate::{
TitleID,
errors::{CatBridgeError, FSError},
fsemul::{
bsf::BootSystemFile,
dlf::DiskLayoutFile,
errors::{FSEmulAPIError, FSEmulFSError},
pcfs::errors::PcfsApiError,
},
};
use bytes::{Bytes, BytesMut};
use scc::{
HashMap as ConcurrentMap, HashSet as ConcurrentSet, hash_map::OccupiedEntry as CMOccupiedEntry,
};
use std::{
collections::HashMap,
ffi::OsString,
fs::{
DirEntry, copy as copy_file_sync, create_dir_all as create_dir_all_sync,
read_dir as read_dir_sync, read_link as read_link_sync,
remove_dir_all as remove_dir_all_sync, remove_file as remove_file_sync,
rename as rename_sync,
},
hash::RandomState,
io::{Error as IOError, SeekFrom},
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, AtomicI32, Ordering as AtomicOrdering},
},
};
use tokio::{
fs::{File, OpenOptions, read as fs_read, write as fs_write},
io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
sync::Mutex,
};
use tracing::{info, warn};
use valuable::{Fields, NamedField, NamedValues, StructDef, Structable, Valuable, Value, Visit};
use walkdir::WalkDir;
use whoami::username;
static UNIQUE_FILE_FD: AtomicI32 = AtomicI32::new(1);
static FOLDER_FD: AtomicI32 = AtomicI32::new(1);
#[allow(
// Clippy the type is _not_ that complex.
clippy::type_complexity,
)]
#[derive(Clone, Debug)]
pub struct HostFilesystem {
cafe_sdk_path: PathBuf,
disc_mounted: Arc<Mutex<Option<(bool, bool, TitleID)>>>,
open_file_handles: Arc<ConcurrentMap<i32, (File, u64, PathBuf, Option<u64>)>>,
open_folder_handles:
Arc<ConcurrentMap<i32, (Vec<DirEntry>, usize, bool, PathBuf, Option<u64>)>>,
folders_marked_read_only: Arc<ConcurrentSet<PathBuf>>,
is_using_unique_fds: bool,
has_opened_file: Arc<AtomicBool>,
}
impl HostFilesystem {
pub async fn from_cafe_dir(cafe_dir: Option<PathBuf>) -> Result<Self, FSError> {
let Some(cafe_sdk_path) = cafe_dir.or_else(Self::default_cafe_folder) else {
return Err(FSEmulFSError::CantFindCafeSdkPath.into());
};
Self::patch_case_sensitivity(&cafe_sdk_path).await?;
for path in [
&[
"data", "mlc", "sys", "title", "00050030", "1001000a", "code", "app.xml",
] as &[&str],
&[
"data", "mlc", "sys", "title", "00050030", "1001010a", "code", "app.xml",
],
&[
"data", "mlc", "sys", "title", "00050030", "1001020a", "code", "app.xml",
],
&[
"data", "mlc", "sys", "title", "00050010", "1f700500", "code",
],
&[
"data", "mlc", "sys", "title", "00050010", "1f700500", "content",
],
&[
"data", "mlc", "sys", "title", "00050010", "1f700500", "meta",
],
&[
"data", "slc", "sys", "title", "00050010", "1000400a", "code", "fw.img",
],
] {
if !Self::join_many(&cafe_sdk_path, path).exists() {
return Err(FSEmulFSError::CafeSdkPathCorrupt.into());
}
}
Self::prepare_for_serving(&cafe_sdk_path).await?;
let ro_folders = Self::get_default_read_only_folders(&cafe_sdk_path);
Ok(Self {
cafe_sdk_path,
disc_mounted: Arc::new(Mutex::new(None)),
folders_marked_read_only: Arc::new(ro_folders),
open_file_handles: Arc::new(ConcurrentMap::new()),
open_folder_handles: Arc::new(ConcurrentMap::new()),
is_using_unique_fds: false,
has_opened_file: Arc::new(AtomicBool::new(false)),
})
}
#[must_use]
pub const fn cafe_sdk_path(&self) -> &PathBuf {
&self.cafe_sdk_path
}
#[must_use]
pub fn disc_emu_path(&self) -> PathBuf {
Self::join_many(&self.cafe_sdk_path, ["data", "disc"])
}
pub fn force_unique_fds(&mut self) -> Result<(), FSEmulAPIError> {
if self.has_opened_file.load(AtomicOrdering::Relaxed) {
Err(FSEmulAPIError::CannotSwapFdStrategy)
} else {
self.is_using_unique_fds = true;
Ok(())
}
}
pub async fn open_file(
&self,
open_options: OpenOptions,
path: &PathBuf,
stream_owner: Option<u64>,
) -> Result<i32, FSError> {
self.has_opened_file.store(true, AtomicOrdering::Relaxed);
let fd = open_options.open(path).await?;
let raw_fd;
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
raw_fd = fd.as_raw_fd();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::io::AsRawHandle;
raw_fd = fd.as_raw_handle() as i32;
}
let md = fd.metadata().await?;
let final_fd = if self.is_using_unique_fds {
UNIQUE_FILE_FD.fetch_add(1, AtomicOrdering::SeqCst)
} else {
raw_fd
};
self.open_file_handles
.insert_async(final_fd, (fd, md.len(), path.clone(), stream_owner))
.await
.map_err(|_| IOError::other("somehow got duplicate fd?"))?;
Ok(final_fd)
}
pub async fn get_file(
&self,
fd: i32,
for_stream: Option<u64>,
) -> Option<CMOccupiedEntry<'_, i32, (File, u64, PathBuf, Option<u64>), RandomState>> {
self.open_file_handles
.get_async(&fd)
.await
.and_then(|entry| {
if Self::allow_file_access(&entry, for_stream) {
Some(entry)
} else {
None
}
})
}
pub async fn file_length(&self, fd: i32, for_stream: Option<u64>) -> Option<u64> {
self.open_file_handles
.get_async(&fd)
.await
.and_then(|entry| {
if Self::allow_file_access(&entry, for_stream) {
Some(entry.1)
} else {
None
}
})
}
pub async fn read_file(
&self,
fd: i32,
mut total_data_to_read: usize,
for_stream: Option<u64>,
) -> Result<Option<Bytes>, FSError> {
let Some(mut real_entry) = self.open_file_handles.get_async(&fd).await else {
return Ok(None);
};
if !Self::allow_file_access(&real_entry, for_stream) {
return Ok(None);
}
let file_reader = &mut real_entry.0;
let mut file_buff = BytesMut::zeroed(total_data_to_read);
let mut total_bytes_read = 0_usize;
while total_data_to_read > 0 {
let bytes_read = file_reader.read(&mut file_buff[total_bytes_read..]).await?;
if bytes_read == 0 {
break;
}
total_data_to_read -= bytes_read;
total_bytes_read += bytes_read;
}
if file_buff.len() > total_bytes_read {
file_buff.truncate(total_bytes_read);
}
Ok(Some(file_buff.freeze()))
}
pub async fn write_file(
&self,
fd: i32,
data_to_write: Bytes,
for_stream: Option<u64>,
) -> Result<(), FSError> {
let Some(mut real_entry) = self.open_file_handles.get_async(&fd).await else {
return Err(FSError::IO(IOError::other("file not open")));
};
if !Self::allow_file_access(&real_entry, for_stream) {
return Err(FSError::IO(IOError::other("file not open")));
}
let file_writer = &mut real_entry.0;
file_writer.write_all(&data_to_write).await?;
Ok(())
}
pub async fn seek_file(
&self,
fd: i32,
begin: bool,
for_stream: Option<u64>,
) -> Result<(), FSError> {
let Some(mut real_entry) = self.open_file_handles.get_async(&fd).await else {
return Ok(());
};
if !Self::allow_file_access(&real_entry, for_stream) {
return Ok(());
}
let file_reader = &mut real_entry.0;
if begin {
file_reader.seek(SeekFrom::Start(0)).await?;
} else {
file_reader.seek(SeekFrom::End(0)).await?;
}
Ok(())
}
pub async fn close_file(&self, fd: i32, for_stream: Option<u64>) {
if let Some(entry) = self.open_file_handles.get_async(&fd).await
&& !Self::allow_file_access(&entry, for_stream)
{
return;
}
self.open_file_handles.remove_async(&fd).await;
}
pub fn open_folder(&self, path: &PathBuf, for_stream: Option<u64>) -> Result<i32, FSError> {
let mut dhandle = read_dir_sync(path)?
.filter_map(Result::ok)
.collect::<Vec<_>>();
dhandle.sort_by_key(DirEntry::path);
let fake_fd = FOLDER_FD.fetch_add(1, AtomicOrdering::SeqCst);
self.open_folder_handles
.insert_sync(fake_fd, (dhandle, 0, false, path.clone(), for_stream))
.map_err(|_| IOError::other("OS returned duplicate fd?"))?;
Ok(fake_fd)
}
pub async fn mark_folder_read_only(&self, path: PathBuf) {
_ = self.folders_marked_read_only.insert_async(path).await;
}
pub async fn ensure_folder_not_read_only(&self, path: &PathBuf) {
self.folders_marked_read_only.remove_async(path).await;
}
pub async fn folder_is_read_only(&self, path: &PathBuf) -> bool {
self.folders_marked_read_only.contains_async(path).await
}
pub async fn next_in_folder(
&self,
fd: i32,
for_stream: Option<u64>,
) -> Result<Option<(PathBuf, usize)>, FSError> {
let Some(mut entry) = self.open_folder_handles.get_async(&fd).await else {
return Ok(None);
};
if !Self::allow_folder_access(&entry, for_stream) {
return Ok(None);
}
let component_count = entry.3.components().count();
let mut value: Option<PathBuf> = None;
if !entry.2 {
loop {
if entry.1 < entry.0.len() {
let ref_value = entry.0[entry.1].path();
entry.1 += 1;
if (!ref_value.is_file() && !ref_value.is_dir()) || ref_value.is_symlink() {
continue;
}
value = Some(ref_value);
}
break;
}
if value.is_none() {
entry.2 = true;
}
}
Ok(value.map(|val| (val, component_count)))
}
pub async fn reverse_folder(&self, fd: i32, for_stream: Option<u64>) -> Result<(), FSError> {
let Some(mut real_entry) = self.open_folder_handles.get_async(&fd).await else {
return Ok(());
};
if !Self::allow_folder_access(&real_entry, for_stream) {
return Ok(());
}
if real_entry.1 == 0 {
return Ok(());
}
real_entry.1 -= 1;
real_entry.2 = false;
Ok(())
}
pub async fn close_folder(&self, fd: i32, for_stream: Option<u64>) {
if let Some(real_entry) = self.open_folder_handles.get_async(&fd).await
&& !Self::allow_folder_access(&real_entry, for_stream)
{
return;
}
self.open_folder_handles.remove_async(&fd).await;
}
pub async fn boot1_sytstem_path(&self) -> Result<PathBuf, FSError> {
let mut path = self.temp_path()?;
path.push("caferun");
if !path.exists() {
create_dir_all_sync(&path)?;
}
path.push("ppc.bsf");
if !path.exists() {
fs_write(&path, Bytes::from(BootSystemFile::default())).await?;
}
Ok(path)
}
pub async fn disk_id_path(&self) -> Result<PathBuf, FSError> {
let mut path = self.temp_path()?;
path.push("caferun");
if !path.exists() {
create_dir_all_sync(&path)?;
}
path.push("diskid.bin");
if !path.exists() {
fs_write(&path, BytesMut::zeroed(32).freeze()).await?;
}
Ok(path)
}
#[doc(
// This is not yet finished and the signature may change....
hidden,
)]
pub async fn mount_disk_title(
&mut self,
is_slc: bool,
is_sys: bool,
title_id: TitleID,
) -> Result<(), FSError> {
let source_path = Self::join_many(
&self.cafe_sdk_path,
[
"data".to_owned(),
if is_slc { "slc" } else { "mlc" }.to_owned(),
if is_sys { "sys" } else { "usr" }.to_owned(),
"title".to_owned(),
format!("{:08x}", title_id.0),
format!("{:08x}", title_id.1),
],
);
let dest_path = Self::join_many(&self.cafe_sdk_path, ["data", "disc"]);
if dest_path.exists() {
remove_dir_all_sync(&dest_path).map_err(FSError::IO)?;
}
Self::copy_dir(&source_path, &dest_path)?;
{
let mut guard = self.disc_mounted.lock().await;
guard.replace((is_slc, is_sys, title_id));
}
todo!("figure out how to mount diskid.bin")
}
#[must_use]
pub fn firmware_file_path(&self) -> PathBuf {
Self::join_many(
&self.slc_path_for((0x0005_0010, 0x1000_400A)),
["code", "fw.img"],
)
}
pub async fn ppc_boot_dlf_path(&self) -> Result<PathBuf, CatBridgeError> {
let mut path = self.temp_path()?;
path.push("caferun");
if !path.exists() {
create_dir_all_sync(&path).map_err(FSError::from)?;
}
path.push("ppc_boot.dlf");
if !path.exists() {
let mut root_dlf = DiskLayoutFile::new(0x00B8_8200_u128);
root_dlf.upsert_addressed_path(0_u128, &self.disk_id_path().await?)?;
root_dlf.upsert_addressed_path(0x80000_u128, &self.boot1_sytstem_path().await?)?;
root_dlf.upsert_addressed_path(0x90000_u128, &self.firmware_file_path())?;
fs_write(&path, Bytes::from(root_dlf))
.await
.map_err(FSError::from)?;
}
Ok(path)
}
#[must_use]
pub fn path_allows_writes(&self, path: &Path) -> bool {
let lossy_path = path.to_string_lossy();
let trimmed_lossy_path = lossy_path
.trim_start_matches("/vol/pc")
.trim_start_matches('/');
if trimmed_lossy_path.starts_with("%DISC_EMU_DIR") {
return trimmed_lossy_path.starts_with("%DISC_EMU_DIR/save");
}
if path.starts_with(Self::join_many(&self.cafe_sdk_path, ["data", "disc"])) {
return path.starts_with(Self::join_many(
&self.cafe_sdk_path,
["data", "disc", "save"],
));
}
true
}
pub fn resolve_path(
&self,
potentially_prefixed_path: &str,
) -> Result<ResolvedLocation, CatBridgeError> {
let path = potentially_prefixed_path.trim_start_matches("/vol/pc");
if path.starts_with("/%NETWORK") {
todo!("NETWORK shares not yet implemented :( sorry!")
}
let non_canonical_path = if path.starts_with("/%MLC_EMU_DIR") {
self.replace_emu_dir(path, "mlc")
} else if path.starts_with("/%SLC_EMU_DIR") {
self.replace_emu_dir(path, "slc")
} else if path.starts_with("/%DISC_EMU_DIR") {
self.replace_emu_dir(path, "disc")
} else if path.starts_with("/%SAVE_EMU_DIR") {
self.replace_emu_dir(path, "save")
} else {
PathBuf::from(path)
};
let mut closest_canonical_directory = non_canonical_path.clone();
let mut changed_at_all = false;
while !closest_canonical_directory.as_os_str().is_empty() {
if let Ok(canonicalized) = closest_canonical_directory.canonicalize() {
closest_canonical_directory = canonicalized;
break;
}
changed_at_all = true;
closest_canonical_directory.pop();
}
if closest_canonical_directory.as_os_str().is_empty() {
return Err(PcfsApiError::PathNotMapped(path.to_owned()).into());
}
let canonicalized_cafe = self
.cafe_sdk_path()
.canonicalize()
.unwrap_or_else(|_| self.cafe_sdk_path().clone());
if !closest_canonical_directory.starts_with(canonicalized_cafe) {
return Err(PcfsApiError::PathNotMapped(path.to_owned()).into());
}
Ok(ResolvedLocation::Filesystem(FilesystemLocation::new(
non_canonical_path,
closest_canonical_directory,
!changed_at_all,
)))
}
pub fn create_directory(&self, at: &Path) -> Result<(), FSError> {
create_dir_all_sync(at).map_err(FSError::IO)
}
pub fn copy(&self, from: &Path, to: &Path) -> Result<(), FSError> {
if from.is_dir() {
Self::copy_dir(from, to)
} else {
copy_file_sync(from, to).map_err(FSError::IO).map(|_| ())
}
}
pub fn rename(&self, from: &Path, to: &Path) -> Result<(), FSError> {
if from.is_dir() {
Self::rename_dir(from, to)
} else {
rename_sync(from, to).map_err(FSError::IO)
}
}
#[must_use]
pub fn slc_path_for(&self, title_id: TitleID) -> PathBuf {
Self::join_many(
&self.cafe_sdk_path,
[
"data".to_owned(),
"slc".to_owned(),
"sys".to_owned(),
"title".to_owned(),
format!("{:08x}", title_id.0),
format!("{:08x}", title_id.1),
],
)
}
#[allow(
// Not actually unreachable unless on unsupported OS.
unreachable_code,
)]
#[must_use]
pub fn default_cafe_folder() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
return Some(PathBuf::from(r"C:\cafe_sdk"));
}
#[cfg(any(
target_os = "linux",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "macos"
))]
{
return Some(PathBuf::from("/opt/cafe_sdk"));
}
None
}
fn temp_path(&self) -> Result<PathBuf, FSError> {
let temp_path = Self::join_many(
&self.cafe_sdk_path,
["temp".to_owned(), username().to_lowercase()],
);
if !temp_path.exists() {
create_dir_all_sync(&temp_path)?;
}
Ok(temp_path)
}
#[must_use]
fn join_many<PathTy, IterTy>(base: &Path, parts: IterTy) -> PathBuf
where
PathTy: AsRef<Path>,
IterTy: IntoIterator<Item = PathTy>,
{
let mut as_owned = PathBuf::from(base);
for part in parts {
as_owned = as_owned.join(part.as_ref());
}
as_owned
}
fn replace_emu_dir(&self, path: &str, dir: &str) -> PathBuf {
let path_minus = path
.trim_start_matches(&format!("/%{}_EMU_DIR", dir.to_ascii_uppercase()))
.trim_start_matches('/')
.trim_start_matches('\\')
.replace('\\', "/");
Self::join_many(
&Self::join_many(self.cafe_sdk_path(), ["data", dir]),
path_minus.split('/'),
)
}
async fn patch_case_sensitivity(cafe_sdk_path: &Path) -> Result<(), FSError> {
if !cafe_sdk_path.exists() {
return Ok(());
}
let capital_path = Self::join_many(cafe_sdk_path, ["InsensitiveCheck.txt"]);
let _ = File::create(&capital_path).await?;
let is_insensitive = File::open(Self::join_many(cafe_sdk_path, ["insensitivecheck.txt"]))
.await
.is_ok();
remove_file_sync(capital_path)?;
if is_insensitive {
return Ok(());
}
info!(
"Your Host OS is not case-insensitive for file-paths... ensuring CafeSDK is all lowercase, this may take awhile..."
);
let cafe_sdk_components = cafe_sdk_path.components().count();
let mut had_rename = true;
while had_rename {
had_rename = false;
for directory in [
Self::join_many(cafe_sdk_path, ["data", "slc", "sys", "title"]),
Self::join_many(cafe_sdk_path, ["data", "slc", "usr", "title"]),
Self::join_many(cafe_sdk_path, ["data", "mlc", "sys", "title"]),
Self::join_many(cafe_sdk_path, ["data", "mlc", "usr", "title"]),
] {
if !directory.exists() {
continue;
}
let mut iter = WalkDir::new(&directory)
.contents_first(false)
.follow_links(false)
.follow_root_links(false)
.into_iter();
while let Some(Ok(entry)) = iter.next() {
let p = entry.path();
if !p.exists() {
continue;
}
let path_minus_cafe = p
.components()
.skip(cafe_sdk_components)
.collect::<PathBuf>();
let Some(path_as_utf8) = path_minus_cafe.as_os_str().to_str() else {
warn!(problematic_path = %p.display(), "Path in Cafe SDK directory is not UTF-8! This may cause errors fetching!");
continue;
};
let new_path = path_as_utf8.to_ascii_lowercase();
if path_as_utf8 != new_path {
let mut final_new_path = cafe_sdk_path.as_os_str().to_owned();
final_new_path.push("/");
final_new_path.push(&new_path);
let new = PathBuf::from(final_new_path);
if p.is_dir() {
Self::rename_dir(p, &new)?;
had_rename = true;
} else {
rename_sync(p, new)?;
had_rename = true;
}
}
}
}
}
info!("ensure CafeSDK path is now case-insensitive by renaming to all lowercase...");
Ok(())
}
fn allow_file_access(
entry: &CMOccupiedEntry<i32, (File, u64, PathBuf, Option<u64>), RandomState>,
requester: Option<u64>,
) -> bool {
let Some(requesting_stream_id) = requester else {
return true;
};
let Some(owned_stream_id) = entry.3 else {
return true;
};
requesting_stream_id == owned_stream_id
}
#[allow(
// TODO(mythra): fix
clippy::type_complexity
)]
fn allow_folder_access(
entry: &CMOccupiedEntry<
i32,
(Vec<DirEntry>, usize, bool, PathBuf, Option<u64>),
RandomState,
>,
requester: Option<u64>,
) -> bool {
let Some(requesting_stream_id) = requester else {
return true;
};
let Some(owned_stream_id) = entry.4 else {
return true;
};
requesting_stream_id == owned_stream_id
}
async fn prepare_for_serving(cafe_sdk_path: &Path) -> Result<(), FSError> {
if !Self::join_many(cafe_sdk_path, ["data", "slc", "sys", "config", "eco.xml"]).exists() {
Self::generate_eco_xml(cafe_sdk_path).await?;
}
if !Self::join_many(
cafe_sdk_path,
["data", "slc", "sys", "proc", "prefs", "wii_acct.xml"],
)
.exists()
{
Self::generate_wii_acct_xml(cafe_sdk_path).await?;
}
if Self::join_many(cafe_sdk_path, ["data", "disc"]).exists() {
remove_dir_all_sync(Self::join_many(cafe_sdk_path, ["data", "disc"]))
.map_err(FSError::IO)?;
}
let disc_dir = Self::join_many(cafe_sdk_path, ["data", "disc"]);
let sctt_dir = Self::join_many(
cafe_sdk_path,
["data", "mlc", "sys", "title", "00050010", "1f700500"],
);
for subpath in ["code", "content", "meta"] {
Self::copy_dir(
&Self::join_many(&sctt_dir, [subpath]),
&Self::join_many(&disc_dir, [subpath]),
)?;
}
let app_xml_path = Self::join_many(cafe_sdk_path, ["data", "disc", "code", "app.xml"]);
let base_app_xml = String::from_utf8_lossy(&fs_read(&app_xml_path).await?).to_string();
fs_write(&app_xml_path, Self::capitilize_title_id(base_app_xml)).await?;
Ok(())
}
fn copy_dir(source_path: &Path, dest_path: &Path) -> Result<(), FSError> {
if !dest_path.exists() {
create_dir_all_sync(dest_path)?;
}
let new_path_as_str_bytes = dest_path.as_os_str().as_encoded_bytes();
let old_path_bytes = source_path.as_os_str().as_encoded_bytes();
for result in WalkDir::new(source_path)
.follow_links(false)
.follow_root_links(false)
{
let rpb = result?.into_path();
let os_str_for_entry = rpb.as_os_str().as_encoded_bytes();
let mut new_bytes = Vec::with_capacity(os_str_for_entry.len() + 3);
new_bytes.extend_from_slice(new_path_as_str_bytes);
new_bytes.extend_from_slice(&os_str_for_entry[old_path_bytes.len()..]);
let as_new_path =
PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(new_bytes) });
if rpb.is_symlink() {
let mut resolved_path = read_link_sync(&rpb)?;
{
let os_str_for_resolved = resolved_path.as_os_str().as_encoded_bytes();
if os_str_for_resolved.starts_with(old_path_bytes) {
let mut new_bytes = Vec::with_capacity(os_str_for_resolved.len() + 3);
new_bytes.extend_from_slice(new_path_as_str_bytes);
new_bytes.extend_from_slice(&os_str_for_entry[old_path_bytes.len()..]);
resolved_path = PathBuf::from(unsafe {
OsString::from_encoded_bytes_unchecked(new_bytes)
});
}
}
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(resolved_path, &as_new_path)?;
}
#[cfg(target_os = "windows")]
{
use std::os::windows::fs::{symlink_dir, symlink_file};
if resolved_path.is_dir() {
symlink_dir(resolved_path, &as_new_path)?;
} else {
symlink_file(resolved_path, &as_new_path)?;
}
}
} else if rpb.is_file() {
copy_file_sync(&rpb, &as_new_path)?;
} else if rpb.is_dir() {
create_dir_all_sync(&as_new_path)?;
}
}
Ok(())
}
fn rename_dir(source_path: &Path, dest_path: &Path) -> Result<(), FSError> {
if !dest_path.exists() {
create_dir_all_sync(dest_path)?;
}
let new_path_as_str_bytes = dest_path.as_os_str().as_encoded_bytes();
let old_path_bytes = source_path.as_os_str().as_encoded_bytes();
for result in WalkDir::new(source_path)
.follow_links(false)
.follow_root_links(false)
{
let rpb = result?.into_path();
let os_str_for_entry = rpb.as_os_str().as_encoded_bytes();
let mut new_bytes = Vec::with_capacity(os_str_for_entry.len() + 3);
new_bytes.extend_from_slice(new_path_as_str_bytes);
new_bytes.extend_from_slice(&os_str_for_entry[old_path_bytes.len()..]);
let as_new_path =
PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(new_bytes) });
if rpb.is_symlink() {
let mut resolved_path = read_link_sync(&rpb)?;
{
let os_str_for_resolved = resolved_path.as_os_str().as_encoded_bytes();
if os_str_for_resolved.starts_with(old_path_bytes) {
let mut new_bytes = Vec::with_capacity(os_str_for_resolved.len() + 3);
new_bytes.extend_from_slice(new_path_as_str_bytes);
new_bytes.extend_from_slice(&os_str_for_entry[old_path_bytes.len()..]);
resolved_path = PathBuf::from(unsafe {
OsString::from_encoded_bytes_unchecked(new_bytes)
});
}
}
let should_remove: bool;
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(resolved_path, &as_new_path)?;
should_remove = true;
}
#[cfg(target_os = "windows")]
{
use std::os::windows::fs::{symlink_dir, symlink_file};
if resolved_path.is_dir() {
symlink_dir(resolved_path, &as_new_path)?;
should_remove = false;
} else {
symlink_file(resolved_path, &as_new_path)?;
should_remove = true;
}
}
if should_remove {
remove_file_sync(&rpb)?;
}
} else if rpb.is_file() {
rename_sync(&rpb, &as_new_path)?;
} else if rpb.is_dir() {
create_dir_all_sync(&as_new_path)?;
}
}
remove_dir_all_sync(source_path)?;
Ok(())
}
async fn generate_eco_xml(cafe_os_path: &Path) -> Result<(), FSError> {
let mut eco_path = Self::join_many(cafe_os_path, ["data", "slc", "sys", "config"]);
if !eco_path.exists() {
create_dir_all_sync(&eco_path).map_err(FSError::IO)?;
}
eco_path.push("eco.xml");
let mut eco_file = File::create(eco_path).await.map_err(FSError::IO)?;
eco_file
.write_all(
br#"<?xml version="1.0" encoding="utf-8"?>
<eco type="complex" access="777">
<enable type="unsignedInt" length="4">0</enable>
<max_on_time type="unsignedInt" length="4">3601</max_on_time>
<default_off_time type="unsignedInt" length="4">15</default_off_time>
<wd_disable type="unsignedInt" length="4">1</wd_disable>
</eco>"#,
)
.await
.map_err(FSError::IO)?;
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::prelude::*};
eco_file
.set_permissions(Permissions::from_mode(0o770))
.await?;
}
Ok(())
}
async fn generate_wii_acct_xml(cafe_os_path: &Path) -> Result<(), FSError> {
let mut wii_path = Self::join_many(cafe_os_path, ["data", "slc", "sys", "proc", "prefs"]);
if !wii_path.exists() {
create_dir_all_sync(&wii_path).map_err(FSError::IO)?;
}
wii_path.push("wii_acct.xml");
let mut wii_file = File::create(wii_path).await.map_err(FSError::IO)?;
wii_file
.write_all(
br#"<?xml version="1.0" encoding="utf-8"?>
<wii_acct type="complex">
<profile type="complex">
<nickname type="hexBinary" length="22">00570069006900000000000000000000000000000000</nickname>
<language type="unsignedInt" length="4">0</language>
<country type="unsignedInt" length="4">1</country>
</profile>
<pc type="complex">
<rating type="unsignedInt" length="4">18</rating>
<organization type="unsignedInt" length="4">0</organization>
<rst_internet_ch type="unsignedByte" length="1">0</rst_internet_ch>
<rst_nw_access type="unsignedByte" length="1">0</rst_nw_access>
<rst_pt_order type="unsignedByte" length="1">0</rst_pt_order>
</pc>
</wii_acct>"#,
)
.await
.map_err(FSError::IO)?;
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::prelude::*};
wii_file
.set_permissions(Permissions::from_mode(0o770))
.await?;
}
Ok(())
}
#[must_use]
fn capitilize_title_id(app_xml: String) -> String {
let Some(title_id_xml_tag_start) = app_xml.find("<title_id") else {
return app_xml;
};
let Some(title_id_tag_end) = app_xml[title_id_xml_tag_start..].find('>') else {
return app_xml;
};
let tid_start = title_id_xml_tag_start + title_id_tag_end;
let Some(title_slash_location) = app_xml[tid_start..].find("</title_id>") else {
return app_xml;
};
let tid_end = tid_start + title_slash_location;
let title_id = &app_xml[tid_start..tid_end];
let mut final_xml = String::with_capacity(app_xml.len());
final_xml += &app_xml[..tid_start];
final_xml += &title_id.to_uppercase();
final_xml += &app_xml[tid_end..];
final_xml
}
fn get_default_read_only_folders(cafe_dir: &Path) -> ConcurrentSet<PathBuf> {
let set = ConcurrentSet::new();
for cafe_sub_paths in [
&["data", "slc", "sys", "config"] as &[&str],
&["data", "slc", "sys", "proc"],
&["data", "slc", "sys", "logs"],
&["data", "mlc", "usr"],
&["data", "mlc", "usr", "import"],
&["data", "mlc", "usr", "title"],
] {
_ = set.insert_sync(Self::join_many(cafe_dir, cafe_sub_paths));
}
set
}
}
const HOST_FILESYSTEM_FIELDS: &[NamedField<'static>] = &[
NamedField::new("cafe_sdk_path"),
NamedField::new("open_file_handles"),
NamedField::new("open_folder_handles"),
];
impl Structable for HostFilesystem {
fn definition(&self) -> StructDef<'_> {
StructDef::new_static("HostFilesystem", Fields::Named(HOST_FILESYSTEM_FIELDS))
}
}
impl Valuable for HostFilesystem {
fn as_value(&self) -> Value<'_> {
Value::Structable(self)
}
fn visit(&self, visitor: &mut dyn Visit) {
let mut values = HashMap::with_capacity(self.open_file_handles.len());
self.open_file_handles.iter_sync(|k, v| {
values.insert(*k, format!("{}", v.2.display()));
true
});
let mut folder_values = HashMap::with_capacity(self.open_folder_handles.len());
self.open_folder_handles.iter_sync(|k, v| {
folder_values.insert(*k, format!("{}", v.3.display()));
true
});
visitor.visit_named_fields(&NamedValues::new(
HOST_FILESYSTEM_FIELDS,
&[
Valuable::as_value(&self.cafe_sdk_path),
Valuable::as_value(&values),
Valuable::as_value(&folder_values),
],
));
}
}
#[derive(Clone, Debug, PartialEq, Eq, Valuable)]
pub enum ResolvedLocation {
Filesystem(FilesystemLocation),
Network(()),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FilesystemLocation {
resolved_path: PathBuf,
closest_resolved_path: PathBuf,
canonicalized_is_exact: bool,
}
impl FilesystemLocation {
#[must_use]
pub const fn new(
resolved_path: PathBuf,
closest_resolved_path: PathBuf,
canonicalized_is_exact: bool,
) -> Self {
Self {
resolved_path,
closest_resolved_path,
canonicalized_is_exact,
}
}
#[must_use]
pub const fn resolved_path(&self) -> &PathBuf {
&self.resolved_path
}
#[must_use]
pub const fn closest_resolved_path(&self) -> &PathBuf {
&self.closest_resolved_path
}
#[must_use]
pub const fn canonicalized_is_exact(&self) -> bool {
self.canonicalized_is_exact
}
}
const FILESYSTEM_LOCATION_FIELDS: &[NamedField<'static>] = &[
NamedField::new("resolved_path"),
NamedField::new("closest_resolved_path"),
NamedField::new("canonicalized_is_exact"),
];
impl Structable for FilesystemLocation {
fn definition(&self) -> StructDef<'_> {
StructDef::new_static(
"FilesystemLocation",
Fields::Named(FILESYSTEM_LOCATION_FIELDS),
)
}
}
impl Valuable for FilesystemLocation {
fn as_value(&self) -> Value<'_> {
Value::Structable(self)
}
fn visit(&self, visitor: &mut dyn Visit) {
visitor.visit_named_fields(&NamedValues::new(
FILESYSTEM_LOCATION_FIELDS,
&[
Valuable::as_value(&self.resolved_path),
Valuable::as_value(&self.closest_resolved_path),
Valuable::as_value(&self.canonicalized_is_exact),
],
));
}
}
#[cfg_attr(docsrs, doc(cfg(test)))]
#[cfg(test)]
pub mod test_helpers {
use super::*;
use std::fs::{File, create_dir_all};
use tempfile::{TempDir, tempdir};
#[allow(
// Allow anyone to write a test for this internally on any feature set.
dead_code,
)]
pub async fn create_temporary_host_filesystem() -> (TempDir, HostFilesystem) {
let dir = tempdir().expect("Failed to create temporary directory!");
for directory_to_create in vec![
vec!["data", "slc"],
vec!["data", "mlc"],
vec!["data", "disc"],
vec!["data", "save"],
vec![
"data", "mlc", "sys", "title", "00050030", "1001000a", "code",
],
vec![
"data", "mlc", "sys", "title", "00050010", "1f700500", "code",
],
vec![
"data", "mlc", "sys", "title", "00050010", "1f700500", "content",
],
vec![
"data", "mlc", "sys", "title", "00050010", "1f700500", "meta",
],
vec![
"data", "mlc", "sys", "title", "00050030", "1001010A", "code",
],
vec![
"data", "mlc", "sys", "title", "00050030", "1001020a", "code",
],
vec![
"data", "slc", "sys", "title", "00050010", "1000400a", "code",
],
vec!["data", "mlc", "sys", "update", "nand", "os_v10_ndebug"],
vec!["data", "mlc", "sys", "update", "nand", "os_v10_debug"],
vec!["data", "slc", "sys", "proc", "prefs"],
vec![
"data", "slc", "sys", "title", "00050010", "1000800a", "code",
],
vec![
"data", "slc", "sys", "title", "00050010", "1000400a", "code",
],
] {
create_dir_all(HostFilesystem::join_many(dir.path(), directory_to_create))
.expect("Failed to create directories necessary for host filesystem to work.");
}
File::create(HostFilesystem::join_many(
dir.path(),
[
"data", "mlc", "sys", "title", "00050030", "1001000a", "code", "app.xml",
],
))
.expect("Failed to create needed app.xml!");
File::create(HostFilesystem::join_many(
dir.path(),
[
"data", "mlc", "sys", "title", "00050030", "1001010A", "code", "app.xml",
],
))
.expect("Failed to create needed app.xml!");
File::create(HostFilesystem::join_many(
dir.path(),
[
"data", "mlc", "sys", "title", "00050030", "1001020a", "code", "app.xml",
],
))
.expect("Failed to create needed app.xml!");
File::create(HostFilesystem::join_many(
dir.path(),
[
"data", "slc", "sys", "title", "00050010", "1000400a", "code", "fw.img",
],
))
.expect("Failed to create needed fw.img!");
File::create(HostFilesystem::join_many(
dir.path(),
[
"data", "mlc", "sys", "title", "00050010", "1f700500", "code", "app.xml",
],
))
.expect("Failed to create needed app.xml for disc!");
let fs = HostFilesystem::from_cafe_dir(Some(PathBuf::from(dir.path())))
.await
.expect("Failed to load empty host filesystem!");
(dir, fs)
}
#[allow(
// Allow anyone to write a test for this internally on any feature set.
dead_code,
)]
#[must_use]
pub fn join_many<PathTy, IterTy>(base: &Path, parts: IterTy) -> PathBuf
where
PathTy: AsRef<Path>,
IterTy: IntoIterator<Item = PathTy>,
{
HostFilesystem::join_many(base, parts)
}
}
#[cfg(test)]
mod unit_tests {
use super::test_helpers::*;
use super::*;
use std::fs::read;
fn only_accepts_send_sync<T: Send + Sync>(_opt: Option<T>) {}
#[test]
pub fn is_send_sync() {
only_accepts_send_sync::<HostFilesystem>(None);
}
#[test]
pub fn can_find_default_cafe_directory() {
assert!(
HostFilesystem::default_cafe_folder().is_some(),
"Failed to find default cafe directory for your OS",
);
}
#[tokio::test]
pub async fn creatable_files() {
let (tempdir, fs) = create_temporary_host_filesystem().await;
let expected_bsf_path = HostFilesystem::join_many(
tempdir.path(),
[
"temp".to_owned(),
username().to_lowercase(),
"caferun".to_owned(),
"ppc.bsf".to_owned(),
],
);
assert!(
!expected_bsf_path.exists(),
"ppc.bsf existed before we asked for it?"
);
let bsf_path = fs
.boot1_sytstem_path()
.await
.expect("Failed to create bsf!");
assert_eq!(expected_bsf_path, bsf_path);
assert!(
BootSystemFile::try_from(Bytes::from(
read(bsf_path).expect("Failed to read written boot system file!")
))
.is_ok(),
"Failed to read generated boot system file!"
);
let expected_diskid_path = HostFilesystem::join_many(
tempdir.path(),
[
"temp".to_owned(),
username().to_lowercase(),
"caferun".to_owned(),
"diskid.bin".to_owned(),
],
);
assert!(
!expected_diskid_path.exists(),
"diskid.bin existed before we asked for it?"
);
let diskid_path = fs
.disk_id_path()
.await
.expect("Failed to create diskid.bin!");
assert_eq!(expected_diskid_path, diskid_path);
assert_eq!(
read(diskid_path).expect("Failed to read written diskid.bin!"),
vec![0; 32],
"Failed to read generated diskid.bin!"
);
assert_eq!(
fs.firmware_file_path(),
HostFilesystem::join_many(
tempdir.path(),
[
"data", "slc", "sys", "title", "00050010", "1000400a", "code", "fw.img"
],
),
);
let expected_ppc_boot_dlf_path = HostFilesystem::join_many(
tempdir.path(),
[
"temp".to_owned(),
username().to_lowercase(),
"caferun".to_owned(),
"ppc_boot.dlf".to_owned(),
],
);
assert!(
!expected_ppc_boot_dlf_path.exists(),
"ppc_boot.dlf existed before we asked for it?"
);
let ppc_boot_dlf_path = fs
.ppc_boot_dlf_path()
.await
.expect("Failed to create ppc_boot.dlf!");
assert_eq!(expected_ppc_boot_dlf_path, ppc_boot_dlf_path);
assert!(
DiskLayoutFile::try_from(Bytes::from(
read(ppc_boot_dlf_path).expect("Failed to read written ppc_boot.dlf!")
))
.is_ok(),
"Failed to read generated ppc_boot.dlf!"
);
}
#[tokio::test]
pub async fn path_allows_writes() {
let (_tempdir, fs) = create_temporary_host_filesystem().await;
assert!(fs.path_allows_writes(&PathBuf::from("/vol/pc/%MLC_EMU_DIR/")));
assert!(fs.path_allows_writes(&PathBuf::from("/vol/pc/%SLC_EMU_DIR/")));
assert!(fs.path_allows_writes(&PathBuf::from("/vol/pc/%SAVE_EMU_DIR/")));
assert!(!fs.path_allows_writes(&PathBuf::from("/vol/pc/%DISC_EMU_DIR/")));
assert!(!fs.path_allows_writes(&PathBuf::from("/vol/pc/%DISC_EMU_DIR/")));
}
#[tokio::test]
pub async fn resolve_path() {
let (tempdir, fs) = create_temporary_host_filesystem().await;
for (dir, name) in [
("/%MLC_EMU_DIR", "mlc"),
("/%SLC_EMU_DIR", "slc"),
("/%DISC_EMU_DIR", "disc"),
("/%SAVE_EMU_DIR", "save"),
] {
assert!(
fs.resolve_path(&format!("{dir}")).is_ok(),
"Failed to resolve: `{}`: {:?}",
dir,
fs.resolve_path(&format!("{dir}"))
);
assert!(
fs.resolve_path(&format!("{dir}/")).is_ok(),
"Failed to resolve: `{}/`",
dir,
);
assert!(
fs.resolve_path(&format!("{dir}/./")).is_ok(),
"Failed to resolve: `{}/./`",
dir,
);
assert!(
fs.resolve_path(&format!("{dir}/../{name}")).is_ok(),
"Failed to resolve: `{}/../{}`",
dir,
name,
);
}
let mut out_of_path = PathBuf::from(tempdir.path());
out_of_path.pop();
assert!(
fs.resolve_path(
&out_of_path
.clone()
.into_os_string()
.into_string()
.expect("Failed to convert pathbuf to string!")
)
.is_err()
);
assert!(fs.resolve_path("/%MLC_EMU_DIR/../../../").is_err());
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let mut tempdir_symlink = PathBuf::from(tempdir.path());
tempdir_symlink.push("symlink");
symlink(out_of_path, tempdir_symlink.clone()).expect("Failed to do symlink!");
assert!(
fs.resolve_path(&format!(
"{}/symlink",
tempdir_symlink
.into_os_string()
.into_string()
.expect("tempdir symlink wasn't utf8?"),
))
.is_err()
);
}
#[cfg(target_os = "windows")]
{
use std::os::windows::fs::symlink_dir;
let mut tempdir_symlink = PathBuf::from(tempdir.path());
tempdir_symlink.push("symlink");
symlink_dir(out_of_path, tempdir_symlink.clone()).expect("Failed to do symlink!");
assert!(
fs.resolve_path(&format!(
"{}/symlink",
tempdir_symlink
.into_os_string()
.into_string()
.expect("tempdir symlink wasn't utf8?"),
))
.is_err()
);
}
}
#[tokio::test]
pub async fn opening_files() {
let (tempdir, fs) = create_temporary_host_filesystem().await;
let path = HostFilesystem::join_many(tempdir.path(), ["file.txt"]);
tokio::fs::write(path.clone(), vec![0; 1307])
.await
.expect("Failed to write test file!");
let create_path = HostFilesystem::join_many(tempdir.path(), ["new-file.txt"]);
let mut oo = OpenOptions::new();
oo.create(false).write(true).read(true);
assert!(
fs.open_file(oo, &create_path, None).await.is_err(),
"Somehow succeeding opening a file that doesn't exist with no create flag?",
);
oo = OpenOptions::new();
oo.create(true).write(true).truncate(true);
let fd = fs
.open_file(oo, &create_path, None)
.await
.expect("Failed opening a file that doesn't exist with a create flag?");
assert!(
fs.open_file_handles.len() == 1 && fs.open_file_handles.get_sync(&fd).is_some(),
"Open file wasn't in open files list!",
);
fs.close_file(fd, None).await;
assert!(
fs.open_file_handles.is_empty(),
"Somehow after opening/closing, open file handles was not empty?",
);
}
#[tokio::test]
pub async fn seek_and_read() {
let (tempdir, fs) = create_temporary_host_filesystem().await;
let path = HostFilesystem::join_many(tempdir.path(), ["file.txt"]);
tokio::fs::write(path.clone(), vec![0; 1307])
.await
.expect("Failed to write test file!");
let mut oo = OpenOptions::new();
oo.read(true).create(false).write(false);
let fd = fs
.open_file(oo, &path, None)
.await
.expect("Failed to open existing file!");
assert_eq!(
Some(BytesMut::zeroed(1307).freeze()),
fs.read_file(fd, 1307, None)
.await
.expect("Failed to read from FD!"),
);
fs.seek_file(fd, true, None)
.await
.expect("Failed to sync to beginning of file!");
assert_eq!(
Some(BytesMut::zeroed(1307).freeze()),
fs.read_file(fd, 1307, None)
.await
.expect("Failed to read from FD!"),
);
fs.close_file(fd, None).await;
assert!(
fs.open_file_handles.is_empty(),
"Somehow after opening/closing, open file handles was not empty?",
);
}
#[tokio::test]
pub async fn open_and_close_folder() {
let (tempdir, fs) = create_temporary_host_filesystem().await;
let path = HostFilesystem::join_many(tempdir.path(), ["a", "b"]);
tokio::fs::create_dir_all(path.clone())
.await
.expect("Failed to create test directory!");
let fd = fs
.open_folder(&path, None)
.expect("Failed to open existing folder!");
assert!(
fs.open_folder_handles.len() == 1,
"Expected one open folder handle",
);
fs.close_folder(fd, None).await;
assert!(
fs.open_folder_handles.is_empty(),
"Somehow after opening/closing, open folder handles was not empty?",
);
}
#[tokio::test]
pub async fn seek_within_folder() {
let (tempdir, fs) = create_temporary_host_filesystem().await;
let path = HostFilesystem::join_many(tempdir.path(), ["a", "b"]);
tokio::fs::create_dir_all(path.clone())
.await
.expect("Failed to create test directory!");
_ = tokio::fs::File::create(HostFilesystem::join_many(&path, ["c"]))
.await
.expect("Failed to create file to use!");
tokio::fs::create_dir(HostFilesystem::join_many(&path, ["d"]))
.await
.expect("Failed to create directory to use!");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let mut tempdir_symlink = path.clone();
tempdir_symlink.push("e");
symlink(tempdir.path(), tempdir_symlink).expect("Failed to do symlink!");
}
#[cfg(target_os = "windows")]
{
use std::os::windows::fs::symlink_dir;
let mut tempdir_symlink = path.clone();
tempdir_symlink.push("e");
symlink_dir(tempdir.path(), tempdir_symlink).expect("Failed to do symlink!");
}
_ = tokio::fs::File::create(HostFilesystem::join_many(&path, ["f"]))
.await
.expect("Failed to create file to use!");
_ = tokio::fs::File::create(HostFilesystem::join_many(&path, ["d", "a"]))
.await
.expect("Failed to create file to use!");
let dfd = fs.open_folder(&path, None).expect("Failed to open folder!");
assert!(
fs.next_in_folder(dfd, None)
.await
.expect("Failed to query for next in folder! 1.1!")
.is_some()
);
assert!(
fs.next_in_folder(dfd, None)
.await
.expect("Failed to query for next in folder! 1.2!")
.is_some()
);
assert!(
fs.next_in_folder(dfd, None)
.await
.expect("Failed to query for next in folder! 1.3!")
.is_some()
);
assert!(
fs.next_in_folder(dfd, None)
.await
.expect("Failed to query for next in folder! 1.4!")
.is_none()
);
assert!(
fs.next_in_folder(dfd, None)
.await
.expect("Failed to query for next in folder! 1.5!")
.is_none()
);
fs.reverse_folder(dfd, None)
.await
.expect("Failed to reverse directory search!");
assert!(
fs.next_in_folder(dfd, None)
.await
.expect("Failed to query for next in folder! 2.1!")
.is_some()
);
assert!(
fs.next_in_folder(dfd, None)
.await
.expect("Failed to query for next in folder! 2.2!")
.is_none()
);
}
#[test]
pub fn can_capitilize_ids() {
assert_eq!(
HostFilesystem::capitilize_title_id(
r#"<?xml version="1.0" encoding = "utf-8"?>
<app type="complex" access="777">
<version type="unsignedInt" length="4">16</version>
<os_version type="hexBinary" length="8">000500101000400A</os_version>
<title_id type="hexBinary" length="8">000500101f700500</title_id>
<title_version type="hexBinary" length="2">090D</title_version>
<sdk_version type="unsignedInt" length="4">21213</sdk_version>
<app_type type="hexBinary" length="4">90000001</app_type>
<group_id type="hexBinary" length="4">00000400</group_id>
<os_mask type="hexBinary" length="32">0</os_mask>
<common_id type="hexBinary" length="8">0000000000000000</common_id>
</app>"#
.to_owned()
),
r#"<?xml version="1.0" encoding = "utf-8"?>
<app type="complex" access="777">
<version type="unsignedInt" length="4">16</version>
<os_version type="hexBinary" length="8">000500101000400A</os_version>
<title_id type="hexBinary" length="8">000500101F700500</title_id>
<title_version type="hexBinary" length="2">090D</title_version>
<sdk_version type="unsignedInt" length="4">21213</sdk_version>
<app_type type="hexBinary" length="4">90000001</app_type>
<group_id type="hexBinary" length="4">00000400</group_id>
<os_mask type="hexBinary" length="32">0</os_mask>
<common_id type="hexBinary" length="8">0000000000000000</common_id>
</app>"#
.to_owned(),
);
assert_eq!(
HostFilesystem::capitilize_title_id(
r#"<?xml version="1.0" encoding = "utf-8"?>
<app type="complex" access="777">
<version type="unsignedInt" length="4">16</version>
<os_version type="hexBinary" length="8">000500101000400A</os_version>
<title_id type="hexBinary" length="8">000500101F700500</title_id>
<title_version type="hexBinary" length="2">090D</title_version>
<sdk_version type="unsignedInt" length="4">21213</sdk_version>
<app_type type="hexBinary" length="4">90000001</app_type>
<group_id type="hexBinary" length="4">00000400</group_id>
<os_mask type="hexBinary" length="32">0</os_mask>
<common_id type="hexBinary" length="8">0000000000000000</common_id>
</app>"#
.to_owned()
),
r#"<?xml version="1.0" encoding = "utf-8"?>
<app type="complex" access="777">
<version type="unsignedInt" length="4">16</version>
<os_version type="hexBinary" length="8">000500101000400A</os_version>
<title_id type="hexBinary" length="8">000500101F700500</title_id>
<title_version type="hexBinary" length="2">090D</title_version>
<sdk_version type="unsignedInt" length="4">21213</sdk_version>
<app_type type="hexBinary" length="4">90000001</app_type>
<group_id type="hexBinary" length="4">00000400</group_id>
<os_mask type="hexBinary" length="32">0</os_mask>
<common_id type="hexBinary" length="8">0000000000000000</common_id>
</app>"#
.to_owned(),
);
assert_eq!(
HostFilesystem::capitilize_title_id(
r#"<?xml version="1.0" encoding = "utf-8"?>
<app type="complex" access="777">
<version type="unsignedInt" length="4">16</version>
<os_version type="hexBinary" length="8">000500101000400A</os_version>
<title_version type="hexBinary" length="2">090D</title_version>
<sdk_version type="unsignedInt" length="4">21213</sdk_version>
<app_type type="hexBinary" length="4">90000001</app_type>
<group_id type="hexBinary" length="4">00000400</group_id>
<os_mask type="hexBinary" length="32">0</os_mask>
<common_id type="hexBinary" length="8">0000000000000000</common_id>
</app>"#
.to_owned()
),
r#"<?xml version="1.0" encoding = "utf-8"?>
<app type="complex" access="777">
<version type="unsignedInt" length="4">16</version>
<os_version type="hexBinary" length="8">000500101000400A</os_version>
<title_version type="hexBinary" length="2">090D</title_version>
<sdk_version type="unsignedInt" length="4">21213</sdk_version>
<app_type type="hexBinary" length="4">90000001</app_type>
<group_id type="hexBinary" length="4">00000400</group_id>
<os_mask type="hexBinary" length="32">0</os_mask>
<common_id type="hexBinary" length="8">0000000000000000</common_id>
</app>"#
.to_owned(),
);
assert_eq!(
HostFilesystem::capitilize_title_id(r#"<?xml version="1.0" encoding = "utf-8"?>
<app type="complex" access="777">
<version type="unsignedInt" length="4">16</version>
<os_version type="hexBinary" length="8">000500101000400A</os_version><title_id type="hexBinary" length="8">000500101f700500</title_id><title_version type="hexBinary" length="2">090D</title_version>
<sdk_version type="unsignedInt" length="4">21213</sdk_version>
<app_type type="hexBinary" length="4">90000001</app_type>
<group_id type="hexBinary" length="4">00000400</group_id>
<os_mask type="hexBinary" length="32">0</os_mask>
<common_id type="hexBinary" length="8">0000000000000000</common_id>
</app>"#.to_owned()),
r#"<?xml version="1.0" encoding = "utf-8"?>
<app type="complex" access="777">
<version type="unsignedInt" length="4">16</version>
<os_version type="hexBinary" length="8">000500101000400A</os_version><title_id type="hexBinary" length="8">000500101F700500</title_id><title_version type="hexBinary" length="2">090D</title_version>
<sdk_version type="unsignedInt" length="4">21213</sdk_version>
<app_type type="hexBinary" length="4">90000001</app_type>
<group_id type="hexBinary" length="4">00000400</group_id>
<os_mask type="hexBinary" length="32">0</os_mask>
<common_id type="hexBinary" length="8">0000000000000000</common_id>
</app>"#.to_owned(),
);
}
}