Serguey Asael Shinder: Path.normalize() tidies a path; it does not tell you where the path leads
A common way to stop a user-supplied file name from escaping a directory looks like this: resolve it against a base directory, normalise it, and check that the result still starts with the base.
Path base = Path.of("/var/app/uploads");
Path target = base.resolve(userInput).normalize();
if (!target.startsWith(base)) {
throw new SecurityException("outside upload directory");
}
That catches ../../etc/passwd. It is still a check on text, and the javadoc says so.
What normalize() promises. The Path javadoc) describes it as returning "a path that is this path with redundant name elements eliminated": . is dropped, and a .. removes the name before it. Then it adds two sentences that matter for security: "This method does not access the file system; the path may not locate a file that exists. Eliminating ".." and a preceding name from a path may result in the path that locates a different file than the original path. This can arise when the preceding name is a symbolic link."
So if uploads/link is a symbolic link to /, the string link/../secret normalises to secret, inside the base, while the operating system, following the link, would go somewhere else. The check passed on a path that the file system does not mean.

What startsWith compares. The same javadoc says one path starts with another if its root component matches and "this path starts with the same name elements as the given path". It compares names, not files. That part is good news - unlike String.startsWith, /var/app/uploads-old does not start with /var/app/uploads - but it never asks the file system anything either.
What asks the file system. toRealPath()) "returns the real path of an existing file": by default it resolves symbolic links to their final target and removes redundant names. It throws if the file does not exist, which is what you want for a read.
Path realBase = base.toRealPath();
Path realTarget = base.resolve(userInput).toRealPath(); // throws if missing
if (!realTarget.startsWith(realBase)) {
throw new SecurityException("outside upload directory");
}
For files that do not exist yet, check the real path of the parent directory and validate the final name separately. And remember that a check followed by an open is two operations: if an attacker can create links in that directory between them, no path check closes the gap.
The rule I take from it. normalize() is for tidying paths you produced yourself. When the input comes from outside, ask the file system where the path goes, or better, do not accept a path at all - accept a name, check it against a short allowed set, and build the path yourself.
No JDK runs on the machine this note was written on; the code is written to compile against Java 21 and the behaviour described is the one stated in the linked javadoc.