diff --git a/crates/fparkan-nres/src/lib.rs b/crates/fparkan-nres/src/lib.rs index f665f31..3a8e264 100644 --- a/crates/fparkan-nres/src/lib.rs +++ b/crates/fparkan-nres/src/lib.rs @@ -20,7 +20,7 @@ )] //! Strict and lossless `NRes` archive support. -use fparkan_binary::{Cursor, DecodeError}; +use fparkan_binary::{checked_allocation_len, Cursor, DecodeError}; use fparkan_path::{ascii_lookup_key, LookupKey}; use std::cmp::Ordering; use std::fmt; @@ -51,6 +51,33 @@ pub enum WriteProfile { CanonicalCompact, } +/// Decode-time archive limits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DecodeLimits { + /// Maximum accepted source archive bytes. + pub max_input_bytes: u64, + /// Maximum accepted entry count. + pub max_entries: u32, + /// Maximum accepted single payload byte length. + pub max_decoded_entry_bytes: u64, + /// Maximum accepted cumulative payload bytes. + pub max_total_decoded_bytes: u64, + /// Maximum accepted preserved-region bytes. + pub max_preserved_bytes: u64, +} + +impl Default for DecodeLimits { + fn default() -> Self { + Self { + max_input_bytes: 256 * 1024 * 1024, + max_entries: 1_000_000, + max_decoded_entry_bytes: 64 * 1024 * 1024, + max_total_decoded_bytes: 512 * 1024 * 1024, + max_preserved_bytes: 64 * 1024 * 1024, + } + } +} + /// `NRes` archive header. #[derive(Clone, Debug, Eq, PartialEq)] pub struct NresHeader { @@ -343,16 +370,31 @@ impl From for NresError { /// Returns [`NresError`] when the header, directory, payload ranges, or strict /// lookup permutation are malformed for the selected [`ReadProfile`]. pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result { - let header = parse_header(&bytes)?; - let entries = parse_entries(&bytes, &header)?; + decode_with_limits(bytes, profile, DecodeLimits::default()) +} + +/// Decodes `NRes` bytes with explicit archive limits. +/// +/// # Errors +/// +/// Returns [`NresError`] when the input exceeds configured limits, the header, +/// directory, payload ranges, or strict lookup permutation are malformed. +pub fn decode_with_limits( + bytes: Arc<[u8]>, + profile: ReadProfile, + limits: DecodeLimits, +) -> Result { + let header = parse_header(&bytes, limits)?; + let entries = parse_entries(&bytes, &header, limits)?; validate_names(&entries)?; - validate_payload_ranges(&entries)?; + validate_payload_ranges(&entries, limits)?; let lookup_order_valid = match validate_lookup_order(&entries) { - Ok(valid) => valid, + Ok(()) => true, Err(err) if profile == ReadProfile::Strict => return Err(err), Err(_) => false, }; - let preserved_regions = find_preserved_regions(&bytes, &entries, header.directory_offset)?; + let preserved_regions = + find_preserved_regions(&bytes, &entries, header.directory_offset, limits)?; Ok(NresDocument { bytes, header, @@ -684,7 +726,11 @@ impl NresEntry { } } -fn parse_header(bytes: &[u8]) -> Result { +fn parse_header(bytes: &[u8], limits: DecodeLimits) -> Result { + enforce_limit( + u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?, + limits.max_input_bytes, + )?; if bytes.len() < HEADER_LEN { let mut got = [0; 4]; let copy_len = bytes.len().min(4); @@ -711,6 +757,7 @@ fn parse_header(bytes: &[u8]) -> Result { } let entry_count = u32::try_from(entry_count_signed).map_err(|_| DecodeError::IntegerOverflow)?; + enforce_limit(u64::from(entry_count), u64::from(limits.max_entries))?; let total_size = cursor.read_u32_le()?; let actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?; if u64::from(total_size) != actual { @@ -750,8 +797,16 @@ fn parse_header(bytes: &[u8]) -> Result { }) } -fn parse_entries(bytes: &[u8], header: &NresHeader) -> Result, NresError> { - let mut entries = Vec::with_capacity(header.entry_count as usize); +fn parse_entries( + bytes: &[u8], + header: &NresHeader, + limits: DecodeLimits, +) -> Result, NresError> { + let capacity = checked_allocation_len( + u64::from(header.entry_count), + u64::from(limits.max_entries), + )?; + let mut entries = Vec::with_capacity(capacity); let directory_offset = usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?; for index in 0..header.entry_count { @@ -832,7 +887,7 @@ fn parse_entry( }) } -fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> { +fn validate_payload_ranges(entries: &[NresEntry], limits: DecodeLimits) -> Result<(), NresError> { let mut ranges: Vec<(u32, Range)> = entries .iter() .map(|entry| (entry.id.0, entry.data_range.clone())) @@ -843,6 +898,15 @@ fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> { .cmp(&right.1.start) .then_with(|| left.1.end.cmp(&right.1.end)) }); + let mut total_payload_bytes = 0_u64; + for entry in entries { + let payload_len = u64::from(entry.meta.data_size); + enforce_limit(payload_len, limits.max_decoded_entry_bytes)?; + total_payload_bytes = total_payload_bytes + .checked_add(payload_len) + .ok_or(DecodeError::IntegerOverflow)?; + enforce_limit(total_payload_bytes, limits.max_total_decoded_bytes)?; + } for pair in ranges.windows(2) { if pair[0].1.end > pair[1].1.start { return Err(NresError::EntryDataOverlap { @@ -863,7 +927,7 @@ fn validate_names(entries: &[NresEntry]) -> Result<(), NresError> { Ok(()) } -fn validate_lookup_order(entries: &[NresEntry]) -> Result { +fn validate_lookup_order(entries: &[NresEntry]) -> Result<(), NresError> { let entry_count = saturating_u32_len(entries.len()); let mut seen = vec![false; entries.len()]; for (position, entry) in entries.iter().enumerate() { @@ -881,7 +945,7 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result { } seen[index_usize] = true; } - for pair in entries.windows(2) { + for (position, pair) in entries.windows(2).enumerate() { let left_index = usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?; let right_index = @@ -889,16 +953,19 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result { let left = entries[left_index].name_bytes(); let right = entries[right_index].name_bytes(); if cmp_ascii_casefold(left, right) == Ordering::Greater { - return Ok(false); + return Err(NresError::SortOrderMismatch { + position: saturating_u32_len(position.saturating_add(1)), + }); } } - Ok(true) + Ok(()) } fn find_preserved_regions( bytes: &[u8], entries: &[NresEntry], directory_offset: u32, + limits: DecodeLimits, ) -> Result, NresError> { let mut ranges: Vec> = entries .iter() @@ -914,18 +981,44 @@ fn find_preserved_regions( let directory_offset = usize::try_from(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?; let mut preserved = Vec::new(); + let mut preserved_bytes = 0_u64; for range in ranges { if cursor < range.start { + preserved_bytes = preserved_bytes + .checked_add( + u64::try_from(range.start - cursor) + .map_err(|_| DecodeError::IntegerOverflow)?, + ) + .ok_or(DecodeError::IntegerOverflow)?; + enforce_limit(preserved_bytes, limits.max_preserved_bytes)?; preserved.push(make_preserved_region(bytes, cursor..range.start)?); } cursor = cursor.max(range.end); } if cursor < directory_offset { + preserved_bytes = preserved_bytes + .checked_add( + u64::try_from(directory_offset - cursor) + .map_err(|_| DecodeError::IntegerOverflow)?, + ) + .ok_or(DecodeError::IntegerOverflow)?; + enforce_limit(preserved_bytes, limits.max_preserved_bytes)?; preserved.push(make_preserved_region(bytes, cursor..directory_offset)?); } Ok(preserved) } +fn enforce_limit(value: u64, limit: u64) -> Result<(), NresError> { + if value > limit { + return Err(DecodeError::LimitExceeded { + count: value, + limit, + } + .into()); + } + Ok(()) +} + fn make_preserved_region(bytes: &[u8], range: Range) -> Result { let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0); Ok(PreservedRegion { @@ -1176,7 +1269,10 @@ mod tests { assert!(matches!( decode(arc(bytes), ReadProfile::Strict), - Err(NresError::DirectoryOutOfBounds { .. }) + Err(NresError::Binary(DecodeError::LimitExceeded { + count, + limit + })) if count == i32::MAX as u64 && limit == DecodeLimits::default().max_entries as u64 )); } @@ -1469,7 +1565,7 @@ mod tests { } #[test] - fn unsorted_lookup_table_falls_back_to_linear_lookup() { + fn strict_rejects_unsorted_lookup_table() { let mut bytes = build_archive(&[ SyntheticEntry { type_id: 1, @@ -1497,9 +1593,68 @@ mod tests { bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64] .copy_from_slice(&1_u32.to_le_bytes()); - let doc = decode(arc(bytes), ReadProfile::Strict).expect("strict nres"); - assert!(!doc.lookup_order_valid()); - assert_eq!(doc.find("A"), Some(EntryId(1))); + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::SortOrderMismatch { position: 1 }) + )); + } + + #[test] + fn decode_rejects_entry_count_above_limit() { + let bytes = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "a", + payload: b"a", + }, + SyntheticEntry { + type_id: 2, + attr1: 0, + attr2: 0, + attr3: 0, + name: "b", + payload: b"b", + }, + ]); + + assert!(matches!( + decode_with_limits( + arc(bytes), + ReadProfile::Strict, + DecodeLimits { + max_entries: 1, + ..DecodeLimits::default() + } + ), + Err(NresError::Binary(DecodeError::LimitExceeded { count: 2, limit: 1 })) + )); + } + + #[test] + fn decode_rejects_preserved_bytes_above_limit() { + let bytes = build_archive_with_nonzero_prefix_gap(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "payload", + payload: b"data", + }]); + + assert!(matches!( + decode_with_limits( + arc(bytes), + ReadProfile::Strict, + DecodeLimits { + max_preserved_bytes: 4, + ..DecodeLimits::default() + } + ), + Err(NresError::Binary(DecodeError::LimitExceeded { .. })) + )); } #[test] diff --git a/crates/fparkan-rsli/Cargo.toml b/crates/fparkan-rsli/Cargo.toml index 481788d..707144a 100644 --- a/crates/fparkan-rsli/Cargo.toml +++ b/crates/fparkan-rsli/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true repository.workspace = true [dependencies] +fparkan-binary = { path = "../fparkan-binary" } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } [lints] diff --git a/crates/fparkan-rsli/src/lib.rs b/crates/fparkan-rsli/src/lib.rs index 29edac7..7ca170f 100644 --- a/crates/fparkan-rsli/src/lib.rs +++ b/crates/fparkan-rsli/src/lib.rs @@ -20,6 +20,7 @@ )] //! Stage-1 `RsLi` archive contract. +use fparkan_binary::DecodeError; use std::fmt; use std::io::Read; use std::sync::Arc; @@ -78,6 +79,33 @@ pub enum WriteProfile { Lossless, } +/// Decode and payload loading limits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DecodeLimits { + /// Maximum accepted source archive bytes. + pub max_input_bytes: u64, + /// Maximum accepted entry count. + pub max_entries: u32, + /// Maximum accepted packed entry bytes. + pub max_packed_entry_bytes: u64, + /// Maximum accepted decoded entry bytes. + pub max_decoded_entry_bytes: u64, + /// Maximum accepted cumulative decoded bytes for a single load operation. + pub max_total_decoded_bytes: u64, +} + +impl Default for DecodeLimits { + fn default() -> Self { + Self { + max_input_bytes: 256 * 1024 * 1024, + max_entries: 1_000_000, + max_packed_entry_bytes: 64 * 1024 * 1024, + max_decoded_entry_bytes: 128 * 1024 * 1024, + max_total_decoded_bytes: 128 * 1024 * 1024, + } + } +} + /// Error returned when mutable editing is attempted. #[derive(Debug)] pub enum RsliMutationError { @@ -105,6 +133,11 @@ pub enum RsliMutationError { /// Format maximum (`u32::MAX`). max: usize, }, + /// Method cannot be represented by the on-disk flags field. + UnsupportedMethod { + /// Requested method. + method: RsliMethod, + }, } impl std::fmt::Display for RsliMutationError { @@ -120,6 +153,9 @@ impl std::fmt::Display for RsliMutationError { Self::PackedPayloadTooLarge { size, max } => { write!(f, "packed payload is too large: {size} > {max}") } + Self::UnsupportedMethod { method } => { + write!(f, "unsupported authoring method: {method:?}") + } } } } @@ -374,6 +410,8 @@ pub enum RsliError { }, /// Integer conversion or arithmetic overflow. IntegerOverflow, + /// Shared bounded decode failure. + Binary(DecodeError), } impl fmt::Display for RsliError { @@ -432,11 +470,25 @@ impl fmt::Display for RsliError { write!(f, "output size mismatch: expected={expected}, got={got}") } Self::IntegerOverflow => write!(f, "integer overflow"), + Self::Binary(source) => write!(f, "{source}"), } } } -impl std::error::Error for RsliError {} +impl std::error::Error for RsliError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Binary(source) => Some(source), + _ => None, + } + } +} + +impl From for RsliError { + fn from(value: DecodeError) -> Self { + Self::Binary(value) + } +} /// Decodes an `RsLi` document. /// @@ -446,7 +498,21 @@ impl std::error::Error for RsliError {} /// compatibility quirks, or packed payloads are invalid for the selected /// profile. pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result { - decode_with_profile(bytes, profile.into()) + decode_with_limits(bytes, profile, DecodeLimits::default()) +} + +/// Decodes an `RsLi` document with explicit archive limits. +/// +/// # Errors +/// +/// Returns [`RsliError`] when the input exceeds configured limits or the +/// archive is malformed for the selected profile. +pub fn decode_with_limits( + bytes: Arc<[u8]>, + profile: ReadProfile, + limits: DecodeLimits, +) -> Result { + decode_with_profile_and_limits(bytes, profile.into(), limits) } /// Decodes an `RsLi` document with explicit compatibility switches. @@ -459,17 +525,33 @@ pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result, profile: RsliReadProfile, +) -> Result { + decode_with_profile_and_limits(bytes, profile, DecodeLimits::default()) +} + +/// Decodes an `RsLi` document with explicit profile and archive limits. +/// +/// # Errors +/// +/// Returns [`RsliError`] when the input exceeds configured limits or the +/// archive is malformed for the selected profile. +pub fn decode_with_profile_and_limits( + bytes: Arc<[u8]>, + profile: RsliReadProfile, + limits: DecodeLimits, ) -> Result { let options = match profile { RsliReadProfile::Strict => ParseOptions { allow_ao_trailer: false, allow_deflate_eof_plus_one: false, allow_invalid_presorted_fallback: false, + limits, }, RsliReadProfile::Compatible(profile) => ParseOptions { allow_ao_trailer: profile.allow_ao_trailer, allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one, allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback, + limits, }, }; let ParsedRsli { @@ -545,6 +627,16 @@ impl RsliDocument { /// Returns [`RsliError`] when `id` is invalid or the packed payload cannot /// be decoded to the declared size. pub fn load(&self, id: EntryId) -> Result, RsliError> { + self.load_with_limits(id, DecodeLimits::default()) + } + + /// Loads and unpacks an entry with explicit decode limits. + /// + /// # Errors + /// + /// Returns [`RsliError`] when the packed payload exceeds configured + /// limits, `id` is invalid, or the payload cannot be decoded. + pub fn load_with_limits(&self, id: EntryId, limits: DecodeLimits) -> Result, RsliError> { let record = self.record_by_id(id)?; let packed = self.packed_slice(id, record)?; decode_payload( @@ -552,6 +644,7 @@ impl RsliDocument { record.meta.method, record.key16, record.meta.unpacked_size, + limits, ) } @@ -651,6 +744,7 @@ impl RsliEditor { /// Returns [`RsliMutationError`] when the entry id is unknown. pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> { let entry = self.entry_mut(id)?; + entry.meta.flags = flags_with_method(entry.meta.flags, method)?; entry.meta.method = method; self.dirty = true; Ok(()) @@ -837,6 +931,7 @@ struct ParseOptions { allow_ao_trailer: bool, allow_deflate_eof_plus_one: bool, allow_invalid_presorted_fallback: bool, + limits: DecodeLimits, } #[derive(Clone, Debug)] @@ -857,6 +952,10 @@ struct EntryRecord { #[allow(clippy::too_many_lines)] fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result { + enforce_limit( + u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?, + options.limits.max_input_bytes, + )?; if bytes.len() < 32 { return Err(RsliError::EntryTableOutOfBounds { table_offset: 32, @@ -892,6 +991,10 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? { return Err(RsliError::TooManyEntries { got: count }); } + enforce_limit( + u64::try_from(count).map_err(|_| RsliError::IntegerOverflow)?, + u64::from(options.limits.max_entries), + )?; let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); @@ -935,6 +1038,14 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result Result Result<(), RsliError> { Ok(()) } +fn validate_lookup_order(records: &[EntryRecord]) -> Result<(), RsliError> { + for pair in records.windows(2) { + let left_original = usize::try_from(i32::from(pair[0].meta.sort_to_original)) + .map_err(|_| RsliError::IntegerOverflow)?; + let right_original = usize::try_from(i32::from(pair[1].meta.sort_to_original)) + .map_err(|_| RsliError::IntegerOverflow)?; + let left = records + .get(left_original) + .ok_or(RsliError::CorruptEntryTable("sort_to_original is not a permutation"))?; + let right = records + .get(right_original) + .ok_or(RsliError::CorruptEntryTable("sort_to_original is not a permutation"))?; + if cmp_c_string(c_name_bytes(&left.meta.name_raw), c_name_bytes(&right.meta.name_raw)) + == std::cmp::Ordering::Greater + { + return Err(RsliError::CorruptEntryTable( + "presorted lookup names are not sorted", + )); + } + } + Ok(()) +} + fn parse_method(raw: u32) -> RsliMethod { match raw { 0x000 => RsliMethod::Stored, @@ -1147,32 +1284,39 @@ fn decode_payload( method: RsliMethod, key16: u16, unpacked_size: u32, + limits: DecodeLimits, ) -> Result, RsliError> { + enforce_limit( + u64::try_from(packed.len()).map_err(|_| RsliError::IntegerOverflow)?, + limits.max_packed_entry_bytes, + )?; + enforce_limit(u64::from(unpacked_size), limits.max_decoded_entry_bytes)?; + enforce_limit(u64::from(unpacked_size), limits.max_total_decoded_bytes)?; let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?; let out = match method { RsliMethod::Stored => { - if packed.len() < expected { + if packed.len() != expected { return Err(RsliError::OutputSizeMismatch { expected: unpacked_size, got: u32::try_from(packed.len()).unwrap_or(u32::MAX), }); } - packed[..expected].to_vec() + packed.to_vec() } RsliMethod::XorOnly => { - if packed.len() < expected { + if packed.len() != expected { return Err(RsliError::OutputSizeMismatch { expected: unpacked_size, got: u32::try_from(packed.len()).unwrap_or(u32::MAX), }); } - xor_stream(&packed[..expected], key16) + xor_stream(packed, key16) } RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?, RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?, RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?, RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?, - RsliMethod::RawDeflate => decode_deflate(packed)?, + RsliMethod::RawDeflate => decode_deflate(packed, expected)?, RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }), }; if out.len() != expected { @@ -1276,15 +1420,61 @@ fn read_packed_byte(data: &[u8], pos: usize, state: &mut Option) -> Op }) } -fn decode_deflate(packed: &[u8]) -> Result, RsliError> { - let mut out = Vec::new(); +fn decode_deflate(packed: &[u8], expected_size: usize) -> Result, RsliError> { + let mut out = Vec::with_capacity(expected_size); + let mut chunk = [0u8; 4096]; let mut decoder = flate2::read::DeflateDecoder::new(packed); - decoder - .read_to_end(&mut out) - .map_err(|_| RsliError::DecompressionFailed("deflate"))?; + loop { + let read = decoder + .read(&mut chunk) + .map_err(|_| RsliError::DecompressionFailed("deflate"))?; + if read == 0 { + break; + } + let next_len = out + .len() + .checked_add(read) + .ok_or(RsliError::IntegerOverflow)?; + if next_len > expected_size { + return Err(RsliError::OutputSizeMismatch { + expected: u32::try_from(expected_size).unwrap_or(u32::MAX), + got: u32::try_from(next_len).unwrap_or(u32::MAX), + }); + } + out.extend_from_slice(&chunk[..read]); + } Ok(out) } +fn method_bits(method: RsliMethod) -> Result { + match method { + RsliMethod::Stored => Ok(0x000), + RsliMethod::XorOnly => Ok(0x020), + RsliMethod::Lzss => Ok(0x040), + RsliMethod::XorLzss => Ok(0x060), + RsliMethod::AdaptiveLzss => Ok(0x080), + RsliMethod::XorAdaptiveLzss => Ok(0x0A0), + RsliMethod::RawDeflate => Ok(0x100), + RsliMethod::Unknown(_) => Err(RsliMutationError::UnsupportedMethod { method }), + } +} + +fn flags_with_method(flags: i32, method: RsliMethod) -> Result { + let method = i32::from(method_bits(method)?); + Ok((flags & !0x1E0) | method) +} + +fn enforce_limit(value: u64, limit: u64) -> Result<(), RsliError> { + if value > limit { + return Err(DecodeError::LimitExceeded { + count: value, + limit, + } + .into()); + } + Ok(()) +} + const LZH_N: usize = 4096; const LZH_F: usize = 60; const LZH_THRESHOLD: usize = 2; @@ -1702,6 +1892,28 @@ mod tests { assert_eq!(doc.find("B"), Some(EntryId(0))); } + #[test] + fn strict_rejects_unsorted_presorted_mapping() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"B", 0, b"bee"), + SyntheticEntry::stored(b"A", 1, b"aye"), + ], + true, + 0x0103, + None, + ); + + assert!(matches!( + decode(arc(bytes.clone()), ReadProfile::Strict), + Err(RsliError::CorruptEntryTable("presorted lookup names are not sorted")) + )); + + let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible fallback"); + assert_eq!(doc.find("A"), Some(EntryId(1))); + assert_eq!(doc.find("B"), Some(EntryId(0))); + } + #[test] fn explicit_profile_controls_invalid_presorted_fallback() { let bytes = synthetic_rsli( @@ -1756,8 +1968,8 @@ mod tests { let packed = xor_stream(&plain, 1); let bytes = synthetic_rsli( &[ - SyntheticEntry::with_payload(b"A", 0x020, 1, &plain, packed), - SyntheticEntry::stored(b"B", 0, b"plain"), + SyntheticEntry::with_payload(b"B", 0x020, 1, &plain, packed), + SyntheticEntry::stored(b"A", 0, b"plain"), ], true, 0x2222, @@ -2141,8 +2353,9 @@ mod tests { let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive"); let mut editor = doc.editor().expect("editor"); editor.set_name(EntryId(1), b"ZETA").expect("edit name"); + let repacked = deflate_bytes(b"repacked-alpha"); editor - .set_packed_payload(EntryId(0), b"repacked-alpha", 14) + .set_packed_payload(EntryId(0), repacked, 14) .expect("edit packed payload"); editor .set_method(EntryId(0), RsliMethod::RawDeflate) @@ -2163,10 +2376,91 @@ mod tests { ); assert_eq!( doc.entries()[original.0 as usize].method, - RsliMethod::Stored + RsliMethod::RawDeflate ); } + #[test] + fn set_method_rejects_unknown_authoring_method() { + let bytes = synthetic_rsli( + &[SyntheticEntry::stored(b"A", 0, b"alpha")], + true, + 0x7780, + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive"); + let mut editor = doc.editor().expect("editor"); + + assert!(matches!( + editor.set_method(EntryId(0), RsliMethod::Unknown(0x1E0)), + Err(RsliMutationError::UnsupportedMethod { .. }) + )); + } + + #[test] + fn decode_rejects_entry_count_above_limit() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"A", 0, b"alpha"), + SyntheticEntry::stored(b"B", 1, b"beta"), + ], + true, + 0x7781, + None, + ); + + assert!(matches!( + decode_with_limits( + arc(bytes), + ReadProfile::Strict, + DecodeLimits { + max_entries: 1, + ..DecodeLimits::default() + } + ), + Err(RsliError::Binary(DecodeError::LimitExceeded { count: 2, limit: 1 })) + )); + } + + #[test] + fn stored_entries_require_exact_packed_size() { + let bytes = synthetic_rsli( + &[SyntheticEntry::with_payload(b"A", 0x000, 0, b"ok", b"ok!".to_vec())], + true, + 0x7782, + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive"); + + assert!(matches!( + doc.load(EntryId(0)), + Err(RsliError::OutputSizeMismatch { expected: 2, got: 3 }) + )); + } + + #[test] + fn load_rejects_unpacked_size_above_limit_before_allocation() { + let bytes = synthetic_rsli( + &[SyntheticEntry::stored(b"A", 0, b"alpha")], + true, + 0x7783, + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive"); + + assert!(matches!( + doc.load_with_limits( + EntryId(0), + DecodeLimits { + max_decoded_entry_bytes: 4, + max_total_decoded_bytes: 4, + ..DecodeLimits::default() + } + ), + Err(RsliError::Binary(DecodeError::LimitExceeded { count: 5, limit: 4 })) + )); + } + #[test] fn editor_rejects_unknown_entry_id_and_invalid_name() { let bytes = synthetic_rsli( @@ -2637,6 +2931,15 @@ mod tests { bytes } + fn deflate_bytes(plain: &[u8]) -> Vec { + use std::io::Write; + + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(plain).expect("deflate write"); + encoder.finish().expect("deflate finish") + } + fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> { let mut a = [0u8; 32]; let mut b = [0u8; 32];