把候选转换为重复文件组

JavaBeginner
立即练习

介绍

候选指纹能降低归档搜索成本,却不能证明内容完全相同。你将完成一个只读重复文件查找器,用真实字节确认结果,并保留每个重复组中的全部相对路径。

本实验假定你已理解上一实验的二进制读取和 SHA-256,以及 Java 类、列表和异常。它独立初始化,提供已完成的摘要和一份新的图片目录。分组由你实现;目录、报告和启动适配器已准备好。作者估计需要 55 分钟。

用真实字节确认每个候选

在这一步中,你将把候选事实转换为完整重复组。打开 WebIDE,选择 Terminal > New Terminal,进入独立工作区:

cd /home/labex/project/duplicates
./run.sh Sample

真实 PNG 解码器再次展示:不同名称可以有相同字节,而相同大小可以有不同字节。启动器每次使用新的临时构建目录;编译器/JVM 子进程使用 C.UTF-8,并明确采用 UTF-8 源码/文本设置。你的终端设置保持不变。

阅读公开接口和初始实现:

cat src/Digester.java src/Finder.java

Finder.find(Path root, Digester candidates) 必须返回重复组列表,每组包含相对于 root 的全部成员路径。root.relativize(path) 生成该相对路径;root.resolve(relative) 再次定位它以便读取。sameBytes(left, right) 比较内容,而不是名称。保留这些公开入口;内部辅助方法和集合可以不同。

下面的实现用 Map 将候选键关联到可能的组。键由大小和摘要组成。containsKey 判断键是否存在,put 建立初始值,get 取回它。每个键保存一份经字节确认的组列表,因为两种不同内容可能共享候选指纹。List<List<Path>> 表示元素为路径列表的列表。

对每个文件,尝试与该桶中每个现有组的代表路径比较。真实匹配时追加新路径,否则创建另一个子组。placed 记录当前路径是否已加入某组;break 在匹配后停止内层搜索,避免同一路径重复加入。只有至少两条路径的组才进入报告。不要到两个成员就停止:第三份副本仍是需要审阅的独立路径。

sameBytes 先拒绝长度不等的情况,再读取两个文件。read() 将一个字节以 0 到 255 的整数返回,到末尾时返回 -1。字节不匹配则返回 false,同时到末尾才返回 true。两个流通过 try-with-resources 关闭。逐字节读取让这个小型课程样例容易理解;缓冲读取或全数组字节比较也是合法替代方案,但各有内存与性能权衡。

before 映射将每个 Path 关联到原始元数据。before.keySet() 返回这些路径键,用于最终变化检查;buckets.values() 返回保存的候选子组列表,用于组装结果。准备好的属性适配器在读取前记录大小、修改时间和文件身份,并拒绝检测到的变化。这不会锁定或冻结目录树。扫描期间保持输入稳定;不支持并发写入、符号链接和特殊文件。每条普通文件路径都重要,包括同一硬链接文件的不同路径。文件身份只用于变化检测,不能用来丢弃路径。

替换 src/Finder.java。与上一实验一样,带引号的 here-document 写入字面文本;结束标记 JAVA 必须独占一行。> 只替换这个源文件。

cat > src/Finder.java <<'JAVA'
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;

public class Finder {
    public static boolean sameBytes(Path left, Path right) throws IOException {
        if (Files.size(left) != Files.size(right)) return false;
        try (InputStream a = Files.newInputStream(left);
             InputStream b = Files.newInputStream(right)) {
            int value;
            while ((value = a.read()) != -1) {
                if (value != b.read()) return false;
            }
            return b.read() == -1;
        }
    }
    public static List<List<Path>> find(Path root, Digester candidates) throws IOException {
        Map<String, List<List<Path>>> buckets = new LinkedHashMap<>();
        Map<Path, BasicFileAttributes> before = new LinkedHashMap<>();
        for (Path path : Inputs.files(root)) {
            BasicFileAttributes facts = Inputs.attributes(path);
            before.put(path, facts);
            String key = facts.size() + ":" + candidates.digest(path);
            Inputs.unchanged(path, facts);
            if (!buckets.containsKey(key)) buckets.put(key, new ArrayList<>());
            List<List<Path>> groups = buckets.get(key);
            boolean placed = false;
            for (List<Path> group : groups) {
                if (sameBytes(path, root.resolve(group.get(0)))) {
                    group.add(root.relativize(path));
                    placed = true;
                    break;
                }
            }
            if (!placed) {
                List<Path> group = new ArrayList<>();
                group.add(root.relativize(path));
                groups.add(group);
            }
        }
        for (Path path : before.keySet()) Inputs.unchanged(path, before.get(path));
        List<List<Path>> result = new ArrayList<>();
        for (List<List<Path>> groups : buckets.values()) {
            for (List<Path> group : groups) {
                if (group.size() > 1) result.add(group);
            }
        }
        return result;
    }
}
JAVA

准备好的 Main 使用 new Sha256() 调用同一个 find 方法。测试可以提供另一个 Digester,让不同文件获得同一候选值。这是受控的候选碰撞测试,不是真实的 SHA-256 碰撞;最终字节比较仍必须分开不同内容。仅写出一个未被调用的比较方法并不够。

在扫描根目录之外生成真实报告:

./run.sh Main data/gallery reports/groups.tsv

预期输出 Scan complete: 3 duplicate groups。如果编译失败,修正 src/Finder.java 中指出的行,保存并重跑;在改动数据前先检查花括号和方法签名。

阅读完整分组并在改动后重跑

在这一步中,你将阅读完成的报告,并观察它如何跟随当前字节变化。继续使用同一个 WebIDE 集成终端,工作目录为 /home/labex/project/duplicates。

cat reports/groups.tsv

UTF-8 TSV 表头依次为 GROUP 和 PATH。每行标识一条相对路径;相同组标签连接相同内容。标签本身不是文件身份,其顺序不重要。红色图片组必须包含全部三条路径,两个空文件组成一组,两份末尾缓冲区不满的二进制文件组成另一组。蓝色图片和内容不同的同名 readme 文件不是重复项。你可以在 Explorer 的 duplicates/data/gallery 下定位各成员;没有输入被删除。

用 work 中的一次性副本试验变化,保持图片原件不变。mkdir -p 在需要时创建练习目录。cp 将一个文件复制到新路径。

mkdir -p work/mini
cp data/gallery/red.png work/mini/first.png
cp data/gallery/copies/renamed.png work/mini/second.png
./run.sh Main work/mini reports/practice-before.tsv

预期得到一个重复组。接下来 printf 写入引号内的文本及换行;> 只替换练习用的第二个文件。它不再包含 PNG,因此扩展名不能证明内容。

printf 'changed\n' > work/mini/second.png
./run.sh Main work/mini reports/practice-after.tsv

预期得到零个重复组。报告仍保留表头:

cat reports/practice-after.tsv

成功的空结果不同于扫描失败。下一个命令故意要求把报告放进输入目录树。预期显示 Scan failed、退出状态 2,且不产生新的输入文件:

./run.sh Main data/gallery data/gallery/report.tsv

修正输出路径,重新生成图片目录的报告:

./run.sh Main data/gallery reports/groups.tsv

准备好的报告适配器向同目录中的新临时文件写入,扫描成功后再替换输出路径。它不会以写入方式打开已有硬链接,因此报告路径不能通过该链接覆盖原件字节。这不保证崩溃后的持久性。如果读取失败,命令失败,旧报告不能证明当前运行成功;绝不能静默跳过文件却宣布成功。

最终检查会在隔离夹具中执行你当前的源码,并读取真实图片目录报告。它检查完整组、当前内容、错误与原件保留情况。先前正确的报告不能掩盖后来改坏的源码。

总结

你使用大小和 SHA-256 缩小候选范围,确认真实字节,保留每条重复路径,并在实际改动后重新生成报告。成功得到零组和扫描失败现在具有不同含义。