En las dos lecciones anteriores hemos escrito rutas así: 'datos/eventos.json', `${DIRECTORIO_BASE}/${mes}`, ruta.slice(ruta.lastIndexOf('/') + 1). Todo eso funciona en tu máquina, hoy, ejecutando desde la raíz del proyecto. Y todo eso está mal.

Falla si alguien ejecuta el programa desde otra carpeta. Falla en Windows. Falla si un nombre trae un espacio en el sitio equivocado. Y, en el caso más grave, permite que un usuario malintencionado escape del directorio que creías estar sirviendo y se lleve ficheros del sistema.

El módulo path existe para eliminar toda esa clase de errores. No calcula nada del disco —path es puro tratamiento de texto y no toca el sistema de archivos—, pero conoce las reglas de cada plataforma. Al terminar esta lección tendrás las rutas de Escena Viva centralizadas en src/config/rutas.js, refactorizadas las dos lecciones anteriores, y sabrás blindar una ruta construida con datos que vienen de fuera.

Contenido

  1. Por qué concatenar rutas es un error
  2. join frente a resolve: la diferencia exacta
  3. Descomponer rutas: basename, dirname, extname, parse, format
  4. process.cwd() frente a __dirname: el error clásico
  5. sep, posix y win32
  6. normalize y relative
  7. Seguridad: recorrido de directorios
  8. src/config/rutas.js y la refactorización de Escena Viva

  1. Por qué concatenar rutas es un error

Pegar cadenas parece inofensivo hasta que dejas de controlar los trozos:

const directorio = 'informes/';
const nombre = '2026-08';

directorio + '/' + nombre;      // 'informes//2026-08'   <- barra duplicada

Los problemas concretos son cuatro y todos aparecen tarde:

Problema Ejemplo Consecuencia
Separadores duplicados o ausentes 'informes//2026-08', 'informesdatos' Funciona a veces; en comparaciones de texto, nunca
Separador equivocado 'informes\\2026-08' en Linux El nombre de fichero contiene una barra invertida literal
.. sin resolver 'informes/2026-08/../2026-07' Dos rutas distintas apuntan al mismo sitio y no se detecta
Relativa contra absoluta '/etc/passwd' pegado tras un prefijo La barra inicial reinicia la ruta y anula tu directorio base

Ese último es la puerta del agujero de seguridad del apartado 7. La regla es sencilla y no admite excepciones: para construir una ruta, path.join o path.resolve; nunca el operador + ni una plantilla de texto.

const path = require('node:path');

path.join('informes/', '/2026-08', 'ocupacion.json');
// 'informes/2026-08/ocupacion.json'   <- barras normalizadas

  1. join frente a resolve: la diferencia exacta

Las dos combinan trozos de ruta, pero responden a preguntas distintas:

  • path.join(...) pega los segmentos y normaliza el resultado. Si el resultado es relativo, se queda relativo.
  • path.resolve(...) construye siempre una ruta absoluta, procesando los argumentos de derecha a izquierda y parando en cuanto ha formado una ruta absoluta. Si se le acaban los argumentos sin conseguirlo, antepone process.cwd().

Con el proceso ejecutándose en /home/ana/escena-viva:

Llamada path.join path.resolve
('datos', 'eventos.json') datos/eventos.json /home/ana/escena-viva/datos/eventos.json
('/datos', 'eventos.json') /datos/eventos.json /datos/eventos.json
('datos', '/eventos.json') datos/eventos.json /eventos.json
('informes', '..', 'datos') datos /home/ana/escena-viva/datos
('informes', '../..', 'x') ../x /home/ana/x
() (sin argumentos) . /home/ana/escena-viva

Las dos filas destacadas son las importantes. En la tercera, resolve descarta todo lo que había a la izquierda en cuanto encuentra un argumento absoluto: path.resolve('datos', '/eventos.json') es /eventos.json, no datos/eventos.json. join, en cambio, trata la barra inicial como un simple separador y la absorbe.

Y en la quinta fila, join conserva el .. que sobresale del punto de partida (no puede saber dónde está), mientras que resolve lo aplica sobre una ruta real y desaparece.

