#!/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 # Iniciar temporizador (milisegundos desde epoch) START_TIME=$(date +%s%3N) TMP_NEW=$(mktemp) TMP_OLD=$(mktemp) # Asegurarse de limpiar archivos temporales cleanup() { rm -f "$TMP_NEW" "$TMP_OLD" } trap cleanup EXIT # Mostrar configuración si el formato es text if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "[topdir] Iniciando análisis..." echo "[topdir] Directorio: $TARGET_DIR" echo "[topdir] Algoritmo: $HASH_ALGO" fi 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 if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "[topdir] Snapshot: $SNAPSHOT_FILE" 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" if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "[topdir] Cargadas ${#EXCLUDE_PATTERNS[@]} exclusiones desde $IGNORE_FILE" fi elif [[ -f ".topdirignore" ]]; then while IFS= read -r pattern; do [[ -z "$pattern" || "$pattern" == \#* ]] && continue EXCLUDE_PATTERNS+=("$pattern") done < ".topdirignore" if [[ "$OUTPUT_FORMAT" == "text" && ${#EXCLUDE_PATTERNS[@]} -gt 0 ]]; then echo "[topdir] Cargadas ${#EXCLUDE_PATTERNS[@]} exclusiones desde .topdirignore" fi fi # Construir comando find con exclusiones find_cmd=(find . -type f ! -name '.topdir_snapshot' ! -name '.topdirignore') for pattern in "${EXCLUDE_PATTERNS[@]}"; do # Normalizar el patrón: # Si el patrón no contiene /* ni * al final, y es un directorio, añadir /* # Esto permite que "logs" se convierta en "logs/*" automáticamente normalized_pattern="$pattern" # Si el patrón no tiene wildcards ni slash al final if [[ "$pattern" != *\** && "$pattern" != */ ]]; then # Si existe como directorio, añadir /* para exclusión recursiva if [[ -d "./$pattern" ]]; then normalized_pattern="$pattern/*" fi fi # Remover / final si existe (para que "logs/" -> "logs/*") if [[ "$normalized_pattern" == */ && "$normalized_pattern" != *\* ]]; then normalized_pattern="${normalized_pattern}*" fi # Convertir pattern a formato find -path find_cmd+=(! -path "./$normalized_pattern") done # Generar nuevo snapshot: compute hash para cada archivo regular if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -n "[topdir] Escaneando archivos..." fi file_count=0 while IFS= read -r -d '' file; do "$HASH_CMD" "$file" file_count=$((file_count + 1)) if [[ "$OUTPUT_FORMAT" == "text" && $((file_count % 100)) -eq 0 ]]; then echo -ne "\r[topdir] Escaneando archivos... $file_count" fi done < <("${find_cmd[@]}" -print0 | sort -z) > "$TMP_NEW" if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e "\r[topdir] Escaneados $file_count archivos " fi # Si no hay snapshot previo, lo creamos y salimos if [[ ! -f "$SNAPSHOT_FILE" ]]; then mv "$TMP_NEW" "$SNAPSHOT_FILE" case "$OUTPUT_FORMAT" in text) END_TIME=$(date +%s%3N) ELAPSED_MS=$((END_TIME - START_TIME)) ELAPSED_S=$(LC_NUMERIC=C awk "BEGIN {printf \"%.3f\", $ELAPSED_MS/1000}") echo "[topdir] ✓ Snapshot creado en '$SNAPSHOT_FILE'" echo "[topdir] ✓ $file_count archivos registrados" echo "[topdir] ⏱ Tiempo de ejecución: ${ELAPSED_S}s" echo "[topdir] Primera ejecución completada. Ejecuta nuevamente para detectar cambios." ;; json) END_TIME=$(date +%s%3N) ELAPSED_MS=$((END_TIME - START_TIME)) ELAPSED_S=$(LC_NUMERIC=C awk "BEGIN {printf \"%.3f\", $ELAPSED_MS/1000}") echo '{"status":"snapshot_created","snapshot_file":"'"$SNAPSHOT_FILE"'","files_tracked":'"$file_count"',"elapsed_seconds":'"$ELAPSED_S"',"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 if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "[topdir] Comparando con snapshot anterior..." fi 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 "" echo "═══════════════════════════════════════════════════════════════" echo " INFORME DE CAMBIOS" echo "═══════════════════════════════════════════════════════════════" echo "Directorio: $TARGET_DIR" echo "Archivos escaneados: $file_count" echo "Nuevos: ${#new_files[@]} | Modificados: ${#modified_files[@]} | Eliminados: ${#delete_files[@]}" echo "───────────────────────────────────────────────────────────────" echo "" 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 "📄 NUEVOS ARCHIVOS (${#new_files[@]}):" for f in "${new_files[@]}"; do echo " + $f" done echo "" fi if [[ ${#modified_files[@]} -gt 0 ]]; then echo "✏️ ARCHIVOS MODIFICADOS (${#modified_files[@]}):" for f in "${modified_files[@]}"; do echo " ~ $f" done echo "" fi if [[ ${#delete_files[@]} -gt 0 ]]; then echo "🗑️ ARCHIVOS ELIMINADOS (${#delete_files[@]}):" for f in "${delete_files[@]}"; do echo " - $f" done echo "" fi fi echo "═══════════════════════════════════════════════════════════════" END_TIME=$(date +%s%3N) ELAPSED_MS=$((END_TIME - START_TIME)) ELAPSED_S=$(LC_NUMERIC=C awk "BEGIN {printf \"%.3f\", $ELAPSED_MS/1000}") echo "⏱ Tiempo de ejecución: ${ELAPSED_S}s" ;; 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+="]" total_changes=$((${#new_files[@]} + ${#modified_files[@]} + ${#delete_files[@]})) END_TIME=$(date +%s%3N) ELAPSED_MS=$((END_TIME - START_TIME)) ELAPSED_S=$(LC_NUMERIC=C awk "BEGIN {printf \"%.3f\", $ELAPSED_MS/1000}") echo "{\"status\":\"ok\",\"directory\":\"$TARGET_DIR\",\"hash\":\"$HASH_ALGO\",\"files_scanned\":$file_count,\"total_changes\":$total_changes,\"elapsed_seconds\":$ELAPSED_S,\"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