diff --git a/src/bin/playground/main.rs b/src/bin/playground/main.rs index ac22965..ded3c80 100644 --- a/src/bin/playground/main.rs +++ b/src/bin/playground/main.rs @@ -3,6 +3,7 @@ use std::fs; use rustii::title::{wad, cert}; use rustii::title; +// use rustii::title::content; fn main() { let data = fs::read("sm.wad").unwrap(); @@ -41,8 +42,17 @@ fn main() { let result = title.verify().unwrap(); println!("full title verified successfully: {}", result); + + + // let mut u8_archive = u8::U8Archive::from_bytes(&fs::read("00000001.app").unwrap()).unwrap(); // println!("files and dirs counted: {}", u8_archive.node_tree.borrow().count()); // fs::write("outfile.arc", u8_archive.to_bytes().unwrap()).unwrap(); // println!("re-written"); + + + + // let mut content_map = content::SharedContentMap::from_bytes(&fs::read("content.map").unwrap()).unwrap(); + // content_map.add(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).unwrap(); + // fs::write("new.map", content_map.to_bytes().unwrap()).unwrap(); } diff --git a/src/bin/rustii/main.rs b/src/bin/rustii/main.rs index 566791d..725edd5 100644 --- a/src/bin/rustii/main.rs +++ b/src/bin/rustii/main.rs @@ -7,6 +7,7 @@ mod archive; mod title; mod filetypes; mod info; +mod nand; use anyhow::Result; use clap::{Subcommand, Parser}; @@ -26,6 +27,11 @@ enum Commands { #[command(subcommand)] command: archive::ash::Commands, }, + /// Manage Wii EmuNANDs + EmuNAND { + #[command(subcommand)] + command: nand::emunand::Commands, + }, /// Fakesign a TMD, Ticket, or WAD (trucha bug) Fakesign { /// The path to a TMD, Ticket, or WAD @@ -49,6 +55,11 @@ enum Commands { #[command(subcommand)] command: title::nus::Commands }, + /// Manage setting.txt + Setting { + #[command(subcommand)] + command: nand::setting::Commands + }, /// Pack/unpack a U8 archive U8 { #[command(subcommand)] @@ -74,6 +85,16 @@ fn main() -> Result<()> { archive::ash::decompress_ash(input, output)? } } + }, + Some(Commands::EmuNAND { command }) => { + match command { + nand::emunand::Commands::Debug { input } => { + nand::emunand::debug(input)? + }, + nand::emunand::Commands::InstallTitle { wad, emunand } => { + nand::emunand::install_title(wad, emunand)? + } + } } Some(Commands::Fakesign { input, output }) => { title::fakesign::fakesign(input, output)? @@ -106,7 +127,17 @@ fn main() -> Result<()> { title::nus::download_tmd(tid, version, output)? } } - } + }, + Some(Commands::Setting { command }) => { + match command { + nand::setting::Commands::Decrypt { input, output } => { + nand::setting::decrypt_setting(input, output)?; + }, + nand::setting::Commands::Encrypt { input, output } => { + nand::setting::encrypt_setting(input, output)?; + } + } + }, Some(Commands::U8 { command }) => { match command { archive::u8::Commands::Pack { input, output } => { diff --git a/src/bin/rustii/nand/emunand.rs b/src/bin/rustii/nand/emunand.rs new file mode 100644 index 0000000..2852c68 --- /dev/null +++ b/src/bin/rustii/nand/emunand.rs @@ -0,0 +1,51 @@ +// nand/emunand.rs from rustii (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii +// +// Code for EmuNAND-related commands in the rustii CLI. + +use std::{str, fs}; +use std::path::{Path, PathBuf}; +use anyhow::{bail, Context, Result}; +use clap::Subcommand; +use rustii::nand::emunand; +use rustii::title; + +#[derive(Subcommand)] +#[command(arg_required_else_help = true)] +pub enum Commands { + /// Placeholder command for debugging EmuNAND module + Debug { + /// The path to the test EmuNAND + input: String, + }, + InstallTitle { + /// The path to the WAD file to install + wad: String, + /// The path to the target EmuNAND + emunand: String, + } +} + +pub fn debug(input: &str) -> Result<()> { + let emunand_root = Path::new(input); + let emunand = emunand::EmuNAND::open(emunand_root.to_path_buf())?; + emunand.install_title(title::Title::from_bytes(&fs::read("channel_retail.wad")?)?)?; + Ok(()) +} + +pub fn install_title(wad: &str, emunand: &str) -> Result<()> { + let wad_path = Path::new(wad); + if !wad_path.exists() { + bail!("Source WAD \"{}\" could not be found.", wad_path.display()); + } + let emunand_path = Path::new(emunand); + if !emunand_path.exists() { + bail!("Target EmuNAND directory \"{}\" could not be found.", emunand_path.display()); + } + let wad_file = fs::read(wad_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", wad_path.display()))?; + let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", wad_path.display()))?; + let emunand = emunand::EmuNAND::open(emunand_path.to_path_buf())?; + emunand.install_title(title)?; + println!("Successfully installed WAD \"{}\" to EmuNAND at \"{}\"!", wad_path.display(), emunand_path.display()); + Ok(()) +} diff --git a/src/bin/rustii/nand/mod.rs b/src/bin/rustii/nand/mod.rs new file mode 100644 index 0000000..9d92790 --- /dev/null +++ b/src/bin/rustii/nand/mod.rs @@ -0,0 +1,5 @@ +// nand/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii + +pub mod emunand; +pub mod setting; diff --git a/src/bin/rustii/nand/setting.rs b/src/bin/rustii/nand/setting.rs new file mode 100644 index 0000000..70af9f6 --- /dev/null +++ b/src/bin/rustii/nand/setting.rs @@ -0,0 +1,61 @@ +// nand/setting.rs from rustii (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii +// +// Code for setting.txt-related commands in the rustii CLI. + +use std::{str, fs}; +use std::path::{Path, PathBuf}; +use anyhow::{bail, Context, Result}; +use clap::Subcommand; +use rustii::nand::setting; + +#[derive(Subcommand)] +#[command(arg_required_else_help = true)] +pub enum Commands { + /// Decrypt setting.txt + Decrypt { + /// The path to the setting.txt file to decrypt + input: String, + /// An optional output path; defaults to setting_dec.txt + #[arg(short, long)] + output: Option, + }, + /// Encrypt setting.txt + Encrypt { + /// The path to the setting.txt to encrypt + input: String, + /// An optional output path; defaults to setting_enc.txt + #[arg(short, long)] + output: Option, + } +} + +pub fn decrypt_setting(input: &str, output: &Option) -> Result<()> { + let in_path = Path::new(input); + if !in_path.exists() { + bail!("Source file \"{}\" could not be found.", in_path.display()); + } + let out_path = if output.is_some() { + PathBuf::from(output.clone().unwrap()).with_extension("txt") + } else { + PathBuf::from("setting_dec.txt") + }; + let setting = setting::SettingTxt::from_bytes(&fs::read(in_path)?).with_context(|| "The provided setting.txt could not be parsed, and is likely invalid.")?; + fs::write(out_path, setting.to_string()?)?; + Ok(()) +} + +pub fn encrypt_setting(input: &str, output: &Option) -> Result<()> { + let in_path = Path::new(input); + if !in_path.exists() { + bail!("Source file \"{}\" could not be found.", in_path.display()); + } + let out_path = if output.is_some() { + PathBuf::from(output.clone().unwrap()).with_extension("txt") + } else { + PathBuf::from("setting_enc.txt") + }; + let setting = setting::SettingTxt::from_string(String::from_utf8(fs::read(in_path)?).with_context(|| "Invalid characters found in input file!")?)?; + fs::write(out_path, setting.to_bytes()?)?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 18bcfe4..1225254 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,4 +4,5 @@ // Root level module that imports the feature modules. pub mod archive; +pub mod nand; pub mod title; diff --git a/src/nand/emunand.rs b/src/nand/emunand.rs new file mode 100644 index 0000000..675814c --- /dev/null +++ b/src/nand/emunand.rs @@ -0,0 +1,150 @@ +// nand/emunand.rs from rustii (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii +// +// Implements the structures and methods required for handling Wii EmuNANDs. + +use std::fs; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use crate::nand::sys; +use crate::title; +use crate::title::{cert, content, ticket, tmd}; + +#[derive(Debug, Error)] +pub enum EmuNANDError { + #[error("EmuNAND requires the directory `{0}`, but a file with that name already exists")] + DirectoryNameConflict(String), + #[error("specified EmuNAND root does not exist")] + RootNotFound, + #[error("uid.sys processing error")] + UidSys(#[from] sys::UidSysError), + #[error("certificate processing error")] + CertificateError(#[from] cert::CertificateError), + #[error("TMD processing error")] + TMD(#[from] tmd::TMDError), + #[error("Ticket processing error")] + Ticket(#[from] ticket::TicketError), + #[error("content processing error")] + Content(#[from] content::ContentError), + #[error("io error occurred during EmuNAND operation")] + IO(#[from] std::io::Error), +} + +fn safe_create_dir(dir: &PathBuf) -> Result<(), EmuNANDError> { + if !dir.exists() { + fs::create_dir(dir)?; + } else if !dir.is_dir() { + return Err(EmuNANDError::DirectoryNameConflict(dir.to_str().unwrap().to_string())); + } + Ok(()) +} + +/// An EmuNAND object that allows for creating and modifying Wii EmuNANDs. +pub struct EmuNAND { + emunand_root: PathBuf, + emunand_dirs: HashMap, +} + +impl EmuNAND { + /// Open an existing EmuNAND in an EmuNAND instance that can be used to interact with it. This + /// will initialize the basic directory structure if it doesn't already exist, but will not do + /// anything beyond that. + pub fn open(emunand_root: PathBuf) -> Result { + if !emunand_root.exists() { + return Err(EmuNANDError::RootNotFound); + } + let mut emunand_dirs: HashMap = HashMap::new(); + emunand_dirs.insert(String::from("import"), emunand_root.join("import")); + emunand_dirs.insert(String::from("meta"), emunand_root.join("meta")); + emunand_dirs.insert(String::from("shared1"), emunand_root.join("shared1")); + emunand_dirs.insert(String::from("shared2"), emunand_root.join("shared2")); + emunand_dirs.insert(String::from("sys"), emunand_root.join("sys")); + emunand_dirs.insert(String::from("ticket"), emunand_root.join("ticket")); + emunand_dirs.insert(String::from("title"), emunand_root.join("title")); + emunand_dirs.insert(String::from("tmp"), emunand_root.join("tmp")); + emunand_dirs.insert(String::from("wfs"), emunand_root.join("wfs")); + for dir in emunand_dirs.keys() { + if !emunand_dirs[dir].exists() { + fs::create_dir(&emunand_dirs[dir])?; + } else if !emunand_dirs[dir].is_dir() { + return Err(EmuNANDError::DirectoryNameConflict(emunand_dirs[dir].to_str().unwrap().to_string())); + } + } + Ok(EmuNAND { + emunand_root, + emunand_dirs, + }) + } + + /// Install the provided title to the EmuNAND, mimicking a WAD installation performed by ES. + pub fn install_title(&self, title: title::Title) -> Result<(), EmuNANDError> { + // Save the two halves of the TID, since those are part of the installation path. + let tid_high = hex::encode(&title.tmd.title_id()[0..4]); + let tid_low = hex::encode(&title.tmd.title_id()[4..8]); + // Tickets are installed to /ticket//.tik. + let ticket_dir = self.emunand_dirs["ticket"].join(&tid_high); + safe_create_dir(&ticket_dir)?; + fs::write(ticket_dir.join(format!("{}.tik", &tid_low)), title.ticket.to_bytes()?)?; + // TMDs and normal content (non-shared) are installed to + // /title///content/, as title.tmd and .app. + let mut title_dir = self.emunand_dirs["title"].join(&tid_high); + safe_create_dir(&title_dir)?; + title_dir = title_dir.join(&tid_low); + safe_create_dir(&title_dir)?; + // Create an empty "data" dir if it doesn't exist. + safe_create_dir(&title_dir.join("data"))?; + title_dir = title_dir.join("content"); + // Delete any existing installed content/the current TMD. + if title_dir.exists() { + fs::remove_dir_all(&title_dir)?; + } + fs::create_dir(&title_dir)?; + fs::write(title_dir.join("title.tmd"), title.content.to_bytes()?)?; + for i in 0..title.content.content_records.borrow().len() { + if matches!(title.content.content_records.borrow()[i].content_type, tmd::ContentType::Normal) { + let content_path = title_dir.join(format!("{:08X}.app", title.content.content_records.borrow()[i].content_id).to_ascii_lowercase()); + fs::write(content_path, title.get_content_by_index(i)?)?; + } + } + // Shared content needs to be installed to /shared1/, with incremental names decided by + // the records in /shared1/content.map. + // Start by checking for a map and loading it if it exists, so that we know what shared + // content is already installed. + let content_map_path = self.emunand_dirs["shared1"].join("content.map"); + let mut content_map = if content_map_path.exists() { + content::SharedContentMap::from_bytes(&fs::read(&content_map_path)?)? + } else { + content::SharedContentMap::new() + }; + for i in 0..title.content.content_records.borrow().len() { + if matches!(title.content.content_records.borrow()[i].content_type, tmd::ContentType::Shared) { + if let Some(file_name) = content_map.add(&title.content.content_records.borrow()[i].content_hash)? { + let content_path = self.emunand_dirs["shared1"].join(format!("{}.app", file_name.to_ascii_lowercase())); + fs::write(content_path, title.get_content_by_index(i)?)?; + } + } + } + fs::write(&content_map_path, content_map.to_bytes()?)?; + // The "footer" (officially "meta") is installed to /meta///title.met. + let meta_data = title.meta(); + if !meta_data.is_empty() { + let mut meta_dir = self.emunand_dirs["meta"].join(&tid_high); + safe_create_dir(&meta_dir)?; + meta_dir = meta_dir.join(&tid_low); + safe_create_dir(&meta_dir)?; + fs::write(meta_dir.join("title.met"), meta_data)?; + } + // Finally, we need to update uid.sys (or create it if it doesn't exist) so that the newly + // installed title will actually show up (at least for channels). + let uid_sys_path = self.emunand_dirs["sys"].join("uid.sys"); + let mut uid_sys = if uid_sys_path.exists() { + sys::UidSys::from_bytes(&fs::read(&uid_sys_path)?)? + } else { + sys::UidSys::new() + }; + uid_sys.add(&title.tmd.title_id())?; + fs::write(&uid_sys_path, &uid_sys.to_bytes()?)?; + Ok(()) + } +} diff --git a/src/nand/mod.rs b/src/nand/mod.rs new file mode 100644 index 0000000..d26b94b --- /dev/null +++ b/src/nand/mod.rs @@ -0,0 +1,8 @@ +// nand/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii +// +// Root for all NAND-related modules. + +pub mod emunand; +pub mod setting; +pub mod sys; diff --git a/src/nand/setting.rs b/src/nand/setting.rs new file mode 100644 index 0000000..e8e3220 --- /dev/null +++ b/src/nand/setting.rs @@ -0,0 +1,102 @@ +// nand/setting.rs from rustii (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii +// +// Implements the structures and methods required for parsing and editing setting.txt in the Wii +// Menu's data. + +use std::collections::HashMap; +use std::io::Cursor; +use byteorder::ReadBytesExt; + +const SETTINGS_KEY: u32 = 0x73B5DBFA; + +/// A structure that allows for encrypting, decrypting, parsing, and editing a setting.txt file. +pub struct SettingTxt { + pub area: String, + pub model: String, + pub dvd: u8, + pub mpch: String, + pub code: String, + pub serial_number: String, + pub video: String, + pub game: String, +} + +impl SettingTxt { + /// Creates a new SettingTxt instance from the binary data of an encrypted setting.txt file. + pub fn from_bytes(data: &[u8]) -> Result { + // Unlike most files we have to deal with, setting.txt is encrypted. This means we need to + // decrypt it first, and *then* we can parse it. + let mut buf = Cursor::new(data); + let mut key: u32 = SETTINGS_KEY; + let mut dec_data: Vec = Vec::new(); + for _ in 0..256 { + dec_data.push(buf.read_u8()? ^ (key & 0xFF) as u8); + key = key.rotate_left(1); // Automatic bit rotation!? Thanks for the tip clippy! + } + let setting_str = String::from_utf8_lossy(&dec_data); + let setting_str = setting_str[0..setting_str.clone().rfind('\n').unwrap_or(setting_str.len() - 2) + 1].to_string(); + println!("{:?}", setting_str); + let setting_txt = SettingTxt::from_string(setting_str)?; + Ok(setting_txt) + } + + /// Creates a new SettingTxt instance from the decrypted text of a setting.txt file. + pub fn from_string(data: String) -> Result { + let mut setting_keys: HashMap = HashMap::new(); + for line in data.lines() { + println!("{}", line); + let (key, value) = line.split_once("=").unwrap(); + setting_keys.insert(key.to_owned(), value.to_owned()); + } + let area = setting_keys["AREA"].to_string(); + let model = setting_keys["MODEL"].to_string(); + let dvd = setting_keys["DVD"].as_str().parse::().unwrap(); + let mpch = setting_keys["MPCH"].to_string(); + let code = setting_keys["CODE"].to_string(); + let serial_number = setting_keys["SERNO"].to_string(); + let video = setting_keys["VIDEO"].to_string(); + let game = setting_keys["GAME"].to_string(); + Ok(SettingTxt { + area, + model, + dvd, + mpch, + code, + serial_number, + video, + game, + }) + } + + /// Encrypts and then dumps the data in a SettingTxt instance back into binary data that can be + /// written to a file. + pub fn to_bytes(&self) -> Result, std::io::Error> { + let setting_str = self.to_string()?; + let setting_bytes = setting_str.as_bytes(); + let mut buf = Cursor::new(setting_bytes); + let mut key: u32 = SETTINGS_KEY; + let mut enc_data: Vec = Vec::new(); + for _ in 0..setting_str.len() { + enc_data.push(buf.read_u8()? ^ (key & 0xFF) as u8); + key = key.rotate_left(1); + } + enc_data.resize(256, 0); + Ok(enc_data) + } + + /// Dumps the decrypted data in a SettingTxt instance into a string that can be written to a + /// file. + pub fn to_string(&self) -> Result { + let mut setting_str = String::new(); + setting_str += &format!("AREA={}\r\n", self.area); + setting_str += &format!("MODEL={}\r\n", self.model); + setting_str += &format!("DVD={}\r\n", self.dvd); + setting_str += &format!("MPCH={}\r\n", self.mpch); + setting_str += &format!("CODE={}\r\n", self.code); + setting_str += &format!("SERNO={}\r\n", self.serial_number); + setting_str += &format!("VIDEO={}\r\n", self.video); + setting_str += &format!("GAME={}\r\n", self.game); + Ok(setting_str) + } +} diff --git a/src/nand/sys.rs b/src/nand/sys.rs new file mode 100644 index 0000000..a87985d --- /dev/null +++ b/src/nand/sys.rs @@ -0,0 +1,87 @@ +// nand/sys.rs from rustii (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii +// +// Implements the structures and methods required for parsing and editing files in /sys/ on the +// Wii's NAND. + +use std::io::{Cursor, Read, Write}; +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum UidSysError { + #[error("uid.sys is an invalid length and cannot be parsed")] + InvalidUidSysLength, + #[error("uid.sys data is not in a valid format")] + IO(#[from] std::io::Error), +} + +/// A structure that represents a Title ID/UID pairing in a uid.sys file. +pub struct UidSysEntry { + pub title_id: [u8; 8], + pub uid: u32, +} + +/// A structure that allows for creating, parsing, and editing a /sys/uid.sys file. +pub struct UidSys { + entries: Vec, +} + +impl UidSys { + /// Creates a new UidSys instance from the binary data of a uid.sys file. + pub fn from_bytes(data: &[u8]) -> Result { + // The uid.sys file must be divisible by a multiple of 12, or something is wrong, since each + // entry is 12 bytes long. + if (data.len() % 12) != 0 { + return Err(UidSysError::InvalidUidSysLength); + } + let entry_count = data.len() / 12; + let mut buf = Cursor::new(data); + let mut entries: Vec = Vec::new(); + for _ in 0..entry_count { + let mut title_id = [0u8; 8]; + buf.read_exact(&mut title_id)?; + let uid = buf.read_u32::()?; + entries.push(UidSysEntry { title_id, uid }); + } + Ok(UidSys { entries }) + } + + /// Creates a new UidSys instance and initializes it with the default entry of the Wii Menu + /// (0000000100000002) with UID 0x1000. + pub fn new() -> Self { + let mut uid_sys = UidSys { entries: Vec::new() }; + uid_sys.add(&[0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x2]).unwrap(); + uid_sys + } + + /// Dumps the data in a UidSys back into binary data that can be written to a file. + pub fn to_bytes(&self) -> Result, UidSysError> { + let mut buf: Vec = Vec::new(); + for entry in self.entries.iter() { + buf.write_all(&entry.title_id)?; + buf.write_u32::(entry.uid)?; + } + Ok(buf) + } + + /// Adds a new Title ID to uid.sys, and assigns it a new UID. The new Title ID will only be + /// added if it is not already present in the file. Returns None if the Title ID was already + /// present, or the newly assigned UID if the Title ID was just added. + pub fn add(&mut self, title_id: &[u8; 8]) -> Result, UidSysError> { + // Return None if the Title ID is already accounted for. + if self.entries.iter().any(|entry| entry.title_id == *title_id) { + return Ok(None); + } + // Find the highest UID and increment it to choose the UID for the new Title ID. + let max_uid = self.entries.iter() + .max_by_key(|entry| entry.uid) + .map(|entry| entry.uid) + .unwrap_or(4095); + self.entries.push(UidSysEntry { + title_id: *title_id, + uid: max_uid + 1, + }); + Ok(Some(max_uid + 1)) + } +} diff --git a/src/title/content.rs b/src/title/content.rs index a3c5baa..843cf9e 100644 --- a/src/title/content.rs +++ b/src/title/content.rs @@ -26,6 +26,10 @@ pub enum ContentError { CIDAlreadyExists(u32), #[error("content's hash did not match the expected value (was {hash}, expected {expected})")] BadHash { hash: String, expected: String }, + #[error("content.map is an invalid length and cannot be parsed")] + InvalidSharedContentMapLength, + #[error("found invalid shared content name `{0}`")] + InvalidSharedContentName(String), #[error("content data is not in a valid format")] IO(#[from] std::io::Error), } @@ -106,7 +110,7 @@ impl ContentRegion { pub fn to_bytes(&self) -> Result, std::io::Error> { let mut buf: Vec = Vec::new(); for i in 0..self.content_records.borrow().len() { - let mut content = self.contents[i as usize].clone(); + let mut content = self.contents[i].clone(); // Round up size to nearest 64 to add appropriate padding. content.resize((content.len() + 63) & !63, 0); buf.write_all(&content)?; @@ -285,3 +289,83 @@ impl ContentRegion { Ok(()) } } + +#[derive(Debug)] +/// A structure that represents a shared Content ID/content hash pairing in a content.map file. +pub struct ContentMapEntry { + pub shared_id: u32, + pub hash: [u8; 20], +} + +/// A structure that allows for parsing and editing a /shared1/content.map file. +pub struct SharedContentMap { + pub records: Vec, +} + +impl SharedContentMap { + /// Creates a new SharedContentMap instance from the binary data of a content.map file. + pub fn from_bytes(data: &[u8]) -> Result { + // The uid.sys file must be divisible by a multiple of 28, or something is wrong, since each + // entry is 28 bytes long. + if (data.len() % 28) != 0 { + return Err(ContentError::InvalidSharedContentMapLength); + } + let record_count = data.len() / 28; + let mut buf = Cursor::new(data); + let mut records: Vec = Vec::new(); + for _ in 0..record_count { + // This requires some convoluted parsing, because Nintendo represents the file names as + // actual chars and not numbers, despite the fact that the names are always numbers and + // using numbers would make incrementing easier. Read the names in as a string, and then + // parse that hex string into a u32. + let mut shared_id_bytes = [0u8; 8]; + buf.read_exact(&mut shared_id_bytes)?; + let shared_id_str = String::from_utf8_lossy(&shared_id_bytes); + let shared_id = match u32::from_str_radix(&shared_id_str, 16) { + Ok(id) => id, + Err(_) => return Err(ContentError::InvalidSharedContentName(shared_id_str.to_string())), + }; + let mut hash = [0u8; 20]; + buf.read_exact(&mut hash)?; + records.push(ContentMapEntry { shared_id, hash }); + } + Ok(SharedContentMap { records }) + } + + /// Creates a new, empty SharedContentMap instance that can then be populated. + pub fn new() -> Self { + SharedContentMap { records: Vec::new() } + } + + /// Dumps the data in a SharedContentMap back into binary data that can be written to a file. + pub fn to_bytes(&self) -> Result, std::io::Error> { + let mut buf: Vec = Vec::new(); + for record in self.records.iter() { + let shared_id = format!("{:08X}", record.shared_id).to_ascii_lowercase(); + buf.write_all(shared_id.as_bytes())?; + buf.write_all(&record.hash)?; + } + Ok(buf) + } + + /// Adds new shared content to content.map, and assigns it a new file name. The new content + /// will only be added if its hash is not already present in the file. Returns None if the + /// content hash was already present, or the assigned file name if the hash was just added. + pub fn add(&mut self, hash: &[u8; 20]) -> Result, ContentError> { + // Return None if the hash is already accounted for. + if self.records.iter().any(|entry| entry.hash == *hash) { + return Ok(None); + } + // Find the highest index (represented by the file name) and increment it to choose the + // name for the new shared content. + let max_index = self.records.iter() + .max_by_key(|record| record.shared_id) + .map(|record| record.shared_id + 1) + .unwrap_or(0); + self.records.push(ContentMapEntry { + shared_id: max_index, + hash: *hash, + }); + Ok(Some(format!("{:08X}", max_index))) + } +} diff --git a/src/title/tmd.rs b/src/title/tmd.rs index e11feda..6267f82 100644 --- a/src/title/tmd.rs +++ b/src/title/tmd.rs @@ -124,50 +124,50 @@ impl TMD { /// Creates a new TMD instance from the binary data of a TMD file. pub fn from_bytes(data: &[u8]) -> Result { let mut buf = Cursor::new(data); - let signature_type = buf.read_u32::().map_err(TMDError::IO)?; + let signature_type = buf.read_u32::()?; let mut signature = [0u8; 256]; - buf.read_exact(&mut signature).map_err(TMDError::IO)?; + buf.read_exact(&mut signature)?; // Maybe this can be read differently? let mut padding1 = [0u8; 60]; - buf.read_exact(&mut padding1).map_err(TMDError::IO)?; + buf.read_exact(&mut padding1)?; let mut signature_issuer = [0u8; 64]; - buf.read_exact(&mut signature_issuer).map_err(TMDError::IO)?; - let tmd_version = buf.read_u8().map_err(TMDError::IO)?; - let ca_crl_version = buf.read_u8().map_err(TMDError::IO)?; - let signer_crl_version = buf.read_u8().map_err(TMDError::IO)?; - let is_vwii = buf.read_u8().map_err(TMDError::IO)?; + buf.read_exact(&mut signature_issuer)?; + let tmd_version = buf.read_u8()?; + let ca_crl_version = buf.read_u8()?; + let signer_crl_version = buf.read_u8()?; + let is_vwii = buf.read_u8()?; let mut ios_tid = [0u8; 8]; - buf.read_exact(&mut ios_tid).map_err(TMDError::IO)?; + buf.read_exact(&mut ios_tid)?; let mut title_id = [0u8; 8]; - buf.read_exact(&mut title_id).map_err(TMDError::IO)?; + buf.read_exact(&mut title_id)?; let mut title_type = [0u8; 4]; - buf.read_exact(&mut title_type).map_err(TMDError::IO)?; - let group_id = buf.read_u16::().map_err(TMDError::IO)?; + buf.read_exact(&mut title_type)?; + let group_id = buf.read_u16::()?; // Same here... let mut padding2 = [0u8; 2]; - buf.read_exact(&mut padding2).map_err(TMDError::IO)?; - let region = buf.read_u16::().map_err(TMDError::IO)?; + buf.read_exact(&mut padding2)?; + let region = buf.read_u16::()?; let mut ratings = [0u8; 16]; - buf.read_exact(&mut ratings).map_err(TMDError::IO)?; + buf.read_exact(&mut ratings)?; // ...and here... let mut reserved1 = [0u8; 12]; - buf.read_exact(&mut reserved1).map_err(TMDError::IO)?; + buf.read_exact(&mut reserved1)?; let mut ipc_mask = [0u8; 12]; - buf.read_exact(&mut ipc_mask).map_err(TMDError::IO)?; + buf.read_exact(&mut ipc_mask)?; // ...and here. let mut reserved2 = [0u8; 18]; - buf.read_exact(&mut reserved2).map_err(TMDError::IO)?; - let access_rights = buf.read_u32::().map_err(TMDError::IO)?; - let title_version = buf.read_u16::().map_err(TMDError::IO)?; - let num_contents = buf.read_u16::().map_err(TMDError::IO)?; - let boot_index = buf.read_u16::().map_err(TMDError::IO)?; - let minor_version = buf.read_u16::().map_err(TMDError::IO)?; + buf.read_exact(&mut reserved2)?; + let access_rights = buf.read_u32::()?; + let title_version = buf.read_u16::()?; + let num_contents = buf.read_u16::()?; + let boot_index = buf.read_u16::()?; + let minor_version = buf.read_u16::()?; // Build content records by iterating over the rest of the data num_contents times. let mut content_records = Vec::with_capacity(num_contents as usize); for _ in 0..num_contents { - let content_id = buf.read_u32::().map_err(TMDError::IO)?; - let index = buf.read_u16::().map_err(TMDError::IO)?; - let type_int = buf.read_u16::().map_err(TMDError::IO)?; + let content_id = buf.read_u32::()?; + let index = buf.read_u16::()?; + let type_int = buf.read_u16::()?; let content_type = match type_int { 1 => ContentType::Normal, 2 => ContentType::Development, @@ -176,9 +176,9 @@ impl TMD { 32769 => ContentType::Shared, _ => return Err(TMDError::InvalidContentType(type_int)) }; - let content_size = buf.read_u64::().map_err(TMDError::IO)?; + let content_size = buf.read_u64::()?; let mut content_hash = [0u8; 20]; - buf.read_exact(&mut content_hash).map_err(TMDError::IO)?; + buf.read_exact(&mut content_hash)?; content_records.push(ContentRecord { content_id, index,