Renombrado masivo seguro: el script que no destroza nombres

¿Te fue útil?

Los renombrados en lote son ese tipo de “pequeño cambio” que aparece en tu calendario como una cita inofensiva con el dentista y se va como una endodoncia. Nunca es solo renombrar. Son colisiones, caracteres raros, normalización de mayúsculas, movimientos entre sistemas de archivos, aplicaciones que almacenan en caché rutas y ese directorio donde alguien guardó “final_FINAL_v7 (use this).docx”.

Si vas a renombrar cientos o millones de archivos, necesitas un plan que asuma fallos. No porque seas descuidado, sino porque los sistemas de archivos, los hábitos humanos para nombrar y los plazos corporativos son adversarios. Este es el enfoque práctico y orientado a producción: dry-runs que predicen exactamente lo que pasará, renombrados en dos fases para evitar colisiones, registros de auditoría para rollback y comprobaciones de rendimiento para no convertir tu NAS en un calefactor.

Requisitos imprescindibles para renombrados seguros

1) Un renombrado es metadata—hasta que deja de serlo

En un único sistema de archivos, rename() suele ser “solo metadata”: rápido, atómico y sin copiar el contenido del archivo. Esa es la buena noticia. La mala noticia es que tu herramienta podría no estar haciendo un rename puro. Si tu “renombrado” cruza límites de sistemas de archivos —por ejemplo, si mueves archivos de un montaje a otro— puede degradarse a copiar+borrar. Eso cambia los tiempos, el comportamiento ante fallos, los permisos y tus opciones de recuperación.

Decisión: trata cada renombrado masivo como un evento de gestión de cambios. Planeas para atomicidad, pero verificas que realmente la estás obteniendo.

2) Dry-run significa “predecir exactamente lo que sucederá”

Un dry-run que imprime una lista vaga no es un dry-run. Tu dry-run debe producir:

  • un mapeo uno-a-uno de ruta origen → ruta destino
  • un informe de colisiones (incluidas colisiones por case-fold)
  • un plan de rollback (un mapeo inverso que puedas reproducir)
  • un ordenamiento estable (para que las re-ejecuciones coincidan)

La salida del dry-run debe ser algo que puedas diffear, revisar y aprobar. Si no puedes explicar por qué un archivo específico pasará a un nombre nuevo específico, no estás haciendo un dry-run. Estás esperando.

3) El renombrado en dos fases previene auto-colisiones

El tropiezo clásico: renombrar a.txt a A.txt en un sistema de archivos insensible a mayúsculas, o renombrar varios archivos a la misma forma normalizada (espacios a guiones bajos, minúsculas, eliminación de puntuación). La solución es aburrida y correcta: renombra todo primero a nombres temporales únicos y luego a los nombres finales.

4) Los registros no son opcionales—el rollback necesita recibos

Quieres un log de solo añadir con las operaciones mv exactas realizadas, en orden, más cualquier omisión y error. En incidentes, la diferencia entre “podemos revertir” y “creemos que podemos revertir” es un archivo de auditoría que puedas reproducir.

5) “Funciona en mi portátil” no es una estrategia de sistemas de archivos

La semántica de sistemas de archivos varía: ext4, XFS, ZFS, comparticiones SMB, montajes NFS, montajes FUSE respaldados por objetos —cada uno tiene peculiaridades. Algunos son insensibles a mayúsculas, algunos tienen límites extraños, y algunos son lentos en operaciones de directorio cuando son gigantes. Tu plan de renombrado debe incluir una lectura rápida sobre en qué estás parado.

Una cita para tener en tu escritorio: “La esperanza no es una estrategia.” — Gene Kranz

Datos interesantes y breve historia

  1. Atomicidad de POSIX rename: En un único sistema de archivos, rename() está diseñado para ser atómico: no obtienes un nombre parcialmente renombrado. Por eso es la columna vertebral de las actualizaciones seguras de archivos.
  2. Windows impulsó expectativas insensibles a mayúsculas: Los sistemas de archivos insensibles a mayúsculas se hicieron comunes en entornos de escritorio y todavía sorprenden a los ingenieros cuando el código asume que Report.csv y report.csv son distintos.
  3. Las herramientas Unix tempranas asumían “sin espacios”: Muchos patrones clásicos de shell nacieron en una era en la que los nombres con espacios se consideraban error de usuario. Hoy en día son el pan de cada día.
  4. SMB y NFS pueden añadir latencia a la metadata: Un rename es metadata, pero la metadata remota puede ser lenta y habladora, especialmente sobre enlaces cargados o con consistencia estricta.
  5. El tamaño del directorio importa: Muchos sistemas de archivos manejan directorios enormes bien ahora, pero operaciones como listar y statear millones de entradas siguen siendo el cuello de botella durante la planificación y verificación.
  6. La normalización Unicode es una trampa real: Algunas plataformas normalizan Unicode de forma diferente, por lo que un nombre “visualmente idéntico” puede ser una secuencia de bytes distinta. Los renombrados masivos pueden accidentalmente “desduplicar” por colisión.
  7. Los enlaces físicos complican expectativas: Renombrar un archivo con hard links cambia la entrada de directorio, no el inode. Si esperabas “dos archivos”, puede que en realidad tengas un inode con dos nombres.
  8. Los snapshots cambiaron el juego del renombrado: Con ZFS y sistemas similares, tomar un snapshot antes del renombrado hace que el rollback de nombres sea más factible—a veces basta con revertir el dataset.

Qué falla realmente (y por qué)

Colisión: dos nombres antiguos se convierten en un solo nombre nuevo

Sanitizar nombres es donde nacen las colisiones. Convierte espacios a guiones bajos, pasa todo a minúsculas, quita puntuación y de repente:

  • ACME - Q4.csv
  • ACME_Q4.csv
  • acme q4.csv

…todos quieren convertirse en acme_q4.csv. Si tu script “el último escribe gana”, acabas de perder significado sin tocar el contenido del archivo. El archivo está allí. El nombre está mal. Eso es peor: corrupción silenciosa del significado.

Cambios solo de mayúsculas en montajes insensibles a case

En un sistema de archivos insensible a mayúsculas, renombrar foo a Foo puede tratarse como “no operación” o puede requerir un baile (renombrar a temporal y luego al deseado). Las comparticiones remotas hacen esto incluso más divertido.

“Renombrado” entre sistemas de archivos se convierte en copiar+borrar

Si usas herramientas que mueven archivos entre montajes mientras “renombran”, ya no estás haciendo actualizaciones atómicas de metadata. Estás copiando datos, consumiendo ancho de banda y creando un modo de fallo donde tienes copias parciales y originales desaparecidos.

Deriva de permisos y propiedad

Un rename puro preserva la metadata del inode. Un copiar+borrar no. De repente el “mismo archivo” tiene un nuevo propietario, ACLs diferentes y un contexto SELinux distinto. Así es como obtienes una caída por un renombrado.

Las aplicaciones no gustan que cambien sus rutas

Algunas apps almacenan rutas absolutas en bases de datos, archivos de configuración o cachés. Renombrar archivos debajo de ellas es como mover el escritorio de alguien mientras está en una reunión. Es gracioso hasta que eres tú quien presenta.

Chiste #1: Renombrar archivos en producción es como reorganizar la cocina a las 2 a.m.—recordarás dónde no está nada, y todos lo descubrirán en el desayuno.

Tareas prácticas: comandos, salidas, decisiones

A continuación hay tareas operativas reales que ejecutas antes de renombrar, durante la ejecución y después de creer que has terminado. Cada tarea incluye un comando, salida de ejemplo y la decisión que tomas a partir de ella.

Tarea 1: Confirma dónde viven los datos (no cruces sistemas de archivos por accidente)

cr0x@server:~$ df -Th /data/projects
Filesystem     Type  Size  Used Avail Use% Mounted on
tank/projects  zfs   8.0T  5.1T  2.9T  64% /data/projects

Qué significa: Estás en el dataset ZFS tank/projects. Si renombrás dentro de este montaje, es solo metadata y compatible con snapshots.

Decisión: Asegura que tu script no mueva archivos a una ruta de montaje diferente. Mantén origen y destino bajo el mismo sistema de archivos.

Tarea 2: Revisa opciones de montaje y si es una compartición de red

cr0x@server:~$ mount | grep -E ' /data/projects | type (nfs|cifs)'
tank/projects on /data/projects type zfs (rw,xattr,noacl)

Qué significa: ZFS local, no NFS/SMB. La latencia del rename debería ser estable.

Decisión: Si ves NFS/CIFS, programa el renombrado en horas de baja actividad y espera viajes de metadata.

Tarea 3: Cuenta archivos y directorios (dimensiona el radio de impacto)

cr0x@server:~$ find /data/projects/clientA -type f | wc -l
284913

Qué significa: ~285k archivos. Los pasos que hacen un stat() por cada archivo tomarán tiempo.

Decisión: Usa escaneos eficientes; evita procesos por archivo si puedes. Considera batching o paralelismo con cuidado (más adelante hablaremos de eso).

Tarea 4: Identifica “caracteres problemáticos” en nombres (espacios, saltos, control)

cr0x@server:~$ find /data/projects/clientA -type f -name $'*\n*' -print | head
/data/projects/clientA/inbox/weird
name.txt

Qué significa: Tienes al menos un nombre de archivo que contiene un salto de línea. No es teórico; está ahí.

Decisión: Tu pipeline debe ser delimitado por NUL (-print0 + read -d '') y tus logs deben ser inequívocos (escapados o con formato seguro para NUL).

Tarea 5: Revisa guiones iniciales (las herramientas los interpretan como opciones)

cr0x@server:~$ find /data/projects/clientA -type f -name '-*' | head
/data/projects/clientA/inbox/-final.pdf

Qué significa: Al menos un archivo comienza con -.

Decisión: Usa siempre mv -- "$src" "$dst" para que los nombres nunca se interpreten como banderas.

Tarea 6: Detecta colisiones por case-fold que te afectarán en SMB o macOS

cr0x@server:~$ find /data/projects/clientA -maxdepth 2 -type f -printf '%f\n' | awk '{print tolower($0)}' | sort | uniq -d | head
readme.txt

Qué significa: Hay al menos dos archivos cuyos nombres difieren solo por mayúsculas en ese escaneo superficial.

Decisión: Si el entorno destino es insensible a mayúsculas (o puede serlo), necesitas una política de normalización y resolución de colisiones (sufijos, hashing, o preservar mayúsculas originales).

Tarea 7: Encuentra basenames duplicados tras tu normalización propuesta (previsualiza colisiones)

cr0x@server:~$ find /data/projects/clientA -type f -printf '%p\n' | \
awk -F/ '{
  base=$NF
  norm=tolower(base)
  gsub(/[^a-z0-9._-]+/,"_",norm)
  print norm
}' | sort | uniq -c | awk '$1>1{print $0}' | head
2 acme_q4.csv
3 invoice_2024_01.pdf

Qué significa: Tu normalización propuesta creará colisiones para al menos dos nombres.

Decisión: Decide la política de colisión ahora: omitir, añadir sufijo __DUP2, incluir el directorio padre o anexar un hash corto.

Tarea 8: Toma un snapshot (si está disponible) antes de cambiar nombres

