A les dues lliçons anteriors hem escrit rutes així: 'dades/esdeveniments.json', `${DIRECTORI_BASE}/${mes}`, ruta.slice(ruta.lastIndexOf('/') + 1). Tot això funciona a la teva màquina, avui, executant des de l'arrel del projecte. I tot això està malament.

Falla si algú executa el programa des d'una altra carpeta. Falla a Windows. Falla si un nom porta un espai al lloc equivocat. I, en el cas més greu, permet que un usuari malintencionat s'escapi del directori que et pensaves que servies i s'endugui fitxers del sistema.

El mòdul path existeix per eliminar tota aquesta mena d'errors. No calcula res del disc —path és pur tractament de text i no toca el sistema de fitxers—, però coneix les regles de cada plataforma. En acabar aquesta lliçó tindràs les rutes d'Escena Viva centralitzades a src/config/rutes.js, refactoritzades les dues lliçons anteriors, i sabràs blindar una ruta construïda amb dades que vénen de fora.

Contingut

  1. Per què concatenar rutes és un error
  2. join davant de resolve: la diferència exacta
  3. Descompondre rutes: basename, dirname, extname, parse, format
  4. process.cwd() davant de __dirname: l'error clàssic
  5. sep, posix i win32
  6. normalize i relative
  7. Seguretat: recorregut de directoris
  8. src/config/rutes.js i la refactorització d'Escena Viva

  1. Per què concatenar rutes és un error

Enganxar cadenes sembla inofensiu fins que deixes de controlar els trossos:

const directori = 'informes/';
const nom = '2026-08';

directori + '/' + nom;          // 'informes//2026-08'   <- barra duplicada

Els problemes concrets són quatre i tots apareixen tard:

Problema Exemple Conseqüència
Separadors duplicats o absents 'informes//2026-08', 'informesdades' Funciona de vegades; en comparacions de text, mai
Separador equivocat 'informes\\2026-08' a Linux El nom de fitxer conté una barra invertida literal
.. sense resoldre 'informes/2026-08/../2026-07' Dues rutes diferents apunten al mateix lloc i no es detecta
Relativa contra absoluta '/etc/passwd' enganxat després d'un prefix La barra inicial reinicia la ruta i anul·la el teu directori base

Aquest últim és la porta del forat de seguretat de l'apartat 7. La regla és senzilla i no admet excepcions: per construir una ruta, path.join o path.resolve; mai l'operador + ni una plantilla de text.

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

path.join('informes/', '/2026-08', 'ocupacio.json');
// 'informes/2026-08/ocupacio.json'   <- barres normalitzades

  1. join davant de resolve: la diferència exacta

Totes dues combinen trossos de ruta, però responen a preguntes diferents:

  • path.join(...) enganxa els segments i normalitza el resultat. Si el resultat és relatiu, es queda relatiu.
  • path.resolve(...) construeix sempre una ruta absoluta, processant els arguments de dreta a esquerra i aturant-se tan bon punt ha format una ruta absoluta. Si se li acaben els arguments sense aconseguir-ho, hi anteposa process.cwd().

Amb el procés executant-se a /home/ana/escena-viva:

Crida path.join path.resolve
('dades', 'esdeveniments.json') dades/esdeveniments.json /home/ana/escena-viva/dades/esdeveniments.json
('/dades', 'esdeveniments.json') /dades/esdeveniments.json /dades/esdeveniments.json
('dades', '/esdeveniments.json') dades/esdeveniments.json /esdeveniments.json
('informes', '..', 'dades') dades /home/ana/escena-viva/dades
('informes', '../..', 'x') ../x /home/ana/x
() (sense arguments) . /home/ana/escena-viva

Les dues files destacades són les importants. A la tercera, resolve descarta tot el que hi havia a l'esquerra tan bon punt troba un argument absolut: path.resolve('dades', '/esdeveniments.json') és /esdeveniments.json, no dades/esdeveniments.json. join, en canvi, tracta la barra inicial com un simple separador i l'absorbeix.

I a la cinquena fila, join conserva el .. que sobresurt del punt de partida (no pot saber on és), mentre que resolve l'aplica sobre una ruta real i desapareix.

Fes-lo servir quan... Funció
Componguis segments que ja saps relatius entre si (nom de fitxer dins d'una carpeta) join
Necessitis una ruta absoluta i definitiva (obrir un fitxer, comparar rutes, validar) resolve
Vagis a comprovar que una ruta cau dins d'un directori permès resolve, sempre

  1. Descompondre rutes: basename, dirname, extname, parse, format

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

path.basename(ruta);                 // 'ocupacio-2026-08-11.json'
path.basename(ruta, '.json');        // 'ocupacio-2026-08-11'   <- sense extensio
path.dirname(ruta);                  // '/home/ana/escena-viva/informes/2026-08'
path.extname(ruta);                  // '.json'   (amb el punt)

path.parse torna totes les peces alhora, i path.format fa el viatge de tornada:

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

// Canviar l'extensio sense tocar text a ma:
path.format({ ...parts, base: undefined, ext: '.csv' });
// '/home/ana/escena-viva/informes/2026-08/ocupacio-2026-08-11.csv'

Aquest base: undefined no és cap caprici: format dona prioritat a base per damunt de name + ext, de manera que si deixes el base original el teu canvi d'extensió s'ignora en silenci. És una d'aquelles trampes que costa mitja hora de localitzar.

Dos avisos sobre extname: en un fitxer sense punt torna la cadena buida, i a fitxer.tar.gz torna només '.gz', perquè no entén d'extensions compostes.

  1. process.cwd() davant de __dirname: l'error clàssic

Aquest apartat explica la fallada més freqüent i més desconcertant del treball amb fitxers a Node.

process.cwd() __dirname
Què és Directori des del qual es va llançar el procés Directori del fitxer que conté aquesta variable
Canvia si... L'usuari executa des d'una altra carpeta Mai (llevat que moguis el fitxer)
Contra què resol una ruta relativa de fs Contra això Contra res: fs no el fa servir

I aquí hi ha la clau que ho explica tot: totes les rutes relatives que passes a fs es resolen contra process.cwd(), no contra el fitxer on escrius la línia. El nostre src/cataleg-dades.js diu 'dades/esdeveniments.json', i això significa «dades/esdeveniments.json a partir d'on l'usuari hagi llançat el procés».

# Des de l'arrel del projecte: funciona.
cd ~/escena-viva
node src/cataleg.js
# ...cataleg complet...

# Des de qualsevol altre lloc: es trenca.
cd ~
node escena-viva/src/cataleg.js
# [cataleg] No es troba dades/esdeveniments.json

El programa és el mateix, el fitxer és al seu lloc, i falla. Perquè en el segon cas process.cwd() és /home/ana i Node busca /home/ana/dades/esdeveniments.json.

La conseqüència és més greu del que sembla: el projecte funcionarà a la teva terminal i fallarà a l'arrencada automàtica del servidor, al cron nocturn, al contenidor Docker del Mòdul 11 i a les proves del Mòdul 9, perquè en tots aquests entorns el directori de treball el decideix un altre.

La solució és ancorar les rutes al codi, no al directori de treball:

// MALAMENT: depen d'on s'executi el proces.
const ruta = 'dades/esdeveniments.json';

// BE: relativa al fitxer, sempre la mateixa.
// __dirname es src/, aixi que pugem un nivell fins a l'arrel del projecte.
const ruta = path.join(__dirname, '..', 'dades', 'esdeveniments.json');

Quan sí que vols process.cwd(): quan la ruta l'escriu l'usuari a la línia d'ordres (node eina.js informes/agost.json), perquè allà el que és relatiu s'ha d'interpretar des d'on és l'usuari, no des d'on viu el teu codi. Aquesta és la regla completa: dades del projecte contra __dirname; arguments de l'usuari contra process.cwd().

  1. sep, posix i win32

path.sep és el separador de la plataforma: '/' a Linux i macOS, '\\' a Windows. És útil per partir una ruta en segments (ruta.split(path.sep)), però no el necessites per construir: d'això ja se n'encarrega join.

El mòdul exposa a més dues variants completes:

Variant Separador Quan forçar-la
path.posix / URL, rutes dins d'un ZIP o un contenidor, claus d'emmagatzematge al núvol, rutes guardades a base de dades
path.win32 \ Manipular rutes de Windows des d'una altra plataforma (scripts de desplegament)
path (per defecte) El de la plataforma Tot el que sigui accés real al disc

La distinció importa més del que sembla. Windows accepta / com a separador en obrir fitxers, així que fs funciona igual; el problema apareix quan aquesta ruta deixa de ser una ruta i es converteix en un identificador: l'URL d'un cartell d'esdeveniment, la clau d'un objecte en un emmagatzematge remot, el nom d'una entrada dins d'un ZIP. Allà informes\2026-08\cartell.png no és el mateix que informes/2026-08/cartell.png, i ningú no t'avisa.

// Ruta real al disc: la de la plataforma.
const rutaFisica = path.join(DIRECTORI_INFORMES, mes, fitxer);

// Identificador que viatjara en una URL: sempre POSIX.
const clauPublica = path.posix.join('informes', mes, fitxer);

Al Mòdul 4, en servir fitxers estàtics, aquesta distinció serà obligatòria: les URL fan servir / a totes les plataformes, sense excepció.

  1. normalize i relative

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

normalize neteja una ruta: col·lapsa separadors repetits, resol . i .. textualment i corregeix el separador. El que no fa és tocar el disc ni resoldre enllaços simbòlics —per a això hi ha fs.realpath—, i per això un .. textual pot portar-te a un lloc diferent del real si hi ha enllaços pel mig. join i resolve ja normalitzen per dins; normalize és per quan reps una ruta ja muntada per un altre.

relative respon a «com arribo d'aquí a allà?»:

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

path.relative('/home/ana/escena-viva', '/etc/passwd');
// '../../../etc/passwd'    <- comenca per '..': la destinacio es FORA

Aquest segon exemple és la base de la comprovació de seguretat que ve ara: si la ruta relativa d'una base a una destinació comença per .., la destinació és fora de la base.

  1. Seguretat: recorregut de directoris

El recorregut de directoris (path traversal) és una de les vulnerabilitats més antigues i més vives del web. Apareix sempre que es construeix una ruta amb dades que vénen de fora.

Imagina el descarregador d'informes d'Escena Viva: l'usuari demana un fitxer pel seu nom i el servidor l'hi torna.

// VULNERABLE. No facis aixo.
async function descarregarInforme(nomDemanat) {
  const ruta = path.join('informes', nomDemanat);
  return fs.readFile(ruta, 'utf8');
}

Amb nomDemanat = '2026-08/ocupacio-2026-08-11.json' tot va bé. Amb això, no:

await descarregarInforme('../../../../etc/passwd');
// path.join('informes', '../../../../etc/passwd') -> '../../../etc/passwd'
// El proces llegeix /etc/passwd i el torna per la xarxa.

join normalitza, però no protegeix: aplica els .. amb tota la correcció del món i et treu del directori. I encara hi ha més variants: un nomDemanat absolut (/etc/passwd) s'escapa encara més fàcilment amb resolve, i els atacants proven a més codificacions (%2e%2e%2f), barres invertides i bytes nuls.

La defensa correcta té tres passos i no depèn de llistes negres:

// src/utils/ruta-segura.js
// Resol una ruta demanada des de fora dins d'un directori base,
// garantint que no se n'escapa.

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

function resoldreDinsDe(directoriBase, rutaDemanada) {
  // 1. Base absoluta i definitiva.
  const base = path.resolve(directoriBase);

  // 2. Resoldre la destinacio CONTRA la base. resolve aplica els '..'.
  const desti = path.resolve(base, rutaDemanada);

  // 3. Comprovar que la destinacio continua a dins. Amb path.relative:
  //    si comenca per '..' o es absoluta, se n'ha sortit.
  const desDeLaBase = path.relative(base, desti);
  const dintre = desDeLaBase !== '' &&
                 !desDeLaBase.startsWith('..') &&
                 !path.isAbsolute(desDeLaBase);

  if (!dintre) {
    const error = new Error("Ruta fora del directori permes");
    error.codi = 'RUTA_NO_PERMESA';
    throw error;
  }

  return desti;
}

module.exports = { resoldreDinsDe };

Per què aquesta comprovació és la bona:

  • Es valida la ruta ja resolta, no la cadena d'entrada. Tant li fa com estigui escrita —.., ., barres repetides, barreja de separadors—: quan resolve acaba, només queda una ruta canònica, i aquesta és la que es jutja.
  • Es fa servir path.relative en comptes de desti.startsWith(base). La comparació de prefixos té una fallada subtil: /dades/informes-privats comença per /dades/informes i passaria el filtre sent un altre directori. Comparar prefixos exigeix recordar-se d'afegir el separador; relative no s'equivoca.
  • Rebutja també la cadena buida, que significa «el directori base mateix» i gairebé mai no és un fitxer vàlid per servir.

Queda un cas que path no pot resoldre tot sol: si dins d'informes/ hi ha un enllaç simbòlic que apunta fora, la ruta resolta sembla legítima. Per blindar-ho del tot cal comprovar la destinació real amb fs.realpath i tornar a validar, o directament no permetre enllaços als directoris que es serveixen. Reprendrem tota aquesta defensa a la lliçó 04-04, on el nom del fitxer el posarà literalment l'URL d'una petició HTTP.

  1. src/config/rutes.js i la refactorització d'Escena Viva

Amb tot l'anterior, ja podem arreglar el deute de les dues lliçons prèvies. La idea és tenir un únic fitxer que sàpiga on és cada cosa, i que tot el projecte el consulti:

// src/config/rutes.js
// Unica font de veritat sobre la ubicacio dels fitxers del projecte.
// Totes les rutes son ABSOLUTES i s'ancoren al codi, no al directori
// des del qual s'executi el proces.

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

// __dirname es <arrel>/src/config, aixi que pugem dos nivells.
const ARREL = path.resolve(__dirname, '..', '..');

const DIRECTORI_DADES = path.join(ARREL, 'dades');
const DIRECTORI_INFORMES = path.join(ARREL, 'informes');

const FITXER_ESDEVENIMENTS = path.join(DIRECTORI_DADES, 'esdeveniments.json');
const FITXER_VENDES = path.join(DIRECTORI_DADES, 'vendes.csv');

module.exports = {
  ARREL,
  DIRECTORI_DADES,
  DIRECTORI_INFORMES,
  FITXER_ESDEVENIMENTS,
  FITXER_VENDES
};

Els canvis a la resta del projecte són petits i d'una sola línia cadascun:

// src/cataleg-dades.js
const { FITXER_ESDEVENIMENTS } = require('./config/rutes.js');

// Abans: const RUTA_ESDEVENIMENTS = 'dades/esdeveniments.json';
// Ara la constant ve de la configuracio i es absoluta.
const contingut = await fs.readFile(FITXER_ESDEVENIMENTS, 'utf8');
// src/informes/magatzem-informes.js
const path = require('node:path');
const { DIRECTORI_INFORMES } = require('../config/rutes.js');

async function guardarInforme(contingut, { data = new Date(), sobreescriure = false } = {}) {
  const { mes, dia } = particionarData(data);
  const directori = path.join(DIRECTORI_INFORMES, mes);
  const ruta = path.join(directori, `ocupacio-${dia}.json`);
  // ...la resta queda igual
}

I a la rotació de registres de l'exercici 3 de la lliçó anterior, el trossejat manual desapareix:

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

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

Quatre avantatges concrets de centralitzar:

  1. El projecte funciona des de qualsevol directori, perquè tot penja d'ARREL, que es calcula a partir de __dirname.
  2. Canviar la ubicació d'una carpeta és editar una línia, no buscar vint-i-tres cadenes repetides pel codi.
  3. Les proves del Mòdul 9 poden substituir el mòdul sencer per un que apunti a un directori temporal.
  4. Es veu d'un cop d'ull quins fitxers toca l'aplicació, que és informació valuosa per revisar seguretat i desplegament.

Comprovació que el deute està saldat:

cd /tmp && node ~/escena-viva/src/cataleg.js --taula
# La taula surt igual que des de l'arrel del projecte.

Errors Comuns i Consells

  • Construir rutes amb + o plantilles de text. Sempre path.join o path.resolve.
  • Creure que path toca el disc. No comprova existència ni resol enllaços: és manipulació de text. Per a la resta, fs.stat i fs.realpath.
  • Fer servir path.resolve amb un segment que podria ser absolut. Descarta tot l'anterior. Si el segment ve de fora, valida abans amb resoldreDinsDe.
  • Validar amb desti.startsWith(base). Deixa passar directoris germans amb prefix comú. Fes servir path.relative.
  • Confiar en process.cwd() per a les dades del projecte. Funciona a la teva terminal i es trenca al cron, a Docker i a les proves.
  • Fer servir path.sep a les URL. Les URL són POSIX a totes les plataformes: path.posix.join.
  • Consell: guarda sempre rutes absolutes a variables i objectes interns. Converteix-les a relativa només en mostrar-les a l'usuari, amb path.relative(ARREL, ruta).
  • Consell: als mòduls ES no existeix __dirname; s'obté amb path.dirname(fileURLToPath(import.meta.url)), com vas veure a la lliçó 02-07.

Exercicis

Exercici 1: inspector de rutes

Escriu src/laboratori/inspeccionar-ruta.js que rebi una ruta per process.argv i mostri per stdout una taula amb: la ruta tal com és, si és absoluta, la seva resolució amb resolve des de process.cwd(), el seu dirname, basename, extname, el resultat de parse, i la seva ruta relativa des d'ARREL. Prova'l amb dades/esdeveniments.json, /etc/hosts i ../../x/y.txt, executant-lo des de dos directoris diferents i explicant-ne les diferències.

Exercici 2: bateria de proves de resoldreDinsDe

Escriu src/laboratori/provar-ruta-segura.js que executi resoldreDinsDe(DIRECTORI_INFORMES, entrada) sobre aquesta llista i mostri per a cada cas si es va permetre o es va rebutjar: '2026-08/ocupacio.json', '../dades/esdeveniments.json', '/etc/passwd', '2026-08/../2026-07/x.json', '..', '', './2026-08/./x.json' i '2026-08/../../../../etc/passwd'. Raona cada resultat abans d'executar-lo.

Exercici 3: migrar el magatzem d'informes

Refactoritza src/informes/magatzem-informes.js del tot perquè no quedi ni una sola ruta construïda amb text: guardarInforme, llistarInformes i purgarInformes han de fer servir path.join sobre DIRECTORI_INFORMES, i llistarInformes ha de tornar a més un camp rutaRelativa calculat amb path.relative(ARREL, ruta), més llegible per mostrar per consola.

Solucions

Solució 1. El que és interessant no és el codi sinó l'experiment. Executat des de l'arrel del projecte i des de /tmp, la ruta dades/esdeveniments.json dona dues resolucions diferents:

cd ~/escena-viva && node src/laboratori/inspeccionar-ruta.js dades/esdeveniments.json
# resolta: /home/ana/escena-viva/dades/esdeveniments.json

cd /tmp && node ~/escena-viva/src/laboratori/inspeccionar-ruta.js dades/esdeveniments.json
# resolta: /tmp/dades/esdeveniments.json

/etc/hosts dona el mateix en tots dos casos perquè ja és absoluta, i ../../x/y.txt canvia igual que la primera. Aquesta és exactament la diferència entre process.cwd() i __dirname en una sola pantalla. Fixa't que path.parse('/etc/hosts') torna ext: '' i name: 'hosts': no hi ha extensió per extreure.

Solució 2. Els resultats, amb DIRECTORI_INFORMES com a base:

Entrada Resultat Per què
2026-08/ocupacio.json Permesa Cau a dins; relative dona 2026-08/ocupacio.json
../dades/esdeveniments.json Rebutjada relative dona ../dades/esdeveniments.json, comença per ..
/etc/passwd Rebutjada resolve la pren com a absoluta i descarta la base
2026-08/../2026-07/x.json Permesa Els .. es cancel·len dins de la base
.. Rebutjada Apunta al pare de la base
'' (buida) Rebutjada relative dona '': és la base mateixa, no un fitxer
./2026-08/./x.json Permesa Els . desapareixen en normalitzar
2026-08/../../../../etc/passwd Rebutjada Quatre nivells amunt surten de la base

La conclusió pràctica: no cal enumerar els atacs. La validació no busca .. ni caràcters estranys a l'entrada; resol primer i pregunta després on ha anat a parar. Qualsevol codificació exòtica que l'atacant s'inventi acaba, després de resolve, en una ruta canònica que es jutja igual que les altres.

Solució 3. El patró es repeteix a les tres funcions: substituir cada plantilla per path.join i afegir la ruta relativa a la sortida.

const path = require('node:path');
const { ARREL, DIRECTORI_INFORMES } = require('../config/rutes.js');

// A llistarInformes, dins del bucle de fitxers:
const ruta = path.join(directori, fitxer.name);
const info = await fs.stat(ruta);

informes.push({
  mes,
  fitxer: fitxer.name,
  ruta,                                        // absoluta: per treballar
  rutaRelativa: path.relative(ARREL, ruta),    // relativa: per mostrar
  midaBytes: info.size,
  modificat: info.mtime.toISOString()
});

Fixa't en el criteri que apareix aquí i que convé adoptar a tot el projecte: absoluta per operar, relativa només per presentar. Un console.table amb rutes absolutes de setanta caràcters és il·legible; una ruta relativa guardada en una variable i feta servir més tard és una bomba de rellotgeria.

Conclusió

Les rutes d'Escena Viva ja no depenen d'on executis el programa. Has vist per què concatenar cadenes de rutes és un error —separadors duplicats, separador equivocat, .. sense resoldre i, sobretot, un segment absolut que anul·la el teu directori base— i coneixes la diferència exacta entre join, que enganxa i normalitza conservant el caràcter relatiu, i resolve, que processa de dreta a esquerra fins a formar una ruta absoluta i descarta tot l'anterior tan bon punt troba un segment absolut.

Saps descompondre rutes amb basename, dirname, extname, parse i format —amb la trampa del base que guanya a name + ext—, i tens clar l'error clàssic que fa que un programa funcioni a la teva carpeta i falli des d'una altra: fs resol les rutes relatives contra process.cwd(), no contra el fitxer on les vas escriure. D'aquí la regla: dades del projecte ancorades a __dirname; arguments de l'usuari, contra process.cwd().

Distingeixes path.posix de path.win32 i saps que la frontera és si la ruta és un accés al disc o un identificador que viatjarà en una URL. I has construït la defensa contra el recorregut de directoris: resoldre contra la base i comprovar amb path.relative que la destinació no s'ha escapat, en comptes de perseguir .. a l'entrada o comparar prefixos de text.

Escena Viva té ara src/config/rutes.js amb ARREL, DIRECTORI_DADES, DIRECTORI_INFORMES, FITXER_ESDEVENIMENTS i FITXER_VENDES, i les dues lliçons anteriors estan refactoritzades per fer-lo servir. El projecte s'executa correctament des de qualsevol directori.

Aquest FITXER_VENDES que hem declarat apunta a un fitxer que encara no existeix, i no és casualitat. A Treballant amb Streams arriba el problema que readFile no pot resoldre: l'històric de vendes d'una temporada no cap al munt de V8. Veurem què és un stream i els seus quatre tipus, per què són EventEmitter dels que ja coneixes, què significa realment la contrapressió i què passa quan s'ignora, i processarem dades/vendes.csv línia a línia amb memòria constant.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats