diff options
Diffstat (limited to 'crates/fparkan-rsli/src')
| -rw-r--r-- | crates/fparkan-rsli/src/lib.rs | 335 |
1 files changed, 335 insertions, 0 deletions
diff --git a/crates/fparkan-rsli/src/lib.rs b/crates/fparkan-rsli/src/lib.rs index e9237ff..eb12051 100644 --- a/crates/fparkan-rsli/src/lib.rs +++ b/crates/fparkan-rsli/src/lib.rs @@ -59,6 +59,71 @@ pub enum WriteProfile { Lossless, } +/// Error returned when mutable editing is attempted. +#[derive(Debug)] +pub enum RsliMutationError { + /// Entry id is not present in this editable document. + EntryNotFound { + /// Requested entry id. + id: EntryId, + }, + /// Entry name does not fit into a 12-byte fixed field. + AuthoringNameTooLong { + /// Observed length in bytes. + len: usize, + /// Maximum accepted length for an authoring field. + max: usize, + }, + /// Entry name contains an explicit NUL byte. + AuthoringNameContainsNul { + /// Byte offset within the provided name. + offset: usize, + }, + /// Packed payload size overflows the format `u32` field. + PackedPayloadTooLarge { + /// Requested packed payload size. + size: usize, + /// Format maximum (`u32::MAX`). + max: usize, + }, +} + +impl std::fmt::Display for RsliMutationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EntryNotFound { id } => write!(f, "entry id {id:?} is not present"), + Self::AuthoringNameTooLong { len, max } => { + write!(f, "authoring name is too long: {len} > {max}") + } + Self::AuthoringNameContainsNul { offset } => { + write!(f, "authoring name contains embedded NUL at {offset}") + } + Self::PackedPayloadTooLarge { size, max } => { + write!(f, "packed payload is too large: {size} > {max}") + } + } + } +} + +impl std::error::Error for RsliMutationError {} + +/// Mutable editor for `RsliDocument` that can rebuild lookup tables. +#[derive(Clone, Debug)] +pub struct RsliEditor { + original_image: Arc<[u8]>, + header: RsliHeader, + overlay: u32, + ao_trailer: Option<[u8; 6]>, + entries: Vec<EditableEntry>, + dirty: bool, +} + +#[derive(Clone, Debug)] +struct EditableEntry { + meta: EntryMeta, + packed: Vec<u8>, +} + /// `RsLi` compatibility switches. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct RsliCompatibilityProfile { @@ -493,6 +558,180 @@ impl RsliDocument { WriteProfile::Lossless => self.bytes.to_vec(), } } + + /// Creates a mutable editor from the parsed document. + /// + /// # Errors + /// + /// Returns [`RsliError`] when source payloads cannot be copied from the + /// underlying archive image. + pub fn editor(&self) -> Result<RsliEditor, RsliError> { + let mut entries = Vec::with_capacity(self.records.len()); + for (id, record) in self.records.iter().enumerate() { + let packed = self + .packed_slice(EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?)?, + record, + )? + .to_vec(); + entries.push(EditableEntry { + meta: record.meta.clone(), + packed, + }); + } + + Ok(RsliEditor { + original_image: self.bytes.clone(), + header: self.header.clone(), + overlay: self.ao_trailer.as_ref().map_or(0, |overlay| overlay.overlay), + ao_trailer: self.ao_trailer.as_ref().map(|overlay| overlay.raw), + entries, + dirty: false, + }) + } +} + +impl RsliEditor { + /// Returns editable entries by original directory id. + #[must_use] + pub fn entry_count(&self) -> usize { + self.entries.len() + } + + /// Replaces packed payload bytes for an entry. + /// + /// `unpacked_size` is stored explicitly for compatibility checks and does + /// not imply a packing transform. + pub fn set_packed_payload( + &mut self, + id: EntryId, + packed: impl Into<Vec<u8>>, + unpacked_size: u32, + ) -> Result<(), RsliMutationError> { + let entry = self.entry_mut(id)?; + let packed = packed.into(); + entry.meta.packed_size = u32::try_from(packed.len()).map_err(|_| { + RsliMutationError::PackedPayloadTooLarge { + size: packed.len(), + max: usize::try_from(u32::MAX).expect("u32 max always fits usize"), + } + })?; + entry.packed = packed; + entry.meta.unpacked_size = unpacked_size; + self.dirty = true; + Ok(()) + } + + /// Replaces entry packing method in-place. + pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> { + let entry = self.entry_mut(id)?; + entry.meta.method = method; + self.dirty = true; + Ok(()) + } + + /// Replaces entry name in the fixed 12-byte table field. + pub fn set_name(&mut self, id: EntryId, name: &[u8]) -> Result<(), RsliMutationError> { + let entry = self.entry_mut(id)?; + entry.meta.name_raw = authoring_name_raw(name)?; + entry.meta.name = decode_name(c_name_bytes(&entry.meta.name_raw)); + self.dirty = true; + Ok(()) + } + + /// Encodes the document according to editor state. + /// + /// For untouched documents returns the original image verbatim. On any + /// mutation this method rebuilds the lookup table and rewrites packed entry + /// bytes deterministically. + /// + /// # Errors + /// + /// Returns [`RsliError`] when offsets, sizes or ids exceed in-memory limits. + pub fn encode(&self) -> Result<Vec<u8>, RsliError> { + if !self.dirty { + return Ok(self.original_image.to_vec()); + } + self.encode_rebuild() + } + + fn encode_rebuild(&self) -> Result<Vec<u8>, RsliError> { + let mut output = Vec::with_capacity(self.original_image.len()); + + let entry_count = u16::try_from(self.entries.len()).map_err(|_| RsliError::IntegerOverflow)?; + let table_len = self + .entries + .len() + .checked_mul(32) + .ok_or(RsliError::IntegerOverflow)?; + + let mut header = self.header.raw; + header[4..6].copy_from_slice(&entry_count.to_le_bytes()); + output.extend_from_slice(&header); + + let mut sorted = (0..self.entries.len()).collect::<Vec<_>>(); + sorted.sort_by(|left, right| { + cmp_c_string( + c_name_bytes(&self.entries[*left].meta.name_raw), + c_name_bytes(&self.entries[*right].meta.name_raw), + ) + }); + + let mut lookup_map = vec![0i16; self.entries.len()]; + for (position, original) in sorted.iter().enumerate() { + lookup_map[*original] = i16::try_from(position).map_err(|_| RsliError::IntegerOverflow)?; + } + + let mut cursor = 32usize + .checked_add(table_len) + .ok_or(RsliError::IntegerOverflow)?; + let mut table_plain = Vec::with_capacity(table_len); + for (index, entry) in self.entries.iter().enumerate() { + let mut row = [0u8; 32]; + let name_len = entry.meta.name_raw.len().min(12); + row[0..name_len].copy_from_slice(&entry.meta.name_raw[..name_len]); + + row[16..18].copy_from_slice(&i16::try_from(entry.meta.flags) + .map_err(|_| RsliError::IntegerOverflow)? + .to_le_bytes()); + row[18..20].copy_from_slice(&lookup_map[index].to_le_bytes()); + row[20..24].copy_from_slice(&entry.meta.unpacked_size.to_le_bytes()); + + let packed_len = u32::try_from(entry.packed.len()).map_err(|_| RsliError::IntegerOverflow)?; + let cursor_u32 = u32::try_from(cursor).map_err(|_| RsliError::IntegerOverflow)?; + let offset_raw = if self.overlay == 0 { + cursor_u32 + } else { + cursor_u32 + .checked_sub(self.overlay) + .ok_or(RsliError::IntegerOverflow)? + }; + + row[24..28].copy_from_slice(&offset_raw.to_le_bytes()); + row[28..32].copy_from_slice(&packed_len.to_le_bytes()); + table_plain.extend_from_slice(&row); + + output.extend_from_slice(&entry.packed); + cursor = cursor + .checked_add(entry.packed.len()) + .ok_or(RsliError::IntegerOverflow)?; + } + + let seed = self.header.xor_seed & 0xFFFF; + let encrypted = xor_stream(&table_plain, seed); + output.splice(32..32, encrypted.into_iter()); + + if let Some(overlay) = &self.ao_trailer { + output.extend_from_slice(overlay); + } + + Ok(output) + } + + fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, RsliMutationError> { + self.entries + .get_mut(usize::try_from(id.0).map_err(|_| RsliMutationError::EntryNotFound { id })?) + .ok_or_else(|| RsliMutationError::EntryNotFound { id }) + } } impl RsliDocument { @@ -833,6 +1072,23 @@ fn decode_name(name: &[u8]) -> String { name.iter().map(|byte| char::from(*byte)).collect() } +fn authoring_name_raw(name: &[u8]) -> Result<[u8; 12], RsliMutationError> { + if name.len() > 12 { + return Err(RsliMutationError::AuthoringNameTooLong { + len: name.len(), + max: 12, + }); + } + let mut output = [0u8; 12]; + for (offset, byte) in name.iter().copied().enumerate() { + if byte == 0 { + return Err(RsliMutationError::AuthoringNameContainsNul { offset }); + } + output[offset] = byte; + } + Ok(output) +} + fn c_name_bytes(raw: &[u8; 12]) -> &[u8] { let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len()); &raw[..len] @@ -1815,6 +2071,85 @@ mod tests { } #[test] + fn editor_roundtrip_without_mutations_is_identity() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"A", 0, b"alpha"), + SyntheticEntry::stored(b"B", 1, b"beta"), + ], + true, + 0x7777, + None, + ); + + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("editable archive"); + let editor = doc.editor().expect("editor"); + + assert_eq!(editor.encode().expect("editor encode"), bytes); + } + + #[test] + fn editor_can_mutate_names_and_payloads() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"A", 0, b"alpha"), + SyntheticEntry::stored(b"B", 1, b"beta"), + ], + true, + 0x7778, + None, + ); + + 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"); + editor + .set_packed_payload(EntryId(0), b"repacked-alpha", 13) + .expect("edit packed payload"); + editor + .set_method(EntryId(0), RsliMethod::RawDeflate) + .expect("edit method"); + + let rebuilt = editor.encode().expect("editor encode"); + let doc = decode(arc(rebuilt), ReadProfile::Strict).expect("repacked archive"); + + let renamed = doc.find("ZETA").expect("renamed entry"); + assert_eq!( + doc.load(renamed).expect("renamed payload"), + b"beta" + ); + let original = doc + .find("A") + .or_else(|| doc.find("a")) + .expect("original renamed entry fallback"); + assert_eq!(doc.load(original).expect("updated payload"), b"repacked-alpha"); + assert_eq!(doc.entries()[original.0 as usize].method, RsliMethod::RawDeflate); + } + + #[test] + fn editor_rejects_unknown_entry_id_and_invalid_name() { + let bytes = synthetic_rsli( + &[SyntheticEntry::stored(b"A", 0, b"alpha")], + true, + 0x7779, + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive"); + let mut editor = doc.editor().expect("editor"); + + assert!(matches!( + editor.set_name(EntryId(10), b"BAD"), + Err(RsliMutationError::EntryNotFound { id: EntryId(10) }) + )); + assert!(matches!( + editor.set_name(EntryId(0), b"TOO_LONG_ENTRY_NAME"), + Err(RsliMutationError::AuthoringNameTooLong { .. }) + )); + } + + #[test] fn generated_supported_methods_decode_expected_bytes() { let cases = [ (0x000, b"STO".as_slice(), b"ok".as_slice(), b"ok".to_vec()), |
