Skip to main content

nsz_rs/
decompress.rs

1//! NCZ decompression: solid and block, reconstructing the encrypted NCA and its
2//! SHA-256. Mirrors `nsz/Decompressor.py::__decompressNcz` and
3//! `nsz/BlockDecompressorReader.py`.
4
5use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
6
7use sha2::{Digest, Sha256};
8
9use crate::crypto::ctr;
10use crate::error::{Error, Result};
11use crate::format::nca::is_ctr;
12use crate::format::ncz::{self, BlockHeader, INCOMPRESSIBLE_HEADER_SIZE, Section};
13use crate::format::read_vec;
14
15const CHUNK: usize = 0x100000;
16
17/// Sequential reader over independently-compressed NCZ blocks. Keeps the
18/// current decompressed block cached so small reads don't re-decompress it.
19struct BlockReader<R: Read + Seek> {
20    reader: R,
21    block_size: usize,
22    compressed_offsets: Vec<u64>,
23    compressed_sizes: Vec<u32>,
24    decompressed_size: u64,
25    position: u64,
26    cached: Option<(usize, Vec<u8>)>,
27}
28
29impl<R: Read + Seek> BlockReader<R> {
30    fn new(reader: R, bh: &BlockHeader, payload_start: u64) -> Result<BlockReader<R>> {
31        if !(14..=32).contains(&bh.block_size_exponent) {
32            return Err(Error::Corrupt(
33                "NCZBLOCK: block size exponent must be 14..=32".into(),
34            ));
35        }
36        let offsets = bh
37            .compressed_block_size_list
38            .iter()
39            .scan(payload_start, |off, &sz| {
40                let start = *off;
41                *off += sz as u64;
42                Some(start)
43            })
44            .collect();
45        Ok(BlockReader {
46            reader,
47            block_size: 1usize << bh.block_size_exponent,
48            compressed_offsets: offsets,
49            compressed_sizes: bh.compressed_block_size_list.clone(),
50            decompressed_size: bh.decompressed_size as u64,
51            position: 0,
52            cached: None,
53        })
54    }
55
56    fn decompress_block(&mut self, block_id: usize) -> io::Result<Vec<u8>> {
57        let mut decompressed_block_size = self.block_size;
58        if block_id + 1 == self.compressed_offsets.len() {
59            let remainder = (self.decompressed_size % self.block_size as u64) as usize;
60            if remainder > 0 {
61                decompressed_block_size = remainder;
62            }
63        }
64        self.reader
65            .seek(SeekFrom::Start(self.compressed_offsets[block_id]))?;
66        let csize = self.compressed_sizes[block_id] as usize;
67        if csize < decompressed_block_size {
68            let mut comp = vec![0u8; csize];
69            self.reader.read_exact(&mut comp)?;
70            zstd::bulk::decompress(&comp, decompressed_block_size)
71        } else {
72            let mut raw = vec![0u8; decompressed_block_size];
73            self.reader.read_exact(&mut raw)?;
74            Ok(raw)
75        }
76    }
77}
78
79impl<R: Read + Seek> Read for BlockReader<R> {
80    fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
81        let block_id = (self.position / self.block_size as u64) as usize;
82        if self.position >= self.decompressed_size || block_id >= self.compressed_offsets.len() {
83            return Ok(0);
84        }
85        if self.cached.as_ref().map(|(id, _)| *id) != Some(block_id) {
86            self.cached = Some((block_id, self.decompress_block(block_id)?));
87        }
88        let block = &self.cached.as_ref().unwrap().1;
89        let offset = (self.position % self.block_size as u64) as usize;
90        let n = out.len().min(block.len().saturating_sub(offset));
91        out[..n].copy_from_slice(&block[offset..offset + n]);
92        self.position += n as u64;
93        Ok(n)
94    }
95}
96
97/// Read the NCZ header region (section table + optional block header) from a
98/// seekable reader positioned at file offset 0. Returns the sections (with the
99/// incompressible-gap fake section inserted), the block header if present, and the
100/// file offset where the compressed payload begins.
101pub fn read_ncz_header<R: Read + Seek>(
102    f: &mut R,
103) -> Result<(Vec<Section>, Option<BlockHeader>, u64)> {
104    f.seek(SeekFrom::Start(INCOMPRESSIBLE_HEADER_SIZE))?;
105    let mut head = [0u8; 16];
106    f.read_exact(&mut head)?;
107    let table_len = u64::try_from(ncz::le_i64(&head, 8))
108        .ok()
109        .and_then(|n| n.checked_mul(Section::WIRE_SIZE as u64))
110        .ok_or_else(|| Error::Corrupt("bad section count".into()))?;
111    let buf = [&head[..], &read_vec(f, table_len)?].concat();
112    let (mut sections, _) = ncz::parse_header(&buf)?;
113    let block_start = INCOMPRESSIBLE_HEADER_SIZE + buf.len() as u64;
114
115    // Peek for NCZBLOCK.
116    let mut fixed = [0u8; BlockHeader::FIXED_SIZE];
117    let has_block = f.read_exact(&mut fixed).is_ok() && fixed[..8] == *ncz::BLOCK_MAGIC;
118    let (block, payload_start) = if has_block {
119        let nblocks = u64::try_from(ncz::le_i32(&fixed, 12))
120            .map_err(|_| Error::Corrupt("negative block count".into()))?;
121        let bh = [&fixed[..], &read_vec(f, nblocks * 4)?].concat();
122        (Some(BlockHeader::read(&bh)?), block_start + bh.len() as u64)
123    } else {
124        (None, block_start)
125    };
126
127    // Insert the fake section covering the gap between the incompressible header
128    // and the first real section (stored uncompressed in the stream).
129    if !sections.is_empty() && sections[0].offset > INCOMPRESSIBLE_HEADER_SIZE as i64 {
130        let fake = Section {
131            offset: INCOMPRESSIBLE_HEADER_SIZE as i64,
132            size: sections[0].offset - INCOMPRESSIBLE_HEADER_SIZE as i64,
133            crypto_type: 0,
134            crypto_key: [0u8; 16],
135            crypto_counter: [0u8; 16],
136        };
137        sections.insert(0, fake);
138    }
139
140    Ok((sections, block, payload_start))
141}
142
143/// Decompress an NCZ (from a seekable reader) into `out`, returning the number of
144/// bytes written and the hex SHA-256 of the reconstructed NCA.
145pub fn decompress_ncz<R: Read + Seek, W: Write>(f: &mut R, out: &mut W) -> Result<(u64, String)> {
146    // The incompressible 0x4000 header is copied verbatim and hashed.
147    f.seek(SeekFrom::Start(0))?;
148    let mut header = vec![0u8; INCOMPRESSIBLE_HEADER_SIZE as usize];
149    f.read_exact(&mut header)?;
150    out.write_all(&header)?;
151    let mut hasher = Sha256::new();
152    hasher.update(&header);
153
154    let (sections, block, payload_start) = read_ncz_header(f)?;
155    f.seek(SeekFrom::Start(payload_start))?;
156    let body: Box<dyn Read + '_> = match block.as_ref() {
157        Some(bh) => Box::new(BlockReader::new(f.by_ref(), bh, payload_start)?),
158        None => Box::new(
159            zstd::stream::read::Decoder::new(BufReader::new(f.by_ref()))
160                .map_err(|e| Error::Corrupt(format!("zstd init: {e}")))?,
161        ),
162    };
163
164    let body_written = decompress_body(body, &sections, out, &mut hasher)?;
165    Ok((
166        INCOMPRESSIBLE_HEADER_SIZE + body_written,
167        hex::encode(hasher.finalize()),
168    ))
169}
170
171/// Core loop shared by solid and block: read the decrypted body sequentially,
172/// re-encrypt each CTR section, write, and hash the reconstructed NCA body.
173fn decompress_body<S: Read, W: Write>(
174    mut body: S,
175    sections: &[Section],
176    out: &mut W,
177    hasher: &mut Sha256,
178) -> Result<u64> {
179    let mut written: u64 = 0;
180    let mut chunk = vec![0u8; CHUNK];
181    for (idx, s) in sections.iter().enumerate() {
182        let mut i = s.offset;
183        let end = s.offset.saturating_add(s.size);
184        if idx == 0 {
185            // The part of the first section inside the 0x4000 header was stored verbatim.
186            i = i.max(INCOMPRESSIBLE_HEADER_SIZE as i64);
187        }
188        while i < end {
189            let want = std::cmp::min(CHUNK as i64, end - i) as usize;
190            let buf = &mut chunk[..want];
191            body.read_exact(buf).map_err(|e| match e.kind() {
192                io::ErrorKind::UnexpectedEof => {
193                    Error::Corrupt(format!("NCZ truncated: body ends before offset {i:#x}"))
194                }
195                _ => Error::Io(e),
196            })?;
197            if is_ctr(s.crypto_type) {
198                ctr::keystream_xor(&s.crypto_key, &s.crypto_counter, i as u64, buf);
199            }
200            out.write_all(buf)?;
201            hasher.update(&*buf);
202            written += want as u64;
203            i += want as i64;
204        }
205    }
206    Ok(written)
207}