Compare commits

...

3 Commits

Author SHA1 Message Date
884657268b
Added single content download command to rustii CLI 2025-04-08 22:27:42 -04:00
5f578fbfd8
Updated README to better represent current features 2025-04-08 20:57:44 -04:00
be9148fcfa
Ported all NUS download functions from libWiiPy and corresponding CLI commands
Also adds the basics of U8 archive packing/unpacking, however they are not in a usable state yet and there are no working CLI commands associated with them.
2025-04-08 20:47:35 -04:00
14 changed files with 2222 additions and 40 deletions

1435
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -35,3 +35,4 @@ regex = "1"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
anyhow = "1" anyhow = "1"
thiserror = "2" thiserror = "2"
reqwest = { version = "0", features = ["blocking"] }

View File

@ -9,15 +9,25 @@ rustii is a library and command line tool written in Rust for handling the vario
I'm still very new to Rust, so pardon any messy code or confusing API decisions you may find. libWiiPy started off like that, too. I'm still very new to Rust, so pardon any messy code or confusing API decisions you may find. libWiiPy started off like that, too.
### What's Included ### What's Included (Library-Side)
- Structs for TMDs and Tickets that can be created from binary data - Structs for parsing and editing WADs, TMDs, Tickets, and Certificate Chains
- Simple Title Key encryption/decryption - Title Key and content encryption/decryption
- Content encryption/decryption - High-level Title struct (offering the same utility as libWiiPy's `Title`)
- WAD parsing (allowing for packing/unpacking) - LZ77 compression/decompression
- ASH decompression
- NUS TMD/Ticket/certificate chain/content downloading
- A basic CLI that uses the above features to allow for packing/unpacking WADs - A basic CLI that uses the above features to allow for packing/unpacking WADs
- The very basics of U8 archive handling (not really functional yet though)
### What's Not Included ### What's Included (CLI-Side)
- Basically anything else. Any other features present in libWiiPy not listed here either do not yet exist, or are in an experimental state. - WAD packing/unpacking/converting
- NUS TMD/Ticket/Title downloading
- LZ77 compression/decompression
- ASH decompression
- Fakesigning command for WADs/TMDs/Tickets
- Info command for WADs/TMDs/Tickets
To see specific usage information, check `rustii --help` and `rustii <command> --help`.
## Building ## Building
rustii is a standard Rust package. You'll need to have [Rust installed](https://www.rust-lang.org/learn/get-started), and then you can simply run: rustii is a standard Rust package. You'll need to have [Rust installed](https://www.rust-lang.org/learn/get-started), and then you can simply run:

View File

@ -3,3 +3,206 @@
// //
// Implements the structures and methods required for parsing U8 archives. // Implements the structures and methods required for parsing U8 archives.
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::path::Path;
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum U8Error {
#[error("invalid file name at offset {0}")]
InvalidFileName(u64),
#[error("this does not appear to be a U8 archive (missing magic number)")]
NotU8Data,
#[error("U8 data is not in a valid format")]
IO(#[from] std::io::Error),
}
#[derive(Clone, Debug)]
struct U8Node {
node_type: u8,
name_offset: u32, // This is really type u24, so the most significant byte will be ignored.
data_offset: u32,
size: u32,
}
#[derive(Debug)]
pub struct U8Archive {
u8_nodes: Vec<U8Node>,
file_names: Vec<String>,
file_data: Vec<Vec<u8>>,
root_node_offset: u32,
header_size: u32,
data_offset: u32,
padding: [u8; 16],
}
impl U8Archive {
/// Creates a new U8 instance from the binary data of a U8 file.
pub fn from_bytes(data: &[u8]) -> Result<Self, U8Error> {
let mut buf = Cursor::new(data);
let mut magic = [0u8; 4];
buf.read_exact(&mut magic)?;
// Check for an IMET header if the magic number isn't the correct value before throwing an
// error.
if &magic != b"\x55\xAA\x38\x2D" {
// Check for an IMET header immediately at the start of the file.
buf.seek(SeekFrom::Start(0x40))?;
buf.read_exact(&mut magic)?;
if &magic == b"\x49\x4D\x45\x54" {
// IMET with no build tag means the U8 archive should start at 0x600.
buf.seek(SeekFrom::Start(0x600))?;
buf.read_exact(&mut magic)?;
if &magic != b"\x55\xAA\x38\x2D" {
return Err(U8Error::NotU8Data);
}
println!("ignoring IMET header at 0x40");
}
// Check for an IMET header that comes after a built tag.
else {
buf.seek(SeekFrom::Start(0x80))?;
buf.read_exact(&mut magic)?;
if &magic == b"\x49\x4D\x45\x54" {
// IMET with a build tag means the U8 archive should start at 0x600.
buf.seek(SeekFrom::Start(0x640))?;
buf.read_exact(&mut magic)?;
if &magic != b"\x55\xAA\x38\x2D" {
return Err(U8Error::NotU8Data);
}
println!("ignoring IMET header at 0x80");
}
}
}
let root_node_offset = buf.read_u32::<BigEndian>()?;
let header_size = buf.read_u32::<BigEndian>()?;
let data_offset = buf.read_u32::<BigEndian>()?;
let mut padding = [0u8; 16];
buf.read_exact(&mut padding)?;
// Manually read the root node, since we need its size anyway to know how many nodes there
// are total.
let root_node_type = buf.read_u8()?;
let root_node_name_offset = buf.read_u24::<BigEndian>()?;
let root_node_data_offset = buf.read_u32::<BigEndian>()?;
let root_node_size = buf.read_u32::<BigEndian>()?;
let root_node = U8Node {
node_type: root_node_type,
name_offset: root_node_name_offset,
data_offset: root_node_data_offset,
size: root_node_size,
};
// Create a vec of nodes, push the root node, and then iterate over the remaining number
// of nodes in the file and push them to the vec.
let mut u8_nodes: Vec<U8Node> = Vec::new();
u8_nodes.push(root_node);
for _ in 1..root_node_size {
let node_type = buf.read_u8()?;
let name_offset = buf.read_u24::<BigEndian>()?;
let data_offset = buf.read_u32::<BigEndian>()?;
let size = buf.read_u32::<BigEndian>()?;
u8_nodes.push(U8Node { node_type, name_offset, data_offset, size })
}
// Iterate over the loaded nodes and load the file names and data associated with them.
let base_name_offset = buf.position();
let mut file_names = Vec::<String>::new();
let mut file_data = Vec::<Vec<u8>>::new();
for node in &u8_nodes {
buf.seek(SeekFrom::Start(base_name_offset + node.name_offset as u64))?;
let mut name_bin = Vec::<u8>::new();
// Read the file name one byte at a time until we find a null byte.
loop {
let byte = buf.read_u8()?;
if byte == b'\0' {
break;
}
name_bin.push(byte);
}
file_names.push(String::from_utf8(name_bin).map_err(|_| U8Error::InvalidFileName(base_name_offset + node.name_offset as u64))?.to_owned());
// If this is a file node, read the data for the file.
if node.node_type == 0 {
buf.seek(SeekFrom::Start(node.data_offset as u64))?;
let mut data = vec![0u8; node.size as usize];
buf.read_exact(&mut data)?;
file_data.push(data);
} else {
file_data.push(Vec::new());
}
}
Ok(U8Archive {
u8_nodes,
file_names,
file_data,
root_node_offset,
header_size,
data_offset,
padding,
})
}
fn pack_dir() {
todo!();
}
pub fn from_dir(_input: &Path) -> Result<Self, U8Error> {
todo!();
}
/// Dumps the data in a U8Archive instance back into binary data that can be written to a file.
pub fn to_bytes(&self) -> Result<Vec<u8>, U8Error> {
// Header size starts at 0 because the header size starts with the nodes and does not
// include the actual file header.
let mut header_size: u32 = 0;
// Add 12 bytes for each node, since that's how many bytes each one is made up of.
for _ in 0..self.u8_nodes.len() {
header_size += 12;
}
// Add the number of bytes used for each file/folder name in the string table.
for file_name in &self.file_names {
header_size += file_name.len() as u32 + 1
}
// The initial data offset is equal to the file header (32 bytes) + node data aligned to
// 64 bytes.
let data_offset: u32 = (header_size + 32 + 63) & !63;
// Adjust all nodes to place file data in the same order as the nodes. For some reason
// Nintendo-made U8 archives don't necessarily do this?
let mut current_data_offset = data_offset;
let mut current_name_offset: u32 = 0;
let mut u8_nodes = self.u8_nodes.clone();
for i in 0..u8_nodes.len() {
if u8_nodes[i].node_type == 0 {
u8_nodes[i].data_offset = (current_data_offset + 31) & !31;
current_data_offset += (u8_nodes[i].size + 31) & !31;
}
// Calculate the name offsets, including the extra 1 for the NULL byte.
u8_nodes[i].name_offset = current_name_offset;
current_name_offset += self.file_names[i].len() as u32 + 1
}
// Begin writing file data.
let mut buf: Vec<u8> = Vec::new();
buf.write_all(b"\x55\xAA\x38\x2D")?;
buf.write_u32::<BigEndian>(0x20)?; // The root node offset is always 0x20.
buf.write_u32::<BigEndian>(header_size)?;
buf.write_u32::<BigEndian>(data_offset)?;
buf.write_all(&self.padding)?;
// Iterate over nodes and write them out.
for node in &u8_nodes {
buf.write_u8(node.node_type)?;
buf.write_u24::<BigEndian>(node.name_offset)?;
buf.write_u32::<BigEndian>(node.data_offset)?;
buf.write_u32::<BigEndian>(node.size)?;
}
// Iterate over file names with a null byte at the end.
for file_name in &self.file_names {
buf.write_all(file_name.as_bytes())?;
buf.write_u8(b'\0')?;
}
// Pad to the nearest multiple of 64 bytes.
buf.resize((buf.len() + 63) & !63, 0);
// Iterate over the file data and dump it. The file needs to be aligned to 32 bytes after
// each write.
for data in &self.file_data {
buf.write_all(data)?;
buf.resize((buf.len() + 31) & !31, 0);
}
Ok(buf)
}
}

View File

@ -3,3 +3,4 @@
pub mod ash; pub mod ash;
pub mod lz77; pub mod lz77;
pub mod u8;

View File

@ -0,0 +1,44 @@
// archive/u8.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Code for the U8 packing/unpacking commands in the rustii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use rustii::archive::u8;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Pack a directory into a U8 archive
Pack {
/// The directory to pack into a U8 archive
input: String,
/// The name of the packed U8 archive
output: String,
},
/// Unpack a U8 archive into a directory
Unpack {
/// The path to the U8 archive to unpack
input: String,
/// The directory to unpack the U8 archive to
output: String,
}
}
pub fn pack_u8_archive(_input: &str, _output: &str) -> Result<()> {
todo!();
}
pub fn unpack_u8_archive(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source U8 archive \"{}\" could not be found.", input);
}
let u8_data = u8::U8Archive::from_bytes(&fs::read(in_path)?)?;
println!("{:?}", u8_data);
fs::write(Path::new(output), u8_data.to_bytes()?)?;
Ok(())
}

