From 2042419abcec75f291925b312f81f71efc42fe39 Mon Sep 17 00:00:00 2001 From: MeownsterHunter <340164973+MeownsterHunter@users.noreply.github.com> Date: Sun, 4 Oct 2026 07:47:27 -0400 Subject: [PATCH 1/2] Read NSO flags from header offset 0xC The NSO header is magic, version, a reserved word, then flags. The parser read flags from the reserved word at 0x8. Real NSO files have zero there, so every segment looked uncompressed and intake failed with a segment size mismatch. The test fixtures wrote flags at 0x8 as well, which is why the tests passed. They now use 0xC. --- crates/recomp-pipeline/src/homebrew/nso.rs | 3 ++- crates/recomp-pipeline/tests/homebrew_intake.rs | 2 +- crates/recomp-pipeline/tests/xci_intake.rs | 2 +- samples/homebrew-intake/generate.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/recomp-pipeline/src/homebrew/nso.rs b/crates/recomp-pipeline/src/homebrew/nso.rs index dee6aab..41047b4 100644 --- a/crates/recomp-pipeline/src/homebrew/nso.rs +++ b/crates/recomp-pipeline/src/homebrew/nso.rs @@ -78,7 +78,8 @@ pub fn parse_nso(path: &Path) -> Result { return Err(format!("NSO magic mismatch: {magic:#x}")); } - let flags = read_u32(&bytes, 0x8).map_err(|err| err.to_string())?; + // Header layout: magic (0x0), version (0x4), reserved (0x8), flags (0xC). + let flags = read_u32(&bytes, 0xC).map_err(|err| err.to_string())?; let text = read_segment(&bytes, 0x10, NsoSegmentKind::Text)?; let rodata = read_segment(&bytes, 0x20, NsoSegmentKind::Rodata)?; let data = read_segment(&bytes, 0x30, NsoSegmentKind::Data)?; diff --git a/crates/recomp-pipeline/tests/homebrew_intake.rs b/crates/recomp-pipeline/tests/homebrew_intake.rs index 5575f54..f9082fb 100644 --- a/crates/recomp-pipeline/tests/homebrew_intake.rs +++ b/crates/recomp-pipeline/tests/homebrew_intake.rs @@ -242,7 +242,7 @@ fn build_nso(path: &Path) -> Vec { let mut bytes = vec![0u8; total]; bytes[0x0..0x4].copy_from_slice(b"NSO0"); - write_u32(&mut bytes, 0x8, 0x1); + write_u32(&mut bytes, 0xC, 0x1); write_u32(&mut bytes, 0x10, text_off); write_u32(&mut bytes, 0x14, 0); write_u32(&mut bytes, 0x18, text.len() as u32); diff --git a/crates/recomp-pipeline/tests/xci_intake.rs b/crates/recomp-pipeline/tests/xci_intake.rs index 1a98f30..b779b1a 100644 --- a/crates/recomp-pipeline/tests/xci_intake.rs +++ b/crates/recomp-pipeline/tests/xci_intake.rs @@ -183,7 +183,7 @@ fn build_nso() -> Vec { let mut bytes = vec![0u8; total]; bytes[0x0..0x4].copy_from_slice(b"NSO0"); - write_u32(&mut bytes, 0x8, 0x1); + write_u32(&mut bytes, 0xC, 0x1); write_u32(&mut bytes, 0x10, text_off); write_u32(&mut bytes, 0x14, 0); write_u32(&mut bytes, 0x18, text.len() as u32); diff --git a/samples/homebrew-intake/generate.py b/samples/homebrew-intake/generate.py index a749c69..cfdce1b 100644 --- a/samples/homebrew-intake/generate.py +++ b/samples/homebrew-intake/generate.py @@ -103,7 +103,7 @@ def build_nso(path: Path) -> None: buf = bytearray(total) buf[0x0:0x4] = b"NSO0" - write_u32(buf, 0x8, 0x0) + write_u32(buf, 0xC, 0x0) write_u32(buf, 0x10, text_off) write_u32(buf, 0x14, 0x0) write_u32(buf, 0x18, len(text)) From eb2f7f0de71515e368ce288087ef4b359a7cb1d0 Mon Sep 17 00:00:00 2001 From: MeownsterHunter <340164973+MeownsterHunter@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:57:29 -0400 Subject: [PATCH 2/2] Verify NSO segment hashes when the header asks for it An NSO header stores the SHA-256 of each decompressed segment, and flag bits 3..5 say whether to check it. The reader now checks them, so a segment that was decompressed wrongly, or read with the wrong layout, is an error instead of silently wrong code. --- crates/recomp-pipeline/src/homebrew/nso.rs | 96 ++++++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/recomp-pipeline/src/homebrew/nso.rs b/crates/recomp-pipeline/src/homebrew/nso.rs index 41047b4..1472149 100644 --- a/crates/recomp-pipeline/src/homebrew/nso.rs +++ b/crates/recomp-pipeline/src/homebrew/nso.rs @@ -1,5 +1,6 @@ use crate::homebrew::util::{hex_bytes, parse_error, read_bytes, read_u32}; use lz4_flex::block::decompress; +use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; @@ -38,6 +39,8 @@ pub struct NsoSegment { pub size: u32, pub file_size: u32, pub compressed: bool, + /// SHA-256 of the decompressed segment, when the header asks for it to be checked. + pub expected_sha256: Option<[u8; 32]>, pub permissions: NsoSegmentPermissions, } @@ -90,14 +93,19 @@ pub fn parse_nso(path: &Path) -> Result { read_u32(&bytes, 0x68).map_err(|err| err.to_string())?, ]; + // Flag bits 0..2 mark text, rodata, data as LZ4-compressed; bits 3..5 + // ask for the segment hash at 0xA0 + 0x20 * index to be checked. let mut segments = vec![text, rodata, data]; - for (segment, file_size) in segments.iter_mut().zip(file_sizes) { + for (index, (segment, file_size)) in segments.iter_mut().zip(file_sizes).enumerate() { segment.file_size = file_size; - segment.compressed = match segment.kind { - NsoSegmentKind::Text => flags & 0x1 != 0, - NsoSegmentKind::Rodata => flags & 0x2 != 0, - NsoSegmentKind::Data => flags & 0x4 != 0, - }; + segment.compressed = flags & (1 << index) != 0; + if flags & (8 << index) != 0 { + let hash = read_bytes(&bytes, 0xA0 + 0x20 * index, 0x20) + .map_err(|err| err.to_string())? + .try_into() + .map_err(|_| "NSO segment hash length mismatch".to_string())?; + segment.expected_sha256 = Some(hash); + } } let bss_size = read_u32(&bytes, 0x3C).map_err(|err| err.to_string())?; @@ -161,6 +169,16 @@ pub fn extract_segments(module: &NsoModule) -> Result, Strin decoded.len() )); } + if let Some(expected) = segment.expected_sha256 { + let mut hasher = Sha256::new(); + hasher.update(&decoded); + if hasher.finalize().as_slice() != expected { + return Err(format!( + "NSO {:?} segment does not match the SHA-256 in the header", + segment.kind + )); + } + } out.push(NsoSegmentData { segment: segment.clone(), data: decoded, @@ -186,6 +204,72 @@ fn read_segment(bytes: &[u8], offset: usize, kind: NsoSegmentKind) -> Result PathBuf { + let segments: [&[u8]; 3] = [b"TEXTTEXTTEXTTEXT", b"RODATA", b"DATA"]; + let stored = [ + lz4_flex::block::compress(segments[0]), + segments[1].to_vec(), + segments[2].to_vec(), + ]; + let mut bytes = vec![0u8; NSO_HEADER_SIZE]; + bytes[0..4].copy_from_slice(b"NSO0"); + // Flags live at 0xC: bit 0 = text compressed, bits 3..5 = check hashes. + bytes[0xC..0x10].copy_from_slice(&0x39u32.to_le_bytes()); + let mut offset = NSO_HEADER_SIZE as u32; + for (index, (plain, blob)) in segments.iter().zip(&stored).enumerate() { + let entry = 0x10 + index * 0x10; + bytes[entry..entry + 4].copy_from_slice(&offset.to_le_bytes()); + bytes[entry + 4..entry + 8].copy_from_slice(&(index as u32 * 0x1000).to_le_bytes()); + bytes[entry + 8..entry + 12].copy_from_slice(&(plain.len() as u32).to_le_bytes()); + let size_entry = 0x60 + index * 4; + bytes[size_entry..size_entry + 4].copy_from_slice(&(blob.len() as u32).to_le_bytes()); + let mut hasher = Sha256::new(); + hasher.update(plain); + let hash_entry = 0xA0 + index * 0x20; + bytes[hash_entry..hash_entry + 0x20].copy_from_slice(&hasher.finalize()); + offset += blob.len() as u32; + } + if corrupt_hash { + bytes[0xC0] ^= 0xFF; + } + for blob in &stored { + bytes.extend_from_slice(blob); + } + let path = dir.join("module.nso"); + fs::write(&path, bytes).expect("write nso"); + path + } + + #[test] + fn segments_are_decompressed_and_match_the_header_hashes() { + let temp = tempfile::tempdir().expect("tempdir"); + let module = parse_nso(&write_nso(temp.path(), false)).expect("parse"); + assert!(module.segments[0].compressed); + assert!(!module.segments[1].compressed); + assert!(module.segments.iter().all(|s| s.expected_sha256.is_some())); + + let segments = extract_segments(&module).expect("extract"); + assert_eq!(segments[0].data, b"TEXTTEXTTEXTTEXT"); + assert_eq!(segments[1].data, b"RODATA"); + assert_eq!(segments[2].data, b"DATA"); + } + + #[test] + fn segment_hash_mismatch_is_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + let module = parse_nso(&write_nso(temp.path(), true)).expect("parse"); + let err = extract_segments(&module).unwrap_err(); + assert!(err.contains("does not match the SHA-256"), "{err}"); + } +}