15 · File Recovery (Arachnid Recover)
arachnid-recover recovers files from a disk image or an attached device, by
parsing filesystem metadata and by carving raw sectors. It is read-only against
its source, like Core and unlike Sanitize. It is also reachable as screen 8 in
arachnid-tui.
Contents
- Where Recover sits
- Read-only, structurally
- Two passes, two kinds of claim
- Supported filesystems
- Carved file types
- Call logs, browser history and system logs
- Confidence scoring
- CLI reference
- Export and chain of custody
- The safety rails
- The TUI Recover screen
- Exit codes
- Results schema
- Not in scope
Where Recover sits
Core Recover Sanitize
acquire evidence → extract files from it → destroy the media
(read-only) (read-only) (destroys data)
Recover reads what Core acquired — or a drive directly, before it goes to
Sanitize — and turns it back into files. Its own output is a container in Core’s
format, so a recovery export verifies with arachnid-core verify, unchanged.
There is one implementation of hashing, signing and verification in this suite, and Recover uses it rather than carrying a second.
Read-only, structurally
Every parser and the carver read through one trait:
pub trait Source: Send {
fn size(&self) -> u64;
fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<usize>;
fn label(&self) -> String;
}
There is no write_at. Not one that returns an error, not one behind a flag —
the method does not exist, so no code path in the crate can write to the media
under examination, and adding one means editing
crates/arachnid-recover-core/src/source.rs. Device handles are opened
.read(true) and never .write(true), so the kernel refuses a write even if
one were somehow issued.
This is the exact inverse of arachnid_sanitize_core::target::WipeTarget, whose
entire purpose is to write. The two traits must never converge.
Two passes, two kinds of claim
Filesystem-aware recovery parses the volume’s own metadata. A file recovered this way comes back with its original name, path and timestamps, because the filesystem is telling you what it was.
Raw carving scans sectors for file signatures. It works where no filesystem is left to parse — a reformatted volume, a partition table that no longer reads, an APFS container — and it recovers content without identity: no name, no path, no timestamp, because none of those live in a file’s own bytes.
| Filesystem pass | Carving pass | |
|---|---|---|
| Reads | NTFS MFT, ext4 inodes and journal | raw sectors |
| Recovers | content, name, path, timestamps | content only |
| Needs a filesystem | yes | no |
| Confidence ceiling | High |
Low |
Both are real recovery. They are not the same claim, and nothing in this module presents them as one.
Supported filesystems
NTFS
An NTFS delete does not erase the file record. It clears the in-use bit in the
record header and frees the clusters in $Bitmap; the record itself — name,
parent, timestamps, and the run list pointing at the data — stays where it was
until something reuses the slot.
Parsed:
- boot sector geometry, including the negative encodings for sectors-per-cluster and MFT record size
- the MFT, read through
$MFT’s own run list rather than walked forward blindly - the update sequence array on every record. A record whose sector-tail numbers do not match is a torn write and is skipped, not repaired over: half a record from before a crash and half from after is not a file
$STANDARD_INFORMATIONtimestamps and$FILE_NAME, preferring the Win32 name over the 8.3 alias$DATArun lists, resident and non-resident, including sparse runs- path reconstruction from parent references — including through deleted directory records, which is what lets a deleted file keep its full path
Not parsed:
- NTFS-compressed
$DATA. The clusters are located, the file is capped atMedium, and the reason is stated on the result. It is not exported as though the raw clusters were its contents. - EFS-encrypted
$DATA. Reported as encrypted; see Not in scope. - Alternate data streams. Skipped rather than exported under the file’s own name.
- The first 16 records (
$MFT,$LogFile,$Bitmap, …). They are NTFS’s own metadata, not user data, and recovering them would bury the results.
ext4
ext4 unlinks a file by clearing its directory entry, setting i_dtime, dropping
i_links_count to zero and freeing its blocks. Unlike ext3, it does not zero
the extent tree in the inode, so the inode usually still says exactly which
blocks held the file.
Parsed:
- superblock, group descriptors (32- and 64-byte), inode tables
- extent trees, following index nodes to their leaves, with uninitialized (preallocated) extents reported rather than exported as data
- directory entries and the deleted entries in their slack. An unlink extends
the preceding entry’s
rec_lenover the old record rather than erasing it, so the old name is still there. Candidates containing control characters or/are rejected — slack is mostly stale bytes, and a “name” of control characters would invent a filename - the jbd2 journal, walked for descriptor blocks whose tags name an
inode-table block. An older copy of an inode found there recovers a file the
live table has already forgotten — at
Mediumat best, because a journalled inode is by definition a superseded snapshot
Not parsed, and each named individually in the results rather than skipped silently: ext2/ext3 indirect block maps, inline data, inline directories.
APFS
Identified, not recovered. The container superblock is parsed for block geometry, and volume superblocks are located to report each volume’s name, file and directory counts, last-modified time and encryption state.
Per-file recovery is not implemented in this version. Recovering a file from APFS means resolving virtual object IDs through the container object map, walking the volume’s file-system B-tree for inode and directory records, then following extent records through the extent-reference tree — with snapshots and clones changing what “the file” even refers to.
The scan says so explicitly:
Filesystems
apfs at offset 0 — 0 entries
unsupported: APFS per-file recovery is not implemented in this build …
Run the raw carving pass against this container to recover
file content.
note: APFS container: 244190646 block(s) of 4096 bytes, 1 volume(s) found
note: volume 0 "Macintosh HD": 412883 file(s), 98214 director(y/ies) …
An empty result set with an explicit “not implemented” is worth more than one that reads as “there was nothing there”. Carving works on an APFS container and is the supported route today.
Carved file types
jpg · png · pdf · zip · mp4 · sqlite · evtx · journal · txt
A carved ZIP is reported as docx, xlsx or pptx when its member layout says
so, so an analyst does not have to open every archive to find the documents.
Where the file ends. Where the format has its own terminator, the end is found structurally rather than guessed:
| Type | How the end is found |
|---|---|
jpg |
the FFD9 end-of-image marker |
png |
IEND plus its fixed CRC — the last eight bytes of every valid PNG |
pdf |
%%EOF |
zip |
the end-of-central-directory record, plus its declared comment length |
mp4 |
walking the box chain and summing the declared box lengths. A box whose type is not four printable characters ends the walk |
sqlite |
the page size and page count in the database’s own header, which multiply to an exact length. SQLite only guarantees that count when the header’s version-valid-for number matches the file change counter, so that is checked rather than assumed |
evtx |
the chunk count in the Windows event log header: a 4096-byte header and that many 64 KiB chunks |
journal |
the header size and arena size in the systemd journal header, which are the whole file between them |
txt |
nothing. Plain text has no terminator, so the length is where printable bytes stopped — which is stated on the result, not implied away |
txt is off by default. On a real volume it matches every log fragment and
string table on the disk and buries everything else.
A header that is unreadable or implausible does not lose the file: the carve
falls back to the type’s size cap, and the result says footer_found: false so
the length reads as a bound rather than a claim.
Nested signatures. A JPEG’s EXIF thumbnail is itself a JPEG. Ranges already claimed by a carved file are skipped, so one photo produces one result rather than two.
Fragmentation. Files are carved as contiguous runs. A file whose terminator
is not found within the type’s size cap is reported with footer_found: false
and flagged likely-incomplete. This build does not reassemble a fragmented
file from non-adjacent runs: bi-fragment gap carving and its relatives guess, and
in evidence a plausible-looking wrong reconstruction is worse than an honest
partial one.
Call logs, browser history and system logs
Recovery hands back files. An investigation usually starts with three questions — who was called, what was browsed, what the machine logged — so results that answer one of them are labelled with a class, and one filter selects them:
arachnid-recover list-results -i ./rec/results.json --type call-log
arachnid-recover export -i ./rec/results.json -o ./rec/exported \
--type browser-history,system-log --confidence high,medium
| Class | Recognised as |
|---|---|
call-log |
Android calllog.db and contacts2.db, iOS CallHistory.storedata and call_history.db |
browser-history |
Chromium’s History inside a browser profile, Firefox places.sqlite, Safari History.db, IE WebCacheV01.dat |
system-log |
anything under /var/log, Windows event logs (.evtx, winevt/Logs), the systemd journal, and syslog / auth.log / kern.log / system.log by name |
Two routes in, matching the two passes. A filesystem-recovered file still
has its name, and the name is evidence: places.sqlite is Firefox’s history
store wherever it was found. A carved file has no name, so the only thing left
to read is the file itself — a SQLite database carries its schema as text on
page one, so moz_places says Firefox and ZCALLRECORD says the iOS call
history, and the two binary log formats are identified by the signature the
carver matched them on to begin with.
A generic name is not enough. A bare History with no browser directory
above it is left unlabelled. The cost of a wrong label here is an analyst
reading an unrelated file as a suspect’s browsing, which is worse than an
unlabelled file they can still see in the results.
The class is a claim like every other claim here, so it is never silent. An
identified file carries an artifact_identified check naming the route and the
evidence:
arachnid-recover list-results -i ./rec/results.json --detail carve-000001
artifact call-log
checks
[ok] artifact_identified call-log: the schema names "CREATE TABLE calls",
which is the Android call log schema
The label does not change what a result is or what it scores. A carved database
is still Low, still has no original name, and is still exported under the name
the carver gave it.
Confidence scoring
Every result carries a label and the checks behind it, because High and
Low look identical once they are files in a folder.
| Label | Means | Reached when |
|---|---|---|
High |
filesystem metadata intact, every allocated byte read back | a live entry, a complete run list or extent tree, every extent readable, nothing compressed or encrypted |
Medium |
filesystem metadata found, something about the data is in doubt | deleted; or the allocation is short of the declared size; or an extent will not read; or the data is compressed or encrypted; or the inode came from the journal |
Low |
raw-carved: structurally valid, completeness unverified | every carved result, without exception |
The rule that does the most work
A deleted file never scores High. Its clusters or blocks are free, so a
clean read proves the bytes are readable — not that they are still that file’s
bytes. That distinction is the difference between evidence and a coincidence,
and no amount of clean reading closes it.
The rationale is stored, not just the label
Each result lists every check that ran, whether it passed, and what was actually observed:
ntfs-000019 <unknown>/orphan.txt
method NTFS MFT
type txt
size 45 bytes
deleted true
modified 2026-03-01T12:00:00Z
extents 1
offset 98304 45 bytes
confidence Medium
MFT record intact and every extent reads back, but the record is deleted: the clusters are free and may since have been reallocated to another file
checks
[ ] mft_entry_in_use record is marked deleted; its clusters are free and may have been reallocated
[ok] run_list_complete 1 run(s) decoded to the declared end of the file
[ok] allocation_covers_size 45 byte(s) mapped for a 45 byte file
[ok] extents_readable 1 extent(s) sampled and readable
Three of four checks passed. The one that did not is the one that matters.
Note the path: this file kept its own name but not its directory, because the
parent directory’s MFT record has been reused. It reports <unknown> rather
than inventing a plausible path.
Checks currently emitted: mft_entry_in_use · run_list_complete ·
allocation_covers_size · no_sparse_holes · extents_within_source ·
extents_readable · data_resident · data_uncompressed · data_unencrypted ·
sparse_flag · inode_linked · extents_cover_size · extent_tree_intact ·
name_from_live_directory · inode_is_current · signature_matched ·
footer_found · within_size_cap · original_metadata ·
contiguity_verified · printable_run.
CLI reference
The examples below run against the synthetic images checked into
test-fixtures/, so they reproduce exactly from a clean checkout.
scan
Filesystem-aware recovery, optionally plus carving.
arachnid-recover scan \
--input test-fixtures/ntfs-deleted.img \
--carve-pass --carve-types jpg,png,pdf,zip \
--output ./rec \
--include-live
Scanning test-fixtures/ntfs-deleted.img (131072 bytes)…
Arachnid Recover — scan summary
===============================
Source test-fixtures/ntfs-deleted.img
Size 131072 bytes
Operator analyst-7@linux
Started 2026-08-29T09:16:35.32200519Z
Finished 2026-08-29T09:16:35.331266775Z
Passes filesystem + raw carving
Filesystems
ntfs at offset 0 — 3 entries
Results 5 file(s)
High 1
Medium 2
Low 2
High filesystem metadata intact, every allocated byte read back
Medium filesystem metadata found, data partly overwritten or truncated
Low raw-carved: structurally valid, completeness unverified
Results index: ./rec/results.json
Summary: ./rec/summary.txt
Nothing has been written to the source. To write the recovered files out:
arachnid-recover export -i ./rec/results.json -o <DIR> --confidence high,medium
| Flag | Does |
|---|---|
-i, --input |
image file, or a device path (/dev/sdb, \\.\PhysicalDrive2) |
-o, --output |
where results.json and summary.txt go |
--filesystem-pass |
on by default; accepted so a scripted run can state its intent |
--no-filesystem-pass |
skip it. carve is the shorter way to say the same thing |
--carve-pass |
adds carving to the filesystem pass; does not replace it |
--carve-types |
comma-separated. Default: every type except txt |
--include-live |
also report files the filesystem still considers live. Off by default: live files are readable through the OS, and including them buries the deleted ones |
--operator |
identity recorded in the results |
carve
Signature carving alone, for media with no filesystem left to parse.
arachnid-recover carve -i /dev/sdb --carve-types jpg,pdf,docx -o ./rec-carved
list-results
arachnid-recover list-results --input ./rec/results.json
ID CONF TYPE SIZE METHOD NAME / PATH
ntfs-000017 High pdf 37 NTFS MFT Cases/quarterly.pdf
ntfs-000018 Medium jpg 206 NTFS MFT Cases/evidence-photo.jpg
ntfs-000019 Medium txt 45 NTFS MFT <unknown>/orphan.txt
carve-000000 Low jpg 206 carved carve-000000-at-90112.jpg
carve-000001 Low pdf 36 carved carve-000001-at-81920.pdf
5 of 5 result(s). Use --detail <ID> for the scoring rationale.
ntfs-000018 and carve-000000 are the same 206 bytes on disk, found twice.
The filesystem pass knows it was Cases/evidence-photo.jpg; the carver knows
only that a JPEG starts at offset 90112. That is the whole point of the two
passes, visible in one table.
| Flag | Does |
|---|---|
--confidence |
keep only these levels: high, medium, low |
--type |
keep only these file types or artifact classes: pdf, sqlite … or call-log, browser-history, system-log |
--detail <ID> |
print the full scoring rationale for one result |
export
arachnid-recover export -i ./rec/results.json -o ./rec/exported --confidence high,medium
Exported 3 file(s) to ./rec/exported
Chain of custody: ./rec/exported/custody.log
Signing key SHA-256: 9d54b3f24faaba5ac128560f12e42627d61b9be66dd7df31ec1f6d06fd48b672
Record that fingerprint out of band. Re-check the export at any time with:
arachnid-core verify ./rec/exported
| Flag | Does |
|---|---|
--confidence / --type |
which results to write; --type takes file types and artifact classes alike |
--id |
export these result ids only; overrides the filters |
--source |
read from this image instead of the one recorded in the results, for when the image has moved |
--operator |
defaults to the operator recorded in the results |
Export and chain of custody
An export is not a folder of loose files. Every exported file is hashed as it is written and its digest goes into the same signed, hash-chained custody log a triage collection uses:
./rec/exported/
manifest.json
custody.log
artifacts/
results.json the index this export was selected from
export-summary.txt
recovered/Cases/quarterly.pdf filesystem-recovered: original structure
recovered/Cases/evidence-photo.jpg
recovered/_unknown_/orphan.txt path was unrecoverable, and it says so
carved/carve-000000-at-90112.jpg carved: flat, named after where it was found
Filesystem-recovered files keep their directory structure under recovered/.
Carved files go flat into carved/, because they have no structure to keep and
mixing them would imply one.
The results index goes in first, so the custody log records what the export was selected from before it records what came out.
arachnid-core verify ./rec/exported
VERIFIED: every artifact matches the signed custody log.
This confirms the container is internally consistent. It is only proof of
origin if the key fingerprint above matches the one recorded at collection.
Paths are hostile input
An original path comes out of the filesystem under examination, which on a
compromised host is attacker-controlled. A path of
../../../../etc/cron.d/backdoor must land inside the output directory or
nowhere. Every component is reduced before it becomes a path:
..,., empty components and absolute roots are dropped — not popped, which would let a crafted path climb out one component at a time- Windows drive prefixes (
C:) are dropped - a NUL byte refuses the whole path outright
- reserved characters become
_; control bytes are removed; Windows reserved device names (CON,NUL,LPT1, …) are prefixed; components are capped at 200 characters - a path that reduces to nothing is reported as skipped, never written
Short files are kept
When the media will not return a file’s whole allocation, what was read is still
written and the shortfall is recorded in export-summary.txt. A partial document
is evidence; deleting it for being incomplete would destroy what the recovery
found.
The safety rails
1. Never writes to the source. Structural — see Read-only, structurally.
2. Recovery output must not land on the device being recovered from. This is
the mistake that quietly destroys a case: every byte written there lands in the
unallocated space the recovery is reading out of. On Linux this is proven from
/proc/mounts and refused:
REFUSED: the output directory /mnt/case/out is on /dev/sdb1, mounted at
/mnt/case, which is part of the device being recovered from. Writing there
would overwrite the unallocated space this recovery reads out of. Choose an
output directory on different media.
On other platforms it cannot be proven cheaply, so the risk is stated loudly instead. Refusing on a guess would block legitimate work; staying silent would let a real one through.
3. An image that does not match the scan is refused at export. Every offset in a results index is relative to one specific source. Export from a different one and the bytes written are unrelated data — hashed into a custody log, filed under a recovered file’s name, and indistinguishable from a real recovery afterwards.
Size is checked first because it is free. Then source_fingerprint, a SHA-256
over the size and three 4 KiB samples — head, middle and tail — because two
images of the same size collide trivially. (The two images in test-fixtures/
are both exactly 131072 bytes, which is how the size-only check was caught; there
is a regression test named after it.)
REFUSED: this is not the source the scan read: fingerprint 4a6fe2b4… does not
match the recorded becefce9…. The two are the same size, which is exactly why
size alone is not enough. Refusing to export.
The refusal happens before the output directory is created, so a rejected export leaves nothing behind. An index carrying no fingerprint — one written before the field existed — still exports, with the caveat written into the custody log:
source identity NOT verified: the results carry no source fingerprint, so this
export could only confirm the size matches — not that this is the same media the
scan read
Whoever reads the container later is the person who needs to know that, so it goes somewhere permanent rather than only onto the operator’s terminal.
4. Encrypted files are reported, not attacked. See Not in scope.
The TUI Recover screen
Screen 8 in arachnid-tui. Five steps, in order:
- Source — an image path, a device from the read-only device list (the same enumeration Sanitize uses, opened without write access), or an artifact out of a prior Core evidence container.
- Configuration — which passes, which carve types, whether to include live files, and where the results index goes.
- Progress — phase, filesystems found, files found, and a carving progress bar. Runs on its own thread and survives navigating away: carving a full disk is an hours-long read.
- Results — a filterable table.
ccycles the confidence filter,tcycles the file types actually present. - Export — an output directory and a confidence threshold, defaulting to
Mediumand better.
| Key | Does |
|---|---|
j / k |
move |
Enter |
select, or edit a field |
Space |
toggle a pass or a carve type |
r |
reload the device list, or re-read a container’s artifact list |
s |
start the scan |
c / t |
filter results by confidence / type |
e |
export |
x |
cancel a running scan |
The results browser shows the confidence label on every row and the checks behind the selected row in a pane beside it — not behind a drill-down. A recovered file looks identical in a folder whether the filesystem handed over its name or a carver found its bytes, so the screen never shows the file without the claim.
The container source reads artifact names out of the custody log with
read_log, which deliberately does not verify signatures. It is a file
picker; presenting it as though the log had been checked would be a lie.
Verification is screen 5’s job, on the same path.
Exit codes
| Code | Meaning |
|---|---|
0 |
success |
1 |
runtime error |
2 |
usage error |
3 |
refused by a safety rail |
4 |
completed, but something was skipped or unsupported |
4 is not a failure. The scan finished and left something out — an unsupported
filesystem feature, an extent that would not read, a cancelled pass — and the
results index names each one:
arachnid-recover scan -i "$IMAGE" -o "$REC" --carve-pass
case $? in
0) echo "scan complete" ;;
3) echo "REFUSED — check the output directory is not on the source"; exit 3 ;;
4) jq -r '.filesystems[].unsupported[]?, .problems[]?' "$REC/results.json" ;;
*) echo "scan failed"; exit 1 ;;
esac
Results schema
results.json carries its own schema_version, moving independently of the
container format. A worked sample — regenerated from the checked-in fixtures
rather than hand-written, so it cannot drift from real output — lives at
schema/samples/recovery-results.json and
schema/samples/recovery-summary.txt.
Top level: schema_version · tool · tool_version · source ·
source_size · source_fingerprint · started_utc · finished_utc ·
operator · filesystem_pass · carve_pass · carve_types · filesystems ·
files · problems.
source_fingerprint identifies the media the offsets in files are relative
to; see The safety rails. It defaults to empty so an older
index still loads — a consumer that finds it empty must report that the check
did not run, never that it passed.
Each entry in files: id · method · original_path (absent for carved
results — it does not exist, and none is invented) · export_name ·
file_type · size · extents · created_utc / modified_utc /
accessed_utc · deleted · encrypted · rationale.
Each entry in filesystems: kind · offset · entries · unsupported ·
notes.
Regenerate the sample after changing a parser or the schema:
cargo test -p arachnid-recover-core --test fixture -- --ignored
Not in scope
- No decryption, key recovery, password guessing or brute force.
EFS-encrypted
$DATA, ext4 per-file encryption and FileVault volumes are identified and reported as encrypted, and recovery stops there. Nothing in this module attempts to get at the plaintext, and nothing will be added that does. - No write-back to source media, under any circumstance.
- No network or remote recovery. Local operator, local image or device — the same threat model as the rest of the suite.
- No proprietary or undocumented filesystems. NTFS, ext4, and best-effort APFS identification. A filesystem this build does not parse is reported as unidentified, not partially supported.
- No partition table parsing. Filesystems are probed at three fixed offsets: 0, 1 MiB, and 63 sectors. Those cover a bare partition image and both mainstream alignment conventions. An image whose volumes start elsewhere needs the partition imaged directly, or the carving pass, which needs no filesystem.