* [PATCH] fs/ntfs3: fix SEEK_DATA/SEEK_HOLE on externally compressed (WOF) files
@ 2026-10-24 11:26 Senjin
0 siblings, 0 replies; only message in thread
From: Senjin @ 2026-10-24 11:26 UTC (permalink / raw)
To: Konstantin Komarov; +Cc: Senjin, ntfs3, linux-fsdevel, stable
ni_seek_data_or_hole() only treats a sparse cluster run as still
containing data when the file uses classic NTFS compression
(FILE_ATTRIBUTE_COMPRESSED, LZNT1) and the run isn't aligned to a
NTFS_LZNT_CLUSTERS (16 cluster) boundary. It never checks whether the
file is externally compressed (WOF, reparse point, Xpress/LZX,
NI_FLAG_COMPRESSED_MASK).
For an externally compressed file, the main $DATA attribute's run list
is not frame-compressed in place like LZNT1: it is one continuous
sparse run end to end. The actual compressed bytes live in a separate
named attribute ("WofCompressedData", see ni_read_frame() and
ni_decompress_file()), and ni_read_folio_cmpr()/ni_read_frame()
reconstruct every logical byte of the file on a normal read. There is
no real hole anywhere in such a file.
Because ni_seek_data_or_hole() never recognised this, SEEK_DATA on an
externally compressed file walks its one sparse run straight to
EOF_LCN and returns -ENXIO for the entire file, as if it were empty.
This silently corrupts anything that trusts SEEK_DATA/SEEK_HOLE to
decide what to copy. cp(1) on a modern coreutils probes the source
file with lseek(SEEK_DATA) before falling back to a plain read loop;
when that probe comes back -ENXIO at offset 0, cp treats the whole
file as a hole, punches a hole of the same size in the destination
with fallocate(FALLOC_FL_PUNCH_HOLE), and never calls read() at all.
The result is a destination file of the right size that is entirely
zeroed, copied without any error being reported anywhere.
Confirmed with strace: cp of a WOF-compressed file does exactly this,
producing an all-zero copy every time, unconditionally (no large-folio
readahead trigger needed, unlike the page-mapping bug fixed earlier in
this series). A plain read() of the same file, e.g. via Python's
open().read(), decompresses correctly throughout.
This is not a new class of problem for this driver: ntfs_fiemap()
already special-cases is_compressed() and refuses fiemap outright for
any compressed file, with a comment noting that "cp -r incorrectly
treats compressed clusters". bmap()/FIBMAP is also unreachable for
compressed files, since they use a separate address_space_operations
table (ntfs_aops_cmpr) that has no .bmap. SEEK_DATA/SEEK_HOLE was the
one remaining place that exposes the main attribute's raw run-list
layout to userspace without either accounting for external compression
or refusing outright, which is how this went unnoticed until a plain
cp hit it.
Treat an externally compressed file as having no holes: SEEK_DATA
returns the requested offset whenever it is inside i_size, and
SEEK_HOLE returns i_size (the implicit hole at EOF, same as for any
other file). This mirrors how the already-existing LZNT1 case is
handled, just without pretending the WOF run list carries any
frame-aligned structure it doesn't have.
Fixes: c61326967728 ("fs/ntfs3: implement llseek SEEK_DATA/SEEK_HOLE by scanning data runs")
Cc: stable@vger.kernel.org # 7.0+
Signed-off-by: Senjin <senjin@hatchling.org>
---
fs/ntfs3/frecord.c | 33 ++++++++++++++++++++++++++++-----
1 file changed, 28 insertions(+), 5 deletions(-)
diff --git a/fs/ntfs3/frecord.c b/fs/ntfs3/frecord.c
index a8b696d..d77465d 100644
--- a/fs/ntfs3/frecord.c
+++ b/fs/ntfs3/frecord.c
@@ -2910,9 +2910,32 @@ loff_t ni_seek_data_or_hole(struct ntfs_inode *ni, loff_t offset, bool data)
{
int err;
u8 cluster_bits = ni->mi.sbi->cluster_bits;
- CLST vcn, lcn, clen;
+ CLST vcn, lcn, clen, frame_clusters = 0;
loff_t vbo;
+ if (ni->ni_flags & NI_FLAG_COMPRESSED_MASK) {
+ /*
+ * Xpress or LZX (WOF). The main data attribute's run list is
+ * entirely sparse: the real, compressed bytes live in a
+ * separate named attribute (see ni_read_frame()) and every
+ * logical byte in [0, i_size) is reconstructed on read. There
+ * is no hole to find here.
+ */
+ loff_t i_size = i_size_read(&ni->vfs_inode);
+
+ if (data)
+ return offset < i_size ? offset : -ENXIO;
+
+ return i_size;
+ }
+
+ if (ni->std_fa & FILE_ATTRIBUTE_COMPRESSED) {
+ /* LZNT compression: compressed frames are embedded directly
+ * in the main data attribute's own run list.
+ */
+ frame_clusters = NTFS_LZNT_CLUSTERS;
+ }
+
/* Enumerate all fragments. */
for (vcn = offset >> cluster_bits;; vcn += clen) {
err = attr_data_get_block(ni, vcn, 1, &lcn, &clen, NULL, false,
@@ -2960,8 +2983,8 @@ loff_t ni_seek_data_or_hole(struct ntfs_inode *ni, loff_t offset, bool data)
break;
}
- if ((ni->std_fa & FILE_ATTRIBUTE_COMPRESSED) &&
- (vcn & (NTFS_LZNT_CLUSTERS - 1))) {
+ if (frame_clusters &&
+ (vcn & (frame_clusters - 1))) {
/* Compressed cluster in compressed frame. */
break;
}
@@ -2975,8 +2998,8 @@ loff_t ni_seek_data_or_hole(struct ntfs_inode *ni, loff_t offset, bool data)
*/
if (lcn == SPARSE_LCN &&
/* native compression hole begins at aligned vcn. */
- (!(ni->std_fa & FILE_ATTRIBUTE_COMPRESSED) ||
- !(vcn & (NTFS_LZNT_CLUSTERS - 1)))) {
+ (!frame_clusters ||
+ !(vcn & (frame_clusters - 1)))) {
/* Hole in sparsed or compressed file frame. */
break;
}
--
2.56.0
^ permalink raw reply related [flat|nested] only message in thread
only message in thread, other threads:[~2026-10-24 11:27 UTC | newest]
Thread overview: (only message) (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-24 11:26 [PATCH] fs/ntfs3: fix SEEK_DATA/SEEK_HOLE on externally compressed (WOF) files Senjin
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox