diff --git a/src/bin/playground/main.rs b/src/bin/playground/main.rs index ade9ad6..25f8100 100644 --- a/src/bin/playground/main.rs +++ b/src/bin/playground/main.rs @@ -16,34 +16,34 @@ fn main() { let patch_count = title::iospatcher::ios_patch_sigchecks(&mut title, index).unwrap(); println!("patches applied: {}", patch_count); - println!("Title ID from WAD via Title object: {}", hex::encode(title.tmd.title_id())); + println!("Title ID from WAD via Title object: {}", hex::encode(title.tmd().title_id())); let wad = wad::WAD::from_bytes(&data).unwrap(); println!("size of tmd: {:?}", wad.tmd().len()); - println!("num content records: {:?}", title.tmd.content_records().len()); - println!("first record data: {:?}", title.tmd.content_records().first().unwrap()); - println!("TMD is fakesigned: {:?}",title.tmd.is_fakesigned()); + println!("num content records: {:?}", title.tmd().content_records().len()); + println!("first record data: {:?}", title.tmd().content_records().first().unwrap()); + println!("TMD is fakesigned: {:?}",title.tmd().is_fakesigned()); - println!("title version from ticket is: {:?}", title.ticket.title_version()); - println!("title key (enc): {:?}", title.ticket.title_key()); - println!("title key (dec): {:?}", title.ticket.title_key_dec()); - println!("ticket is fakesigned: {:?}", title.ticket.is_fakesigned()); + println!("title version from ticket is: {:?}", title.ticket().title_version()); + println!("title key (enc): {:?}", title.ticket().title_key()); + println!("title key (dec): {:?}", title.ticket().title_key_dec()); + println!("ticket is fakesigned: {:?}", title.ticket().is_fakesigned()); println!("title is fakesigned: {:?}", title.is_fakesigned()); - let cert_chain = &title.cert_chain; + let cert_chain = &title.cert_chain(); println!("cert chain OK"); let result = cert::verify_ca_cert(&cert_chain.ca_cert()).unwrap(); println!("CA cert {} verified successfully: {}", cert_chain.ca_cert().child_cert_identity(), result); let result = cert::verify_child_cert(&cert_chain.ca_cert(), &cert_chain.tmd_cert()).unwrap(); println!("TMD cert {} verified successfully: {}", cert_chain.tmd_cert().child_cert_identity(), result); - let result = cert::verify_tmd(&cert_chain.tmd_cert(), &title.tmd).unwrap(); + let result = cert::verify_tmd(&cert_chain.tmd_cert(), title.tmd()).unwrap(); println!("TMD verified successfully: {}", result); let result = cert::verify_child_cert(&cert_chain.ca_cert(), &cert_chain.ticket_cert()).unwrap(); println!("Ticket cert {} verified successfully: {}", cert_chain.ticket_cert().child_cert_identity(), result); - let result = cert::verify_ticket(&cert_chain.ticket_cert(), &title.ticket).unwrap(); + let result = cert::verify_ticket(&cert_chain.ticket_cert(), title.ticket()).unwrap(); println!("Ticket verified successfully: {}", result); let result = title.verify().unwrap(); diff --git a/src/bin/rustwii/info.rs b/src/bin/rustwii/info.rs index 46da4c7..e8e6b5d 100644 --- a/src/bin/rustwii/info.rs +++ b/src/bin/rustwii/info.rs @@ -39,7 +39,7 @@ fn print_title_version(title_version: u16, title_id: [u8; 8], is_vwii: bool) -> Ok(()) } -fn print_tmd_info(tmd: tmd::TMD, cert: Option) -> Result<()> { +fn print_tmd_info(tmd: &tmd::TMD, cert: Option) -> Result<()> { // Print all important keys from the TMD. println!("Title Info"); print_tid(tmd.title_id())?; @@ -91,7 +91,7 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option) -> Result<()> println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo)); println!(" AHB Access: {}", tmd.check_access_right(tmd::AccessRight::AHB)); if let Some(cert) = cert { - let signing_str = match cert::verify_tmd(&cert, &tmd) { + let signing_str = match cert::verify_tmd(&cert, tmd) { Ok(result) => match result { true => "Valid (Unmodified TMD)", false => { @@ -128,7 +128,7 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option) -> Result<()> Ok(()) } -fn print_ticket_info(ticket: ticket::Ticket, cert: Option) -> Result<()> { +fn print_ticket_info(ticket: &ticket::Ticket, cert: Option) -> Result<()> { // Print all important keys from the Ticket. println!("Ticket Info"); print_tid(ticket.title_id())?; @@ -160,7 +160,7 @@ fn print_ticket_info(ticket: ticket::Ticket, cert: Option) -> println!(" Title Key (Encrypted): {}", hex::encode(ticket.title_key())); println!(" Title Key (Decrypted): {}", hex::encode(ticket.title_key_dec())); if let Some(cert) = cert { - let signing_str = match cert::verify_ticket(&cert, &ticket) { + let signing_str = match cert::verify_ticket(&cert, ticket) { Ok(result) => match result { true => "Valid (Unmodified Ticket)", false => { @@ -216,9 +216,9 @@ fn print_wad_info(wad: wad::WAD) -> Result<()> { false => { if title.is_fakesigned() { "Fakesigned" - } else if cert::verify_tmd(&title.cert_chain.tmd_cert(), &title.tmd)? { + } else if cert::verify_tmd(&title.cert_chain().tmd_cert(), title.tmd())? { "Piratelegit (Unmodified TMD, Modified Ticket)" - } else if cert::verify_ticket(&title.cert_chain.ticket_cert(), &title.ticket)? { + } else if cert::verify_ticket(&title.cert_chain().ticket_cert(), title.ticket())? { "Edited (Modified TMD, Unmodified Ticket)" } else { "Illegitimate (Modified TMD + Ticket)" @@ -235,9 +235,9 @@ fn print_wad_info(wad: wad::WAD) -> Result<()> { }; println!(" Signing Status: {}", signing_str); println!(); - print_ticket_info(title.ticket, Some(title.cert_chain.ticket_cert()))?; + print_ticket_info(title.ticket(), Some(title.cert_chain().ticket_cert()))?; println!(); - print_tmd_info(title.tmd, Some(title.cert_chain.tmd_cert()))?; + print_tmd_info(title.tmd(), Some(title.cert_chain().tmd_cert()))?; Ok(()) } @@ -278,11 +278,11 @@ pub fn info(input: &str) -> Result<()> { match identify_file_type(input) { Some(WiiFileType::Tmd) => { let tmd = tmd::TMD::from_bytes(&fs::read(in_path)?).with_context(|| "The provided TMD file could not be parsed, and is likely invalid.")?; - print_tmd_info(tmd, None)?; + print_tmd_info(&tmd, None)?; }, Some(WiiFileType::Ticket) => { let ticket = ticket::Ticket::from_bytes(&fs::read(in_path)?).with_context(|| "The provided Ticket file could not be parsed, and is likely invalid.")?; - print_ticket_info(ticket, None)?; + print_ticket_info(&ticket, None)?; }, Some(WiiFileType::Wad) => { let wad = wad::WAD::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?; diff --git a/src/bin/rustwii/nand/emunand.rs b/src/bin/rustwii/nand/emunand.rs index 49d3e45..01e8404 100644 --- a/src/bin/rustwii/nand/emunand.rs +++ b/src/bin/rustwii/nand/emunand.rs @@ -290,7 +290,7 @@ pub fn install_missing(emunand: &str, vwii: &bool) -> Result<()> { for ios in missing_tids { println!("Downloading IOS{} ({})...", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase()); let title = nus::download_title(ios, None, true)?; - let version = title.tmd.title_version(); + let version = title.tmd().title_version(); println!(" Installing IOS{} ({}) v{}...", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase(), version); emunand.install_title(title, false)?; println!(" Installed IOS{} ({}) v{}!", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase(), version); @@ -325,7 +325,7 @@ pub fn uninstall_title(tid: &str, emunand: &str, remove_ticket: &bool) -> Result let tid_bin: [u8; 8] = if tid_as_path.exists() { let wad_file = fs::read(tid_as_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", tid_as_path.display()))?; let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", tid_as_path.display()))?; - title.tmd.title_id() + title.tmd().title_id() } else { hex::decode(tid).with_context(|| "The specified Title ID is not valid! The Title ID must be in hex format.")?.try_into().unwrap() }; diff --git a/src/bin/rustwii/title/iospatcher.rs b/src/bin/rustwii/title/iospatcher.rs index 9982060..9bc4008 100644 --- a/src/bin/rustwii/title/iospatcher.rs +++ b/src/bin/rustwii/title/iospatcher.rs @@ -9,6 +9,7 @@ use anyhow::{bail, Context, Result}; use clap::Args; use rustwii::title; use rustwii::title::iospatcher; +use rustwii::title::tmd::ContentType; #[derive(Args)] #[clap(next_help_heading = "Patches")] @@ -51,7 +52,7 @@ pub fn patch_ios( }; let mut ios = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?; - let tid = hex::encode(ios.tmd.title_id()); + let tid = hex::encode(ios.tmd().title_id()); // If the TID is not a valid IOS TID, then bail. if !tid[..8].eq("00000001") || tid[8..].eq("00000001") || tid[8..].eq("00000002") { @@ -102,6 +103,12 @@ pub fn patch_ios( println!("{} patch(es) applied", count); patches_applied += count; } + + // Set the type of the content containing ES to "Normal" to avoid it getting installed to + // /shared1 on NAND. + if *no_shared { + set_type_normal(&mut ios, es_index)?; + } } if enabled_patches.drive_inquiry { @@ -112,6 +119,10 @@ pub fn patch_ios( let count = iospatcher::ios_patch_drive_inquiry(&mut ios, dip_index)?; println!("{} patch(es) applied", count); patches_applied += count; + + if *no_shared { + set_type_normal(&mut ios, dip_index)?; + } } println!("\nTotal patches applied: {patches_applied}"); @@ -127,3 +138,13 @@ pub fn patch_ios( println!("IOS successfully patched!"); Ok(()) } + +fn set_type_normal(ios: &mut title::Title, index: usize) -> Result<()> { + let mut content_records = ios.tmd().content_records().clone(); + content_records[index].content_type = ContentType::Normal; + let mut tmd = ios.tmd().clone(); + tmd.set_content_records(content_records); + ios.set_tmd(tmd); + + Ok(()) +} diff --git a/src/bin/rustwii/title/nus.rs b/src/bin/rustwii/title/nus.rs index b85c251..ad3d375 100644 --- a/src/bin/rustwii/title/nus.rs +++ b/src/bin/rustwii/title/nus.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use anyhow::{bail, Context, Result}; use clap::{Subcommand, Args}; use sha1::{Sha1, Digest}; -use rustwii::title::{cert, content, crypto, nus, ticket, tmd}; +use rustwii::title::{cert, crypto, nus, ticket, tmd}; use rustwii::title; #[derive(Subcommand)] @@ -159,15 +159,15 @@ fn download_title_dir(title: title::Title, output: String) -> Result<()> { } else { fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?; } - let tid = hex::encode(title.tmd.title_id()); + let tid = hex::encode(title.tmd().title_id()); println!(" - Saving TMD..."); - fs::write(out_path.join(format!("{}.tmd", &tid)), title.tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?; + fs::write(out_path.join(format!("{}.tmd", &tid)), title.tmd().to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?; println!(" - Saving Ticket..."); - fs::write(out_path.join(format!("{}.tik", &tid)), title.ticket.to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}.tmd\" for writing.", tid))?; + fs::write(out_path.join(format!("{}.tik", &tid)), title.ticket().to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}.tmd\" for writing.", tid))?; println!(" - Saving certificate chain..."); - fs::write(out_path.join(format!("{}.cert", &tid)), title.cert_chain.to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}.cert\" for writing.", tid))?; + fs::write(out_path.join(format!("{}.cert", &tid)), title.cert_chain().to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}.cert\" for writing.", tid))?; // Iterate over the content files and write them out in encrypted form. - for record in title.content.content_records().iter() { + for record in title.tmd().content_records().iter() { println!(" - Decrypting and saving content with Content ID {}...", record.content_id); fs::write(out_path.join(format!("{:08X}.app", record.content_id)), title.get_content_by_cid(record.content_id)?) .with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", record.content_id))?; @@ -176,7 +176,7 @@ fn download_title_dir(title: title::Title, output: String) -> Result<()> { Ok(()) } -fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion, cert_chain: cert::CertificateChain, output: String) -> Result<()> { +fn download_title_dir_enc(tmd: tmd::TMD, contents: Vec>, cert_chain: cert::CertificateChain, output: String) -> Result<()> { println!(" - Saving downloaded data..."); let out_path = PathBuf::from(output); if out_path.exists() { @@ -192,9 +192,10 @@ fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion, println!(" - Saving certificate chain..."); fs::write(out_path.join(format!("{}.cert", &tid)), cert_chain.to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}.cert\" for writing.", tid))?; // Iterate over the content files and write them out in encrypted form. - for record in content_region.content_records().iter() { + for record in tmd.content_records().iter() { println!(" - Saving content with Content ID {}...", record.content_id); - fs::write(out_path.join(format!("{:08X}", record.content_id)), content_region.get_enc_content_by_cid(record.content_id)?) + let idx = tmd.get_index_from_cid(record.content_id)?; + fs::write(out_path.join(format!("{:08X}", record.content_id)), &contents[idx]) .with_context(|| format!("Failed to open content file \"{:08X}\" for writing.", record.content_id))?; } println!("Successfully downloaded title with Title ID {} to directory \"{}\"!", tid, out_path.display()); @@ -205,7 +206,7 @@ fn download_title_wad(title: title::Title, output: String) -> Result<()> { println!(" - Packing WAD..."); let out_path = PathBuf::from(output).with_extension("wad"); fs::write(&out_path, title.to_wad().with_context(|| "A WAD could not be packed.")?.to_bytes()?).with_context(|| format!("Could not open WAD file \"{}\" for writing.", out_path.display()))?; - println!("Successfully downloaded title with Title ID {} to WAD file \"{}\"!", hex::encode(title.tmd.title_id()), out_path.display()); + println!("Successfully downloaded title with Title ID {} to WAD file \"{}\"!", hex::encode(title.tmd().title_id()), out_path.display()); Ok(()) } @@ -242,12 +243,11 @@ pub fn download_title(tid: &str, version: &Option, output: &TitleOutputType contents.push(nus::download_content(tid, record.content_id, true).with_context(|| format!("Content with Content ID {} could not be downloaded.", record.content_id))?); println!(" - Done!"); } - let content_region = content::ContentRegion::from_contents(contents, tmd.content_records().clone())?; println!(" - Building certificate chain..."); let cert_chain = cert::CertificateChain::from_bytes(&nus::download_cert_chain(true).with_context(|| "Certificate chain could not be built.")?)?; if let Some(tik) = tik { // If we have a Ticket, then build a Title and jump to the output method. - let title = title::Title::from_parts(cert_chain, None, tik, tmd, content_region, None)?; + let title = title::Title::from_parts_with_content(cert_chain, None, tik, tmd, contents, None)?; if output.wad.is_some() { download_title_wad(title, output.wad.clone().unwrap())?; } else { @@ -256,7 +256,7 @@ pub fn download_title(tid: &str, version: &Option, output: &TitleOutputType } else { // If we're downloading to a directory and have no Ticket, save the TMD and encrypted // contents to the directory only. - download_title_dir_enc(tmd, content_region, cert_chain, output.output.clone().unwrap())?; + download_title_dir_enc(tmd, contents, cert_chain, output.output.clone().unwrap())?; } Ok(()) } diff --git a/src/bin/rustwii/title/tmd.rs b/src/bin/rustwii/title/tmd.rs index 7d58520..8fdb332 100644 --- a/src/bin/rustwii/title/tmd.rs +++ b/src/bin/rustwii/title/tmd.rs @@ -106,7 +106,7 @@ pub fn tmd_remove(input: &str, output: &Option, identifier: &ContentIden if let Some(index) = identifier.index { let mut content_records = tmd.content_records().clone(); content_records.remove(index); - tmd.set_content_records(&content_records); + tmd.set_content_records(content_records); tmd.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified TMD.")?; fs::write(&out_path, tmd.to_bytes()?).with_context(|| "Could not open output file for writing.")?; println!("Successfully removed content at index {} in TMD file \"{}\".", index, out_path.display()); @@ -121,7 +121,7 @@ pub fn tmd_remove(input: &str, output: &Option, identifier: &ContentIden }; let mut content_records = tmd.content_records().clone(); content_records.remove(index as usize); - tmd.set_content_records(&content_records); + tmd.set_content_records(content_records); tmd.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified TMD.")?; fs::write(&out_path, tmd.to_bytes()?).with_context(|| "Could not open output file for writing.")?; println!("Successfully removed content with Content ID \"{}\" ({}) in WAD file \"{}\".", identifier.cid.clone().unwrap(), cid, out_path.display()); diff --git a/src/bin/rustwii/title/wad.rs b/src/bin/rustwii/title/wad.rs index 6efaeef..11b9226 100644 --- a/src/bin/rustwii/title/wad.rs +++ b/src/bin/rustwii/title/wad.rs @@ -10,7 +10,7 @@ use clap::{Subcommand, Args}; use glob::glob; use hex::FromHex; use rand::prelude::*; -use rustwii::title::{cert, crypto, tmd, ticket, content, wad}; +use rustwii::title::{cert, crypto, tmd, ticket}; use rustwii::title; use crate::title::shared::{validate_target_ios, validate_target_tid, validate_target_type, ContentIdentifier, TitleModifications}; @@ -156,7 +156,7 @@ pub fn wad_add(input: &str, content: &str, output: &Option, cid: &Option }; let target_cid = if cid.is_some() { let cid = u32::from_str_radix(cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?; - if title.content.content_records().iter().any(|record| record.content_id == cid) { + if title.tmd().content_records().iter().any(|record| record.content_id == cid) { bail!("The specified Content ID \"{:08X}\" is already being used in this WAD!", cid); } cid @@ -166,7 +166,7 @@ pub fn wad_add(input: &str, content: &str, output: &Option, cid: &Option let mut cid: u32; loop { cid = rng.random_range(0..=0xFF); - if !title.content.content_records().iter().any(|record| record.content_id == cid) { + if !title.tmd().content_records().iter().any(|record| record.content_id == cid) { break; } } @@ -205,47 +205,51 @@ pub fn wad_convert(input: &str, target: &ConvertTargets, output: &Option }; let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?; // Bail if the WAD is already using the selected encryption. - if matches!(target, Target::Dev) && title.ticket.is_dev() { + if matches!(target, Target::Dev) && title.ticket().is_dev() { bail!("This is already a development WAD!"); - } else if matches!(target, Target::Retail) && !title.ticket.is_dev() && !title.tmd.is_vwii() { + } else if matches!(target, Target::Retail) && !title.ticket().is_dev() && !title.tmd().is_vwii() { bail!("This is already a retail WAD!"); - } else if matches!(target, Target::Vwii) && !title.ticket.is_dev() && title.tmd.is_vwii() { + } else if matches!(target, Target::Vwii) && !title.ticket().is_dev() && title.tmd().is_vwii() { bail!("This is already a vWii WAD!"); } // Save the current encryption to display at the end. - let source = if title.ticket.is_dev() { + let source = if title.ticket().is_dev() { "development" - } else if title.tmd.is_vwii() { + } else if title.tmd().is_vwii() { "vWii" } else { "retail" }; - let title_key = title.ticket.title_key_dec(); + let title_key = title.ticket().title_key_dec(); let title_key_new: [u8; 16]; + let mut tmd = title.tmd().clone(); + let mut ticket = title.ticket().clone(); match target { Target::Dev => { - title.tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?; - title.ticket.set_signature_issuer(String::from("Root-CA00000002-XS00000006"))?; - title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), true); - title.ticket.set_common_key_index(0); - title.tmd.set_is_vwii(false); + tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?; + ticket.set_signature_issuer(String::from("Root-CA00000002-XS00000006"))?; + title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket().title_id(), true); + ticket.set_common_key_index(0); + tmd.set_is_vwii(false); }, Target::Retail => { - title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?; - title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?; - title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), false); - title.ticket.set_common_key_index(0); - title.tmd.set_is_vwii(false); + tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?; + ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?; + title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket().title_id(), false); + ticket.set_common_key_index(0); + tmd.set_is_vwii(false); }, Target::Vwii => { - title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?; - title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?; - title_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket.title_id(), false); - title.ticket.set_common_key_index(2); - title.tmd.set_is_vwii(true); + tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?; + ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?; + title_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket().title_id(), false); + ticket.set_common_key_index(2); + tmd.set_is_vwii(true); } } - title.ticket.set_title_key(title_key_new); + ticket.set_title_key(title_key_new); + title.set_tmd(tmd); + title.set_ticket(ticket); title.fakesign()?; fs::write(&out_path, title.to_wad()?.to_bytes()?)?; println!("Successfully converted {} WAD to {} WAD \"{}\"!", source, target, out_path.file_name().unwrap().to_str().unwrap()); @@ -271,18 +275,18 @@ pub fn wad_edit(input: &str, output: &Option, edits: &TitleModifications if edits.tid.is_some() || edits.r#type.is_some() { let tid_high = if let Some(new_type) = &edits.r#type { let new_type = validate_target_type(&new_type.to_ascii_lowercase())?; - changes_summary.push(format!("Changed title type from \"{}\" to \"{}\"", title.tmd.title_type()?, new_type)); + changes_summary.push(format!("Changed title type from \"{}\" to \"{}\"", title.tmd().title_type()?, new_type)); Vec::from_hex(format!("{:08X}", new_type as u32))? } else { - title.tmd.title_id()[0..4].to_vec() + title.tmd().title_id()[0..4].to_vec() }; let tid_low = if let Some(new_tid) = &edits.tid { let new_tid = validate_target_tid(&new_tid.to_ascii_uppercase())?; - changes_summary.push(format!("Changed Title ID from \"{}\" to \"{}\"", hex::encode(&title.tmd.title_id()[4..8]).to_ascii_uppercase(), hex::encode(&new_tid).to_ascii_uppercase())); + changes_summary.push(format!("Changed Title ID from \"{}\" to \"{}\"", hex::encode(&title.tmd().title_id()[4..8]).to_ascii_uppercase(), hex::encode(&new_tid).to_ascii_uppercase())); new_tid } else { - title.tmd.title_id()[4..8].to_vec() + title.tmd().title_id()[4..8].to_vec() }; let new_tid: Vec = tid_high.iter().chain(&tid_low).copied().collect(); @@ -291,8 +295,10 @@ pub fn wad_edit(input: &str, output: &Option, edits: &TitleModifications if let Some(new_ios) = edits.ios { let new_ios_tid = validate_target_ios(new_ios)?; - title.tmd.set_ios_tid(new_ios_tid)?; - changes_summary.push(format!("Changed required IOS from IOS{} to IOS{}", title.tmd.ios_tid().last().unwrap(), new_ios)); + changes_summary.push(format!("Changed required IOS from IOS{} to IOS{}", title.tmd().ios_tid().last().unwrap(), new_ios)); + let mut tmd = title.tmd().clone(); + tmd.set_ios_tid(new_ios_tid)?; + title.set_tmd(tmd); } title.fakesign()?; @@ -317,7 +323,7 @@ pub fn wad_pack(input: &str, output: &str) -> Result<()> { } else if tmd_files.len() > 1 { bail!("More than one TMD file found in the source directory."); } - let mut tmd = tmd::TMD::from_bytes(&fs::read(&tmd_files[0]).with_context(|| "Could not open TMD file for reading.")?) + let tmd = tmd::TMD::from_bytes(&fs::read(&tmd_files[0]).with_context(|| "Could not open TMD file for reading.")?) .with_context(|| "The provided TMD file appears to be invalid.")?; // Read Ticket file (only accept one file). let ticket_files: Vec = glob(&format!("{}/*.tik", in_path.display()))? @@ -346,17 +352,25 @@ pub fn wad_pack(input: &str, output: &str) -> Result<()> { if footer_files.len() == 1 { footer = fs::read(&footer_files[0]).with_context(|| "Could not open footer file for reading.")?; } - // Iterate over expected content and read it into a content region. - let mut content_region = content::ContentRegion::new(tmd.content_records().clone())?; - let content_indexes: Vec = tmd.content_records().iter().map(|record| record.index).collect(); + + // Create a title to use for content loading. + let mut title = title::Title::from_parts( + cert_chain, + None, + tik, + tmd, + Some(&footer) + )?; + + // Iterate over expected content and load the content into the title. + let content_indexes: Vec = title.tmd().content_records().iter().map(|record| record.index).collect(); for index in content_indexes { - let data = fs::read(format!("{}/{:08X}.app", in_path.display(), index)).with_context(|| format!("Could not open content file \"{:08X}.app\" for reading.", index))?; - content_region.set_content(&data, index as usize, None, None, tik.title_key_dec()) + let data = fs::read(format!("{}/{:08X}.app", in_path.display(), index)) + .with_context(|| format!("Could not open content file \"{:08X}.app\" for reading.", index))?; + title.set_content(&data, index as usize, None, None) .with_context(|| "Failed to load content into the ContentRegion.")?; } - // Ensure that the TMD is modified with our potentially updated content records. - tmd.set_content_records(content_region.content_records()); - let wad = wad::WAD::from_parts(&cert_chain, &[], &tik, &tmd, &content_region, &footer).with_context(|| "An unknown error occurred while building a WAD from the input files.")?; + let wad = title.to_wad()?; // Write out WAD file. let mut out_path = PathBuf::from(output); match out_path.extension() { @@ -388,19 +402,17 @@ pub fn wad_remove(input: &str, output: &Option, identifier: &ContentIden // Parse the identifier passed to choose how to find and remove the target. // ...maybe don't take the above comment out of context if let Some(index) = identifier.index { - title.content.remove_content(index).with_context(|| "The specified index does not exist in the provided WAD!")?; - println!("{:?}", title.tmd); + title.remove_content(index).with_context(|| "The specified index does not exist in the provided WAD!")?; title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?; fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?; println!("Successfully removed content at index {} in WAD file \"{}\".", index, out_path.display()); } else if identifier.cid.is_some() { let cid = u32::from_str_radix(identifier.cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?; - let index = match title.content.get_index_from_cid(cid) { + let index = match title.tmd().get_index_from_cid(cid) { Ok(index) => index, Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid), }; - title.content.remove_content(index).with_context(|| "An unknown error occurred while removing content from the WAD.")?; - println!("{:?}", title.tmd); + title.remove_content(index).with_context(|| "An unknown error occurred while removing content from the WAD.")?; title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?; fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?; println!("Successfully removed content with Content ID \"{}\" ({}) in WAD file \"{}\".", identifier.cid.clone().unwrap(), cid, out_path.display()); @@ -437,7 +449,7 @@ pub fn wad_set(input: &str, content: &str, output: &Option, identifier: // Parse the identifier passed to choose how to do the find and replace. if let Some(index) = identifier.index { match title.set_content(&new_content, index, None, target_type) { - Err(title::TitleError::Content(content::ContentError::IndexOutOfRange { index, max })) => { + Err(title::TitleError::IndexOutOfRange { index, max }) => { bail!("The specified index {} does not exist in this WAD! The maximum index is {}.", index, max) }, Err(e) => bail!("An unknown error occurred while setting the new content: {e}"), @@ -448,7 +460,7 @@ pub fn wad_set(input: &str, content: &str, output: &Option, identifier: println!("Successfully replaced content at index {} in WAD file \"{}\".", identifier.index.unwrap(), out_path.display()); } else if identifier.cid.is_some() { let cid = u32::from_str_radix(identifier.cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?; - let index = match title.content.get_index_from_cid(cid) { + let index = match title.tmd().get_index_from_cid(cid) { Ok(index) => index, Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid), }; @@ -467,7 +479,7 @@ pub fn wad_unpack(input: &str, output: &str) -> Result<()> { } let wad_file = fs::read(in_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", in_path.display()))?; let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", in_path.display()))?; - let tid = hex::encode(title.tmd.title_id()); + let tid = hex::encode(title.tmd().title_id()); // Create output directory if it doesn't exist. let out_path = Path::new(output); if !out_path.exists() { @@ -475,18 +487,18 @@ pub fn wad_unpack(input: &str, output: &str) -> Result<()> { } // Write out all WAD components. let tmd_file_name = format!("{}.tmd", tid); - fs::write(Path::join(out_path, tmd_file_name.clone()), title.tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}\" for writing.", tmd_file_name))?; + fs::write(Path::join(out_path, tmd_file_name.clone()), title.tmd().to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}\" for writing.", tmd_file_name))?; let ticket_file_name = format!("{}.tik", tid); - fs::write(Path::join(out_path, ticket_file_name.clone()), title.ticket.to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}\" for writing.", ticket_file_name))?; + fs::write(Path::join(out_path, ticket_file_name.clone()), title.ticket().to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}\" for writing.", ticket_file_name))?; let cert_file_name = format!("{}.cert", tid); - fs::write(Path::join(out_path, cert_file_name.clone()), title.cert_chain.to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}\" for writing.", cert_file_name))?; + fs::write(Path::join(out_path, cert_file_name.clone()), title.cert_chain().to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}\" for writing.", cert_file_name))?; let meta_file_name = format!("{}.footer", tid); fs::write(Path::join(out_path, meta_file_name.clone()), title.meta()).with_context(|| format!("Failed to open footer file \"{}\" for writing.", meta_file_name))?; // Iterate over contents, decrypt them, and write them out. - for i in 0..title.tmd.content_records().len() { - let content_file_name = format!("{:08X}.app", title.content.content_records()[i].index); - let dec_content = title.get_content_by_index(i).with_context(|| format!("Failed to unpack content with Content ID {:08X}.", title.content.content_records()[i].content_id))?; - fs::write(Path::join(out_path, content_file_name), dec_content).with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", title.content.content_records()[i].content_id))?; + for i in 0..title.tmd().content_records().len() { + let content_file_name = format!("{:08X}.app", title.tmd().content_records()[i].index); + let dec_content = title.get_content_by_index(i).with_context(|| format!("Failed to unpack content with Content ID {:08X}.", title.tmd().content_records()[i].content_id))?; + fs::write(Path::join(out_path, content_file_name), dec_content).with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", title.tmd().content_records()[i].content_id))?; } println!("Successfully unpacked WAD file to \"{}\"!", out_path.display()); Ok(()) diff --git a/src/nand/emunand.rs b/src/nand/emunand.rs index adc9081..8cbc979 100644 --- a/src/nand/emunand.rs +++ b/src/nand/emunand.rs @@ -8,9 +8,9 @@ use std::collections::HashMap; use std::path::PathBuf; use glob::glob; use thiserror::Error; -use crate::nand::sys; +use crate::nand::{sharedcontentmap, sys}; use crate::title; -use crate::title::{cert, content, ticket, tmd}; +use crate::title::{cert, ticket, tmd}; #[derive(Debug, Error)] pub enum EmuNANDError { @@ -28,8 +28,10 @@ pub enum EmuNANDError { TMD(#[from] tmd::TMDError), #[error("Ticket processing error")] Ticket(#[from] ticket::TicketError), - #[error("content processing error")] - Content(#[from] content::ContentError), + #[error("Title content processing error")] + TitleContent(#[from] title::TitleError), + #[error("content.map processing error")] + SharedContent(#[from] sharedcontentmap::SharedContentError), #[error("io error occurred during EmuNAND operation")] IO(#[from] std::io::Error), } @@ -163,12 +165,12 @@ impl EmuNAND { /// actual meta/footer data contained in the title. pub fn install_title(&self, title: title::Title, override_meta: bool) -> 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]); + 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()?)?; + 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); @@ -183,10 +185,10 @@ impl EmuNAND { fs::remove_dir_all(&title_dir)?; } fs::create_dir(&title_dir)?; - fs::write(title_dir.join("title.tmd"), title.tmd.to_bytes()?)?; - for i in 0..title.content.content_records().len() { - if matches!(title.content.content_records()[i].content_type, tmd::ContentType::Normal) { - let content_path = title_dir.join(format!("{:08X}.app", title.content.content_records()[i].content_id).to_ascii_lowercase()); + fs::write(title_dir.join("title.tmd"), title.tmd().to_bytes()?)?; + for i in 0..title.tmd().content_records().len() { + if matches!(title.tmd().content_records()[i].content_type, tmd::ContentType::Normal) { + let content_path = title_dir.join(format!("{:08X}.app", title.tmd().content_records()[i].content_id).to_ascii_lowercase()); fs::write(content_path, title.get_content_by_index(i)?)?; } } @@ -196,13 +198,13 @@ impl EmuNAND { // 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)?)? + sharedcontentmap::SharedContentMap::from_bytes(&fs::read(&content_map_path)?)? } else { - content::SharedContentMap::new() + sharedcontentmap::SharedContentMap::new() }; - for i in 0..title.content.content_records().len() { - if matches!(title.content.content_records()[i].content_type, tmd::ContentType::Shared) { - if let Some(file_name) = content_map.add(&title.content.content_records()[i].content_hash)? { + for i in 0..title.tmd().content_records().len() { + if matches!(title.tmd().content_records()[i].content_type, tmd::ContentType::Shared) { + if let Some(file_name) = content_map.add(&title.tmd().content_records()[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)?)?; } @@ -232,7 +234,7 @@ impl EmuNAND { } else { sys::UidSys::new() }; - uid_sys.add(&title.tmd.title_id())?; + 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 index 00029fd..12bd05c 100644 --- a/src/nand/mod.rs +++ b/src/nand/mod.rs @@ -6,3 +6,4 @@ pub mod emunand; pub mod setting; pub mod sys; +pub mod sharedcontentmap; diff --git a/src/nand/sharedcontentmap.rs b/src/nand/sharedcontentmap.rs new file mode 100644 index 0000000..fbeae2c --- /dev/null +++ b/src/nand/sharedcontentmap.rs @@ -0,0 +1,104 @@ +// nand/sharedcontentmap.rs from ruswtii (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustwii +// +// Implements shared content map parsing and editing to update the records of what content is +// installed at /shared1/ on NAND. + +use std::io::{Cursor, Read, Write}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SharedContentError { + #[error("content.map is an invalid length and cannot be parsed")] + InvalidSharedContentMapLength, + #[error("found invalid shared content name `{0}`")] + InvalidSharedContentName(String), + #[error("shared content map is not in a valid format")] + IO(#[from] std::io::Error), +} + +#[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 Default for SharedContentMap { + fn default() -> Self { + Self::new() + } +} + +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().is_multiple_of(28) { + return Err(SharedContentError::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(SharedContentError::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, SharedContentError> { + // 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/content.rs b/src/title/content.rs deleted file mode 100644 index c45e462..0000000 --- a/src/title/content.rs +++ /dev/null @@ -1,395 +0,0 @@ -// title/content.rs from ruswtii (c) 2025 NinjaCheetah & Contributors -// https://github.com/NinjaCheetah/rustwii -// -// Implements content parsing and editing. - -use std::io::{Cursor, Read, Seek, SeekFrom, Write}; -use sha1::{Sha1, Digest}; -use thiserror::Error; -use crate::title::tmd::{ContentRecord, ContentType}; -use crate::title::crypto; -use crate::title::crypto::encrypt_content; - -#[derive(Debug, Error)] -pub enum ContentError { - #[error("requested index {index} is out of range (must not exceed {max})")] - IndexOutOfRange { index: usize, max: usize }, - #[error("expected {required} contents based on content records but found {found}")] - MissingContents { required: usize, found: usize }, - #[error("content with requested Content ID {0} could not be found")] - CIDNotFound(u32), - #[error("the specified index {0} already exists in the content records")] - IndexAlreadyExists(u16), - #[error("the specified Content ID {0} already exists in the content records")] - 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), -} - -#[derive(Debug)] -/// A structure that represents the block of data containing the content of a digital Wii title. -pub struct ContentRegion { - content_records: Vec, - content_region_size: u32, - content_start_offsets: Vec, - contents: Vec>, -} - -impl ContentRegion { - /// Creates a ContentRegion instance that can be used to parse and edit content stored in a - /// digital Wii title from the content area of a WAD and the ContentRecords from a TMD. - pub fn from_bytes(data: &[u8], content_records: Vec) -> Result { - let content_region_size = data.len() as u32; - let num_contents = content_records.len() as u16; - // Calculate the starting offsets of each content. - let content_start_offsets: Vec = std::iter::once(0) - .chain(content_records.iter().scan(0, |offset, record| { - *offset += record.content_size; - if record.content_size % 64 != 0 { - *offset += 64 - (record.content_size % 64); - } - Some(*offset) - })).take(content_records.len()).collect(); // Trims the extra final entry. - // Parse the content blob and create a vector of vectors from it. - let mut contents: Vec> = Vec::with_capacity(num_contents as usize); - let mut buf = Cursor::new(data); - for i in 0..num_contents { - buf.seek(SeekFrom::Start(content_start_offsets[i as usize]))?; - let size = (content_records[i as usize].content_size + 15) & !15; - let mut content = vec![0u8; size as usize]; - buf.read_exact(&mut content)?; - contents.push(content); - } - Ok(ContentRegion { - content_records, - content_region_size, - content_start_offsets, - contents, - }) - } - - /// Creates a ContentRegion instance that can be used to parse and edit content stored in a - /// digital Wii title from a vector of contents and the ContentRecords from a TMD. - pub fn from_contents(contents: Vec>, content_records: Vec) -> Result { - if contents.len() != content_records.len() { - return Err(ContentError::MissingContents { required: content_records.len(), found: contents.len()}); - } - let mut content_region = Self::new(content_records)?; - for (index, content) in contents.iter().enumerate() { - let target_index = content_region.content_records[index].index; - content_region.load_enc_content(content, target_index as usize)?; - } - Ok(content_region) - } - - /// Creates a ContentRegion instance from the ContentRecords of a TMD that contains no actual - /// content. This can be used to load existing content from files. - pub fn new(content_records: Vec) -> Result { - let content_region_size: u64 = content_records.iter().map(|x| (x.content_size + 63) & !63).sum(); - let content_region_size = content_region_size as u32; - let num_contents = content_records.len() as u16; - let content_start_offsets: Vec = vec![0; num_contents as usize]; - let contents: Vec> = vec![Vec::new(); num_contents as usize]; - Ok(ContentRegion { - content_records, - content_region_size, - content_start_offsets, - contents, - }) - } - - /// Dumps the entire ContentRegion 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 i in 0..self.content_records.len() { - 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)?; - } - Ok(buf) - } - - /// Gets the content records in the ContentRegion. - pub fn content_records(&self) -> &Vec { - &self.content_records - } - - /// Gets the size of the ContentRegion. - pub fn content_region_size(&self) -> u32 { - self.content_region_size - } - - /// Gets the start offsets of the content in the ContentRegion. - pub fn content_start_offsets(&self) -> &Vec { - &self.content_start_offsets - } - - /// Gets the actual data of the content in the ContentRegion. - pub fn contents(&self) -> &Vec> { - &self.contents - } - - /// Gets the index of content using its Content ID. - pub fn get_index_from_cid(&self, cid: u32) -> Result { - // Use fancy Rust find and map methods to find the index matching the provided CID. Take - // that libWiiPy! - let content_index = self.content_records.iter() - .find(|record| record.content_id == cid) - .map(|record| record.index); - if let Some(index) = content_index { - Ok(index as usize) - } else { - Err(ContentError::CIDNotFound(cid)) - } - } - - /// Gets the encrypted content file from the ContentRegion at the specified index. - pub fn get_enc_content_by_index(&self, index: usize) -> Result, ContentError> { - let content = self.contents.get(index).ok_or(ContentError::IndexOutOfRange { index, max: self.content_records.len() - 1 })?; - Ok(content.clone()) - } - - /// Gets the decrypted content file from the ContentRegion at the specified index. - pub fn get_content_by_index(&self, index: usize, title_key: [u8; 16]) -> Result, ContentError> { - let content = self.get_enc_content_by_index(index)?; - // Verify the hash of the decrypted content against its record. - let mut content_dec = crypto::decrypt_content(&content, title_key, self.content_records[index].index); - content_dec.resize(self.content_records[index].content_size as usize, 0); - let mut hasher = Sha1::new(); - hasher.update(content_dec.clone()); - let result = hasher.finalize(); - if result[..] != self.content_records[index].content_hash { - return Err(ContentError::BadHash { hash: hex::encode(result), expected: hex::encode(self.content_records[index].content_hash) }); - } - Ok(content_dec) - } - - /// Gets the encrypted content file from the ContentRegion with the specified Content ID. - pub fn get_enc_content_by_cid(&self, cid: u32) -> Result, ContentError> { - let index = self.content_records.iter().position(|x| x.content_id == cid); - if let Some(index) = index { - let content = self.get_enc_content_by_index(index).map_err(|_| ContentError::CIDNotFound(cid))?; - Ok(content) - } else { - Err(ContentError::CIDNotFound(cid)) - } - } - - /// Gets the decrypted content file from the ContentRegion with the specified Content ID. - pub fn get_content_by_cid(&self, cid: u32, title_key: [u8; 16]) -> Result, ContentError> { - let index = self.content_records.iter().position(|x| x.content_id == cid); - if let Some(index) = index { - let content_dec = self.get_content_by_index(index, title_key)?; - Ok(content_dec) - } else { - Err(ContentError::CIDNotFound(cid)) - } - } - - /// Loads existing content into the specified index of a ContentRegion instance. This content - /// must be encrypted. - pub fn load_enc_content(&mut self, content: &[u8], index: usize) -> Result<(), ContentError> { - if index >= self.content_records.len() { - return Err(ContentError::IndexOutOfRange { index, max: self.content_records.len() - 1 }); - } - self.contents[index] = content.to_vec(); - Ok(()) - } - - /// Sets the content at the specified index to the provided encrypted content. This requires - /// the size and hash of the original decrypted content to be known so that the appropriate - /// values can be set in the corresponding content record. Optionally, a new Content ID or - /// content type can be provided, with the existing values being preserved by default. - pub fn set_enc_content(&mut self, content: &[u8], index: usize, content_size: u64, content_hash: [u8; 20], cid: Option, content_type: Option) -> Result<(), ContentError> { - if index >= self.content_records.len() { - return Err(ContentError::IndexOutOfRange { index, max: self.content_records.len() - 1 }); - } - self.content_records[index].content_size = content_size; - self.content_records[index].content_hash = content_hash; - if let Some(cid) = cid { - // Make sure that the new CID isn't already in use. - if self.content_records.iter().any(|record| record.content_id == cid) { - return Err(ContentError::CIDAlreadyExists(cid)); - } - self.content_records[index].content_id = cid; - } - if let Some(content_type) = content_type { - self.content_records[index].content_type = content_type; - } - self.contents[index] = content.to_vec(); - Ok(()) - } - - /// Loads existing content into the specified index of a ContentRegion instance. This content - /// must be decrypted and needs to match the size and hash listed in the content record at that - /// index. - pub fn load_content(&mut self, content: &[u8], index: usize, title_key: [u8; 16]) -> Result<(), ContentError> { - if index >= self.content_records.len() { - return Err(ContentError::IndexOutOfRange { index, max: self.content_records.len() - 1 }); - } - // Hash the content we're trying to load to ensure it matches the hash expected in the - // matching record. - let mut hasher = Sha1::new(); - hasher.update(content); - let result = hasher.finalize(); - if result[..] != self.content_records[index].content_hash { - return Err(ContentError::BadHash { hash: hex::encode(result), expected: hex::encode(self.content_records[index].content_hash) }); - } - let content_enc = encrypt_content(content, title_key, self.content_records[index].index, self.content_records[index].content_size); - self.contents[index] = content_enc; - Ok(()) - } - - /// Sets the content at the specified index to the provided decrypted content. This content will - /// have its size and hash saved into the matching record. Optionally, a new Content ID or - /// content type can be provided, with the existing values being preserved by default. The - /// Title Key will be used to encrypt this content before it is stored. - pub fn set_content(&mut self, content: &[u8], index: usize, cid: Option, content_type: Option, title_key: [u8; 16]) -> Result<(), ContentError> { - let content_size = content.len() as u64; - let mut hasher = Sha1::new(); - hasher.update(content); - let content_hash: [u8; 20] = hasher.finalize().into(); - let content_enc = encrypt_content(content, title_key, index as u16, content_size); - self.set_enc_content(&content_enc, index, content_size, content_hash, cid, content_type)?; - Ok(()) - } - - /// Removes the content at the specified index from the content list and content records. This - /// may leave a gap in the indexes recorded in the content records, but this should not cause - /// issues on the Wii or with correctly implemented WAD parsers. - pub fn remove_content(&mut self, index: usize) -> Result<(), ContentError> { - if self.contents.get(index).is_none() || self.content_records.get(index).is_none() { - return Err(ContentError::IndexOutOfRange { index, max: self.content_records.len() - 1 }); - } - self.contents.remove(index); - self.content_records.remove(index); - Ok(()) - } - - /// Adds new encrypted content to the end of the content list and content records. The provided - /// Content ID, type, index, and decrypted hash will be added to the record. - pub fn add_enc_content(&mut self, content: &[u8], index: u16, cid: u32, content_type: ContentType, content_size: u64, content_hash: [u8; 20]) -> Result<(), ContentError> { - // Return an error if the specified index or CID already exist in the records. - if self.content_records.iter().any(|record| record.index == index) { - return Err(ContentError::IndexAlreadyExists(index)); - } - if self.content_records.iter().any(|record| record.content_id == cid) { - return Err(ContentError::CIDAlreadyExists(cid)); - } - self.contents.push(content.to_vec()); - self.content_records.push(ContentRecord { content_id: cid, index, content_type, content_size, content_hash }); - Ok(()) - } - - /// Adds new decrypted content to the end of the content list and content records. The provided - /// Content ID and type will be added to the record alongside a hash of the decrypted data. An - /// index will be automatically assigned based on the highest index currently recorded in the - /// content records. - pub fn add_content(&mut self, content: &[u8], cid: u32, content_type: ContentType, title_key: [u8; 16]) -> Result<(), ContentError> { - let max_index = self.content_records.iter() - .max_by_key(|record| record.index) - .map(|record| record.index) - .unwrap_or(0); // This should be impossible, but I guess 0 is a safe value just in case? - let new_index = max_index + 1; - let content_size = content.len() as u64; - let mut hasher = Sha1::new(); - hasher.update(content); - let content_hash: [u8; 20] = hasher.finalize().into(); - let content_enc = encrypt_content(content, title_key, new_index, content_size); - self.add_enc_content(&content_enc, new_index, cid, content_type, content_size, content_hash)?; - 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 Default for SharedContentMap { - fn default() -> Self { - Self::new() - } -} - -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().is_multiple_of(28) { - 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/iospatcher.rs b/src/title/iospatcher.rs index bb02579..381ea2f 100644 --- a/src/title/iospatcher.rs +++ b/src/title/iospatcher.rs @@ -6,7 +6,6 @@ use std::io::{Cursor, Seek, SeekFrom, Write}; use thiserror::Error; use crate::title; -use crate::title::content; #[derive(Debug, Error)] pub enum IOSPatcherError { @@ -14,8 +13,6 @@ pub enum IOSPatcherError { NotIOS, #[error("the required module \"{0}\" could not be found, this may not be a valid IOS")] ModuleNotFound(String), - #[error("failed to get IOS content")] - Content(#[from] content::ContentError), #[error("failed to set content in Title")] Title(#[from] title::TitleError), #[error("IOS content is invalid")] diff --git a/src/title/mod.rs b/src/title/mod.rs index 97fb6dc..ffb53b1 100644 --- a/src/title/mod.rs +++ b/src/title/mod.rs @@ -5,7 +5,6 @@ pub mod cert; pub mod commonkeys; -pub mod content; pub mod crypto; pub mod iospatcher; pub mod nus; @@ -14,6 +13,8 @@ pub mod tmd; pub mod versions; pub mod wad; +use std::io::{Cursor, Read, Seek, SeekFrom, Write}; +use sha1::{Sha1, Digest}; use thiserror::Error; #[derive(Debug, Error)] @@ -28,22 +29,33 @@ pub enum TitleError { TMD(#[from] tmd::TMDError), #[error("Ticket processing error")] Ticket(#[from] ticket::TicketError), - #[error("content processing error")] - Content(#[from] content::ContentError), #[error("WAD processing error")] WAD(#[from] wad::WADError), #[error("WAD data is not in a valid format")] IO(#[from] std::io::Error), + // Content-specific (not generic or inherited from another struct's errors). + #[error("requested index {index} is out of range (must not exceed {max})")] + IndexOutOfRange { index: usize, max: usize }, + #[error("expected {required} contents based on content records but found {found}")] + MissingContents { required: usize, found: usize }, + #[error("content with requested Content ID {0} could not be found")] + CIDNotFound(u32), + #[error("the specified index {0} already exists in the content records")] + IndexAlreadyExists(u16), + #[error("the specified Content ID {0} already exists in the content records")] + CIDAlreadyExists(u32), + #[error("content's hash did not match the expected value (was {hash}, expected {expected})")] + BadHash { hash: String, expected: String }, } #[derive(Debug)] /// A structure that represents the components of a digital Wii title. pub struct Title { - pub cert_chain: cert::CertificateChain, + cert_chain: cert::CertificateChain, crl: Vec, - pub ticket: ticket::Ticket, - pub tmd: tmd::TMD, - pub content: content::ContentRegion, + ticket: ticket::Ticket, + tmd: tmd::TMD, + content: Vec>, meta: Vec } @@ -53,7 +65,7 @@ impl Title { let cert_chain = cert::CertificateChain::from_bytes(&wad.cert_chain()).map_err(TitleError::CertificateError)?; let ticket = ticket::Ticket::from_bytes(&wad.ticket()).map_err(TitleError::Ticket)?; let tmd = tmd::TMD::from_bytes(&wad.tmd()).map_err(TitleError::TMD)?; - let content = content::ContentRegion::from_bytes(&wad.content(), tmd.content_records().clone()).map_err(TitleError::Content)?; + let content = Self::parse_content_region(wad.content(), tmd.content_records())?; Ok(Title { cert_chain, crl: wad.crl(), @@ -65,8 +77,18 @@ impl Title { } /// Creates a new Title instance from all of its individual components. - pub fn from_parts(cert_chain: cert::CertificateChain, crl: Option<&[u8]>, ticket: ticket::Ticket, tmd: tmd::TMD, - content: content::ContentRegion, meta: Option<&[u8]>) -> Result { + pub fn from_parts_with_content( + cert_chain: cert::CertificateChain, + crl: Option<&[u8]>, + ticket: ticket::Ticket, + tmd: tmd::TMD, + content: Vec>, + meta: Option<&[u8]> + ) -> Result { + // Validate the provided content. + if content.len() != tmd.content_records().len() { + return Err(TitleError::MissingContents { required: tmd.content_records().len(), found: content.len()}); + } // Create empty vecs for the CRL and meta areas if we weren't supplied with any, as they're // optional components. let crl = match crl { @@ -86,16 +108,68 @@ impl Title { meta }) } + + /// Creates a new Title instance from all of its individual components. Content is expected to + /// be added to the title once created. + pub fn from_parts( + cert_chain: cert::CertificateChain, + crl: Option<&[u8]>, + ticket: ticket::Ticket, + tmd: tmd::TMD, + meta: Option<&[u8]> + ) -> Result { + let content: Vec> = vec![vec![]; tmd.content_records().len()]; + Self::from_parts_with_content( + cert_chain, + crl, + ticket, + tmd, + content, + meta + ) + } + + fn parse_content_region(content_data: Vec, content_records: &[tmd::ContentRecord]) -> Result>, TitleError> { + let num_contents = content_records.len(); + // Calculate the starting offsets of each content. + let content_start_offsets: Vec = std::iter::once(0) + .chain(content_records.iter().scan(0, |offset, record| { + *offset += record.content_size; + if record.content_size % 64 != 0 { + *offset += 64 - (record.content_size % 64); + } + Some(*offset) + })).take(content_records.len()).collect(); // Trims the extra final entry. + // Parse the content blob and create a vector of vectors from it. + let mut contents: Vec> = Vec::with_capacity(num_contents); + let mut buf = Cursor::new(content_data); + for i in 0..num_contents { + buf.seek(SeekFrom::Start(content_start_offsets[i]))?; + let size = (content_records[i].content_size + 15) & !15; + let mut content = vec![0u8; size as usize]; + buf.read_exact(&mut content)?; + contents.push(content); + } + + Ok(contents) + } /// Converts a Title instance into a WAD, which can be used to export the Title back to a file. pub fn to_wad(&self) -> Result { + let mut content: Vec = Vec::new(); + for i in 0..self.tmd.content_records().len() { + let mut content_cur = self.content[i].clone(); + // Round up size to nearest 64 to add appropriate padding. + content_cur.resize((content_cur.len() + 63) & !63, 0); + content.write_all(&content_cur)?; + } // Create a new WAD from the data in the Title. let wad = wad::WAD::from_parts( &self.cert_chain, &self.crl, &self.ticket, &self.tmd, - &self.content, + &content, &self.meta ).map_err(TitleError::WAD)?; Ok(wad) @@ -107,6 +181,18 @@ impl Title { let title = Title::from_wad(&wad)?; Ok(title) } + + pub fn cert_chain(&self) -> &cert::CertificateChain { + &self.cert_chain + } + + pub fn ticket(&self) -> &ticket::Ticket { + &self.ticket + } + + pub fn tmd(&self) -> &tmd::TMD { + &self.tmd + } /// Gets whether the TMD and Ticket of a Title are both fakesigned. pub fn is_fakesigned(&self) -> bool { @@ -120,25 +206,176 @@ impl Title { self.ticket.fakesign().map_err(TitleError::Ticket)?; Ok(()) } - + + /// Gets the encrypted content file from the ContentRegion at the specified index. + pub fn get_enc_content_by_index(&self, index: usize) -> Result, TitleError> { + let content = self.content.get(index).ok_or( + TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 } + )?; + Ok(content.clone()) + } + /// Gets the decrypted content file from the Title at the specified index. - pub fn get_content_by_index(&self, index: usize) -> Result, content::ContentError> { - let content = self.content.get_content_by_index(index, self.ticket.title_key_dec())?; - Ok(content) + pub fn get_content_by_index(&self, index: usize) -> Result, TitleError> { + let content = self.get_enc_content_by_index(index)?; + // Verify the hash of the decrypted content against its record. + let mut content_dec = crypto::decrypt_content(&content, self.ticket.title_key_dec(), self.tmd.content_records()[index].index); + content_dec.resize(self.tmd.content_records()[index].content_size as usize, 0); + let mut hasher = Sha1::new(); + hasher.update(content_dec.clone()); + let result = hasher.finalize(); + if result[..] != self.tmd.content_records()[index].content_hash { + return Err(TitleError::BadHash { + hash: hex::encode(result), expected: hex::encode(self.tmd.content_records()[index].content_hash) + }); + } + Ok(content_dec) + } + + /// Gets the encrypted content file from the ContentRegion with the specified Content ID. + pub fn get_enc_content_by_cid(&self, cid: u32) -> Result, TitleError> { + let index = self.tmd.content_records().iter().position(|x| x.content_id == cid); + if let Some(index) = index { + let content = self.get_enc_content_by_index(index).map_err(|_| TitleError::CIDNotFound(cid))?; + Ok(content) + } else { + Err(TitleError::CIDNotFound(cid)) + } } /// Gets the decrypted content file from the Title with the specified Content ID. - pub fn get_content_by_cid(&self, cid: u32) -> Result, content::ContentError> { - let content = self.content.get_content_by_cid(cid, self.ticket.title_key_dec())?; - Ok(content) + pub fn get_content_by_cid(&self, cid: u32) -> Result, TitleError> { + let index = self.tmd.content_records().iter().position(|x| x.content_id == cid); + if let Some(index) = index { + let content_dec = self.get_content_by_index(index)?; + Ok(content_dec) + } else { + Err(TitleError::CIDNotFound(cid)) + } + } + + /// Loads existing content into the specified index of a ContentRegion instance. This content + /// must be encrypted. + pub fn load_enc_content(&mut self, content: &[u8], index: usize) -> Result<(), TitleError> { + if index >= self.tmd.content_records().len() { + return Err(TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 }); + } + self.content[index] = content.to_vec(); + Ok(()) + } + + /// Sets the content at the specified index to the provided encrypted content. This requires + /// the size and hash of the original decrypted content to be known so that the appropriate + /// values can be set in the corresponding content record. Optionally, a new Content ID or + /// content type can be provided, with the existing values being preserved by default. + pub fn set_enc_content( + &mut self, content: &[u8], + index: usize, content_size: u64, + content_hash: [u8; 20], + cid: Option, + content_type: Option + ) -> Result<(), TitleError> { + if index >= self.tmd.content_records().len() { + return Err(TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 }); + } + let mut content_records = self.tmd.content_records().clone(); + content_records[index].content_size = content_size; + content_records[index].content_hash = content_hash; + if let Some(cid) = cid { + // Make sure that the new CID isn't already in use. + if content_records.iter().any(|record| record.content_id == cid) { + return Err(TitleError::CIDAlreadyExists(cid)); + } + content_records[index].content_id = cid; + } + if let Some(content_type) = content_type { + content_records[index].content_type = content_type; + } + self.tmd.set_content_records(content_records); + self.content[index] = content.to_vec(); + Ok(()) + } + + /// Loads existing content into the specified index of a ContentRegion instance. This content + /// must be decrypted and needs to match the size and hash listed in the content record at that + /// index. + pub fn load_content(&mut self, content: &[u8], index: usize) -> Result<(), TitleError> { + if index >= self.tmd.content_records().len() { + return Err(TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 }); + } + // Hash the content we're trying to load to ensure it matches the hash expected in the + // matching record. + let mut hasher = Sha1::new(); + hasher.update(content); + let result = hasher.finalize(); + if result[..] != self.tmd.content_records()[index].content_hash { + return Err(TitleError::BadHash { + hash: hex::encode(result), expected: hex::encode(self.tmd.content_records()[index].content_hash) + }); + } + let content_enc = crypto::encrypt_content( + content, + self.ticket.title_key_dec(), + self.tmd.content_records()[index].index, + self.tmd.content_records()[index].content_size + ); + self.content[index] = content_enc; + Ok(()) } /// Sets the content at the specified index to the provided decrypted content. This content will /// have its size and hash saved into the matching record. Optionally, a new Content ID or /// content type can be provided, with the existing values being preserved by default. pub fn set_content(&mut self, content: &[u8], index: usize, cid: Option, content_type: Option) -> Result<(), TitleError> { - self.content.set_content(content, index, cid, content_type, self.ticket.title_key_dec())?; - self.tmd.set_content_records(self.content.content_records()); + let content_size = content.len() as u64; + let mut hasher = Sha1::new(); + hasher.update(content); + let content_hash: [u8; 20] = hasher.finalize().into(); + let content_enc = crypto::encrypt_content( + content, + self.ticket.title_key_dec(), + index as u16, + content_size + ); + self.set_enc_content(&content_enc, index, content_size, content_hash, cid, content_type)?; + Ok(()) + } + + /// Removes the content at the specified index from the content list and content records. This + /// may leave a gap in the indexes recorded in the content records, but this should not cause + /// issues on the Wii or with correctly implemented WAD parsers. + pub fn remove_content(&mut self, index: usize) -> Result<(), TitleError> { + if self.content.get(index).is_none() || self.tmd.content_records().get(index).is_none() { + return Err(TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 }); + } + self.content.remove(index); + let mut content_records = self.tmd.content_records().clone(); + content_records.remove(index); + self.tmd.set_content_records(content_records); + Ok(()) + } + + /// Adds new encrypted content to the end of the content list and content records. The provided + /// Content ID, type, index, and decrypted hash will be added to the record. + pub fn add_enc_content( + &mut self, content: + &[u8], index: u16, + cid: u32, + content_type: tmd::ContentType, + content_size: u64, + content_hash: [u8; 20] + ) -> Result<(), TitleError> { + // Return an error if the specified index or CID already exist in the records. + if self.tmd.content_records().iter().any(|record| record.index == index) { + return Err(TitleError::IndexAlreadyExists(index)); + } + if self.tmd.content_records().iter().any(|record| record.content_id == cid) { + return Err(TitleError::CIDAlreadyExists(cid)); + } + self.content.push(content.to_vec()); + let mut content_records = self.tmd.content_records().clone(); + content_records.push(tmd::ContentRecord { content_id: cid, index, content_type, content_size, content_hash }); + self.tmd.set_content_records(content_records); Ok(()) } @@ -147,8 +384,17 @@ impl Title { /// index will be automatically assigned based on the highest index currently recorded in the /// content records. pub fn add_content(&mut self, content: &[u8], cid: u32, content_type: tmd::ContentType) -> Result<(), TitleError> { - self.content.add_content(content, cid, content_type, self.ticket.title_key_dec())?; - self.tmd.set_content_records(self.content.content_records()); + let max_index = self.tmd.content_records().iter() + .max_by_key(|record| record.index) + .map(|record| record.index) + .unwrap_or(0); // This should be impossible, but I guess 0 is a safe value just in case? + let new_index = max_index + 1; + let content_size = content.len() as u64; + let mut hasher = Sha1::new(); + hasher.update(content); + let content_hash: [u8; 20] = hasher.finalize().into(); + let content_enc = crypto::encrypt_content(content, self.ticket.title_key_dec(), new_index, content_size); + self.add_enc_content(&content_enc, new_index, cid, content_type, content_size, content_hash)?; Ok(()) } @@ -223,7 +469,7 @@ impl Title { self.tmd = tmd; } - pub fn set_content_region(&mut self, content: content::ContentRegion) { + pub fn set_contents(&mut self, content: Vec>) { self.content = content; } diff --git a/src/title/nus.rs b/src/title/nus.rs index 9d86b6e..f84fd42 100644 --- a/src/title/nus.rs +++ b/src/title/nus.rs @@ -7,7 +7,7 @@ use std::str; use std::io::Write; use reqwest; use thiserror::Error; -use crate::title::{cert, tmd, ticket, content}; +use crate::title::{cert, tmd, ticket}; use crate::title; const WII_NUS_ENDPOINT: &str = "http://nus.cdn.shop.wii.com/ccs/download/"; @@ -25,8 +25,6 @@ pub enum NUSError { TMD(#[from] tmd::TMDError), #[error("Ticket processing error")] Ticket(#[from] ticket::TicketError), - #[error("Content processing error")] - Content(#[from] content::ContentError), #[error("an error occurred while assembling a Title from the downloaded data")] Title(#[from] title::TitleError), #[error("data could not be downloaded from the NUS")] @@ -112,8 +110,8 @@ pub fn download_title(title_id: [u8; 8], title_version: Option, wiiu_endpoi let cert_chain = cert::CertificateChain::from_bytes(&download_cert_chain(wiiu_endpoint)?)?; let tmd = tmd::TMD::from_bytes(&download_tmd(title_id, title_version, wiiu_endpoint)?)?; let tik = ticket::Ticket::from_bytes(&download_ticket(title_id, wiiu_endpoint)?)?; - let content_region = content::ContentRegion::from_contents(download_contents(&tmd, wiiu_endpoint)?, tmd.content_records().clone())?; - let title = title::Title::from_parts(cert_chain, None, tik, tmd, content_region, None)?; + let contents = download_contents(&tmd, wiiu_endpoint)?; + let title = title::Title::from_parts_with_content(cert_chain, None, tik, tmd, contents, None)?; Ok(title) } diff --git a/src/title/ticket.rs b/src/title/ticket.rs index f6ec023..ee6c47e 100644 --- a/src/title/ticket.rs +++ b/src/title/ticket.rs @@ -30,7 +30,7 @@ pub struct TitleLimit { pub limit_max: u32, } -#[derive(Debug)] +#[derive(Debug, Clone)] /// A structure that represents a Wii Ticket file. pub struct Ticket { signature_type: u32, diff --git a/src/title/tmd.rs b/src/title/tmd.rs index b1a9281..cb583b3 100644 --- a/src/title/tmd.rs +++ b/src/title/tmd.rs @@ -24,6 +24,8 @@ pub enum TMDError { InvalidContentType(u16), #[error("encountered unknown title type `{0}`")] InvalidTitleType(String), + #[error("content with requested Content ID {0} could not be found")] + CIDNotFound(u32), #[error("TMD data is not in a valid format")] IO(#[from] std::io::Error), } @@ -90,7 +92,7 @@ pub struct ContentRecord { } /// A structure that represents a Wii TMD (Title Metadata) file. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct TMD { signature_type: u32, signature: [u8; 256], @@ -322,8 +324,8 @@ impl TMD { } /// Sets the content records in the TMD. - pub fn set_content_records(&mut self, content_records: &[ContentRecord]) { - self.content_records = content_records.to_vec(); + pub fn set_content_records(&mut self, content_records: Vec) { + self.content_records = content_records; } /// Gets whether a TMD is fakesigned using the strncmp (trucha) bug or not. @@ -476,4 +478,18 @@ impl TMD { self.ios_tid = ios_tid; Ok(()) } + + /// Gets the index of content using its Content ID. + pub fn get_index_from_cid(&self, cid: u32) -> Result { + // Use fancy Rust find and map methods to find the index matching the provided CID. Take + // that libWiiPy! + let content_index = self.content_records().iter() + .find(|record| record.content_id == cid) + .map(|record| record.index); + if let Some(index) = content_index { + Ok(index as usize) + } else { + Err(TMDError::CIDNotFound(cid)) + } + } } diff --git a/src/title/wad.rs b/src/title/wad.rs index 0fdbaeb..9c0bfca 100644 --- a/src/title/wad.rs +++ b/src/title/wad.rs @@ -7,7 +7,7 @@ use std::str; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use thiserror::Error; -use crate::title::{cert, tmd, ticket, content}; +use crate::title::{cert, tmd, ticket}; use crate::title::ticket::TicketError; use crate::title::tmd::TMDError; @@ -143,13 +143,13 @@ impl WADHeader { impl WADBody { /// Creates a new WADBody instance from instances of the components stored in a WAD file. pub fn from_parts(cert_chain: &cert::CertificateChain, crl: &[u8], ticket: &ticket::Ticket, tmd: &tmd::TMD, - content: &content::ContentRegion, meta: &[u8]) -> Result { + content: &[u8], meta: &[u8]) -> Result { let body = WADBody { cert_chain: cert_chain.to_bytes().map_err(WADError::IO)?, crl: crl.to_vec(), ticket: ticket.to_bytes().map_err(WADError::IO)?, tmd: tmd.to_bytes().map_err(WADError::IO)?, - content: content.to_bytes().map_err(WADError::IO)?, + content: content.to_vec(), meta: meta.to_vec(), }; Ok(body) @@ -239,7 +239,7 @@ impl WAD { /// Creates a new WAD instance from instances of the components stored in a WAD file. This /// first creates a WADBody from the components, then generates a new WADHeader from them. pub fn from_parts(cert_chain: &cert::CertificateChain, crl: &[u8], ticket: &ticket::Ticket, tmd: &tmd::TMD, - content: &content::ContentRegion, meta: &[u8]) -> Result { + content: &[u8], meta: &[u8]) -> Result { let body = WADBody::from_parts(cert_chain, crl, ticket, tmd, content, meta)?; let header = WADHeader::from_body(&body)?; let wad = WAD {