Paths and Directories

Build, combine and normalise paths without string concatenation, walk directory trees, and stop user input from escaping an upload directory.

SyntaxPath 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.
  • resolveSibling replaces the last element: the same directory, a different file.
  • normalize() removes . and folds .. into its parent, purely on the text.
  • relativize gives the path from one location to another, handy for storing paths relative to a root.
  • getFileName() and getParent() return paths, not strings. getFileName() is null for a root, and getParent() is null for a root and for a single-name relative path such as Path.of("report.csv"), a common source of NullPointerException.

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.createDirectories creates every missing parent and does nothing if the directory already exists. createDirectory throws if it exists or a parent is missing.
  • Files.list gives one level; Files.walk goes recursively, with an optional depth limit. Both return a Stream that holds an open directory handle, so close it with try-with-resources.
  • Files.newDirectoryStream(dir, "*.{csv,json}") filters with a glob pattern.
  • Files.delete on a directory that still has entries throws DirectoryNotEmptyException. 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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.png

Creating, 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.

Example · java
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: false

Glob 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.

Example · java
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

  • Be the first to comment on this lesson.