From: Senjin <senjin@hatchling.org>
To: Konstantin Komarov <almaz.alexandrovich@paragon-software.com>
Cc: Senjin <senjin@hatchling.org>,
ntfs3@lists.linux.dev, linux-fsdevel@vger.kernel.org,
stable@vger.kernel.org
Subject: [PATCH] fs/ntfs3: fix SEEK_DATA/SEEK_HOLE on externally compressed (WOF) files
Date: 24 Oct 2026 11:26:52 -0000 [thread overview]
Message-ID: <6adc95fced5aa4bf9555fe31@mailersend.net> (raw)
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
next reply other threads:[~2026-10-24 11:27 UTC|newest]
Thread overview: 2+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-24 11:26 Senjin [this message]
-- strict thread matches above, loose matches on Subject: below --
2026-10-24 11:26 [PATCH] fs/ntfs3: fix SEEK_DATA/SEEK_HOLE on externally compressed (WOF) files Senjin
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=6adc95fced5aa4bf9555fe31@mailersend.net \
--to=senjin@hatchling.org \
--cc=almaz.alexandrovich@paragon-software.com \
--cc=linux-fsdevel@vger.kernel.org \
--cc=ntfs3@lists.linux.dev \
--cc=stable@vger.kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.