From cb38e885dacb9ba617b50727e7d812e38b3f0e5d Mon Sep 17 00:00:00 2001 From: Evan Sarkar Date: Mon, 29 Jun 2026 01:05:28 +0530 Subject: [PATCH 1/2] feat(core): add DOS date/time decoders for Fat12Entry fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pure helpers that decode the raw DOS-packed 16-bit date/time integers carried on Fat12Entry (createdDate, modifiedDate, modifiedTime) into java.time types: - decodeFatDate(packed): LocalDate? — null for the 0 sentinel and any bit pattern that is not a valid calendar date - decodeFatTime(packed): LocalTime — 2-second resolution - Fat12Entry.createdDateOrNull() / modifiedDateOrNull() / modifiedDateTimeOrNull() KDoc cites the FAT bit layout. The raw Int fields on Fat12Entry are unchanged (additive only). Adds FatDateTimeTest with hand-computed fixtures (2026-06-18, the engine DEFAULT_DOS_DATE/TIME, year-1980 offset 0), a full round-trip over the representable time space, and edge cases (zero date -> null, invalid month -> null, even-seconds, high-bit masking). Fixes #2 --- .../com/ams/fat12ex/core/FatDateTime.kt | 75 ++++++++++ .../com/ams/fat12ex/core/FatDateTimeTest.kt | 140 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 core/src/main/kotlin/com/ams/fat12ex/core/FatDateTime.kt create mode 100644 core/src/test/kotlin/com/ams/fat12ex/core/FatDateTimeTest.kt diff --git a/core/src/main/kotlin/com/ams/fat12ex/core/FatDateTime.kt b/core/src/main/kotlin/com/ams/fat12ex/core/FatDateTime.kt new file mode 100644 index 0000000..062b161 --- /dev/null +++ b/core/src/main/kotlin/com/ams/fat12ex/core/FatDateTime.kt @@ -0,0 +1,75 @@ +package com.ams.fat12ex.core + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +/** + * Pure decoders for the DOS-packed 16-bit date/time fields carried raw on + * [Fat12Entry] ([Fat12Entry.createdDate], [Fat12Entry.modifiedDate], + * [Fat12Entry.modifiedTime]). + * + * **Date** (16-bit — [Fat12Entry.createdDate] / [Fat12Entry.modifiedDate]): + * - bits 0–4: day of month (1–31) + * - bits 5–8: month (1–12) + * - bits 9–15: year offset from 1980 + * + * **Time** (16-bit — [Fat12Entry.modifiedTime]): + * - bits 0–4: seconds / 2 (0–29 → 0–58s, 2-second resolution) + * - bits 5–10: minutes (0–59) + * - bits 11–15: hours (0–23) + * + * These are additive helpers; the raw `Int` fields on [Fat12Entry] are unchanged. + */ + +/** + * Decode a FAT-packed 16-bit date field into a [LocalDate]. + * + * Returns `null` for the zero sentinel (`0x0000` = the invalid DOS date + * 1980-00-00 strict readers reject) and for any bit pattern that does not form a + * valid calendar date (e.g. month 0 or day 30 of February in a corrupt entry). + * Only the low 16 bits of [packed] are considered. + */ +fun decodeFatDate(packed: Int): LocalDate? { + val bits = packed and 0xFFFF + if (bits == 0) return null + val day = bits and 0x1F + val month = (bits ushr 5) and 0x0F + val year = 1980 + ((bits ushr 9) and 0x7F) + return runCatching { LocalDate.of(year, month, day) }.getOrNull() +} + +/** + * Decode a FAT-packed 16-bit time field into a [LocalTime] (2-second resolution; + * odd seconds are not representable and are truncated to the lower even value by + * the encoding). + * + * The three components are masked to their bit-widths; the hours (0–23), minutes + * (0–59) and seconds-field (0–29) of a well-formed entry are always in range. A + * corrupt field whose decoded components fall outside those ranges (e.g. a + * seconds-field of 30/31 decoding to 60/62s) throws [java.time.DateTimeException]. + * Only the low 16 bits of [packed] are considered. + */ +fun decodeFatTime(packed: Int): LocalTime { + val bits = packed and 0xFFFF + val seconds = (bits and 0x1F) * 2 + val minutes = (bits ushr 5) and 0x3F + val hours = (bits ushr 11) and 0x1F + return LocalTime.of(hours, minutes, seconds) +} + +/** The decoded creation date, or `null` when the raw [Fat12Entry.createdDate] field is absent/invalid. */ +fun Fat12Entry.createdDateOrNull(): LocalDate? = decodeFatDate(createdDate) + +/** The decoded last-modified date, or `null` when the raw [Fat12Entry.modifiedDate] field is absent/invalid. */ +fun Fat12Entry.modifiedDateOrNull(): LocalDate? = decodeFatDate(modifiedDate) + +/** + * The decoded last-modified date+time, or `null` when the raw modified date is + * absent/invalid. The modified time is combined with the modified date; a corrupt + * time field yields `null` rather than throwing. + */ +fun Fat12Entry.modifiedDateTimeOrNull(): LocalDateTime? { + val date = modifiedDateOrNull() ?: return null + return runCatching { LocalDateTime.of(date, decodeFatTime(modifiedTime)) }.getOrNull() +} diff --git a/core/src/test/kotlin/com/ams/fat12ex/core/FatDateTimeTest.kt b/core/src/test/kotlin/com/ams/fat12ex/core/FatDateTimeTest.kt new file mode 100644 index 0000000..3d1cc1a --- /dev/null +++ b/core/src/test/kotlin/com/ams/fat12ex/core/FatDateTimeTest.kt @@ -0,0 +1,140 @@ +package com.ams.fat12ex.core + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +/** + * [decodeFatDate] / [decodeFatTime] and the [Fat12Entry] decode accessors. + * + * Fixtures are hand-computed from the FAT bit packing: + * date = ((year-1980) shl 9) or (month shl 5) or day + * time = (hours shl 11) or (minutes shl 5) or (seconds / 2) + */ +class FatDateTimeTest { + + // ----- decodeFatDate ------------------------------------------------------ + + @Test + fun decodeFatDate_knownValue() { + // 2026-06-18 = (46 shl 9) or (6 shl 5) or 18 = 23762 (0x5CD2) + assertEquals(LocalDate.of(2026, 6, 18), decodeFatDate(0x5CD2)) + } + + @Test + fun decodeFatDate_engineDefault() { + // DirRegion.DEFAULT_DOS_DATE (0x58C1) = 2024-06-01 + assertEquals(LocalDate.of(2024, 6, 1), decodeFatDate(0x58C1)) + } + + @Test + fun decodeFatDate_epochYear1980_offsetZero() { + // year offset 0, month 1, day 1 = (0 shl 9) or (1 shl 5) or 1 = 0x0021 + assertEquals(LocalDate.of(1980, 1, 1), decodeFatDate(0x0021)) + } + + @Test + fun decodeFatDate_zeroIsNull() { + assertNull(decodeFatDate(0x0000)) + } + + @Test + fun decodeFatDate_invalidComponentsIsNull() { + // month = 0 (day 1) — not a valid calendar date. + assertNull(decodeFatDate(0x0001)) + // month = 13, day = 1, year offset 0 = (13 shl 5) or 1 = 0x01A1 — invalid month. + assertNull(decodeFatDate(0x01A1)) + } + + @Test + fun decodeFatDate_ignoresHighBits() { + // High bits above the 16-bit field must not affect the result. + assertEquals(decodeFatDate(0x5CD2), decodeFatDate(0x5CD2 or 0x7FFF_0000)) + } + + // ----- decodeFatTime ------------------------------------------------------ + + @Test + fun decodeFatTime_knownValue() { + // 13:45:30 = (13 shl 11) or (45 shl 5) or (30 / 2) = 28079 (0x6DAF) + assertEquals(LocalTime.of(13, 45, 30), decodeFatTime(0x6DAF)) + } + + @Test + fun decodeFatTime_engineDefault() { + // DirRegion.DEFAULT_DOS_TIME (0x6000) = 12:00:00 + assertEquals(LocalTime.of(12, 0, 0), decodeFatTime(0x6000)) + } + + @Test + fun decodeFatTime_zeroIsMidnight() { + assertEquals(LocalTime.of(0, 0, 0), decodeFatTime(0x0000)) + } + + @Test + fun decodeFatTime_roundTripsValidFields_secondsAlwaysEven() { + // Over the representable space (2-second resolution) the decode round-trips + // the packed components, and seconds are always even. + for (hours in 0..23) { + for (minutes in intArrayOf(0, 1, 30, 59)) { + for (secHalf in 0..29) { + val packed = (hours shl 11) or (minutes shl 5) or secHalf + val decoded = decodeFatTime(packed) + assertEquals(LocalTime.of(hours, minutes, secHalf * 2), decoded) + assertTrue(decoded.second % 2 == 0) + } + } + } + } + + @Test + fun decodeFatTime_maxSecondsField() { + // seconds field = 29 -> 58s; minutes 0, hours 0 -> 0x001D. + assertEquals(LocalTime.of(0, 0, 58), decodeFatTime(0x001D)) + } + + // ----- Fat12Entry accessors ---------------------------------------------- + + @Test + fun entryAccessors_decodeRawFields() { + val entry = Fat12Entry( + name = "REPORT.TXT", + shortName = "REPORT TXT", + isDirectory = false, + size = 42, + firstCluster = 2, + attributes = 0x20, // ARCHIVE + createdDate = 0x5CD2, // 2026-06-18 + modifiedDate = 0x58C1, // 2024-06-01 + modifiedTime = 0x6DAF, // 13:45:30 + ) + assertEquals(LocalDate.of(2026, 6, 18), entry.createdDateOrNull()) + assertEquals(LocalDate.of(2024, 6, 1), entry.modifiedDateOrNull()) + assertEquals( + LocalDateTime.of(2024, 6, 1, 13, 45, 30), + entry.modifiedDateTimeOrNull(), + ) + } + + @Test + fun entryAccessors_zeroDateFieldsAreNull() { + val entry = Fat12Entry( + name = "EMPTY.TXT", + shortName = "EMPTY TXT", + isDirectory = false, + size = 0, + firstCluster = 0, + attributes = 0, + createdDate = 0, + modifiedDate = 0, + modifiedTime = 0, + ) + assertNull(entry.createdDateOrNull()) + assertNull(entry.modifiedDateOrNull()) + assertNull(entry.modifiedDateTimeOrNull()) + } +} From 6271e7b6882101b9266681d308059a7381e65845 Mon Sep 17 00:00:00 2001 From: Evan Sarkar Date: Mon, 29 Jun 2026 02:39:54 +0530 Subject: [PATCH 2/2] test(core): cover corrupt modifiedTime in date/time accessors Add the case that distinguishes decodeFatTime() from modifiedDateTimeOrNull(): a packed time of 0x001E (seconds-field 30 -> 60s) is rejected by the low-level decoder (DateTimeException) but yields null from the entry-level accessor. Pins down that contract. Addresses CodeRabbit review feedback on this PR. --- .../com/ams/fat12ex/core/FatDateTimeTest.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/core/src/test/kotlin/com/ams/fat12ex/core/FatDateTimeTest.kt b/core/src/test/kotlin/com/ams/fat12ex/core/FatDateTimeTest.kt index 3d1cc1a..b681f6e 100644 --- a/core/src/test/kotlin/com/ams/fat12ex/core/FatDateTimeTest.kt +++ b/core/src/test/kotlin/com/ams/fat12ex/core/FatDateTimeTest.kt @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime @@ -137,4 +138,25 @@ class FatDateTimeTest { assertNull(entry.modifiedDateOrNull()) assertNull(entry.modifiedDateTimeOrNull()) } + + @Test + fun entryAccessors_corruptModifiedTime_decoderThrowsButAccessorIsNull() { + // modifiedTime = 0x001E has a seconds-field of 30 -> 60s, which is not a + // valid LocalTime. The low-level decoder rejects it; the entry accessor + // swallows that to null rather than propagating the exception. + assertThrows { decodeFatTime(0x001E) } + + val entry = Fat12Entry( + name = "BROKEN.TXT", + shortName = "BROKEN TXT", + isDirectory = false, + size = 1, + firstCluster = 2, + attributes = 0x20, + createdDate = 0x5CD2, // valid date + modifiedDate = 0x58C1, // valid date + modifiedTime = 0x001E, // corrupt time + ) + assertNull(entry.modifiedDateTimeOrNull()) + } }