Úsalo cuando... Función
Compones segmentos que ya sabes relativos entre sí (nombre de fichero dentro de una carpeta) join
Necesitas una ruta absoluta y definitiva (abrir un fichero, comparar rutas, validar) resolve
Vas a comprobar que una ruta cae dentro de un directorio permitido resolve, siempre

  1. Descomponer rutas: basename, dirname, extname, parse, format

const ruta = '/home/ana/escena-viva/informes/2026-08/ocupacion-2026-08-11.json';

path.basename(ruta);                 // 'ocupacion-2026-08-11.json'
path.basename(ruta, '.json');        // 'ocupacion-2026-08-11'   <- sin extension
path.dirname(ruta);                  // '/home/ana/escena-viva/informes/2026-08'
path.extname(ruta);                  // '.json'   (con el punto)

path.parse devuelve todas las piezas a la vez, y path.format hace el viaje de vuelta:

const partes = path.parse(ruta);
// {
//   root: '/',
//   dir:  '/home/ana/escena-viva/informes/2026-08',
//   base: 'ocupacion-2026-08-11.json',
//   ext:  '.json',
//   name: 'ocupacion-2026-08-11'
// }

// Cambiar la extension sin tocar texto a mano:
path.format({ ...partes, base: undefined, ext: '.csv' });
// '/home/ana/escena-viva/informes/2026-08/ocupacion-2026-08-11.csv'

Ese base: undefined no es un capricho: format da prioridad a base sobre name + ext, así que si dejas el base original tu cambio de extensión se ignora en silencio. Es una de esas trampas que cuesta media hora localizar.

Dos avisos sobre extname: en un fichero sin punto devuelve la cadena vacía, y en archivo.tar.gz devuelve solo '.gz', porque no entiende de extensiones compuestas.

  1. process.cwd() frente a __dirname: el error clásico

Este apartado explica el fallo más frecuente y más desconcertante del trabajo con ficheros en Node.

process.cwd() __dirname
Qué es Directorio desde el que se lanzó el proceso Directorio del fichero que contiene esa variable
Cambia si... El usuario ejecuta desde otra carpeta Nunca (salvo que muevas el fichero)
A qué resuelve una ruta relativa de fs A esto A nada: fs no lo usa

Y aquí está la clave que lo explica todo: todas las rutas relativas que pasas a fs se resuelven contra process.cwd(), no contra el fichero donde escribes la línea. Nuestro src/catalogo-datos.js dice 'datos/eventos.json', y eso significa "datos/eventos.json a partir de donde el usuario haya lanzado el proceso".

# Desde la raiz del proyecto: funciona.
cd ~/escena-viva
node src/catalogo.js
# ...catalogo completo...

# Desde cualquier otro sitio: se rompe.
cd ~
node escena-viva/src/catalogo.js
# [catalogo] No se encuentra datos/eventos.json

El programa es el mismo, el fichero está en su sitio, y falla. Porque en el segundo caso process.cwd() es /home/ana y Node busca /home/ana/datos/eventos.json.

La consecuencia es más grave de lo que parece: el proyecto funcionará en tu terminal y fallará en el arranque automático del servidor, en el cron nocturno, en el contenedor Docker del Módulo 11 y en las pruebas del Módulo 9, porque en todos esos entornos el directorio de trabajo lo decide otro.

La solución es anclar las rutas al código, no al directorio de trabajo:

// MAL: depende de donde se ejecute el proceso.
const ruta = 'datos/eventos.json';

// BIEN: relativa al fichero, siempre la misma.
// __dirname es src/, asi que subimos un nivel hasta la raiz del proyecto.
const ruta = path.join(__dirname, '..', 'datos', 'eventos.json');

Cuándo sí quieres process.cwd(): cuando la ruta la escribe el usuario en la línea de comandos (node herramienta.js informes/agosto.json), porque ahí lo relativo debe interpretarse desde donde está el usuario, no desde donde vive tu código. Esa es la regla completa: datos del proyecto contra __dirname; argumentos del usuario contra process.cwd().

  1. sep, posix y win32

