Skip to content

File Reconstruction

New in 0.8.0

The Files section of Triage tells you which calls a run made on a file. In 0.8.0 it can also tell you what the file’s bytes were: what the run wrote, what the file held before the run touched it, and, call by call, what each byte was at every point of the run. Nothing is invented: a byte nobody saw stays unknown, and every missing range says why.

This page covers the four parts that make that work, then the File Reconstruction window that puts them together.

Triage › Files › Per-file history: the line Written content: 2 bytes kept (caps 67108864 bytes (64.0 MB) per file, 1073741824 bytes (1.0 GB) per run; change in Settings › Advanced); a file list with /tmp/analysis-note.txt; its history graph open → read ×2 → write; All calls with open, two reads at offsets 0x0 and 0x5 and a pwrite64 at 0x1; Content changes with Size 5 B → 5 B, 2 bytes differ, 0x00000001 65 6c → 58 59 |XY|; and a Reconstruct contents… button.
Per-file history: the written content kept, the calls with their offsets, and Reconstruct contents… Antinomie 0.8.0, synthetic data.

Triage › Files › Per-file history, the first Files tab, lists every file the run touched. Choose a file to see its history as a graph of calls, the calls themselves with their Offset, Length and the first 64 bytes of Content, and Content changes: the file’s size before and after, the bytes that differ between what the run saw first and last, and the guest’s SHA-256 when it took one. In Changed files, the Per-file history button in Details opens this tab on the selected file. See Triage files for the tab itself.

Every write the guest kernel captured is indexed per file as a byte range (offset and length) that reads back exactly from the run’s recording, however many writes the file took. The list of calls shown per file is bounded; the written content is not, apart from two caps:

Cap Default
Per file 64 MiB
Per run 1 GiB (1024 MiB)

Past a cap, the first writes are kept and the later ranges are listed as not captured, never silently dropped. The tab’s header line says how much was kept and what the caps are, for example Written content: 2 bytes kept (caps … per file, … per run; change in Settings › Advanced). Each file shows its own Written content: line and up to eight ranges that were not kept, as not captured: N B at offset X (reason).

Settings › Advanced: Show FPS counter; File activity: written content kept, 64 MiB per file and 1024 MiB per run, with an explanation; and File activity: earlier bytes kept, 64 MiB per file and 1024 MiB per run, with an explanation; a note at the bottom says A VM is running: Recording applies after it stops.
Both pairs of caps are in Settings › Advanced. Antinomie 0.8.0, synthetic data.

Change the caps in Settings › Advanced › File activity: written content kept. The recording itself is still limited by the Recording quota.

Reason Meaning
past the per-file cap, past the per-run cap The write was captured, but the cap was already reached
not recorded by the kernel The guest did not capture the write’s data
at an unknown offset The host could not tell where in the file the write landed (an append or a shared descriptor without a position record, on an older base)
written through a mapping, past the guest’s copy budget Pages of a shared mapping beyond the 4 MiB the guest copies per call

Earlier bytes: what the file held before the run

Section titled “Earlier bytes: what the file held before the run”

The trace records what a run wrote, not what it overwrote. For a file that existed when the run started, Antinomie reads its earlier bytes from the disk the run started from.

  • At every boot, the disk the VM was left in is sealed under a fresh, empty layer. This is instant and copies nothing, and the boot cannot overwrite the sealed disk. So even a plain restart of a used VM has a readable starting state, like a new VM.
  • While the boot runs, a changed file’s earlier bytes are read on demand from that starting disk (the installed base, any checkpoint and the sealed layer), with the sandboxed disk reader over the qcow2 chain. Nothing is mounted and nothing is copied into the recording.
  • When the boot is over and its recording is saved, the earlier bytes of only the files the runs changed are copied into the recording (file-before/), and the sealed layer is merged back down at once. From then on the earlier bytes read from the recording alone, even after the base or the checkpoint is deleted.

The sealed layer is not a snapshot: it never appears in Snapshots, and your snapshots are never merged or changed. There is at most one extra layer, and only while the VM runs. If the app or the machine stops mid-run, the VM still boots, and the next start of the VM or of the app finishes the copy and the merge. Several runs in one boot share the boot’s starting state.

