Refactored entire way that title content is handled

The ContentRegion has been entirely dissolved. Its fields were not particularly useful, as the content records were just a duplicate from the TMD, the file data itself, and then two integers that were assigned during construction and then literally never referenced.
Instead, the only copy of the content records now lives in the TMD, and the content is stored within the title directly since that was the only meaningful field. All the content related methods were moved from the ContentRegion struct over to the Title struct, since the content just lives there now.
This should hopefully make things much easier to deal with as you no longer need to worry about keeping two separate copies of the content records in sync.
This also might all change again in the future idk
This commit is contained in:
2026-03-02 00:31:53 -05:00
parent 0d34fbc383
commit 326bb56ece
17 changed files with 547 additions and 545 deletions

View File

@@ -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<ContentRecord>,
content_region_size: u32,
content_start_offsets: Vec<u64>,
contents: Vec<Vec<u8>>,
}
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<ContentRecord>) -> Result<Self, ContentError> {
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<u64> = 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<u8>> = 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<Vec<u8>>, content_records: Vec<ContentRecord>) -> Result<Self, ContentError> {
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<ContentRecord>) -> Result<Self, ContentError> {
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<u64> = vec![0; num_contents as usize];
let contents: Vec<Vec<u8>> = 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<Vec<u8>, std::io::Error> {
let mut buf: Vec<u8> = 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<ContentRecord> {
&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<u64> {
&self.content_start_offsets
}
/// Gets the actual data of the content in the ContentRegion.
pub fn contents(&self) -> &Vec<Vec<u8>> {
&self.contents
}
/// Gets the index of content using its Content ID.
pub fn get_index_from_cid(&self, cid: u32) -> Result<usize, ContentError> {
// 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<u32>, content_type: Option<ContentType>) -> 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<u32>, content_type: Option<ContentType>, 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<ContentMapEntry>,
}
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<SharedContentMap, ContentError> {
// 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<ContentMapEntry> = 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<Vec<u8>, std::io::Error> {
let mut buf: Vec<u8> = 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<Option<String>, 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)))
}
}

View File

@@ -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")]

View File

@@ -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<u8>,
pub ticket: ticket::Ticket,
pub tmd: tmd::TMD,
pub content: content::ContentRegion,
ticket: ticket::Ticket,
tmd: tmd::TMD,
content: Vec<Vec<u8>>,
meta: Vec<u8>
}
@@ -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<Title, TitleError> {
pub fn from_parts_with_content(
cert_chain: cert::CertificateChain,
crl: Option<&[u8]>,
ticket: ticket::Ticket,
tmd: tmd::TMD,
content: Vec<Vec<u8>>,
meta: Option<&[u8]>
) -> Result<Title, TitleError> {
// 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<Title, TitleError> {
let content: Vec<Vec<u8>> = 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<u8>, content_records: &[tmd::ContentRecord]) -> Result<Vec<Vec<u8>>, TitleError> {
let num_contents = content_records.len();
// Calculate the starting offsets of each content.
let content_start_offsets: Vec<u64> = 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<u8>> = 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<wad::WAD, TitleError> {
let mut content: Vec<u8> = 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<u32>,
content_type: Option<tmd::ContentType>
) -> 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<u32>, content_type: Option<tmd::ContentType>) -> 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<Vec<u8>>) {
self.content = content;
}

View File

@@ -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<u16>, 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)
}

View File

@@ -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,

View File

@@ -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<ContentRecord>) {
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<usize, TMDError> {
// 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))
}
}
}

View File

@@ -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<WADBody, WADError> {
content: &[u8], meta: &[u8]) -> Result<WADBody, WADError> {
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<WAD, WADError> {
content: &[u8], meta: &[u8]) -> Result<WAD, WADError> {
let body = WADBody::from_parts(cert_chain, crl, ticket, tmd, content, meta)?;
let header = WADHeader::from_body(&body)?;
let wad = WAD {