Skip to content

daxfs: Discard overlay data on truncate - #16

Open
congwang-mk wants to merge 1 commit into
mainfrom
fix-truncate-stale-data
Open

daxfs: Discard overlay data on truncate#16
congwang-mk wants to merge 1 commit into
mainfrom
fix-truncate-stale-data

Conversation

@congwang-mk

Copy link
Copy Markdown
Contributor

Problem

Truncating a file left its overlay pages in place. Nothing erased them from the per-inode xarray or the DAX overlay hash, so extending the file again handed back the old contents instead of zeros.

Reproduced on a 40 CPU host, empty-mode dma-buf mount:

write 8192 bytes of 'A'
truncate to 0
write 1 byte at offset 65536
read offset 0     ->  b'AAAAAAAAAAAAAAAA'      (must be zeros)

The partial-page case fails the same way: truncate to 4 bytes and the remaining 4092 bytes of that page come back on re-extend.

POSIX requires the extended range to read as zeros. For a filesystem built around a shared base image with per-container overlays, data surviving a truncate is a disclosure bug rather than only a correctness one.

daxfs_setattr() called truncate_setsize() and updated oie->size, and that was all. overlay_pool_free() is never reached from the DATA path except for the publish-race case at overlay.c:599, so the pages were also leaked.

Fix

Zero the overlay pages from the new size up to the old end of file, including the tail of a partial page at the boundary.

The pages are kept mapped rather than freed. Two reasons:

  • Freeing means deleting keys from the open-addressed overlay hash. DAXFS_OVL_STATE() is a single bit with the key in the remaining 63, so there is no room for a tombstone state, and marking a bucket FREE would truncate the probe chain for every key that probed past it. Adding tombstones is a format change.
  • For an inode with base image data behind it, removing the overlay page would re-expose the base contents rather than zeros. Zeroing is correct for both the overlay-only and base-backed cases.

Keeping the pages also leaves them ready for a re-extend, which is the common truncate-then-rewrite pattern. The pool space is not reclaimed, which matches the existing behaviour for every other data page.

The old size is captured before any field is overwritten, and taken as the larger of the cached i_size and the overlay entry's size, so pages another host wrote past our stale i_size are discarded too.

Zeroing runs after truncate_setsize(), so a concurrent reader sees the smaller i_size before the tail starts changing rather than after.

Testing

New regression test in tests/test_overlay.sh covering three cases: the full-page case, the partial-page tail, and that data below the truncation point survives (so the test cannot pass by zeroing too much).

Verified it actually catches the bug rather than passing vacuously:

without the fix:   FAIL: Truncate zeroes
                         stale data after truncate: 'AAAAAAAAAAAAAAAA'
                   Tests run: 21, passed: 20, failed: 1

with the fix:      PASS: Truncate discards data (no stale bytes on re-extend)
                   Tests run: 21, passed: 21, failed: 0

Also re-checked after the change: 256 MiB random payload round-trips with a matching md5, and no new dmesg warnings.

Cost

Truncating to zero now memsets the overlay allocation, so a 2 GiB file costs a 2 GiB memset, roughly 0.2 s at 10 GB/s. That is inherent to zeroing in place rather than freeing; reclaiming the pages instead would need the tombstone format change described above.

Truncating a file left its overlay pages in place. Nothing erased them
from the per-inode xarray or the DAX hash, so extending the file again
handed back the old contents instead of zeros:

  write 8192 bytes of 'A'
  truncate to 0
  write 1 byte at offset 65536
  read offset 0     ->  'AAAAAAAAAAAAAAAA'   (must be zeros)

POSIX requires the extended range to read as zeros, and for a filesystem
built around a shared base image with per-container overlays, data
surviving a truncate is a disclosure bug rather than only a correctness
one.

Zero the overlay pages from the new size up to the old end of file,
including the tail of a partial page at the boundary. The pages are kept
mapped rather than freed: dropping them would mean deleting keys from
the open-addressed overlay hash, which has a single state bit and no
room for tombstones, and for an inode with base image data behind it
removing the overlay page would re-expose the base contents instead of
zeros. Zeroing is correct in both cases and leaves the pages ready for a
re-extend, which is the common truncate-then-rewrite pattern.

The old size is captured before any field is overwritten, and taken as
the larger of the cached i_size and the overlay entry, so pages another
host wrote past our stale i_size are discarded too.

Add a regression test covering the full-page case, the partial-page tail
and that data below the truncation point survives. It fails without the
fix and passes with it; the suite is 21/21 on a 40 CPU host.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant