Files
topdir/topdir.sh
2025-11-01 17:35:58 +01:00

306 líneas
8.2 KiB
Bash
Archivo Ejecutable

#!/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 <<EOF
Usage: $(basename "$0") [OPTIONS] [DIRECTORY]
Compara recursivamente un directorio y genera un informe de archivos nuevos, modificados y eliminados.
Opciones:
-h, --help Muestra esta ayuda
-s, --snapshot-file PATH
Ruta personalizada para el archivo snapshot
(por defecto: .topdir_snapshot dentro del DIRECTORY)
-H, --hash ALGORITHM Algoritmo de hash: sha256 (por defecto), sha1, md5
-f, --format FORMAT Formato de salida: text (por defecto), json, csv
-e, --exclude PATTERN Excluir archivos/directorios que coincidan con el patrón
(puede usarse múltiples veces, soporta wildcards)
-i, --ignore-file FILE Leer patrones de exclusión desde un archivo
(por defecto lee .topdirignore si existe)
Argumentos:
DIRECTORY Directorio a monitorear (por defecto: directorio actual)
Ejemplos:
$(basename "$0") /path/to/dir
$(basename "$0") --hash md5 --format json .
$(basename "$0") --exclude '*.log' --exclude 'tmp/*' /data
$(basename "$0") --snapshot-file /tmp/my.snap --format csv /project
EOF
}
# Variables por defecto
TARGET_DIR="."
SNAPSHOT_FILE=""
HASH_ALGO="sha256"
OUTPUT_FORMAT="text"
EXCLUDE_PATTERNS=()
IGNORE_FILE=""
# Parsear opciones
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-s|--snapshot-file)
SNAPSHOT_FILE="$2"
shift 2
;;
-H|--hash)
HASH_ALGO="$2"
shift 2
;;
-f|--format)
OUTPUT_FORMAT="$2"
shift 2
;;
-e|--exclude)
EXCLUDE_PATTERNS+=("$2")
shift 2
;;
-i|--ignore-file)
IGNORE_FILE="$2"
shift 2
;;
-*)
echo "Error: Opción desconocida '$1'" >&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
# 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
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: <path>\t<checksum>
# Convertir líneas "checksum path" a formato "path<TAB>checksum" 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: <path>\t<checksum>
# 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