Skip to content

VOL 01 / CH 15 / LESSON 02

15.2 Files, Directories, and Paths ​

After completing I/O Streams and Persistence Boundaries, spend about two hours running this Java 17 temporary directory experiment. The goal is to be able to combine, validate, and traverse paths, and to choose file operations based on failure recovery and atomicity requirements.

Start with a Concrete Set of File Operations ​

The experiment has a concrete job: find text files whose names begin with iron-, copy a map file, and move a material list into an archive directory. Each operation must define its source, destination, replacement policy, and state after failure.

The file content is processed as a stream; the file system API handles names, locations, directory structures, and metadata. The most common mistake isn't forgetting to call a single method, but rather treating a path string as a verified file, or treating a single check as a long-term fact, or assuming that all file systems support the same semantics for moving files.

Reading Path ​

  1. Use Path to express location and Files to execute operations;
  2. Distinguish between concatenation, lexical normalization, and real paths;
  3. Create, copy, move, replace, and delete;
  4. Close and sort lazy directory streams;
  5. Read attributes and define symbolic link strategies;
  6. Prevent simple path escape while being aware of symbolic link races;

1. File Is Still Available, Path Better Expresses Modern Operations ​

Old code often uses java.io.File:

java
File file = new File("warehouse/records/iron-map.txt");
System.out.println(file.exists());

File simultaneously handles path values and some operations; many methods only return boolean, and the specific reason for failure is not detailed. It has not been removed, and it is not something that must be rewritten as soon as it is seen. The Swing file chooser, old libraries, and existing interfaces may still return File.

Path and Files arrived in Java 7. The Path.of factory below arrived in Java 11; Java 7/8 code can use Paths.get:

java
Path path = Path.of("warehouse", "records", "iron-map.txt");
File legacy = path.toFile();
Path modern = legacy.toPath();

toFile() supports only paths from the default file system; ZIP and other provider paths may throw UnsupportedOperationException. Path is simply a path value within a particular file system, and it does not require the target to already exist. Files methods perform reads, creates, copies, and property queries, and report specific exceptions for failures.

2. Combining Paths, Not Concatenating Strings ​

java
Path base = Path.of("warehouse");
Path records = base.resolve("records");
Path map = records.resolve("iron-map.txt");

String concatenation is error-prone and difficult to handle absolute paths. resolve adheres to the semantics of the current FileSystem. If the right side is an absolute path, it returns the right path directly. Therefore, when handling external input, additional restrictions are necessary.

Common observation methods:

java
map.getFileName();       // iron-map.txt
map.getParent();         // warehouse/records
map.getNameCount();      // 3
map.isAbsolute();
map.toAbsolutePath();

normalize() and toRealPath() ​

java
Path lexical = Path.of("warehouse/./records/../archive").normalize();
// warehouse/archive

Path real = lexical.toRealPath();
  • normalize() only removes redundant . and matching name/.. according to path syntax. It does not access the disk or parse symbolic links.
  • toRealPath() requires the target to exist, accesses the file system, and parses symbolic links according to options, returning the real absolute path.

Removing link/.. can even change the target originally denoted by a path containing a symbolic link. Different path spellings may also point to the same file. A path that appears within a directory may jump outside through a link. The choice depends on whether you are organizing displayed paths or establishing security boundaries.

Relative Paths ​

java
Path root = Path.of("warehouse");
Path file = Path.of("warehouse/records/iron-map.txt");
Path relative = root.relativize(file); // records/iron-map.txt

relativize requires that both paths come from compatible providers, and both absolute and relative forms must be compatible. Different drive letters on Windows typically cannot be relativized.

3. Creating Files and Directories ​

java
Path logs = Files.createDirectories(Path.of("logs", "2026", "07"));
Path today = Files.createFile(logs.resolve("day-16.txt"));
MethodIf the target already exists
createDirectory(path)Throws FileAlreadyExistsException; the parent directory must already exist
createDirectories(path)Returns normally if the target is already a directory, and creates missing ancestors when needed
createFile(path)Throws FileAlreadyExistsException
createTempFile(...)Atomically creates a new temporary file and returns a unique path
createTempDirectory(...)Atomically creates a new temporary directory

If createDirectories encounters an ordinary file with the same name, the operation will still fail. Some ancestors may have been created before a later permission error occurs, so the caller cannot assume that a failure will automatically roll back the directory tree.

4. Writing, Copying, Moving, and Deleting ​

Writing ​

