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: <6adc95fc26efdd1658b01fc5@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
reply other threads:[~2026-10-24 11:27 UTC|newest]
Thread overview: [no followups] expand[flat|nested] mbox.gz Atom feed
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=6adc95fc26efdd1658b01fc5@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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox