Skip to main content

cat_dev/fsemul/filesystem/
nus_fuse.rs

1//! A "NUS" fuse like filesystem.
2//!
3//! The goal of this provider is meant to be a fuse like filesystem for
4//! reading contents from an arbitrary "NUS" endpoint. The goal is to provide
5//! the necessary data to PCFS without having to download ALL the content from
6//! the NUS.
7//!
8//! We can answer many of the requests from PCFS with just the TMD, CETK,
9//! FST, and some of the xml files. All in all this is significantly less data
10//! and is pretty reasonable to store. From there we can download the files as
11//! they are actually needed.
12
13use crate::{
14	errors::{CatBridgeError, FSError, NetworkError},
15	fsemul::errors::FSEmulAPIError,
16};
17use bytes::{Bytes, BytesMut};
18use fnv::{FnvHashSet, FnvHasher};
19use reqwest::{Client, ClientBuilder, Url};
20use sachet::{
21	common::{CafeContentFileInformation, CafeContentFilesystemTree},
22	content::decrypt,
23	title::{
24		TitleID,
25		key_generation::title_key_guesses,
26		metadata::{TitleMetadata, contents::ContentRecord},
27		ticket::Ticket,
28	},
29};
30use std::{
31	hash::{Hash, Hasher},
32	path::{Path, PathBuf},
33};
34use tracing::{debug, error, info, warn};
35use valuable::{Fields, NamedField, NamedValues, StructDef, Structable, Valuable, Value, Visit};
36
37/// The NUS Fuse filesystem that downloads from NUS, and caches to the
38/// filesystem.
39#[derive(Clone, Debug)]
40pub struct NUSFuse {
41	/// The Base NUS URL.
42	///
43	/// Should normally be: `https://<hostname>/ccs/download/`.
44	base_url: Url,
45	/// Our HTTP Client we use to contact the remote NUS server.
46	client: Client,
47	/// The 'hash' of the NUS URL. To cache our content from one nus specifically
48	/// without colliding.
49	nus_hash: String,
50	/// The root directory we store titles, and cached content.
51	root_directory: PathBuf,
52	/// The list of title ids to fetch from NUS FUSE.
53	title_ids: FnvHashSet<TitleID>,
54}
55
56impl NUSFuse {
57	/// Create a new NUS FUSE filesystem.
58	///
59	/// ## Errors
60	///
61	/// If we cannot create a new HTTP client, or parse the base NUS URL.
62	pub fn new(
63		nus_url: &str,
64		root_directory: PathBuf,
65		title_ids: FnvHashSet<TitleID>,
66	) -> Result<Self, FSEmulAPIError> {
67		let client = ClientBuilder::new()
68			.user_agent(concat!(
69				env!("CARGO_PKG_NAME"),
70				"/",
71				env!("CARGO_PKG_VERSION"),
72			))
73			.brotli(true)
74			.deflate(true)
75			.gzip(true)
76			.zstd(true)
77			.build()?;
78		let base_url = Url::parse(nus_url)?;
79		let nus_hash = {
80			let mut hasher = FnvHasher::with_key(0x69420);
81			nus_url.hash(&mut hasher);
82			format!("{:016x}", hasher.finish())
83		};
84
85		Ok(Self {
86			base_url,
87			client,
88			nus_hash,
89			root_directory,
90			title_ids,
91		})
92	}
93
94	/// Get a list of group ids in a sorted fashion.
95	#[must_use]
96	pub fn get_sorted_group_ids(&self) -> Vec<u32> {
97		let mut group_ids = Vec::new();
98		for title in &self.title_ids {
99			if !group_ids.contains(&title.group_id()) {
100				group_ids.push(title.group_id());
101			}
102		}
103		group_ids.sort_by(|a, b| format!("{a:08x}").cmp(&format!("{b:08x}")));
104		group_ids
105	}
106
107	/// Get a list of title ids within a group sorted.
108	#[must_use]
109	pub fn get_sorted_tids_in_group(&self, group_id: u32) -> Vec<u32> {
110		let mut tids = Vec::new();
111		for title in &self.title_ids {
112			if title.group_id() == group_id && !tids.contains(&title.title_id()) {
113				tids.push(title.title_id());
114			}
115		}
116		tids.sort_by(|a, b| format!("{a:08x}").cmp(&format!("{b:08x}")));
117		tids
118	}
119
120	/// Get all the files that are in a particular folder.
121	pub async fn get_files_in_folder(
122		&self,
123		tid: TitleID,
124		folder_relative_to_nus: &Path,
125		is_code: bool,
126	) -> Vec<(PathBuf, Option<CafeContentFileInformation>)> {
127		let mut nus_title_path = self.root_directory.clone();
128		nus_title_path.push(format!("{:08x}", tid.group_id()));
129		nus_title_path.push(format!("{:08x}", tid.title_id()));
130		nus_title_path.push(".nus");
131		nus_title_path.push(&self.nus_hash);
132
133		let Ok((tmd, tmd_raw)) = self.get_tmd(&nus_title_path, tid).await else {
134			return Vec::with_capacity(0);
135		};
136		let Ok((fst, fst_raw)) = self.get_fst(&nus_title_path, tid, &tmd).await else {
137			return Vec::with_capacity(0);
138		};
139
140		let mut saw_fst = false;
141		let mut saw_preload = false;
142		let mut saw_tmd = false;
143
144		let mut items = Vec::new();
145		for (relative_path, file_data) in fst.paths() {
146			let Ok(leftover_path) = relative_path.strip_prefix(folder_relative_to_nus) else {
147				continue;
148			};
149			// We only want to list things _in_ this directory straight up.
150			if leftover_path.components().count() != 1 {
151				continue;
152			}
153
154			if leftover_path.to_string_lossy() == "title.fst" {
155				saw_fst = true;
156			}
157			if leftover_path.to_string_lossy() == "title.tmd" {
158				saw_tmd = true;
159			}
160			if leftover_path.to_string_lossy() == "preload.txt" {
161				saw_preload = true;
162			}
163
164			items.push((leftover_path.to_path_buf(), *file_data));
165		}
166
167		if is_code {
168			if !saw_fst {
169				items.push((
170					PathBuf::from("title.fst"),
171					Some(CafeContentFileInformation::new(
172						u16::MAX,
173						u32::try_from(fst_raw.len()).unwrap_or(u32::MAX),
174						u64::MAX - 1,
175					)),
176				));
177			}
178			if !saw_preload {
179				items.push((
180					PathBuf::from("preload.txt"),
181					Some(CafeContentFileInformation::new(u16::MAX, 0, u64::MAX - 2)),
182				));
183			}
184			if !saw_tmd {
185				items.push((
186					PathBuf::from("title.tmd"),
187					Some(CafeContentFileInformation::new(
188						u16::MAX,
189						u32::try_from(tmd_raw.len()).unwrap_or(u32::MAX),
190						u64::MAX,
191					)),
192				));
193			}
194		}
195
196		items
197	}
198
199	/// Check if a particular file exists on this particular NUS FUSE filesystem.
200	#[must_use]
201	pub async fn exists(
202		&self,
203		tid: TitleID,
204		path_in_title: &Path,
205	) -> Option<Option<CafeContentFileInformation>> {
206		// This is not a title id that we have on NUS.
207		if !self.title_ids.contains(&tid) {
208			debug!(
209				"NUS queried for unknown title: {:016x}, not serving.",
210				tid.full()
211			);
212			return None;
213		}
214		let mut nus_title_path = self.root_directory.clone();
215		nus_title_path.push(format!("{:08x}", tid.group_id()));
216		nus_title_path.push(format!("{:08x}", tid.title_id()));
217		nus_title_path.push(".nus");
218		nus_title_path.push(&self.nus_hash);
219		if !nus_title_path.exists()
220			&& let Err(cause) = tokio::fs::create_dir_all(&nus_title_path).await
221		{
222			error!(
223				?cause,
224				title = format!("{:016x}", tid.full()),
225				"Failed to create NUS cache directory for title, cannot download from NUS!",
226			);
227
228			return None;
229		}
230
231		let (tmd, tmd_raw) = match self.get_tmd(&nus_title_path, tid).await {
232			Ok(t) => t,
233			Err(cause) => {
234				warn!(
235					?cause,
236					lisa.force_combine_fields = true,
237					title = format!("{:016x}", tid.full()),
238					"Failed to get TMD for title, will not be processing.",
239				);
240				return None;
241			}
242		};
243		let (fst, fst_raw) = match self.get_fst(&nus_title_path, tid, &tmd).await {
244			Ok(f) => f,
245			Err(cause) => {
246				warn!(
247					?cause,
248					lisa.force_combine_fields = true,
249					title = format!("{:016x}", tid.full()),
250					"Failed to get FST for title, will not be processing.",
251				);
252				return None;
253			}
254		};
255		for (key, file_info) in fst.paths() {
256			if key == path_in_title {
257				return Some(*file_info);
258			}
259		}
260
261		if path_in_title.to_string_lossy().replace('\\', "/") == "code/title.tmd" {
262			return Some(Some(CafeContentFileInformation::new(
263				u16::MAX,
264				u32::try_from(tmd_raw.len()).unwrap_or(u32::MAX),
265				u64::MAX,
266			)));
267		}
268		if path_in_title.to_string_lossy().replace('\\', "/") == "code/title.fst" {
269			return Some(Some(CafeContentFileInformation::new(
270				u16::MAX,
271				u32::try_from(fst_raw.len()).unwrap_or(u32::MAX),
272				u64::MAX - 1,
273			)));
274		}
275		if path_in_title.to_string_lossy().replace('\\', "/") == "code/preload.txt" {
276			return Some(Some(CafeContentFileInformation::new(
277				u16::MAX,
278				0,
279				u64::MAX - 2,
280			)));
281		}
282
283		None
284	}
285
286	/// Attempt to download a particular file information into an actual path on
287	/// disk, so it can be operated on locally.
288	///
289	/// ## Errors
290	///
291	/// If we cannot download the file from NUS, cannot decrypt the file from NUS,
292	/// or write the file back to disk.
293	pub async fn download_to(
294		&self,
295		disk_path: &Path,
296		title_id: TitleID,
297		file_info: CafeContentFileInformation,
298	) -> Result<(), CatBridgeError> {
299		info!(
300			lisa.force_combine_fields = true,
301			file.path = %disk_path.display(),
302			title_id = format!("{:016x}", title_id.full()),
303			"Downloading File To Disk From NUS!",
304		);
305		let mut nus_title_path = self.root_directory.clone();
306		nus_title_path.push(format!("{:08x}", title_id.group_id()));
307		nus_title_path.push(format!("{:08x}", title_id.title_id()));
308		nus_title_path.push(".nus");
309		nus_title_path.push(&self.nus_hash);
310		if !nus_title_path.exists() {
311			tokio::fs::create_dir_all(&nus_title_path)
312				.await
313				.map_err(FSError::IO)?;
314		}
315		if let Some(disk_dir) = disk_path.parent()
316			&& !disk_dir.exists()
317		{
318			tokio::fs::create_dir_all(&disk_dir)
319				.await
320				.map_err(FSError::IO)?;
321		}
322
323		let (tmd, tmd_raw) = self.get_tmd(&nus_title_path, title_id).await?;
324
325		// Special markers, is a file that needs to exist but isn't in the archive itself.
326		//
327		// TODO(mythra): fix all of this, this is so hacky, and bad.
328		if file_info.content_idx() == u16::MAX && file_info.file_offset() == u64::MAX {
329			tokio::fs::write(disk_path, &tmd_raw)
330				.await
331				.map_err(FSError::IO)?;
332		} else if file_info.content_idx() == u16::MAX && file_info.file_offset() == u64::MAX - 1 {
333			let (_, fst_raw) = self.get_fst(&nus_title_path, title_id, &tmd).await?;
334			tokio::fs::write(disk_path, &fst_raw)
335				.await
336				.map_err(FSError::IO)?;
337		} else if file_info.content_idx() == u16::MAX && file_info.file_offset() == u64::MAX - 2 {
338			tokio::fs::File::create(disk_path)
339				.await
340				.map_err(FSError::IO)?;
341		} else {
342			let record = tmd
343				.contents()
344				.get(usize::from(file_info.content_idx()))
345				.ok_or_else(|| {
346					NetworkError::NUSInvalidContentID(title_id, file_info.content_idx())
347				})?;
348			let decrypted_content = self
349				.get_and_decrypt_file(&nus_title_path, title_id, record, &file_info)
350				.await?;
351			tokio::fs::write(disk_path, &decrypted_content)
352				.await
353				.map_err(FSError::IO)?;
354		}
355
356		Ok(())
357	}
358
359	/// Fetch the TMD or read an already cached one.
360	///
361	/// This will return none if we cannot parse an already existing TMD, or cannot
362	/// fetch a new one. This function will also cache our file to disk, so we never need
363	/// to fetch from this NUS again.
364	async fn get_tmd(
365		&self,
366		base_path: &Path,
367		title_id: TitleID,
368	) -> Result<(TitleMetadata, Bytes), CatBridgeError> {
369		let mut tmd_path = PathBuf::from(base_path);
370		tmd_path.push("tmd");
371
372		if tmd_path.exists() {
373			match tokio::fs::read(&tmd_path).await {
374				Ok(data) => {
375					let d_as_bytes = Bytes::from(data);
376					match TitleMetadata::try_from(d_as_bytes.clone()) {
377						Ok(tmd) => {
378							debug!(
379								nus_hash = %self.nus_hash,
380								title = format!("{:016x}", title_id.full()),
381								"is using cached tmd for title",
382							);
383							return Ok((tmd, d_as_bytes));
384						}
385						Err(cause) => {
386							warn!(
387								?cause,
388								lisa.force_combine_fields = true,
389								nus_hash = %self.nus_hash,
390								title = format!("{:016x}", title_id.full()),
391								"Failed to parse existing cached TMD, will download again!",
392							);
393						}
394					}
395				}
396				Err(cause) => {
397					warn!(
398						?cause,
399						lisa.force_combine_fields = true,
400						nus_hash = %self.nus_hash,
401						title = format!("{:016x}", title_id.full()),
402						"Failed to read existing cached TMD, will download again!",
403					);
404				}
405			}
406		}
407
408		let mut my_url = self.base_url.clone();
409		my_url.set_path(&format!("{}/{:016x}/tmd", my_url.path(), title_id.full()));
410		let resulting_bytes = match self.client.get(my_url).send().await {
411			Ok(resp) => {
412				if !resp.status().is_success() {
413					error!(
414						status = %resp.status(),
415						title = format!("{:016x}", title_id.full()),
416						"Failed to fetch tmd for title got bad status code, will not return TMD",
417					);
418					return Err(NetworkError::HTTPStatusCode(
419						resp.status(),
420						resp.bytes().await.ok(),
421					)
422					.into());
423				}
424
425				match resp.bytes().await {
426					Ok(success) => success,
427					Err(cause) => {
428						error!(
429							?cause,
430							title = format!("{:016x}", title_id.full()),
431							"Failed to read TMD response from successful NUS server!",
432						);
433						return Err(cause.into());
434					}
435				}
436			}
437			Err(cause) => {
438				error!(
439					?cause,
440					title = format!("{:016x}", title_id.full()),
441					"Failed to make TMD request to NUS server!",
442				);
443				return Err(cause.into());
444			}
445		};
446
447		let tmd = match TitleMetadata::try_from(resulting_bytes.clone()) {
448			Ok(t) => t,
449			Err(cause) => {
450				error!(
451					?cause,
452					title = format!("{:016x}", title_id.full()),
453					"NUS responded with invalid TMD, will not use or cache!",
454				);
455				return Err(NetworkError::NUS(cause.into()).into());
456			}
457		};
458		if let Err(cause) = tokio::fs::write(&tmd_path, &resulting_bytes).await {
459			warn!(
460				?cause,
461				lisa.force_combine_fields = true,
462				path = %tmd_path.display(),
463				"Failed to write TMD to disk! Will need to download again...",
464			);
465		}
466
467		Ok((tmd, resulting_bytes))
468	}
469
470	/// Get the filesystem tree for this particular NUS package.
471	///
472	/// This will return none, when the title does not have a FST Record, or we
473	/// cannot download, decrypt, and prase the filesystem tree.
474	async fn get_fst(
475		&self,
476		base_path: &Path,
477		title_id: TitleID,
478		tmd: &TitleMetadata,
479	) -> Result<(CafeContentFilesystemTree, Bytes), CatBridgeError> {
480		let Some(fst_record) = tmd.fst_record() else {
481			error!(
482				title = format!("{:016x}", title_id.full()),
483				"Title does not contain a FST Record, cannot be served!",
484			);
485			return Err(NetworkError::NUSMissingFST(title_id).into());
486		};
487
488		let mut fst_path = PathBuf::from(base_path);
489		fst_path.push("fst");
490		if fst_path.exists() {
491			match tokio::fs::read(&fst_path).await {
492				Ok(data) => {
493					let d_as_bytes = Bytes::from(data);
494					match CafeContentFilesystemTree::try_from(d_as_bytes.clone()) {
495						Ok(t) => return Ok((t, d_as_bytes)),
496						Err(cause) => {
497							warn!(
498								?cause,
499								lisa.force_combine_fields = true,
500								nus_hash = %self.nus_hash,
501								title = format!("{:016x}", title_id.full()),
502								"Failed to parse existing fst, will download again!",
503							);
504						}
505					}
506				}
507				Err(cause) => {
508					warn!(
509						?cause,
510						lisa.force_combine_fields = true,
511						nus_hash = %self.nus_hash,
512						title = format!("{:016x}", title_id.full()),
513						"Failed to read existing fst, will download again!",
514					);
515				}
516			}
517		}
518
519		let possible_fst_contents = self
520			.possible_decrypt_contents(base_path, title_id, fst_record)
521			.await?;
522
523		let mut last_error = CatBridgeError::FS(FSError::IO(std::io::Error::other(
524			"zero title keys could be guessed.",
525		)));
526		for (content_possiblity, key) in possible_fst_contents {
527			match CafeContentFilesystemTree::try_from(content_possiblity.clone()) {
528				Ok(content) => {
529					let mut tkeys_path = PathBuf::from(base_path);
530					tkeys_path.push("possible-title-keys");
531					tokio::fs::write(&tkeys_path, key)
532						.await
533						.map_err(FSError::IO)?;
534					tokio::fs::write(&fst_path, &content_possiblity)
535						.await
536						.map_err(FSError::IO)?;
537
538					return Ok((content, content_possiblity));
539				}
540				Err(cause) => {
541					last_error = NetworkError::NUS(cause).into();
542				}
543			}
544		}
545		Err(last_error)
546	}
547
548	/// Attempt to get, and decrypt the content given a particular content id.
549	///
550	/// This will return none if we cannot fetch the content file, or decrypt it
551	/// in anyway. Otherwise it will return all possible contents.
552	async fn get_and_decrypt_file(
553		&self,
554		base_path: &Path,
555		title_id: TitleID,
556		record: &ContentRecord,
557		file_info: &CafeContentFileInformation,
558	) -> Result<Bytes, CatBridgeError> {
559		let content_id = record.id();
560		let encrypted_content = self
561			.get_encrypted_content_id(base_path, title_id, content_id)
562			.await?;
563
564		let possible_title_keys = self.get_possible_title_keys(base_path, title_id).await;
565		let mut decrypted: Option<Bytes> = None;
566		for key in possible_title_keys {
567			if let Ok(copied) = decrypt(encrypted_content.clone(), key, Some(file_info), record) {
568				decrypted = Some(copied);
569				break;
570			}
571		}
572
573		let Some(final_bytes) = decrypted else {
574			warn!(
575				content_id = format!("{content_id:08x}"),
576				lisa.force_combine_fields = true,
577				nus_hash = %self.nus_hash,
578				title = format!("{:016x}", title_id.full()),
579				"Failed to decrypt content for title ID! will not load file!",
580			);
581			return Err(NetworkError::NUSNoTitleKey(title_id).into());
582		};
583
584		Ok(final_bytes)
585	}
586
587	/// Attempt to get all possible decryptions for a piece of content.
588	///
589	/// This will return none if we cannot fetch the content file, or decrypt it
590	/// in anyway. Otherwise it will return all possible contents.
591	async fn possible_decrypt_contents(
592		&self,
593		base_path: &Path,
594		title_id: TitleID,
595		record: &ContentRecord,
596	) -> Result<Vec<(Bytes, [u8; 16])>, CatBridgeError> {
597		let content_id = record.id();
598		let encrypted_content = self
599			.get_encrypted_content_id(base_path, title_id, content_id)
600			.await?;
601		let possible_title_keys = self.get_possible_title_keys(base_path, title_id).await;
602
603		let mut decrypted = Vec::new();
604		for key in possible_title_keys {
605			if let Ok(copied) = decrypt(encrypted_content.clone(), key, None, record) {
606				decrypted.push((copied, key));
607			}
608		}
609
610		Ok(decrypted)
611	}
612
613	/// Attempt to get, and decrypt the content given a particular content id.
614	///
615	/// This will return none if we cannot fetch the content file.
616	async fn get_encrypted_content_id(
617		&self,
618		base_path: &Path,
619		title_id: TitleID,
620		content_id: u32,
621	) -> Result<Bytes, CatBridgeError> {
622		let content_id_fetchable = format!("{content_id:08x}");
623		let mut encrypted_path = PathBuf::from(base_path);
624		encrypted_path.push("encrypted");
625		tokio::fs::create_dir_all(&encrypted_path)
626			.await
627			.map_err(FSError::IO)?;
628		encrypted_path.push(format!("{content_id_fetchable}.app"));
629
630		if encrypted_path.exists() {
631			match tokio::fs::read(&encrypted_path).await {
632				Ok(data) => {
633					debug!(
634						content_id = format!("{content_id:08x}"),
635						nus_hash = %self.nus_hash,
636						title = format!("{:016x}", title_id.full()),
637						"Using cached encrypted app.",
638					);
639
640					return Ok(Bytes::from(data));
641				}
642				Err(cause) => {
643					warn!(
644						?cause,
645						content_id = format!("{content_id:08x}"),
646						lisa.force_combine_fields = true,
647						nus_hash = %self.nus_hash,
648						title = format!("{:016x}", title_id.full()),
649						"Failed to read existing encrypted app, will download again!",
650					);
651				}
652			}
653		}
654
655		let mut my_url = self.base_url.clone();
656		my_url.set_path(&format!(
657			"{}/{:016x}/{content_id_fetchable}",
658			my_url.path(),
659			title_id.full()
660		));
661		let resulting_bytes = match self.client.get(my_url).send().await {
662			Ok(resp) => {
663				if !resp.status().is_success() {
664					warn!(
665						content_id = format!("{content_id:08x}"),
666						status = %resp.status(),
667						title = format!("{:016x}", title_id.full()),
668						"Failed to fetch content for title got bad status code, will not return encrypted app",
669					);
670					return Err(NetworkError::HTTPStatusCode(
671						resp.status(),
672						resp.bytes().await.ok(),
673					)
674					.into());
675				}
676
677				match resp.bytes().await {
678					Ok(success) => success,
679					Err(cause) => {
680						warn!(
681							?cause,
682							content_id = format!("{content_id:08x}"),
683							title = format!("{:016x}", title_id.full()),
684							"Failed to read content for title response from successful NUS server!",
685						);
686						return Err(cause.into());
687					}
688				}
689			}
690			Err(cause) => {
691				warn!(
692					?cause,
693					content_id = format!("{content_id:08x}"),
694					title = format!("{:016x}", title_id.full()),
695					"Failed to read content for title request to NUS server!",
696				);
697				return Err(cause.into());
698			}
699		};
700
701		if let Err(cause) = tokio::fs::write(&encrypted_path, &resulting_bytes).await {
702			warn!(
703				?cause,
704				content_id = format!("{content_id:08x}"),
705				lisa.force_combine_fields = true,
706				path = %encrypted_path.display(),
707				"Failed to write encrypted app file to disk! Will need to fetch again.",
708			);
709		}
710		Ok(resulting_bytes)
711	}
712
713	/// Get a list of all the possible title keys.
714	///
715	/// *NOTE(anyone): These title keys are not guaranteed to be correct, or have any.*
716	async fn get_possible_title_keys(
717		&self,
718		base_path: &Path,
719		title_id: TitleID,
720	) -> Vec<[u8; 0x10]> {
721		let mut tkeys_path = PathBuf::from(base_path);
722		tkeys_path.push("possible-title-keys");
723
724		if tkeys_path.exists() {
725			match tokio::fs::read(&tkeys_path).await {
726				Ok(data) => {
727					debug!(
728						nus_hash = %self.nus_hash,
729						title = format!("{:016x}", title_id.full()),
730						"Using cached title keys",
731					);
732
733					return data
734						.chunks(0x10)
735						.filter_map(|c| {
736							if c.len() == 0x10 {
737								Some([
738									c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], c[8], c[9],
739									c[10], c[11], c[12], c[13], c[14], c[15],
740								])
741							} else {
742								None
743							}
744						})
745						.collect::<Vec<_>>();
746				}
747				Err(cause) => {
748					warn!(
749						?cause,
750						lisa.force_combine_fields = true,
751						nus_hash = %self.nus_hash,
752						title = format!("{:016x}", title_id.full()),
753						"Failed to read existing cached title keys, will generate again!",
754					);
755				}
756			}
757		}
758
759		let title_keys: Vec<[u8; 0x10]> =
760			if let Ok(tik) = self.fetch_ticket(base_path, title_id).await {
761				if let Ok(title_key) = tik.v0_header().title_key() {
762					vec![title_key]
763				} else {
764					title_key_guesses(title_id)
765				}
766			} else {
767				title_key_guesses(title_id)
768			};
769
770		let mut serialized = BytesMut::with_capacity(title_keys.len() * 0x10);
771		for key in &title_keys {
772			serialized.extend(key);
773		}
774		if let Err(cause) = tokio::fs::write(&tkeys_path, &serialized.freeze()).await {
775			warn!(
776				?cause,
777				lisa.force_combine_fields = true,
778				path = %tkeys_path.display(),
779				"Failed to write title-keys to disk! Will need to generate again...",
780			);
781		}
782		title_keys
783	}
784
785	/// Attempt to fetch a ticket for a particular title.
786	///
787	/// *NOTE(anyone): THIS IS NOT GUARANTEED TO EXIST. EVEN FOR A VALID TITLE.*
788	/// The CETK is only used for getting the title key, which we can also attempt to
789	/// generate. This function is the raw wrapper around getting a ticket.
790	///
791	/// This will return none if:
792	/// - We cannot fetch a local cache of the ticket.
793	/// - We cannot download the ticket from NUS.
794	/// - We cannot parse the ticket from NUS.
795	async fn fetch_ticket(
796		&self,
797		base_path: &Path,
798		title_id: TitleID,
799	) -> Result<Ticket, CatBridgeError> {
800		let mut ticket_path = PathBuf::from(base_path);
801		ticket_path.push("title.tik");
802
803		if ticket_path.exists() {
804			match tokio::fs::read(&ticket_path).await {
805				Ok(data) => match Ticket::try_from(Bytes::from(data)) {
806					Ok(tik) => {
807						debug!(
808							nus_hash = %self.nus_hash,
809							title = format!("{:016x}", title_id.full()),
810							"is using cached cetk for title",
811						);
812						return Ok(tik);
813					}
814					Err(cause) => {
815						warn!(
816							?cause,
817							lisa.force_combine_fields = true,
818							nus_hash = %self.nus_hash,
819							title = format!("{:016x}", title_id.full()),
820							"Failed to parse existing CETK, will download again!",
821						);
822					}
823				},
824				Err(cause) => {
825					warn!(
826						?cause,
827						lisa.force_combine_fields = true,
828						nus_hash = %self.nus_hash,
829						title = format!("{:016x}", title_id.full()),
830						"Failed to read existing cached CETK, will download again!",
831					);
832				}
833			}
834		}
835
836		let mut my_url = self.base_url.clone();
837		my_url.set_path(&format!("{}/{:016x}/cetk", my_url.path(), title_id.full()));
838		let resulting_bytes = match self.client.get(my_url).send().await {
839			Ok(resp) => {
840				if !resp.status().is_success() {
841					warn!(
842						status = %resp.status(),
843						title = format!("{:016x}", title_id.full()),
844						"Failed to fetch cetk for title got bad status code, will not return cetk",
845					);
846					return Err(NetworkError::HTTPStatusCode(
847						resp.status(),
848						resp.bytes().await.ok(),
849					)
850					.into());
851				}
852
853				match resp.bytes().await {
854					Ok(success) => success,
855					Err(cause) => {
856						warn!(
857							?cause,
858							title = format!("{:016x}", title_id.full()),
859							"Failed to read CETK response from successful NUS server!",
860						);
861						return Err(cause.into());
862					}
863				}
864			}
865			Err(cause) => {
866				warn!(
867					?cause,
868					title = format!("{:016x}", title_id.full()),
869					"Failed to make CETK request to NUS server!",
870				);
871				return Err(cause.into());
872			}
873		};
874
875		let tik = match Ticket::try_from(resulting_bytes.clone()) {
876			Ok(t) => t,
877			Err(cause) => {
878				warn!(
879					?cause,
880					title = format!("{:016x}", title_id.full()),
881					"NUS responded with invalid CETK, will not use or cache!",
882				);
883				return Err(NetworkError::NUS(cause.into()).into());
884			}
885		};
886		if let Err(cause) = tokio::fs::write(&ticket_path, &resulting_bytes).await {
887			warn!(
888				?cause,
889				lisa.force_combine_fields = true,
890				path = %ticket_path.display(),
891				"Failed to write CETK to disk! Will need to download again...",
892			);
893		}
894		Ok(tik)
895	}
896}
897
898const NUS_FUSE_FIELDS: &[NamedField<'static>] = &[
899	NamedField::new("base_url"),
900	NamedField::new("client"),
901	NamedField::new("nus_hash"),
902	NamedField::new("root_directory"),
903];
904
905impl Structable for NUSFuse {
906	fn definition(&self) -> StructDef<'_> {
907		StructDef::new_static("NUSFuse", Fields::Named(NUS_FUSE_FIELDS))
908	}
909}
910
911impl Valuable for NUSFuse {
912	fn as_value(&self) -> Value<'_> {
913		Value::Structable(self)
914	}
915
916	fn visit(&self, visitor: &mut dyn Visit) {
917		visitor.visit_named_fields(&NamedValues::new(
918			NUS_FUSE_FIELDS,
919			&[
920				Valuable::as_value(&format!("{}", self.base_url)),
921				Valuable::as_value(&format!("{:?}", self.client)),
922				Valuable::as_value(&self.nus_hash),
923				Valuable::as_value(&format!("{}", self.root_directory.display())),
924			],
925		));
926	}
927}