git: bbaf254293f7 - main - fsck_msdosfs: add tests for lost cluster chain repair accounting

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

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

commit bbaf254293f7e19fa7b0f9ed1da21c7a43126d79
Author:     Xin LI <delphij@FreeBSD.org>
AuthorDate: 2026-09-03 02:15:05 +0000
Commit:     Xin LI <delphij@FreeBSD.org>
CommitDate: 2026-09-03 02:16:22 +0000

    fsck_msdosfs: add tests for lost cluster chain repair accounting
    
    Add an ATF test suite covering Phase 3 ("Checking for Lost Files")
    error accounting.  Test images are created using newfs_msdos(8),
    and lost cluster chains are injected directly into FAT copies at
    offsets derived from the BPB.  The LOST.DIR directory required by
    reconnect() is constructed similarly: a root directory entry with
    ATTR_DIRECTORY set and its first cluster pointing to a zero-filled
    cluster containing "." and ".." entries.
    
    The lost_chain_cleared and corrupted_lost_chain_reconnected test
    cases provide regression coverage for the preceding commit:
     - lost_chain_cleared verifies that clearing a lost chain (the fallback
       taken when LOST.DIR is absent) exits with status 0 rather than 8
       (unrecovered error).
     - corrupted_lost_chain_reconnected verifies that FAT modifications
       from a chain truncated by checkchain() prior to reconnection are
       written back to disk, requiring "Update FATs? yes" and ensuring a
       clean second pass.
    
    Additionally, lost_chain_left_alone, lost_chain_preen, and
    corrupted_lost_chain_left_alone cover scenarios that must continue to
    report unrecovered errors: read-only mode (-n), which performs no
    repairs and leaves the image byte-for-byte unchanged, and preen mode
    (-p), which attempts reconnection but does not clear lost chains.
    
    MFC after:      1 week
---
 etc/mtree/BSD.tests.dist                     |   2 +
 sbin/fsck_msdosfs/Makefile                   |   5 +
 sbin/fsck_msdosfs/tests/Makefile             |   5 +
 sbin/fsck_msdosfs/tests/fsck_msdosfs_test.sh | 261 +++++++++++++++++++++++++++
 4 files changed, 273 insertions(+)

diff --git a/etc/mtree/BSD.tests.dist b/etc/mtree/BSD.tests.dist
index 9cc9fad8222b..3843ac9254b5 100644
--- a/etc/mtree/BSD.tests.dist
+++ b/etc/mtree/BSD.tests.dist
@@ -491,6 +491,8 @@
         ..
         dhclient
         ..
+        fsck_msdosfs
+        ..
         growfs
         ..
         ifconfig
diff --git a/sbin/fsck_msdosfs/Makefile b/sbin/fsck_msdosfs/Makefile
index d5fe24b220b7..2651f5c2b755 100644
--- a/sbin/fsck_msdosfs/Makefile
+++ b/sbin/fsck_msdosfs/Makefile
@@ -1,5 +1,7 @@
 #	$NetBSD: Makefile,v 1.6 1997/05/08 21:11:11 gwr Exp $
 
+.include <src.opts.mk>
+
 FSCK=	${.CURDIR:H}/fsck
 .PATH: ${FSCK}
 
@@ -11,4 +13,7 @@ SRCS=	main.c check.c boot.c fat.c dir.c fsutil.c
 CFLAGS+= -I${FSCK} -DHAVE_LIBUTIL_H
 LIBADD=	util
 
+HAS_TESTS=
+SUBDIR.${MK_TESTS}+= tests
+
 .include <bsd.prog.mk>
