xref: /freebsd/sbin/fsck_msdosfs/tests/fsck_msdosfs_test.sh (revision bbaf254293f7e19fa7b0f9ed1da21c7a43126d79)
1#
2# SPDX-License-Identifier: BSD-2-Clause
3#
4# Copyright (c) 2026 The FreeBSD Foundation
5#
6
7# Tests for fsck_msdosfs(8) phase 3 ("Checking for Lost Files") repair
8# accounting: a lost cluster chain that fsck_msdosfs(8) has repaired must
9# not be reported as an unrecovered error, and one that it has left alone
10# must be.
11
12IMG=fat16.img
13
14# Read an unsigned little-endian integer of $3 bytes at offset $2 of $1.
15bpb_read()
16{
17	od -An -v -tu1 -j "$2" -N "$3" "$1" | awk '
18	    { for (i = 1; i <= NF; i++) b[n++] = $i }
19	    END { v = 0; for (i = n - 1; i >= 0; i--) v = v * 256 + b[i]
20		  print v }'
21}
22
23# Write the unsigned 16 bit little-endian value $3 at offset $2 of $1.
24poke16()
25{
26	printf "$(printf '\\%03o\\%03o' $(($3 & 255)) $((($3 >> 8) & 255)))" |
27	    dd of="$1" bs=1 seek="$2" conv=notrunc status=none
28}
29
30# Write the unsigned 8 bit value $3 at offset $2 of $1.
31poke8()
32{
33	printf "$(printf '\\%03o' $(($3 & 255)))" |
34	    dd of="$1" bs=1 seek="$2" conv=notrunc status=none
35}
36
37# Write the ASCII string $3 at offset $2 of $1.
38poke_str()
39{
40	printf '%s' "$3" | dd of="$1" bs=1 seek="$2" conv=notrunc status=none
41}
42
43# Create a 4 MiB FAT16 file system in $IMG.  One sector per cluster keeps
44# the cluster numbers used below well inside the data area.
45make_image()
46{
47	atf_check -s exit:0 -o ignore -e ignore \
48	    newfs_msdos -C 4m -F 16 -c 1 -S 512 ./${IMG}
49	# A freshly created file system must be clean.
50	atf_check -s exit:0 -o ignore -e ignore fsck_msdosfs -y ./${IMG}
51}
52
53# Mark clusters 300, 301 and 302 of $IMG as an allocated chain in every
54# copy of the FAT.  No directory entry refers to them, so fsck_msdosfs(8)
55# has to find them as a lost chain in phase 3.  These cluster numbers are
56# used because neither byte of their little-endian FAT16 encoding is NUL.
57inject_lost_chain()
58{
59	local bps rsvd nfats fatsz i base
60
61	bps=$(bpb_read ${IMG} 11 2)
62	rsvd=$(bpb_read ${IMG} 14 2)
63	nfats=$(bpb_read ${IMG} 16 1)
64	fatsz=$(bpb_read ${IMG} 22 2)
65
66	i=0
67	while [ "${i}" -lt "${nfats}" ]; do
68		base=$((rsvd * bps + i * fatsz * bps))
69		poke16 ${IMG} $((base + 300 * 2)) 301
70		poke16 ${IMG} $((base + 301 * 2)) 302
71		poke16 ${IMG} $((base + 302 * 2)) 65535
72		i=$((i + 1))
73	done
74}
75
76# Mark clusters 300 and 301 of $IMG as an allocated lost chain where 300 points
77# to 301 and 301 points to CLUST_FREE (0).  No directory entry refers to them, so
78# checklost() finds cluster 300 as a lost chain head, but checkchain() fails
79# because the chain ends unexpectedly with a free cluster.
80inject_corrupted_lost_chain()
81{
82	local bps rsvd nfats fatsz i base
83
84	bps=$(bpb_read ${IMG} 11 2)
85	rsvd=$(bpb_read ${IMG} 14 2)
86	nfats=$(bpb_read ${IMG} 16 1)
87	fatsz=$(bpb_read ${IMG} 22 2)
88
89	i=0
90	while [ "${i}" -lt "${nfats}" ]; do
91		base=$((rsvd * bps + i * fatsz * bps))
92		poke16 ${IMG} $((base + 300 * 2)) 301
93		poke16 ${IMG} $((base + 301 * 2)) 0
94		i=$((i + 1))
95	done
96}
97
98# Create an empty LOST.DIR in the root directory of $IMG, with cluster 400
99# holding its contents, so that reconnect() has somewhere to link a lost
100# chain to.  Everything not written here is already zero in a freshly
101# created file system, which is what these fields need to be.
102create_lost_dir()
103{
104	local bps spc rsvd nfats rootent fatsz i base rootoff dataoff dir
105
106	bps=$(bpb_read ${IMG} 11 2)
107	spc=$(bpb_read ${IMG} 13 1)
108	rsvd=$(bpb_read ${IMG} 14 2)
109	nfats=$(bpb_read ${IMG} 16 1)
110	rootent=$(bpb_read ${IMG} 17 2)
111	fatsz=$(bpb_read ${IMG} 22 2)
112
113	i=0
114	while [ "${i}" -lt "${nfats}" ]; do
115		base=$((rsvd * bps + i * fatsz * bps))
116		poke16 ${IMG} $((base + 400 * 2)) 65535
117		i=$((i + 1))
118	done
119
120	rootoff=$(((rsvd + nfats * fatsz) * bps))
121	dataoff=$((rootoff + rootent * 32))
122	dir=$((dataoff + (400 - 2) * spc * bps))
123
124	# The entry in the root directory.  16 is ATTR_DIRECTORY.
125	poke_str ${IMG} ${rootoff} 'LOST    DIR'
126	poke8 ${IMG} $((rootoff + 11)) 16
127	poke16 ${IMG} $((rootoff + 26)) 400
128
129	# Its "." and ".." entries.  The remainder of the cluster stays
130	# zero, which reads as SLOT_EMPTY, so reconnect() has free slots.
131	poke_str ${IMG} ${dir} '.          '
132	poke8 ${IMG} $((dir + 11)) 16
133	poke16 ${IMG} $((dir + 26)) 400
134	poke_str ${IMG} $((dir + 32)) '..         '
135	poke8 ${IMG} $((dir + 43)) 16
136}
137
138atf_test_case lost_chain_cleared
139lost_chain_cleared_head()
140{
141	atf_set "descr" "A lost chain that was cleared is not an error"
142	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
143}
144lost_chain_cleared_body()
145{
146	make_image
147	inject_lost_chain
148
149	# There is no LOST.DIR, so reconnect() fails and fsck_msdosfs(8)
150	# falls back to clearing the chain.  That repairs the file system,
151	# so the exit status must be 0 and not 8 (unrecovered error).
152	atf_check -s exit:0 \
153	    -o match:'Lost cluster chain at cluster 300' \
154	    -o match:'3 Cluster\(s\) lost' \
155	    -o match:'No LOST.DIR directory' \
156	    -o match:'Clear\? yes' \
157	    -e ignore \
158	    fsck_msdosfs -y ./${IMG}
159
160	# The repair has to be durable: a second pass must find nothing.
161	atf_check -s exit:0 -o not-match:'Lost cluster chain' -e ignore \
162	    fsck_msdosfs -y ./${IMG}
163}
164
165atf_test_case lost_chain_left_alone
166lost_chain_left_alone_head()
167{
168	atf_set "descr" "A lost chain that was not repaired is an error"
169	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
170}
171lost_chain_left_alone_body()
172{
173	make_image
174	inject_lost_chain
175	cp ${IMG} ${IMG}.save
176
177	# In -n mode nothing is repaired, so the lost chain must still be
178	# reported as an unrecovered error and the image must not change.
179	atf_check -s exit:8 -o match:'Lost cluster chain at cluster 300' \
180	    -e ignore fsck_msdosfs -n ./${IMG}
181	atf_check cmp ${IMG}.save ${IMG}
182}
183
184atf_test_case lost_chain_preen
185lost_chain_preen_head()
186{
187	atf_set "descr" "Preen mode reports a lost chain it cannot reconnect"
188	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
189}
190lost_chain_preen_body()
191{
192	make_image
193	inject_lost_chain
194
195	# Preen mode attempts the reconnect but never clears, so with no
196	# LOST.DIR the chain stays lost and has to be reported.
197	atf_check -s exit:8 -o match:'Lost cluster chain at cluster 300' \
198	    -o match:'No LOST.DIR directory' -e ignore \
199	    fsck_msdosfs -p -f ./${IMG}
200}
201
202atf_test_case corrupted_lost_chain_left_alone
203corrupted_lost_chain_left_alone_head()
204{
205	atf_set "descr" "A corrupted lost chain that checkchain fails on is an error if left alone"
206	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
207}
208corrupted_lost_chain_left_alone_body()
209{
210	make_image
211	inject_corrupted_lost_chain
212	cp ${IMG} ${IMG}.save
213
214	# In -n mode nothing is repaired, so checkchain() returns FSERROR when
215	# the chain ends unexpectedly with CLUST_FREE.  checklost() must not
216	# swallow this FSERROR, so fsck_msdosfs must exit 8 and leave the image unchanged.
217	atf_check -s exit:8 \
218	    -o match:'Cluster chain starting at 300 ends with cluster marked free' \
219	    -e ignore fsck_msdosfs -n ./${IMG}
220	atf_check cmp ${IMG}.save ${IMG}
221}
222
223atf_test_case corrupted_lost_chain_reconnected
224corrupted_lost_chain_reconnected_head()
225{
226	atf_set "descr" "Truncating a lost chain before reconnecting it is written out"
227	atf_set "require.progs" "newfs_msdos fsck_msdosfs"
228}
229corrupted_lost_chain_reconnected_body()
230{
231	make_image
232	create_lost_dir
233	# Adding LOST.DIR by hand must not have damaged anything.
234	atf_check -s exit:0 -o ignore -e ignore fsck_msdosfs -y ./${IMG}
235	inject_corrupted_lost_chain
236
237	# checkchain() truncates the chain (FSFATMOD) and reconnect() then
238	# links it into LOST.DIR (FSDIRMOD).  Both results have to reach
239	# mod: without the FSFATMOD, checkfilesys() never writes the FATs
240	# back and the truncation is silently discarded.
241	atf_check -s exit:0 \
242	    -o match:'Cluster chain starting at 300 ends with cluster marked free' \
243	    -o match:'Truncate\? yes' \
244	    -o match:'Lost cluster chain at cluster 300' \
245	    -o match:'Update FATs\? yes' \
246	    -e ignore \
247	    fsck_msdosfs -y ./${IMG}
248
249	# The truncation has to be durable: a second pass must find nothing.
250	atf_check -s exit:0 -o not-match:'ends with cluster marked free' \
251	    -e ignore fsck_msdosfs -y ./${IMG}
252}
253
254atf_init_test_cases()
255{
256	atf_add_test_case lost_chain_cleared
257	atf_add_test_case lost_chain_left_alone
258	atf_add_test_case lost_chain_preen
259	atf_add_test_case corrupted_lost_chain_left_alone
260	atf_add_test_case corrupted_lost_chain_reconnected
261}
262