fix: expose configurable rsli read profiles

This commit is contained in:
2026-06-22 16:58:59 +04:00
parent 813beec7be
commit ccd61c05b0
+162 -8
View File
@@ -14,6 +14,44 @@ pub enum ReadProfile {
Compatible, Compatible,
} }
/// Detailed read profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RsliReadProfile {
/// Reject compatibility quirks.
Strict,
/// Accept selected retail compatibility quirks.
Compatible(RsliCompatibilityProfile),
}
impl From<ReadProfile> for RsliReadProfile {
fn from(value: ReadProfile) -> Self {
match value {
ReadProfile::Strict => Self::Strict,
ReadProfile::Compatible => Self::Compatible(RsliCompatibilityProfile::default()),
}
}
}
impl RsliReadProfile {
/// Strict profile with every compatibility quirk disabled.
#[must_use]
pub const fn strict() -> Self {
Self::Strict
}
/// Retail-compatible profile with the default approved quirk set.
#[must_use]
pub const fn compatible() -> Self {
Self::Compatible(RsliCompatibilityProfile::retail())
}
/// Retail-compatible profile with a caller-provided quirk set.
#[must_use]
pub const fn compatible_with(profile: RsliCompatibilityProfile) -> Self {
Self::Compatible(profile)
}
}
/// Write profile. /// Write profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WriteProfile { pub enum WriteProfile {
@@ -34,12 +72,30 @@ pub struct RsliCompatibilityProfile {
impl Default for RsliCompatibilityProfile { impl Default for RsliCompatibilityProfile {
fn default() -> Self { fn default() -> Self {
Self::retail()
}
}
impl RsliCompatibilityProfile {
/// Retail-compatible profile with every approved quirk enabled.
#[must_use]
pub const fn retail() -> Self {
Self { Self {
allow_ao_trailer: true, allow_ao_trailer: true,
allow_deflate_eof_plus_one: true, allow_deflate_eof_plus_one: true,
allow_invalid_presorted_fallback: true, allow_invalid_presorted_fallback: true,
} }
} }
/// Profile with every compatibility quirk disabled.
#[must_use]
pub const fn none() -> Self {
Self {
allow_ao_trailer: false,
allow_deflate_eof_plus_one: false,
allow_invalid_presorted_fallback: false,
}
}
} }
/// `RsLi` packing method. /// `RsLi` packing method.
@@ -197,6 +253,11 @@ pub enum RsliError {
/// Archive byte length. /// Archive byte length.
file_len: u64, file_len: u64,
}, },
/// Registered `AO` overlay is rejected by the selected profile.
AoTrailerQuirkRejected {
/// Overlay byte offset.
overlay: u32,
},
/// Unsupported packing method. /// Unsupported packing method.
UnsupportedMethod { UnsupportedMethod {
/// Raw method bits. /// Raw method bits.
@@ -266,6 +327,9 @@ impl fmt::Display for RsliError {
"media overlay out of bounds: overlay={overlay}, file={file_len}" "media overlay out of bounds: overlay={overlay}, file={file_len}"
) )
} }
Self::AoTrailerQuirkRejected { overlay } => {
write!(f, "AO trailer quirk rejected: overlay={overlay}")
}
Self::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"), Self::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
Self::PackedSizePastEof { Self::PackedSizePastEof {
id, id,
@@ -298,20 +362,31 @@ impl std::error::Error for RsliError {}
/// compatibility quirks, or packed payloads are invalid for the selected /// compatibility quirks, or packed payloads are invalid for the selected
/// profile. /// profile.
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> { pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
decode_with_profile(bytes, profile.into())
}
/// Decodes an `RsLi` document with explicit compatibility switches.
///
/// # Errors
///
/// Returns [`RsliError`] when the header, table, payload ranges, registered
/// compatibility quirks, or packed payloads are invalid for the selected
/// profile.
pub fn decode_with_profile(
bytes: Arc<[u8]>,
profile: RsliReadProfile,
) -> Result<RsliDocument, RsliError> {
let options = match profile { let options = match profile {
ReadProfile::Strict => ParseOptions { RsliReadProfile::Strict => ParseOptions {
allow_ao_trailer: false, allow_ao_trailer: false,
allow_deflate_eof_plus_one: false, allow_deflate_eof_plus_one: false,
allow_invalid_presorted_fallback: false, allow_invalid_presorted_fallback: false,
}, },
ReadProfile::Compatible => { RsliReadProfile::Compatible(profile) => ParseOptions {
let profile = RsliCompatibilityProfile::default();
ParseOptions {
allow_ao_trailer: profile.allow_ao_trailer, allow_ao_trailer: profile.allow_ao_trailer,
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one, allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback, allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
} },
}
}; };
let ParsedRsli { let ParsedRsli {
header, header,
@@ -691,7 +766,7 @@ fn rebuild_sorted_mapping(records: &mut [EntryRecord]) -> Result<(), RsliError>
} }
fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>), RsliError> { fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>), RsliError> {
if !allow || bytes.len() < 6 || &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" { if bytes.len() < 6 || &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
return Ok((0, None)); return Ok((0, None));
} }
let mut raw = [0u8; 6]; let mut raw = [0u8; 6];
@@ -703,6 +778,9 @@ fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>),
file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?, file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
}); });
} }
if !allow {
return Err(RsliError::AoTrailerQuirkRejected { overlay });
}
Ok((overlay, Some(raw))) Ok((overlay, Some(raw)))
} }
@@ -1330,6 +1408,39 @@ mod tests {
assert_eq!(doc.find("B"), Some(EntryId(0))); assert_eq!(doc.find("B"), Some(EntryId(0)));
} }
#[test]
fn explicit_profile_controls_invalid_presorted_fallback() {
let bytes = synthetic_rsli(
&[
SyntheticEntry::stored(b"B", 0, b"bee"),
SyntheticEntry::stored(b"A", 0, b"aye"),
],
true,
0x0102,
None,
);
let profile = RsliCompatibilityProfile {
allow_invalid_presorted_fallback: false,
..RsliCompatibilityProfile::retail()
};
assert!(matches!(
decode_with_profile(
arc(bytes.clone()),
RsliReadProfile::compatible_with(profile)
),
Err(RsliError::CorruptEntryTable(_))
));
let profile = RsliCompatibilityProfile {
allow_invalid_presorted_fallback: true,
..RsliCompatibilityProfile::none()
};
let doc = decode_with_profile(arc(bytes), RsliReadProfile::compatible_with(profile))
.expect("presorted fallback only");
assert_eq!(doc.find("A"), Some(EntryId(1)));
}
#[test] #[test]
fn stored_method_uses_exact_size() { fn stored_method_uses_exact_size() {
let bytes = synthetic_rsli( let bytes = synthetic_rsli(
@@ -1510,6 +1621,16 @@ mod tests {
decode(arc(approved.clone()), ReadProfile::Strict), decode(arc(approved.clone()), ReadProfile::Strict),
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 }) Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
)); ));
assert!(matches!(
decode_with_profile(
arc(approved.clone()),
RsliReadProfile::compatible_with(RsliCompatibilityProfile {
allow_deflate_eof_plus_one: false,
..RsliCompatibilityProfile::retail()
})
),
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
));
let doc = decode(arc(approved), ReadProfile::Compatible).expect("approved EOF+1 quirk"); let doc = decode(arc(approved), ReadProfile::Compatible).expect("approved EOF+1 quirk");
assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw"); assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw");
@@ -1606,11 +1727,22 @@ mod tests {
Some(4), Some(4),
); );
let doc = decode(arc(bytes), ReadProfile::Compatible).expect("AO overlay"); let doc = decode(arc(bytes.clone()), ReadProfile::Compatible).expect("AO overlay");
let meta = doc.entry(EntryId(0)).expect("AO meta"); let meta = doc.entry(EntryId(0)).expect("AO meta");
assert_eq!(meta.data_offset, 64); assert_eq!(meta.data_offset, 64);
assert_eq!(meta.data_offset_raw, 60); assert_eq!(meta.data_offset_raw, 60);
assert_eq!(doc.load(EntryId(0)).expect("AO payload"), b"media"); assert_eq!(doc.load(EntryId(0)).expect("AO payload"), b"media");
assert!(matches!(
decode_with_profile(
arc(bytes),
RsliReadProfile::compatible_with(RsliCompatibilityProfile {
allow_ao_trailer: false,
..RsliCompatibilityProfile::retail()
})
),
Err(RsliError::AoTrailerQuirkRejected { overlay: 4 })
));
} }
#[test] #[test]
@@ -1625,6 +1757,28 @@ mod tests {
)); ));
} }
#[test]
fn strict_profile_distinguishes_valid_ao_quirk_from_malformed_ao() {
let valid = synthetic_rsli(
&[SyntheticEntry::stored(b"A", 0, b"media")],
true,
0x3333,
Some(4),
);
assert!(matches!(
decode_with_profile(arc(valid), RsliReadProfile::strict()),
Err(RsliError::AoTrailerQuirkRejected { overlay: 4 })
));
let mut malformed = synthetic_rsli(&[], false, 0, None);
malformed.extend_from_slice(b"AO");
malformed.extend_from_slice(&1000u32.to_le_bytes());
assert!(matches!(
decode_with_profile(arc(malformed), RsliReadProfile::strict()),
Err(RsliError::MediaOverlayOutOfBounds { overlay: 1000, .. })
));
}
#[test] #[test]
fn unknown_header_bytes_are_lossless() { fn unknown_header_bytes_are_lossless() {
let mut bytes = synthetic_rsli( let mut bytes = synthetic_rsli(