#!/usr/bin/env python # Impacket - Collection of Python classes for working with network protocols. # # Copyright Fortra, LLC and its affiliated companies # # All rights reserved. # # This software is provided under a slightly modified version # of the Apache Software License. See the accompanying LICENSE file # for more information. # # Unit tests for examples/ntfs-read.py — data run parsing, read logic, # attribute list handling, walk() inline entries, etc. # import importlib.util import io import os import struct import unittest # --------------------------------------------------------------------------- # Import ntfs-read.py (hyphenated name requires importlib) # --------------------------------------------------------------------------- _NTFS_READ_PATH = os.path.normpath( os.path.join(os.path.dirname(__file__), '..', '..', 'examples', 'ntfs-read.py') ) _spec = importlib.util.spec_from_file_location('ntfs_read', _NTFS_READ_PATH) ntfs_read = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(ntfs_read) # Shorthand constants from the module DATA = ntfs_read.DATA FILE_NAME = ntfs_read.FILE_NAME FILE_NAME_WIN32 = ntfs_read.FILE_NAME_WIN32 FILE_NAME_DOS = ntfs_read.FILE_NAME_DOS INDEX_ROOT = ntfs_read.INDEX_ROOT BYTES_PER_SECTOR = 512 SECTORS_PER_CLUSTER = 8 CLUSTER_SIZE = BYTES_PER_SECTOR * SECTORS_PER_CLUSTER # 4096 # =========================================================================== # Helpers / Fixtures # =========================================================================== class MockBPB: """Dict-like mock for volume BPB.""" def __getitem__(self, key): return { 'BytesPerSector': BYTES_PER_SECTOR, 'SectorsPerCluster': SECTORS_PER_CLUSTER, }[key] class MockVolume: """Minimal NTFS volume mock backed by BytesIO.""" def __init__(self, size=0): self.volumeFD = io.BytesIO(b'\x00' * size) self.BPB = MockBPB() class MockINode: """Minimal inode mock for attribute construction.""" def __init__(self, volume=None): self.NTFSVolume = volume or MockVolume() self.INodeNumber = 100 def build_nonresident_attr(data_runs_bytes, data_size, initialized_size=None, allocated_size=None): """Build a complete raw non-resident attribute record. Returns bytes: common header + non-resident header + data run bytes. """ if initialized_size is None: initialized_size = data_size if allocated_size is None: allocated_size = max( ((data_size + CLUSTER_SIZE - 1) // CLUSTER_SIZE) * CLUSTER_SIZE, CLUSTER_SIZE, ) COMMON_HDR_SIZE = 16 NR_HDR_SIZE = 48 runs_offset = COMMON_HDR_SIZE + NR_HDR_SIZE # 64 total_length = runs_offset + len(data_runs_bytes) total_length = (total_length + 7) & ~7 # 8-byte align # NTFS_ATTRIBUTE_RECORD (common header) buf = struct.pack(' 1 len byte, 1 offset byte; len=8, offset=10 attr = self._make(b'\x11\x08\x0A\x00', data_size=8 * CLUSTER_SIZE) self.assertEqual(len(attr.DataRuns), 1) dr = attr.DataRuns[0] self.assertEqual(dr['LCN'], 10) self.assertEqual(dr['Clusters'], 8) self.assertEqual(dr['StartVCN'], 0) self.assertEqual(dr['LastVCN'], 7) def test_sparse_run(self): # 0x01 -> 1 len byte, 0 offset bytes -> sparse attr = self._make(b'\x01\x05\x00', data_size=5 * CLUSTER_SIZE) self.assertEqual(len(attr.DataRuns), 1) self.assertEqual(attr.DataRuns[0]['LCN'], -1) self.assertEqual(attr.DataRuns[0]['Clusters'], 5) def test_negative_delta(self): # Run 1: 8 clusters @ LCN 20 (delta=+20=0x14) # Run 2: 4 clusters @ LCN 16 (delta=-4=0xFC signed byte) attr = self._make(b'\x11\x08\x14\x11\x04\xFC\x00', data_size=12 * CLUSTER_SIZE) self.assertEqual(len(attr.DataRuns), 2) self.assertEqual(attr.DataRuns[0]['LCN'], 20) self.assertEqual(attr.DataRuns[1]['LCN'], 16) self.assertEqual(attr.DataRuns[1]['Clusters'], 4) def test_multi_run(self): # 4@LCN10, 4@LCN20(delta+10), 4@LCN30(delta+10) attr = self._make(b'\x11\x04\x0A\x11\x04\x0A\x11\x04\x0A\x00', data_size=12 * CLUSTER_SIZE) self.assertEqual(len(attr.DataRuns), 3) self.assertEqual([dr['LCN'] for dr in attr.DataRuns], [10, 20, 30]) self.assertEqual(attr.DataRuns[0]['StartVCN'], 0) self.assertEqual(attr.DataRuns[0]['LastVCN'], 3) self.assertEqual(attr.DataRuns[1]['StartVCN'], 4) self.assertEqual(attr.DataRuns[1]['LastVCN'], 7) self.assertEqual(attr.DataRuns[2]['StartVCN'], 8) self.assertEqual(attr.DataRuns[2]['LastVCN'], 11) def test_empty(self): attr = self._make(b'\x00') self.assertEqual(attr.DataRuns, []) def test_truncated_stops_gracefully(self): # 0x21 -> 1 len byte + 2 offset bytes needed, but allocated_size # limits AttrValue so the offset bytes are absent attr = self._make(b'\x21\x08', allocated_size=2) self.assertEqual(len(attr.DataRuns), 0) class TestReadVCN(unittest.TestCase): """Verify readVCN() across multiple data runs including sparse.""" def _write_volume(self, lcn_map): """Return a MockVolume with known data written at given LCN offsets.""" max_end = max(lcn * CLUSTER_SIZE + len(d) for lcn, d in lcn_map.items()) vol = MockVolume(max_end) for lcn, data in lcn_map.items(): vol.volumeFD.seek(lcn * CLUSTER_SIZE) vol.volumeFD.write(data) return vol def test_span_two_runs(self): pattern_a = b'\xAA' * (2 * CLUSTER_SIZE) pattern_b = b'\xBB' * (2 * CLUSTER_SIZE) vol = self._write_volume({10: pattern_a, 20: pattern_b}) # 2 clusters @ LCN 10, 2 clusters @ LCN 20 (delta +10) runs = b'\x11\x02\x0A\x11\x02\x0A\x00' raw = build_nonresident_attr(runs, data_size=4 * CLUSTER_SIZE) attr = ntfs_read.AttributeNonResident(MockINode(vol), raw) attr.ClusterSize = CLUSTER_SIZE buf = attr.readVCN(0, 4) self.assertEqual(len(buf), 4 * CLUSTER_SIZE) self.assertEqual(buf[:2 * CLUSTER_SIZE], pattern_a) self.assertEqual(buf[2 * CLUSTER_SIZE:], pattern_b) def test_sparse_in_middle(self): pattern_a = b'\xAA' * (2 * CLUSTER_SIZE) pattern_c = b'\xCC' * (2 * CLUSTER_SIZE) vol = self._write_volume({10: pattern_a, 30: pattern_c}) # 2@LCN10, 2 sparse, 2@LCN30 (delta from prevLcn=10 -> +20=0x14) runs = b'\x11\x02\x0A\x01\x02\x11\x02\x14\x00' raw = build_nonresident_attr(runs, data_size=6 * CLUSTER_SIZE) attr = ntfs_read.AttributeNonResident(MockINode(vol), raw) attr.ClusterSize = CLUSTER_SIZE buf = attr.readVCN(0, 6) self.assertEqual(len(buf), 6 * CLUSTER_SIZE) self.assertEqual(buf[:2 * CLUSTER_SIZE], pattern_a) self.assertEqual(buf[2 * CLUSTER_SIZE:4 * CLUSTER_SIZE], b'\x00' * 2 * CLUSTER_SIZE) self.assertEqual(buf[4 * CLUSTER_SIZE:], pattern_c) class TestRead(unittest.TestCase): """Verify read() clamping, zero-fill, and partial-cluster alignment.""" def _setup(self, data_size, initialized_size=None, lcn=5, num_clusters=None): """Build a volume + non-resident attr backed by a repeating pattern.""" if num_clusters is None: num_clusters = max((data_size + CLUSTER_SIZE - 1) // CLUSTER_SIZE, 1) total_bytes = num_clusters * CLUSTER_SIZE # Deterministic pattern: 0x00..0xFF repeating pattern = bytes(range(256)) * ((total_bytes // 256) + 1) cluster_data = pattern[:total_bytes] vol = MockVolume(lcn * CLUSTER_SIZE + total_bytes) vol.volumeFD.seek(lcn * CLUSTER_SIZE) vol.volumeFD.write(cluster_data) # Single data run: num_clusters @ lcn runs = (b'\x11' + struct.pack('B', num_clusters) + struct.pack('B', lcn) + b'\x00') raw = build_nonresident_attr(runs, data_size, initialized_size=initialized_size, allocated_size=total_bytes) attr = ntfs_read.AttributeNonResident(MockINode(vol), raw) return attr, cluster_data def test_basic_read(self): attr, disk = self._setup(data_size=5000) result = attr.read(0, 5000) self.assertEqual(len(result), 5000) self.assertEqual(result, disk[:5000]) def test_eof_clamp(self): attr, disk = self._setup(data_size=5000) result = attr.read(4000, 2000) # offset+length exceeds data_size self.assertEqual(len(result), 1000) self.assertEqual(result, disk[4000:5000]) def test_past_eof(self): attr, _ = self._setup(data_size=5000) result = attr.read(5000, 100) self.assertEqual(result, b'') def test_initialized_size_zero_fill(self): attr, disk = self._setup(data_size=8000, initialized_size=4000) result = attr.read(0, 8000) self.assertEqual(len(result), 8000) self.assertEqual(result[:4000], disk[:4000]) self.assertEqual(result[4000:], b'\x00' * 4000) def test_partial_cluster_offset(self): attr, disk = self._setup(data_size=CLUSTER_SIZE * 2) result = attr.read(100, 200) self.assertEqual(len(result), 200) self.assertEqual(result, disk[100:300]) class TestAttributeListEntry(unittest.TestCase): """Verify AttributeListEntry field extraction.""" def test_mft_record_split(self): record_num = 0x123456789AB seq = 0x0007 entry_bytes = build_attr_list_entry( DATA, starting_vcn=0, mft_record_num=record_num, attr_id=1, mft_seq=seq, ) entry = ntfs_read.AttributeListEntry(entry_bytes) self.assertEqual(entry.MftRecordNumber, record_num) self.assertEqual(entry.MftSequenceNumber, seq) def test_named_attribute(self): entry_bytes = build_attr_list_entry( DATA, starting_vcn=0, mft_record_num=42, attr_id=3, name='$SDS', ) entry = ntfs_read.AttributeListEntry(entry_bytes) self.assertEqual(entry.AttributeName, '$SDS') def test_unnamed_attribute(self): entry_bytes = build_attr_list_entry( DATA, starting_vcn=0, mft_record_num=42, attr_id=3, ) entry = ntfs_read.AttributeListEntry(entry_bytes) self.assertIsNone(entry.AttributeName) class TestAttributeListParsing(unittest.TestCase): """Verify AttributeList._parseEntries() with multiple entries.""" @staticmethod def _make_resident_attr(raw_data): """Build a fake resident attribute whose getValue() returns raw_data.""" class _Fake: def getValue(self): return raw_data def getDataSize(self): return len(raw_data) return _Fake() def test_three_entries(self): e1 = build_attr_list_entry(0x10, 0, 100, 1) # STANDARD_INFORMATION e2 = build_attr_list_entry(0x30, 0, 100, 2) # FILE_NAME e3 = build_attr_list_entry(0x80, 0, 200, 3) # DATA sentinel = b'\x00' * 26 # zero-length entry terminates raw = e1 + e2 + e3 + sentinel al = ntfs_read.AttributeList(self._make_resident_attr(raw)) self.assertEqual(len(al.Entries), 3) self.assertEqual(al.Entries[0].AttributeType, 0x10) self.assertEqual(al.Entries[1].AttributeType, 0x30) self.assertEqual(al.Entries[2].AttributeType, 0x80) self.assertEqual(al.Entries[2].MftRecordNumber, 200) def test_truncated_stops(self): """Data shorter than minimum entry size (26 bytes) -> empty list.""" al = ntfs_read.AttributeList(self._make_resident_attr(b'\x00' * 20)) self.assertEqual(len(al.Entries), 0) class TestWalk(unittest.TestCase): """Verify INODE.walk() with inline INDEX_ROOT entries.""" def _make_inode(self, index_root=None): inode = ntfs_read.INODE(MockVolume()) inode.INodeNumber = 200 if index_root is not None: inode.Attributes[INDEX_ROOT] = index_root return inode def test_inline_entries(self): valid_key = build_file_name_attr('report.txt', FILE_NAME_WIN32) dos_key = build_file_name_attr('REPORT~1.TXT', FILE_NAME_DOS) system_key = build_file_name_attr('$MFT', FILE_NAME_WIN32) entries = [ MockIndexEntry(valid_key, inode_num=100), # valid MockIndexEntry(dos_key, inode_num=101), # DOS -> skip MockIndexEntry(system_key, inode_num=5), # inode <= 16 -> skip ] ir = MockIndexRoot(type_flag=FILE_NAME, entries=entries) inode = self._make_inode(ir) files = inode.walk() self.assertEqual(len(files), 1) self.assertEqual(files[0]['FileName'].decode('utf-16le'), 'report.txt') self.assertEqual(inode._walk_root_count, 1) def test_no_index_root(self): inode = self._make_inode() self.assertIsNone(inode.walk()) class TestGetDataSize(unittest.TestCase): """getDataSize() must return DataSize, not InitializedSize.""" def test_returns_data_size(self): runs = b'\x11\x04\x05\x00' raw = build_nonresident_attr(runs, data_size=1000, initialized_size=500) attr = ntfs_read.AttributeNonResident(MockINode(), raw) self.assertEqual(attr.getDataSize(), 1000) if __name__ == '__main__': unittest.main()