View File

@ -44,6 +44,16 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
command: archive::lz77::Commands command: archive::lz77::Commands
}, },
/// Download data from the NUS
Nus {
#[command(subcommand)]
command: title::nus::Commands
},
/// Pack/unpack a U8 archive
U8 {
#[command(subcommand)]
command: archive::u8::Commands
},
/// Pack/unpack/edit a WAD file /// Pack/unpack/edit a WAD file
Wad { Wad {
#[command(subcommand)] #[command(subcommand)]
@ -68,6 +78,9 @@ fn main() -> Result<()> {
Some(Commands::Fakesign { input, output }) => { Some(Commands::Fakesign { input, output }) => {
title::fakesign::fakesign(input, output)? title::fakesign::fakesign(input, output)?
}, },
Some(Commands::Info { input }) => {
info::info(input)?
},
Some(Commands::Lz77 { command }) => { Some(Commands::Lz77 { command }) => {
match command { match command {
archive::lz77::Commands::Compress { input, output } => { archive::lz77::Commands::Compress { input, output } => {
@ -78,8 +91,31 @@ fn main() -> Result<()> {
} }
} }
}, },
Some(Commands::Info { input }) => { Some(Commands::Nus { command }) => {
info::info(input)? match command {
title::nus::Commands::Content { tid, cid, version, output, decrypt} => {
title::nus::download_content(tid, cid, version, output, decrypt)?
},
title::nus::Commands::Ticket { tid, output } => {
title::nus::download_ticket(tid, output)?
},
title::nus::Commands::Title { tid, version, output} => {
title::nus::download_title(tid, version, output)?
}
title::nus::Commands::Tmd { tid, version, output} => {
title::nus::download_tmd(tid, version, output)?
}
}
}
Some(Commands::U8 { command }) => {
match command {
archive::u8::Commands::Pack { input, output } => {
archive::u8::pack_u8_archive(input, output)?
},
archive::u8::Commands::Unpack { input, output } => {
archive::u8::unpack_u8_archive(input, output)?
}
}
}, },
Some(Commands::Wad { command }) => { Some(Commands::Wad { command }) => {
match command { match command {

View File

@ -2,4 +2,5 @@
// https://github.com/NinjaCheetah/rustii // https://github.com/NinjaCheetah/rustii
pub mod fakesign; pub mod fakesign;
pub mod nus;
pub mod wad; pub mod wad;

291
src/bin/rustii/title/nus.rs Normal file
View File

@ -0,0 +1,291 @@
// title/nus.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Code for NUS-related commands in the rustii CLI.
use std::{str, fs};
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Subcommand, Args};
use sha1::{Sha1, Digest};
use rustii::title::{cert, content, crypto, nus, ticket, tmd};
use rustii::title;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Download specific content from the NUS
Content {
/// The Title ID that the content belongs to
tid: String,
/// The Content ID of the content (in hex format, like 000000xx)
cid: String,
/// The title version that the content belongs to (only required for decryption)
#[arg(short, long)]
version: Option<String>,
/// An optional content file name; defaults to <cid>(.app)
#[arg(short, long)]
output: Option<String>,
/// Decrypt the content
#[arg(short, long)]
decrypt: bool,
},
/// Download a Ticket from the NUS
Ticket {
/// The Title ID that the Ticket is for
tid: String,
/// An optional Ticket name; defaults to <tid>.tik
#[arg(short, long)]
output: Option<String>,
},
/// Download a title from the NUS
Title {
/// The Title ID of the Title to download
tid: String,
/// The version of the Title to download
#[arg(short, long)]
version: Option<String>,
#[command(flatten)]
output: TitleOutputType,
},
/// Download a TMD from the NUS
Tmd {
/// The Title ID that the TMD is for
tid: String,
/// The version of the TMD to download
#[arg(short, long)]
version: Option<String>,
/// An optional TMD name; defaults to <tid>.tmd
#[arg(short, long)]
output: Option<String>,
}
}
#[derive(Args)]
#[clap(next_help_heading = "Output Format")]
#[group(multiple = false, required = true)]
pub struct TitleOutputType {
/// Download the Title data to the specified output directory
#[arg(short, long)]
output: Option<String>,
/// Download the Title to a WAD file
#[arg(short, long)]
wad: Option<String>,
}
pub fn download_content(tid: &str, cid: &str, version: &Option<String>, output: &Option<String>, decrypt: &bool) -> Result<()> {
println!("Downloading content with Content ID {cid}...");
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
let cid = u32::from_str_radix(cid, 16).with_context(|| "The specified Content ID is invalid!")?;
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
let content = nus::download_content(tid, cid, true).with_context(|| "Content data could not be downloaded.")?;
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else if *decrypt {
PathBuf::from(format!("{:08X}.app", cid))
} else {
PathBuf::from(format!("{:08X}", cid))
};
if *decrypt {
// We need the version to get the correct TMD because the content's index is the IV for
// decryption. A Ticket also needs to be available, of course.
let version: u16 = if version.is_some() {
version.clone().unwrap().parse().with_context(|| "The specified Title version must be a valid integer!")?
} else {
bail!("You must specify the title version that the requested content belongs to for decryption!");
};
let tmd_res = &nus::download_tmd(tid, Some(version), true);
println!(" - Downloading TMD...");
let tmd = match tmd_res {
Ok(tmd) => tmd::TMD::from_bytes(tmd)?,
Err(_) => bail!("No TMD could be found for the specified version! Check the version and try again.")
};
println!(" - Downloading Ticket...");
let tik_res = &nus::download_ticket(tid, true);
let tik = match tik_res {
Ok(tik) => ticket::Ticket::from_bytes(tik)?,
Err(_) => bail!("No Ticket is available for this title! The content cannot be decrypted.")
};
println!(" - Decrypting content...");
let (content_hash, content_size, content_index) = tmd.content_records.iter()
.find(|record| record.content_id == cid)
.map(|record| (record.content_hash, record.content_size, record.index))
.with_context(|| "No matching content record could be found. Please make sure the requested content is from the specified title version.")?;
let mut content_dec = crypto::decrypt_content(&content, tik.dec_title_key(), content_index);
content_dec.resize(content_size as usize, 0);
// Verify the content's hash before saving it.
let mut hasher = Sha1::new();
hasher.update(&content_dec);
let result = hasher.finalize();
if result[..] != content_hash {
bail!("The content's hash did not match the expected value. (Hash was {}, but the expected hash is {}.)",
hex::encode(result), hex::encode(content_hash));
}
fs::write(&out_path, content_dec).with_context(|| format!("Failed to open content file \"{}\" for writing.", out_path.display()))?;
} else {
// If we're not decrypting, just write the file out and call it a day.
fs::write(&out_path, content).with_context(|| format!("Failed to open content file \"{}\" for writing.", out_path.display()))?
}
println!("Successfully downloaded content with Content ID {:08X} to file \"{}\"!", cid, out_path.display());
Ok(())
}
pub fn download_ticket(tid: &str, output: &Option<String>) -> Result<()> {
println!("Downloading Ticket for title {tid}...");
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else {
PathBuf::from(format!("{}.tik", tid))
};
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
let tik_data = nus::download_ticket(tid, true).with_context(|| "Ticket data could not be downloaded.")?;
fs::write(&out_path, tik_data)?;
println!("Successfully downloaded Ticket to \"{}\"!", out_path.display());
Ok(())
}
fn download_title_dir(title: title::Title, output: String) -> Result<()> {
println!(" - Saving downloaded data...");
let out_path = PathBuf::from(output);
if out_path.exists() {
if !out_path.is_dir() {
bail!("A file already exists with the specified directory name!");
}
} 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);
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))?;
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))?;
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))?;
// Iterate over the content files and write them out in encrypted form.
for record in &title.content.content_records {
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))?;
}
println!("Successfully downloaded title with Title ID {} to directory \"{}\"!", tid, out_path.display());
Ok(())
}
fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion, cert_chain: cert::CertificateChain, output: String) -> Result<()> {
println!(" - Saving downloaded data...");
let out_path = PathBuf::from(output);
if out_path.exists() {
if !out_path.is_dir() {
bail!("A file already exists with the specified directory name!");
}
} else {
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
let tid = hex::encode(tmd.title_id);
println!(" - Saving TMD...");
fs::write(out_path.join(format!("{}.tmd", &tid)), tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?;
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 {
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)?)
.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());
Ok(())
}
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());
Ok(())
}
pub fn download_title(tid: &str, version: &Option<String>, output: &TitleOutputType) -> Result<()> {
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
if version.is_some() {
println!("Downloading title {} v{}, please wait...", tid, version.clone().unwrap());
} else {
println!("Downloading title {} vLatest, please wait...", tid);
}
let version: Option<u16> = if version.is_some() {
Some(version.clone().unwrap().parse().with_context(|| "The specified Title version must be a valid integer!")?)
} else {
None
};
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
println!(" - Downloading and parsing TMD...");
let tmd = tmd::TMD::from_bytes(&nus::download_tmd(tid, version, true).with_context(|| "TMD data could not be downloaded.")?)?;
println!(" - Downloading and parsing Ticket...");
let tik_res = &nus::download_ticket(tid, true);
let tik = match tik_res {
Ok(tik) => Some(ticket::Ticket::from_bytes(tik)?),
Err(_) => {
if output.wad.is_some() {
bail!("--wad was specified, but this Title has no common Ticket and cannot be packed into a WAD!");
} else {
println!(" - No Ticket is available!");
None
}
}
};
// Build a vec of contents by iterating over the content records and downloading each one.
let mut contents: Vec<Vec<u8>> = Vec::new();
for record in &tmd.content_records {
println!(" - Downloading content {} of {} (Content ID: {}, Size: {} bytes)...",
record.index + 1, &tmd.content_records.len(), record.content_id, record.content_size);
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 tik.is_some() {
// 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.unwrap(), tmd, content_region, None)?;
if output.wad.is_some() {
download_title_wad(title, output.wad.clone().unwrap())?;
} else {
download_title_dir(title, output.output.clone().unwrap())?;
}
} 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())?;
}
Ok(())
}
pub fn download_tmd(tid: &str, version: &Option<String>, output: &Option<String>) -> Result<()> {
let version: Option<u16> = if version.is_some() {
Some(version.clone().unwrap().parse().with_context(|| "The specified TMD version must be a valid integer!")?)
} else {
None
};
println!("Downloading TMD for title {tid}...");
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else if version.is_some() {
PathBuf::from(format!("{}.tmd.{}", tid, version.unwrap()))
} else {
PathBuf::from(format!("{}.tmd", tid))
};
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
let tmd_data = nus::download_tmd(tid, version, true).with_context(|| "TMD data could not be downloaded.")?;
fs::write(&out_path, tmd_data)?;
println!("Successfully downloaded TMD to \"{}\"!", out_path.display());
Ok(())
}

View File

@ -138,7 +138,7 @@ pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option<String>
} }
title.ticket.title_key = title_key_new; title.ticket.title_key = title_key_new;
title.fakesign()?; title.fakesign()?;
fs::write(out_path.clone(), title.to_wad()?.to_bytes()?)?; 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()); println!("Successfully converted {} WAD to {} WAD \"{}\"!", source, target, out_path.file_name().unwrap().to_str().unwrap());
Ok(()) Ok(())
} }
@ -205,7 +205,7 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> {
out_path.set_extension("wad"); out_path.set_extension("wad");
} }
} }
fs::write(out_path.clone(), wad.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?; fs::write(&out_path, wad.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("WAD file packed!"); println!("WAD file packed!");
Ok(()) Ok(())
} }

View File

@ -6,6 +6,7 @@
use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use sha1::{Sha1, Digest}; use sha1::{Sha1, Digest};
use thiserror::Error; use thiserror::Error;
use crate::title::content::ContentError::MissingContents;
use crate::title::tmd::ContentRecord; use crate::title::tmd::ContentRecord;
use crate::title::crypto; use crate::title::crypto;
@ -13,6 +14,8 @@ use crate::title::crypto;
pub enum ContentError { pub enum ContentError {
#[error("requested index {index} is out of range (must not exceed {max})")] #[error("requested index {index} is out of range (must not exceed {max})")]
IndexOutOfRange { index: usize, max: usize }, 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")] #[error("content with requested Content ID {0} could not be found")]
CIDNotFound(u32), CIDNotFound(u32),
#[error("content's hash did not match the expected value (was {hash}, expected {expected})")] #[error("content's hash did not match the expected value (was {hash}, expected {expected})")]
@ -71,15 +74,27 @@ impl ContentRegion {
}) })
} }
/// 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(MissingContents { required: content_records.len(), found: contents.len()});
}
let mut content_region = Self::new(content_records)?;
for i in 0..contents.len() {
content_region.load_enc_content(&contents[i], content_region.content_records[i].index as usize)?;
}
Ok(content_region)
}
/// Creates a ContentRegion instance from the ContentRecords of a TMD that contains no actual /// 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. /// content. This can be used to load existing content from files.
pub fn new(content_records: Vec<ContentRecord>) -> Result<Self, ContentError> { 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: u64 = content_records.iter().map(|x| (x.content_size + 63) & !63).sum();
let content_region_size = content_region_size as u32; let content_region_size = content_region_size as u32;
let num_contents = content_records.len() as u16; let num_contents = content_records.len() as u16;
let content_start_offsets: Vec<u64> = Vec::new(); let content_start_offsets: Vec<u64> = vec![0; num_contents as usize];
let mut contents: Vec<Vec<u8>> = Vec::new(); let contents: Vec<Vec<u8>> = vec![Vec::new(); num_contents as usize];
contents.resize(num_contents as usize, Vec::new());
Ok(ContentRegion { Ok(ContentRegion {
content_records, content_records,
content_region_size, content_region_size,
@ -144,6 +159,16 @@ impl ContentRegion {
} }
} }
/// 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] = Vec::from(content);
Ok(())
}
/// Loads existing content into the specified index of a ContentRegion instance. This content /// 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 /// must be decrypted and needs to match the size and hash listed in the content record at that
/// index. /// index.

View File

@ -7,6 +7,7 @@ pub mod cert;
pub mod commonkeys; pub mod commonkeys;
pub mod content; pub mod content;
pub mod crypto; pub mod crypto;
pub mod nus;
pub mod ticket; pub mod ticket;
pub mod tmd; pub mod tmd;
pub mod versions; pub mod versions;
@ -52,15 +53,37 @@ impl Title {
let ticket = ticket::Ticket::from_bytes(&wad.ticket()).map_err(TitleError::Ticket)?; 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 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 = content::ContentRegion::from_bytes(&wad.content(), tmd.content_records.clone()).map_err(TitleError::Content)?;
let title = Title { Ok(Title {
cert_chain, cert_chain,
crl: wad.crl(), crl: wad.crl(),
ticket, ticket,
tmd, tmd,
content, content,
meta: wad.meta(), meta: wad.meta(),
})
}
/// 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> {
// 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 {
Some(crl) => crl.to_vec(),
None => Vec::new()
}; };
Ok(title) let meta = match meta {
Some(meta) => meta.to_vec(),
None => Vec::new()
};
Ok(Title {
cert_chain,
crl,
ticket,
tmd,
content,
meta
})
} }
/// Converts a Title instance into a WAD, which can be used to export the Title back to a file. /// Converts a Title instance into a WAD, which can be used to export the Title back to a file.

141
src/title/nus.rs Normal file
View File

@ -0,0 +1,141 @@
// title/nus.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Implements the functions required for downloading data from the NUS.
use std::str;
use std::io::Write;
use reqwest;
use thiserror::Error;
use crate::title::{cert, tmd, ticket, content};
use crate::title;
const WII_NUS_ENDPOINT: &str = "http://nus.cdn.shop.wii.com/ccs/download/";
const WII_U_NUS_ENDPOINT: &str = "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/";
#[derive(Debug, Error)]
pub enum NUSError {
#[error("the data returned by the NUS is not valid")]
InvalidData,
#[error("the requested Title ID or version could not be found on the NUS")]
NotFound,
#[error("Certificate processing error")]
Certificate(#[from] cert::CertificateError),
#[error("TMD processing error")]
TMD(#[from] tmd::TMDError),
#[error("Ticket processing error")]
Ticket(#[from] ticket::TicketError),
#[error("Content processing error")]
Content(#[from] content::ContentError),
#[error("an error occurred while assembling a Title from the downloaded data")]
Title(#[from] title::TitleError),
#[error("data could not be downloaded from the NUS")]
Request(#[from] reqwest::Error),
#[error("an error occurred writing NUS data")]
IO(#[from] std::io::Error),
}
/// Downloads the retail certificate chain from the NUS.
pub fn download_cert_chain(wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// To build the certificate chain, we need to download both the TMD and Ticket of a title. For
// the sake of simplicity, we'll use the Wii Menu 4.3U because I already found the required TMD
// and Ticket offsets for it.
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let tmd_url = format!("{}0000000100000002/tmd.513", endpoint_url);
let tik_url = format!("{}0000000100000002/cetk", endpoint_url);
let client = reqwest::blocking::Client::new();
let tmd = client.get(tmd_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?.bytes()?;
let tik = client.get(tik_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?.bytes()?;
// Assemble the certificate chain.
let mut cert_chain: Vec<u8> = Vec::new();
// Certificate Authority data.
cert_chain.write_all(&tik[0x2A4 + 768..])?;
// Certificate Policy (TMD certificate) data.
cert_chain.write_all(&tmd[0x328..0x328 + 768])?;
// XS (Ticket certificate) data.
cert_chain.write_all(&tik[0x2A4..0x2A4 + 768])?;
Ok(cert_chain)
}
/// Downloads a specified content file from the specified title from the NUS.
pub fn download_content(title_id: [u8; 8], content_id: u32, wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// Build the download URL. The structure is download/<TID>/<CID>
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let content_url = format!("{}{}/{:08X}", endpoint_url, &hex::encode(title_id), content_id);
let client = reqwest::blocking::Client::new();
let response = client.get(content_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
if !response.status().is_success() {
return Err(NUSError::NotFound);
}
Ok(response.bytes()?.to_vec())
}
/// Downloads all contents from the specified title from the NUS.
pub fn download_contents(tmd: &tmd::TMD, wiiu_endpoint: bool) -> Result<Vec<Vec<u8>>, NUSError> {
let content_ids: Vec<u32> = tmd.content_records.iter().map(|record| { record.content_id }).collect();
let mut contents: Vec<Vec<u8>> = Vec::new();
for id in content_ids {
contents.push(download_content(tmd.title_id, id, wiiu_endpoint)?);
}
Ok(contents)
}
/// Downloads the Ticket for a specified Title ID from the NUS, if it's available.
pub fn download_ticket(title_id: [u8; 8], wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// Build the download URL. The structure is download/<TID>/cetk.
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let tik_url = format!("{}{}/cetk", endpoint_url, &hex::encode(title_id));
let client = reqwest::blocking::Client::new();
let response = client.get(tik_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
if !response.status().is_success() {
return Err(NUSError::NotFound);
}
let tik = ticket::Ticket::from_bytes(&response.bytes()?).map_err(|_| NUSError::InvalidData)?;
tik.to_bytes().map_err(|_| NUSError::InvalidData)
}
/// Downloads an entire title with all of its content from the NUS and returns a Title instance.
pub fn download_title(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint: bool) -> Result<title::Title, NUSError> {
// Download the individual components of a title and then build a title from them.
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)?;
Ok(title)
}
/// Downloads the TMD for a specified Title ID from the NUS.
pub fn download_tmd(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// Build the download URL. The structure is download/<TID>/tmd for latest and
// download/<TID>/tmd.<version> for when a specific version is requested.
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let tmd_url = if title_version.is_some() {
format!("{}{}/tmd.{}", endpoint_url, &hex::encode(title_id), title_version.unwrap())
} else {
format!("{}{}/tmd", endpoint_url, &hex::encode(title_id))
};
let client = reqwest::blocking::Client::new();
let response = client.get(tmd_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
if !response.status().is_success() {
return Err(NUSError::NotFound);
}
let tmd = tmd::TMD::from_bytes(&response.bytes()?).map_err(|_| NUSError::InvalidData)?;
tmd.to_bytes().map_err(|_| NUSError::InvalidData)
}

View File

@ -50,11 +50,11 @@ impl fmt::Display for TitleType {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ContentType { pub enum ContentType {
Normal, Normal = 1,
Development, Development = 2,
HashTree, HashTree = 3,
DLC, DLC = 16385,
Shared, Shared = 32769,
} }
impl fmt::Display for ContentType { impl fmt::Display for ContentType {
@ -70,8 +70,8 @@ impl fmt::Display for ContentType {
} }
pub enum AccessRight { pub enum AccessRight {
AHB, AHB = 0,
DVDVideo, DVDVideo = 1,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -332,10 +332,7 @@ impl TMD {
/// Gets whether a specified access right is enabled in a TMD. /// Gets whether a specified access right is enabled in a TMD.
pub fn check_access_right(&self, right: AccessRight) -> bool { pub fn check_access_right(&self, right: AccessRight) -> bool {
match right { self.access_rights & (1 << right as u8) != 0
AccessRight::AHB => (self.access_rights & (1 << 0)) != 0,
AccessRight::DVDVideo => (self.access_rights & (1 << 1)) != 0,
}
} }
/// Gets the name of the certificate used to sign a TMD as a string. /// Gets the name of the certificate used to sign a TMD as a string.