Windows 11 correctly comparing NTFS record number/s

aisehi

New Member
Joined
Oct 18, 2025
Messages
1
Hello everyone,

I'm working on a low-level NTFS parser in C++ and have run into a question about the most robust way to compare file identifiers. I am currently on windows 11 but the software I'm making is for Windows 10 and 11.

My goal is to check if an attribute in an $ATTRIBUTE_LIST is a self-reference (i.e., it points back to the MFT record it's in).

The MFT_RECORD_ATTRIBUTE (structure for each attribute in the $ATTRIBUTE_LIST) structure from an $ATTRIBUTE_LIST provides a full 64-bit File Reference Number (recordNumber), where the lower 48 bits are the inode and the upper 16 are a sequence number.

However, the MFT_RECORD_HEADER structure on disk stores the record's own index in a 32-bit DWORD field (MFTRecordIndex).

I have seen two approaches in an existing codebase:

C++:
DWORD64 next_inode = pAttrListI->recordNumber & 0xffffffffffff;
if (next_inode != _record->data()->MFTRecordIndex) {}
here recordNumber, a 64 bit int is AND'ed and compared directly with a 32 bit uint/DWORD

Code:
if ((pAttr->recordNumber & 0xffffffffffff) != (header()->MFTRecordIndex & 0xffffffffffff)) {}
here both the numbers are AND'ed and compared.

My question is: What is the safest and most correct way to compare these two different-sized identifiers to check for equality of the inode part?
Is there a canonical "right way" to do this that is guaranteed to be safe from compiler quirks or unexpected behavior, especially considering that NTFS inodes can theoretically exceed 32 bits? Is Approach A sufficient, or is the explicit cast in B (or another method) strongly recommended for correctness and clarity?

Thanks for your insights!
 

Short answer: normalize both operands to the same unsigned width (prefer uint64_t), mask out the 16-bit sequence number, and compare the 48‑bit inode portion. Use an explicit 64‑bit mask literal and explicit casts for clarity and to avoid any integer-promotion surprises.
Why
  • The $ATTRIBUTE_LIST file reference is a 64‑bit value: upper 16 bits = sequence, lower 48 bits = record number (inode).
  • The MFT header's MFTRecordIndex field is a 32‑bit value. If your parser only has that 32‑bit value available you can only compare the low 32 bits — that works in practice for small volumes but can produce false matches if the inode exceeds 32 bits.
  • Always use unsigned types and explicit casts so the code documents your intent and avoids sign‑extension or literal‑type surprises.
Recommended code (canonical and safe)
  • define the mask and use uint64_t everywhere:
Code:
#include <cstdint>

constexpr std::uint64_t FILE_REFERENCE_INODE_MASK = 0xFFFFFFFFFFFFULL; // lower 48 bits

std::uint64_t attr_inode = pAttrListI->recordNumber & FILE_REFERENCE_INODE_MASK;

// If you have the full record number for this MFT record (recommended):
std::uint64_t my_record_index = static_cast<std::uint64_t>(myRecordFullIndex); // full 48-bit index
if (attr_inode == my_record_index) {
    // self-reference
}
If all you have is header()->MFTRecordIndex (DWORD, 32-bit), then either:
  • Compare only the low 32 bits explicitly and document the limitation:
Code:
constexpr std::uint64_t LOW32_MASK = 0xFFFFFFFFULL;
if ((attr_inode & LOW32_MASK) == static_cast<std::uint64_t>(header()->MFTRecordIndex)) {
    // possible self-reference (only compares low 32 bits)
}
or
  • Better: obtain or track the full 48‑bit record number for the record you are parsing (e.g. from the MFT reader that knows the record number) and compare the full 48 bits as in the first snippet.
Notes on the two approaches you showed
  • Approach A (mask attribute, compare directly to 32‑bit header): often works because the 32‑bit DWORD will be promoted to 64‑bit for the comparison, but it is implicit and hides the fact that the header only holds 32 bits.
  • Approach B (mask both sides): masking the header value with a 48‑bit mask is harmless, but if header is a 32‑bit value you are effectively comparing a 48‑bit number to a zero‑extended 32‑bit number — again implicit. B is marginally clearer than A, but both should be written with explicit uint64_t constants and casts for clarity.
Extra safety tips
  • Use the ULL suffix or std::uint64_t typed constants so the mask is definitely 64‑bit (0xFFFFFFFFFFFFULL).
  • Use unsigned integer types (uint64_t / uint32_t) — never signed — for file references.
  • Add an assert or a debug log when you detect attr_inode > 0xFFFFFFFF and you only have a 32‑bit header value so you can catch possible collisions during testing.
  • Document in comments that full NTFS record numbers are 48 bits and header->MFTRecordIndex is only 32 bits, so future readers won’t be surprised.
Summary
  • Canonical: mask to the 48‑bit inode, cast both operands to uint64_t, compare.
  • If you only have the 32‑bit header field, either compare low 32 bits with explicit masking (but be aware of collisions) or, better, obtain and use the full 48‑bit record index for the record.
 

Back
Top