Skip to content

Instantly share code, notes, and snippets.

@vbz-repo
Last active August 25, 2024 11:30
Show Gist options
  • Select an option

  • Save vbz-repo/b012cc7ca5d04304e6b1caa4cbfc4638 to your computer and use it in GitHub Desktop.

Select an option

Save vbz-repo/b012cc7ca5d04304e6b1caa4cbfc4638 to your computer and use it in GitHub Desktop.
Comparing the metadata of two files
#!/usr/bin/env bash
# Compare the metadata of two files:
# file type
# permission bits in octal
# raw mode in hex
# user ID of owner
# group ID of owner
# user name of owner
# group name of owner
# total size, in bytes
# number of hard links
# inode number
# time of last access, seconds since Epoch
# time of last data modification, seconds since Epoch
# time of last status change, seconds since Epoch
# time of file birth, seconds since Epoch; 0 if unknown
# extended attributes
#
# BSD Zero Clause License
#
# Copyright (C) 2024 by Valdis Bluzma
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
# AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
# OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
# PERFORMANCE OF THIS SOFTWARE.
#
# TODO:
# 2024-07-11 Add Access Control Lists (ACLs) permissions for comparison.
#
# URL: <https://gist.github.com/vbz-repo/b012cc7ca5d04304e6b1caa4cbfc4638>
# The last modified date is used as the script version.
readonly LAST_MODIFIED='2024-08-25 13:38:21'
readonly SCRIPT_NAME='compare-file-metadata'
# ------------------------------
# Output error information to STDERR.
# Globals:
# SCRIPT_NAME
# Arguments:
# $*: error message
# Outputs:
# STDERR: error information
# ------------------------------
error() {
printf "%s: %b\n" "${SCRIPT_NAME}" "${*:-"Unknown Error"}" >&2
}
# ------------------------------
# Outputs:
# STDOUT: script website
# ------------------------------
print_website() {
local ws='https://gist.github.com/vbz-repo/b012cc7ca5d04304e6b1caa4cbfc4638'
printf 'Web site: <%s>' "${ws}"
}
# ------------------------------
# Globals:
# SCRIPT_NAME
# LAST_MODIFIED
# Outputs:
# STDOUT: script name, last modified date
# ------------------------------
print_last_modified() {
printf '%s, last modified: %s' "${SCRIPT_NAME}" "${LAST_MODIFIED}"
}
# ------------------------------
# Display script usage information.
# Globals:
# SCRIPT_NAME
# Arguments:
# None
# Outputs:
# STDOUT: script usage information
# ------------------------------
usage() {
cat <<EOF
Usage:
${SCRIPT_NAME} --help
${SCRIPT_NAME} --version
${SCRIPT_NAME} [OPTION]... FILE1 FILE2
Compare the metadata of FILE1 and FILE2:
file type
permission bits in octal
raw mode in hex
user ID of owner
group ID of owner
user name of owner
group name of owner
total size, in bytes
number of hard links
inode number
time of last access, seconds since Epoch
time of last data modification, seconds since Epoch
time of last status change, seconds since Epoch
time of file birth, seconds since Epoch; 0 if unknown
extended attributes
Options:
-h, --hlinks include number of hard links for comparison
-i, --inode include inode number for comparison
-X, --atime include access time for comparison
-Y, --mtime include modification time for comparison
-Z, --ctime include time of status change for comparison
-W, --crtime include birth (or creation) time
--xattrs include extended attributes for comparison
--suppress-common-lines do not output common lines
-v, --verbose report additional details:
the metadata is identical or different
--help display help and exit
--version output version information and exit
EOF
}
# ------------------------------
# Outputs:
# STDOUT: the script version and license information
# ------------------------------
version() {
cat <<EOF
$(print_last_modified)
Copyright (C) 2024 by Valdis Bluzma
License: 0BSD BSD Zero Clause License
For more information, please refer to <https://spdx.org/licenses/0BSD.html>
$(print_website)
EOF
}
# ------------------------------
# Outputs:
# STDOUT: help information
# ------------------------------
help() {
cat <<EOF
$(print_last_modified)
$(usage)
$(print_website)
EOF
}
# ------------------------------
# Check for required external commands.
# Globals:
# FUNCNAME
# Arguments:
# $@: the names of required commands
# Outputs:
# STDERR: if a command is not found, then output the error message
# Returns:
# 1 if a command is not found
# ------------------------------
check_dependencies() {
# no arguments
if (( $# == 0 )); then
error "${FUNCNAME[0]}: missing argument"
return 1
fi
for cmd in "$@"; do
if ! hash -- "${cmd}" >/dev/null 2>&1; then
error "the command '${cmd}' is not found"
return 1
fi
done
return 0
}
# ------------------------------
# Get all extended attributes.
# Globals:
# FUNCNAME
# Arguments:
# $1: file or directory
# Outputs:
# STDOUT: formatted string of file attributes
# ------------------------------
getxattrs() {
# Check for 'getfattr' external command.
local -ar dependencies=(getfattr)
check_dependencies "${dependencies[@]}" || return 1
# Check the number of positional parameters.
if (( $# != 1 )); then
local error_message
if (( $# == 0 )); then # no arguments
error_message='missing argument'
else
error_message='too many arguments'
fi
error "${FUNCNAME[0]}: ${error_message}"
return 1
fi
# Check if the file exists.
local file="$1"
if [[ ! -e "${file}" ]]; then
error "'${file}': the file or directory does not exist"
return 1
fi
local data
local -a getfattr_options
getfattr_options+=(--dump)
getfattr_options+=(--match=-) # "-": for including all attributes
getfattr_options+=(--no-dereference)
getfattr_options+=(--absolute-names)
getfattr_options+=(--encoding=hex)
getfattr_options+=(--) # "--": end of command line options
local -r getfattr_options # set readonly attribute
data="$(getfattr "${getfattr_options[@]}" "${file}")" || return 1
if [[ -n "${data}" ]]; then # file with extended attributes
# Check the output format of 'getfattr'.
if [[ ! "${data}" =~ ^"# file: ".+$ || ! "${data}" =~ ($'\n')+ ]]; then
error 'extended attributes:' \
"the output format of 'getfattr' is incorrect:\n'${data}'"
return 1
fi
# Remove the first line of output.
data="xattrs:\n${data#*$'\n'}"
else
data="xattrs:-" # without extended attributes
fi
printf "%s" "${data}"
return 0
}
# ------------------------------
# main program
# Globals:
# None
# Arguments:
# $@: the command line options and arguments (FILE1, FILE2)
# Outputs:
# STDOUT: the comparison result of the 'diff' command in a side-by-side format
# STDERR: all error messages
# Returns:
# The exit status is the same as the 'diff' command status.
# 0 if metadata (FILE1, FILE2) are the same
# 1 if different
# 2 if trouble
# ------------------------------
main() {
# variables for the command line options
local hlinks # number of hard links (-h, --hlinks)
local inode # inode number (-i, --inode)
local atime # access time (-X, --atime)
local mtime # modification time (-Y, --mtime)
local ctime # time of status change (-Z, --ctime)
local crtime # creation or birth time (-W, --crtime)
local xattrs # extended attributes (--xattrs)
# Do not output common lines - the command line option.
# (--suppress-common-lines)
local suppress_common_lines
# Output an additional message about the comparison result:
# the metadata is identical or not. (-v, --verbose)
local verbose
# Declare the array of positional arguments containing files to compare,
# not parameters like options/flags.
local -a arguments
local -i arguments_count
# the array for the metadata of files
local -a arguments_metadata
# the default options for the 'diff' command
# "-y" or "--side-by-side": output in two columns
local -a diff_options
diff_options=(-y --width=100)
# default file metadata attributes
local attributes
attributes+="type:%F\n"
attributes+="permission:%#a\n"
attributes+="raw:%f\n"
attributes+="uid:%u\n"
attributes+="gid:%g\n"
attributes+="user:%U\n"
attributes+="group:%G\n"
attributes+="size:%s\n"
local error_message
local -i exit_status
# Check required external commands.
local -ar dependencies=(cat stat diff)
check_dependencies "${dependencies[@]}" || exit 2
# no arguments
if (( $# == 0 )); then
error 'missing argument'
usage
exit 2
fi
# Parse command-line arguments.
while (( $# > 0 )); do
case "$1" in
--help)
help
exit 0
;;
--version)
version
exit 0
;;
-h | --hlinks) # number of hard links
hlinks='true'
shift
;;
-i | --inode) # inode number
inode='true'
shift
;;
-X | --atime) # file access time
atime='true'
shift
;;
-Y | --mtime) # file modification time
mtime='true'
shift
;;
-Z | --ctime) # file change time
ctime='true'
shift
;;
-W | --crtime) # file creation time
crtime='true'
shift
;;
--xattrs) # extended attributes
xattrs='true'
shift
;;
--suppress-common-lines) # don't output common lines
suppress_common_lines='true'
shift
;;
-v | --verbose)
verbose='true'
shift
;;
--* | -*)
error "invalid option '$1'"
usage
exit 2
;;
*)
# Save positional arguments such as FILE1 or FILE2.
arguments+=("$1")
shift # past argument
;;
esac
done
# Check the number of positional arguments.
arguments_count=${#arguments[@]}
if (( arguments_count != 2 )); then
if (( arguments_count < 2 )); then
error_message='missing argument'
else
error_message='too many arguments'
fi
error "${error_message}"
usage
exit 2
fi
exit_status=0
# Check if files exist.
for file in "${arguments[@]}"; do
if [[ ! -e "${file}" ]]; then
error "'${file}': the file or directory does not exist"
exit_status=2
fi
done
(( exit_status == 2 )) && exit 2
# Check and process the command line options:
# hlinks, inode, atime, mtime, ctime, crtime.
if [[ "${hlinks}" == 'true' ]]; then
attributes+="hlinks:%h\n"
fi
if [[ "${inode}" == 'true' ]]; then
attributes+="inode:%i\n"
fi
if [[ "${atime}" == 'true' ]]; then
attributes+="atime:%X\n"
fi
if [[ "${mtime}" == 'true' ]]; then
attributes+="mtime:%Y\n"
fi
if [[ "${ctime}" == 'true' ]]; then
attributes+="ctime:%Z\n"
fi
if [[ "${crtime}" == 'true' ]]; then
attributes+="crtime:%W\n"
fi
for i in "${!arguments[@]}"; do
arguments_metadata[i]="$(stat \
--printf="${attributes}" "${arguments[$i]}")\n" || exit 2
# extended attributes
if [[ "${xattrs}" == 'true' ]]; then
# Get a dump of all extended attributes.
arguments_metadata[i]+="$(getxattrs "${arguments[$i]}")\n" || exit 2
fi
done
# Do not output common lines.
if [[ "${suppress_common_lines}" == 'true' ]]; then
diff_options+=(--suppress-common-lines)
fi
# Compare with the 'diff' command.
diff "${diff_options[@]}" \
<(printf '%b' "${arguments_metadata[0]}") \
<(printf '%b' "${arguments_metadata[1]}")
exit_status=$?
# Output an additional message about the comparison result.
if [[ "${verbose}" == 'true' ]]; then
(( exit_status == 0 )) && echo 'The metadata of the files are identical'
(( exit_status == 1 )) \
&& echo 'The metadata of the files are NOT identical'
fi
return ${exit_status}
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment