checkdisk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ # sdist carries everything needed to run the full suite from source;
2
+ # the wheel ships only checkdisk (see pyproject py-modules).
3
+ include format.py
4
+ include replay.py
5
+ include Workabouts.md
6
+ graft tests
7
+ global-exclude __pycache__ *.pyc
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: checkdisk
3
+ Version: 0.1.0
4
+ Summary: Dependency-free NTFS repair tool — the chkdsk /f flow on raw devices and images
5
+ License-Expression: 0BSD
6
+ Project-URL: Homepage, https://github.com/h5rdly/checkdisk
7
+ Keywords: ntfs,chkdsk,filesystem,repair,recovery,forensics,mft
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Topic :: System :: Filesystems
12
+ Classifier: Topic :: System :: Recovery Tools
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # checkdisk
17
+
18
+ A self-contained, pure-Python NTFS repair tool — the parts of `chkdsk /f` that matter for a crashed volume (dangling dirents, torn indexes,
19
+ lost files, torn truncates, `$Bitmap`, `$Secure`, the USN journal).
20
+
21
+ `checkdisk` uses a native read/write engine that parses and rewrites on-disk NTFS structures directly, with multi-sector fixups and plan-then-commit
22
+ atomicity.
23
+
24
+ ## Install
25
+
26
+ ```
27
+ pip install checkdisk
28
+ ```
29
+
30
+ Or skip installing — `checkdisk.py` is self contained
31
+
32
+ ## Quick start
33
+
34
+ `checkdisk` works on the raw (unmounted) device or an image file — never through
35
+ a mount:
36
+
37
+ ```sh
38
+ checkdisk list # find NTFS partitions (mounts nothing)
39
+ sudo umount /mnt/point # get off the volume first
40
+ sudo setfacl -m u:$USER:rw /dev/sdXN # or run the tool with sudo
41
+ checkdisk /f /dev/sdXN # dry run: reports, writes nothing
42
+ checkdisk /f /dev/sdXN --really # repair (each fix re-verified)
43
+ checkdisk /f /dev/sdXN # confirm: expect 0 remaining
44
+ ```
45
+
46
+ `/r` adds the full surface read. Running from the repo instead:
47
+ `python checkdisk.py /f ...` — identical.
@@ -0,0 +1,32 @@
1
+ # checkdisk
2
+
3
+ A self-contained, pure-Python NTFS repair tool — the parts of `chkdsk /f` that matter for a crashed volume (dangling dirents, torn indexes,
4
+ lost files, torn truncates, `$Bitmap`, `$Secure`, the USN journal).
5
+
6
+ `checkdisk` uses a native read/write engine that parses and rewrites on-disk NTFS structures directly, with multi-sector fixups and plan-then-commit
7
+ atomicity.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ pip install checkdisk
13
+ ```
14
+
15
+ Or skip installing — `checkdisk.py` is self contained
16
+
17
+ ## Quick start
18
+
19
+ `checkdisk` works on the raw (unmounted) device or an image file — never through
20
+ a mount:
21
+
22
+ ```sh
23
+ checkdisk list # find NTFS partitions (mounts nothing)
24
+ sudo umount /mnt/point # get off the volume first
25
+ sudo setfacl -m u:$USER:rw /dev/sdXN # or run the tool with sudo
26
+ checkdisk /f /dev/sdXN # dry run: reports, writes nothing
27
+ checkdisk /f /dev/sdXN --really # repair (each fix re-verified)
28
+ checkdisk /f /dev/sdXN # confirm: expect 0 remaining
29
+ ```
30
+
31
+ `/r` adds the full surface read. Running from the repo instead:
32
+ `python checkdisk.py /f ...` — identical.
@@ -0,0 +1,224 @@
1
+ ## The volume at a glance
2
+
3
+ An NTFS volume is bootstrapped from a single sector and then becomes entirely
4
+ self-describing — **everything is a file**, including all metadata.
5
+
6
+ The **boot sector** (`RawVolume.__init__`) carries:
7
+
8
+ | offset | field | note |
9
+ |-------:|-------|------|
10
+ | 3 | `"NTFS"` magic | |
11
+ | 11 | bytes/sector (u16) | almost always 512 |
12
+ | 13 | sectors/cluster (u8) | values > 0x80 encode `2^(256-x)` |
13
+ | 40 | total sectors (u64) | |
14
+ | 48 | LCN of $MFT (u64) | where record 0 lives |
15
+ | 64 | MFT record size (s8) | negative → `2^-x` bytes (typically −10 = 1024); positive → clusters |
16
+
17
+ The first 24 MFT records are reserved for system files. The ones this tool
18
+ touches:
19
+
20
+ | # | file | role |
21
+ |--:|------|------|
22
+ | 0 | `$MFT` | the Master File Table itself — one record per file |
23
+ | 1 | `$MFTMirr` | mirror of the first 4 records (drivers verify it) |
24
+ | 2 | `$LogFile` | metadata journal (see §6) |
25
+ | 3 | `$Volume` | volume flags — including the **dirty bit** |
26
+ | 5 | `.` (root) | root directory |
27
+ | 6 | `$Bitmap` | one bit per cluster: allocated or free (§7) |
28
+ | 9 | `$Secure` | all security descriptors, deduplicated (§5) |
29
+ | 10 | `$UpCase` | 64K-entry uppercasing table — defines case-insensitivity (§4) |
30
+ | 11 | `$Extend` | directory holding `$UsnJrnl`, `$Reparse`, `$ObjId`, `$Quota` |
31
+
32
+ Addresses are **LCN** (logical cluster number, volume-absolute) or **VCN**
33
+ (virtual cluster number, position within one file's data). Runlists map VCN→LCN.
34
+
35
+ ## MFT records and attributes
36
+
37
+ ### The FILE record
38
+
39
+ Each file is a fixed-size **FILE record** (`read_record`, `_load_record`):
40
+
41
+ | offset | field |
42
+ |-------:|-------|
43
+ | 0 | `"FILE"` magic |
44
+ | 4 | update-sequence array offset / count (fixups, below) |
45
+ | 16 | **sequence number** — bumped every time the record is freed |
46
+ | 18 | hard-link count |
47
+ | 20 | first-attribute offset |
48
+ | 22 | flags: bit 0 = in use, bit 1 = directory |
49
+ | 32 | base-record reference (non-zero ⇒ this is an *extension* record) |
50
+
51
+ A **file reference (mref)** is 64 bits: low 48 = record number, high 16 = the
52
+ sequence number *at the time the reference was made* (`MREF_MASK`). This is
53
+ NTFS's stale-pointer defence: when a record is reused, its sequence changes,
54
+ and every old reference to it becomes detectably stale. Half the repairs in
55
+ this tool reduce to "does the sequence still match?" (`_mref_live`).
56
+
57
+ ### Multi-sector fixups — how NTFS detects torn writes
58
+
59
+ Any structure larger than a sector (FILE records, INDX blocks) is protected by
60
+ the **update sequence array**: before writing, the last 2 bytes of every sector
61
+ are stashed in the header's USA and replaced by a counter (USN); the counter is
62
+ bumped each write. On read, all sector tails must equal the USN — if the disk
63
+ died mid-write, some sectors are old and some new, the tails disagree, and the
64
+ record is **torn** (`_apply_fixups` / inverse `_seal_fixups`). Torn = the
65
+ strongest possible corruption signal: the structure's content cannot be
66
+ trusted at all.
67
+
68
+ The seal/unseal pair is also a bug magnet for anything that *writes* these
69
+ structures, and the failure has a recognizable fingerprint. Re-seal a buffer
70
+ that was never unsealed and the stale USN gets captured into the USA as if it
71
+ were data: exactly two garbage bytes at each sector-tail offset (0x1FE, 0x3FE,
72
+ …), typically equal to a recent USN, with every other byte intact. Corruption
73
+ confined to those offsets means a mis-sealed writer, not random damage — worth
74
+ checking before blaming the disk.
75
+
76
+ ### Attributes
77
+
78
+ A record's content is a chain of **attributes** (`_attrs` walks it): each has a
79
+ type, length, optional UTF-16LE name, and is **resident** (value inline in the
80
+ record) or **non-resident** (value on clusters, located by a runlist). The
81
+ chain ends with type `0xFFFFFFFF`. Types used here: `$STANDARD_INFORMATION`
82
+ (0x10, holds the security_id at value offset 52), `$ATTRIBUTE_LIST` (0x20),
83
+ `$FILE_NAME` (0x30), `$OBJECT_ID` (0x40), `$DATA` (0x80), `$INDEX_ROOT` (0x90),
84
+ `$INDEX_ALLOCATION` (0xA0), `$BITMAP` (0xB0), `$REPARSE_POINT` (0xC0).
85
+
86
+ `$FILE_NAME` is the linchpin of repair: it stores the parent directory's mref
87
+ (offset 0), name length (64), namespace (65: POSIX/WIN32/DOS), and the name
88
+ (66). **The same fact exists twice** — in the file's record *and* in the
89
+ parent's index — and that redundancy is what makes directory repair possible.
90
+
91
+ ### Runlists (mapping pairs)
92
+
93
+ Non-resident data is described by a compact varint encoding
94
+ (`_check_mapping_pairs` decodes + validates, `_encode_mapping_pairs` builds):
95
+ each pair is a header byte (low nibble = length-field size, high nibble =
96
+ offset-field size), a run length, and an LCN **delta from the previous run**
97
+ (signed). **Both fields are signed**, the length included: a 128-cluster run
98
+ must encode its length as two bytes (`80 00`) — a lone `80` sign-extends to
99
+ −128 and every NTFS implementation then rejects the whole runlist. Offset
100
+ size 0 = sparse run (a hole). A zero header byte terminates.
101
+ The attribute header redundantly states `lowest_vcn`/`highest_vcn` — the
102
+ decoded runs must cover exactly that range, another self-check this tool
103
+ leans on.
104
+
105
+ ### Extension records and $ATTRIBUTE_LIST
106
+
107
+ When one record can't hold all attributes, they spill into **extension
108
+ records**; a resident-or-not `$ATTRIBUTE_LIST` in the base enumerates where
109
+ every attribute lives (`_record_attrs_uncached` follows it transparently;
110
+ `_spill_index_alloc` performs the spill natively when a directory outgrows its
111
+ base record).
112
+
113
+ ## Indexes — the B+ trees
114
+
115
+ A directory is not a list; it's a **B+ tree keyed by name**. Three attributes
116
+ cooperate (all named `$I30` for directories):
117
+
118
+ - `$INDEX_ROOT` (resident): header (collation rule, index block size) + the
119
+ root node. A *small* index is just this.
120
+ - `$INDEX_ALLOCATION` (non-resident): the **INDX blocks** — 4 KiB fixup-
121
+ protected nodes (`"INDX"` magic, header VCN at offset 16, INDEX_HEADER at 24).
122
+ - `$BITMAP`: one bit per INDX block — allocated blocks whose bit is clear are
123
+ free and may contain stale garbage legally.
124
+
125
+ A node is a run of **INDEX_ENTRY** structures: `{value, entry_len(8),
126
+ key_len(10), flags(12)}` with the key at offset 16. Flags: bit 0 = has a
127
+ subnode (its VCN sits in the entry's **last 8 bytes**), bit 1 = END entry (no
128
+ key; terminates the node; its subnode pointer, if any, covers everything
129
+ greater than the last key). Directory entries store the child's mref as the
130
+ value at offset 0; the key is the **entire `$FILE_NAME` attribute value**.
131
+
132
+ Ordering is `COLLATION_FILE_NAME` (0x01): compare names **uppercased through
133
+ the volume's own `$UpCase` table**, not through any programming language's
134
+ notion of case (`_upcase_seq`, `names_equal`). A prefix sorts first.
135
+
136
+ That table is more load-bearing than it looks: **Windows chkdsk silently
137
+ skips its entire index verification stage on a volume whose `$UpCase` is not
138
+ the exact frozen table it expects** — no warning, exit 0. The frozen table
139
+ predates modern Unicode: characters that gained uppercase mappings later map
140
+ to *themselves* on real volumes, and the iota-subscript vocalics map to their
141
+ titlecase forms.
142
+
143
+
144
+ ## The journals
145
+
146
+ - **`$LogFile`** is a metadata write-ahead log. This tool — like chkdsk —
147
+ **resets it rather than replaying it** (`reset_logfile` fills it with
148
+ `0xFF`): the structural checks reconcile the on-disk state directly, which
149
+ makes replay redundant, and a reset log is unambiguously clean. The reset
150
+ happens as `/f --really`'s **final step, and only when every finding above
151
+ it converged** — an unresolved finding leaves the log and flag alone so the
152
+ next mount still triggers a full check.
153
+ - The **dirty bit** lives in `$Volume`'s `$VOLUME_INFORMATION` (flags bit 0).
154
+ The write engine *sets it on open and clears it on clean close*
155
+ (`RawVolumeRW.__init__`/`close`) so that a crash mid-repair leaves the
156
+ volume flagged for a full check — the same discipline the driver uses. A
157
+ volume that arrives *already* flagged (a crashed session) is itself a
158
+ finding: the flag is not cosmetic — Windows re-checks a dirty volume at
159
+ boot, and Linux's ntfs3 **refuses to mount one at all** — so a repair that
160
+ fixes every structure but leaves the flag set has not finished the job.
161
+ - **`$UsnJrnl`** (`\$Extend\$UsnJrnl`) is the *user-visible* change journal:
162
+ `$Max` (32 bytes: max size, allocation delta, journal id, LowestValidUsn)
163
+ and `$J`, an append-only sparse stream whose **byte offset is the USN**.
164
+ Old content is punched out with sparse holes, so the file's allocated tail
165
+ is the live window. Each V2 record self-states its USN at offset 24 — it
166
+ must equal the record's own position (`_walk_usn_records`).
167
+
168
+ ### What `$LogFile` replay actually does (research)
169
+
170
+ The `$LogFile` is an LFS (Log File Service) circular log of two page kinds: the
171
+ first two 4 KiB pages are **restart pages** (`RSTR`) holding a checkpoint pointer
172
+ plus per-client state; the rest are **record pages** (`RCRD`) carrying the log
173
+ records. A driver's crash recovery is three passes from the last checkpoint:
174
+
175
+ - **restart selection** — take the restart page with the higher `current_lsn`;
176
+ the other is a stale backup (fall back to it if the newest checkpoint's own
177
+ pages never reached the disk).
178
+ - **analysis** — rebuild the transaction / dirty-page / open-attribute tables
179
+ from the checkpoint's table dumps, walking records forward to find `redo_lsn`,
180
+ the oldest LSN any still-dirty page needs.
181
+ - **redo** — from `redo_lsn`, re-apply each committed op whose target page is
182
+ older than the record (`page_lsn < record_lsn`, the LSN gate; a page already
183
+ past the change is skipped).
184
+ - **undo** — roll back ops of transactions that never committed, walking each
185
+ transaction's undo-next chain backward.
186
+
187
+ The load-bearing subtlety, learned building a replayer validated chkdsk-clean on
188
+ a real Windows crash: **NTFS logs multi-sector-protected pages (FILE records,
189
+ INDX blocks) in *logical* form — the update-sequence fixup is NOT applied.** In a
190
+ logged page image the sector tails hold the real data, not the USN; sealing is a
191
+ flush-time concern. So a redo that lays down a fresh INDX block writes a logical
192
+ page, and a *later* redo against it must accept that logical form (its fixup
193
+ check 'fails' because tails ≠ USN — expected, not a torn page) and re-seal only
194
+ on write-back. A replayer that demands a sealed on-disk block at every step
195
+ silently drops those ops.
196
+
197
+ The same rule governs the **read side**, and getting it wrong there is much
198
+ quieter. Target FILE records must be loaded in logical form too (fixups
199
+ applied), because log ops describe the unsealed image and the write path
200
+ re-seals on flush. Modify a still-sealed buffer and the re-seal captures the
201
+ *old* USN into the USA as if it were data: every field that straddles a
202
+ sector-tail offset (0x1FE / 0x3FE of a 1 KiB record) silently gains two garbage
203
+ bytes, and everything else stays perfect. On a real crash volume this surfaced
204
+ as `$Extend\$RmMetadata\$Repair:$Verify` claiming `allocated_size =
205
+ 0x19 << 48 | true size` — the leaked USN sitting in the high bytes — plus
206
+ phantom dangling entries and bitmap drift in other records whose structures
207
+ happened to cross a tail. The bug carried a second lesson: **Windows 10's
208
+ chkdsk passed the damaged volume; Windows Server 2022's flagged it — and this
209
+ tool's own checker had flagged it all along.** "chkdsk-clean" is a floor, not a
210
+ proof, and the floor's height depends on which chkdsk; when a strict checker
211
+ and a lenient oracle disagree, suspect the oracle's leniency before the
212
+ checker's pedantry.
213
+
214
+ Two more traps: the **v2 log relocates the record-page
215
+ header** — its logical file offset is at 0x3C and last-end LSN at 0x20, so the
216
+ 0x18 "first free byte" field some parsers read is v1-shaped and truncates a v2
217
+ walk; and a **dry run applies nothing** (redo/undo only run when you commit), so
218
+ "zero ops applied" can just mean you didn't ask.
219
+
220
+ Why this tool still **resets** rather than replays: the structural checks
221
+ reconcile the on-disk state directly, so replay yields no repair the passes
222
+ don't already achieve, and an all-`0xFF` log is unambiguously clean to the next
223
+ mount. A genuine redo/undo engine (`replay.py`) that *does* produce a
224
+ chkdsk-clean volume is kept as research, not the repair path.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: checkdisk
3
+ Version: 0.1.0
4
+ Summary: Dependency-free NTFS repair tool — the chkdsk /f flow on raw devices and images
5
+ License-Expression: 0BSD
6
+ Project-URL: Homepage, https://github.com/h5rdly/checkdisk
7
+ Keywords: ntfs,chkdsk,filesystem,repair,recovery,forensics,mft
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Topic :: System :: Filesystems
12
+ Classifier: Topic :: System :: Recovery Tools
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # checkdisk
17
+
18
+ A self-contained, pure-Python NTFS repair tool — the parts of `chkdsk /f` that matter for a crashed volume (dangling dirents, torn indexes,
19
+ lost files, torn truncates, `$Bitmap`, `$Secure`, the USN journal).
20
+
21
+ `checkdisk` uses a native read/write engine that parses and rewrites on-disk NTFS structures directly, with multi-sector fixups and plan-then-commit
22
+ atomicity.
23
+
24
+ ## Install
25
+
26
+ ```
27
+ pip install checkdisk
28
+ ```
29
+
30
+ Or skip installing — `checkdisk.py` is self contained
31
+
32
+ ## Quick start
33
+
34
+ `checkdisk` works on the raw (unmounted) device or an image file — never through
35
+ a mount:
36
+
37
+ ```sh
38
+ checkdisk list # find NTFS partitions (mounts nothing)
39
+ sudo umount /mnt/point # get off the volume first
40
+ sudo setfacl -m u:$USER:rw /dev/sdXN # or run the tool with sudo
41
+ checkdisk /f /dev/sdXN # dry run: reports, writes nothing
42
+ checkdisk /f /dev/sdXN --really # repair (each fix re-verified)
43
+ checkdisk /f /dev/sdXN # confirm: expect 0 remaining
44
+ ```
45
+
46
+ `/r` adds the full surface read. Running from the repo instead:
47
+ `python checkdisk.py /f ...` — identical.
@@ -0,0 +1,19 @@
1
+ MANIFEST.in
2
+ Readme.md
3
+ Workabouts.md
4
+ checkdisk.py
5
+ format.py
6
+ pyproject.toml
7
+ replay.py
8
+ checkdisk.egg-info/PKG-INFO
9
+ checkdisk.egg-info/SOURCES.txt
10
+ checkdisk.egg-info/dependency_links.txt
11
+ checkdisk.egg-info/entry_points.txt
12
+ checkdisk.egg-info/top_level.txt
13
+ tests/ci_tests.py
14
+ tests/crash_tests.py
15
+ tests/replay_tests.py
16
+ tests/tests.py
17
+ tests/win_github_ci.py
18
+ tests/win_vhd.py
19
+ tests/fixtures/dirty-big-win.img.gz
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ checkdisk = checkdisk:main
@@ -0,0 +1 @@
1
+ checkdisk