Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 119 additions & 3 deletions crates/rompatch-core/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ pub struct ApplyOptions {
pub verify_input: Option<HashSpec>,
pub verify_output: Option<HashSpec>,
pub format_override: Option<FormatKind>,
/// Downgrade every hash check (patch-embedded checksums and the
/// `verify_*` specs) from a hard error to a warning collected in
/// [`ApplyOutcome::hash_warnings`]. Meant to be set only after the user
/// explicitly confirms patching a non-matching ROM; structural checks
/// (sizes, offsets, encoding) still fail hard.
#[cfg_attr(feature = "serde", serde(default))]
pub ignore_hash_mismatch: bool,
}

/// Result of a successful [`run`] call.
Expand All @@ -125,6 +132,10 @@ pub struct ApplyOutcome {
/// `Some(family)` if [`ApplyOptions::fix_checksum`] was set and a
/// known cartridge family was detected.
pub fixed_checksum: Option<ChecksumFamily>,
/// Human-readable hash mismatches that were bypassed because
/// [`ApplyOptions::ignore_hash_mismatch`] was set. Empty when every
/// check passed.
pub hash_warnings: Vec<String>,
}

/// Errors returned by [`run`].
Expand Down Expand Up @@ -164,6 +175,19 @@ impl fmt::Display for ApplyError {
}
}

impl ApplyError {
/// True when the failure is a hash/checksum mismatch that
/// [`ApplyOptions::ignore_hash_mismatch`] would have bypassed.
#[must_use]
pub fn is_hash_mismatch(&self) -> bool {
match self {
Self::HashMismatch { .. } => true,
Self::Patch(e) => e.is_hash_mismatch(),
_ => false,
}
}
}

impl std::error::Error for ApplyError {}

