#!/usr/bin/env bash # topdir.sh # Compara recursivamente un directorio y genera un informe de archivos nuevos y modificados # desde la última ejecución. Guarda el snapshot en .topdir_snapshot dentro del directorio monitoreado. set -euo pipefail IFS=$'\n\t' usage() { cat <&2 usage exit 2 ;; *) TARGET_DIR="$1" shift ;; esac done # Validar algoritmo de hash case "$HASH_ALGO" in sha256|sha1|md5) HASH_CMD="${HASH_ALGO}sum" ;; *) echo "Error: Algoritmo de hash no soportado '$HASH_ALGO'. Use: sha256, sha1 o md5" >&2 exit 2 ;; esac # Validar formato de salida case "$OUTPUT_FORMAT" in text|json|csv) ;; *) echo "Error: Formato de salida no soportado '$OUTPUT_FORMAT'. Use: text, json o csv" >&2 exit 2 ;; esac if [[ ! -d "$TARGET_DIR" ]]; then echo "Error: '$TARGET_DIR' no es un directorio válido." >&2 exit 2 fi TMP_NEW=$(mktemp) TMP_OLD=$(mktemp) # Asegurarse de limpiar archivos temporales cleanup() { rm -f "$TMP_NEW" "$TMP_OLD" } trap cleanup EXIT pushd "$TARGET_DIR" >/dev/null || exit 1 # Definir snapshot relativo al directorio objetivo o usar el custom if [[ -z "$SNAPSHOT_FILE" ]]; then SNAPSHOT_FILE="./.topdir_snapshot" else # Si es relativo, hacerlo relativo al TARGET_DIR original popd >/dev/null if [[ "$SNAPSHOT_FILE" != /* ]]; then SNAPSHOT_FILE="$(pwd)/$SNAPSHOT_FILE" fi pushd "$TARGET_DIR" >/dev/null || exit 1 fi # Leer patrones de exclusión desde archivo si existe if [[ -n "$IGNORE_FILE" && -f "$IGNORE_FILE" ]]; then while IFS= read -r pattern; do [[ -z "$pattern" || "$pattern" == \#* ]] && continue EXCLUDE_PATTERNS+=("$pattern") done < "$IGNORE_FILE" elif [[ -f ".topdirignore" ]]; then while IFS= read -r pattern; do [[ -z "$pattern" || "$pattern" == \#* ]] && continue EXCLUDE_PATTERNS+=("$pattern") done < ".topdirignore" fi # Construir comando find con exclusiones find_cmd=(find . -type f ! -name '.topdir_snapshot' ! -name '.topdirignore') for pattern in "${EXCLUDE_PATTERNS[@]}"; do # Convertir pattern a formato find -path find_cmd+=(! -path "./$pattern") done # Generar nuevo snapshot: compute hash para cada archivo regular while IFS= read -r -d '' file; do "$HASH_CMD" "$file" done < <("${find_cmd[@]}" -print0 | sort -z) > "$TMP_NEW" # Si no hay snapshot previo, lo creamos y salimos if [[ ! -f "$SNAPSHOT_FILE" ]]; then mv "$TMP_NEW" "$SNAPSHOT_FILE" case "$OUTPUT_FORMAT" in text) echo "Snapshot creado en '$SNAPSHOT_FILE' (no había ejecución previa)." ;; json) echo '{"status":"snapshot_created","snapshot_file":"'"$SNAPSHOT_FILE"'","new":[],"modified":[],"deleted":[]}' ;; csv) echo "status,path" ;; esac popd >/dev/null || true exit 0 fi # Convertir snapshot antiguo y nuevo a formato: \t # Convertir líneas "checksum path" a formato "pathchecksum" de forma robusta sed -E 's/^[[:space:]]*([0-9a-f]+)[[:space:]]+(.*)$/\2\t\1/' "$SNAPSHOT_FILE" > "$TMP_OLD" 2>/dev/null || true sed -E 's/^[[:space:]]*([0-9a-f]+)[[:space:]]+(.*)$/\2\t\1/' "$TMP_NEW" > "$TMP_NEW.sorted" mv "$TMP_NEW.sorted" "$TMP_NEW" # Ambos archivos ahora tienen formato: \t # Usar awk para detectar NEW, MOD, DEL new_files=() modified_files=() delete_files=() awk -F"\t" ' NR==FNR { old[$1]=$2; next } { new[$1]=$2 if (!($1 in old)) { print "NEW\t" $1 } else if (old[$1] != $2) { print "MOD\t" $1 } delete old[$1] } END { for (p in old) print "DEL\t" p } ' "$TMP_OLD" "$TMP_NEW" > "$TMP_OLD.changes" while IFS=$'\t' read -r tag path; do case "$tag" in NEW) new_files+=("$path") ;; MOD) modified_files+=("$path") ;; DEL) delete_files+=("$path") ;; esac done < "$TMP_OLD.changes" # Mostrar informe según formato case "$OUTPUT_FORMAT" in text) echo "Informe de comparación para: $TARGET_DIR" if [[ ${#new_files[@]} -eq 0 && ${#modified_files[@]} -eq 0 && ${#delete_files[@]} -eq 0 ]]; then echo "No se detectaron cambios desde la última ejecución." else if [[ ${#new_files[@]} -gt 0 ]]; then echo -e "\nNuevos archivos (${#new_files[@]}):" for f in "${new_files[@]}"; do echo " $f" done fi if [[ ${#modified_files[@]} -gt 0 ]]; then echo -e "\nArchivos modificados (${#modified_files[@]}):" for f in "${modified_files[@]}"; do echo " $f" done fi if [[ ${#delete_files[@]} -gt 0 ]]; then echo -e "\nArchivos eliminados desde último snapshot (${#delete_files[@]}):" for f in "${delete_files[@]}"; do echo " $f" done fi fi ;; json) # Construir arrays JSON new_json="[" for i in "${!new_files[@]}"; do [[ $i -gt 0 ]] && new_json+="," # Escapar comillas en el path escaped_path="${new_files[$i]//\"/\\\"}" new_json+="\"$escaped_path\"" done new_json+="]" mod_json="[" for i in "${!modified_files[@]}"; do [[ $i -gt 0 ]] && mod_json+="," escaped_path="${modified_files[$i]//\"/\\\"}" mod_json+="\"$escaped_path\"" done mod_json+="]" del_json="[" for i in "${!delete_files[@]}"; do [[ $i -gt 0 ]] && del_json+="," escaped_path="${delete_files[$i]//\"/\\\"}" del_json+="\"$escaped_path\"" done del_json+="]" echo "{\"status\":\"ok\",\"directory\":\"$TARGET_DIR\",\"hash\":\"$HASH_ALGO\",\"new\":$new_json,\"modified\":$mod_json,\"deleted\":$del_json}" ;; csv) echo "status,path" for f in "${new_files[@]}"; do echo "new,\"$f\"" done for f in "${modified_files[@]}"; do echo "modified,\"$f\"" done for f in "${delete_files[@]}"; do echo "deleted,\"$f\"" done ;; esac # Actualizar snapshot atómico mv "$TMP_NEW" "$SNAPSHOT_FILE" popd >/dev/null || true exit 0