NTFS3 file system kernel mode driver
 help / color / mirror / Atom feed
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