[PATCH] xfs: test unlinked inode list checking and repair with loops

"Darrick J. Wong" <[email protected]>
Newsgroups org.kernel.vger.linux-xfs,org.kernel.vger.fstests
Message-ID <20260819155225.GB839663@frogsfrogsfrogs>
From: Darrick J. Wong <[email protected]>

Simple test of various weird ways we can screw up unlinked inode list
reconstruction.

Signed-off-by: "Darrick J. Wong" <[email protected]>
Reviewed-by: Christoph Hellwig <[email protected]>
---
 tests/xfs/1907     |  212 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/xfs/1907.out |   17 ++++
 2 files changed, 229 insertions(+)
 create mode 100755 tests/xfs/1907
 create mode 100644 tests/xfs/1907.out

diff --git a/tests/xfs/1907 b/tests/xfs/1907
new file mode 100755
index 00000000000000..eb817eddd8a22f
--- /dev/null
+++ b/tests/xfs/1907
@@ -0,0 +1,212 @@
+#! /bin/bash
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Oracle.  All Rights Reserved.
+#
+# FS QA Test No. 1907
+#
+# Test using online fsck code to fix unlinked inodes on a clean filesystem that
+# never got cleaned up.
+#
+. ./common/preamble
+_begin_fstest auto quick unlink
+
+. ./common/filter
+. ./common/fuzzy
+. ./common/quota
+
+_require_command "$TIMEOUT_PROG" timeout
+_require_xfs_db_command iunlink
+_require_scratch_nocheck	# we'll run repair ourselves
+
+_scratch_mount
+$XFS_IO_PROG -x -c 'repair -R agi 0' $SCRATCH_MNT 2>&1 | \
+	grep -q 'Operation not supported' && \
+	_notrun "cannot repair agi"
+_scratch_unmount
+
+# From the AGI definition
+XFS_AGI_UNLINKED_BUCKETS=64
+NULLAGINO="0xffffffff"
+
+# Try to make each iunlink bucket have this many inodes in it.
+IUNLINK_BUCKETLEN=5
+
+# Disable quota since quotacheck will break this test
+orig_mount_options="$MOUNT_OPTIONS"
+_qmount_option 'noquota'
+
+format_scratch() {
+	_scratch_mkfs -d agcount=1 | _filter_mkfs 2> "${tmp}.mkfs" >> $seqres.full
+	source "${tmp}.mkfs"
+	test "${agcount}" -eq 1 || _notrun "test requires 1 AG for error injection"
+
+	local nr_iunlinks="$((IUNLINK_BUCKETLEN * XFS_AGI_UNLINKED_BUCKETS))"
+	readarray -t BADINODES < <(_scratch_xfs_db -x -c "iunlink -n $nr_iunlinks" | awk '{print $4}')
+
+	ROOTINO="$(_scratch_xfs_get_sb_field rootino)"
+
+	BUCKET_23=()
+	BUCKET_24=()
+	for badinode in "${BADINODES[@]}"; do
+		(( (badinode % 64) == 23 )) && BUCKET_23+=("${badinode}")
+		(( (badinode % 64) == 24 )) && BUCKET_24+=("${badinode}")
+	done
+	test "${#BUCKET_23[@]}" -ge "$IUNLINK_BUCKETLEN" || \
+		echo "bucket 23 should have at least $IUNLINK_BUCKETLEN inodes, has ${#BUCKET_23[@]}"
+	test "${#BUCKET_24[@]}" -ge "$IUNLINK_BUCKETLEN" || \
+		echo "bucket 24 should have at least $IUNLINK_BUCKETLEN inodes, has ${#BUCKET_23[@]}"
+
+	# Log what we think the bucket 23 unlinked list will look like
+	printf "ROOTINO 0x%x\n" "$ROOTINO" >> $seqres.full
+	for badinode in "${BUCKET_23[@]}"; do
+		printf "0x%x <- " "${badinode}" >> $seqres.full
+	done
+	echo " AGI.iunlinked[23]" >> $seqres.full
+	for badinode in "${BUCKET_24[@]}"; do
+		printf "0x%x <- " "${badinode}" >> $seqres.full
+	done
+	echo " AGI.iunlinked[24]" >> $seqres.full
+
+	# Log what the actual bucket 23 unlinked list ended up looking like
+	local subcommands=()
+	for badinode in "${BUCKET_23[@]}"; do
+		subcommands+=(-c "inode ${badinode}" -c "print next_unlinked")
+	done
+	_scratch_xfs_db -x "${subcommands[@]}" >> $seqres.full
+}
+
+__repair_check_scratch() {
+	_scratch_xfs_repair -o force_geometry -n 2>&1 | \
+		tee -a $seqres.full | \
+		grep -E '(disconnected inode.*would move|next_unlinked in inode|unlinked bucket.*is.*in ag)'
+	return "${PIPESTATUS[0]}"
+}
+
+exercise_scratch() {
+	# Create a bunch of files...
+	declare -A inums
+	for ((i = 0; i < (XFS_AGI_UNLINKED_BUCKETS * 2); i++)); do
+		touch "${SCRATCH_MNT}/${i}" || break
+		inums["${i}"]="$(stat -c %i "${SCRATCH_MNT}/${i}")"
+	done
+
+	# ...then delete them to exercise the unlinked buckets
+	for ((i = 0; i < (XFS_AGI_UNLINKED_BUCKETS * 2); i++)); do
+		if ! rm -f "${SCRATCH_MNT}/${i}"; then
+			echo "rm failed on inum ${inums[$i]}"
+			break
+		fi
+	done
+}
+
+test_body() {
+	_scratch_mount
+	timeout $((30 * TIME_FACTOR))s $XFS_IO_PROG -x -c 'scrub agi 0' -c 'repair agi 0' $SCRATCH_MNT
+	$XFS_IO_PROG -x -c 'repair fscounters' $SCRATCH_MNT >> $seqres.full
+	exercise_scratch
+	_scratch_unmount
+	final_check_scratch
+}
+
+# Offline repair should not find anything
+final_check_scratch() {
+	__repair_check_scratch
+	res=$?
+	if [ $res -eq 2 ]; then
+		echo "scratch fs went offline?"
+		_scratch_mount
+		_scratch_unmount
+		__repair_check_scratch
+	fi
+	test "$res" -ne 0 && echo "repair returned $res?"
+}
+
+echo "+ Part 1: Fix a correct unlinked list" | tee -a $seqres.full
+_kernlog "part 1"
+format_scratch
+test_body
+
+echo "+ Part 2: Fix a loop between 1 and 3" | tee -a $seqres.full
+_kernlog "part 2"
+format_scratch
+# BUCKET_23 is in reverse order of the ondisk list, so we make
+# inode 1 point back to inode 3.
+_scratch_xfs_db -x \
+	-c "inode ${BUCKET_23[1]}" \
+	-c "print next_unlinked" \
+	-c "write -d next_unlinked ${BUCKET_23[3]}" \
+	>> $seqres.full
+test_body
+
+echo "+ Part 3: Fix a truncated bucket" | tee -a $seqres.full
+_kernlog "part 3"
+format_scratch
+_scratch_xfs_db -x \
+	-c "agi 0" \
+	-c "print" \
+	-c "write -d unlinked[23] ${NULLAGINO}" \
+	>> $seqres.full
+test_body
+
+echo "+ Part 4: Fix a loop at the end" | tee -a $seqres.full
+_kernlog "part 4"
+format_scratch
+# BUCKET_23 is in reverse order of the ondisk list, so we make
+# inode 0 point back to inode 0.
+_scratch_xfs_db -x \
+	-c "inode ${BUCKET_23[0]}" \
+	-c "print next_unlinked" \
+	-c "write -d next_unlinked ${BUCKET_23[0]}" \
+	>> $seqres.full
+test_body
+
+echo "+ Part 5: Fix an inode in the wrong bucket" | tee -a $seqres.full
+_kernlog "part 5"
+format_scratch
+_scratch_xfs_db -x \
+	-c "inode ${BUCKET_23[0]}" \
+	-c "print next_unlinked" \
+	-c "write -d next_unlinked ${BUCKET_24[1]}" \
+	-c "inode ${BUCKET_24[2]}" \
+	-c "print next_unlinked" \
+	-c "write -d next_unlinked ${NULLAGINO}" \
+	>> $seqres.full
+test_body
+
+echo "+ Part 6: Fix an inode that isn't free and truncates list" | tee -a $seqres.full
+_kernlog "part 6"
+format_scratch
+_scratch_xfs_db -x \
+	-c "inode ${BUCKET_23[2]}" \
+	-c "print next_unlinked" \
+	-c "write -d next_unlinked ${ROOTINO}" \
+	>> $seqres.full
+test_body
+
+echo "+ Part 7: Fix an inode that isn't free" | tee -a $seqres.full
+_kernlog "part 7"
+format_scratch
+_scratch_xfs_db -x \
+	-c "inode ${BUCKET_23[2]}" \
+	-c "print next_unlinked" \
+	-c "write -d next_unlinked ${ROOTINO}" \
+	-c "inode ${ROOTINO}" \
+	-c "print next_unlinked" \
+	-c "write -d next_unlinked ${BUCKET_23[1]}" \
+	>> $seqres.full
+test_body
+
+echo "+ Part 8: Fix an totally unallocated inode" | tee -a $seqres.full
+_kernlog "part 8"
+format_scratch
+target=$(( BADINODES[-1] + 256 ))
+_scratch_xfs_db -x \
+	-c "inode ${BUCKET_23[2]}" \
+	-c "print next_unlinked" \
+	-c "write -d next_unlinked $target" \
+	>> $seqres.full
+test_body
+
+# success, all done
+status=0
+exit
diff --git a/tests/xfs/1907.out b/tests/xfs/1907.out
new file mode 100644
index 00000000000000..83746ad602b21d
--- /dev/null
+++ b/tests/xfs/1907.out
@@ -0,0 +1,17 @@
+QA output created by 1907
++ Part 1: Fix a correct unlinked list
+Corruption detected.
++ Part 2: Fix a loop between 1 and 3
+Corruption detected.
++ Part 3: Fix a truncated bucket
+Corruption detected.
++ Part 4: Fix a loop at the end
+Corruption detected.
++ Part 5: Fix an inode in the wrong bucket
+Corruption detected.
++ Part 6: Fix an inode that isn't free and truncates list
+Corruption detected.
++ Part 7: Fix an inode that isn't free
+Corruption detected.
++ Part 8: Fix an totally unallocated inode
+Corruption detected.
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.