Files.walk 遍历符号链接:目录范围、深度和环路怎样控制
递归遍历从 root 开始,并不保证随后访问的所有文件都属于 root 的真实目录树。只要允许跟随符号链接,root/outside 就可以指向旁边的目录;路径字符串仍以 root 开头,读取到的内容却来自别处。
Files.walk 提供的是遍历工具,不是目录权限隔离。备份、打包、导入工具在使用它以前,应先决定链接本身是不是待处理对象,以及是否允许进入链接指向的目录。这个决定会改变访问范围,也会改变重复访问和异常的含义。
下面在一个临时目录里构造三个场景:同目录别名、指向兄弟目录的链接、指回祖先的环。代码固定 Java 17,在支持符号链接的 macOS/Linux 文件系统上可直接执行。Windows 对符号链接的支持还取决于权限和系统设置,不能把创建链接失败当成遍历逻辑出错。
先把路径关系造出来
实验中的树是:
sandbox/
root/
real/
a.txt
alias -> real
outside -> ../external
external/
secret.txt
secret.txt 只是样本文件,内容是字符串 outside,不读取系统目录。程序最后再添加 real/back -> .. 形成环,并清理自己创建的临时目录。
保存为 WalkLinks.java:
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
public class WalkLinks {
static List<String> list(Path root, int depth, boolean follow) throws IOException {
FileVisitOption[] options = follow
? new FileVisitOption[]{FileVisitOption.FOLLOW_LINKS}
: new FileVisitOption[0];
try (Stream<Path> paths = Files.walk(root, depth, options)) {
return paths.filter(p -> !p.equals(root))
.map(p -> root.relativize(p).toString())
.sorted().toList();
} catch (UncheckedIOException ex) {
throw ex.getCause();
}
}
public static void main(String[] args) throws Exception {
Path sandbox = Files.createTempDirectory("walk-links-");
Path root = Files.createDirectory(sandbox.resolve("root"));
Path real = Files.createDirectory(root.resolve("real"));
Path external = Files.createDirectory(sandbox.resolve("external"));
Files.writeString(real.resolve("a.txt"), "inside");
Files.writeString(external.resolve("secret.txt"), "outside");
try {
Files.createSymbolicLink(root.resolve("alias"), Path.of("real"));
Files.createSymbolicLink(root.resolve("outside"), Path.of("..", "external"));
System.out.println("no_follow=" + list(root, 3, false));
System.out.println("follow=" + list(root, 3, true));
System.out.println("depth_one=" + list(root, 1, true));
Files.createSymbolicLink(real.resolve("back"), Path.of(".."));
try {
list(root, 5, true);
throw new AssertionError("expected cycle");
} catch (FileSystemLoopException expected) {
System.out.println("cycle=" + expected.getClass().getSimpleName());
}
} finally {
// No FOLLOW_LINKS: delete symbolic links themselves, never their targets.
try (Stream<Path> paths = Files.walk(sandbox)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
}
执行:
java WalkLinks.java
在上述环境中核对到的结果:
no_follow=[alias, outside, real, real/a.txt]
follow=[alias, alias/a.txt, outside, outside/secret.txt, real, real/a.txt]
depth_one=[alias, outside, real]
cycle=FileSystemLoopException
输出先排序,只为了让不同文件系统的目录枚举顺序不影响阅读。排序不改变访问对象,也不为文件系统提供一致性快照。
没跟随链接,不代表链接没出现在结果中
第一行仍包含 alias 和 outside。未指定 FOLLOW_LINKS 时,遍历不会因为它们指向目录就继续深入,但链接路径本身可以出现在流中。消费结果时仍要决定:保存链接、跳过链接,还是拒绝这批输入。
这里很容易出现第二层误判:遍历没有跟随链接,后面的 Files.isRegularFile(path) 却默认查询链接目标。若链接指向普通文件,这个过滤条件可能返回 true,后续打开文件也会访问目标。遍历选项和后续文件操作的链接策略不是一个总开关。
需要按链接本身判断类型时,要显式传 LinkOption.NOFOLLOW_LINKS,或者读取同样带该选项的属性。若后面还要打开文件,则应继续核查打开动作的选项及文件系统支持,不能只靠一次属性检查声称彻底封住竞态。
第二行则显示,跟随链接后,alias/a.txt 与 real/a.txt 都出现了。它们是两条路径,可能指向相同内容。遍历环检测不会顺便把所有别名去重。备份按路径保存、内容扫描按文件身份去重、计费按逻辑对象统计,各有不同口径;不能直接把路径数量当成独立文件数量。
maxDepth 是遍历层数,不是安全范围
起始路径处于深度 0,它的直接子项深度为 1。把 maxDepth 设成 1,本例能够看见几个直接子项,却不会进入它们读取内部文件。这对只处理一级目录的工具很合适。
但层数限制不会限制链接目标的位置。一个深度为 1 的链接,完全可以直接指向另一块挂载盘、兄弟目录或网络文件系统。即使只进入一层,访问范围仍可能已经越界。
若需求确实允许部分链接,可以比较解析后的真实路径是否落在允许根目录下,并对多次访问作出策略。这个比较比字符串前缀正确,却仍只是某个时点的结果:检查后链接可以被替换,祖先目录也可能变化。面对不可信且可并发修改的目录,最好从权限和所有权上限制写入者;要求更严格时,需要具备相应目录相对操作能力的接口。一个 normalize().startsWith(root) 不能承担这样的保证。
看到环时,是失败还是跳过
开启跟随后,程序遇到指向祖先的 back 链接,会因环路失败。Files.walk 是惰性的,创建 Stream 与真正访问后续目录不在同一时刻。因此访问起点失败可以直接得到 IOException,遍历中途失败则可能以 UncheckedIOException 从流操作中抛出。示例统一取出其 cause,让方法的调用者按 I/O 失败处理。
这份 list 的协议是全有或失败:不返回半份路径列表。文件扫描若要“某个目录失败仍处理其他部分”,应该显式设计部分成功结果,记录失败路径和原因;采用 walkFileTree 的访问回调通常更容易表达这种控制。不能在 Stream 外层吞掉异常,再把已经输出的几行当完整清单。
无论选择哪种失败协议,遍历流都要关闭。终止操作执行完成不等于已经替调用方履行资源关闭责任。try-with-resources 把目录句柄的生命周期与消费范围绑定,部分失败和提前结束都经过同一清理入口。
清理代码本身也要遵守链接策略
程序在清理时不指定 FOLLOW_LINKS,删除的是临时树中的链接本身,目标只会通过它原本在 sandbox 中的路径被删除。随后按路径逆序删除,确保子项在父目录之前处理。这个实现只适用于样本自己创建、自己持有的受控目录,不应直接改成清理任意用户提供的路径。
真正的批处理工具还要决定目录在扫描期间变化怎么办。本例得到的是遍历期间的观察结果,文件可能在输出以后被改写或删除。若下游动作必须作用于同一份字节,应在打开后的文件句柄上校验,或把输入交付设计成写完后原子移入受控目录。把路径列表生成得再漂亮,也不能让正在变化的目录自动变成稳定快照。