In the two previous lessons we have written paths like this: 'data/events.json', `${BASE_DIR}/${month}`, file.slice(file.lastIndexOf('/') + 1). All of that works on your machine, today, running from the project root. And all of that is wrong.

It breaks if somebody runs the program from another folder. It breaks on Windows. It breaks if a name carries a space in the wrong place. And, in the worst case, it lets a malicious user escape the directory you thought you were serving and walk away with system files.

The path module exists to eliminate that entire class of bugs. It computes nothing from the disk — path is pure string handling and never touches the file system — but it knows the rules of each platform. By the end of this lesson you will have the Escena Viva paths centralized in src/config/paths.js, the two previous lessons refactored, and you will know how to harden a path built from data that comes from outside.

Contents

  1. Why concatenating paths is a mistake
  2. join versus resolve: the exact difference
  3. Taking paths apart: basename, dirname, extname, parse, format
  4. process.cwd() versus __dirname: the classic mistake
  5. sep, posix and win32
  6. normalize and relative
  7. Security: path traversal
  8. src/config/paths.js and the Escena Viva refactor

  1. Why concatenating paths is a mistake

Gluing strings looks harmless until you stop controlling the pieces:

const directory = 'reports/';
const name = '2026-08';

directory + '/' + name;      // 'reports//2026-08'   <- duplicated slash

The concrete problems are four and they all show up late:

Problem Example Consequence
Duplicated or missing separators 'reports//2026-08', 'reportsdata' Works sometimes; in string comparisons, never
Wrong separator 'reports\\2026-08' on Linux The file name contains a literal backslash
Unresolved .. 'reports/2026-08/../2026-07' Two different paths point to the same place and nobody notices
Relative against absolute '/etc/passwd' glued after a prefix The leading slash restarts the path and cancels your base directory

That last one is the doorway to the security hole in section 7. The rule is simple and admits no exceptions: to build a path, path.join or path.resolve; never the + operator or a template string.

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

path.join('reports/', '/2026-08', 'occupancy.json');
// 'reports/2026-08/occupancy.json'   <- normalized slashes

  1. join versus resolve: the exact difference

Both combine path fragments, but they answer different questions:

  • path.join(...) glues the segments and normalizes the result. If the result is relative, it stays relative.
  • path.resolve(...) always builds an absolute path, processing the arguments from right to left and stopping as soon as it has formed an absolute path. If it runs out of arguments without getting there, it prepends process.cwd().

With the process running in /home/ana/escena-viva:

Call path.join path.resolve
('data', 'events.json') data/events.json /home/ana/escena-viva/data/events.json
('/data', 'events.json') /data/events.json /data/events.json
('data', '/events.json') data/events.json /events.json
('reports', '..', 'data') data /home/ana/escena-viva/data
('reports', '../..', 'x') ../x /home/ana/x
() (no arguments) . /home/ana/escena-viva

The two highlighted rows are the important ones. In the third, resolve discards everything to its left the moment it finds an absolute argument: path.resolve('data', '/events.json') is /events.json, not data/events.json. join, on the other hand, treats the leading slash as a plain separator and absorbs it.

And in the fifth row, join keeps the .. that sticks out beyond the starting point (it cannot know where it is), while resolve applies it to a real path and it disappears.

Use it when... Function
You compose segments you already know to be relative to each other (a file name inside a folder) join
You need an absolute, definitive path (opening a file, comparing paths, validating) resolve
You are about to check that a path falls inside an allowed directory resolve, always

  1. Taking paths apart: basename, dirname, extname, parse, format

const file = '/home/ana/escena-viva/reports/2026-08/occupancy-2026-08-11.json';

path.basename(file);                 // 'occupancy-2026-08-11.json'
path.basename(file, '.json');        // 'occupancy-2026-08-11'   <- without extension
path.dirname(file);                  // '/home/ana/escena-viva/reports/2026-08'
path.extname(file);                  // '.json'   (with the dot)

path.parse returns all the pieces at once, and path.format makes the return trip:

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

// Change the extension without editing text by hand:
path.format({ ...parts, base: undefined, ext: '.csv' });
// '/home/ana/escena-viva/reports/2026-08/occupancy-2026-08-11.csv'

That base: undefined is no whim: format gives base priority over name + ext, so if you leave the original base in place your extension change is silently ignored. It is one of those traps that takes half an hour to track down.

Two warnings about extname: on a file with no dot it returns the empty string, and on archive.tar.gz it returns only '.gz', because it knows nothing about compound extensions.

  1. process.cwd() versus __dirname: the classic mistake

This section explains the most frequent and most baffling failure in file work with Node.

process.cwd() __dirname
What it is Directory the process was launched from Directory of the file containing that variable
It changes if... The user runs from another folder Never (unless you move the file)
What an fs relative path resolves against This Nothing: fs does not use it

