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
- Use
Pathto express location andFilesto execute operations; - Distinguish between concatenation, lexical normalization, and real paths;
- Create, copy, move, replace, and delete;
- Close and sort lazy directory streams;
- Read attributes and define symbolic link strategies;
- 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:
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:
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
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:
map.getFileName(); // iron-map.txt
map.getParent(); // warehouse/records
map.getNameCount(); // 3
map.isAbsolute();
map.toAbsolutePath();normalize() and toRealPath()
Path lexical = Path.of("warehouse/./records/../archive").normalize();
// warehouse/archive
Path real = lexical.toRealPath();normalize()only removes redundant.and matchingname/..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
Path root = Path.of("warehouse");
Path file = Path.of("warehouse/records/iron-map.txt");
Path relative = root.relativize(file); // records/iron-map.txtrelativize 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
Path logs = Files.createDirectories(Path.of("logs", "2026", "07"));
Path today = Files.createFile(logs.resolve("day-16.txt"));| Method | If 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
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:
Files.writeString(path, content, StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE);Copying
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
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.
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
Files.delete(path); // Throws NoSuchFileException if absent
Files.deleteIfExists(path); // Returns false if absentNon-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
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:
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:
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 passedportable 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
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:
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
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
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:
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.
8. Attributes and Links
Reading a set of basic attributes at once:
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:
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
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:
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:
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:
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");
}
}Lexical path checks passedThis 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/Phenomenon | First Check |
|---|---|
NoSuchFileException | Does the target or intermediate directory exist? Is the current working directory as expected? |
FileAlreadyExistsException | Is CREATE_NEW/createFile being used? What is the type of the existing target? |
AccessDeniedException | File permissions, ACLs, read-only mounting, and occupancy policies |
DirectoryNotEmptyException | Did the user accidentally treat a regular delete as a recursive delete? |
AtomicMoveNotSupportedException | Is the operation crossing file systems? Is the business allowing non-atomic fallbacks? |
FileSystemLoopException | After enabling FOLLOW_LINKS, does a link cycle appear? |
| List order varies each time | The directory order is not specified; explicit sorting is required |
| Windows test path differs | Is the Path.toString() being treated as a fixed / format? |
| Check exists and still fails to create | Normal race condition; directly attempt an atomic operation and handle the exception |
13. Exercise
- Add
BasicFileAttributesoutput toFileSystemDemo, but only assert the platform-agnosticisRegularFileand content size. - Implement
DirectoryStreamforiron-*.txtfiltering, then sort the results. Compare it with the resource form ofFiles.list. - Use
walkFileTreeto copy a small directory tree. Clearly state the policies when encountering existing targets, permission failures, and symbolic links. - 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.
- 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.