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