java
Files.writeString(path, "Inventory:1", StandardCharsets.UTF_8);

The default option is to create or truncate and then write. If you need an atomic "create if not exists" option:

java
Files.writeString(path, content, StandardCharsets.UTF_8,
        StandardOpenOption.CREATE_NEW,
        StandardOpenOption.WRITE);

Copying ​

java
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);

Copying is not guaranteed atomic; failure can leave a partially written target. Copying a directory itself does not recursively copy the entire directory tree. When recursive copying is required, first define symbolic links, attributes, overrides, failure recovery, and partial result cleanup policies, then use walkFileTree to implement or select a mature tool.

COPY_ATTRIBUTES requests copying of certain attributes, but the specific support is determined by the provider. POSIX permissions, ACLs, extended attributes, and creation time are not present on all file systems.

Moving ​

java
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);

Renaming within the same file system typically has a lower cost. Moving across file systems may not be possible directly, and the provider may fail, or it may be implemented in another way.

java
try {
    Files.move(temp, target,
            StandardCopyOption.ATOMIC_MOVE,
            StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
    // Fall back only if non-atomic replacement is allowed
    Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}

ATOMIC_MOVE requires an atomic move and fails when unsupported. With it, all other options are ignored, including REPLACE_EXISTING. If the target exists, replacement or failure with IOException is implementation-specific. Systems that cannot accept intermediate states should test actual behavior on the target file system and clearly define whether it should fail or degrade when not supported.

Deleting ​

java
Files.delete(path);          // Throws NoSuchFileException if absent
Files.deleteIfExists(path);  // Returns false if absent

Non-empty directories typically throw DirectoryNotEmptyException. Deleting a symbolic link by default deletes the link itself, not the target it points to.

5. Complete Experiment: A Temporary Directory ​

java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.StringJoiner;
import java.util.stream.Stream;

public class FileSystemDemo {
    public static void main(String[] args) throws IOException {
        Path root = Files.createTempDirectory("file-demo-");
        TemporaryTree cleanup = new TemporaryTree(root);
        try (cleanup) {
            Path records = Files.createDirectories(root.resolve("warehouse/records"));
            Path ironMap = Files.writeString(
                    records.resolve("iron-map.txt"), "batch=A", StandardCharsets.UTF_8);
            Files.writeString(
                    records.resolve("iron-notes.txt"), "quantity=15", StandardCharsets.UTF_8);
            Path wood = Files.writeString(
                    records.resolve("wood.txt"), "wood=8", StandardCharsets.UTF_8);

            List<String> matches;
            try (Stream<Path> entries = Files.list(records)) {
                matches = entries
                        .filter(path -> Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS))
                        .map(path -> path.getFileName().toString())
                        .filter(name -> name.startsWith("iron-") && name.endsWith(".txt"))
                        .sorted()
                        .toList();
            }

            Path backup = root.resolve("backup.txt");
            Files.copy(ironMap, backup, StandardCopyOption.REPLACE_EXISTING);
            Path archive = Files.createDirectories(root.resolve("archive"));
            Files.move(wood, archive.resolve("wood.txt"));

            List<String> tree;
            try (Stream<Path> paths = Files.walk(root)) {
                tree = paths
                        .filter(path -> Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS))
                        .map(root::relativize)
                        .map(FileSystemDemo::portable)
                        .sorted()
                        .toList();
            }

            boolean identical = Files.mismatch(ironMap, backup) == -1;
            check(matches.equals(List.of("iron-map.txt", "iron-notes.txt")), "Filter results are incorrect");
            check(tree.equals(List.of(
                    "archive/wood.txt",
                    "backup.txt",
                    "warehouse/records/iron-map.txt",
                    "warehouse/records/iron-notes.txt")), "Directory tree error");
            check(identical, "Backup content differs");

            System.out.println("Matched files: " + matches);
            System.out.println("Directory tree: " + tree);
            System.out.println("Backup consistent: " + identical);
            System.out.println("Number of regular files: " + tree.size());
            System.out.println("File check passed");
        }
    }

    private static String portable(Path path) {
        StringJoiner result = new StringJoiner("/");
        for (Path part : path) {
            result.add(part.toString());
        }
        return result.toString();
    }

    private record TemporaryTree(Path root) implements AutoCloseable {
        @Override
        public void close() throws IOException {
            Files.walkFileTree(root, new java.nio.file.SimpleFileVisitor<>() {
                @Override
                public java.nio.file.FileVisitResult visitFile(
                        Path file, java.nio.file.attribute.BasicFileAttributes attrs)
                        throws IOException {
                    Files.delete(file);
                    return java.nio.file.FileVisitResult.CONTINUE;
                }

                @Override
                public java.nio.file.FileVisitResult postVisitDirectory(Path directory, IOException error)
                        throws IOException {
                    if (error != null) throw error;
                    Files.delete(directory);
                    return java.nio.file.FileVisitResult.CONTINUE;
                }
            });
        }
    }

    private static void check(boolean condition, String message) {
        if (!condition) {
            throw new AssertionError(message);
        }
    }
}

Running:

bash
chapter15_files_root=$(mktemp -d)
mkdir -p "$chapter15_files_root/out"
# Save the above code as "$chapter15_files_root/FileSystemDemo.java"
javac --release 17 -Xlint:all -Werror \
  -d "$chapter15_files_root/out" \
  "$chapter15_files_root/FileSystemDemo.java"
java -ea -cp "$chapter15_files_root/out" FileSystemDemo
rm -r -- "$chapter15_files_root"

Output:

text
Matched files: [iron-map.txt, iron-notes.txt]
Directory tree: [archive/wood.txt, backup.txt, warehouse/records/iron-map.txt, warehouse/records/iron-notes.txt]
Backup consistent: true
Number of regular files: 4
File check passed

portable does not directly depend on Path.toString()'s platform-specific separator; instead, it uses / to concatenate test output. Business code that needs to generate URLs should not rely on this helper method; use the URI/URL API for handling escaping and scheme.

TemporaryTree uses a visitor to delete files and then each directory on exit, without collecting the whole tree. Try-with-resources preserves cleanup failures. It accepts only a temporary tree created by this program. Do not pass unverified external directories to the recursive delete function.

6. One-Level Directory: list and DirectoryStream ​

java
try (Stream<Path> entries = Files.list(directory)) {
    entries.filter(path -> path.getFileName().toString().endsWith(".txt"))
           .sorted()
           .forEach(System.out::println);
}

Files.list returns a lazy stream, accessing only one level. It must be closed.

When you need to process items as you traverse, avoid using a stream pipeline, or use glob:

java
try (DirectoryStream<Path> entries =
        Files.newDirectoryStream(directory, "iron-*.txt")) {
    for (Path entry : entries) {
        System.out.println(entry.getFileName());
    }
}

The glob syntax is implemented by the file system provider. Common patterns include *.txt and iron-*.{txt,md}. The order of directories is not specified. When requiring stable display or testing, sort them yourself.

DirectoryStream.iterator() provides one iterator; requesting another throws IllegalStateException. That iterator is thread-safe and weakly consistent, without freezing the directory. It may or may not reflect later changes. Traversal I/O failures are wrapped in DirectoryIteratorException.

7. Recursive Traversal: walk, find and walkFileTree ​

Files.walk ​

java
try (Stream<Path> paths = Files.walk(root)) {
    paths.filter(path -> Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS))
         .forEach(System.out::println);
}

walk is lazy and depth-first, and releases open directories when the stream closes. Later I/O errors during traversal may be wrapped in UncheckedIOException; not all errors occur during the initial Files.walk call. Files.walk(root, 2) can limit the maximum depth.

Files.find ​

java
try (Stream<Path> matches = Files.find(root, 4,
        (path, attrs) -> attrs.isRegularFile()
                && path.getFileName().toString().endsWith(".log"))) {
    matches.forEach(System.out::println);
}

find passes the read BasicFileAttributes to the predicate, avoiding the predicate from querying common attributes again.

walkFileTree ​

When entering or leaving a directory requires a callback or per-item error policy, use a visitor:

java
Files.walkFileTree(root, new SimpleFileVisitor<>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
            throws IOException {
        System.out.println(file);
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult visitFileFailed(Path file, IOException error)
            throws IOException {
        throw new IOException("Traversal failed:" + file.getFileName(), error);
    }
});

Optional return values include CONTINUE, SKIP_SUBTREE, SKIP_SIBLINGS, and TERMINATE. Do not silently ignore permission errors for the sake of "completeness"; backup, delete, and index tasks require different failure policies.

Reading a set of basic attributes at once:

java
BasicFileAttributes attrs = Files.readAttributes(
        path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);

attrs.isRegularFile();
attrs.isDirectory();
attrs.isSymbolicLink();
attrs.size();
attrs.lastModifiedTime();
attrs.fileKey();

For special files, size need not equal the number of readable bytes. The precision of the modification time and the availability of attributes depend on the file system.

By default, walk does not recursively follow symbolic links, but Files.isRegularFile(path) follows links when checking the target. This page passes NOFOLLOW_LINKS explicitly to exclude symbolic links from the regular-file filter. After enabling FileVisitOption.FOLLOW_LINKS, links may leave the original tree or form cycles. Java will attempt to detect cycles and may throw FileSystemLoopException. When scanning user-writable directories, define in advance whether links are allowed; security-sensitive operations commonly disable link following.

POSIX permissions are only available on providers that support PosixFileAttributeView. Cross-platform programs should check for support rather than assuming rwx permissions exist.

9. Write a Temporary File, Then Replace the Target ​

When directly clearing the target and writing back, a process crash may leave an empty file or a partial content. A common improvement is to write the full content to a temporary file in the same directory as the target, validate and close it, then replace the target:

java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

final class ReportWriter {
    private record PendingFile(Path path) implements AutoCloseable {
        @Override
        public void close() throws IOException {
            Files.deleteIfExists(path);
        }
    }

    static boolean replace(Path destination, String report, boolean allowFallback)
            throws IOException {
        Path target = destination.toAbsolutePath().normalize();
        Path parent = target.getParent();
        if (parent == null) throw new IllegalArgumentException("Target needs a parent directory");
        try (PendingFile temp = new PendingFile(Files.createTempFile(parent, "report-", ".tmp"))) {
            Files.writeString(temp.path(), report, StandardCharsets.UTF_8);
            try {
                Files.move(temp.path(), target, StandardCopyOption.ATOMIC_MOVE);
                return true;
            } catch (AtomicMoveNotSupportedException error) {
                if (!allowFallback) throw error;
                Files.move(temp.path(), target, StandardCopyOption.REPLACE_EXISTING);
                return false;
            }
        }
    }
}

Call replace(target, text, false) to require an atomic move; true permits fallback only when atomic movement is explicitly unsupported. The method returns true for atomic movement and false for a non-atomic fallback, which the caller should record. Other IOExceptions propagate. PendingFile cleans up the temporary item on failure, adding cleanup errors to the primary exception. After a failed non-atomic move, the target’s state may still be uncertain.

A temporary file in the same directory is more likely to support same-filesystem movement, but its permissions and attributes may differ from the old target. Preserve permissions or ownership through an explicit attribute policy; sharing a parent directory is no guarantee.

This pattern reduces the risk of partially written files, but it does not automatically guarantee persistence in the event of a power failure. When strong guarantees are required, considerations of temporary files force, metadata synchronization after replacement, and platform documentation are also necessary.

10. exists() Not Authorization, Not Locking ​

java
if (!Files.exists(path)) {
    Files.createFile(path);
}

Between checking and creating, another thread or process can create the path first. This is called TOCTOU (check-and-use inconsistency). Use atomic operations and handle the result:

java
try {
    Files.createFile(path);
} catch (FileAlreadyExistsException e) {
    // Process existing target according to business rules
}

Similarly, isRegularFile, isWritable, and property reads are all instantaneous observations. They are suitable for UI hints and branch optimization, but should not be considered evidence that subsequent operations will definitely succeed, let alone as a substitute for authorization.

11. External Path Input and Directory Escaping ​

The most basic lexical restrictions:

java
static Path safeResolve(Path base, String userInput) {
    Path normalizedBase = base.toAbsolutePath().normalize();
    Path candidate = normalizedBase.resolve(userInput).normalize();
    if (!candidate.startsWith(normalizedBase)) {
        throw new IllegalArgumentException("path escapes base directory");
    }
    return candidate;
}

The method normalizes an absolute base and compares Path components, rejecting ../outside.txt and sibling directories with a similar name prefix. It allows the base itself and absolute paths inside it. Requiring one filename is a separate rule.

This standalone program checks a valid child and two escaping inputs:

java
import java.nio.file.Path;

public class PathBoundaryDemo {
    static Path safeResolve(Path base, String userInput) {
        Path normalizedBase = base.toAbsolutePath().normalize();
        Path candidate = normalizedBase.resolve(userInput).normalize();
        if (!candidate.startsWith(normalizedBase)) {
            throw new IllegalArgumentException("path escapes base directory");
        }
        return candidate;
    }

    public static void main(String[] args) {
        Path base = Path.of("warehouse").toAbsolutePath().normalize();
        if (!safeResolve(base, "record.txt").equals(base.resolve("record.txt"))) {
            throw new AssertionError("Valid child rejected");
        }
        String sibling = base.resolveSibling("warehouse-copy").resolve("record.txt").toString();
        for (String input : new String[]{"../outside.txt", sibling}) {
            try {
                safeResolve(base, input);
                throw new AssertionError("Escaping path accepted");
            } catch (IllegalArgumentException expected) {
                // Expected rejection; this test does not access the filesystem.
            }
        }
        System.out.println("Lexical path checks passed");
    }
}
text
Lexical path checks passed

This only blocks lexical .. escaping. If an attacker can create a symbolic link within the base, the link could point outside; after checking, there could still be renaming or symlink race conditions.

A more robust design follows risk progression:

  • Public APIs accept file IDs, which the server maps to its own generated names;
  • Prohibit or explicitly handle symbolic links, and separate authorization from path checking;
  • When the provider supports it, use SecureDirectoryStream, execute relative operations through an already opened directory handle, reducing symlink race conditions;
  • For high-value data, use operating system sandboxes and minimum-privilege accounts.

Even though toRealPath() indicates that the current target is within the base, after checking, the target could still be replaced. Security conclusions must cover the entire operation of "checking to use."

12. Common Exceptions and Debugging ​

Exception/PhenomenonFirst Check
NoSuchFileExceptionDoes the target or intermediate directory exist? Is the current working directory as expected?
FileAlreadyExistsExceptionIs CREATE_NEW/createFile being used? What is the type of the existing target?
AccessDeniedExceptionFile permissions, ACLs, read-only mounting, and occupancy policies
DirectoryNotEmptyExceptionDid the user accidentally treat a regular delete as a recursive delete?
AtomicMoveNotSupportedExceptionIs the operation crossing file systems? Is the business allowing non-atomic fallbacks?
FileSystemLoopExceptionAfter enabling FOLLOW_LINKS, does a link cycle appear?
List order varies each timeThe directory order is not specified; explicit sorting is required
Windows test path differsIs the Path.toString() being treated as a fixed / format?
Check exists and still fails to createNormal race condition; directly attempt an atomic operation and handle the exception

13. Exercise ​

  1. Add BasicFileAttributes output to FileSystemDemo, but only assert the platform-agnostic isRegularFile and content size.
  2. Implement DirectoryStream for iron-*.txt filtering, then sort the results. Compare it with the resource form of Files.list.
  3. Use walkFileTree to copy a small directory tree. Clearly state the policies when encountering existing targets, permission failures, and symbolic links.
  4. Change the report writing to "temporary file replacement". Implement two strategies: one that fails if atomic movement is not supported, and another that allows non-atomic fallback.
  5. Extend safeResolve: allow only [a-zA-Z0-9._-]+ filenames, prohibit path separators, and explicitly reject . and ... Explain why the allow list cannot replace file system permissions.

Before Leaving the Repository, Validate the Location, Not the String ​

Run FileSystemDemo again in a separate temporary directory, and only assert the sorted relative path, standard file attributes, and content. The directory enumeration order, path separator, time precision, and POSIX permissions cannot be mistakenly written as cross-platform guarantees.

Prepare two clear strategies for the report replacement: require ATOMIC_MOVE's version to fail directly if the provider does not support it; allow degraded versions to record this non-atomic replacement. Both strategies place the temporary files in the same directory as the target and clean up the temporary items if the move fails.

Finally test safeResolve: valid filenames remain under the absolute-normalized base, while ../outside.txt and external absolute paths are rejected. The lexical check still cannot prevent symbolic link replacements in writable directories; high-risk operations should further restrict links, use directory handle capabilities, and rely on the operating system's minimum privilege.

Reviewing the Formal Contract for Java 17 ​

  • Path: Path combination, normalization, relativization, and resolution of real paths.
  • Files: Creation, copying, movement, traversal, attributes, and closing lazy Stream resources.
  • Files.move: Contracts for replacement and atomic movement provided by the provider.
  • SecureDirectoryStream: Performing relative operations on already opened directories to reduce some path-replacement races.

If a directory grows from ten files to a million, how do sorting, scanning, and indexing affect time and memory? The second volume begins with this question. You will learn how to describe input size, computation steps, and memory growth, rather than relying on a single timing measurement to judge the algorithm.

Enter the Second Volume: Complexity and Big-O

Built with VitePress | Software Systems Atlas