And here is the key that explains everything: every relative path you hand to fs is resolved against process.cwd(), not against the file where you wrote the line. Our src/catalog-data.js says 'data/events.json', and that means "data/events.json starting from wherever the user launched the process".

# From the project root: it works.
cd ~/escena-viva
node src/catalog.js
# ...full catalog...

# From anywhere else: it breaks.
cd ~
node escena-viva/src/catalog.js
# [catalog] data/events.json not found

The program is the same, the file is where it should be, and it fails. Because in the second case process.cwd() is /home/ana and Node looks for /home/ana/data/events.json.

The consequence is more serious than it looks: the project will work in your terminal and fail in the server's automatic startup, in the nightly cron, in the Docker container of Module 11 and in the tests of Module 9, because in all those environments the working directory is decided by somebody else.

The solution is to anchor paths to the code, not to the working directory:

// BAD: depends on where the process is run from.
const file = 'data/events.json';

// GOOD: relative to the file, always the same.
// __dirname is src/, so we go up one level to the project root.
const file = path.join(__dirname, '..', 'data', 'events.json');

When you do want process.cwd(): when the path is typed by the user on the command line (node tool.js reports/august.json), because there anything relative must be interpreted from where the user is, not from where your code lives. That is the complete rule: project data against __dirname; user arguments against process.cwd().

  1. sep, posix and win32

path.sep is the platform separator: '/' on Linux and macOS, '\\' on Windows. It is useful for splitting a path into segments (file.split(path.sep)), but you do not need it to build: join already takes care of that.

The module also exposes two complete variants:

Variant Separator When to force it
path.posix / URLs, paths inside a ZIP or a container, cloud storage keys, paths stored in a database
path.win32 \ Manipulating Windows paths from another platform (deployment scripts)
path (default) The platform's Anything that is real disk access

The distinction matters more than it seems. Windows accepts / as a separator when opening files, so fs works either way; the problem shows up when that path stops being a path and becomes an identifier: the URL of an event poster, the key of an object in remote storage, the name of an entry inside a ZIP. There, reports\2026-08\poster.png is not the same as reports/2026-08/poster.png, and nobody warns you.

// Real path on disk: the platform's.
const physicalPath = path.join(REPORTS_DIR, month, file);

// Identifier that will travel in a URL: always POSIX.
const storageKey = path.posix.join('reports', month, file);

In Module 4, when serving static files, this distinction will be mandatory: URLs use / on every platform, with no exceptions.

  1. normalize and relative

path.normalize('reports//2026-08/../2026-07/./occupancy.json');
// 'reports/2026-07/occupancy.json'

normalize cleans a path: it collapses repeated separators, resolves . and .. textually and fixes the separator. What it does not do is touch the disk or resolve symbolic links — that is what fs.realpath is for — and that is why a textual .. can take you somewhere other than the real place if links are involved. join and resolve already normalize internally; normalize is for when you receive a path somebody else assembled.

relative answers "how do I get from here to there?":

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

path.relative('/home/ana/escena-viva', '/etc/passwd');
// '../../../etc/passwd'    <- starts with '..': the target is OUTSIDE

That second example is the basis of the security check that comes next: if the relative path from a base to a target starts with .., the target is outside the base.

  1. Security: path traversal

Path traversal is one of the oldest and most alive vulnerabilities on the web. It shows up whenever a path is built from data that comes from outside.

Picture the Escena Viva report downloader: the user asks for a file by name and the server hands it over.

// VULNERABLE. Do not do this.
async function downloadReport(requestedName) {
  const file = path.join('reports', requestedName);
  return fs.readFile(file, 'utf8');
}

With requestedName = '2026-08/occupancy-2026-08-11.json' everything is fine. With this, not so much:

await downloadReport('../../../../etc/passwd');
// path.join('reports', '../../../../etc/passwd') -> '../../../etc/passwd'
// The process reads /etc/passwd and sends it over the network.

join normalizes, but does not protect: it applies the .. with perfect correctness and takes you out of the directory. And there are more variants: an absolute requestedName (/etc/passwd) escapes even more easily with resolve, and attackers also try encodings (%2e%2e%2f), backslashes and null bytes.

The correct defense has three steps and does not rely on blacklists:

// src/utils/safe-path.js
// Resolves an externally requested path inside a base directory,
// guaranteeing that it does not escape from it.

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

