git: 37aec55d0a71 - main - fsck_msdosfs: add a test for reconnecting on volumes larger than 4 GiB

From: Xin LI <delphij_at_FreeBSD.org>
Date: Thu, 03 Sep 2026 07:57:38 UTC
The branch main has been updated by delphij:

URL: https://cgit.FreeBSD.org/src/commit/?id=37aec55d0a7165960f686e5277f030ab3d44cf45

commit 37aec55d0a7165960f686e5277f030ab3d44cf45
Author:     Xin LI <delphij@FreeBSD.org>
AuthorDate: 2026-09-03 07:33:31 +0000
Commit:     Xin LI <delphij@FreeBSD.org>
CommitDate: 2026-09-03 07:56:24 +0000

    fsck_msdosfs: add a test for reconnecting on volumes larger than 4 GiB
    
    Build a 4.5 GiB FAT32 image whose LOST.DIR cluster sits exactly 4 GiB above
    the single cluster of a PAYLOAD.BIN, so that truncating the offset of the
    former to 32 bits yields the offset of the latter, then inject a lost cluster
    chain and let fsck_msdosfs(8) reconnect it.
    
    The test asserts both halves of the bug fixed in the previous commit: that
    PAYLOAD.BIN's cluster is unchanged, and that a second pass no longer reports
    the chain as lost, which it only stops doing once the directory entry reaches
    the real LOST.DIR.
    
    newfs_msdos(8) -C only calls ftruncate(2) and nothing outside the reserved
    area, the FATs and a handful of clusters is ever written, so the image stays
    sparse and costs about 2 MiB on disk.
    
    The geometry is read back out of the BPB rather than assumed, so
    newfs_msdos(8) stays free to lay the file system out differently; the test
    fails with a clear message if the volume ever becomes too small to hold a
    cluster a full 4 GiB beyond the data area.
    
    MFC after:      1 week
---
 sbin/fsck_msdosfs/tests/Makefile                   |   3 +-
 sbin/fsck_msdosfs/tests/fsck_msdosfs_large_test.sh | 241 +++++++++++++++++++++
 2 files changed, 243 insertions(+), 1 deletion(-)

diff --git a/sbin/fsck_msdosfs/tests/Makefile b/sbin/fsck_msdosfs/tests/Makefile
index c8963837b7b0..33b0ae8310e8 100644
--- a/sbin/fsck_msdosfs/tests/Makefile
+++ b/sbin/fsck_msdosfs/tests/Makefile
@@ -1,6 +1,7 @@
 PACKAGE=	tests
 
 ATF_TESTS_SH=	fsck_msdosfs_test \
-		fsck_msdosfs_boot_test
+		fsck_msdosfs_boot_test \
+		fsck_msdosfs_large_test
 
 .include <bsd.test.mk>
diff --git a/sbin/fsck_msdosfs/tests/fsck_msdosfs_large_test.sh b/sbin/fsck_msdosfs/tests/fsck_msdosfs_large_test.sh
new file mode 100644
index 000000000000..3b96e239b9c1
--- /dev/null
+++ b/sbin/fsck_msdosfs/tests/fsck_msdosfs_large_test.sh
@@ -0,0 +1,241 @@
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2026 The FreeBSD Foundation
+#
+
+# Tests for fsck_msdosfs(8) on FAT32 volumes larger than 4 GiB, where a
+# cluster's byte offset no longer fits in 32 bits.  reconnect() used to
+# compute the offset of the LOST.DIR cluster in 32 bit arithmetic, so it
+# read, modified and wrote back the cluster 4 GiB below the intended one,
+# silently corrupting whatever user data lived there while leaving the lost
+# chain unreferenced.
+
+IMG=fat32.img
+
+# Read an unsigned little-endian integer of $3 bytes at offset $2 of $1.
+bpb_read()
+{
+	od -An -v -tu1 -j "$2" -N "$3" "$1" | awk '
+	    { for (i = 1; i <= NF; i++) b[n++] = $i }
+	    END { v = 0; for (i = n - 1; i >= 0; i--) v = v * 256 + b[i]
+		  print v }'
+}
+
+# Write the unsigned 8 bit value $3 at offset $2 of $1.
+poke8()
+{
+	printf "$(printf '\\%03o' $(($3 & 255)))" |
+	    dd of="$1" bs=1 seek="$2" conv=notrunc status=none
+}
+
+# Write the unsigned 16 bit little-endian value $3 at offset $2 of $1.
+poke16()
+{
+	printf "$(printf '\\%03o\\%03o' $(($3 & 255)) $((($3 >> 8) & 255)))" |
+	    dd of="$1" bs=1 seek="$2" conv=notrunc status=none
+}
+
+# Write the unsigned 32 bit little-endian value $3 at offset $2 of $1.
+poke32()
+{
+	printf "$(printf '\\%03o\\%03o\\%03o\\%03o' $(($3 & 255)) \
+	    $((($3 >> 8) & 255)) $((($3 >> 16) & 255)) \
+	    $((($3 >> 24) & 255)))" |
+	    dd of="$1" bs=1 seek="$2" conv=notrunc status=none
+}
+
+# Write the ASCII string $3 at offset $2 of $1.
+poke_str()
+{
+	printf '%s' "$3" | dd of="$1" bs=1 seek="$2" conv=notrunc status=none
+}
+
+# Read the file system geometry of $IMG out of its BPB and derive the two
+# cluster numbers the tests below use.  Everything is taken from the image
+# rather than assumed, so newfs_msdos(8) remains free to pick a different
+# layout than the one requested.
+#
+# victimcl is an ordinary data cluster near the start of the volume and
+# lostcl is exactly 4 GiB further into the volume, so that truncating the
+# offset of lostcl to 32 bits yields the offset of victimcl.
+fat32_geom()
+{
+	local totsec
+
+	bps=$(bpb_read ${IMG} 11 2)
+	spc=$(bpb_read ${IMG} 13 1)
+	rsvd=$(bpb_read ${IMG} 14 2)
+	nfats=$(bpb_read ${IMG} 16 1)
+	totsec=$(bpb_read ${IMG} 32 4)
+	fatsz=$(bpb_read ${IMG} 36 4)
+	rootcl=$(bpb_read ${IMG} 44 4)
+
+	clsz=$((spc * bps))
+	fatoff=$((rsvd * bps))
+	dataoff=$(((rsvd + nfats * fatsz) * bps))
+	numclust=$(((totsec - rsvd - nfats * fatsz) / spc))
+
+	victimcl=64
+	lostcl=$((victimcl + 4294967296 / clsz))
+
+	if [ "${lostcl}" -ge "${numclust}" ]; then
+		atf_fail "image holds ${numclust} clusters, need ${lostcl}"
+	fi
+}
+
+# Print the byte offset of cluster $1.
+cloff()
+{
+	echo $((dataoff + ($1 - 2) * clsz))
+}
+
+# Set the FAT32 entry for cluster $2 to $3 in every copy of the FAT of $1.
+fat_set()
+{
+	local i
+
+	i=0
+	while [ "${i}" -lt "${nfats}" ]; do
+		poke32 "$1" $((fatoff + i * fatsz * bps + $2 * 4)) "$3"
+		i=$((i + 1))
+	done
+}
+
+# Write the 8.3 directory entry $2 at offset $1 of $IMG, with attribute $3,
+# start cluster $4 and size $5.  Everything not written here is already zero
+# in a freshly created file system, which is what the remaining fields need
+# to be.
+dirent()
+{
+	poke_str ${IMG} "$1" "$2"
+	poke8 ${IMG} $(($1 + 11)) "$3"
+	poke16 ${IMG} $(($1 + 20)) $(($4 >> 16))
+	poke16 ${IMG} $(($1 + 26)) $(($4 & 65535))
+	poke32 ${IMG} $(($1 + 28)) "$5"
+}
+
+# Create a 4.5 GiB FAT32 file system in $IMG.  newfs_msdos(8) -C only calls
+# ftruncate(2), and nothing outside the reserved area, the FATs and a
+# handful of clusters is ever written, so the image stays sparse.
+#
+# The volume has to be large enough that a cluster can sit a full 4 GiB
+# beyond an ordinary data cluster, which 4.5 GiB satisfies for every cluster
+# size newfs_msdos(8) may choose here.
+make_image()
+{
+	atf_check -s exit:0 -o ignore -e ignore \
+	    newfs_msdos -C 4608m -F 32 -c 64 -S 512 ./${IMG}
+	fat32_geom
+	# A freshly created file system must be clean.
+	atf_check -s exit:0 -o ignore -e ignore fsck_msdosfs -y ./${IMG}
+}
+
+# Create an empty LOST.DIR in the root directory of $IMG, with cluster
+# $lostcl holding its contents, so that reconnect() has somewhere to link a
+# lost chain to.  That cluster begins more than 4 GiB into the volume.
+create_lost_dir()
+{
+	local root dir
+
+	fat_set ${IMG} ${lostcl} 268435455
+
+	root=$(cloff ${rootcl})
+	dir=$(cloff ${lostcl})
+
+	# The entry in the root directory.  16 is ATTR_DIRECTORY.
+	dirent ${root} 'LOST    DIR' 16 ${lostcl} 0
+
+	# Its "." and ".." entries.  The remainder of the cluster stays
+	# zero, which reads as SLOT_EMPTY, so reconnect() has free slots.
+	dirent ${dir} '.          ' 16 ${lostcl} 0
+	dirent $((dir + 32)) '..         ' 16 0 0
+}
+
+# Create PAYLOAD.BIN in the root directory of $IMG, occupying the single
+# cluster $victimcl, which is exactly 4 GiB below the LOST.DIR cluster.
+#
+# The first 32 bytes are left zero on purpose: a truncated offset makes
+# reconnect() search this cluster for a free directory slot, and a leading
+# NUL reads as SLOT_EMPTY, so the bogus entry lands at a known place.  The
+# rest carries a marker so the region is recognisable in a corrupted image.
+create_payload()
+{
+	local data
+
+	fat_set ${IMG} ${victimcl} 268435455
+
+	data=$(cloff ${victimcl})
+	poke_str ${IMG} $((data + 32)) 'PAYLOAD.BIN DATA - MUST NOT BE TOUCHED'
+
+	# 32 is ATTR_ARCHIVE.
+	dirent $(($(cloff ${rootcl}) + 32)) 'PAYLOAD BIN' 32 ${victimcl} ${clsz}
+}
+
+# Mark clusters 300, 301 and 302 of $IMG as an allocated chain in every copy
+# of the FAT.  No directory entry refers to them, so fsck_msdosfs(8) has to
+# find them as a lost chain in phase 3 and reconnect them into LOST.DIR.
+inject_lost_chain()
+{
+	fat_set ${IMG} 300 301
+	fat_set ${IMG} 301 302
+	fat_set ${IMG} 302 268435455
+}
+
+# Copy the cluster $1 of $IMG into the file $2.  The size is checked so that
+# a short read cannot turn the comparison below into a vacuous success.
+save_cluster()
+{
+	dd if=${IMG} of="$2" bs=${bps} skip=$(($(cloff "$1") / bps)) \
+	    count=${spc} status=none
+	if [ "$(stat -f %z "$2")" -ne "${clsz}" ]; then
+		atf_fail "could not read cluster $1 of ${IMG}"
+	fi
+}
+
+atf_test_case reconnect_above_4g
+reconnect_above_4g_head()
+{
+	atf_set "descr" "Reconnecting into a LOST.DIR past 4 GiB does not corrupt user data"
+	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
+}
+reconnect_above_4g_body()
+{
+	make_image
+	create_lost_dir
+	create_payload
+
+	# Adding the entries by hand must not have damaged anything.  This
+	# also brings the free cluster count in the FSInfo block back in
+	# line with the FAT, so the run below does not have to fix it.
+	atf_check -s exit:0 -o ignore -e ignore fsck_msdosfs -y ./${IMG}
+
+	inject_lost_chain
+	save_cluster ${victimcl} victim.before
+
+	# reconnect() has to link the lost chain into the LOST.DIR cluster
+	# more than 4 GiB into the volume.  Computing that offset in 32 bit
+	# arithmetic instead lands on PAYLOAD.BIN's cluster.
+	atf_check -s exit:0 \
+	    -o match:'Lost cluster chain at cluster 300' \
+	    -o match:'3 Cluster\(s\) lost' \
+	    -o match:'Reconnect\? yes' \
+	    -e ignore \
+	    fsck_msdosfs -y ./${IMG}
+
+	# PAYLOAD.BIN is 4 GiB below LOST.DIR, so a truncated offset
+	# rewrites its cluster with a directory entry in the first slot.
+	save_cluster ${victimcl} victim.after
+	atf_check cmp victim.before victim.after
+
+	# The reconnect has to be durable: the chain is only referenced if
+	# the entry reached the real LOST.DIR, so a second pass that still
+	# reports it means the first one wrote somewhere else.
+	atf_check -s exit:0 -o not-match:'Lost cluster chain' -e ignore \
+	    fsck_msdosfs -y ./${IMG}
+}
+
+atf_init_test_cases()
+{
+	atf_add_test_case reconnect_above_4g
+}