A file is found on the starting disk by its path and, when the guest recorded its inode, by inode, generation, size, owner and time too, so a name that another file took over is not mistaken for it. The file’s header in the Per-file history says what was found, for example:

  • Earlier bytes: none, the run created this file (it started empty)
  • Earlier bytes: 5 B on the starting disk, read when asked (the file's inode was checked against the disk)
  • Earlier bytes: 5 B kept in this recording (matched by path only: the guest recorded no inode)

The copy into the recording has its own caps, Settings › Advanced › File activity: earlier bytes kept (64 MiB per file and 1024 MiB per run by default). It is not counted in the Recording quota. A file over a cap keeps its size and SHA-256 and is listed as not captured.

The header says Why
not on the starting disk: … The file is on tmpfs (such as /dev/shm, /run, or /tmp where it is a RAM disk) or another mount, or the name belongs to another file on the disk
not offered, the file was … (limit 1 GiB) The file is over 1 GiB
not captured, the file was … (…) The file is over an earlier-bytes cap; its size and SHA-256 are kept
unavailable: a snapshot restore replaced the disk while the run was using this file The disk under the file changed during the run
unavailable: the run's starting disk was not recorded … A recording made before Antinomie kept the starting disk, or a boot that could not save it
unavailable: not looked up: only the first 300 changed files are Only the first 300 changed files of a run are looked up

With a base built for 0.8.0 (guest contract revision 18), each read and write of a regular file carries the position the file system used, and each write the order in which the file system applied it. As a result:

  • an O_APPEND write, or a write through a descriptor shared with another process (after fork or dup), lands at its real offset instead of at an unknown one;
  • when two processes write the same range at once, the bytes of the write applied last win, as they did in the guest, whatever order the calls returned in.

An older base keeps working without these records; such writes are then counted at an unknown offset.

sendfile, splice, copy_file_range, a reflink clone (ioctl FICLONE or FICLONERANGE) and fallocate move or change bytes inside the kernel. With a revision-18 guest, each records its operands: both files, both offsets, the length, and the destination’s size after the call. Such a call changes only its own range of the destination. The bytes a splice from a pipe put into a file cannot be read again, so the guest sends them along (up to 1 MiB per call), and they show as written content.

A program that stores into a MAP_SHARED mapping of a file makes no system call. With a revision-18 guest, the pages it changed appear in the file’s history and its written content, marked through a shared mapping, with their bytes, when msync (or fsync, sync) wrote them back, or when munmap, an exec or the program’s exit dropped them while dirty.

Each page is known as it was at that moment, not every store before it. Pages beyond the guest’s 4 MiB copy budget per call are listed as not captured, with the reason. The guest’s hash at the file’s last close covers the final content. Not seen: a page the kernel’s background writeback cleaned while the mapping stayed and that was not stored to again, and mappings of the kernel’s own files (shared anonymous memory, System V shared memory, memfd).

  1. Open Triage › Files › Per-file history and choose a file.
  2. Press Reconstruct contents… under Content changes. You can also right-click a row in any Files tab and choose Reconstruct contents….
  3. The File reconstruction window opens on that file.

The window replays the file’s reads, writes, resizes and zeroed ranges in the order the file system applied them, over what the file held when the run started. The header shows the path, any other names (also seen as …), whether the run created or deleted the file, the size after the chosen call, and the earlier-bytes line.

Use the slider, ◀ and ▶, or the arrow keys, to move from Start (the file before any call) to End. The line beside them reads Call N of M and describes the chosen call, for example wrote 2 B at 0x1 or resized the file to 5 B.

The map shows the file 16 bytes per row, as hex and ASCII. Each byte is shown as it is known at the chosen call; the legend above the map counts the bytes in each state, and hovering a legend entry names it:

In the map (legend) State Meaning
?? never seen No call showed this byte
The value, tinted (rd) seen in a read A read showed the byte as it was then
The value, tinted (bf) from the starting disk The earlier bytes the file held when the run started
The value, tinted (wr) written by the run A write with captured data
## changed, new value not captured A call changed the byte, but its new value was not captured
~~ may have changed through a shared mapping A shared writable mapping covers the byte

A byte that a later read or write shows takes that value. Under a shared writable mapping, the pages the guest copied replace ~~ with their bytes, and a page it never saw written stays ~~.

Click a byte for its Byte history: each state it went through and the time each was learned. What this call changed lists the ranges the chosen call changed. Text diff shows the lines a text file gained or lost since the start of the run, when every byte is known (files up to 1 MiB).

Hash this state takes the SHA-256 of the reconstruction at the chosen call, when every byte is known, and compares it with each hash the guest took of the file. Go to the last hash moves to the point where the guest took its last hash. A match says the reconstruction is byte-exact. A hash cannot be taken while bytes have no value (??, ##, ~~), while the size is not known, or when some earlier bytes could not be read back because the starting disk is gone.

  • Save reconstruction… writes the file as reconstructed to reconstructed/ in the recording, with unknown bytes as 00, and a .mask file beside it with one byte per file byte: 00 known, 01 unknown, 02 not captured, 03 mapping.
  • Copy hex copies the byte map as hex (the first 256 KiB).
  • Show call in Trace opens the original syscall of the chosen call.

The File activity and reconstruction section of Export report… lists every created or changed file with its final size, the guest’s SHA-256, whether its reconstruction is complete, and the bytes written, not captured and unknown.