From 9bf887220b69efa15f94789167a3a67b332d07db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 11:01:17 +0000 Subject: [PATCH] Read the NTFS $LogFile: restart areas and log records, shown as recorded Part of #57, at a conservative scope: restart areas, log record headers and redo/undo operations, target attribute fields, and names with timestamps only where the payload is a plain $FILE_NAME or index entry. No timeline is reconstructed. Adds the artefact route and viewer, a synthetic $LogFile builder and a fuzz target. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017DPjyZZHvaivtvJ3N4hZhz --- CHANGELOG.md | 11 + README.md | 6 +- docs/ROADMAP.md | 14 +- engine/artifacts.py | 10 + engine/logfile.py | 438 ++++++++++++++++++++++++++++++++++ engine/server.py | 63 ++++- tests/fuzz.py | 18 ++ tests/imagebuild_logfile.py | 180 ++++++++++++++ tests/test_logfile.py | 372 +++++++++++++++++++++++++++++ web/app.js | 128 +++++++++- web/index.html | 1 + web/strings/en-GB/engine.json | 13 +- web/strings/en-GB/ui.json | 20 +- 13 files changed, 1263 insertions(+), 11 deletions(-) create mode 100644 engine/logfile.py create mode 100644 tests/imagebuild_logfile.py create mode 100644 tests/test_logfile.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a90ba1..34b1f8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,17 @@ records, not how the code changed. and the QCOW2 variants Strata cannot read (external data file, subcluster tables, zstd compression). Tested on synthetic images only; DMG and AFF4 are still refused. +- **The NTFS log (`$LogFile`) can be read.** A new artefact, "NTFS log", + shows the log's restart areas (whether the last shutdown was clean, log + version, size) and its log records: LSN, transaction, the redo and undo + operation (name and code), and the attribute they act on. Where a record + adds, deletes or updates a file name and its payload is a plain + `$FILE_NAME` that passes sanity checks, the name, the file it refers to and + its four timestamps are shown too. Everything is shown as recorded: the log + is a ring, so it is not a full history, and Strata does not infer a + sequence of events from it. Torn pages are skipped and counted. Built from + published third-party format descriptions and tested on synthetic logs + only; it has not been checked against a real `$LogFile`. ### Fixed diff --git a/README.md b/README.md index 3017ef6..a183e13 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,8 @@ alongside the classification, and the measurement window is always stated. ## Analysis -**Artefacts** — sixteen parsers: volume layout, encrypted volumes, the change -journal (`$UsnJrnl`), prefetch, shortcuts and Jump Lists, Recycle Bin, +**Artefacts** — seventeen parsers: volume layout, encrypted volumes, the change +journal (`$UsnJrnl`), the NTFS log (`$LogFile`, shown as recorded), prefetch, shortcuts and Jump Lists, Recycle Bin, well-known registry keys, shellbags, Amcache and ShimCache, browser history, Windows event logs, file-type verification, timeline, hashing every file, signature carving, and the content index. Each says what it will cost before @@ -161,7 +161,7 @@ separate from the tool's suggestions, and never promotes one to the other. ## Not implemented -EWF v2 (Ex01), FileVault, OST-specific structures, `$LogFile`, and carving +EWF v2 (Ex01), FileVault, OST-specific structures, reconstructing events from `$LogFile`, and carving across fragments. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c784f0f..9cba69f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -108,9 +108,17 @@ recognised and refused by name today; these entries are about *reading* it. ## Artefacts -- [ ] **`$LogFile`** — the log NTFS keeps for crash recovery. A different - structure from `$UsnJrnl` and a much shorter window, but it records the - operations rather than a summary per file. +- [~] **`$LogFile`** — the log NTFS keeps for crash recovery. Restart areas + and log records are read and shown as recorded: LSN, transaction, + redo/undo operation (name and code), target attribute, and a file name + with its timestamps where the payload of an add/delete-index-entry or + update-file-name record is a plain `$FILE_NAME` that passes sanity + checks. Built from third-party format descriptions and tested on + synthetic logs only — no real `$LogFile` has been checked. Not done: + deriving the target MFT record number, names from + InitializeFileRecordSegment (whole MFT records) or other operations, + and any reconstruction of what happened in order (deliberately left + out: the log is a ring and an inferred story could mislead). - [x] **Browser disk cache** — Chromium blockfile (what Chrome and Edge on Windows write for web content), Chromium Simple Cache and Firefox cache2 entries are read (URL, status, content type, times), separate from the diff --git a/engine/artifacts.py b/engine/artifacts.py index 8a4b265..b5c4826 100644 --- a/engine/artifacts.py +++ b/engine/artifacts.py @@ -136,6 +136,16 @@ def presence(fs, root): "deleted file's name.", "needs": ["NTFS"], }, + { + "id": "logfile", "label": "NTFS log ($LogFile)", "cost": MINUTES, + "scope": "volume", "method": "POST", "route": "logfile", + "per_volume": True, + "answers": "What NTFS's crash-recovery log still holds: recent " + "transactions, the operations they performed and, where " + "an entry carries one, a file name and its timestamps. " + "Read as recorded; it is a ring, not a full history.", + "needs": ["NTFS"], + }, { "id": "registry", "label": "Well-known registry keys", "cost": MINUTES, "scope": "image", "method": "POST", "route": "registry/report", "per_volume": False, diff --git a/engine/logfile.py b/engine/logfile.py new file mode 100644 index 0000000..5fb523e --- /dev/null +++ b/engine/logfile.py @@ -0,0 +1,438 @@ +"""The NTFS transaction log, $LogFile: what its restart areas and log records +say, reported as they are found. + +$LogFile is Microsoft's crash-recovery log and is not officially documented. +The layout here follows the third-party descriptions (ntfs-3g's logfile.h and +the parsers built on it), and the operation names are those the same sources +give. Nothing is inferred: a record is shown with the numbers it holds, a name +or timestamp is shown only where the payload is a plain $FILE_NAME or index +entry that passes sanity checks, and no sequence of events is reconstructed. +The log is a ring, so what survives is whatever was written most recently +plus older records not yet overwritten; it is not a complete history. + +Layout: two restart pages ("RSTR") at the start, then log pages ("RCRD") of +the same size. A log record has a 48-byte header (LSN, transaction, client) +followed by the client's data; a record can run on into the next page. Every +page carries an update sequence array that is checked and removed.""" + +import struct + +from .text import t as _t + +PAGE = 4096 +SECTOR = 512 +HEADER = 48 +RCRD_FIRST = 2 # pages 0 and 1 are the restart pages +MAX_CLIENT_DATA = 1 << 20 +MAX_RECORDS = 200000 +MAX_LCNS = 16 + +STANDARD, CHECKPOINT = 1, 2 +_TYPES = {STANDARD: "standard", CHECKPOINT: "checkpoint"} + +# Operation codes for the redo and undo halves of a record, as the +# third-party documentation names them. The code is always reported too. +OPERATIONS = { + 0x00: "Noop", 0x01: "CompensationLogRecord", + 0x02: "InitializeFileRecordSegment", 0x03: "DeallocateFileRecordSegment", + 0x04: "WriteEndOfFileRecordSegment", 0x05: "CreateAttribute", + 0x06: "DeleteAttribute", 0x07: "UpdateResidentValue", + 0x08: "UpdateNonresidentValue", 0x09: "UpdateMappingPairs", + 0x0A: "DeleteDirtyClusters", 0x0B: "SetNewAttributeSizes", + 0x0C: "AddIndexEntryRoot", 0x0D: "DeleteIndexEntryRoot", + 0x0E: "AddIndexEntryAllocation", 0x0F: "DeleteIndexEntryAllocation", + 0x10: "WriteEndOfIndexBuffer", 0x11: "SetIndexEntryVcnRoot", + 0x12: "SetIndexEntryVcnAllocation", 0x13: "UpdateFileNameRoot", + 0x14: "UpdateFileNameAllocation", 0x15: "SetBitsInNonresidentBitMap", + 0x16: "ClearBitsInNonresidentBitMap", 0x17: "HotFix", + 0x18: "EndTopLevelAction", 0x19: "PrepareTransaction", + 0x1A: "CommitTransaction", 0x1B: "ForgetTransaction", + 0x1C: "OpenNonresidentAttribute", 0x1D: "OpenAttributeTableDump", + 0x1E: "AttributeNamesDump", 0x1F: "DirtyPageTableDump", + 0x20: "TransactionTableDump", 0x21: "UpdateRecordDataRoot", + 0x22: "UpdateRecordDataAllocation", +} + +_INDEX_ENTRY_OPS = (0x0C, 0x0D, 0x0E, 0x0F) +_FILE_NAME_OPS = (0x13, 0x14) + +_FILETIME_MIN = 116444736000000000 + 20 * 365 * 86400 * 10 ** 7 # ~1990 +_FILETIME_MAX = 116444736000000000 + 130 * 365 * 86400 * 10 ** 7 # ~2100 + + +class LogFileError(ValueError): + pass + + +def operation_name(code): + return OPERATIONS.get(code, "unknown") + + +def _align8(n): + return (n + 7) & ~7 + + +def apply_fixups(page): + """The page with its update sequence array removed, or None if any + sector's last two bytes do not match the sequence number (a torn write: + the page is not trusted).""" + if len(page) < SECTOR: + return None + usa_off, usa_count = struct.unpack_from(" len(page) \ + or (usa_count - 1) * SECTOR > len(page): + return None + usn = page[usa_off:usa_off + 2] + out = bytearray(page) + for i in range(1, usa_count): + end = i * SECTOR + if out[end - 2:end] != usn: + return None + out[end - 2:end] = page[usa_off + 2 * i:usa_off + 2 * i + 2] + return bytes(out) + + +def filetime(v): + """ISO time for a FILETIME that falls between 1990 and 2100, else None.""" + if not _FILETIME_MIN <= v <= _FILETIME_MAX: + return None + import datetime + epoch = datetime.datetime(1601, 1, 1) + return (epoch + datetime.timedelta(microseconds=v // 10)).isoformat() + "Z" + + +def _lsn_offset(lsn, seq_bits): + """The file offset an LSN encodes (its low bits, in 8-byte units).""" + if not 0 < seq_bits < 64: + return None + return (lsn & ((1 << (64 - seq_bits)) - 1)) * 8 + + +# -- restart areas ---------------------------------------------------------------- + +def parse_restart(page): + """The fields of a restart page (dict) or None if it is not usable.""" + if page[:4] != b"RSTR": + return None + fixed = apply_fixups(page) + if fixed is None: + return {"valid": False, "reason": _t("logfile.torn_page")} + (chkdsk_lsn, sys_page, log_page, area_off, minor, major) = \ + struct.unpack_from(" len(fixed): + return {"valid": False, "reason": _t("logfile.restart_bad_area")} + a = area_off + (current_lsn, clients, free_list, in_use, flags, seq_bits, area_len, + client_off, file_size, last_len, rec_hdr_len, data_off, + open_count) = struct.unpack_from(" len(fixed): + break + oldest, restart_lsn = struct.unpack_from(" len(buf): + return None + ref, entry_len, key_len = struct.unpack_from(" len(buf) \ + or key_len < 66: + return None + key = off + 16 + elif off + 66 > len(buf): + return None + parent, c, m, mm, a = struct.unpack_from(" len(buf): + return None + if need_entry and end > off + 16 + key_len: + return None + if not all(v == 0 or _FILETIME_MIN <= v <= _FILETIME_MAX + for v in (c, m, mm, a)): + return None + name = buf[key + 66:end].decode("utf-16-le", "replace") + if any(ord(ch) < 32 for ch in name) or "�" in name: + return None + out = {"name": name, "namespace": buf[key + 65], + "parent_reference": parent & 0xFFFFFFFFFFFF, + "parent_sequence": parent >> 48, + "times": {"created": filetime(c), "modified": filetime(m), + "mft_modified": filetime(mm), "accessed": filetime(a)}} + if ref is not None: + out["file_reference"] = ref & 0xFFFFFFFFFFFF + out["file_sequence"] = ref >> 48 + return out + + +def _payload_name(op, data): + """Name fields from a redo/undo payload, where it is a plain index entry + (add/delete index entry) or $FILE_NAME (update file name); else None.""" + if op in _INDEX_ENTRY_OPS: + got = _file_name(data, 0, True) + if got: + got["source"] = "index entry" + return got + if op in _FILE_NAME_OPS: + got = _file_name(data, 0, False) + if got: + got["source"] = "$FILE_NAME" + return got + return None + + +def parse_record(buf, offset, stats, seq_bits): + """One log record from its full bytes (header and client data), as a + dict.""" + (lsn, prev_lsn, undo_next, data_len, client_seq, client_idx, rtype, tx, + flags) = struct.unpack_from(" len(data): + continue + got = _payload_name(op, data[off:off + length]) + if got: + got["from"] = which + rec.setdefault("names", []).append(got) + stats["names"] += 1 + return rec + + +class _Walker: + """Cuts the data areas of successive log pages into records, joining a + record that runs from one page into the next.""" + + def __init__(self, seq_bits, stats, sink): + self.seq_bits, self.stats, self.sink = seq_bits, stats, sink + self.pending = b"" + self.pending_at = 0 + self.need = 0 + + def _emit(self, raw, at): + self.stats["records"] += 1 + rec = parse_record(raw, at, self.stats, self.seq_bits) + self.sink(rec) + + def _drop_pending(self): + if self.pending: + self.stats["incomplete"] += 1 + self.pending = b"" + self.need = 0 + + def feed(self, region, base): + """`region` is a page's data area; `base` its offset in the file.""" + i = 0 + while i < len(region): + if self.pending: + if len(self.pending) < HEADER: + take = region[i:i + HEADER - len(self.pending)] + self.pending += take + i += len(take) + if len(self.pending) < HEADER: + return + self.need = self._total(self.pending) + if self.need is None: + self.stats["stopped"] += 1 + self._drop_pending() + return + take = region[i:i + self.need - len(self.pending)] + self.pending += take + i += len(take) + if len(self.pending) < self.need: + return + self._emit(self.pending, self.pending_at) + self.pending, self.need = b"", 0 + i = _align8(i) + continue + if len(region) - i < HEADER: + self.pending, self.pending_at = region[i:], base + i + return + total = self._total(region[i:i + HEADER]) + if total is None: + self.stats["stopped"] += 1 + return + if i + total <= len(region): + self._emit(region[i:i + total], base + i) + i += _align8(total) + else: + self.pending, self.pending_at = region[i:], base + i + self.need = total + return + + @staticmethod + def _total(head): + lsn = struct.unpack_from(" MAX_CLIENT_DATA: + return None + return HEADER + data_len + + +def parse(read, size, progress=None): + """Read a $LogFile through read(offset, length) over `size` bytes. + Returns (report, records): the report holds the restart areas, the + page tally and the record counts; records are ordered by LSN.""" + stats = {"pages": 0, "restart_pages": 0, "log_pages": 0, + "unused_pages": 0, "torn_pages": 0, "unrecognised_pages": 0, + "records": 0, "names": 0, "incomplete": 0, "stopped": 0, + "truncated": False, "position_matches": 0} + findings = [] + restarts = [] + page_size = PAGE + for n in range(2): + raw = read(n * page_size, page_size) + if len(raw) < page_size: + continue + got = parse_restart(raw) + if got is None: + continue + got["page"] = n + restarts.append(got) + usable = [r for r in restarts if r.get("valid")] + if not usable: + raise LogFileError(_t("logfile.no_restart")) + stats["restart_pages"] = len(usable) + newest = max(usable, key=lambda r: r["current_lsn"]) + if newest["log_page_size"] != PAGE or newest["system_page_size"] != PAGE: + findings.append(_t("logfile.page_size") % ( + newest["system_page_size"], newest["log_page_size"])) + data_off = newest["page_data_offset"] + if not 0x28 <= data_off < PAGE or data_off % 8: + data_off = 0x40 + findings.append(_t("logfile.data_offset_default")) + if len(usable) == 2 and usable[0]["current_lsn"] != usable[1]["current_lsn"]: + findings.append(_t("logfile.restarts_differ")) + if not newest["clean_shutdown"]: + findings.append(_t("logfile.not_clean")) + + records = [] + + def sink(rec): + if len(records) >= MAX_RECORDS: + stats["truncated"] = True + return + if rec.get("lsn_position_matches"): + stats["position_matches"] += 1 + records.append(rec) + + walker = _Walker(newest["seq_number_bits"], stats, sink) + total_pages = size // PAGE + for n in range(RCRD_FIRST, total_pages): + if progress and n % 256 == 0: + progress(n / total_pages) + at = n * PAGE + raw = read(at, PAGE) + if len(raw) < PAGE: + break + stats["pages"] += 1 + magic = raw[:4] + if magic == b"\xff\xff\xff\xff" or not any(raw[:64]): + stats["unused_pages"] += 1 + walker._drop_pending() + continue + if magic != b"RCRD": + stats["unrecognised_pages"] += 1 + walker._drop_pending() + continue + page = apply_fixups(raw) + if page is None: + stats["torn_pages"] += 1 + walker._drop_pending() + continue + stats["log_pages"] += 1 + flags = struct.unpack_from(" log_pages: + raise ValueError("records do not fit in %d pages" % log_pages) + last_lsn = lsn_for(seq, starts[-1]) if starts else lsn_for(seq, 0) + pages = [] + for p in range(log_pages): + body = bytes(stream[p * area:(p + 1) * area]) + if p >= n_used: + pages.append(b"\xff" * PAGE if blank else bytes(PAGE)) + continue + page = bytearray(PAGE) + page[0:4] = b"RCRD" + end = DATA_OFF + len(body) + # A record ends on the page unless the data runs to its last byte and + # the stream carries on into the next page. + continues = p + 1 < n_used and len(body) == area + struct.pack_into(" b.PAGE for s in starts)) + self.assertEqual(report["stats"]["incomplete"], 0) + + def test_torn_log_page_is_skipped_and_reported(self): + data, _ = b.logfile(self.specs(40), log_pages=6) + good, _ = parse(data) + data, _ = b.logfile(self.specs(40), log_pages=6, tear_page=1) + report, recs = parse(data) + self.assertEqual(report["stats"]["torn_pages"], 1) + self.assertLess(len(recs), good["stats"]["records"]) + self.assertTrue(any("update sequence check" in f + for f in report["findings"])) + + def test_unused_and_foreign_pages_are_counted_not_parsed(self): + data, _ = b.logfile(SPECS, log_pages=6) + data = bytearray(data) + at = 4 * b.PAGE + data[at:at + 4] = b"ABCD" + report, recs = parse(bytes(data)) + st = report["stats"] + self.assertEqual((st["unused_pages"], st["unrecognised_pages"]), + (4, 1)) + self.assertEqual(len(recs), 4) + + def test_record_cap_is_reported(self): + old = logfile.MAX_RECORDS + logfile.MAX_RECORDS = 3 + try: + report, recs = parse(b.logfile(self.specs(10))[0]) + finally: + logfile.MAX_RECORDS = old + self.assertEqual(len(recs), 3) + self.assertTrue(report["stats"]["truncated"]) + self.assertTrue(any("not listed" in f for f in report["findings"])) + + def test_progress_is_reported_and_bounded(self): + calls = [] + data, _ = b.logfile(SPECS) + parse(data * 1, progress=calls.append) + self.assertTrue(all(0 <= c <= 1 for c in calls)) + + +class Damage(unittest.TestCase): + + def test_random_damage_never_raises_anything_but_logfile_error(self): + data, _ = b.logfile(SPECS + Pages.specs(None, 30), log_pages=6) + rng = random.Random(4) + for _ in range(400): + mutated = bytearray(data) + for _ in range(rng.randint(1, 30)): + mutated[rng.randrange(len(mutated))] = rng.randrange(256) + if rng.random() < 0.2: + mutated = mutated[:rng.randrange(len(mutated))] + try: + parse(bytes(mutated)) + except logfile.LogFileError: + pass + + +class OnAVolume(unittest.TestCase): + + def test_read_through_an_ntfs_volume(self): + data, _ = b.logfile(SPECS, log_pages=4) + img = b.ntfs_with_logfile(data) + + class Mem(object): + bytes_per_sector = 512 + + def __init__(self, d): + self.d, self.size = d, len(d) + + def read_at(self, off, n): + return self.d[off:off + n] if 0 <= off < self.size else b"" + + fs = open_fs(OffsetReader(Mem(img), 0, len(img))) + attr = logfile.find(fs) + self.assertIsNotNone(attr) + report, recs = logfile.read_volume(fs, attr) + expect, _ = parse(data) + self.assertEqual(report["stats"], expect["stats"]) + self.assertEqual([r["lsn"] for r in recs], [r["lsn"] for r in parse(data)[1]]) + self.assertEqual(recs[0]["names"][0]["name"], "secret.doc") + + def test_a_volume_whose_log_is_empty_is_refused_not_guessed(self): + class Fs(object): + def record(self, n): + return None + self.assertIsNone(logfile.find(Fs())) + with self.assertRaises(logfile.LogFileError): + parse(b"") + + +class Route(unittest.TestCase): + """The artefact route, through the real server on an NTFS image.""" + + def test_read_then_page_and_filter(self): + import http.client + import json + import shutil + import socket + import tempfile + import threading + import time + from engine import server + + d = tempfile.mkdtemp(prefix="strata-logfile-route-") + self.addCleanup(lambda: shutil.rmtree(d, ignore_errors=True)) + img = os.path.join(d, "disk.img") + data, _ = b.logfile(SPECS, log_pages=4) + with open(img, "wb") as fh: + fh.write(b.ntfs_with_logfile(data)) + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + ready = threading.Event() + threading.Thread(target=server.serve, daemon=True, kwargs={ + "host": "127.0.0.1", "port": port, + "on_ready": lambda h, p: ready.set()}).start() + ready.wait(10) + cookie = [] + + def call(method, path, body=None): + c = http.client.HTTPConnection("127.0.0.1", port, timeout=15) + host = "127.0.0.1:%d" % port + h = {"Content-Type": "application/json", "Host": host, + "Origin": "http://" + host} + if cookie: + h["Cookie"] = cookie[0] + c.request(method, path, json.dumps(body) if body is not None + else None, h) + r = c.getresponse() + raw = r.read() + if r.getheader("Set-Cookie"): + cookie[:] = [r.getheader("Set-Cookie").split(";")[0]] + c.close() + if "json" in (r.getheader("Content-Type") or ""): + return json.loads(raw) + return None + + call("GET", "/") + opened = call("POST", "/api/open", { + "path": img, "case_path": os.path.join(d, "case"), + "examiner": "T"}) + self.assertTrue(opened["open"], opened) + self.assertFalse(call("GET", "/api/logfile")["loaded"]) + task = call("POST", "/api/logfile", {"part": 0}) + for _ in range(200): + t = call("GET", "/api/task?id=" + task["id"]) + if t.get("state") != "running": + break + time.sleep(0.05) + self.assertEqual(t["state"], "done", t) + self.assertTrue(t["result"]["present"], t["result"]) + self.assertEqual(t["result"]["records"], 4) + got = call("GET", "/api/logfile?limit=2") + self.assertEqual((got["total"], len(got["records"])), (4, 2)) + self.assertIn("CommitTransaction", got["operations"]) + found = call("GET", "/api/logfile?q=renamed") + self.assertEqual(found["total"], 1) + only = call("GET", "/api/logfile?op=CommitTransaction") + self.assertEqual(only["total"], 1) + self.assertEqual(only["records"][0]["transaction"], 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app.js b/web/app.js index 67904ef..ddd8fed 100644 --- a/web/app.js +++ b/web/app.js @@ -6158,12 +6158,12 @@ const ART_RENDER = { }; const ART_ORDER = ['recyclebin', 'lnk', 'browser', 'appcompat', 'prefetch', - 'evtx', 'usn', 'shellbags', 'mail', 'leveldb', 'vss', 'snapshots', + 'evtx', 'usn', 'logfile', 'shellbags', 'mail', 'leveldb', 'vss', 'snapshots', 'wallets']; const ART_LABEL = { recyclebin: 'Recycle Bin', lnk: 'Shortcuts', browser: 'Browsing', appcompat: 'Programs', prefetch: 'Execution', evtx: txt('ui.tree.events'), - usn: txt('ui.change_journal'), + usn: txt('ui.change_journal'), logfile: txt('ui.tree.logfile'), shellbags: 'Folders', mail: 'Mail', leveldb: 'LevelDB', vss: txt('ui.tree.shadow_copies'), snapshots: txt('ui.tree.apfs_snapshots'), wallets: 'Crypto', @@ -6273,6 +6273,10 @@ function showArtPick(part) { usnPage = 0; usnQuery = ''; usnReason = ''; return loadUsn(part); } + if (artPick.mode === 'logfile') { + logPage = 0; logQuery = ''; logOp = ''; + return loadLogfile(part); + } const draw = ART_RENDER[artPick.mode]; if (draw) draw(r, part); } @@ -6393,6 +6397,7 @@ function showCachedArtefact(mode, part) { const hit = artCache.get(artKey(mode, part)); if (!hit) return false; if (mode === 'usn') { usnPage = 0; usnQuery = ''; usnReason = ''; loadUsn(part); return true; } + if (mode === 'logfile') { logPage = 0; logQuery = ''; logOp = ''; loadLogfile(part); return true; } const draw = ART_RENDER[mode]; if (!draw) return false; draw(hit, part); @@ -6501,6 +6506,24 @@ async function doArtifacts(force = false) { artCache.set(artKey('usn', part), true); return loadUsn(part); } + if (artMode === 'logfile') { + const have = await api.get('logfile', { limit: 1 }); + if (!(have.loaded && have.part === part)) { + const t = await api.post('logfile', { part }); + const r = await awaitTask(t, txt('ui.tree.logfile'), { + modal: { title: txt('ui.reading_logfile'), detail: txt('ui.logfile.caveat') }, + }); + if (!r) return null; + if (!r.present || r.error) { + $('#art-results').innerHTML = + `

${esc(r.note || r.error)}

`; + return null; + } + } + logPage = 0; logQuery = ''; logOp = ''; + artCache.set(artKey('logfile', part), true); + return loadLogfile(part); + } if (artMode === 'shellbags') { const t = await api.post('shellbags', { part }); const r = await awaitTask(t, 'Shellbags', { @@ -9458,6 +9481,107 @@ function renderUsn(r, part) { initColumnResize(); } +let logPage = 0, logQuery = '', logOp = ''; +const LOG_PER_PAGE = 200; + +async function loadLogfile(part) { + const r = await api.get('logfile', { + offset: logPage * LOG_PER_PAGE, limit: LOG_PER_PAGE, + q: logQuery || undefined, op: logOp || undefined, + }); + if (!r.loaded) { + $('#art-results').innerHTML = + `

${txt('ui.logfile.not_read_yet')}

`; + return; + } + renderLogfile(r, part); +} + +function renderLogfile(r, part) { + const rep = r.report || {}, st = rep.stats || {}; + const newest = (rep.restart || []).find(x => x.page === rep.newest_restart) || {}; + const pages = Math.max(1, Math.ceil(r.total / LOG_PER_PAGE)); + const yesno = v => txt(v ? 'ui.logfile.yes' : 'ui.logfile.no'); + const notes = [ + `

${esc(txt('ui.logfile.summary', { + restarts: st.restart_pages, records: (st.records || 0).toLocaleString(), + pages: (st.log_pages || 0).toLocaleString(), + clean: yesno(newest.clean_shutdown), version: newest.version, + size: (rep.size || 0).toLocaleString() }))}

`, + `

${esc(txt('ui.logfile.caveat'))}

`, + ]; + for (const f of rep.findings || []) { + notes.push(`

${esc(f)}

`); + } + const rows = (r.records || []).map(x => { + const ops = x.redo_name + ? `${esc(x.redo_name)} (0x${x.redo_op.toString(16)}) / ${ + esc(x.undo_name)} (0x${x.undo_op.toString(16)})` + : esc(x.type); + const tgt = x.redo_name + ? `attr ${x.target_attribute}, vcn ${x.target_vcn}${ + (x.lcns || []).length ? ', lcn ' + x.lcns.join(',') : ''}` + : '—'; + const names = (x.names || []).map(n => { + const t = n.times || {}; + const tip = ['created', 'modified', 'mft_modified', 'accessed'] + .map(k => `${k}: ${t[k] ? fmt.time(t[k]) : '—'}`).join('\n'); + return `${esc(n.name)} (${ + esc(n.from)} ${esc(n.source)}${ + n.file_reference != null ? ', file ' + n.file_reference : ''}, parent ${ + n.parent_reference})`; + }).join('
'); + return ` + ${x.lsn.toLocaleString()} + ${x.transaction || '—'} + ${ops} + ${esc(tgt)} + ${names || '—'} + ${x.offset.toLocaleString()} + `; + }).join(''); + $('#art-results').innerHTML = ` + ${notes.join('')} +
+ + + + ${esc(txt('ui.logfile.pager', { + page: (logPage + 1).toLocaleString(), pages: pages.toLocaleString(), + n: r.total.toLocaleString() }))} + +
+ + + + + ${rows} +
${txt('ui.logfile.lsn')}${txt('ui.logfile.transaction')}${txt('ui.logfile.operation')}${txt('ui.logfile.target')}${txt('ui.logfile.name')}${txt('ui.logfile.position')}
`; + $('#log-prev').addEventListener('click', () => { + if (logPage) { logPage--; loadLogfile(part); } + }); + $('#log-next').addEventListener('click', () => { + if (logPage + 1 < pages) { logPage++; loadLogfile(part); } + }); + $('#log-op').addEventListener('change', e => { + logOp = e.target.value; logPage = 0; loadLogfile(part); + }); + let t; + $('#log-q').addEventListener('input', e => { + clearTimeout(t); + const v = e.target.value; + t = setTimeout(() => { logQuery = v; logPage = 0; loadLogfile(part); }, 250); + }); + initColumnResize(); +} + let toastTimer; function noticeWanted(channel) { if (!channel || channel === 'problem') return true; diff --git a/web/index.html b/web/index.html index d23459e..40154d1 100644 --- a/web/index.html +++ b/web/index.html @@ -90,6 +90,7 @@ + diff --git a/web/strings/en-GB/engine.json b/web/strings/en-GB/engine.json index 631eb31..a86e0ae 100644 --- a/web/strings/en-GB/engine.json +++ b/web/strings/en-GB/engine.json @@ -403,5 +403,16 @@ "raid.parity_differs": "%d of %d stripe rows have parity that does not match their data; the first is at byte %d of the array. This is what the members hold; it can come from a write in progress, a member that was out of date, or a chunk size, order or layout that is not how the set was built.", "raid.stores_no_hash": "A RAID set stores no acquisition hash, so there is nothing to verify this against. The digests above are of the array as reassembled now.", "server.open.raid_definition": "This case holds no definition for that RAID set, so it cannot be assembled again.", - "server.raid.case_needed": "Say which case file to keep the RAID set in: it has no file to name one after." + "server.raid.case_needed": "Say which case file to keep the RAID set in: it has no file to name one after.", + "logfile.torn_page": "The restart page failed its update sequence check, so it was not read.", + "logfile.restart_bad_area": "The restart page's restart area does not lie inside the page.", + "logfile.no_restart": "$LogFile has no readable restart page, so its log pages cannot be interpreted.", + "logfile.page_size": "The restart area gives a system page size of %d and a log page size of %d; Strata reads 4096-byte pages, so what follows may be misread.", + "logfile.data_offset_default": "The restart area's page data offset is not one Strata accepts; 0x40 is used.", + "logfile.restarts_differ": "The two restart pages record different current LSNs; the one with the higher LSN is shown as the newest.", + "logfile.not_clean": "The newest restart area says NTFS did not shut down cleanly (or the log was in use when the image was taken).", + "logfile.torn_pages": "%d log page(s) failed their update sequence check and were skipped.", + "logfile.unrecognised_pages": "%d log page(s) held neither log records nor unused space and were skipped.", + "logfile.truncated": "More than %d records were found; the rest are not listed.", + "server.logfile.not_found": "This volume has no readable $LogFile." } diff --git a/web/strings/en-GB/ui.json b/web/strings/en-GB/ui.json index 7a6713e..a48a1c6 100644 --- a/web/strings/en-GB/ui.json +++ b/web/strings/en-GB/ui.json @@ -1078,5 +1078,23 @@ "kv.raid_chunk": "Chunk size", "kv.raid_layout": "Layout", "kv.raid_read_from": "Read from", - "kv.raid_member": "Member" + "kv.raid_member": "Member", + "tree.logfile": "NTFS log", + "reading_logfile": "Reading the NTFS log", + "logfile.not_read_yet": "The NTFS log has not been read yet.", + "logfile.name_placeholder": "Name or operation…", + "logfile.every_operation": "Every operation", + "logfile.lsn": "LSN", + "logfile.transaction": "Transaction", + "logfile.operation": "Operation (redo / undo)", + "logfile.target": "Target", + "logfile.name": "Name in the record", + "logfile.position": "Position", + "logfile.summary": "{restarts} restart area(s), {records} record(s) in {pages} log page(s); clean shutdown recorded: {clean}; log version {version}, {size} bytes.", + "logfile.caveat": "Shown as recorded in the log. The log is a ring: older records are overwritten, so this is not a full history, and no sequence of events is inferred. A name and its times appear only where a record holds a plain file name that passes checks.", + "logfile.pager": "Page {page} of {pages} ({n} records)", + "logfile.prev": "Prev", + "logfile.next": "Next", + "logfile.yes": "yes", + "logfile.no": "no" }