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