impl From<PatchError> for ApplyError {
Expand All @@ -190,16 +214,30 @@ pub fn run(
(None, rom)
};

let mut hash_warnings = Vec::new();

if let Some(spec) = &opts.verify_input {
verify(body, spec, HashCheckKind::Input)?;
if let Err(e) = verify(body, spec, HashCheckKind::Input) {
if opts.ignore_hash_mismatch {
hash_warnings.push(e.to_string());
} else {
return Err(e);
}
}
}

let kind = opts
.format_override
.or_else(|| format::detect(patch))
.ok_or(ApplyError::UnknownFormat)?;

let mut output = kind.apply(patch, body)?;
let mut output = if opts.ignore_hash_mismatch {
let (output, warnings) = kind.apply_lenient(patch, body)?;
hash_warnings.extend(warnings.iter().map(ToString::to_string));
output
} else {
kind.apply(patch, body)?
};

let fixed_checksum = if opts.fix_checksum {
checksum_fix::sniff_and_fix(&mut output)
Expand All @@ -208,7 +246,13 @@ pub fn run(
};

if let Some(spec) = &opts.verify_output {
verify(&output, spec, HashCheckKind::Output)?;
if let Err(e) = verify(&output, spec, HashCheckKind::Output) {
if opts.ignore_hash_mismatch {
hash_warnings.push(e.to_string());
} else {
return Err(e);
}
}
}

let final_output = if let Some(kind) = stripped_header {
Expand All @@ -226,6 +270,7 @@ pub fn run(
output: final_output,
stripped_header,
fixed_checksum,
hash_warnings,
})
}

Expand Down Expand Up @@ -406,6 +451,77 @@ mod tests {
run(&rom, &patch, &opts).unwrap();
}

#[test]
fn ignore_hash_mismatch_downgrades_verify_input_to_warning() {
let rom = vec![0xAB; 1024];
let patch = ips_identity_patch();
let opts = ApplyOptions {
verify_input: Some(HashSpec {
algo: HashAlgo::Crc32,
expected_hex: "deadbeef".into(),
}),
ignore_hash_mismatch: true,
..ApplyOptions::default()
};
let outcome = run(&rom, &patch, &opts).unwrap();
assert_eq!(outcome.output, rom);
assert_eq!(outcome.hash_warnings.len(), 1);
assert!(outcome.hash_warnings[0].contains("input crc32 hash mismatch"));
}

#[test]
fn ignore_hash_mismatch_downgrades_verify_output_to_warning() {
let rom = vec![0xAB; 1024];
let patch = ips_identity_patch();
let opts = ApplyOptions {
verify_output: Some(HashSpec {
algo: HashAlgo::Sha1,
expected_hex: "deadbeef".into(),
}),
ignore_hash_mismatch: true,
..ApplyOptions::default()
};
let outcome = run(&rom, &patch, &opts).unwrap();
assert_eq!(outcome.hash_warnings.len(), 1);
assert!(outcome.hash_warnings[0].contains("output sha1 hash mismatch"));
}

#[test]
fn hash_warnings_empty_when_checks_pass() {
let rom = vec![0xAB; 1024];
let patch = ips_identity_patch();
let opts = ApplyOptions {
ignore_hash_mismatch: true,
..ApplyOptions::default()
};
let outcome = run(&rom, &patch, &opts).unwrap();
assert!(outcome.hash_warnings.is_empty());
}

#[test]
fn apply_error_classifies_hash_mismatches() {
let hash_err = ApplyError::HashMismatch {
kind: HashCheckKind::Input,
algo: HashAlgo::Crc32,
expected: "0".into(),
actual: "1".into(),
};
assert!(hash_err.is_hash_mismatch());
assert!(ApplyError::Patch(PatchError::InputHashMismatch {
expected: 0,
actual: 1
})
.is_hash_mismatch());
assert!(ApplyError::Patch(PatchError::NoMatchingFile).is_hash_mismatch());
assert!(!ApplyError::UnknownFormat.is_hash_mismatch());
assert!(!ApplyError::Patch(PatchError::Truncated).is_hash_mismatch());
assert!(!ApplyError::Patch(PatchError::InputSizeMismatch {
expected: 0,
actual: 1
})
.is_hash_mismatch());
}

#[test]
fn verify_output_mismatch_returns_error() {
let rom = vec![0xAB; 1024];
Expand Down
20 changes: 20 additions & 0 deletions crates/rompatch-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ pub enum PatchError {
UnsupportedFeature(&'static str),
}

impl PatchError {
/// True for errors that report a hash/checksum mismatch (embedded CRC32
/// or MD5 checks, including RUP's MD5-keyed file lookup). These are the
/// checks a caller may deliberately downgrade to warnings via
/// [`crate::apply::ApplyOptions::ignore_hash_mismatch`]; structural
/// errors (sizes, offsets, encoding) are not.
#[must_use]
pub fn is_hash_mismatch(&self) -> bool {
matches!(
self,
Self::InputHashMismatch { .. }
| Self::OutputHashMismatch { .. }
| Self::PatchHashMismatch { .. }
| Self::InputMd5Mismatch { .. }
| Self::OutputMd5Mismatch { .. }
| Self::NoMatchingFile
)
}
}

fn fmt_md5(bytes: &[u8; 16]) -> String {
use core::fmt::Write;
let mut s = String::with_capacity(32);
Expand Down
13 changes: 9 additions & 4 deletions crates/rompatch-core/src/format/aps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

use crate::bin_file::BinReader;
use crate::error::{PatchError, Result};
use crate::format::HashChecks;
use crate::MAX_PATCH_OUTPUT_SIZE;

const MAGIC_GBA: &[u8] = b"APS1";
Expand Down Expand Up @@ -117,6 +118,10 @@ pub fn apply_gba(patch: &[u8], rom: &[u8]) -> Result<Vec<u8>> {
}

pub fn apply_n64(patch: &[u8], rom: &[u8]) -> Result<Vec<u8>> {
apply_n64_with(patch, rom, &mut HashChecks::strict())
}

pub(crate) fn apply_n64_with(patch: &[u8], rom: &[u8], checks: &mut HashChecks) -> Result<Vec<u8>> {
let mut r = BinReader::new(patch);
if r.read_bytes(MAGIC_N64.len())? != MAGIC_N64 {
return Err(PatchError::InvalidMagic);
Expand All @@ -139,16 +144,16 @@ pub fn apply_n64(patch: &[u8], rom: &[u8]) -> Result<Vec<u8>> {
});
}
if &rom[0x3C..0x3F] != cart_id.as_slice() {
return Err(PatchError::InputHashMismatch {
checks.record(PatchError::InputHashMismatch {
expected: u32::from_be_bytes([0, cart_id[0], cart_id[1], cart_id[2]]),
actual: u32::from_be_bytes([0, rom[0x3C], rom[0x3D], rom[0x3E]]),
});
})?;
}
if &rom[0x10..0x18] != crc.as_slice() {
return Err(PatchError::InputHashMismatch {
checks.record(PatchError::InputHashMismatch {
expected: u32::from_be_bytes([crc[0], crc[1], crc[2], crc[3]]),
actual: u32::from_be_bytes([rom[0x10], rom[0x11], rom[0x12], rom[0x13]]),
});
})?;
}
} else if header_type != N64_HEADER_TYPE_SIMPLE {
return Err(PatchError::InvalidEncoding);
Expand Down
19 changes: 12 additions & 7 deletions crates/rompatch-core/src/format/bps.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::bin_file::BinReader;
use crate::error::{PatchError, Result};
use crate::format::HashChecks;
use crate::hash;
use crate::MAX_PATCH_OUTPUT_SIZE;

Expand All @@ -11,8 +12,12 @@ const ACTION_TARGET_READ: u64 = 1;
const ACTION_SOURCE_COPY: u64 = 2;
const ACTION_TARGET_COPY: u64 = 3;

#[allow(clippy::too_many_lines)]
pub fn apply(patch: &[u8], rom: &[u8]) -> Result<Vec<u8>> {
apply_with(patch, rom, &mut HashChecks::strict())
}

#[allow(clippy::too_many_lines)]
pub(crate) fn apply_with(patch: &[u8], rom: &[u8], checks: &mut HashChecks) -> Result<Vec<u8>> {
if patch.len() < MAGIC.len() + FOOTER_LEN {
return Err(PatchError::Truncated);
}
Expand Down Expand Up @@ -193,26 +198,26 @@ pub fn apply(patch: &[u8], rom: &[u8]) -> Result<Vec<u8>> {

let source_crc_actual = hash::crc32(rom);
if source_crc_actual != source_crc_expected {
return Err(PatchError::InputHashMismatch {
checks.record(PatchError::InputHashMismatch {
expected: source_crc_expected,
actual: source_crc_actual,
});
})?;
}

let target_crc_actual = hash::crc32(&output);
if target_crc_actual != target_crc_expected {
return Err(PatchError::OutputHashMismatch {
checks.record(PatchError::OutputHashMismatch {
expected: target_crc_expected,
actual: target_crc_actual,
});
})?;
}

let patch_crc_actual = hash::crc32(&patch[..patch.len() - 4]);
if patch_crc_actual != patch_crc_expected {
return Err(PatchError::PatchHashMismatch {
checks.record(PatchError::PatchHashMismatch {
expected: patch_crc_expected,
actual: patch_crc_actual,
});
})?;
}

Ok(output)
Expand Down
61 changes: 61 additions & 0 deletions crates/rompatch-core/src/format/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,44 @@ pub mod ups;

use crate::error::{PatchError, Result};

/// Policy for patch-embedded hash checks. Strict mode turns a mismatch into
/// an error; lenient mode records it in `warnings` and lets the apply
/// continue. Structural checks (sizes, offsets, encoding) are unaffected.
pub(crate) struct HashChecks {
enforce: bool,
pub(crate) warnings: Vec<PatchError>,
}

impl HashChecks {
pub(crate) fn strict() -> Self {
Self {
enforce: true,
warnings: Vec::new(),
}
}

pub(crate) fn lenient() -> Self {
Self {
enforce: false,
warnings: Vec::new(),
}
}

pub(crate) fn enforcing(&self) -> bool {
self.enforce
}

/// Fail with `err` in strict mode; record it and continue in lenient mode.
pub(crate) fn record(&mut self, err: PatchError) -> Result<()> {
if self.enforce {
Err(err)
} else {
self.warnings.push(err);
Ok(())
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FormatKind {
Expand Down Expand Up @@ -60,6 +98,9 @@ impl FormatKind {
}

/// Apply a patch of this format to `rom`, returning the patched output.
///
/// Hash checks embedded in the patch are enforced; use
/// [`FormatKind::apply_lenient`] to downgrade them to warnings.
pub fn apply(self, patch: &[u8], rom: &[u8]) -> Result<Vec<u8>> {
match self {
Self::Ips => ips::apply(patch, rom),
Expand All @@ -73,6 +114,26 @@ impl FormatKind {
Self::Bdf => bdf::apply(patch, rom),
}
}

/// Like [`FormatKind::apply`], but hash mismatches (patch-embedded CRC32
/// and MD5 checks) are collected and returned alongside the output
/// instead of aborting the apply. Structural checks (sizes, offsets,
/// encoding) still fail hard.
pub fn apply_lenient(self, patch: &[u8], rom: &[u8]) -> Result<(Vec<u8>, Vec<PatchError>)> {
let mut checks = HashChecks::lenient();
let output = match self {
Self::Ips => ips::apply(patch, rom),
Self::Ups => ups::apply_with(patch, rom, &mut checks),
Self::Bps => bps::apply_with(patch, rom, &mut checks),
Self::Pmsr => pmsr::apply_with(patch, rom, &mut checks),
Self::ApsGba => aps::apply_gba(patch, rom),
Self::ApsN64 => aps::apply_n64_with(patch, rom, &mut checks),
Self::Ppf => ppf::apply(patch, rom),
Self::Rup => rup::apply_with(patch, rom, &mut checks),
Self::Bdf => bdf::apply(patch, rom),
}?;
Ok((output, checks.warnings))
}
}

#[must_use]
Expand Down
Loading
Loading