/* * AppleCommander - An Apple ][ image utility. * Copyright (C) 2021-2022 by Robert Greene and others * robgreene at users.sourceforge.net * * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the * Free Software Foundation; either version 2 of the License, or (at your * option) any later version. * * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ package com.webcodepro.applecommander.storage.compare; import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.BiConsumer; import java.util.stream.Collectors; import com.webcodepro.applecommander.storage.Disk; import com.webcodepro.applecommander.storage.DiskException; import com.webcodepro.applecommander.storage.DiskGeometry; import com.webcodepro.applecommander.storage.DiskUnrecognizedException; import com.webcodepro.applecommander.storage.FormattedDisk; import com.webcodepro.applecommander.storage.physical.ImageOrder; import com.webcodepro.applecommander.util.Range; import com.webcodepro.applecommander.util.filestreamer.FileStreamer; import com.webcodepro.applecommander.util.filestreamer.FileTuple; import com.webcodepro.applecommander.util.filestreamer.TypeOfFile; import com.webcodepro.applecommander.util.readerwriter.FileEntryReader; /** * Perform a disk comparison based on selected strategy. */ public class DiskDiff { public static ComparisonResult compare(Disk diskA, Disk diskB) { return new DiskDiff(diskA, diskB).compare(); } public static Builder create(Disk diskA, Disk diskB) { return new Builder(diskA, diskB); } private Disk diskA; private Disk diskB; private ComparisonResult results = new ComparisonResult(); private BiConsumer diskComparisonStrategy = this::compareByNativeGeometry; private DiskDiff(Disk diskA, Disk diskB) { Objects.requireNonNull(diskA); Objects.requireNonNull(diskB); this.diskA = diskA; this.diskB = diskB; } public ComparisonResult compare() { FormattedDisk[] formattedDisksA = null; try { formattedDisksA = diskA.getFormattedDisks(); } catch (DiskUnrecognizedException e) { results.addError(e); } FormattedDisk[] formattedDisksB = null; try { formattedDisksB = diskB.getFormattedDisks(); } catch (DiskUnrecognizedException e) { results.addError(e); } if (!results.hasErrors()) { compareAll(formattedDisksA, formattedDisksB); } return results; } public void compareAll(FormattedDisk[] formattedDisksA, FormattedDisk[] formattedDisksB) { Objects.requireNonNull(formattedDisksA); Objects.requireNonNull(formattedDisksB); if (formattedDisksA.length != formattedDisksB.length) { results.addWarning("Cannot compare all disks; %s has %d while %s has %d.", diskA.getFilename(), formattedDisksA.length, diskB.getFilename(), formattedDisksB.length); } int min = Math.min(formattedDisksA.length, formattedDisksB.length); for (int i=0; i %d)", orderA.getBlocksOnDevice(), orderB.getBlocksOnDevice()); return; } List unequalBlocks = new ArrayList<>(); for (int block=0; block %d)", orderA.getSectorsPerDisk(), orderB.getSectorsPerDisk()); return; } for (int track=0; track unequalSectors = new ArrayList<>(); for (int sector=0; sector> filesA = FileStreamer.forDisk(formattedDiskA) .includeTypeOfFile(TypeOfFile.FILE) .recursive(true) .stream() .collect(Collectors.groupingBy(FileTuple::fullPath)); Map> filesB = FileStreamer.forDisk(formattedDiskB) .includeTypeOfFile(TypeOfFile.FILE) .recursive(true) .stream() .collect(Collectors.groupingBy(FileTuple::fullPath)); Set pathsOnlyA = new HashSet<>(filesA.keySet()); pathsOnlyA.removeAll(filesB.keySet()); if (!pathsOnlyA.isEmpty()) { results.addError("Files only in %s: %s", formattedDiskA.getFilename(), String.join(", ", pathsOnlyA)); } Set pathsOnlyB = new HashSet<>(filesB.keySet()); pathsOnlyB.removeAll(filesA.keySet()); if (!pathsOnlyB.isEmpty()) { results.addError("Files only in %s: %s", formattedDiskB.getFilename(), String.join(", ", pathsOnlyB)); } Set pathsInAB = new HashSet<>(filesA.keySet()); pathsInAB.retainAll(filesB.keySet()); for (String path : pathsInAB) { List tuplesA = filesA.get(path); List tuplesB = filesB.get(path); // Since this is by name, we expect a single file; report oddities FileTuple tupleA = tuplesA.get(0); if (tuplesA.size() > 1) { results.addWarning("Path %s on disk %s has %d entries.", path, formattedDiskA.getFilename(), tuplesA.size()); } FileTuple tupleB = tuplesB.get(0); if (tuplesB.size() > 1) { results.addWarning("Path %s on disk %s has %d entries.", path, formattedDiskB.getFilename(), tuplesB.size()); } // Do our own custom compare so we can capture a description of differences: FileEntryReader readerA = FileEntryReader.get(tupleA.fileEntry); FileEntryReader readerB = FileEntryReader.get(tupleB.fileEntry); List differences = compare(readerA, readerB); if (!differences.isEmpty()) { results.addWarning("Path %s differ: %s", path, String.join(", ", differences)); } } } catch (DiskException ex) { results.addError(ex); } } /** Compare by file content. Accounts for content differences that are "only" in disk A or "only" in disk B. */ public void compareByFileContent(FormattedDisk formattedDiskA, FormattedDisk formattedDiskB) { try { Map> contentA = FileStreamer.forDisk(formattedDiskA) .includeTypeOfFile(TypeOfFile.FILE) .recursive(true) .stream() .collect(Collectors.groupingBy(this::contentHash)); Map> contentB = FileStreamer.forDisk(formattedDiskB) .includeTypeOfFile(TypeOfFile.FILE) .recursive(true) .stream() .collect(Collectors.groupingBy(this::contentHash)); Set contentOnlyA = new HashSet<>(contentA.keySet()); contentOnlyA.removeAll(contentB.keySet()); if (!contentOnlyA.isEmpty()) { Set pathNamesA = contentOnlyA.stream() .map(contentA::get) .flatMap(List::stream) .map(FileTuple::fullPath) .collect(Collectors.toSet()); results.addError("Content that only exists in %s: %s", formattedDiskA.getFilename(), String.join(", ", pathNamesA)); } Set contentOnlyB = new HashSet<>(contentB.keySet()); contentOnlyB.removeAll(contentA.keySet()); if (!contentOnlyB.isEmpty()) { Set pathNamesB = contentOnlyB.stream() .map(contentB::get) .flatMap(List::stream) .map(FileTuple::fullPath) .collect(Collectors.toSet()); results.addError("Content that only exists in %s: %s", formattedDiskB.getFilename(), String.join(", ", pathNamesB)); } Set contentInAB = new HashSet<>(contentA.keySet()); contentInAB.retainAll(contentB.keySet()); for (String content : contentInAB) { List tuplesA = contentA.get(content); List tuplesB = contentB.get(content); // This is by content, but uncertain how to report multiple per disk, so pick first one FileTuple tupleA = tuplesA.get(0); if (tuplesA.size() > 1) { results.addWarning("Hash %s on disk %s has %d entries.", content, formattedDiskA.getFilename(), tuplesA.size()); } FileTuple tupleB = tuplesB.get(0); if (tuplesB.size() > 1) { results.addWarning("Hash %s on disk %s has %d entries.", content, formattedDiskB.getFilename(), tuplesB.size()); } // Do our own custom compare so we can capture a description of differences: FileEntryReader readerA = FileEntryReader.get(tupleA.fileEntry); FileEntryReader readerB = FileEntryReader.get(tupleB.fileEntry); List differences = compare(readerA, readerB); if (!differences.isEmpty()) { results.addWarning("Files %s and %s share same content but file attributes differ: %s", tupleA.fullPath(), tupleB.fullPath(), String.join(", ", differences)); } } } catch (DiskException ex) { results.addError(ex); } } private String contentHash(FileTuple tuple) { try { MessageDigest messageDigest = MessageDigest.getInstance("MD5"); byte[] digest = messageDigest.digest(tuple.fileEntry.getFileData()); return String.format("%032X", new BigInteger(1, digest)); } catch (NoSuchAlgorithmException ex) { throw new RuntimeException(ex); } } private List compare(FileEntryReader readerA, FileEntryReader readerB) { List differences = new ArrayList<>(); if (!readerA.getFilename().equals(readerB.getFilename())) { differences.add("filename"); } if (!readerA.getProdosFiletype().equals(readerB.getProdosFiletype())) { differences.add("filetype"); } if (!readerA.isLocked().equals(readerB.isLocked())) { differences.add("locked"); } if (!Arrays.equals(readerA.getFileData().orElse(null), readerB.getFileData().orElse(null))) { differences.add("file data"); } if (!Arrays.equals(readerA.getResourceData().orElse(null), readerB.getResourceData().orElse(null))) { differences.add("resource fork"); } if (!readerA.getBinaryAddress().equals(readerB.getBinaryAddress())) { differences.add("address"); } if (!readerA.getBinaryLength().equals(readerB.getBinaryLength())) { differences.add("length"); } if (!readerA.getAuxiliaryType().equals(readerB.getAuxiliaryType())) { differences.add("aux. type"); } if (!readerA.getCreationDate().equals(readerB.getCreationDate())) { differences.add("create date"); } if (!readerA.getLastModificationDate().equals(readerB.getLastModificationDate())) { differences.add("mod. date"); } return differences; } public static class Builder { private DiskDiff diff; public Builder(Disk diskA, Disk diskB) { diff = new DiskDiff(diskA, diskB); } /** Compare disks by whatever native geometry the disks have. Fails if geometries do not match. */ public Builder selectCompareByNativeGeometry() { diff.diskComparisonStrategy = diff::compareByNativeGeometry; return this; } /** Compare disks by 256-byte DOS sectors. */ public Builder selectCompareByTrackSectorGeometry() { diff.diskComparisonStrategy = diff::compareByTrackSectorGeometry; return this; } /** Compare disks by 512-byte ProDOS/Pascal blocks. */ public Builder selectCompareByBlockGeometry() { diff.diskComparisonStrategy = diff::compareByBlockGeometry; return this; } /** Compare disks by files ensuring that all filenames match. */ public Builder selectCompareByFileName() { diff.diskComparisonStrategy = diff::compareByFileName; return this; } /** Compare disks by files based on content; allowing files to have moved or been renamed. */ public Builder selectCompareByFileContent() { diff.diskComparisonStrategy = diff::compareByFileContent; return this; } public ComparisonResult compare() { return diff.compare(); } } }