288 líneas
7.6 KiB
Bash
Archivo Ejecutable
288 líneas
7.6 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
|
|
# 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: <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
|