#!/bin/bash
# Copyright 2018-2020 eomanis
#
# This file is part of disk-test.
#
# disk-test is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# disk-test is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with disk-test. If not, see <http://www.gnu.org/licenses/>.
# TODO Default settings via config file(s), such as
# /etc/disk-test/disk-test.conf
# TODO Bash completion
# Set shell options
set -o errexit
set -o noclobber
set -o pipefail
set -o nounset
# Semantic versioning
declare -r versionMajor=0
declare -r versionMinor=2
declare -r versionPatch=4
declare -r versionLabel=""
getVersion () {
echo -n "${versionMajor}.${versionMinor}.${versionPatch}"
test "$versionLabel" != "" && echo "-$versionLabel" || echo ""
}
# Make sure the required programs are available
allRequiredProgramsPresent=true
type base64 &> /dev/null || { echo "ERROR Required program \"base64\" is not available" >&2; allRequiredProgramsPresent=false; }
type blockdev &> /dev/null || { echo "ERROR Required program \"blockdev\" is not available" >&2; allRequiredProgramsPresent=false; }
type cmp &> /dev/null || { echo "ERROR Required program \"cmp\" is not available" >&2; allRequiredProgramsPresent=false; }
type dd &> /dev/null || { echo "ERROR Required program \"dd\" is not available" >&2; allRequiredProgramsPresent=false; }
type grep &> /dev/null || { echo "ERROR Required program \"grep\" is not available" >&2; allRequiredProgramsPresent=false; }
type openssl &> /dev/null || { echo "ERROR Required program \"openssl\" is not available" >&2; allRequiredProgramsPresent=false; }
type pv &> /dev/null || { echo "ERROR Required program \"pv\" is not available" >&2; allRequiredProgramsPresent=false; }
type realpath &> /dev/null || { echo "ERROR Required program \"realpath\" is not available" >&2; allRequiredProgramsPresent=false; }
type sed &> /dev/null || { echo "ERROR Required program \"sed\" is not available" >&2; allRequiredProgramsPresent=false; }
type stat &> /dev/null || { echo "ERROR Required program \"stat\" is not available" >&2; allRequiredProgramsPresent=false; }
type tr &> /dev/null || { echo "ERROR Required program \"tr\" is not available" >&2; allRequiredProgramsPresent=false; }
$allRequiredProgramsPresent || exit 1
unset allRequiredProgramsPresent
# No arguments, or --help as single argument
if test $# -eq 0 || { test $# -eq 1 && test "$1" = "--help"; }; then
echo -n "disk-test "; getVersion
echo -n "Block device read-write test utility written in bash
Usage:
disk-test
[-a|--actions wvzr...]
[-b|--block-size blockSize]
[-c|--count-bytes numberOfBytes]
[-s|--skip-bytes numberOfBytes]
[--]
deviceOrFile
disk-test [--help]
Tests if a device can write and correctly read arbitrary data over its
entire reported size, or over a specified range.
The desired sequence of actions is given as a string of characters.
Available actions are:
w - Write pseudo-random data
v - Read and verify the data written by the most recent preceding
write action
z - Write zeros
r - Read, but do not verify, the data on the device
Default: \"r\"
For more information see manual page disk-test(1).
"
exit 1
fi
# Declare defaults
declare -r actionsDefault="r"
declare -r blockSizeDefault="8M"
declare -r skipBytesDefault=0
# Set defaults
actions="$actionsDefault"
blockSize="$blockSizeDefault"
skipBytes="$skipBytesDefault"
# countBytes will be calculated/validated later
# Parse the arguments
trailingArgs=false
while test $# -gt 0; do
if ! $trailingArgs && test "$1" = "--"; then
trailingArgs=true; shift
elif $trailingArgs || ! { test ${#1} -ge 1 && test "${1:0:1}" = "-"; }; then
# Trailing arguments section reached, or the argument does not
# start with "-": Must be the device
if ! test -z ${device+x}; then
echo "ERROR Multiple devices specified: \"$device\", \"$1\"" >&2
exit 1
fi
device="$1"; shift
elif test "$1" = "-a" || test "$1" = "--actions"; then
test $# -ge 2 || { echo "ERROR No value given for argument \"$1\"" >&2; exit 1; }
shift
actions="$1"; shift
elif test "$1" = "-b" || test "$1" = "--block-size"; then
test $# -ge 2 || { echo "ERROR No value given for argument \"$1\"" >&2; exit 1; }
shift
blockSize="$1"; shift
elif test "$1" = "-s" || test "$1" = "--skip-bytes"; then
test $# -ge 2 || { echo "ERROR No value given for argument \"$1\"" >&2; exit 1; }
shift
skipBytes="$1"; shift
elif test "$1" = "-c" || test "$1" = "--count-bytes"; then
test $# -ge 2 || { echo "ERROR No value given for argument \"$1\"" >&2; exit 1; }
shift
countBytes="$1"; shift
else
echo "ERROR Unknown argument \"$1\"" >&2; exit 1
fi
done
unset trailingArgs
# Validate the device
if test -z ${device+x}; then
echo "ERROR No device specified" >&2
exit 1
fi
if test -L "$device"; then
echo -n " INFO Device \"$device\" is a symbolic link to " >&2
device="$(realpath --physical --canonicalize-missing "$device")"
echo "\"$device\"" >&2
fi
if ! test -b "$device" && ! test -f "$device"; then
echo "ERROR Neither a block device nor a regular file: \"$device\"" >&2
exit 1
fi
# Determine the device's or file's size
declare -i deviceSizeTmp
if test -b "$device"; then
if ! deviceSizeTmp="$(blockdev --getsize64 "$device")" || test "$deviceSizeTmp" = ""; then
echo "ERROR Failed to determine the size of device \"$device\"" >&2
exit 1
fi
else
if ! deviceSizeTmp="$(stat -c "%s" "$device")" || test "$deviceSizeTmp" = ""; then
echo "ERROR Failed to determine the size of file \"$device\"" >&2
exit 1
fi
fi
declare -r -i deviceSize="$deviceSizeTmp"
unset deviceSizeTmp
# Validate --actions
if ! echo "$actions" | grep -E '^[wzvr]*$' &> /dev/null; then
echo "ERROR Invalid character(s) in actions string \"$actions\"; allowed characters: wzvr" >&2
exit 1
fi
if echo "$actions" | grep "v" &> /dev/null \
&& ! echo "$actions" | sed -re 's/^([^v]*)v.*$/\1/;s/[^wz]+//g' | grep -E '^[wz]+$' &> /dev/null; then
echo "ERROR Invalid sequence of actions \"$actions\": Read-and-verify action (v) without preceding write action (wz)" >&2
exit 1
fi
# Validate --block-size (basic validation only)
if test "$blockSize" = "" || ! echo "$blockSize" | grep -E '^[1-9]' &> /dev/null; then
# The block size must not be empty, and must start with a digit 1..9
echo "ERROR Invalid value for --block-size: \"$blockSize\"" >&2
exit 1
fi
# Validate --skip-bytes
if ! echo "$skipBytes" | grep -E '^(0|[1-9][0-9]*)$' &> /dev/null; then
echo "ERROR Value for --skip-bytes must be an integer >= 0, was \"$skipBytes\"" >&2
exit 1
fi
if test "$skipBytes" -gt "$deviceSize"; then
echo "ERROR Value for --skip-bytes must not exceed the device's size in bytes ($deviceSize), was $skipBytes" >&2
exit 1
fi
# Calculate (if required) and validate --count-bytes
countBytesMax="$(( "$deviceSize" - "$skipBytes" ))"
if test -z ${countBytes+x}; then
countBytes="$countBytesMax"
fi
if ! echo "$countBytes" | grep -E '^(0|[1-9][0-9]*)$' &> /dev/null; then
echo "ERROR Value for --count-bytes must be an integer >= 0, was \"$countBytes\"" >&2
exit 1
fi
if test "$countBytes" -gt "$countBytesMax"; then
echo "ERROR Value for --count-bytes must not exceed the number of bytes from --skip-bytes to the end of the device ($countBytesMax), was $countBytes" >&2
exit 1
fi
unset countBytesMax
# Print a summary
echo " INFO Testing block device or file: \"$device\"" >&2
echo " INFO Performing ${#actions} action(s): \"$actions\"" >&2
test "$skipBytes" -ne "$skipBytesDefault" && echo " INFO Skipping the first $skipBytes byte(s)" >&2
test "$countBytes" -ne "$deviceSize" && echo " INFO Testing a range of $countBytes byte(s)" >&2
test "$blockSize" != "$blockSizeDefault" && echo " INFO Using custom block size \"$blockSize\"" >&2
# Handle some corner cases up front
if test "$countBytes" -eq 0; then
echo " WARN Range to test is 0 bytes wide, nothing to do" >&2
exit 0
fi
if test ${#actions} -eq 0; then
echo " WARN No actions specified, nothing to do" >&2
exit 0
fi
# Perform actions
declare -i actionIndex=0
declare action
declare -i stepreturn
declare seed
declare mostRecentWriteAction
while test "$actionIndex" -lt ${#actions}; do
action="${actions:$actionIndex:1}"
echo -n " INFO Action $(( actionIndex + 1 ))/${#actions} (${action}) on device \"$device\": " >&2
# shellcheck disable=SC2018 # We really just want to upper-case
# shellcheck disable=SC2019 # w, v, z and r
echo "$actions" | sed -re 's/^(.{'$actionIndex'})./\1'"$(echo "$action" | tr 'a-z' 'A-Z')"'/' >&2
if test "$action" = "w"; then
mostRecentWriteAction="$action"
if ! seed="$(dd if=/dev/urandom iflag=fullblock bs=128 count=1 2>/dev/null | base64 --wrap 0)"; then
echo "ERROR Could not generate random seed for pseudo-random data generation" >&2
exit 1
fi
dd if=/dev/zero iflag=fullblock,count_bytes count="$countBytes" ibs="$blockSize" 2> /dev/null \
| openssl enc -aes-256-ctr -pass 'pass:'"$seed" -nosalt -pbkdf2 -iter 1 \
| pv --progress --timer --eta --rate --bytes --size "$countBytes" --name " INFO Writing pseudo-random" \
| dd of="$device" conv=notrunc oflag=direct,seek_bytes seek="$skipBytes" obs="$blockSize" 2> /dev/null \
&& stepreturn=$? || stepreturn=$?
if test $stepreturn != 0; then
echo "ERROR Writing pseudo-random data failed with return value $stepreturn" >&2
exit 2
fi
elif test "$action" = "z"; then
mostRecentWriteAction="$action"
dd if=/dev/zero iflag=fullblock,count_bytes count="$countBytes" ibs="$blockSize" 2> /dev/null \
| pv --progress --timer --eta --rate --bytes --size "$countBytes" --name " INFO Writing zeros" \
| dd of="$device" conv=notrunc oflag=direct,seek_bytes seek="$skipBytes" obs="$blockSize" 2> /dev/null \
&& stepreturn=$? || stepreturn=$?
if test $stepreturn != 0; then
echo "ERROR Writing zeros failed with return value $stepreturn" >&2
exit 2
fi
elif test "$action" = "v"; then
if test "$mostRecentWriteAction" = "w"; then
dd if=/dev/zero iflag=fullblock,count_bytes count="$countBytes" ibs="$blockSize" 2> /dev/null \
| openssl enc -aes-256-ctr -pass 'pass:'"$seed" -nosalt -pbkdf2 -iter 1 \
| pv --progress --timer --eta --rate --bytes --size "$countBytes" --name " INFO Reading and comparing" \
| cmp --quiet --ignore-initial="${skipBytes}:0" --bytes="$countBytes" -- "$device" - \
&& stepreturn=$? || stepreturn=$?
if test $stepreturn != 0; then
echo "ERROR Reading and comparing pseudo-random data failed with return value $stepreturn" >&2
exit 2
fi
elif test "$mostRecentWriteAction" = "z"; then
dd if=/dev/zero iflag=fullblock,count_bytes count="$countBytes" ibs="$blockSize" 2> /dev/null \
| pv --progress --timer --eta --rate --bytes --size "$countBytes" --name " INFO Reading and comparing" \
| cmp --quiet --ignore-initial="${skipBytes}:0" --bytes="$countBytes" -- "$device" - \
&& stepreturn=$? || stepreturn=$?
if test $stepreturn != 0; then
echo "ERROR Reading and comparing zeros failed with return value $stepreturn" >&2
exit 2
fi
else
echo "ERROR Preceding write action is not known" >&2
exit 1
fi
elif test "$action" = "r"; then
dd if="$device" iflag=fullblock,skip_bytes,count_bytes skip="$skipBytes" count="$countBytes" ibs="$blockSize" 2> /dev/null \
| pv --progress --timer --eta --rate --bytes --size "$countBytes" --name " INFO Reading" \
| dd of=/dev/null conv=notrunc obs="$blockSize" 2> /dev/null \
&& stepreturn=$? || stepreturn=$?
if test $stepreturn != 0; then
echo "ERROR Reading failed with return value $stepreturn" >&2
exit 2
fi
else
echo "ERROR Unknown action \"$action\" (what the hell, argument validation should have caught that)" >&2
exit 1
fi
actionIndex=$(( actionIndex + 1 ))
done
echo " INFO Finished, no errors: \"$device\"" >&2