mirror of
https://gitea.tendokyu.moe/beerpsi/fsdecrypt.git
synced 2026-10-12 01:18:11 +03:00
test: add unit tests and a local Docker test runner
- Unit tests for calculate_page_iv (incl. 8-byte wrap), calculate_file_iv round-trip, and game-key lookup - Extract exfat_utc_offset_seconds and nt_timestamp_to_system_time as pure helpers and test them (covers the recent exFAT UtcOffset fix) - scripts/test.sh runs cargo test + a smoke test natively, then repeats for the x86_64-musl target in a clux/muslrust container (no hosted CI needed) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9c2821ddab
commit
d92e4b5ba9
@@ -515,3 +515,76 @@ pub fn get_game_keys(game_id: &str) -> Option<GameKeys> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aes::cipher::{block_padding::NoPadding, BlockEncryptMut, KeyIvInit};
|
||||
|
||||
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128Enc>;
|
||||
|
||||
#[test]
|
||||
fn page_iv_offset_zero_equals_file_iv() {
|
||||
// offset 0 XORs every byte with 0, so the page IV is the file IV.
|
||||
let file_iv = [0xAB; 16];
|
||||
let mut page_iv = [0u8; 16];
|
||||
calculate_page_iv(0, &file_iv, &mut page_iv);
|
||||
assert_eq!(page_iv, file_iv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_iv_wraps_every_8_bytes() {
|
||||
// Zero file IV isolates the offset contribution: the little-endian bytes
|
||||
// of the offset fill 0..8 and repeat in 8..16 because of the `i % 8` wrap.
|
||||
let mut page_iv = [0u8; 16];
|
||||
calculate_page_iv(0x0102_0304_0506_0708, &[0u8; 16], &mut page_iv);
|
||||
assert_eq!(page_iv, [8, 7, 6, 5, 4, 3, 2, 1, 8, 7, 6, 5, 4, 3, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_iv_xors_offset_with_file_iv() {
|
||||
let mut page_iv = [0u8; 16];
|
||||
calculate_page_iv(0x0000_0000_0000_00FF, &[0xFF; 16], &mut page_iv);
|
||||
// byte 0 (and its wrap at 8): 0xFF ^ 0xFF = 0; the rest: 0xFF ^ 0 = 0xFF.
|
||||
let mut expected = [0xFFu8; 16];
|
||||
expected[0] = 0x00;
|
||||
expected[8] = 0x00;
|
||||
assert_eq!(page_iv, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_iv_round_trips_against_known_header() {
|
||||
// Mirror the real format: the first page block decrypts under
|
||||
// (key, file_iv) to the FS header. So building the ciphertext as
|
||||
// CBC_Enc(key, file_iv, header) must let calculate_file_iv recover file_iv.
|
||||
let key = [0x11u8; 16];
|
||||
let file_iv = [0x22u8; 16];
|
||||
let mut block = NTFS_HEADER;
|
||||
Aes128CbcEnc::new_from_slices(&key, &file_iv)
|
||||
.unwrap()
|
||||
.encrypt_padded_mut::<NoPadding>(&mut block, 16)
|
||||
.unwrap();
|
||||
|
||||
let recovered = calculate_file_iv(key, NTFS_HEADER, &block).unwrap();
|
||||
assert_eq!(recovered, file_iv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_keys_known_lookup() {
|
||||
let keys = get_game_keys("SBZS").expect("SBZS should be in the key table");
|
||||
assert_eq!(keys.key, hex!("2ecbcff65ce0abecc10547f8ac8351d8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_keys_unknown_returns_none() {
|
||||
// No "ZZZZ.bin" exists in the crate root, so the fallback yields None.
|
||||
assert!(get_game_keys("ZZZZ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_header_magics_start_with_jump_opcode() {
|
||||
// Both NTFS and exFAT boot sectors begin with the 0xEB short-jump byte.
|
||||
assert_eq!(NTFS_HEADER[0], 0xEB);
|
||||
assert_eq!(EXFAT_HEADER[0], 0xEB);
|
||||
}
|
||||
}
|
||||
|
||||
+78
-15
@@ -25,22 +25,28 @@ mod crypto;
|
||||
mod stream;
|
||||
mod vhd;
|
||||
|
||||
/// Decode an exFAT `UtcOffset` byte into seconds east of UTC.
|
||||
///
|
||||
/// The byte packs an `OffsetValid` flag (bit 7) with a 7-bit two's-complement
|
||||
/// `OffsetFromUtc` in 15-minute units. When `OffsetValid` is 0 the timestamp has
|
||||
/// no timezone info and the offset bits must be ignored (treated as UTC).
|
||||
fn exfat_utc_offset_seconds(raw: u8) -> i32 {
|
||||
if raw & 0x80 == 0 {
|
||||
0
|
||||
} else {
|
||||
// Sign-extend the 7-bit value, then convert quarter-hours to seconds.
|
||||
let offset_quarters = (((raw & 0x7F) << 1) as i8) >> 1;
|
||||
offset_quarters as i32 * 15 * 60
|
||||
}
|
||||
}
|
||||
|
||||
fn exfat_timestamp_to_system_time(
|
||||
timestamp: &exfat_fs::timestamp::Timestamp,
|
||||
) -> Result<SystemTime> {
|
||||
let exfat_date = timestamp.date();
|
||||
let exfat_time = timestamp.time();
|
||||
|
||||
// The exFAT UtcOffset byte packs an OffsetValid flag (bit 7) with a 7-bit
|
||||
// two's-complement OffsetFromUtc in 15-minute units. When OffsetValid is 0
|
||||
// the timestamp has no timezone info and the offset bits must be ignored.
|
||||
let raw = timestamp.utc_offset() as u8;
|
||||
let offset_seconds = if raw & 0x80 == 0 {
|
||||
0
|
||||
} else {
|
||||
let offset_quarters = (((raw & 0x7F) << 1) as i8) >> 1;
|
||||
offset_quarters as i32 * 15 * 60
|
||||
};
|
||||
let offset_seconds = exfat_utc_offset_seconds(timestamp.utc_offset() as u8);
|
||||
let fixed_offset = FixedOffset::east_opt(offset_seconds).unwrap_or_else(|| FixedOffset::east_opt(0).unwrap());
|
||||
|
||||
let chrono_date_time = match fixed_offset.with_ymd_and_hms(
|
||||
@@ -149,14 +155,23 @@ fn extract_exfat_elements(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ntfs_time_to_system_time(ntfs_time: NtfsTime) -> SystemTime {
|
||||
/// Number of 100ns intervals between the Windows epoch (1601-01-01) and the
|
||||
/// Unix epoch (1970-01-01).
|
||||
const NT_INTERVALS_TO_UNIX_EPOCH: u64 = 116_444_736_000_000_000;
|
||||
|
||||
/// Convert a raw NTFS timestamp (100ns intervals since 1601-01-01) to a
|
||||
/// `SystemTime`.
|
||||
fn nt_timestamp_to_system_time(intervals_since_windows_epoch: u64) -> SystemTime {
|
||||
// An NTFS "interval" is 100 nanoseconds.
|
||||
// The Windows epoch is 1601-01-01, while the Unix epoch is 1970-01-01.
|
||||
let intervals_since_windows_epoch = ntfs_time.nt_timestamp();
|
||||
let intervals_since_unix_epoch = intervals_since_windows_epoch - 116_444_736_000_000_000;
|
||||
let intervals_since_unix_epoch =
|
||||
intervals_since_windows_epoch - NT_INTERVALS_TO_UNIX_EPOCH;
|
||||
let nanos_since_unix_epoch = intervals_since_unix_epoch * 100;
|
||||
|
||||
return SystemTime::UNIX_EPOCH + Duration::from_nanos(nanos_since_unix_epoch);
|
||||
SystemTime::UNIX_EPOCH + Duration::from_nanos(nanos_since_unix_epoch)
|
||||
}
|
||||
|
||||
fn ntfs_time_to_system_time(ntfs_time: NtfsTime) -> SystemTime {
|
||||
nt_timestamp_to_system_time(ntfs_time.nt_timestamp())
|
||||
}
|
||||
|
||||
fn extract_internal_vhd(image_path: &Path, sequence_number: u8) -> Result<PathBuf> {
|
||||
@@ -514,3 +529,51 @@ fn process_chain(base: &ExtractedVhd, deltas: &[&ExtractedVhd]) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{exfat_utc_offset_seconds, nt_timestamp_to_system_time, NT_INTERVALS_TO_UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[test]
|
||||
fn exfat_offset_invalid_flag_is_utc() {
|
||||
// OffsetValid (bit 7) clear -> the offset bits are ignored (treat as UTC).
|
||||
assert_eq!(exfat_utc_offset_seconds(0x00), 0);
|
||||
assert_eq!(exfat_utc_offset_seconds(0x7F), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exfat_offset_zero_when_valid() {
|
||||
assert_eq!(exfat_utc_offset_seconds(0x80), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exfat_offset_positive_japan() {
|
||||
// UTC+9 = +36 quarter-hours = 0x80 | 0x24 = 0xA4.
|
||||
assert_eq!(exfat_utc_offset_seconds(0xA4), 9 * 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exfat_offset_negative() {
|
||||
// UTC-8 = -32 quarter-hours; -32 in 7-bit two's complement is 0x60,
|
||||
// plus the valid flag -> 0xE0.
|
||||
assert_eq!(exfat_utc_offset_seconds(0xE0), -8 * 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntfs_epoch_maps_to_unix_epoch() {
|
||||
assert_eq!(
|
||||
nt_timestamp_to_system_time(NT_INTERVALS_TO_UNIX_EPOCH),
|
||||
SystemTime::UNIX_EPOCH
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntfs_one_second_after_epoch() {
|
||||
// 1 second == 10_000_000 intervals of 100ns.
|
||||
assert_eq!(
|
||||
nt_timestamp_to_system_time(NT_INTERVALS_TO_UNIX_EPOCH + 10_000_000),
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user