FS/XFS testing framework
 help / color / mirror / Atom feed
From: Zorro Lang <zlang@kernel.org>
To: Rochan Avlur <rochan.avlur@gmail.com>
Cc: fstests@vger.kernel.org, linkinjeon@kernel.org, Yuezhang.Mo@sony.com
Subject: Re: [PATCH] exfat/001: test that rename and move preserve benign secondary entries
Date: Thu, 13 Aug 2026 02:06:35 +0800	[thread overview]
Message-ID: <any03ucw8qjY7rIR@zlang-mailbox> (raw)
In-Reply-To: <20260723041155.20740-1-rochan.avlur@gmail.com>

On Wed, Jul 22, 2026 at 09:11:55PM -0700, Rochan Avlur wrote:
> Verify that unrecognized benign secondary directory entries (such as
> vendor extensions) survive same-directory rename, cross-directory move,
> in-place rename to a shorter name, rename overwriting an existing file,
> and operations on files with multiple benign entries.
> 
> exFAT spec section 8.2 [1] requires implementations to preserve benign
> secondary entries they do not recognize.  This is a regression test for
> kernel commit 8258ef28001a ("exfat: handle unreconized benign secondary
> entries") which incorrectly freed benign entry clusters in the rename
> and move paths.
> 
> [1]: https://learn.microsoft.com/en-us/windows/win32/fileio/exfat-specification#8-implementation-notes
> 
> Signed-off-by: Rochan Avlur <rochan.avlur@gmail.com>
> ---
>  src/Makefile                   |   3 +-
>  src/exfat_inject_vendor_ext.py | 155 ++++++++++++++++++++++++++++++
>  src/exfat_verify_vendor_ext.py |  62 ++++++++++++
>  tests/exfat/001                | 171 +++++++++++++++++++++++++++++++++
>  tests/exfat/001.out            |   6 ++
>  tests/exfat/Makefile           |  23 +++++
>  6 files changed, 419 insertions(+), 1 deletion(-)
>  create mode 100755 src/exfat_inject_vendor_ext.py
>  create mode 100755 src/exfat_verify_vendor_ext.py
>  create mode 100755 tests/exfat/001
>  create mode 100644 tests/exfat/001.out
>  create mode 100644 tests/exfat/Makefile
> 
> diff --git a/src/Makefile b/src/Makefile
> index 31ac43b2..a42cf96f 100644
> --- a/src/Makefile
> +++ b/src/Makefile
> @@ -40,7 +40,8 @@ LINUX_TARGETS = xfsctl bstat t_mtab getdevicesize preallo_rw_pattern_reader \
>  
>  EXTRA_EXECS = dmerror fill2attr fill2fs fill2fs_check scaleread.sh \
>  	      btrfs_crc32c_forged_name.py popdir.pl popattr.py \
> -	      soak_duration.awk parse-dev-tree.awk parse-free-space.py
> +	      soak_duration.awk parse-dev-tree.awk parse-free-space.py \
> +	      exfat_inject_vendor_ext.py exfat_verify_vendor_ext.py
>  
>  SUBDIRS = log-writes perf
>  
> diff --git a/src/exfat_inject_vendor_ext.py b/src/exfat_inject_vendor_ext.py
> new file mode 100755
> index 00000000..81350da8
> --- /dev/null
> +++ b/src/exfat_inject_vendor_ext.py
> @@ -0,0 +1,155 @@
> +#!/usr/bin/python3
> +# SPDX-License-Identifier: GPL-2.0
> +#
> +# Inject vendor extension (0xE0) benign secondary entries into an exFAT
> +# directory entry set on an unmounted filesystem image or block device.
> +#
> +# Usage: exfat_inject_vendor_ext.py <device> <marker> <filename> [count]
> +#
> +# Adds <count> (default 1) vendor_ext entries carrying <marker> into the
> +# entry set of <filename>.  The marker is stored in the custom-defined
> +# area so it can be verified after kernel operations.
> +#
> +# exFAT on-disk directory entry layout (each entry is 32 bytes):
> +#
> +#   Entry set for a file:
> +#     [0] File entry        (type 0x85)
> +#         byte 1: num_ext (number of secondary entries following)
> +#         bytes 2-3: entry set checksum (covers all entries, skipping these bytes)
> +#     [1] Stream extension  (type 0xC0)
> +#         byte 3: name_len (filename length in Unicode characters)
> +#         bytes 4-7: name_hash
> +#     [2..N] Filename entries (type 0xC1, each holds up to 15 UTF-16LE chars)
> +#         bytes 2-31: 15 UTF-16LE characters
> +#     [N+1..] Benign secondary entries (type 0xA0-0xFF, e.g. vendor_ext 0xE0)
> +#         Implementations MUST preserve these even if unrecognized (spec s8.2)
> +#
> +#   Vendor extension entry (type 0xE0):
> +#     byte 0: 0xE0 (entry type)
> +#     bytes 2-15: vendor GUID (we store our marker here)
> +#     bytes 18-31: vendor-defined data (we duplicate the marker here)
> +#
> +#   Free/unused directory slot: all 32 bytes are 0x00 (type byte == 0x00)
> +#
> +
> +import struct
> +import sys
> +
> +ENTRY_SIZE = 32
> +
> +# exFAT directory entry type codes
> +TYPE_FILE = 0x85
> +TYPE_STREAM = 0xC0
> +TYPE_FILENAME = 0xC1
> +TYPE_VENDOR_EXT = 0xE0
> +TYPE_FREE = 0x00
> +
> +# Offsets within specific entry types
> +FILE_NUM_EXT_OFF = 1        # byte offset of num_ext in file entry
> +FILE_CHECKSUM_OFF = 2       # byte offset of checksum in file entry
> +STREAM_NAME_LEN_OFF = 3    # byte offset of name_len in stream entry
> +FILENAME_CHARS_OFF = 2      # byte offset of first char in filename entry
> +CHARS_PER_NAME_ENTRY = 15   # UTF-16LE characters per filename entry
> +
> +
> +def find_entry_set(data, fname):
> +    """Scan raw data for a FILE+STREAM+NAME entry set matching fname."""
> +    pos = 0
> +    while pos < len(data) - ENTRY_SIZE * 3:
> +        if (data[pos] == TYPE_FILE and
> +                data[pos + ENTRY_SIZE] == TYPE_STREAM and
> +                data[pos + ENTRY_SIZE * 2] == TYPE_FILENAME):
> +            name_len = data[pos + ENTRY_SIZE + STREAM_NAME_LEN_OFF]
> +            num_name_entries = (name_len + CHARS_PER_NAME_ENTRY - 1) // \
> +                CHARS_PER_NAME_ENTRY
> +            chars = []
> +            for ne in range(num_name_entries):
> +                ne_off = pos + ENTRY_SIZE * (2 + ne)
> +                for c in range(CHARS_PER_NAME_ENTRY):
> +                    if len(chars) >= name_len:
> +                        break
> +                    ch = struct.unpack_from('<H', data,
> +                                           ne_off + FILENAME_CHARS_OFF +
> +                                           c * 2)[0]
> +                    chars.append(chr(ch))
> +            if ''.join(chars) == fname:
> +                return pos
> +        pos += ENTRY_SIZE
> +    return -1
> +
> +
> +def build_vendor_ext(marker_bytes, index):
> +    """Build a single vendor_ext directory entry with marker payload."""
> +    entry = bytearray(ENTRY_SIZE)
> +    entry[0] = TYPE_VENDOR_EXT
> +    tag = marker_bytes + struct.pack('B', index)
> +    # Store marker in both the GUID field (bytes 2-15) and
> +    # vendor-defined field (bytes 18-31)
> +    entry[2:2 + min(len(tag), 14)] = tag[:14]
> +    entry[18:18 + min(len(tag), 14)] = tag[:14]
> +    return entry
> +
> +
> +def update_checksum(data, file_off, num_ext):
> +    """Recompute entry set checksum (skip bytes 2-3 of file entry)."""
> +    es_len = ENTRY_SIZE * (1 + num_ext)
> +    es_data = data[file_off:file_off + es_len]
> +    chksum = 0
> +    for i in range(len(es_data)):
> +        if i == FILE_CHECKSUM_OFF or i == FILE_CHECKSUM_OFF + 1:
> +            continue
> +        chksum = (((chksum << 15) | (chksum >> 1)) + es_data[i]) & 0xFFFF
> +    return chksum
> +
> +
> +def main():
> +    if len(sys.argv) < 4:
> +        print("Usage: %s <device> <marker> <filename> [count]" % sys.argv[0],
> +              file=sys.stderr)
> +        sys.exit(1)
> +
> +    dev, marker_str, fname = sys.argv[1], sys.argv[2], sys.argv[3]
> +    count = int(sys.argv[4]) if len(sys.argv) > 4 else 1
> +    marker = marker_str.encode('ascii')
> +
> +    with open(dev, 'r+b') as f:
> +        data = bytearray(f.read())

It looks like exfat needs a tool like xfs_db or debugfs :) Reading the entire
disk into memory at once only works for tiny filesystem sizes, that is why
you chose a loop device over SCRATCH_DEV as the test device below.

For this specific exfat test case, I think this patch's logic is acceptable,
especially since it covers a known regression bug. If there are no further
review comments from exfat developers, I will merge this patch. More exfat
test coverage is always welcome.

Reviewed-by: Zorro Lang <zlang@kernel.org>

> +
> +    file_off = find_entry_set(data, fname)
> +    if file_off < 0:
> +        print("ERROR: could not find file '%s'" % fname, file=sys.stderr)
> +        sys.exit(1)
> +
> +    num_ext = data[file_off + FILE_NUM_EXT_OFF]
> +    es_end = file_off + ENTRY_SIZE * (1 + num_ext)
> +
> +    # Verify free slots exist after the entry set
> +    for i in range(count):
> +        slot = es_end + i * ENTRY_SIZE
> +        if slot + ENTRY_SIZE > len(data) or data[slot] != TYPE_FREE:
> +            print("ERROR: no free slot at offset %d for entry %d" %
> +                  (slot, i), file=sys.stderr)
> +            sys.exit(1)
> +
> +    # Write vendor_ext entries into the free slots
> +    for i in range(count):
> +        slot = es_end + i * ENTRY_SIZE
> +        entry = build_vendor_ext(marker, i)
> +        data[slot:slot + ENTRY_SIZE] = entry
> +
> +    # Update num_ext in the file entry and recompute checksum
> +    num_ext += count
> +    data[file_off + FILE_NUM_EXT_OFF] = num_ext
> +
> +    chksum = update_checksum(data, file_off, num_ext)
> +    struct.pack_into('<H', data, file_off + FILE_CHECKSUM_OFF, chksum)
> +
> +    with open(dev, 'r+b') as f:
> +        f.write(data)
> +
> +    print("Injected %d vendor_ext entries for '%s' at offset %d, num_ext=%d" %
> +          (count, fname, es_end, num_ext))
> +
> +
> +if __name__ == '__main__':
> +    main()
> diff --git a/src/exfat_verify_vendor_ext.py b/src/exfat_verify_vendor_ext.py
> new file mode 100755
> index 00000000..2d7eb048
> --- /dev/null
> +++ b/src/exfat_verify_vendor_ext.py
> @@ -0,0 +1,62 @@
> +#!/usr/bin/python3
> +# SPDX-License-Identifier: GPL-2.0
> +#
> +# Verify that vendor extension (0xE0) benign secondary entries with a
> +# given marker are present in an exFAT filesystem image or block device.
> +#
> +# Usage: exfat_verify_vendor_ext.py <device> <marker> [count]
> +#
> +# Scans for a live (non-deleted) FILE entry set containing <count>
> +# (default 1) vendor_ext entries with <marker>.  Prints "PASS" or "FAIL".
> +#
> +# See exfat_inject_vendor_ext.py for the on-disk layout description.
> +
> +import sys
> +
> +ENTRY_SIZE = 32
> +
> +# exFAT directory entry type codes
> +TYPE_FILE = 0x85
> +TYPE_VENDOR_EXT = 0xE0
> +
> +# Offsets within the file entry
> +FILE_NUM_EXT_OFF = 1
> +
> +
> +def main():
> +    if len(sys.argv) < 3:
> +        print("Usage: %s <device> <marker> [count]" % sys.argv[0],
> +              file=sys.stderr)
> +        sys.exit(1)
> +
> +    dev, marker_str = sys.argv[1], sys.argv[2]
> +    expected = int(sys.argv[3]) if len(sys.argv) > 3 else 1
> +    marker = marker_str.encode('ascii')
> +
> +    with open(dev, 'rb') as f:
> +        data = f.read()
> +
> +    # Walk directory entries looking for a FILE entry set that contains
> +    # the expected number of vendor_ext entries with our marker.
> +    for i in range(0, len(data) - ENTRY_SIZE, ENTRY_SIZE):
> +        if data[i] != TYPE_FILE:
> +            continue
> +        num_ext = data[i + FILE_NUM_EXT_OFF]
> +        found = 0
> +        for j in range(1, num_ext + 1):
> +            off = i + j * ENTRY_SIZE
> +            if off + ENTRY_SIZE > len(data):
> +                break
> +            if data[off] == TYPE_VENDOR_EXT and \
> +                    marker in data[off:off + ENTRY_SIZE]:
> +                found += 1
> +        if found >= expected:
> +            print("PASS")
> +            sys.exit(0)
> +
> +    print("FAIL")
> +    sys.exit(1)
> +
> +
> +if __name__ == '__main__':
> +    main()
> diff --git a/tests/exfat/001 b/tests/exfat/001
> new file mode 100755
> index 00000000..858e9750
> --- /dev/null
> +++ b/tests/exfat/001
> @@ -0,0 +1,171 @@
> +#! /bin/bash
> +# SPDX-License-Identifier: GPL-2.0
> +# Copyright (c) 2026 Rochan Avlur.  All Rights Reserved.
> +#
> +# FS QA Test No. exfat/001
> +#
> +# Verify that rename and cross-directory move do not destroy
> +# unrecognized benign secondary directory entries.  exFAT spec section 8.2
> +# requires implementations to preserve benign secondary entries they
> +# do not recognize.
> +#
> +# Regression test for kernel commit 8258ef28001a
> +# ("exfat: handle unreconized benign secondary entries") which added
> +# cluster freeing for benign secondary entries inside
> +# exfat_remove_entries(), but that function is also called by the
> +# rename and move paths where freeing is incorrect.
> +#
> +. ./common/preamble
> +_begin_fstest auto quick
> +
> +# Override the default cleanup function.
> +_cleanup()
> +{
> +	cd /
> +	_unmount $loop_mnt 2>/dev/null
> +	[ -n "$loop_dev" ] && _destroy_loop_device $loop_dev
> +	rm -rf $loop_mnt $img_file $tmp.*
> +}
> +
> +_fixed_by_kernel_commit 942296784b2a \
> +	"exfat: preserve benign secondary entries during rename and move"
> +
> +_require_test
> +_require_loop
> +_require_command "$PYTHON3_PROG" python3
> +
> +img_file=$TEST_DIR/$seq.exfat.img
> +loop_mnt=$TEST_DIR/$seq.mnt
> +loop_dev=""
> +
> +INJECT=$here/src/exfat_inject_vendor_ext.py
> +VERIFY=$here/src/exfat_verify_vendor_ext.py
> +MARKER="XFST_BENIGN_01"
> +
> +create_exfat_image()
> +{
> +	rm -f $img_file
> +	truncate -s 10M $img_file
> +	loop_dev=$(_create_loop_device $img_file)
> +	_mkfs_dev $loop_dev >> $seqres.full 2>&1
> +	mkdir -p $loop_mnt
> +}
> +
> +mount_image()
> +{
> +	_mount $loop_dev $loop_mnt
> +}
> +
> +unmount_image()
> +{
> +	_unmount $loop_mnt
> +}
> +
> +teardown_image()
> +{
> +	_unmount $loop_mnt 2>/dev/null
> +	_destroy_loop_device $loop_dev
> +	loop_dev=""
> +}
> +
> +verify_benign_preserved()
> +{
> +	local label="$1"
> +	local count="${2:-1}"
> +
> +	result=$($PYTHON3_PROG $VERIFY $loop_dev "$MARKER" "$count" \
> +		2>>$seqres.full)
> +	if [ "$result" = "PASS" ]; then
> +		echo "PASS: benign secondary entries preserved after $label"
> +	else
> +		echo "FAIL: benign secondary entries destroyed by $label"
> +	fi
> +}
> +
> +# Test 1: rename to longer name (entry set must be relocated)
> +create_exfat_image
> +mount_image
> +echo "payload" > $loop_mnt/a
> +unmount_image
> +
> +$PYTHON3_PROG $INJECT $loop_dev "$MARKER" a \
> +	>> $seqres.full 2>&1 || _fail "inject failed for test 1"
> +
> +mount_image
> +mv $loop_mnt/a "$loop_mnt/a_long_name_to_force_entry_set_relocation"
> +unmount_image
> +
> +verify_benign_preserved "rename to longer name"
> +teardown_image
> +
> +# Test 2: cross-directory move
> +create_exfat_image
> +mount_image
> +echo "payload" > $loop_mnt/b
> +unmount_image
> +
> +$PYTHON3_PROG $INJECT $loop_dev "$MARKER" b \
> +	>> $seqres.full 2>&1 || _fail "inject failed for test 2"
> +
> +mount_image
> +mkdir $loop_mnt/subdir
> +mv $loop_mnt/b $loop_mnt/subdir/b
> +unmount_image
> +
> +verify_benign_preserved "cross-directory move"
> +teardown_image
> +
> +# Test 3: rename to shorter name (entry set is updated in place)
> +create_exfat_image
> +mount_image
> +echo "payload" > "$loop_mnt/a_long_name_to_force_multiple_name_entries"
> +unmount_image
> +
> +$PYTHON3_PROG $INJECT $loop_dev "$MARKER" \
> +	a_long_name_to_force_multiple_name_entries \
> +	>> $seqres.full 2>&1 || _fail "inject failed for test 3"
> +
> +mount_image
> +mv "$loop_mnt/a_long_name_to_force_multiple_name_entries" $loop_mnt/c
> +unmount_image
> +
> +verify_benign_preserved "rename to shorter name"
> +teardown_image
> +
> +# Test 4: rename overwriting an existing file (source's benign entries
> +#         must survive even though the target's entries are deleted)
> +create_exfat_image
> +mount_image
> +echo "target" > $loop_mnt/dst
> +echo "source" > $loop_mnt/src
> +unmount_image
> +
> +$PYTHON3_PROG $INJECT $loop_dev "$MARKER" src \
> +	>> $seqres.full 2>&1 || _fail "inject failed for test 4 (src)"
> +
> +mount_image
> +mv $loop_mnt/src $loop_mnt/dst
> +unmount_image
> +
> +verify_benign_preserved "rename overwriting existing file"
> +teardown_image
> +
> +# Test 5: multiple benign entries (verifies all are copied, not just one)
> +create_exfat_image
> +mount_image
> +echo "payload" > $loop_mnt/m
> +unmount_image
> +
> +$PYTHON3_PROG $INJECT $loop_dev "$MARKER" m 3 \
> +	>> $seqres.full 2>&1 || _fail "inject failed for test 5"
> +
> +mount_image
> +mv $loop_mnt/m "$loop_mnt/m_longer_name_for_relocation"
> +unmount_image
> +
> +verify_benign_preserved "rename with multiple benign entries" 3
> +teardown_image
> +
> +# success, all done
> +status=0
> +exit
> diff --git a/tests/exfat/001.out b/tests/exfat/001.out
> new file mode 100644
> index 00000000..f82ea010
> --- /dev/null
> +++ b/tests/exfat/001.out
> @@ -0,0 +1,6 @@
> +QA output created by 001
> +PASS: benign secondary entries preserved after rename to longer name
> +PASS: benign secondary entries preserved after cross-directory move
> +PASS: benign secondary entries preserved after rename to shorter name
> +PASS: benign secondary entries preserved after rename overwriting existing file
> +PASS: benign secondary entries preserved after rename with multiple benign entries
> diff --git a/tests/exfat/Makefile b/tests/exfat/Makefile
> new file mode 100644
> index 00000000..9322f797
> --- /dev/null
> +++ b/tests/exfat/Makefile
> @@ -0,0 +1,23 @@
> +# SPDX-License-Identifier: GPL-2.0
> +# Copyright (c) 2026 Rochan Avlur.  All Rights Reserved.
> +
> +TOPDIR = ../..
> +include $(TOPDIR)/include/builddefs
> +include $(TOPDIR)/include/buildgrouplist
> +
> +EXFAT_DIR = exfat
> +TARGET_DIR = $(PKG_LIB_DIR)/$(TESTS_DIR)/$(EXFAT_DIR)
> +DIRT = group.list
> +
> +default: $(DIRT)
> +
> +include $(BUILDRULES)
> +
> +install: default
> +	$(INSTALL) -m 755 -d $(TARGET_DIR)
> +	$(INSTALL) -m 755 $(TESTS) $(TARGET_DIR)
> +	$(INSTALL) -m 644 group.list $(TARGET_DIR)
> +	$(INSTALL) -m 644 $(OUTFILES) $(TARGET_DIR)
> +
> +# Nothing.
> +install-dev install-lib:
> -- 
> 2.45.2
> 

      reply	other threads:[~2026-08-12 18:06 UTC|newest]

Thread overview: 2+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-23  4:11 [PATCH] exfat/001: test that rename and move preserve benign secondary entries Rochan Avlur
2026-08-12 18:06 ` Zorro Lang [this message]

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=any03ucw8qjY7rIR@zlang-mailbox \
    --to=zlang@kernel.org \
    --cc=Yuezhang.Mo@sony.com \
    --cc=fstests@vger.kernel.org \
    --cc=linkinjeon@kernel.org \
    --cc=rochan.avlur@gmail.com \
    /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