commit 2ae06661a23209d266b50e04c1f7f988d398ea9e
parent 82bbcce6882010f6464b0a9a1094bdb23eec5aeb
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 16 Sep 2026 21:15:49 -0400
ntfs: parse the boot sector
The boot sector gives the three numbers everything else depends on: cluster
size, record size, and the MFT's first cluster. Two fields use a signed
exponent encoding, where 0xF6 means 2^10 bytes, so a record can be smaller
than a cluster and a cluster can reach 2 MiB; the decoder handles both forms.
Validation rejects sizes that would make later volume reads misaligned.
Diffstat:
| A | ntfs/boot.odin | | | 87 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 87 insertions(+), 0 deletions(-)
diff --git a/ntfs/boot.odin b/ntfs/boot.odin
@@ -0,0 +1,87 @@
+package ntfs
+
+import "core:math/bits"
+
+Boot_Sector :: struct {
+ bytes_per_sector: u32,
+ sectors_per_cluster: u32,
+ bytes_per_cluster: u32,
+ total_sectors: u64,
+ mft_lcn: u64, // first cluster of $MFT
+ mft_mirror_lcn: u64,
+ record_size: u32, // bytes per FILE record, normally 1024
+ index_block_size: u32, // bytes per directory index block, normally 4096
+ serial: u64,
+}
+
+/*
+Parse the first sector of an NTFS volume. `b` must hold at least 512 bytes.
+
+Two fields use an encoding worth knowing about. Sectors-per-cluster and
+clusters-per-record are single bytes; values of 0x80 and above are negative exponents,
+so 0xF6 means 2^(256-0xF6) = 2^10 = 1024 bytes. This lets a cluster (up to 2 MiB) or a
+record be smaller or larger than the other without a wider field.
+*/
+parse_boot_sector :: proc(b: []byte) -> (bs: Boot_Sector, err: Error) {
+ if len(b) < 512 {
+ return {}, .Bad_Boot_Sector
+ }
+ if string(b[3:11]) != "NTFS " {
+ return {}, .Not_Ntfs
+ }
+ if b[0x1FE] != 0x55 || b[0x1FF] != 0xAA {
+ return {}, .Bad_Boot_Sector
+ }
+
+ bs.bytes_per_sector = u32(rd16(b, 0x0B))
+ bs.sectors_per_cluster = decode_shift_byte(b[0x0D])
+ bs.total_sectors = rd64(b, 0x28)
+ bs.mft_lcn = rd64(b, 0x30)
+ bs.mft_mirror_lcn = rd64(b, 0x38)
+ bs.serial = rd64(b, 0x48)
+
+ if bs.bytes_per_sector < 256 || bs.bytes_per_sector > 4096 || !is_pow2(bs.bytes_per_sector) {
+ return {}, .Bad_Boot_Sector
+ }
+ if bs.sectors_per_cluster == 0 || !is_pow2(bs.sectors_per_cluster) {
+ return {}, .Bad_Boot_Sector
+ }
+ bs.bytes_per_cluster = bs.bytes_per_sector * bs.sectors_per_cluster
+
+ bs.record_size = decode_size_byte(b[0x40], bs.bytes_per_cluster)
+ bs.index_block_size = decode_size_byte(b[0x44], bs.bytes_per_cluster)
+
+ // A record must be a whole number of sectors so it can be read directly from the
+ // volume, and at least two fixup blocks so the update sequence array has something
+ // to protect.
+ if bs.record_size < 512 || !is_pow2(bs.record_size) || bs.record_size % bs.bytes_per_sector != 0 {
+ return {}, .Bad_Boot_Sector
+ }
+ if bs.mft_lcn == 0 || bs.total_sectors == 0 {
+ return {}, .Bad_Boot_Sector
+ }
+ return bs, .None
+}
+
+// Byte values below 0x80 are literal counts; values at or above are 2^(256-v).
+@(private)
+decode_shift_byte :: proc "contextless" (v: byte) -> u32 {
+ if v < 0x80 {
+ return u32(v)
+ }
+ return u32(1) << uint(256 - int(v))
+}
+
+// Clusters-per-X fields: positive is a cluster count, negative is a byte exponent.
+@(private)
+decode_size_byte :: proc "contextless" (v: byte, bytes_per_cluster: u32) -> u32 {
+ if v < 0x80 {
+ return u32(v) * bytes_per_cluster
+ }
+ return u32(1) << uint(256 - int(v))
+}
+
+@(private)
+is_pow2 :: proc "contextless" (v: u32) -> bool {
+ return v != 0 && bits.count_ones(v) == 1
+}