diff --git a/sbin/fsck_msdosfs/tests/Makefile b/sbin/fsck_msdosfs/tests/Makefile
new file mode 100644
index 000000000000..a36439f91bce
--- /dev/null
+++ b/sbin/fsck_msdosfs/tests/Makefile
@@ -0,0 +1,5 @@
+PACKAGE=	tests
+
+ATF_TESTS_SH=	fsck_msdosfs_test
+
+.include <bsd.test.mk>
diff --git a/sbin/fsck_msdosfs/tests/fsck_msdosfs_test.sh b/sbin/fsck_msdosfs/tests/fsck_msdosfs_test.sh
new file mode 100644
index 000000000000..1c5dd2cff887
--- /dev/null
+++ b/sbin/fsck_msdosfs/tests/fsck_msdosfs_test.sh
@@ -0,0 +1,261 @@
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2026 The FreeBSD Foundation
+#
+
+# Tests for fsck_msdosfs(8) phase 3 ("Checking for Lost Files") repair
+# accounting: a lost cluster chain that fsck_msdosfs(8) has repaired must
+# not be reported as an unrecovered error, and one that it has left alone
+# must be.
+
+IMG=fat16.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 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 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 ASCII string $3 at offset $2 of $1.
+poke_str()
+{
+	printf '%s' "$3" | dd of="$1" bs=1 seek="$2" conv=notrunc status=none
+}
+
+# Create a 4 MiB FAT16 file system in $IMG.  One sector per cluster keeps
+# the cluster numbers used below well inside the data area.
+make_image()
+{
+	atf_check -s exit:0 -o ignore -e ignore \
+	    newfs_msdos -C 4m -F 16 -c 1 -S 512 ./${IMG}
+	# A freshly created file system must be clean.
+	atf_check -s exit:0 -o ignore -e ignore fsck_msdosfs -y ./${IMG}
+}
+
+# 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.  These cluster numbers are
+# used because neither byte of their little-endian FAT16 encoding is NUL.
+inject_lost_chain()
+{
+	local bps rsvd nfats fatsz i base
+
+	bps=$(bpb_read ${IMG} 11 2)
+	rsvd=$(bpb_read ${IMG} 14 2)
+	nfats=$(bpb_read ${IMG} 16 1)
+	fatsz=$(bpb_read ${IMG} 22 2)
+
+	i=0
+	while [ "${i}" -lt "${nfats}" ]; do
+		base=$((rsvd * bps + i * fatsz * bps))
+		poke16 ${IMG} $((base + 300 * 2)) 301
+		poke16 ${IMG} $((base + 301 * 2)) 302
+		poke16 ${IMG} $((base + 302 * 2)) 65535
+		i=$((i + 1))
+	done
+}
+
+# Mark clusters 300 and 301 of $IMG as an allocated lost chain where 300 points
+# to 301 and 301 points to CLUST_FREE (0).  No directory entry refers to them, so
+# checklost() finds cluster 300 as a lost chain head, but checkchain() fails
+# because the chain ends unexpectedly with a free cluster.
+inject_corrupted_lost_chain()
+{
+	local bps rsvd nfats fatsz i base
+
+	bps=$(bpb_read ${IMG} 11 2)
+	rsvd=$(bpb_read ${IMG} 14 2)
+	nfats=$(bpb_read ${IMG} 16 1)
+	fatsz=$(bpb_read ${IMG} 22 2)
+
+	i=0
+	while [ "${i}" -lt "${nfats}" ]; do
+		base=$((rsvd * bps + i * fatsz * bps))
+		poke16 ${IMG} $((base + 300 * 2)) 301
+		poke16 ${IMG} $((base + 301 * 2)) 0
+		i=$((i + 1))
+	done
+}
+
+# Create an empty LOST.DIR in the root directory of $IMG, with cluster 400
+# holding its contents, so that reconnect() has somewhere to link a lost
+# chain to.  Everything not written here is already zero in a freshly
+# created file system, which is what these fields need to be.
+create_lost_dir()
+{
+	local bps spc rsvd nfats rootent fatsz i base rootoff dataoff dir
+
+	bps=$(bpb_read ${IMG} 11 2)
+	spc=$(bpb_read ${IMG} 13 1)
+	rsvd=$(bpb_read ${IMG} 14 2)
+	nfats=$(bpb_read ${IMG} 16 1)
+	rootent=$(bpb_read ${IMG} 17 2)
+	fatsz=$(bpb_read ${IMG} 22 2)
+
+	i=0
+	while [ "${i}" -lt "${nfats}" ]; do
+		base=$((rsvd * bps + i * fatsz * bps))
+		poke16 ${IMG} $((base + 400 * 2)) 65535
+		i=$((i + 1))
+	done
+
+	rootoff=$(((rsvd + nfats * fatsz) * bps))
+	dataoff=$((rootoff + rootent * 32))
+	dir=$((dataoff + (400 - 2) * spc * bps))
+
+	# The entry in the root directory.  16 is ATTR_DIRECTORY.
+	poke_str ${IMG} ${rootoff} 'LOST    DIR'
+	poke8 ${IMG} $((rootoff + 11)) 16
+	poke16 ${IMG} $((rootoff + 26)) 400
+
+	# Its "." and ".." entries.  The remainder of the cluster stays
+	# zero, which reads as SLOT_EMPTY, so reconnect() has free slots.
+	poke_str ${IMG} ${dir} '.          '
+	poke8 ${IMG} $((dir + 11)) 16
+	poke16 ${IMG} $((dir + 26)) 400
+	poke_str ${IMG} $((dir + 32)) '..         '
+	poke8 ${IMG} $((dir + 43)) 16
+}
+
+atf_test_case lost_chain_cleared
+lost_chain_cleared_head()
+{
+	atf_set "descr" "A lost chain that was cleared is not an error"
+	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
+}
+lost_chain_cleared_body()
+{
+	make_image
+	inject_lost_chain
+
+	# There is no LOST.DIR, so reconnect() fails and fsck_msdosfs(8)
+	# falls back to clearing the chain.  That repairs the file system,
+	# so the exit status must be 0 and not 8 (unrecovered error).
+	atf_check -s exit:0 \
+	    -o match:'Lost cluster chain at cluster 300' \
+	    -o match:'3 Cluster\(s\) lost' \
+	    -o match:'No LOST.DIR directory' \
+	    -o match:'Clear\? yes' \
+	    -e ignore \
+	    fsck_msdosfs -y ./${IMG}
+
+	# The repair has to be durable: a second pass must find nothing.
+	atf_check -s exit:0 -o not-match:'Lost cluster chain' -e ignore \
+	    fsck_msdosfs -y ./${IMG}
+}
+
+atf_test_case lost_chain_left_alone
+lost_chain_left_alone_head()
+{
+	atf_set "descr" "A lost chain that was not repaired is an error"
+	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
+}
+lost_chain_left_alone_body()
+{
+	make_image
+	inject_lost_chain
+	cp ${IMG} ${IMG}.save
+
+	# In -n mode nothing is repaired, so the lost chain must still be
+	# reported as an unrecovered error and the image must not change.
+	atf_check -s exit:8 -o match:'Lost cluster chain at cluster 300' \
+	    -e ignore fsck_msdosfs -n ./${IMG}
+	atf_check cmp ${IMG}.save ${IMG}
+}
+
+atf_test_case lost_chain_preen
+lost_chain_preen_head()
+{
+	atf_set "descr" "Preen mode reports a lost chain it cannot reconnect"
+	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
+}
+lost_chain_preen_body()
+{
+	make_image
+	inject_lost_chain
+
+	# Preen mode attempts the reconnect but never clears, so with no
+	# LOST.DIR the chain stays lost and has to be reported.
+	atf_check -s exit:8 -o match:'Lost cluster chain at cluster 300' \
+	    -o match:'No LOST.DIR directory' -e ignore \
+	    fsck_msdosfs -p -f ./${IMG}
+}
+
+atf_test_case corrupted_lost_chain_left_alone
+corrupted_lost_chain_left_alone_head()
+{
+	atf_set "descr" "A corrupted lost chain that checkchain fails on is an error if left alone"
+	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
+}
+corrupted_lost_chain_left_alone_body()
+{
+	make_image
+	inject_corrupted_lost_chain
+	cp ${IMG} ${IMG}.save
+
+	# In -n mode nothing is repaired, so checkchain() returns FSERROR when
+	# the chain ends unexpectedly with CLUST_FREE.  checklost() must not
+	# swallow this FSERROR, so fsck_msdosfs must exit 8 and leave the image unchanged.
+	atf_check -s exit:8 \
+	    -o match:'Cluster chain starting at 300 ends with cluster marked free' \
+	    -e ignore fsck_msdosfs -n ./${IMG}
+	atf_check cmp ${IMG}.save ${IMG}
+}
+
+atf_test_case corrupted_lost_chain_reconnected
+corrupted_lost_chain_reconnected_head()
+{
+	atf_set "descr" "Truncating a lost chain before reconnecting it is written out"
+	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
+}
+corrupted_lost_chain_reconnected_body()
+{
+	make_image
+	create_lost_dir
+	# Adding LOST.DIR by hand must not have damaged anything.
+	atf_check -s exit:0 -o ignore -e ignore fsck_msdosfs -y ./${IMG}
+	inject_corrupted_lost_chain
+
+	# checkchain() truncates the chain (FSFATMOD) and reconnect() then
+	# links it into LOST.DIR (FSDIRMOD).  Both results have to reach
+	# mod: without the FSFATMOD, checkfilesys() never writes the FATs
+	# back and the truncation is silently discarded.
+	atf_check -s exit:0 \
+	    -o match:'Cluster chain starting at 300 ends with cluster marked free' \
+	    -o match:'Truncate\? yes' \
+	    -o match:'Lost cluster chain at cluster 300' \
+	    -o match:'Update FATs\? yes' \
+	    -e ignore \
+	    fsck_msdosfs -y ./${IMG}
+
+	# The truncation has to be durable: a second pass must find nothing.
+	atf_check -s exit:0 -o not-match:'ends with cluster marked free' \
+	    -e ignore fsck_msdosfs -y ./${IMG}
+}
+
+atf_init_test_cases()
+{
+	atf_add_test_case lost_chain_cleared
+	atf_add_test_case lost_chain_left_alone
+	atf_add_test_case lost_chain_preen
+	atf_add_test_case corrupted_lost_chain_left_alone
+	atf_add_test_case corrupted_lost_chain_reconnected
+}