cr0x@server:~$ zfs snapshot tank/projects@pre_rename_clientA
cr0x@server:~$ zfs list -t snapshot -o name,creation | grep pre_rename_clientA
tank/projects@pre_rename_clientA  Mon Feb  5 10:12 2026

Qué significa: Ahora tienes un snapshot puntual del dataset.

Decisión: Si el renombrado sale mal, puedes revertir el dataset (martillo grande) o usar la navegación por snapshots para restaurar nombres.

Tarea 9: Mide rendimiento base de metadata (para detectar cuellos de botella)

cr0x@server:~$ /usr/bin/time -f 'elapsed=%E cpu=%P' bash -c 'find /data/projects/clientA -maxdepth 2 -type f -print0 | xargs -0 -n 1000 stat >/dev/null'
elapsed=0:08.41 cpu=92%

Qué significa: Hacer stat a una muestra es rápido y consume CPU (buen indicador: metadata local, no limitada por red).

Decisión: Si el tiempo es enorme y la CPU baja, estás limitado por I/O o red. Planea lotes más pequeños y ejecútalos fuera de horas pico.

Tarea 10: Revisa agotamiento de inodos (sí, aún pasa)

cr0x@server:~$ df -i /data/projects
Filesystem     Inodes   IUsed   IFree IUse% Mounted on
tank/projects       -       -       -     - /data/projects

Qué significa: En ZFS, el reporte de inodos difiere; no obtendrás los conteos clásicos de inodos.

Decisión: En ext4/XFS verificarías disponibilidad de inodos. En ZFS, céntrate en espacio y rendimiento de metadata.

Tarea 11: Construye una lista determinista de archivos (orden estable para reproducibilidad)

cr0x@server:~$ cd /data/projects/clientA
cr0x@server:~$ find . -type f -print0 | sort -z > /tmp/clientA.files.zlist
cr0x@server:~$ python3 - <<'PY'
import os
p="/tmp/clientA.files.zlist"
print("bytes", os.path.getsize(p))
PY
bytes 19844712

Qué significa: Ahora tienes una lista estable delimitada por NUL que puedes reutilizar para dry-run y ejecución.

Decisión: Congela el alcance. Si llegan nuevos archivos durante la ventana de renombrado, trátalos en una segunda pasada—no persigas un objetivo en movimiento.

Tarea 12: Ejecuta un verdadero dry-run mapeando (origen → destino) e inspecciónalo

cr0x@server:~$ bash safe_rename.sh --plan --root /data/projects/clientA --rule 'lower,sanitize' --out /tmp/rename.plan.tsv
Plan written: /tmp/rename.plan.tsv
Rows: 284913
Collisions: 17
Skips: 0

Qué significa: Tu plan encontró 17 colisiones. Bien: no fingió que todo estaba bien.

Decisión: Detente y resuelve colisiones antes de ejecutar. Ajusta reglas o elige una política de colisión.

Tarea 13: Verifica que las colisiones sean realmente problemáticas (spot-check)

cr0x@server:~$ awk -F'\t' '$4=="COLLISION"{print $0}' /tmp/rename.plan.tsv | head -3
/data/projects/clientA/inbox/ACME - Q4.csv	/data/projects/clientA/inbox/acme_q4.csv	COLLISION	with:/data/projects/clientA/inbox/ACME_Q4.csv
/data/projects/clientA/inbox/ACME_Q4.csv	/data/projects/clientA/inbox/acme_q4.csv	COLLISION	with:/data/projects/clientA/inbox/ACME - Q4.csv
/data/projects/clientA/finance/Invoice 2024-01.pdf	/data/projects/clientA/finance/invoice_2024-01.pdf	COLLISION	with:/data/projects/clientA/finance/Invoice_2024_01.pdf

Qué significa: Dos nombres originales convergen. Esto no es un falso positivo.

Decisión: Elige: anexar sufijos hash, preservar una variante, o segregar duplicados en un directorio de cuarentena.

Tarea 14: Ejecuta primero un subconjunto canario pequeño

cr0x@server:~$ head -z -n 2000 /tmp/clientA.files.zlist > /tmp/clientA.canary.zlist
cr0x@server:~$ bash safe_rename.sh --apply --filelist /tmp/clientA.canary.zlist --root /data/projects/clientA --rule 'lower,sanitize' --log /tmp/rename.canary.log
Applied.
Renamed: 1983
Unchanged: 17
Errors: 0

Qué significa: Una ejecución canaria tuvo éxito. Algunos archivos no cambiaron (ya normalizados o excluidos).

Decisión: Valida las aplicaciones aguas abajo y los permisos ahora, antes de tocar los otros 282k archivos.

Tarea 15: Verifica que el renombrado no cambió el contenido (muestreo)

cr0x@server:~$ awk -F'\t' '$3=="RENAMED"{print $1"\t"$2}' /tmp/rename.canary.log | head -5
/data/projects/clientA/inbox/Report 1.txt	/data/projects/clientA/inbox/report_1.txt
/data/projects/clientA/inbox/Spec (Draft).pdf	/data/projects/clientA/inbox/spec_draft_.pdf
/data/projects/clientA/inbox/notes!.md	/data/projects/clientA/inbox/notes_.md
/data/projects/clientA/inbox/Q4#plan.xlsx	/data/projects/clientA/inbox/q4_plan.xlsx
/data/projects/clientA/inbox/Team Photo.JPG	/data/projects/clientA/inbox/team_photo.jpg
cr0x@server:~$ src="/data/projects/clientA/inbox/Report 1.txt"
cr0x@server:~$ dst="/data/projects/clientA/inbox/report_1.txt"
cr0x@server:~$ sha256sum "$dst"
8c1b5e9b6bbfe19c1f0f3f51c2d7e1ce0b41b2dcf2a44d26e7e4a6e93c39d9d0  /data/projects/clientA/inbox/report_1.txt

Qué significa: Puedes hashear el archivo renombrado. Para un rename puro, el contenido debe coincidir, pero muestreas para detectar workflows accidentales de copia/transcodificación.

Decisión: Si los hashes difieren para archivos “renombrados”, no estás haciendo renombres—estás haciendo otra cosa. Detén la operación.

Tarea 16: Monitoriza el progreso y la tasa de errores en vivo

cr0x@server:~$ tail -f /tmp/rename.full.log
2026-02-05T10:22:11Z	RENAMED	/data/projects/clientA/inbox/Team Photo.JPG	/data/projects/clientA/inbox/team_photo.jpg
2026-02-05T10:22:11Z	UNCHANGED	/data/projects/clientA/inbox/readme.txt	/data/projects/clientA/inbox/readme.txt
2026-02-05T10:22:11Z	ERROR	/data/projects/clientA/legal/Contract?.pdf	Permission denied

Qué significa: Estás viendo un fallo de permiso.

Decisión: No sigas adelante si los errores se agrupan. Decide si omitir y reportar, o arreglar permisos y reintentar.

El script: dry-run primero, renombrado en dos fases, registro de rollback

Este script está construido para el mundo desordenado: espacios, saltos, guiones iniciales, colisiones y sistemas de archivos que no comparten tu optimismo. Soporta:

  • Modo plan: produce un TSV con detección de colisiones
  • Modo aplicar: realiza un renombrado en dos fases para evitar colisiones
  • Rollback: usa el log para invertir la operación
  • Ámbito estable: lista opcional delimitada por NUL para renombrar exactamente lo que revisaste

Opinión: si vas a renombrar más de unos pocos cientos de archivos, no “lances un one-liner”. Los one-liners son geniales hasta que tienes que explicar a Auditoría por qué revenue_final.xlsx desapareció. Usa un script con logs.

cr0x@server:~$ cat safe_rename.sh
#!/usr/bin/env bash
set -euo pipefail

usage() {
  cat <<'USAGE'
safe_rename.sh --plan|--apply|--rollback
  --root PATH                 Root directory containing files to rename
  --rule RULES                Comma-separated rules: lower,sanitize
  --out PLAN.tsv              (plan) output plan TSV
  --log LOG.tsv               (apply/rollback) operation log
  --filelist FILE.zlist       Optional NUL-delimited, sorted file list (relative or absolute)
  --collision-policy POLICY   one of: error, suffix-hash
USAGE
}

mode=""
root=""
rules=""
out=""
log=""
filelist=""
collision_policy="error"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --plan|--apply|--rollback) mode="${1#--}"; shift ;;
    --root) root="$2"; shift 2 ;;
    --rule) rules="$2"; shift 2 ;;
    --out) out="$2"; shift 2 ;;
    --log) log="$2"; shift 2 ;;
    --filelist) filelist="$2"; shift 2 ;;
    --collision-policy) collision_policy="$2"; shift 2 ;;
    -h|--help) usage; exit 0 ;;
    *) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
  esac
done

[[ -n "$mode" ]] || { echo "Missing mode" >&2; usage; exit 2; }
[[ "$mode" == "rollback" || -n "$root" ]] || { echo "Missing --root" >&2; exit 2; }
[[ -n "$rules" || "$mode" == "rollback" ]] || { echo "Missing --rule" >&2; exit 2; }

ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }

norm_name() {
  local name="$1"
  local outname="$name"

  IFS=',' read -r -a rr <<< "$rules"
  for r in "${rr[@]}"; do
    case "$r" in
      lower) outname="$(printf '%s' "$outname" | tr '[:upper:]' '[:lower:]')" ;;
      sanitize)
        outname="$(printf '%s' "$outname" | sed -E 's/[^a-zA-Z0-9._-]+/_/g; s/^_+//; s/_+$//')"
        ;;
      *) echo "Unknown rule: $r" >&2; exit 2 ;;
    esac
  done
  printf '%s' "$outname"
}

sha8() {
  # short stable suffix for collision avoidance
  printf '%s' "$1" | sha256sum | awk '{print substr($1,1,8)}'
}

