#!/bin/bash # Includes . $(miniade) || { echo "${0##*/}: ERROR: miniade failed (hint: run 'miniade' to see error)" >&2; exit 1; } # Configurable stuff #DBFILE=/tmp/$PROGNAME.sqlite # Other globals LOTS_OF_EQUALS="==============================================================================================================================================" SUFFIX=md5sum main() { local MY_ARGS PROGNAME ZENCODED_ROOT_DIR DESIRED_MODES ROOT_DIR miniade_assert_set SUFFIX # Defaults for options DESIRED_MODES=() # Process options special_opts_handler() { case $1 in --scan) DESIRED_MODES=( "${DESIRED_MODES[@]}" scan ) ;; --compare) DESIRED_MODES=( "${DESIRED_MODES[@]}" compare ) ;; --freeze) DESIRED_MODES=( "${DESIRED_MODES[@]}" freeze ) ;; --list) DESIRED_MODES=( "${DESIRED_MODES[@]}" list ) ;; --progname=*) miniade_set_progname ${1#*=} ;; *) return 1 ;; esac } miniade_do_option_processing --special-opts-handler=special_opts_handler --help-handler=help_handler MY_ARGS "$@" && set -- "${MY_ARGS[@]}" # Process arguments # '--list' and other options cannot be combined ('--list wants no arguments, other # modes do). However, we don't check for illegal combinations yet. We only check # if list is present or not and act accordingly. if wordin list "${DESIRED_MODES[@]}"; then [ $# = 0 ] || miniade_do_bad_usage else [ $# = 1 ] || miniade_do_bad_usage ROOT_DIR=$1 fi # Sanity checks and derivations miniade_get_progname PROGNAME [ ${#DESIRED_MODES[*]} -gt 0 ] || miniade_do_bad_usage # '--list' with other options is illegal combination. { ! wordin list "${DESIRED_MODES[@]}"; } || [ ${#DESIRED_MODES[*]} = 1 ] || miniade_do_bad_usage if [ "X$SCANPUB_STATE_DIR" != X ]; then STATE_DIR=$SCANPUB_STATE_DIR elif [ -w / ]; then STATE_DIR=/var/local/$PROGNAME else STATE_DIR=$HOME/.cache/$PROGNAME fi # Lock if { ! wordin list "${DESIRED_MODES[@]}"; }; then miniade_get_zencoded_str "$ROOT_DIR" ZENCODED_ROOT_DIR miniade_try_lock /tmp/$PROGNAME.$ZENCODED_ROOT_DIR.lock || miniade_error "locked" fi # Guts for MODE in scan compare freeze list; do if wordin $MODE "${DESIRED_MODES[@]}"; then if [ $MODE = list ]; then $MODE else $MODE "$ROOT_DIR" fi fi done # Unlock if { ! wordin list "${DESIRED_MODES[@]}"; }; then miniade_get_zencoded_str "$ROOT_DIR" ZENCODED_ROOT_DIR miniade_do_unlock /tmp/$PROGNAME.$ZENCODED_ROOT_DIR.lock fi } wordin() { local FIRSTWORD NEXTWORD FOUND FIRSTWORD=$1 shift FOUND=false for NEXTWORD in "$@"; do [ "X$NEXTWORD" != "X$FIRSTWORD" ] || { FOUND=true; break; } done $FOUND } scan() { local ROOT_DIR UNSUFFIXED_FROZEN_FILE UNSUFFIXED_SCANNED_FILE PROGNAME ZENCODED_ROOT_DIR miniade_assert_set SUFFIX # Process arguments [ $# = 1 ] || miniade_do_bad_usage ROOT_DIR=$1 # Sanity checks and derivations [[ $ROOT_DIR =~ ^/ ]] || miniade_error "$ROOT_DIR: not absolute" ( cd $ROOT_DIR 2>/dev/null ) || miniade_error "$ROOT_DIR: can't access" miniade_assert_set STATE_DIR miniade_get_zencoded_str "$ROOT_DIR" ZENCODED_ROOT_DIR UNSUFFIXED_FROZEN_FILE=$STATE_DIR/$ZENCODED_ROOT_DIR-frozen UNSUFFIXED_SCANNED_FILE=$STATE_DIR/$ZENCODED_ROOT_DIR-scanned miniade_get_progname PROGNAME # Guts mkdir -p $STATE_DIR miniade_debug 10 "scan: running find ..." miniade_register_tmp_file $UNSUFFIXED_SCANNED_FILE.$$.$SUFFIX # Alphabetical order scan (easier to read by me while being written by script). find $ROOT_DIR -type f -print0 | sort -z | xargs -r0 md5sum > $UNSUFFIXED_SCANNED_FILE.$$.$SUFFIX || true miniade_debug 10 "scan: moving result to $UNSUFFIXED_SCANNED_FILE.$SUFFIX ..." mv $UNSUFFIXED_SCANNED_FILE.$$.$SUFFIX $UNSUFFIXED_SCANNED_FILE.$SUFFIX miniade_deregister_tmp_file $UNSUFFIXED_SCANNED_FILE.$$.$SUFFIX } list() { local ROOT_DIR UNSUFFIXED_FROZEN_FILE UNSUFFIXED_SCANNED_FILE ROOT_DIRS ZENCODED_ROOT_DIR miniade_assert_set SUFFIX # Process arguments [ $# = 0 ] || miniade_do_bad_usage # Sanity checks and derivations miniade_assert_set STATE_DIR # Guts mkdir -p $STATE_DIR ROOT_DIRS=() while read FILENAME; do [[ $FILENAME =~ (.*)-(scanned|frozen)\.$SUFFIX$ ]] || continue ZENCODED_ROOT_DIR=${BASH_REMATCH[1]} miniade_get_zdecoded_str "$ZENCODED_ROOT_DIR" ROOT_DIR ROOT_DIRS+=( "$ROOT_DIR" ) done < <(ls $STATE_DIR) # Sort and deduplicate array. LC_ALL=C readarray -t ROOT_DIRS < <(printf '%s\n' "${ROOT_DIRS[@]}" | sort -u) for ROOT_DIR in "${ROOT_DIRS[@]}"; do miniade_get_zencoded_str "$ROOT_DIR" ZENCODED_ROOT_DIR UNSUFFIXED_FROZEN_FILE=$STATE_DIR/$ZENCODED_ROOT_DIR-frozen UNSUFFIXED_SCANNED_FILE=$STATE_DIR/$ZENCODED_ROOT_DIR-scanned if [ -f $UNSUFFIXED_SCANNED_FILE.$SUFFIX ] && [ -f $UNSUFFIXED_FROZEN_FILE.$SUFFIX ]; then COMMENT="frozen based on a scan that completed at $(stat -c %y $UNSUFFIXED_FROZEN_FILE.$SUFFIX | sed -r 's/\.[0-9]{9} [-+][0-9]{4}//')" elif [ -f $UNSUFFIXED_SCANNED_FILE.$SUFFIX ] && [ ! -f $UNSUFFIXED_FROZEN_FILE.$SUFFIX ]; then COMMENT="not frozen but scan completed at $(stat -c %y $UNSUFFIXED_SCANNED_FILE.$SUFFIX | sed -r 's/\.[0-9]{9} [-+][0-9]{4}//')" elif [ ! -f $UNSUFFIXED_SCANNED_FILE.$SUFFIX ] && [ -f $UNSUFFIXED_FROZEN_FILE.$SUFFIX ]; then COMMENT="frozen based on a scan that completed at $(stat -c %y $UNSUFFIXED_FROZEN_FILE.$SUFFIX | sed -r 's/\.[0-9]{9} [-+][0-9]{4}//')" elif [ ! -f $UNSUFFIXED_SCANNED_FILE.$SUFFIX ] && [ ! -f $UNSUFFIXED_FROZEN_FILE.$SUFFIX ]; then COMMENT="odd state!" fi printf "%-40s %s\\n" "$ROOT_DIR:" "$COMMENT" done } compare() { local ROOT_DIR UNSUFFIXED_FROZEN_FILE UNSUFFIXED_SCANNED_FILE PROGNAME ZENCODED_ROOT_DIR miniade_assert_set SUFFIX # Process arguments [ $# = 1 ] || miniade_do_bad_usage ROOT_DIR=$1 # Sanity checks and derivations [[ $ROOT_DIR =~ ^/ ]] || miniade_error "$ROOT_DIR: not absolute" miniade_assert_set STATE_DIR miniade_get_zencoded_str "$ROOT_DIR" ZENCODED_ROOT_DIR UNSUFFIXED_FROZEN_FILE=$STATE_DIR/$ZENCODED_ROOT_DIR-frozen UNSUFFIXED_SCANNED_FILE=$STATE_DIR/$ZENCODED_ROOT_DIR-scanned miniade_get_progname PROGNAME [ -f $UNSUFFIXED_SCANNED_FILE.$SUFFIX ] || miniade_error "no scanned file exists yet (hint: do you need to run '$PROGNAME --scan $ROOT_DIR'?)" [ -f $UNSUFFIXED_FROZEN_FILE.$SUFFIX ] || miniade_error "no frozen file exists yet (hint: do you need to run '$PROGNAME --freeze $ROOT_DIR'?)" # Guts [ "X$DBFILE" = X ] || rm -f $DBFILE miniade_register_tmp_file /tmp/$PROGNAME.$$.sql { echo "CREATE TABLE scanned_files (checksum TEXT, name TEXT, PRIMARY KEY (name));" echo "CREATE INDEX scanned_files_checksum_index ON scanned_files(checksum);" echo "CREATE INDEX scanned_files_name_index ON scanned_files(name);" echo "CREATE TABLE frozen_files (checksum TEXT, name TEXT, PRIMARY KEY (name));" echo "CREATE INDEX frozen_files_checksum_index ON frozen_files(checksum);" echo "CREATE INDEX frozen_files_name_index ON frozen_files(name);" echo "BEGIN TRANSACTION;" # md5sum puts a backslash at the beginning of the line (directly in front of # the checksum if the filename contains a backslash). Single quotes need to # escaped, which, in the case of quotes is actually *two* single quotes. sed -r -e 's/^\\//' -e "s/'/''/g" -e "s/^([0-9a-f]{32}) (.*)/INSERT INTO scanned_files VALUES ('\\1', '\\2');/" $UNSUFFIXED_SCANNED_FILE.$SUFFIX sed -r -e 's/^\\//' -e "s/'/''/g" -e "s/^([0-9a-f]{32}) (.*)/INSERT INTO frozen_files VALUES ('\\1', '\\2');/" $UNSUFFIXED_FROZEN_FILE.$SUFFIX echo "END TRANSACTION;" echo "CREATE VIEW interesting_files as select *,'frozen' as source from (select * from frozen_files except select * from scanned_files) union select *,'scanned' as source from (select * from scanned_files except select * from frozen_files);" # Moved files are those with a 'frozen' and a 'scanned' entry for the same # checksum but with different names. echo "CREATE view moved_files as SELECT i1.checksum, i1.name as name_frozen, i2.name as name_scanned FROM interesting_files as i1, interesting_files as i2 where i1.checksum = i2.checksum and i1.source = 'frozen' and i2.source = 'scanned';" echo ".output /tmp/$PROGNAME.$$.moved" echo ".width 140" echo "SELECT name_frozen || ' --> ' || name_scanned as 'old name --> new name' FROM moved_files order by name_frozen;" echo ".output" # Modified files are the opposite: those with a 'frozen' and a 'scanned' entry # for the same name but with different checksums. echo "CREATE VIEW modified_files as SELECT i1.checksum as checksum_frozen, i2.checksum as checksum_scanned, i1.name FROM interesting_files as i1, interesting_files as i2 where i1.name = i2.name and i1.source = 'frozen' and i2.source = 'scanned';" echo ".output /tmp/$PROGNAME.$$.modified" echo ".width 140" echo "SELECT name FROM modified_files order by name;" echo ".output" # Added files are not moved or modified and have a 'scanned' entry but no # 'frozen' entry. echo "CREATE VIEW added_files as select checksum, name from (select * from interesting_files except select * from (select checksum_frozen as checksum, name, 'frozen' as source from modified_files union select checksum_scanned as checksum, name, 'scanned' as source from modified_files union select checksum, name_frozen as name, 'frozen' as source from moved_files union select checksum, name_scanned as name, 'scanned' as source from moved_files)) where source = 'scanned';" echo ".output /tmp/$PROGNAME.$$.added" echo ".width 140" echo "SELECT name FROM added_files order by name;" echo ".output" # Deleted files are the opposite: those that are are not moved or modified # and have no 'scanned' entry but do have a 'frozen' entry. echo "CREATE VIEW deleted_files as select checksum, name from (select * from interesting_files except select * from (select checksum_frozen as checksum, name, 'frozen' as source from modified_files union select checksum_scanned as checksum, name, 'scanned' as source from modified_files union select checksum, name_frozen as name, 'frozen' as source from moved_files union select checksum, name_scanned as name, 'scanned' as source from moved_files)) where source = 'frozen';" echo ".output /tmp/$PROGNAME.$$.deleted" echo ".width 140" echo "SELECT name FROM deleted_files order by name;" echo ".output" } > /tmp/$PROGNAME.$$.sql miniade_debug 10 "compare: running /tmp/$PROGNAME.$$.sql and not deleting it afterwards! ..." miniade_register_tmp_file /tmp/$PROGNAME.$$.{moved,added,deleted,modified} sqlite3 $DBFILE < /tmp/$PROGNAME.$$.sql # Generate the report including only those sections that have files. REPORTED_SOMETHING_FLAG=false for SUFFIX in moved modified added deleted; do if [ $(stat -c %s /tmp/$PROGNAME.$$.$SUFFIX) != 0 ]; then TITLE="$SUFFIX files" echo "$TITLE" printf "%${#TITLE}.${#TITLE}s\\n" $LOTS_OF_EQUALS echo cat /tmp/$PROGNAME.$$.$SUFFIX echo REPORTED_SOMETHING_FLAG=true fi done if $REPORTED_SOMETHING_FLAG; then echo "(To save the current state you can run '$PROGNAME --freeze $ROOT_DIR'.)" fi # Clean up. rm -f /tmp/$PROGNAME.$$.* miniade_deregister_tmp_file /tmp/$PROGNAME.$$.{sql,moved,added,deleted,modified} } freeze() { local ROOT_DIR UNSUFFIXED_FROZEN_FILE UNSUFFIXED_SCANNED_FILE ZENCODED_ROOT_DIR miniade_assert_set SUFFIX # Process arguments [ $# = 1 ] || miniade_do_bad_usage ROOT_DIR=$1 # Sanity checks and derivations [[ $ROOT_DIR =~ ^/ ]] || miniade_error "$ROOT_DIR: not absolute" miniade_assert_set STATE_DIR miniade_get_zencoded_str "$ROOT_DIR" ZENCODED_ROOT_DIR UNSUFFIXED_FROZEN_FILE=$STATE_DIR/$ZENCODED_ROOT_DIR-frozen UNSUFFIXED_SCANNED_FILE=$STATE_DIR/$ZENCODED_ROOT_DIR-scanned [ -f $UNSUFFIXED_SCANNED_FILE.$SUFFIX ] || miniade_error "$ROOT_DIR: no scanned file to freeze" # Guts miniade_debug 10 "freeze: copying $SCANNED_FILE to $FROZEN_FILE ..." cp --preserve=timestamps $UNSUFFIXED_SCANNED_FILE.$SUFFIX $UNSUFFIXED_FROZEN_FILE.$SUFFIX } help_handler() { local PROGNAME miniade_get_progname PROGNAME echo "Usage: $PROGNAME [ ] { --{scan|compare|freeze} [ ... ] | --list }" exit 0 } main "$@"