xref: /freebsd/sbin/fsck_msdosfs/tests/fsck_msdosfs_large_test.sh (revision 37aec55d0a7165960f686e5277f030ab3d44cf45)
1#
2# SPDX-License-Identifier: BSD-2-Clause
3#
4# Copyright (c) 2026 The FreeBSD Foundation
5#
6
7# Tests for fsck_msdosfs(8) on FAT32 volumes larger than 4 GiB, where a
8# cluster's byte offset no longer fits in 32 bits.  reconnect() used to
9# compute the offset of the LOST.DIR cluster in 32 bit arithmetic, so it
10# read, modified and wrote back the cluster 4 GiB below the intended one,
11# silently corrupting whatever user data lived there while leaving the lost
12# chain unreferenced.
13
14IMG=fat32.img
15
16# Read an unsigned little-endian integer of $3 bytes at offset $2 of $1.
17bpb_read()
18{
19	od -An -v -tu1 -j "$2" -N "$3" "$1" | awk '
20	    { for (i = 1; i <= NF; i++) b[n++] = $i }
21	    END { v = 0; for (i = n - 1; i >= 0; i--) v = v * 256 + b[i]
22		  print v }'
23}
24
25# Write the unsigned 8 bit value $3 at offset $2 of $1.
26poke8()
27{
28	printf "$(printf '\\%03o' $(($3 & 255)))" |
29	    dd of="$1" bs=1 seek="$2" conv=notrunc status=none
30}
31
32# Write the unsigned 16 bit little-endian value $3 at offset $2 of $1.
33poke16()
34{
35	printf "$(printf '\\%03o\\%03o' $(($3 & 255)) $((($3 >> 8) & 255)))" |
36	    dd of="$1" bs=1 seek="$2" conv=notrunc status=none
37}
38
39# Write the unsigned 32 bit little-endian value $3 at offset $2 of $1.
40poke32()
41{
42	printf "$(printf '\\%03o\\%03o\\%03o\\%03o' $(($3 & 255)) \
43	    $((($3 >> 8) & 255)) $((($3 >> 16) & 255)) \
44	    $((($3 >> 24) & 255)))" |
45	    dd of="$1" bs=1 seek="$2" conv=notrunc status=none
46}
47
48# Write the ASCII string $3 at offset $2 of $1.
49poke_str()
50{
51	printf '%s' "$3" | dd of="$1" bs=1 seek="$2" conv=notrunc status=none
52}
53
54# Read the file system geometry of $IMG out of its BPB and derive the two
55# cluster numbers the tests below use.  Everything is taken from the image
56# rather than assumed, so newfs_msdos(8) remains free to pick a different
57# layout than the one requested.
58#
59# victimcl is an ordinary data cluster near the start of the volume and
60# lostcl is exactly 4 GiB further into the volume, so that truncating the
61# offset of lostcl to 32 bits yields the offset of victimcl.
62fat32_geom()
63{
64	local totsec
65
66	bps=$(bpb_read ${IMG} 11 2)
67	spc=$(bpb_read ${IMG} 13 1)
68	rsvd=$(bpb_read ${IMG} 14 2)
69	nfats=$(bpb_read ${IMG} 16 1)
70	totsec=$(bpb_read ${IMG} 32 4)
71	fatsz=$(bpb_read ${IMG} 36 4)
72	rootcl=$(bpb_read ${IMG} 44 4)
73
74	clsz=$((spc * bps))
75	fatoff=$((rsvd * bps))
76	dataoff=$(((rsvd + nfats * fatsz) * bps))
77	numclust=$(((totsec - rsvd - nfats * fatsz) / spc))
78
79	victimcl=64
80	lostcl=$((victimcl + 4294967296 / clsz))
81
82	if [ "${lostcl}" -ge "${numclust}" ]; then
83		atf_fail "image holds ${numclust} clusters, need ${lostcl}"
84	fi
85}
86
87# Print the byte offset of cluster $1.
88cloff()
89{
90	echo $((dataoff + ($1 - 2) * clsz))
91}
92
93# Set the FAT32 entry for cluster $2 to $3 in every copy of the FAT of $1.
94fat_set()
95{
96	local i
97
98	i=0
99	while [ "${i}" -lt "${nfats}" ]; do
100		poke32 "$1" $((fatoff + i * fatsz * bps + $2 * 4)) "$3"
101		i=$((i + 1))
102	done
103}
104
105# Write the 8.3 directory entry $2 at offset $1 of $IMG, with attribute $3,
106# start cluster $4 and size $5.  Everything not written here is already zero
107# in a freshly created file system, which is what the remaining fields need
108# to be.
109dirent()
110{
111	poke_str ${IMG} "$1" "$2"
112	poke8 ${IMG} $(($1 + 11)) "$3"
113	poke16 ${IMG} $(($1 + 20)) $(($4 >> 16))
114	poke16 ${IMG} $(($1 + 26)) $(($4 & 65535))
115	poke32 ${IMG} $(($1 + 28)) "$5"
116}
117
118# Create a 4.5 GiB FAT32 file system in $IMG.  newfs_msdos(8) -C only calls
119# ftruncate(2), and nothing outside the reserved area, the FATs and a
120# handful of clusters is ever written, so the image stays sparse.
121#
122# The volume has to be large enough that a cluster can sit a full 4 GiB
123# beyond an ordinary data cluster, which 4.5 GiB satisfies for every cluster
124# size newfs_msdos(8) may choose here.
125make_image()
126{
127	atf_check -s exit:0 -o ignore -e ignore \
128	    newfs_msdos -C 4608m -F 32 -c 64 -S 512 ./${IMG}
129	fat32_geom
130	# A freshly created file system must be clean.
131	atf_check -s exit:0 -o ignore -e ignore fsck_msdosfs -y ./${IMG}
132}
133
134# Create an empty LOST.DIR in the root directory of $IMG, with cluster
135# $lostcl holding its contents, so that reconnect() has somewhere to link a
136# lost chain to.  That cluster begins more than 4 GiB into the volume.
137create_lost_dir()
138{
139	local root dir
140
141	fat_set ${IMG} ${lostcl} 268435455
142
143	root=$(cloff ${rootcl})
144	dir=$(cloff ${lostcl})
145
146	# The entry in the root directory.  16 is ATTR_DIRECTORY.
147	dirent ${root} 'LOST    DIR' 16 ${lostcl} 0
148
149	# Its "." and ".." entries.  The remainder of the cluster stays
150	# zero, which reads as SLOT_EMPTY, so reconnect() has free slots.
151	dirent ${dir} '.          ' 16 ${lostcl} 0
152	dirent $((dir + 32)) '..         ' 16 0 0
153}
154
155# Create PAYLOAD.BIN in the root directory of $IMG, occupying the single
156# cluster $victimcl, which is exactly 4 GiB below the LOST.DIR cluster.
157#
158# The first 32 bytes are left zero on purpose: a truncated offset makes
159# reconnect() search this cluster for a free directory slot, and a leading
160# NUL reads as SLOT_EMPTY, so the bogus entry lands at a known place.  The
161# rest carries a marker so the region is recognisable in a corrupted image.
162create_payload()
163{
164	local data
165
166	fat_set ${IMG} ${victimcl} 268435455
167
168	data=$(cloff ${victimcl})
169	poke_str ${IMG} $((data + 32)) 'PAYLOAD.BIN DATA - MUST NOT BE TOUCHED'
170
171	# 32 is ATTR_ARCHIVE.
172	dirent $(($(cloff ${rootcl}) + 32)) 'PAYLOAD BIN' 32 ${victimcl} ${clsz}
173}
174
175# Mark clusters 300, 301 and 302 of $IMG as an allocated chain in every copy
176# of the FAT.  No directory entry refers to them, so fsck_msdosfs(8) has to
177# find them as a lost chain in phase 3 and reconnect them into LOST.DIR.
178inject_lost_chain()
179{
180	fat_set ${IMG} 300 301
181	fat_set ${IMG} 301 302
182	fat_set ${IMG} 302 268435455
183}
184
185# Copy the cluster $1 of $IMG into the file $2.  The size is checked so that
186# a short read cannot turn the comparison below into a vacuous success.
187save_cluster()
188{
189	dd if=${IMG} of="$2" bs=${bps} skip=$(($(cloff "$1") / bps)) \
190	    count=${spc} status=none
191	if [ "$(stat -f %z "$2")" -ne "${clsz}" ]; then
192		atf_fail "could not read cluster $1 of ${IMG}"
193	fi
194}
195
196atf_test_case reconnect_above_4g
197reconnect_above_4g_head()
198{
199	atf_set "descr" "Reconnecting into a LOST.DIR past 4 GiB does not corrupt user data"
200	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
201}
202reconnect_above_4g_body()
203{
204	make_image
205	create_lost_dir
206	create_payload
207
208	# Adding the entries by hand must not have damaged anything.  This
209	# also brings the free cluster count in the FSInfo block back in
210	# line with the FAT, so the run below does not have to fix it.
211	atf_check -s exit:0 -o ignore -e ignore fsck_msdosfs -y ./${IMG}
212
213	inject_lost_chain
214	save_cluster ${victimcl} victim.before
215
216	# reconnect() has to link the lost chain into the LOST.DIR cluster
217	# more than 4 GiB into the volume.  Computing that offset in 32 bit
218	# arithmetic instead lands on PAYLOAD.BIN's cluster.
219	atf_check -s exit:0 \
220	    -o match:'Lost cluster chain at cluster 300' \
221	    -o match:'3 Cluster\(s\) lost' \
222	    -o match:'Reconnect\? yes' \
223	    -e ignore \
224	    fsck_msdosfs -y ./${IMG}
225
226	# PAYLOAD.BIN is 4 GiB below LOST.DIR, so a truncated offset
227	# rewrites its cluster with a directory entry in the first slot.
228	save_cluster ${victimcl} victim.after
229	atf_check cmp victim.before victim.after
230
231	# The reconnect has to be durable: the chain is only referenced if
232	# the entry reached the real LOST.DIR, so a second pass that still
233	# reports it means the first one wrote somewhere else.
234	atf_check -s exit:0 -o not-match:'Lost cluster chain' -e ignore \
235	    fsck_msdosfs -y ./${IMG}
236}
237
238atf_init_test_cases()
239{
240	atf_add_test_case reconnect_above_4g
241}
242