emit_file_list() {
  # Output NUL-delimited absolute file paths
  if [[ -n "$filelist" ]]; then
    # filelist may contain relative entries; anchor under root if relative
    while IFS= read -r -d '' p; do
      if [[ "$p" = /* ]]; then
        printf '%s\0' "$p"
      else
        printf '%s\0' "$root/$p"
      fi
    done < "$filelist"
  else
    find "$root" -type f -print0
  fi
}

plan() {
  [[ -n "$out" ]] || { echo "Missing --out" >&2; exit 2; }
  : > "$out"

  declare -A seen=()
  local collisions=0 rows=0 skips=0

  while IFS= read -r -d '' src; do
    rows=$((rows+1))
    local dir base dstbase dst status extra
    dir="$(dirname -- "$src")"
    base="$(basename -- "$src")"
    dstbase="$(norm_name "$base")"
    dst="$dir/$dstbase"

    if [[ "$src" == "$dst" ]]; then
      status="UNCHANGED"; extra=""
      skips=$((skips+1))
    else
      # collision check: destination path already targeted by another source
      if [[ -n "${seen[$dst]:-}" ]]; then
        status="COLLISION"; extra="with:${seen[$dst]}"
        collisions=$((collisions+1))
      else
        status="OK"; extra=""
      fi
      seen[$dst]="$src"
    fi

    printf '%s\t%s\t%s\t%s\n' "$src" "$dst" "$status" "$extra" >> "$out"
  done < <(emit_file_list)

  echo "Plan written: $out"
  echo "Rows: $rows"
  echo "Collisions: $collisions"
  echo "Skips: $skips"

  if [[ "$collisions" -gt 0 && "$collision_policy" == "error" ]]; then
    echo "Refusing to proceed with collisions under policy=error." >&2
  fi
}

apply() {
  [[ -n "$log" ]] || { echo "Missing --log" >&2; exit 2; }

  local planfile
  planfile="$(mktemp /tmp/rename.plan.XXXXXX.tsv)"
  out="$planfile"
  plan >/tmp/rename.plan.summary

  if grep -q $'\tCOLLISION\t' "$planfile"; then
    if [[ "$collision_policy" == "error" ]]; then
      echo "Collisions detected; aborting. See: $planfile" >&2
      exit 3
    fi
  fi

  : > "$log"

  # Phase 1: rename to temporary unique names in same directory
  # We only rename items that will change.
  while IFS=$'\t' read -r src dst status extra; do
    [[ "$status" == "OK" ]] || continue

    local dir base tmp
    dir="$(dirname -- "$src")"
    base="$(basename -- "$src")"
    tmp="$dir/.rename_tmp.$(sha8 "$src").$base"

    # Ensure temp name doesn't exist
    if [[ -e "$tmp" ]]; then
      echo -e "$(ts)\tERROR\t$src\t$temp_exists" >> "$log"
      echo "Temp already exists: $tmp" >&2
      exit 4
    fi

    mv -- "$src" "$tmp"
    printf '%s\tPHASE1\t%s\t%s\n' "$(ts)" "$tmp" "$dst" >> "$log"
  done < "$planfile"

  # Phase 2: rename temp to final, with optional collision suffixing
  while IFS=$'\t' read -r src dst status extra; do
    if [[ "$status" == "UNCHANGED" ]]; then
      printf '%s\tUNCHANGED\t%s\t%s\n' "$(ts)" "$src" "$dst" >> "$log"
      continue
    fi
    [[ "$status" == "OK" || "$status" == "COLLISION" ]] || continue

    local dir base tmp final
    dir="$(dirname -- "$src")"
    base="$(basename -- "$src")"
    tmp="$dir/.rename_tmp.$(sha8 "$src").$base"
    final="$dst"

    if [[ "$status" == "COLLISION" && "$collision_policy" == "suffix-hash" ]]; then
      # append hash before extension
      local b ext h
      h="$(sha8 "$src")"
      b="$(basename -- "$dst")"
      ext=""
      if [[ "$b" == *.* && "$b" != .* ]]; then
        ext=".${b##*.}"
        b="${b%.*}"
      fi
      final="$dir/${b}__${h}${ext}"
    elif [[ "$status" == "COLLISION" ]]; then
      printf '%s\tERROR\t%s\t%s\n' "$(ts)" "$src" "collision" >> "$log"
      continue
    fi

    if [[ -e "$final" ]]; then
      printf '%s\tERROR\t%s\t%s\n' "$(ts)" "$src" "dest_exists:$final" >> "$log"
      continue
    fi

    mv -- "$tmp" "$final"
    printf '%s\tRENAMED\t%s\t%s\n' "$(ts)" "$src" "$final" >> "$log"
  done < "$planfile"

  echo "Applied."
  echo "Renamed: $(awk -F'\t' '$2=="RENAMED"{c++} END{print c+0}' "$log")"
  echo "Unchanged: $(awk -F'\t' '$2=="UNCHANGED"{c++} END{print c+0}' "$log")"
  echo "Errors: $(awk -F'\t' '$2=="ERROR"{c++} END{print c+0}' "$log")"
}

rollback() {
  [[ -n "$log" ]] || { echo "Missing --log" >&2; exit 2; }
  # Roll back in reverse order; only RENAMED and PHASE1 entries matter.
  tac "$log" | while IFS=$'\t' read -r t action a b; do
    case "$action" in
      RENAMED)
        # a=src(original), b=final; move final back to original name
        if [[ -e "$b" ]]; then
          mv -- "$b" "$a"
          printf '%s\tROLLED_BACK\t%s\t%s\n' "$(ts)" "$b" "$a"
        else
          printf '%s\tROLLBACK_MISSING\t%s\t%s\n' "$(ts)" "$b" "$a"
        fi
        ;;
      PHASE1)
        # a=temp, b=dst planned; if temp still exists, move back to original? not stored here
        # We don't have original in PHASE1 entry; rollback relies primarily on RENAMED lines.
        :
        ;;
      *) : ;;
    esac
  done
}

case "$mode" in
  plan) plan ;;
  apply) apply ;;
  rollback) rollback ;;
  *) echo "Bad mode: $mode" >&2; exit 2 ;;
esac

Cómo ejecutarlo de forma segura (como lo hacen los adultos)

1) Genera una lista de archivos (opcional pero recomendado). 2) Planifica. 3) Revisa colisiones. 4) Ejecuta un canario. 5) Aplica completamente con registro. 6) Verifica.

cr0x@server:~$ cd /data/projects/clientA
cr0x@server:~$ find . -type f -print0 | sort -z > /tmp/clientA.files.zlist
cr0x@server:~$ bash safe_rename.sh --plan --root /data/projects/clientA --filelist /tmp/clientA.files.zlist --rule 'lower,sanitize' --out /tmp/clientA.plan.tsv
Plan written: /tmp/clientA.plan.tsv
Rows: 284913
Collisions: 17
Skips: 9211

Ahora tomas una decisión. Si las colisiones son inaceptables, corrige las reglas de nombre o manéjalas con una política. Si las colisiones son esperadas y necesitas unicidad determinista, usa --collision-policy suffix-hash. Eso produce nombres estables y explicables.

cr0x@server:~$ bash safe_rename.sh --apply --root /data/projects/clientA --filelist /tmp/clientA.files.zlist --rule 'lower,sanitize' --collision-policy suffix-hash --log /tmp/clientA.rename.log
Applied.
Renamed: 275702
Unchanged: 9211
Errors: 0

Chiste #2: Un renombrado masivo sin log es como un truco de magia sin público—nadie sabe lo que pasó, incluida tú.

Guía rápida de diagnóstico

Cuando un renombrado masivo es lento o inestable, a la gente le encanta discutir sobre “la red” o “ZFS que está raro”. No discutas. Mide tres cosas y normalmente encontrarás el cuello de botella en diez minutos.

Primero: ¿es metadata local o metadata remota?

  • Revisa el tipo de montaje (mount, df -Th). Si es NFS/SMB/FUSE, espera latencia de metadata.
  • Síntoma de metadata remota: CPU baja, tiempo transcurrido alto, progreso a ráfagas.
cr0x@server:~$ /usr/bin/time -f 'elapsed=%E cpu=%P' bash -c 'for i in {1..2000}; do stat /data/projects/clientA/inbox/readme.txt >/dev/null; done'
elapsed=0:00.36 cpu=99%

Decisión: Si esto es segundos-a-minutos con CPU baja, estás limitado por latencia. Reduce viajes de ida y vuelta (operaciones por lotes, evita comandos externos por archivo) y programa en horas de baja actividad.

Segundo: ¿el directorio es enorme y listar es el cuello de botella?

cr0x@server:~$ /usr/bin/time -f 'elapsed=%E' ls -U /data/projects/clientA/inbox >/dev/null
elapsed=0:02.91

Decisión: Si listar un solo directorio toma segundos, renombrar miles dentro de él será doloroso. Divide el trabajo por subdirectorios o considera reorganizar antes de renombrar.

Tercero: ¿estás copiando accidentalmente contenidos de archivos?

cr0x@server:~$ awk -F: '$2==" /data/projects"{print}' /proc/mounts
tank/projects /data/projects zfs rw,xattr,noacl 0 0

Decisión: Asegura que las rutas origen y destino permanezcan dentro de /data/projects. Si tu herramienta “mueve” a otro montaje, para y rediseña.

Bonus: detecta rápidamente contención a nivel de sistema de archivos

cr0x@server:~$ iostat -xz 1 3
Linux 6.6.0 (server)  02/05/2026  _x86_64_ (16 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          12.15    0.00    6.42    1.11    0.00   80.32

Device            r/s     rkB/s   rrqm/s  %rrqm  r_await rareq-sz     w/s     wkB/s   wrqm/s  %wrqm  w_await wareq-sz  aqu-sz  %util
nvme0n1          2.10     88.3     0.00   0.00    1.42    42.0     55.10   1400.0    10.00  15.36    4.10    25.4    0.25  12.4

Decisión: Si %util está al máximo y await crece, estás limitado por almacenamiento. Reduce la concurrencia y evita tormentas de renombrado en paralelo.

Errores comunes: síntoma → causa raíz → solución

1) Síntoma: “Algunos archivos desaparecieron”

Causa raíz: Sobrescritura por colisión. Tu script renombró múltiples orígenes a un destino y sobrescribió o saltó silenciosamente.

Solución: Renombrado en dos fases más política explícita de colisiones. El valor por defecto debe ser “error y detenerse”, no “hacer de tripas corazón y seguir”. Usa --collision-policy suffix-hash solo cuando los stakeholders acepten nombres más feos.

2) Síntoma: “Rápido en dev, lento en prod”

Causa raíz: Prod está en NFS/SMB o tiene un servidor de metadata cargado. Dev estaba en SSD local.

Solución: Mide la latencia de metadata. Reduce procesos por archivo. Ejecuta en horas de baja actividad. Si hace falta, paraleliza con cuidado (pocos workers), no “uno por core”.

3) Síntoma: “Solo cambiamos mayúsculas y no pasó nada”

Causa raíz: Sistema de archivos insensible a mayúsculas trata foo y Foo como la misma entrada, así que el rename es no-op o da errores.

Solución: Renombrado en dos pasos: foo.__tmp__Foo. El enfoque en dos fases del script hace esto efectivamente.

4) Síntoma: “La mitad falló con Permission denied”

Causa raíz: Algunos directorios/archivos tienen permisos más estrictos, ACLs o flags inmutables. O estás renombrando en un directorio que puedes leer pero no escribir.

Solución: Preflight con cheques de escritura en directorios, no solo en archivos. En Linux, el permiso de escritura del directorio gobierna los renombres.

cr0x@server:~$ namei -l /data/projects/clientA/legal/Contract?.pdf
f: /data/projects/clientA/legal/Contract?.pdf
drwxr-xr-x root root /
drwxr-xr-x root root data
drwxr-xr-x root root projects
drwxr-x--- team legal clientA
dr-xr-x--- team legal legal
-rw-r----- team legal Contract?.pdf

Decisión: Si el directorio destino no es escribible (dr-x), tu renombrado fallará. Arregla permisos de directorio o excluye esa subárbol.

5) Síntoma: “El rollback no lo restauró todo”

Causa raíz: Falta de mapeo completo, editaste el log o la ejecución tuvo resultados mixtos (quedaron temporales PHASE1 por un crash).

Solución: Trata los logs como append-only. Almacénalos fuera del host si eres paranoico. Tras cualquier error, inventaría nombres temporales (.rename_tmp.) y resuélvelos antes de reejecutar.

cr0x@server:~$ find /data/projects/clientA -name '.rename_tmp.*' | head
/data/projects/clientA/inbox/.rename_tmp.1a2b3c4d.Report 1.txt

Decisión: Si hay temporales, detente con nuevas ejecuciones. Termina Phase 2 limpiamente o haz rollback.

6) Síntoma: “La app no encuentra archivos después del renombrado”

Causa raíz: La app guardó rutas absolutas; las cambiaste sin actualizar referencias.

Solución: O actualizas referencias (DB/config) en la misma ventana de cambio, o mantienes compatibilidad con symlinks (con cuidado: los symlinks no ayudan en todas las plataformas y pueden confundir herramientas de backup).

Listas de verificación / plan paso a paso

Checklist de preflight (no lo saltes salvo que disfrutes sorpresas)

  1. Confirma sistema de archivos y tipo de montaje (df -Th, mount). Decide si la metadata es local o remota.
  2. Confirma el alcance: cuenta archivos, identifica directorios gigantes.
  3. Escanea nombres patológicos: saltos, guiones iniciales, Unicode extraño. Decide si vas a sanitizar o preservar.
  4. Define reglas de nombrado: ¿minúsculas? ¿espacios? ¿puntuación? ¿manejo de extensiones? Define qué es “correcto”.
  5. Política de colisiones: por defecto detente ante colisión; usa suffix-hash solo cuando sea necesario.
  6. Backups/snapshots: toma un snapshot si puedes; de lo contrario asegura que las copias de seguridad seguirán rastreando renombres.
  7. Congela la entrada: detén la llegada de nuevos archivos, o comprométete a una segunda pasada.

Plan de ejecución (la parte que puedes poner en un ticket de cambio)

  1. Crea una lista estable de archivos (delimitada por NUL, ordenada).
  2. Genera el TSV de plan; revisa el informe de colisiones.
  3. Ejecuta un canario en 1–2k archivos de directorios representativos.
  4. Valida un consumidor aguas abajo (índice de búsqueda, sistema de builds, app) con los resultados del canario.
  5. Aplica el renombrado completo con logging habilitado; haz tail del log.
  6. Tras la finalización: busca temporales sobrantes, líneas de error y patrones de nombres inesperados.
  7. Comunica: publica la ubicación del log de renombrado y las reglas de normalización usadas.

Checklist de verificación post-renombrado

  1. Errores: el recuento con awk de entradas ERROR debe ser cero o explicado.
  2. Archivos temporales: no debe quedar ningún .rename_tmp.*.
  3. Resultados de colisiones: si se usó suffix-hash, asegura que los stakeholders acepten el formato.
  4. Sanidad de apps: los flujos clave aún resuelven rutas.
  5. Sanidad de backups: la próxima copia de seguridad no trata todo como “datos nuevos no relacionados”.
cr0x@server:~$ awk -F'\t' '$2=="ERROR"{c++} END{print c+0}' /tmp/clientA.rename.log
0

Decisión: Si es distinto de cero, decide si arreglar y reejecutar solo para esas rutas (preferido) o revertir.

cr0x@server:~$ find /data/projects/clientA -name '.rename_tmp.*' | wc -l
0

Decisión: Si es distinto de cero, tienes una ejecución interrumpida o intervención manual. Resuélvelo antes de declarar victoria.

Tres micro-historias corporativas desde las trincheras del renombrado

Incidente #1: La suposición equivocada (“rename siempre es atómico”)

La petición parecía inofensiva: “Normalizar nombres de archivo en el directorio de exportación a minúsculas.” El directorio vivía bajo /exports, y el ingeniero asumió que era un montaje local normal. Escribieron un script rápido que movía archivos a un directorio de staging (también bajo /exports), los renombraba y los movía de vuelta. Funcionó bien en pruebas.

En producción, /exports era un montaje NFS gestionado por autofs, y el directorio de staging era en realidad un punto de montaje diferente en otro backend. El “mover” se convirtió silenciosamente en copiar+borrar. A mitad de la ejecución, la red falló. La fase de copia se ralentizó; la fase de borrado aún ocurrió para algunos archivos; el script reintentó varias veces, produciendo un jardín de archivos parciales y originales faltantes.

Lo divertido vino después: trabajos aguas abajo vieron archivos “nuevos” con nombres nuevos, más los esperados que faltaban. Su lógica de reconciliación era basada en rutas, no en contenido, por lo que marcó un montón de discrepancias falsas. Finanzas se unieron al chat. Nadie lo disfrutó.

La solución no fue sofisticada. Reejecutaron la operación como un rename en sitio puro dentro de un único sistema de archivos, después de confirmar los límites de montaje con df -Th. También añadieron una comprobación que “se niega a operar si el destino está en otro dispositivo”. Es una barrera que no aprecias hasta que la necesitas.

Incidente #2: La optimización que salió mal (“paralelízalo”)

Otro equipo tenía millones de imágenes pequeñas para renombrar. Un ingeniero vio una estimación en tiempo de pared que le hizo pestañear y hizo lo que los ingenieros hacen: lanzó trabajos en paralelo. No solo unos pocos—docenas de workers, cada uno machacando mv y stat en bucles cerrados.

El almacenamiento era un NAS compartido con un servidor de metadata ya ocupado. Los renombrados son operaciones de metadata, y las operaciones de metadata pueden ser lo más serializado de tu flota. La ejecución en paralelo convirtió una cola manejable en una estampida. La latencia se disparó para cargas no relacionadas. Los builds se ralentizaron. El pipeline CI de alguien caducó y reintentó, empeorando la situación.

El remate: el tiempo total de finalización apenas mejoró. La carga no estaba limitada por CPU; estaba limitada por metadata. Más workers solo significaron más gente esperando en la cola mientras además molestaban al resto.

Se recuperaron reduciendo la concurrencia a un número pequeño y fijo, agrupando operaciones y haciendo un canario para medir el rendimiento antes de comprometerse. También movieron la ventana a fuera de pico y coordinó con el equipo de almacenamiento. La “optimización” no fue paralelismo; fue no pelear con los cuellos de botella del sistema de almacenamiento.

Incidente #3: La práctica aburrida que salvó el día (snapshots + revisión de plan)

Una organización sujeta a cumplimiento necesitaba sanitizar nombres antes de archivar: quitar espacios, eliminar caracteres especiales, conservar extensiones. El equipo tuvo el buen juicio de tratarlo como un cambio operativo, no como una fiesta de shell. Tomaron un snapshot, generaron un archivo de plan y otro ingeniero revisó el informe de colisiones.

Durante la revisión encontraron un patrón: un subconjunto de archivos usaba puntuación para codificar significado (piensa en nombres tipo “A/B test”), y la sanitización colapsaría categorías distintas. No era pérdida de datos—peor, sería un etiquetado erróneo. Ajustaron el conjunto de reglas para preservar ciertos caracteres y añadieron suffix-hash solo para el puñado de colisiones restantes. Luego ejecutaron un canario, validaron que un job interno de indexado aún coincidiera con los patrones esperados y procedieron con la ejecución completa. El trabajo de archivo después completó sin reingestar todo porque el sistema de almacenamiento reconoció los inodes; solo cambiaron nombres.

No hubo nada glamoroso. Nadie ganó un trofeo. Pero evitó el tipo de catástrofe en cámara lenta que termina con una sala de conferencias y una presentación con timeline.

Preguntas frecuentes

1) ¿Puedo simplemente usar el comando rename?

A veces. Pero hay múltiples implementaciones de rename (basado en Perl vs estilo util-linux), diferentes flags y comportamientos muy distintos. Para trabajo en producción prefiero un script que registre cada acción y haga detección de colisiones explícita.

2) ¿Por qué no usar find ... -exec mv?

Puedes, pero es fácil crear sobrecarga de subprocess por archivo e imposible hacer un dry-run significativo con detección de colisiones. Además, te odiarás cuando un nombre con salto rompa el parseo de tu log.

3) ¿Renombrar cambia el contenido o las marcas de tiempo?

Un rename puro en el mismo sistema de archivos no cambia el contenido. Típicamente tampoco modifica mtime, pero ctime (tiempo de cambio del inode) sí cambiará porque la metadata cambió. Si ves cambios de contenido, probablemente hiciste copiar+borrar al cruzar sistemas de archivos.

4) ¿Qué pasa con los hard links—duplicaré datos?

Renombrar no duplica datos. Pero los hard links significan que varios nombres apuntan al mismo inode. Si renombras un nombre, los otros nombres siguen existiendo. Si esperabas “un archivo”, podrías encontrar “otro archivo” aún con el nombre antiguo.

5) ¿Cómo manejo colisiones de forma segura?

Por defecto deténte y corrige tus reglas. Si el negocio exige “sin revisión humana”, usa unicidad determinista (suffix-hash) y documenta el formato. Evita sufijos aleatorios; quieres resultados reproducibles.

6) ¿Cómo hago rollback?

Mejor: rollback de snapshot del sistema de archivos (cuando sea aceptable). Si no: reproducir el mapeo inverso desde el log. Por eso el script registra RENAMED src original → dest final. Si no tienes un log completo, el rollback se convierte en arqueología.

7) ¿Debo ejecutar esto como root?

Sólo si es necesario. Root hace que los problemas de permisos “desaparezcan” y los reemplaza por problemas de responsabilidad. Prefiere ejecutar como la cuenta de servicio propietaria de los datos, con concesiones de acceso explícitas donde se necesiten.

8) ¿Cómo mantengo las apps funcionando después de renombrar?

O actualizas referencias (BD/config) como parte del cambio, o usas una capa de compatibilidad como symlinks. Los symlinks pueden romper herramientas y suposiciones de seguridad, así que pruébalos. Para algunos flujos, es mejor actualizar la app que parchear rutas.

9) ¿Bajar a minúsculas los nombres siempre es buena idea?

No. Es cómodo, pero puede destruir significado (códigos de producto, convenciones humanas) e introducir colisiones. Minúsculas sólo cuando tengas una razón y una estrategia de colisiones.

10) ¿Cómo manejo Unicode de forma segura?

Al menos trata los nombres como bytes en tu pipeline y evita herramientas que asuman “texto imprimible”. Si debes normalizar Unicode, hazlo con una librería deliberada y detección de colisiones. “Se ve igual” no es garantía.

Próximos pasos que realmente deberías hacer

  1. Elige la política: decide tus reglas de normalización y comportamiento ante colisiones. Escríbelo en el ticket.
  2. Congela el alcance: genera una lista ordenada delimitada por NUL para renombrar exactamente lo que revisaste.
  3. Planifica primero: genera el TSV de mapeo y revisa colisiones como si importara.
  4. Snapshot si puedes: snapshot ZFS, snapshot del sistema de archivos o al menos confirma que los backups están sanos.
  5. Ejecución canaria: 1–2k archivos de directorios representativos, luego valida consumidores aguas abajo.
  6. Ejecución completa con log: haz tail, cuenta errores y busca nombres temporales sobrantes.
  7. Comunica los cambios: publica el conjunto de reglas y la ruta del log. La gente preguntará “dónde quedó mi archivo” por semanas.

Si recuerdas una cosa: un renombrado masivo seguro no trata sobre transformaciones de cadena ingeniosas. Trata de probar que cada ruta origen mapea a una ruta destino, sin colisiones, con rollback y con el sistema de archivos comportándose como crees que lo hace.

← Anterior
Claves SSH en WSL: Configuración segura (y cómo no filtrarlas)
Siguiente →
Instalación de Void Linux: la distro minimalista que se siente sorprendentemente moderna

Deja un comentario