commit dc6fe10448fbabbd07277d00b9d318bd6c159389
parent fb720cfe1676b227f1d97c4d3ef6aa3d6331d40b
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 16 Sep 2026 21:15:50 -0400
ntfs: parse FILE records and attributes
NTFS stamps the last two bytes of each 512-byte block of a record with a
sequence number and keeps the originals in an array, so a torn write is
detectable. apply_fixups verifies and restores in place. The attribute
iterator yields one decoded view per attribute and stops at the first
malformed header, so corruption yields a truncated record, not garbage. Typed
views cover $STANDARD_INFORMATION and $FILE_NAME. attr_disk_size returns
compressed_size for compressed or sparse attributes because allocated_size is
only logical for those.
Diffstat:
| A | ntfs/record.odin | | | 333 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 333 insertions(+), 0 deletions(-)
diff --git a/ntfs/record.odin b/ntfs/record.odin
@@ -0,0 +1,333 @@
+package ntfs
+
+import "core:mem"
+
+RECORD_MAGIC :: u32(0x454C4946) // "FILE"
+RECORD_MAGIC_BAD :: u32(0x44414142) // "BAAD": chkdsk marked the record as damaged
+
+// Fixups protect every 512 bytes regardless of the physical sector size.
+FIXUP_BLOCK :: 512
+
+Record_Flag :: enum u16 {
+ In_Use = 0,
+ Directory = 1,
+ Extension = 2, // record lives in $Extend
+ View_Index = 3,
+}
+Record_Flags :: bit_set[Record_Flag; u16]
+
+Record_Header :: struct #packed {
+ magic: u32,
+ usa_offset: u16, // update sequence array
+ usa_count: u16, // entries in the array, including the sequence number itself
+ lsn: u64, // $LogFile sequence number
+ sequence: u16,
+ link_count: u16, // hard links, i.e. number of $FILE_NAME attributes across all namespaces
+ attrs_offset: u16,
+ flags: Record_Flags,
+ bytes_in_use: u32,
+ bytes_allocated: u32,
+ base_record: File_Ref, // 0 for a base record; otherwise this is an extension record
+ next_attr_id: u16,
+ _pad: u16,
+ record_number: u32, // NTFS 3.1+
+}
+#assert(size_of(Record_Header) == 48)
+
+Attr_Type :: enum u32 {
+ Standard_Information = 0x10,
+ Attribute_List = 0x20,
+ File_Name = 0x30,
+ Object_Id = 0x40,
+ Security_Descriptor = 0x50,
+ Volume_Name = 0x60,
+ Volume_Information = 0x70,
+ Data = 0x80,
+ Index_Root = 0x90,
+ Index_Allocation = 0xA0,
+ Bitmap = 0xB0,
+ Reparse_Point = 0xC0,
+ EA_Information = 0xD0,
+ EA = 0xE0,
+ Logged_Utility_Stream = 0x100,
+ End = 0xFFFFFFFF,
+}
+
+Attr_Flag :: enum u16 {
+ Compressed = 0,
+ Encrypted = 14,
+ Sparse = 15,
+}
+Attr_Flags :: bit_set[Attr_Flag; u16]
+
+Attr_Header :: struct #packed {
+ type: Attr_Type,
+ length: u32, // whole attribute record, header included, 8-byte aligned
+ non_resident: u8,
+ name_length: u8, // in UTF-16 code units
+ name_offset: u16,
+ flags: Attr_Flags,
+ id: u16,
+}
+#assert(size_of(Attr_Header) == 16)
+
+Resident_Header :: struct #packed {
+ hdr: Attr_Header,
+ value_length: u32,
+ value_offset: u16,
+ indexed: u8,
+ _pad: u8,
+}
+#assert(size_of(Resident_Header) == 24)
+
+Nonresident_Header :: struct #packed {
+ hdr: Attr_Header,
+ lowest_vcn: u64,
+ highest_vcn: u64,
+ runlist_offset: u16,
+ compression_unit: u8,
+ _pad: [5]u8,
+ allocated_size: u64, // clusters reserved, in bytes; for compressed/sparse this is logical, see compressed_size
+ data_size: u64, // logical length
+ initialized_size: u64,
+ // compressed_size: u64 follows only when flags contain Compressed or Sparse
+}
+#assert(size_of(Nonresident_Header) == 64)
+
+// Decoded view of one attribute record. Slices point into the record buffer.
+Attribute :: struct {
+ type: Attr_Type,
+ flags: Attr_Flags,
+ id: u16,
+ name: []u16, // empty for the unnamed attribute
+ non_resident: bool,
+ value: []byte, // resident only
+ lowest_vcn: u64, // non-resident only from here on
+ highest_vcn: u64,
+ allocated_size: u64,
+ data_size: u64,
+ initialized_size: u64,
+ compressed_size: u64, // equals allocated_size when the attribute is neither compressed nor sparse
+ runlist: []byte, // raw mapping pairs; decode with decode_runlist
+}
+
+// Bytes this attribute extent actually occupies on disk. Resident values live inside
+// the record and cost nothing extra. Compressed and sparse attributes report their
+// logical allocation in allocated_size, so the real figure is compressed_size.
+attr_disk_size :: proc "contextless" (a: Attribute) -> u64 {
+ if !a.non_resident {
+ return 0
+ }
+ if .Compressed in a.flags || .Sparse in a.flags {
+ return a.compressed_size
+ }
+ return a.allocated_size
+}
+
+record_header :: proc "contextless" (rec: []byte) -> ^Record_Header {
+ return (^Record_Header)(raw_data(rec))
+}
+
+/*
+Undo the update sequence protection in place.
+
+NTFS writes a record in 512-byte blocks and cannot guarantee all blocks land together
+after a power loss. So it stamps the last two bytes of every block with the same
+sequence number and stores the displaced bytes in the update sequence array. Reading
+a record means checking every stamp matches, then restoring the original bytes. A
+mismatch means a torn write; the record is unreliable.
+*/
+apply_fixups :: proc(rec: []byte) -> Error {
+ if len(rec) < size_of(Record_Header) {
+ return .Bad_Record
+ }
+ h := record_header(rec)
+ if h.magic != RECORD_MAGIC {
+ return .Bad_Record
+ }
+ usa_ofs := int(h.usa_offset)
+ count := int(h.usa_count)
+ if count < 2 || usa_ofs + 2 * count > len(rec) || (count - 1) * FIXUP_BLOCK > len(rec) {
+ return .Bad_Record
+ }
+ usn := rd16(rec, usa_ofs)
+ for i in 1 ..< count {
+ end := i * FIXUP_BLOCK
+ if rd16(rec, end - 2) != usn {
+ return .Bad_Record
+ }
+ wr16(rec, end - 2, rd16(rec, usa_ofs + 2 * i))
+ }
+ return .None
+}
+
+Attr_Iterator :: struct {
+ rec: []byte,
+ offset: int,
+}
+
+// Iterate a fixed-up record's attributes. Stops at the end marker or the first
+// malformed header, so a corrupt record yields a prefix rather than garbage.
+attributes :: proc "contextless" (rec: []byte) -> Attr_Iterator {
+ return Attr_Iterator{rec = rec, offset = int(record_header(rec).attrs_offset)}
+}
+
+next_attribute :: proc(it: ^Attr_Iterator) -> (a: Attribute, ok: bool) {
+ rec := it.rec
+ if it.offset < 0 || it.offset + 4 > len(rec) {
+ return
+ }
+ if Attr_Type(rd32(rec, it.offset)) == .End {
+ return
+ }
+ if it.offset + size_of(Attr_Header) > len(rec) {
+ return
+ }
+ hdr := (^Attr_Header)(raw_data(rec[it.offset:]))^
+ length := int(hdr.length)
+ if length < size_of(Attr_Header) || length % 8 != 0 || it.offset + length > len(rec) {
+ return
+ }
+ raw := rec[it.offset:it.offset + length]
+
+ a.type = hdr.type
+ a.flags = hdr.flags
+ a.id = hdr.id
+ if hdr.name_length > 0 {
+ no := int(hdr.name_offset)
+ nl := int(hdr.name_length) * 2
+ if no + nl > length {
+ return
+ }
+ a.name = mem.slice_data_cast([]u16, raw[no:no + nl])
+ }
+
+ if hdr.non_resident == 0 {
+ if length < size_of(Resident_Header) {
+ return
+ }
+ r := (^Resident_Header)(raw_data(raw))^
+ vo := int(r.value_offset)
+ vl := int(r.value_length)
+ if vo + vl > length {
+ return
+ }
+ a.value = raw[vo:vo + vl]
+ } else {
+ if length < size_of(Nonresident_Header) {
+ return
+ }
+ n := (^Nonresident_Header)(raw_data(raw))^
+ a.non_resident = true
+ a.lowest_vcn = n.lowest_vcn
+ a.highest_vcn = n.highest_vcn
+ a.allocated_size = n.allocated_size
+ a.data_size = n.data_size
+ a.initialized_size = n.initialized_size
+ a.compressed_size = n.allocated_size
+ if (.Compressed in hdr.flags || .Sparse in hdr.flags) && length >= size_of(Nonresident_Header) + 8 {
+ a.compressed_size = rd64(raw, size_of(Nonresident_Header))
+ }
+ ro := int(n.runlist_offset)
+ if ro >= size_of(Nonresident_Header) && ro <= length {
+ a.runlist = raw[ro:]
+ }
+ }
+
+ it.offset += length
+ ok = true
+ return
+}
+
+// Windows FILE_ATTRIBUTE_* bits as stored in $STANDARD_INFORMATION and $FILE_NAME.
+File_Attribute :: enum u32 {
+ Read_Only = 0,
+ Hidden = 1,
+ System = 2,
+ Directory = 4,
+ Archive = 5,
+ Device = 6,
+ Normal = 7,
+ Temporary = 8,
+ Sparse = 9,
+ Reparse_Point = 10,
+ Compressed = 11,
+ Offline = 12,
+ Not_Content_Indexed = 13,
+ Encrypted = 14,
+ Integrity_Stream = 15,
+ Virtual = 16,
+ No_Scrub = 17,
+ Recall_On_Open = 18,
+ Pinned = 19,
+ Unpinned = 20,
+ Recall_On_Data_Access = 22,
+ Directory_Index = 28, // NTFS-internal: record has an $I30 index
+ View_Index = 29,
+}
+File_Attributes :: bit_set[File_Attribute; u32]
+
+Standard_Information :: struct #packed {
+ created: u64, // FILETIME
+ modified: u64,
+ mft_modified: u64,
+ accessed: u64,
+ file_attributes: File_Attributes,
+ max_versions: u32,
+ version: u32,
+ class_id: u32,
+ // NTFS 3.x adds owner id, security id, quota, and USN; not needed here
+}
+#assert(size_of(Standard_Information) == 48)
+
+standard_information :: proc(a: Attribute) -> (si: Standard_Information, ok: bool) {
+ if a.type != .Standard_Information || a.non_resident || len(a.value) < size_of(Standard_Information) {
+ return
+ }
+ return (^Standard_Information)(raw_data(a.value))^, true
+}
+
+Name_Space :: enum u8 {
+ Posix = 0, // case-sensitive, any Unicode except NUL and '/'
+ Win32 = 1,
+ Dos = 2, // 8.3 alias generated alongside a Win32 name
+ Win32_And_Dos = 3, // the Win32 name already fits 8.3, so one entry serves both
+}
+
+File_Name_Header :: struct #packed {
+ parent: File_Ref,
+ created: u64,
+ modified: u64,
+ mft_modified: u64,
+ accessed: u64,
+ allocated_size: u64, // only updated when the directory entry is; do not trust for sizing
+ data_size: u64,
+ file_attributes: File_Attributes,
+ reparse_or_ea: u32,
+ name_length: u8, // UTF-16 code units
+ namespace: Name_Space,
+}
+#assert(size_of(File_Name_Header) == 66)
+
+File_Name :: struct {
+ parent: File_Ref,
+ file_attributes: File_Attributes,
+ namespace: Name_Space,
+ name: []u16,
+}
+
+file_name :: proc(a: Attribute) -> (fn: File_Name, ok: bool) {
+ if a.type != .File_Name || a.non_resident || len(a.value) < size_of(File_Name_Header) {
+ return
+ }
+ h := (^File_Name_Header)(raw_data(a.value))^
+ n := int(h.name_length) * 2
+ if size_of(File_Name_Header) + n > len(a.value) {
+ return
+ }
+ fn.parent = h.parent
+ fn.file_attributes = h.file_attributes
+ fn.namespace = h.namespace
+ fn.name = mem.slice_data_cast([]u16, a.value[size_of(File_Name_Header):size_of(File_Name_Header) + n])
+ return fn, true
+}