function resolveWithin(baseDir, requestedPath) {
  // 1. Absolute, definitive base.
  const base = path.resolve(baseDir);

  // 2. Resolve the target AGAINST the base. resolve applies the '..'.
  const target = path.resolve(base, requestedPath);

  // 3. Check the target is still inside. With path.relative:
  //    if it starts with '..' or is absolute, it has escaped.
  const fromBase = path.relative(base, target);
  const inside = fromBase !== '' &&
                 !fromBase.startsWith('..') &&
                 !path.isAbsolute(fromBase);

  if (!inside) {
    const error = new Error('Path outside the allowed directory');
    error.appCode = 'PATH_NOT_ALLOWED';
    throw error;
  }

  return target;
}

module.exports = { resolveWithin };

Why this check is the right one:

  • It validates the already resolved path, not the input string. It does not matter how it is written — .., ., repeated slashes, mixed separators: once resolve is done there is only one canonical path left, and that is the one being judged.
  • It uses path.relative instead of target.startsWith(base). Prefix comparison has a subtle flaw: /data/reports-private starts with /data/reports and would pass the filter while being a different directory. Comparing prefixes forces you to remember to add the separator; relative never gets it wrong.
  • It also rejects the empty string, which means "the base directory itself" and is almost never a valid file to serve.

One case remains that path cannot solve on its own: if inside reports/ there is a symbolic link pointing outside, the resolved path looks legitimate. To harden it completely you have to check the real target with fs.realpath and validate again, or simply not allow links in the directories being served. We will pick this defense back up in lesson 04-04, where the file name will be supplied literally by the URL of an HTTP request.

  1. src/config/paths.js and the Escena Viva refactor

With all of the above, we can now fix the debt from the two previous lessons. The idea is to have a single file that knows where everything is, and to have the whole project consult it:

// src/config/paths.js
// Single source of truth about the location of the project's files.
// Every path is ABSOLUTE and anchored to the code, not to the directory
// the process happens to be run from.

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

// __dirname is <root>/src/config, so we go up two levels.
const ROOT = path.resolve(__dirname, '..', '..');

const DATA_DIR = path.join(ROOT, 'data');
const REPORTS_DIR = path.join(ROOT, 'reports');

const EVENTS_FILE = path.join(DATA_DIR, 'events.json');
const SALES_FILE = path.join(DATA_DIR, 'sales.csv');

module.exports = {
  ROOT,
  DATA_DIR,
  REPORTS_DIR,
  EVENTS_FILE,
  SALES_FILE
};

The changes in the rest of the project are small and one line each:

// src/catalog-data.js
const { EVENTS_FILE } = require('./config/paths.js');

// Before: const EVENTS_FILE = 'data/events.json';
// Now the constant comes from the configuration and is absolute.
const content = await fs.readFile(EVENTS_FILE, 'utf8');
// src/reports/report-store.js
const path = require('node:path');
const { REPORTS_DIR } = require('../config/paths.js');

async function saveReport(content, { date = new Date(), overwrite = false } = {}) {
  const { month, day } = partitionDate(date);
  const directory = path.join(REPORTS_DIR, month);
  const file = path.join(directory, `occupancy-${day}.json`);
  // ...the rest stays the same
}

And in the log rotation of exercise 3 from the previous lesson, the manual slicing disappears:

// Before:
// const directory = file.slice(0, file.lastIndexOf('/')) || '.';
// const base = file.slice(file.lastIndexOf('/') + 1);

const directory = path.dirname(file);
const base = path.basename(file);

Four concrete advantages of centralizing:

  1. The project works from any directory, because everything hangs off ROOT, which is computed from __dirname.
  2. Moving a folder means editing one line, not hunting down twenty-three repeated strings across the code.
  3. The tests in Module 9 can replace the whole module with one pointing at a temporary directory.
  4. You can see at a glance which files the application touches, which is valuable information for security and deployment reviews.

Proof that the debt is settled:

cd /tmp && node ~/escena-viva/src/catalog.js --table
# The table comes out just as it does from the project root.

Common Mistakes and Tips

  • Building paths with + or template strings. Always path.join or path.resolve.
  • Believing path touches the disk. It checks no existence and resolves no links: it is string manipulation. For the rest, fs.stat and fs.realpath.
  • Using path.resolve with a segment that might be absolute. It discards everything before it. If the segment comes from outside, validate first with resolveWithin.
  • Validating with target.startsWith(base). It lets sibling directories with a common prefix through. Use path.relative.
  • Trusting process.cwd() for project data. It works in your terminal and breaks in cron, in Docker and in the tests.
  • Using path.sep in URLs. URLs are POSIX on every platform: path.posix.join.
  • Tip: always store absolute paths in variables and internal objects. Convert to relative only when showing it to the user, with path.relative(ROOT, file).
  • Tip: in ES modules __dirname does not exist; you get it with path.dirname(fileURLToPath(import.meta.url)), as you saw in lesson 02-07.

Exercises

Exercise 1: path inspector

Write src/lab/inspect-path.js that takes a path from process.argv and prints on stdout a table with: the path as given, whether it is absolute, its resolution with resolve from process.cwd(), its dirname, basename, extname, the result of parse, and its relative path from ROOT. Try it with data/events.json, /etc/hosts and ../../x/y.txt, running it from two different directories and explaining the differences.

Exercise 2: test battery for resolveWithin

Write src/lab/test-safe-path.js that runs resolveWithin(REPORTS_DIR, input) over this list and shows for each case whether it was allowed or rejected: '2026-08/occupancy.json', '../data/events.json', '/etc/passwd', '2026-08/../2026-07/x.json', '..', '', './2026-08/./x.json' and '2026-08/../../../../etc/passwd'. Reason out each result before running it.

Exercise 3: migrating the report store

Refactor src/reports/report-store.js completely so that not a single path is built with strings: saveReport, listReports and purgeReports must use path.join over REPORTS_DIR, and listReports must also return a relativePath field computed with path.relative(ROOT, file), more readable for console output.

Solutions

Solution 1. What is interesting is not the code but the experiment. Run from the project root and from /tmp, the path data/events.json gives two different resolutions:

cd ~/escena-viva && node src/lab/inspect-path.js data/events.json
# resolved: /home/ana/escena-viva/data/events.json

cd /tmp && node ~/escena-viva/src/lab/inspect-path.js data/events.json
# resolved: /tmp/data/events.json

/etc/hosts gives the same thing in both cases because it is already absolute, and ../../x/y.txt changes just like the first one. That is exactly the difference between process.cwd() and __dirname on a single screen. Note that path.parse('/etc/hosts') returns ext: '' and name: 'hosts': there is no extension to extract.

Solution 2. The results, with REPORTS_DIR as the base:

Input Result Why
2026-08/occupancy.json Allowed Falls inside; relative gives 2026-08/occupancy.json
../data/events.json Rejected relative gives ../data/events.json, starts with ..
/etc/passwd Rejected resolve takes it as absolute and discards the base
2026-08/../2026-07/x.json Allowed The .. cancel out inside the base
.. Rejected Points to the base's parent
'' (empty) Rejected relative gives '': it is the base itself, not a file
./2026-08/./x.json Allowed The . disappear when normalizing
2026-08/../../../../etc/passwd Rejected Four levels up leave the base

The practical conclusion: there is no need to enumerate the attacks. The validation does not look for .. or odd characters in the input; it resolves first and asks afterwards where it ended up. Any exotic encoding the attacker invents ends up, after resolve, as a canonical path judged like all the others.

Solution 3. The pattern repeats in all three functions: replace every template with path.join and add the relative path to the output.

const path = require('node:path');
const { ROOT, REPORTS_DIR } = require('../config/paths.js');

// In listReports, inside the file loop:
const filePath = path.join(directory, file.name);
const info = await fs.stat(filePath);

reports.push({
  month,
  file: file.name,
  path: filePath,                              // absolute: to work with
  relativePath: path.relative(ROOT, filePath), // relative: to display
  sizeBytes: info.size,
  modified: info.mtime.toISOString()
});

Notice the criterion that appears here and is worth adopting across the whole project: absolute to operate, relative only to present. A console.table with seventy-character absolute paths is unreadable; a relative path stored in a variable and used later is a time bomb.

Conclusion

The Escena Viva paths no longer depend on where you run the program from. You have seen why concatenating path strings is a mistake — duplicated separators, wrong separator, unresolved .. and, above all, an absolute segment that cancels your base directory — and you know the exact difference between join, which glues and normalizes while preserving the relative character, and resolve, which processes from right to left until it forms an absolute path and discards everything before an absolute segment.

You can take paths apart with basename, dirname, extname, parse and format — with the trap of base beating name + ext — and you are clear on the classic mistake that makes a program work in your folder and fail from another: fs resolves relative paths against process.cwd(), not against the file where you wrote them. Hence the rule: project data anchored to __dirname; user arguments, against process.cwd().

You tell path.posix from path.win32 and you know the boundary lies in whether the path is disk access or an identifier that will travel in a URL. And you have built the defense against path traversal: resolve against the base and check with path.relative that the target has not escaped, instead of chasing .. in the input or comparing text prefixes.

Escena Viva now has src/config/paths.js with ROOT, DATA_DIR, REPORTS_DIR, EVENTS_FILE and SALES_FILE, and the two previous lessons are refactored to use it. The project runs correctly from any directory.

That SALES_FILE we declared points to a file that does not exist yet, and that is no coincidence. In Working with Streams comes the problem readFile cannot solve: a season's sales history does not fit in the V8 heap. We will see what a stream is and its four types, why they are EventEmitter objects of the kind you already know, what backpressure really means and what happens when it is ignored, and we will process data/sales.csv line by line with constant memory.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved