Paths and Directories
Build, combine and normalise paths without string concatenation, walk directory trees, and stop user input from escaping an upload directory.
Path file = Path.of("/srv/app").resolve("data/report.csv");
Path clean = file.normalize();
Files.createDirectories(file.getParent());
try (Stream<Path> s = Files.walk(root)) { /* ... */ }A Path is a name, not a file. Creating one with Path.of never touches the disk, so it cannot fail because a file is missing. Only Files methods read the file system. Path.of arrived in Java 11; Paths.get does the same thing and is found in older code.
Combining paths
base.resolve("a/b.txt")appends a relative path. Use it instead of string concatenation, which gets separators wrong.resolveSiblingreplaces the last element: the same directory, a different file.normalize()removes.and folds..into its parent, purely on the text.relativizegives the path from one location to another, handy for storing paths relative to a root.getFileName()andgetParent()return paths, not strings.getFileName()isnullfor a root, andgetParent()isnullfor a root and for a single-name relative path such asPath.of("report.csv"), a common source ofNullPointerException.
The trap in resolve
If the argument to resolve is absolute, it wins: Path.of("/srv/uploads").resolve("/etc/passwd") is /etc/passwd. Combined with .. segments, that makes any endpoint that builds a path from a request parameter a path traversal risk. The fix is to resolve, normalise and then check the result is still inside the base directory:
Path target = base.resolve(userInput).normalize();
if (!target.startsWith(base)) {
throw new IllegalArgumentException("invalid path");
}
Path.startsWith compares whole name elements, so /srv/uploads2 does not start with /srv/uploads, unlike String.startsWith. The base must itself be absolute and normalised. If attackers could place symbolic links inside the directory, also compare toRealPath(), which resolves links against the real file system. Better still, never use client-supplied names on disk: store files under generated IDs and keep the original name in the database.
Directories
Files.createDirectoriescreates every missing parent and does nothing if the directory already exists.createDirectorythrows if it exists or a parent is missing.Files.listgives one level;Files.walkgoes recursively, with an optional depth limit. Both return aStreamthat holds an open directory handle, so close it with try-with-resources.Files.newDirectoryStream(dir, "*.{csv,json}")filters with a glob pattern.Files.deleteon a directory that still has entries throwsDirectoryNotEmptyException. To remove a tree, walk it and delete the deepest paths first.
The order of entries from list and walk is whatever the file system returns. Sort them if the order matters, for example in a listing endpoint or a test.
Example
When to use it
- Serve user-uploaded files by name from one directory without letting ../ or absolute paths reach the rest of the server.
- Create a dated folder tree for nightly reports and clean up folders older than the retention period.
- List only the .csv and .json files waiting in an import directory for a batch job to pick up.
More examples
Guarding against path traversal
The last input normalises to /srv/app/uploads2/x.png, which a String prefix check would wrongly accept; Path.startsWith compares whole directory names and rejects it. A download endpoint that serves files by name needs exactly this check.
static Path safeResolve(Path base, String userPath) {
Path resolved = base.resolve(userPath).normalize();
if (!resolved.startsWith(base)) {
throw new IllegalArgumentException("path escapes upload directory: " + userPath);
}
return resolved;
}
void main() {
Path base = Path.of("/srv/app/uploads").toAbsolutePath().normalize();
List<String> inputs = List.of(
"avatars/ada.png",
"../config/secrets.env",
"/etc/passwd",
"avatars/../../uploads2/x.png");
for (String input : inputs) {
try {
IO.println("ok " + base.relativize(safeResolve(base, input)));
} catch (IllegalArgumentException e) {
IO.println("deny " + e.getMessage());
}
}
}
// Output:
// ok avatars/ada.png
// deny path escapes upload directory: ../config/secrets.env
// deny path escapes upload directory: /etc/passwd
// deny path escapes upload directory: avatars/../../uploads2/x.pngCreating, walking and deleting a tree
Sorting paths in reverse puts every file before the directory that contains it, which is the order Files.delete needs. Calling createDirectories twice on overlapping paths is safe because it skips directories that already exist.
static void deleteTree(Path root) throws IOException {
try (Stream<Path> paths = Files.walk(root)) {
// Deepest paths first, so each directory is empty when it is deleted.
for (Path p : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.delete(p);
}
}
}
void main() throws IOException {
Path root = Files.createTempDirectory("reports");
Files.createDirectories(root.resolve("2026/09"));
Files.createDirectories(root.resolve("2026/10"));
Files.writeString(root.resolve("2026/09/sales.csv"), "id,total\n");
Files.writeString(root.resolve("2026/10/sales.csv"), "id,total\n");
Files.writeString(root.resolve("2026/10/refunds.csv"), "id,total\n");
Files.writeString(root.resolve("README.txt"), "monthly reports\n");
try (Stream<Path> top = Files.list(root)) {
IO.println("top level: " + top.map(p -> p.getFileName().toString()).sorted().toList());
}
try (Stream<Path> all = Files.walk(root)) {
all.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".csv"))
.map(p -> root.relativize(p).toString())
.sorted()
.forEach(IO::println);
}
deleteTree(root);
IO.println("exists after delete: " + Files.exists(root));
}
// Output:
// top level: [2026, README.txt]
// 2026/09/sales.csv
// 2026/10/refunds.csv
// 2026/10/sales.csv
// exists after delete: falseGlob filters and non-empty directories
The glob {csv,json} matches either extension in one pass, and the result is sorted because directory order is not defined. Collecting the listing with toList before deleting avoids changing the directory while it is still being read.
void main() throws IOException {
Path dir = Files.createTempDirectory("exports");
for (String name : List.of("users.csv", "orders.csv", "orders.json", "notes.txt")) {
Files.writeString(dir.resolve(name), "x");
}
List<String> matched = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.{csv,json}")) {
for (Path p : stream) {
matched.add(p.getFileName().toString());
}
}
Collections.sort(matched);
IO.println("matched: " + matched);
try {
Files.delete(dir);
} catch (DirectoryNotEmptyException e) {
IO.println("cannot delete: directory not empty");
}
try (Stream<Path> files = Files.list(dir)) {
for (Path p : files.toList()) {
Files.delete(p);
}
}
Files.delete(dir);
IO.println("deleted: " + Files.notExists(dir));
}
// Output:
// matched: [orders.csv, orders.json, users.csv]
// cannot delete: directory not empty
// deleted: true
Discussion