ProjectSnapshot.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package org.sandbox.jdt.core.cleanupapp;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Immutable byte-level snapshot of every file participating in one project
* cleanup transaction.
*/
final class ProjectSnapshot {
/** One immutable original file image. */
record Entry(Path path, byte[] content, String sha256) {
Entry {
path= Objects.requireNonNull(path, "path"); //$NON-NLS-1$
content= content.clone();
sha256= Objects.requireNonNull(sha256, "sha256"); //$NON-NLS-1$
}
@Override
public byte[] content() {
return content.clone();
}
}
/** Verification result for one path after an operation. */
record Verification(Path path, boolean restored, String expectedSha256, String actualSha256, String message) {
Verification {
message= message == null ? "" : message; //$NON-NLS-1$
}
}
/** Aggregated restore failure after every snapshot entry was attempted. */
static final class RestoreException extends IOException {
private static final long serialVersionUID= 1L;
private final List<Verification> failures;
RestoreException(List<Verification> failures) {
super("Could not restore or verify " + failures.size() + " project snapshot file(s)"); //$NON-NLS-1$ //$NON-NLS-2$
this.failures= List.copyOf(failures);
}
List<Verification> failures() {
return failures;
}
}
private final List<Entry> entries;
private ProjectSnapshot(List<Entry> entries) {
this.entries= List.copyOf(entries);
}
static ProjectSnapshot capture(Collection<File> files) throws IOException {
if (files == null) {
throw new NullPointerException("files"); //$NON-NLS-1$
}
Map<String, Path> paths= new LinkedHashMap<>();
for (File file : files) {
if (file == null) {
throw new IOException("Project snapshot contains a null file entry"); //$NON-NLS-1$
}
Path path= file.toPath().toRealPath();
if (!Files.isRegularFile(path)) {
throw new IOException("Project snapshot input is not a regular file: " + stablePath(path)); //$NON-NLS-1$
}
paths.putIfAbsent(stablePath(path), path);
}
List<Entry> entries= new ArrayList<>();
for (Map.Entry<String, Path> item : paths.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList()) {
byte[] content= Files.readAllBytes(item.getValue());
entries.add(new Entry(item.getValue(), content, sha256(content)));
}
return new ProjectSnapshot(entries);
}
List<Entry> entries() {
return entries;
}
/** Returns deterministic verification results without changing any file. */
List<Verification> verify() {
List<Verification> results= new ArrayList<>();
for (Entry entry : entries) {
try {
byte[] current= Files.readAllBytes(entry.path());
String actual= sha256(current);
boolean restored= MessageDigest.isEqual(entry.content, current);
results.add(new Verification(entry.path(), restored, entry.sha256(), actual,
restored ? "" : "File content differs from the captured snapshot")); //$NON-NLS-1$ //$NON-NLS-2$
} catch (IOException exception) {
results.add(new Verification(entry.path(), false, entry.sha256(), "", //$NON-NLS-1$
exception.getMessage()));
}
}
return List.copyOf(results);
}
/**
* Restores every file, even when an earlier write fails, and then verifies every
* path. A single aggregated exception is thrown only after all entries have been
* attempted.
*/
List<Verification> restoreAndVerify() throws RestoreException {
Map<Path, String> writeFailures= new LinkedHashMap<>();
for (Entry entry : entries) {
try {
Files.write(entry.path(), entry.content,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
} catch (IOException exception) {
writeFailures.put(entry.path(), exception.getMessage());
}
}
List<Verification> results= new ArrayList<>();
for (Verification verification : verify()) {
String writeFailure= writeFailures.get(verification.path());
if (writeFailure == null) {
results.add(verification);
} else {
String message= verification.message().isEmpty()
? "Restore write failed: " + writeFailure //$NON-NLS-1$
: "Restore write failed: " + writeFailure + "; verification: " + verification.message(); //$NON-NLS-1$ //$NON-NLS-2$
results.add(new Verification(verification.path(), false, verification.expectedSha256(),
verification.actualSha256(), message));
}
}
List<Verification> failures= results.stream().filter(result -> !result.restored()).toList();
if (!failures.isEmpty()) {
throw new RestoreException(failures);
}
return List.copyOf(results);
}
private static String sha256(byte[] content) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); //$NON-NLS-1$
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception); //$NON-NLS-1$
}
}
private static String stablePath(Path path) {
return path.toAbsolutePath().normalize().toString().replace(File.separatorChar, '/');
}
}