path.sep es el separador de la plataforma: '/' en Linux y macOS, '\\' en Windows. Es útil para partir una ruta en segmentos (ruta.split(path.sep)), pero no lo necesitas para construir: de eso ya se encarga join.

El módulo expone además dos variantes completas:

Variante Separador Cuándo forzarla
path.posix / URLs, rutas dentro de un ZIP o un contenedor, claves de almacenamiento en la nube, rutas guardadas en base de datos
path.win32 \ Manipular rutas de Windows desde otra plataforma (scripts de despliegue)
path (por defecto) El de la plataforma Todo lo que sea acceso real al disco

La distinción importa más de lo que parece. Windows acepta / como separador al abrir ficheros, así que fs funciona igual; el problema aparece cuando esa ruta deja de ser una ruta y se convierte en un identificador: la URL de un cartel de evento, la clave de un objeto en un almacenamiento remoto, el nombre de una entrada dentro de un ZIP. Ahí informes\2026-08\cartel.png no es lo mismo que informes/2026-08/cartel.png, y nadie te avisa.

// Ruta real en disco: la de la plataforma.
const rutaFisica = path.join(DIRECTORIO_INFORMES, mes, fichero);

// Identificador que viajara en una URL: siempre POSIX.
const clavePublica = path.posix.join('informes', mes, fichero);

En el Módulo 4, al servir archivos estáticos, esta distinción será obligatoria: las URLs usan / en todas las plataformas, sin excepción.

  1. normalize y relative

path.normalize('informes//2026-08/../2026-07/./ocupacion.json');
// 'informes/2026-07/ocupacion.json'

normalize limpia una ruta: colapsa separadores repetidos, resuelve . y .. textualmente y corrige el separador. Lo que no hace es tocar el disco ni resolver enlaces simbólicos —para eso está fs.realpath—, y por eso un .. textual puede llevarte a un sitio distinto del real si hay enlaces por medio. join y resolve ya normalizan por dentro; normalize es para cuando recibes una ruta ya montada por otro.

relative responde a "¿cómo llego de aquí a allí?":

path.relative('/home/ana/escena-viva/src', '/home/ana/escena-viva/datos/eventos.json');
// '../datos/eventos.json'

path.relative('/home/ana/escena-viva', '/etc/passwd');
// '../../../etc/passwd'    <- empieza por '..': el destino esta FUERA

Ese segundo ejemplo es la base de la comprobación de seguridad que viene ahora: si la ruta relativa de una base a un destino empieza por .., el destino está fuera de la base.

  1. Seguridad: recorrido de directorios

El recorrido de directorios (path traversal) es una de las vulnerabilidades más antiguas y más vivas de la web. Aparece siempre que se construye una ruta con datos que vienen de fuera.

Imagina el descargador de informes de Escena Viva: el usuario pide un fichero por su nombre y el servidor se lo devuelve.

// VULNERABLE. No hagas esto.
async function descargarInforme(nombrePedido) {
  const ruta = path.join('informes', nombrePedido);
  return fs.readFile(ruta, 'utf8');
}

Con nombrePedido = '2026-08/ocupacion-2026-08-11.json' todo va bien. Con esto, no:

await descargarInforme('../../../../etc/passwd');
// path.join('informes', '../../../../etc/passwd') -> '../../../etc/passwd'
// El proceso lee /etc/passwd y lo devuelve por la red.

join normaliza, pero no protege: aplica los .. con toda la corrección del mundo y te saca del directorio. Y hay más variantes: un nombrePedido absoluto (/etc/passwd) escapa aún más fácil con resolve, y los atacantes prueban además codificaciones (%2e%2e%2f), barras invertidas y bytes nulos.

La defensa correcta tiene tres pasos y no depende de listas negras:

// src/utiles/ruta-segura.js
// Resuelve una ruta pedida desde fuera dentro de un directorio base,
// garantizando que no se escapa de el.

const path = require('node:path');

function resolverDentroDe(directorioBase, rutaPedida) {
  // 1. Base absoluta y definitiva.
  const base = path.resolve(directorioBase);

  // 2. Resolver el destino CONTRA la base. resolve aplica los '..'.
  const destino = path.resolve(base, rutaPedida);

  // 3. Comprobar que el destino sigue dentro. Con path.relative:
  //    si empieza por '..' o es absoluta, se ha salido.
  const desdeLaBase = path.relative(base, destino);
  const dentro = desdeLaBase !== '' &&
                 !desdeLaBase.startsWith('..') &&
                 !path.isAbsolute(desdeLaBase);

  if (!dentro) {
    const error = new Error('Ruta fuera del directorio permitido');
    error.codigo = 'RUTA_NO_PERMITIDA';
    throw error;
  }

  return destino;
}

module.exports = { resolverDentroDe };

Por qué esta comprobación es la buena:

  • Se valida la ruta ya resuelta, no la cadena de entrada. Da igual cómo esté escrita —.., ., barras repetidas, mezcla de separadores—: cuando resolve termina, solo queda una ruta canónica, y esa es la que se juzga.
  • Se usa path.relative en lugar de destino.startsWith(base). La comparación de prefijos tiene un fallo sutil: /datos/informes-privados empieza por /datos/informes y pasaría el filtro siendo otro directorio. Comparar prefijos exige acordarse de añadir el separador; relative no se equivoca.
  • Rechaza también la cadena vacía, que significa "el propio directorio base" y casi nunca es un fichero válido que servir.

Queda un caso que path no puede resolver solo: si dentro de informes/ hay un enlace simbólico que apunta fuera, la ruta resuelta parece legítima. Para blindarlo del todo hay que comprobar el destino real con fs.realpath y volver a validar, o directamente no permitir enlaces en los directorios que se sirven. Retomaremos toda esta defensa en la lección 04-04, donde el nombre del fichero lo pondrá literalmente la URL de una petición HTTP.

  1. src/config/rutas.js y la refactorización de Escena Viva

Con todo lo anterior, ya podemos arreglar la deuda de las dos lecciones previas. La idea es tener un único fichero que sepa dónde está cada cosa, y que todo el proyecto lo consulte:

// src/config/rutas.js
// Unica fuente de verdad sobre la ubicacion de los ficheros del proyecto.
// Todas las rutas son ABSOLUTAS y se anclan al codigo, no al directorio
// desde el que se ejecute el proceso.

const path = require('node:path');

// __dirname es <raiz>/src/config, asi que subimos dos niveles.
const RAIZ = path.resolve(__dirname, '..', '..');

const DIRECTORIO_DATOS = path.join(RAIZ, 'datos');
const DIRECTORIO_INFORMES = path.join(RAIZ, 'informes');

const FICHERO_EVENTOS = path.join(DIRECTORIO_DATOS, 'eventos.json');
const FICHERO_VENTAS = path.join(DIRECTORIO_DATOS, 'ventas.csv');

module.exports = {
  RAIZ,
  DIRECTORIO_DATOS,
  DIRECTORIO_INFORMES,
  FICHERO_EVENTOS,
  FICHERO_VENTAS
};

Los cambios en el resto del proyecto son pequeños y de una sola línea cada uno:

// src/catalogo-datos.js
const { FICHERO_EVENTOS } = require('./config/rutas.js');

// Antes: const RUTA_EVENTOS = 'datos/eventos.json';
// Ahora la constante viene de la configuracion y es absoluta.
const contenido = await fs.readFile(FICHERO_EVENTOS, 'utf8');
// src/informes/almacen-informes.js
const path = require('node:path');
const { DIRECTORIO_INFORMES } = require('../config/rutas.js');

async function guardarInforme(contenido, { fecha = new Date(), sobrescribir = false } = {}) {
  const { mes, dia } = particionarFecha(fecha);
  const directorio = path.join(DIRECTORIO_INFORMES, mes);
  const ruta = path.join(directorio, `ocupacion-${dia}.json`);
  // ...el resto queda igual
}

Y en la rotación de registros del ejercicio 3 de la lección anterior, el troceado manual desaparece:

// Antes:
// const directorio = ruta.slice(0, ruta.lastIndexOf('/')) || '.';
// const base = ruta.slice(ruta.lastIndexOf('/') + 1);

const directorio = path.dirname(ruta);
const base = path.basename(ruta);

Cuatro ventajas concretas de centralizar:

  1. El proyecto funciona desde cualquier directorio, porque todo cuelga de RAIZ, que se calcula a partir de __dirname.
  2. Cambiar la ubicación de una carpeta es editar una línea, no buscar veintitrés cadenas repetidas por el código.
  3. Las pruebas del Módulo 9 pueden sustituir el módulo entero por uno que apunte a un directorio temporal.
  4. Se ve de un vistazo qué ficheros toca la aplicación, que es información valiosa para revisar seguridad y despliegue.

Comprobación de que la deuda está saldada:

cd /tmp && node ~/escena-viva/src/catalogo.js --tabla
# La tabla sale igual que desde la raiz del proyecto.

Errores Comunes y Consejos

  • Construir rutas con + o plantillas de texto. Siempre path.join o path.resolve.
  • Creer que path toca el disco. No comprueba existencia ni resuelve enlaces: es manipulación de texto. Para lo otro, fs.stat y fs.realpath.
  • Usar path.resolve con un segmento que podría ser absoluto. Descarta todo lo anterior. Si el segmento viene de fuera, valida antes con resolverDentroDe.
  • Validar con destino.startsWith(base). Deja pasar directorios hermanos con prefijo común. Usa path.relative.
  • Confiar en process.cwd() para los datos del proyecto. Funciona en tu terminal y se rompe en cron, en Docker y en las pruebas.
  • Usar path.sep en URLs. Las URLs son POSIX en todas las plataformas: path.posix.join.
  • Consejo: guarda siempre rutas absolutas en variables y objetos internos. Convierte a relativa solo al mostrarla al usuario, con path.relative(RAIZ, ruta).
  • Consejo: en módulos ES no existe __dirname; se obtiene con path.dirname(fileURLToPath(import.meta.url)), como viste en la lección 02-07.

Ejercicios

Ejercicio 1: inspector de rutas

Escribe src/laboratorio/inspeccionar-ruta.js que reciba una ruta por process.argv y muestre por stdout una tabla con: la ruta tal cual, si es absoluta, su resolución con resolve desde process.cwd(), su dirname, basename, extname, el resultado de parse, y su ruta relativa desde RAIZ. Pruébalo con datos/eventos.json, /etc/hosts y ../../x/y.txt, ejecutándolo desde dos directorios distintos y explicando las diferencias.

Ejercicio 2: batería de pruebas de resolverDentroDe

Escribe src/laboratorio/probar-ruta-segura.js que ejecute resolverDentroDe(DIRECTORIO_INFORMES, entrada) sobre esta lista y muestre para cada caso si se permitió o se rechazó: '2026-08/ocupacion.json', '../datos/eventos.json', '/etc/passwd', '2026-08/../2026-07/x.json', '..', '', './2026-08/./x.json' y '2026-08/../../../../etc/passwd'. Razona cada resultado antes de ejecutarlo.

Ejercicio 3: migrar el almacén de informes

Refactoriza src/informes/almacen-informes.js por completo para que no quede ni una sola ruta construida con texto: guardarInforme, listarInformes y purgarInformes deben usar path.join sobre DIRECTORIO_INFORMES, y listarInformes debe devolver además un campo rutaRelativa calculado con path.relative(RAIZ, ruta), más legible para mostrar por consola.

Soluciones

Solución 1. Lo interesante no es el código sino el experimento. Ejecutado desde la raíz del proyecto y desde /tmp, la ruta datos/eventos.json da dos resoluciones distintas:

cd ~/escena-viva && node src/laboratorio/inspeccionar-ruta.js datos/eventos.json
# resuelta: /home/ana/escena-viva/datos/eventos.json

cd /tmp && node ~/escena-viva/src/laboratorio/inspeccionar-ruta.js datos/eventos.json
# resuelta: /tmp/datos/eventos.json

/etc/hosts da lo mismo en los dos casos porque ya es absoluta, y ../../x/y.txt cambia igual que el primero. Esa es exactamente la diferencia entre process.cwd() y __dirname en una sola pantalla. Nota que path.parse('/etc/hosts') devuelve ext: '' y name: 'hosts': no hay extensión que extraer.

Solución 2. Los resultados, con DIRECTORIO_INFORMES como base:

Entrada Resultado Por qué
2026-08/ocupacion.json Permitida Cae dentro; relative da 2026-08/ocupacion.json
../datos/eventos.json Rechazada relative da ../datos/eventos.json, empieza por ..
/etc/passwd Rechazada resolve la toma como absoluta y descarta la base
2026-08/../2026-07/x.json Permitida Los .. se cancelan dentro de la base
.. Rechazada Apunta al padre de la base
'' (vacía) Rechazada relative da '': es la propia base, no un fichero
./2026-08/./x.json Permitida Los . desaparecen al normalizar
2026-08/../../../../etc/passwd Rechazada Cuatro niveles arriba salen de la base

La conclusión práctica: no hay que enumerar los ataques. La validación no busca .. ni caracteres raros en la entrada; resuelve primero y pregunta después dónde ha ido a parar. Cualquier codificación exótica que el atacante invente acaba, tras resolve, en una ruta canónica que se juzga igual que las demás.

Solución 3. El patrón se repite en las tres funciones: sustituir cada plantilla por path.join y añadir la ruta relativa en la salida.

const path = require('node:path');
const { RAIZ, DIRECTORIO_INFORMES } = require('../config/rutas.js');

// En listarInformes, dentro del bucle de ficheros:
const ruta = path.join(directorio, fichero.name);
const info = await fs.stat(ruta);

informes.push({
  mes,
  fichero: fichero.name,
  ruta,                                        // absoluta: para trabajar
  rutaRelativa: path.relative(RAIZ, ruta),     // relativa: para mostrar
  tamanoBytes: info.size,
  modificado: info.mtime.toISOString()
});

Fíjate en el criterio que aparece aquí y que conviene adoptar en todo el proyecto: absoluta para operar, relativa solo para presentar. Un console.table con rutas absolutas de setenta caracteres es ilegible; una ruta relativa guardada en una variable y usada más tarde es una bomba de relojería.

Conclusión

Las rutas de Escena Viva ya no dependen de dónde ejecutes el programa. Has visto por qué concatenar cadenas de rutas es un error —separadores duplicados, separador equivocado, .. sin resolver y, sobre todo, un segmento absoluto que anula tu directorio base— y conoces la diferencia exacta entre join, que pega y normaliza conservando el carácter relativo, y resolve, que procesa de derecha a izquierda hasta formar una ruta absoluta y descarta todo lo anterior en cuanto encuentra un segmento absoluto.

Sabes descomponer rutas con basename, dirname, extname, parse y format —con la trampa del base que gana a name + ext—, y tienes claro el error clásico que hace que un programa funcione en tu carpeta y falle desde otra: fs resuelve las rutas relativas contra process.cwd(), no contra el fichero donde las escribiste. De ahí la regla: datos del proyecto anclados a __dirname; argumentos del usuario, contra process.cwd().

Distingues path.posix de path.win32 y sabes que la frontera está en si la ruta es un acceso al disco o un identificador que viajará en una URL. Y has construido la defensa contra el recorrido de directorios: resolver contra la base y comprobar con path.relative que el destino no se ha escapado, en lugar de perseguir .. en la entrada o comparar prefijos de texto.

Escena Viva tiene ahora src/config/rutas.js con RAIZ, DIRECTORIO_DATOS, DIRECTORIO_INFORMES, FICHERO_EVENTOS y FICHERO_VENTAS, y las dos lecciones anteriores están refactorizadas para usarlo. El proyecto se ejecuta correctamente desde cualquier directorio.

Ese FICHERO_VENTAS que hemos declarado apunta a un fichero que todavía no existe, y no es casualidad. En Trabajando con Streams llega el problema que readFile no puede resolver: el histórico de ventas de una temporada no cabe en el montículo de V8. Veremos qué es un stream y sus cuatro tipos, por qué son EventEmitter de los que ya conoces, qué significa realmente la contrapresión y qué pasa cuando se ignora, y procesaremos datos/ventas.csv línea a línea con memoria constante.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados