use crate::{
errors::{CatBridgeError, FSError, NetworkError},
fsemul::errors::FSEmulAPIError,
};
use bytes::{Bytes, BytesMut};
use fnv::{FnvHashSet, FnvHasher};
use reqwest::{Client, ClientBuilder, Url};
use sachet::{
common::{CafeContentFileInformation, CafeContentFilesystemTree},
content::decrypt,
title::{
TitleID,
key_generation::title_key_guesses,
metadata::{TitleMetadata, contents::ContentRecord},
ticket::Ticket,
},
};
use std::{
hash::{Hash, Hasher},
path::{Path, PathBuf},
};
use tracing::{debug, error, info, warn};
use valuable::{Fields, NamedField, NamedValues, StructDef, Structable, Valuable, Value, Visit};
#[derive(Clone, Debug)]
pub struct NUSFuse {
base_url: Url,
client: Client,
nus_hash: String,
root_directory: PathBuf,
title_ids: FnvHashSet<TitleID>,
}
impl NUSFuse {
pub fn new(
nus_url: &str,
root_directory: PathBuf,
title_ids: FnvHashSet<TitleID>,
) -> Result<Self, FSEmulAPIError> {
let client = ClientBuilder::new()
.user_agent(concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
))
.brotli(true)
.deflate(true)
.gzip(true)
.zstd(true)
.build()?;
let base_url = Url::parse(nus_url)?;
let nus_hash = {
let mut hasher = FnvHasher::with_key(0x69420);
nus_url.hash(&mut hasher);
format!("{:016x}", hasher.finish())
};
Ok(Self {
base_url,
client,
nus_hash,
root_directory,
title_ids,
})
}
#[must_use]
pub fn get_sorted_group_ids(&self) -> Vec<u32> {
let mut group_ids = Vec::new();
for title in &self.title_ids {
if !group_ids.contains(&title.group_id()) {
group_ids.push(title.group_id());
}
}
group_ids.sort_by(|a, b| format!("{a:08x}").cmp(&format!("{b:08x}")));
group_ids
}
#[must_use]
pub fn get_sorted_tids_in_group(&self, group_id: u32) -> Vec<u32> {
let mut tids = Vec::new();
for title in &self.title_ids {
if title.group_id() == group_id && !tids.contains(&title.title_id()) {
tids.push(title.title_id());
}
}
tids.sort_by(|a, b| format!("{a:08x}").cmp(&format!("{b:08x}")));
tids
}
pub async fn get_files_in_folder(
&self,
tid: TitleID,
folder_relative_to_nus: &Path,
is_code: bool,
) -> Vec<(PathBuf, Option<CafeContentFileInformation>)> {
let mut nus_title_path = self.root_directory.clone();
nus_title_path.push(format!("{:08x}", tid.group_id()));
nus_title_path.push(format!("{:08x}", tid.title_id()));
nus_title_path.push(".nus");
nus_title_path.push(&self.nus_hash);
let Ok((tmd, tmd_raw)) = self.get_tmd(&nus_title_path, tid).await else {
return Vec::with_capacity(0);
};
let Ok((fst, fst_raw)) = self.get_fst(&nus_title_path, tid, &tmd).await else {
return Vec::with_capacity(0);
};
let mut saw_fst = false;
let mut saw_preload = false;
let mut saw_tmd = false;
let mut items = Vec::new();
for (relative_path, file_data) in fst.paths() {
let Ok(leftover_path) = relative_path.strip_prefix(folder_relative_to_nus) else {
continue;
};
if leftover_path.components().count() != 1 {
continue;
}
if leftover_path.to_string_lossy() == "title.fst" {
saw_fst = true;
}
if leftover_path.to_string_lossy() == "title.tmd" {
saw_tmd = true;
}
if leftover_path.to_string_lossy() == "preload.txt" {
saw_preload = true;
}
items.push((leftover_path.to_path_buf(), *file_data));
}
if is_code {
if !saw_fst {
items.push((
PathBuf::from("title.fst"),
Some(CafeContentFileInformation::new(
u16::MAX,
u32::try_from(fst_raw.len()).unwrap_or(u32::MAX),
u64::MAX - 1,
)),
));
}
if !saw_preload {
items.push((
PathBuf::from("preload.txt"),
Some(CafeContentFileInformation::new(u16::MAX, 0, u64::MAX - 2)),
));
}
if !saw_tmd {
items.push((
PathBuf::from("title.tmd"),
Some(CafeContentFileInformation::new(
u16::MAX,
u32::try_from(tmd_raw.len()).unwrap_or(u32::MAX),
u64::MAX,
)),
));
}
}
items
}
#[must_use]
pub async fn exists(
&self,
tid: TitleID,
path_in_title: &Path,
) -> Option<Option<CafeContentFileInformation>> {
if !self.title_ids.contains(&tid) {
debug!(
"NUS queried for unknown title: {:016x}, not serving.",
tid.full()
);
return None;
}
let mut nus_title_path = self.root_directory.clone();
nus_title_path.push(format!("{:08x}", tid.group_id()));
nus_title_path.push(format!("{:08x}", tid.title_id()));
nus_title_path.push(".nus");
nus_title_path.push(&self.nus_hash);
if !nus_title_path.exists()
&& let Err(cause) = tokio::fs::create_dir_all(&nus_title_path).await
{
error!(
?cause,
title = format!("{:016x}", tid.full()),
"Failed to create NUS cache directory for title, cannot download from NUS!",
);
return None;
}
let (tmd, tmd_raw) = match self.get_tmd(&nus_title_path, tid).await {
Ok(t) => t,
Err(cause) => {
warn!(
?cause,
lisa.force_combine_fields = true,
title = format!("{:016x}", tid.full()),
"Failed to get TMD for title, will not be processing.",
);
return None;
}
};
let (fst, fst_raw) = match self.get_fst(&nus_title_path, tid, &tmd).await {
Ok(f) => f,
Err(cause) => {
warn!(
?cause,
lisa.force_combine_fields = true,
title = format!("{:016x}", tid.full()),
"Failed to get FST for title, will not be processing.",
);
return None;
}
};
for (key, file_info) in fst.paths() {
if key == path_in_title {
return Some(*file_info);
}
}
if path_in_title.to_string_lossy().replace('\\', "/") == "code/title.tmd" {
return Some(Some(CafeContentFileInformation::new(
u16::MAX,
u32::try_from(tmd_raw.len()).unwrap_or(u32::MAX),
u64::MAX,
)));
}
if path_in_title.to_string_lossy().replace('\\', "/") == "code/title.fst" {
return Some(Some(CafeContentFileInformation::new(
u16::MAX,
u32::try_from(fst_raw.len()).unwrap_or(u32::MAX),
u64::MAX - 1,
)));
}
if path_in_title.to_string_lossy().replace('\\', "/") == "code/preload.txt" {
return Some(Some(CafeContentFileInformation::new(
u16::MAX,
0,
u64::MAX - 2,
)));
}
None
}
pub async fn download_to(
&self,
disk_path: &Path,
title_id: TitleID,
file_info: CafeContentFileInformation,
) -> Result<(), CatBridgeError> {
info!(
lisa.force_combine_fields = true,
file.path = %disk_path.display(),
title_id = format!("{:016x}", title_id.full()),
"Downloading File To Disk From NUS!",
);
let mut nus_title_path = self.root_directory.clone();
nus_title_path.push(format!("{:08x}", title_id.group_id()));
nus_title_path.push(format!("{:08x}", title_id.title_id()));
nus_title_path.push(".nus");
nus_title_path.push(&self.nus_hash);
if !nus_title_path.exists() {
tokio::fs::create_dir_all(&nus_title_path)
.await
.map_err(FSError::IO)?;
}
if let Some(disk_dir) = disk_path.parent()
&& !disk_dir.exists()
{
tokio::fs::create_dir_all(&disk_dir)
.await
.map_err(FSError::IO)?;
}
let (tmd, tmd_raw) = self.get_tmd(&nus_title_path, title_id).await?;
if file_info.content_idx() == u16::MAX && file_info.file_offset() == u64::MAX {
tokio::fs::write(disk_path, &tmd_raw)
.await
.map_err(FSError::IO)?;
} else if file_info.content_idx() == u16::MAX && file_info.file_offset() == u64::MAX - 1 {
let (_, fst_raw) = self.get_fst(&nus_title_path, title_id, &tmd).await?;
tokio::fs::write(disk_path, &fst_raw)
.await
.map_err(FSError::IO)?;
} else if file_info.content_idx() == u16::MAX && file_info.file_offset() == u64::MAX - 2 {
tokio::fs::File::create(disk_path)
.await
.map_err(FSError::IO)?;
} else {
let record = tmd
.contents()
.get(usize::from(file_info.content_idx()))
.ok_or_else(|| {
NetworkError::NUSInvalidContentID(title_id, file_info.content_idx())
})?;
let decrypted_content = self
.get_and_decrypt_file(&nus_title_path, title_id, record, &file_info)
.await?;
tokio::fs::write(disk_path, &decrypted_content)
.await
.map_err(FSError::IO)?;
}
Ok(())
}
async fn get_tmd(
&self,
base_path: &Path,
title_id: TitleID,
) -> Result<(TitleMetadata, Bytes), CatBridgeError> {
let mut tmd_path = PathBuf::from(base_path);
tmd_path.push("tmd");
if tmd_path.exists() {
match tokio::fs::read(&tmd_path).await {
Ok(data) => {
let d_as_bytes = Bytes::from(data);
match TitleMetadata::try_from(d_as_bytes.clone()) {
Ok(tmd) => {
debug!(
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"is using cached tmd for title",
);
return Ok((tmd, d_as_bytes));
}
Err(cause) => {
warn!(
?cause,
lisa.force_combine_fields = true,
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Failed to parse existing cached TMD, will download again!",
);
}
}
}
Err(cause) => {
warn!(
?cause,
lisa.force_combine_fields = true,
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Failed to read existing cached TMD, will download again!",
);
}
}
}
let mut my_url = self.base_url.clone();
my_url.set_path(&format!("{}/{:016x}/tmd", my_url.path(), title_id.full()));
let resulting_bytes = match self.client.get(my_url).send().await {
Ok(resp) => {
if !resp.status().is_success() {
error!(
status = %resp.status(),
title = format!("{:016x}", title_id.full()),
"Failed to fetch tmd for title got bad status code, will not return TMD",
);
return Err(NetworkError::HTTPStatusCode(
resp.status(),
resp.bytes().await.ok(),
)
.into());
}
match resp.bytes().await {
Ok(success) => success,
Err(cause) => {
error!(
?cause,
title = format!("{:016x}", title_id.full()),
"Failed to read TMD response from successful NUS server!",
);
return Err(cause.into());
}
}
}
Err(cause) => {
error!(
?cause,
title = format!("{:016x}", title_id.full()),
"Failed to make TMD request to NUS server!",
);
return Err(cause.into());
}
};
let tmd = match TitleMetadata::try_from(resulting_bytes.clone()) {
Ok(t) => t,
Err(cause) => {
error!(
?cause,
title = format!("{:016x}", title_id.full()),
"NUS responded with invalid TMD, will not use or cache!",
);
return Err(NetworkError::NUS(cause.into()).into());
}
};
if let Err(cause) = tokio::fs::write(&tmd_path, &resulting_bytes).await {
warn!(
?cause,
lisa.force_combine_fields = true,
path = %tmd_path.display(),
"Failed to write TMD to disk! Will need to download again...",
);
}
Ok((tmd, resulting_bytes))
}
async fn get_fst(
&self,
base_path: &Path,
title_id: TitleID,
tmd: &TitleMetadata,
) -> Result<(CafeContentFilesystemTree, Bytes), CatBridgeError> {
let Some(fst_record) = tmd.fst_record() else {
error!(
title = format!("{:016x}", title_id.full()),
"Title does not contain a FST Record, cannot be served!",
);
return Err(NetworkError::NUSMissingFST(title_id).into());
};
let mut fst_path = PathBuf::from(base_path);
fst_path.push("fst");
if fst_path.exists() {
match tokio::fs::read(&fst_path).await {
Ok(data) => {
let d_as_bytes = Bytes::from(data);
match CafeContentFilesystemTree::try_from(d_as_bytes.clone()) {
Ok(t) => return Ok((t, d_as_bytes)),
Err(cause) => {
warn!(
?cause,
lisa.force_combine_fields = true,
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Failed to parse existing fst, will download again!",
);
}
}
}
Err(cause) => {
warn!(
?cause,
lisa.force_combine_fields = true,
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Failed to read existing fst, will download again!",
);
}
}
}
let possible_fst_contents = self
.possible_decrypt_contents(base_path, title_id, fst_record)
.await?;
let mut last_error = CatBridgeError::FS(FSError::IO(std::io::Error::other(
"zero title keys could be guessed.",
)));
for (content_possiblity, key) in possible_fst_contents {
match CafeContentFilesystemTree::try_from(content_possiblity.clone()) {
Ok(content) => {
let mut tkeys_path = PathBuf::from(base_path);
tkeys_path.push("possible-title-keys");
tokio::fs::write(&tkeys_path, key)
.await
.map_err(FSError::IO)?;
tokio::fs::write(&fst_path, &content_possiblity)
.await
.map_err(FSError::IO)?;
return Ok((content, content_possiblity));
}
Err(cause) => {
last_error = NetworkError::NUS(cause).into();
}
}
}
Err(last_error)
}
async fn get_and_decrypt_file(
&self,
base_path: &Path,
title_id: TitleID,
record: &ContentRecord,
file_info: &CafeContentFileInformation,
) -> Result<Bytes, CatBridgeError> {
let content_id = record.id();
let encrypted_content = self
.get_encrypted_content_id(base_path, title_id, content_id)
.await?;
let possible_title_keys = self.get_possible_title_keys(base_path, title_id).await;
let mut decrypted: Option<Bytes> = None;
for key in possible_title_keys {
if let Ok(copied) = decrypt(encrypted_content.clone(), key, Some(file_info), record) {
decrypted = Some(copied);
break;
}
}
let Some(final_bytes) = decrypted else {
warn!(
content_id = format!("{content_id:08x}"),
lisa.force_combine_fields = true,
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Failed to decrypt content for title ID! will not load file!",
);
return Err(NetworkError::NUSNoTitleKey(title_id).into());
};
Ok(final_bytes)
}
async fn possible_decrypt_contents(
&self,
base_path: &Path,
title_id: TitleID,
record: &ContentRecord,
) -> Result<Vec<(Bytes, [u8; 16])>, CatBridgeError> {
let content_id = record.id();
let encrypted_content = self
.get_encrypted_content_id(base_path, title_id, content_id)
.await?;
let possible_title_keys = self.get_possible_title_keys(base_path, title_id).await;
let mut decrypted = Vec::new();
for key in possible_title_keys {
if let Ok(copied) = decrypt(encrypted_content.clone(), key, None, record) {
decrypted.push((copied, key));
}
}
Ok(decrypted)
}
async fn get_encrypted_content_id(
&self,
base_path: &Path,
title_id: TitleID,
content_id: u32,
) -> Result<Bytes, CatBridgeError> {
let content_id_fetchable = format!("{content_id:08x}");
let mut encrypted_path = PathBuf::from(base_path);
encrypted_path.push("encrypted");
tokio::fs::create_dir_all(&encrypted_path)
.await
.map_err(FSError::IO)?;
encrypted_path.push(format!("{content_id_fetchable}.app"));
if encrypted_path.exists() {
match tokio::fs::read(&encrypted_path).await {
Ok(data) => {
debug!(
content_id = format!("{content_id:08x}"),
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Using cached encrypted app.",
);
return Ok(Bytes::from(data));
}
Err(cause) => {
warn!(
?cause,
content_id = format!("{content_id:08x}"),
lisa.force_combine_fields = true,
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Failed to read existing encrypted app, will download again!",
);
}
}
}
let mut my_url = self.base_url.clone();
my_url.set_path(&format!(
"{}/{:016x}/{content_id_fetchable}",
my_url.path(),
title_id.full()
));
let resulting_bytes = match self.client.get(my_url).send().await {
Ok(resp) => {
if !resp.status().is_success() {
warn!(
content_id = format!("{content_id:08x}"),
status = %resp.status(),
title = format!("{:016x}", title_id.full()),
"Failed to fetch content for title got bad status code, will not return encrypted app",
);
return Err(NetworkError::HTTPStatusCode(
resp.status(),
resp.bytes().await.ok(),
)
.into());
}
match resp.bytes().await {
Ok(success) => success,
Err(cause) => {
warn!(
?cause,
content_id = format!("{content_id:08x}"),
title = format!("{:016x}", title_id.full()),
"Failed to read content for title response from successful NUS server!",
);
return Err(cause.into());
}
}
}
Err(cause) => {
warn!(
?cause,
content_id = format!("{content_id:08x}"),
title = format!("{:016x}", title_id.full()),
"Failed to read content for title request to NUS server!",
);
return Err(cause.into());
}
};
if let Err(cause) = tokio::fs::write(&encrypted_path, &resulting_bytes).await {
warn!(
?cause,
content_id = format!("{content_id:08x}"),
lisa.force_combine_fields = true,
path = %encrypted_path.display(),
"Failed to write encrypted app file to disk! Will need to fetch again.",
);
}
Ok(resulting_bytes)
}
async fn get_possible_title_keys(
&self,
base_path: &Path,
title_id: TitleID,
) -> Vec<[u8; 0x10]> {
let mut tkeys_path = PathBuf::from(base_path);
tkeys_path.push("possible-title-keys");
if tkeys_path.exists() {
match tokio::fs::read(&tkeys_path).await {
Ok(data) => {
debug!(
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Using cached title keys",
);
return data
.chunks(0x10)
.filter_map(|c| {
if c.len() == 0x10 {
Some([
c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], c[8], c[9],
c[10], c[11], c[12], c[13], c[14], c[15],
])
} else {
None
}
})
.collect::<Vec<_>>();
}
Err(cause) => {
warn!(
?cause,
lisa.force_combine_fields = true,
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Failed to read existing cached title keys, will generate again!",
);
}
}
}
let title_keys: Vec<[u8; 0x10]> =
if let Ok(tik) = self.fetch_ticket(base_path, title_id).await {
if let Ok(title_key) = tik.v0_header().title_key() {
vec![title_key]
} else {
title_key_guesses(title_id)
}
} else {
title_key_guesses(title_id)
};
let mut serialized = BytesMut::with_capacity(title_keys.len() * 0x10);
for key in &title_keys {
serialized.extend(key);
}
if let Err(cause) = tokio::fs::write(&tkeys_path, &serialized.freeze()).await {
warn!(
?cause,
lisa.force_combine_fields = true,
path = %tkeys_path.display(),
"Failed to write title-keys to disk! Will need to generate again...",
);
}
title_keys
}
async fn fetch_ticket(
&self,
base_path: &Path,
title_id: TitleID,
) -> Result<Ticket, CatBridgeError> {
let mut ticket_path = PathBuf::from(base_path);
ticket_path.push("title.tik");
if ticket_path.exists() {
match tokio::fs::read(&ticket_path).await {
Ok(data) => match Ticket::try_from(Bytes::from(data)) {
Ok(tik) => {
debug!(
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"is using cached cetk for title",
);
return Ok(tik);
}
Err(cause) => {
warn!(
?cause,
lisa.force_combine_fields = true,
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Failed to parse existing CETK, will download again!",
);
}
},
Err(cause) => {
warn!(
?cause,
lisa.force_combine_fields = true,
nus_hash = %self.nus_hash,
title = format!("{:016x}", title_id.full()),
"Failed to read existing cached CETK, will download again!",
);
}
}
}
let mut my_url = self.base_url.clone();
my_url.set_path(&format!("{}/{:016x}/cetk", my_url.path(), title_id.full()));
let resulting_bytes = match self.client.get(my_url).send().await {
Ok(resp) => {
if !resp.status().is_success() {
warn!(
status = %resp.status(),
title = format!("{:016x}", title_id.full()),
"Failed to fetch cetk for title got bad status code, will not return cetk",
);
return Err(NetworkError::HTTPStatusCode(
resp.status(),
resp.bytes().await.ok(),
)
.into());
}
match resp.bytes().await {
Ok(success) => success,
Err(cause) => {
warn!(
?cause,
title = format!("{:016x}", title_id.full()),
"Failed to read CETK response from successful NUS server!",
);
return Err(cause.into());
}
}
}
Err(cause) => {
warn!(
?cause,
title = format!("{:016x}", title_id.full()),
"Failed to make CETK request to NUS server!",
);
return Err(cause.into());
}
};
let tik = match Ticket::try_from(resulting_bytes.clone()) {
Ok(t) => t,
Err(cause) => {
warn!(
?cause,
title = format!("{:016x}", title_id.full()),
"NUS responded with invalid CETK, will not use or cache!",
);
return Err(NetworkError::NUS(cause.into()).into());
}
};
if let Err(cause) = tokio::fs::write(&ticket_path, &resulting_bytes).await {
warn!(
?cause,
lisa.force_combine_fields = true,
path = %ticket_path.display(),
"Failed to write CETK to disk! Will need to download again...",
);
}
Ok(tik)
}
}
const NUS_FUSE_FIELDS: &[NamedField<'static>] = &[
NamedField::new("base_url"),
NamedField::new("client"),
NamedField::new("nus_hash"),
NamedField::new("root_directory"),
];
impl Structable for NUSFuse {
fn definition(&self) -> StructDef<'_> {
StructDef::new_static("NUSFuse", Fields::Named(NUS_FUSE_FIELDS))
}
}
impl Valuable for NUSFuse {
fn as_value(&self) -> Value<'_> {
Value::Structable(self)
}
fn visit(&self, visitor: &mut dyn Visit) {
visitor.visit_named_fields(&NamedValues::new(
NUS_FUSE_FIELDS,
&[
Valuable::as_value(&format!("{}", self.base_url)),
Valuable::as_value(&format!("{:?}", self.client)),
Valuable::as_value(&self.nus_hash),
Valuable::as_value(&format!("{}", self.root_directory.display())),
],
));
}
}