diff --git a/.gitignore b/.gitignore index 5f7f37c..88a1d59 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,13 @@ boot.key # User Model List (internal DB) uploaded_models.json pending_downloads.json +model_hashes.json # Local config & assets workflow.json +*.json +!pom.xml +!src/** *.png *.bat *.zip diff --git a/pom.xml b/pom.xml index 6c86809..e1dd67b 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,18 @@ 6.1.5 test + + org.mockito + mockito-core + 5.11.0 + test + + + org.mockito + mockito-junit-jupiter + 5.11.0 + test + com.formdev flatlaf diff --git a/src/main/java/de/tki/comfymodels/Main.java b/src/main/java/de/tki/comfymodels/Main.java index 1535d9c..ac0556b 100644 --- a/src/main/java/de/tki/comfymodels/Main.java +++ b/src/main/java/de/tki/comfymodels/Main.java @@ -39,6 +39,8 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.ArrayList; +import java.util.Map; +import java.util.HashMap; import java.util.stream.Collectors; @Component @@ -58,6 +60,9 @@ public class Main extends JFrame { @Autowired private ModelListService modelListService; + @Autowired + private de.tki.comfymodels.service.impl.ModelHashRegistry hashRegistry; + private JTextField modelsPathField; private JPasswordField geminiKeyField; private JPasswordField hfTokenField; @@ -326,8 +331,19 @@ public class Main extends JFrame { JButton helpBtn = new JButton("ℹ Help & Security Information"); helpBtn.addActionListener(e -> showHelpDialog()); - JButton verifyBtn = new JButton("🔍 Verify Local Models (Corruption Check)"); - verifyBtn.addActionListener(e -> verifyLocalModels()); + JButton verifyBtn = new JButton("🔍 Fast Verify (Corruption)"); + verifyBtn.addActionListener(e -> verifyLocalModels(false)); + + JButton optimizeBtn = new JButton("👯 Storage Optimizer (Duplicates)"); + optimizeBtn.addActionListener(e -> { + int confirm = JOptionPane.showConfirmDialog(this, + "The Storage Optimizer calculates SHA-256 hashes for all local models.\n" + + "This is EXTREMELY slow and resource-intensive for large libraries.\n\n" + + "Do you want to proceed?", "Performance Warning", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); + if (confirm == JOptionPane.YES_OPTION) { + verifyLocalModels(true); + } + }); backgroundCheck = new JCheckBox("Stay in Background on Close"); backgroundCheck.addActionListener(e -> configService.setBackgroundModeEnabled(backgroundCheck.isSelected())); @@ -353,6 +369,7 @@ public class Main extends JFrame { infoRow.add(helpBtn); infoRow.add(verifyBtn); + infoRow.add(optimizeBtn); infoRow.add(backgroundCheck); infoRow.add(shutdownCheck); infoRow.add(darkCheck); @@ -629,7 +646,7 @@ public class Main extends JFrame { ); } - private void verifyLocalModels() { + private void verifyLocalModels(boolean checkDuplicates) { String baseDir = modelsPathField.getText(); if (baseDir == null || baseDir.isEmpty()) { JOptionPane.showMessageDialog(this, "Please set a models directory first."); @@ -642,10 +659,13 @@ public class Main extends JFrame { return; } - statusLabel.setText("Verifying models... please wait."); + String taskName = checkDuplicates ? "Verifying models & checking for duplicates" : "Verifying models (Fast Check)"; + statusLabel.setText(taskName + "... please wait."); new Thread(() -> { try { List errors = new ArrayList<>(); + Map> hashToPaths = new HashMap<>(); + List allFiles = Files.walk(root.toPath()) .filter(Files::isRegularFile) .filter(p -> { @@ -663,55 +683,107 @@ public class Main extends JFrame { IModelValidator.ValidationResult res = modelValidator.validateFile(p.toFile()); if (!res.ok) { errors.add(res); + } else if (checkDuplicates) { + String hash = hashRegistry.getOrCalculateHash(p.toFile()); + if (hash != null) { + hashToPaths.computeIfAbsent(hash, k -> new ArrayList<>()).add(p); + } } } + Map> duplicates = checkDuplicates ? hashToPaths.entrySet().stream() + .filter(e -> e.getValue().size() > 1) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)) : new HashMap<>(); + SwingUtilities.invokeLater(() -> { - statusLabel.setText("Verification finished. Found " + errors.size() + " issues."); - if (errors.isEmpty()) { + statusLabel.setText("Scan finished. Found " + errors.size() + " issues and " + duplicates.size() + " duplicate sets."); + + if (errors.isEmpty() && duplicates.isEmpty()) { JOptionPane.showMessageDialog(this, "All " + total + " models verified successfully!", "Verification Complete", JOptionPane.INFORMATION_MESSAGE); - } else { - StringBuilder sb = new StringBuilder("The following " + errors.size() + " files appear to be corrupted or invalid:\n\n"); + return; + } + + StringBuilder sb = new StringBuilder(); + if (!errors.isEmpty()) { + sb.append("⚠️ CORRUPTED FILES (").append(errors.size()).append("):\n"); for (IModelValidator.ValidationResult err : errors) { - sb.append("- ").append(new File(err.filePath).getName()) - .append(" (").append(err.message).append(")\n") - .append(" Path: ").append(err.filePath).append("\n\n"); + sb.append("- ").append(new File(err.filePath).getName()).append(" (").append(err.message).append(")\n"); } - - JTextArea textArea = new JTextArea(sb.toString()); - textArea.setEditable(false); - JScrollPane scrollPane = new JScrollPane(textArea); - scrollPane.setPreferredSize(new Dimension(800, 500)); - - Object[] options = {"OK", "Delete All Corrupted Files"}; - int choice = JOptionPane.showOptionDialog(this, scrollPane, "Verification Results - Issues Found", - JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE, null, options, options[0]); - - if (choice == 1) { // Delete All Corrupted Files - int confirm = JOptionPane.showConfirmDialog(this, - "Are you sure you want to delete these " + errors.size() + " files?\nThis action cannot be undone.", - "Confirm Deletion", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); - - if (confirm == JOptionPane.YES_OPTION) { - int deletedCount = 0; - for (IModelValidator.ValidationResult err : errors) { - File f = new File(err.filePath); - if (f.exists() && f.delete()) { - deletedCount++; - } - } - JOptionPane.showMessageDialog(this, "Deleted " + deletedCount + " files."); - statusLabel.setText("Cleanup finished. Deleted " + deletedCount + " files."); + sb.append("\n"); + } + + if (!duplicates.isEmpty()) { + sb.append("👯 DUPLICATES FOUND (").append(duplicates.size()).append(" sets):\n"); + for (Map.Entry> entry : duplicates.entrySet()) { + sb.append("Hash: ").append(entry.getKey().substring(0, 12)).append("...\n"); + for (Path p : entry.getValue()) { + sb.append(" - ").append(p.toFile().getAbsolutePath()).append(" (").append(formatSize(p.toFile().length())).append(")\n"); } + sb.append("\n"); } } + + JTextArea textArea = new JTextArea(sb.toString()); + textArea.setEditable(false); + JScrollPane scrollPane = new JScrollPane(textArea); + scrollPane.setPreferredSize(new Dimension(850, 550)); + + Object[] options = {"OK", "Delete All Corrupted", "Manage Duplicates..."}; + int choice = JOptionPane.showOptionDialog(this, scrollPane, "Verification & Duplicate Results", + JOptionPane.DEFAULT_OPTION, JOptionPane.WARNING_MESSAGE, null, options, options[0]); + + if (choice == 1) { // Delete All Corrupted + handleDeleteCorrupted(errors); + } else if (choice == 2) { // Manage Duplicates + handleManageDuplicates(duplicates); + } }); } catch (IOException e) { - SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(this, "Error during verification: " + e.getMessage())); + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(this, "Error: " + e.getMessage())); } }).start(); } + private void handleDeleteCorrupted(List errors) { + int confirm = JOptionPane.showConfirmDialog(this, "Delete " + errors.size() + " corrupted files?", "Confirm", JOptionPane.YES_NO_OPTION); + if (confirm == JOptionPane.YES_OPTION) { + int deleted = 0; + for (IModelValidator.ValidationResult err : errors) { + File f = new File(err.filePath); + if (f.exists() && f.delete()) { + hashRegistry.unregister(f); + deleted++; + } + } + JOptionPane.showMessageDialog(this, "Deleted " + deleted + " files."); + } + } + + private void handleManageDuplicates(Map> duplicates) { + long totalSavings = 0; + for (List paths : duplicates.values()) { + for (int i = 1; i < paths.size(); i++) totalSavings += paths.get(i).toFile().length(); + } + + int confirm = JOptionPane.showConfirmDialog(this, + "Keep the first file of each set and delete others?\nEstimated savings: " + formatSize(totalSavings), + "Storage Optimizer", JOptionPane.YES_NO_OPTION); + + if (confirm == JOptionPane.YES_OPTION) { + int deletedCount = 0; + for (List paths : duplicates.values()) { + for (int i = 1; i < paths.size(); i++) { + File f = paths.get(i).toFile(); + if (f.exists() && f.delete()) { + hashRegistry.unregister(f); + deletedCount++; + } + } + } + JOptionPane.showMessageDialog(this, "Freed " + formatSize(totalSavings) + "."); + } + } + private void showHelpDialog() { String helpText = "" + "

ComfyUI Model Downloader Help

" + @@ -754,12 +826,19 @@ public class Main extends JFrame { private void fetchSizeInBackground(ModelInfo info, int rowIndex) { new Thread(() -> { long size = searchService.getRemoteSize(info.getUrl()); - String s = searchService.formatSize(size); + String s = formatSize(size); info.setSize(s); SwingUtilities.invokeLater(() -> tableModel.setValueAt(s, rowIndex, 3)); }).start(); } + private String formatSize(long bytes) { + if (bytes <= 0) return "Unknown"; + if (bytes < 1024 * 1024) return (bytes / 1024) + " KB"; + if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + return String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)); + } + public static void main(String[] args) { // Enable modern window decorations BEFORE anything else com.formdev.flatlaf.FlatLaf.setUseNativeWindowDecorations(true); diff --git a/src/main/java/de/tki/comfymodels/service/IModelValidator.java b/src/main/java/de/tki/comfymodels/service/IModelValidator.java index b9e7681..b7dbdc6 100644 --- a/src/main/java/de/tki/comfymodels/service/IModelValidator.java +++ b/src/main/java/de/tki/comfymodels/service/IModelValidator.java @@ -4,6 +4,7 @@ import java.io.File; public interface IModelValidator { ValidationResult validateFile(File file); + String calculateHash(File file); class ValidationResult { public final boolean ok; diff --git a/src/main/java/de/tki/comfymodels/service/impl/ModelHashRegistry.java b/src/main/java/de/tki/comfymodels/service/impl/ModelHashRegistry.java new file mode 100644 index 0000000..d01ae38 --- /dev/null +++ b/src/main/java/de/tki/comfymodels/service/impl/ModelHashRegistry.java @@ -0,0 +1,113 @@ +package de.tki.comfymodels.service.impl; + +import de.tki.comfymodels.service.IModelValidator; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PostConstruct; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Service +public class ModelHashRegistry { + private final String HASH_FILE = "model_hashes.json"; + private Map cache = new HashMap<>(); + private Map hashToPath = new HashMap<>(); + + @Autowired + private ConfigService configService; + + @Autowired + private IModelValidator validator; + + private static class CacheEntry { + String hash; + long size; + long lastModified; + + CacheEntry(String hash, long size, long lastModified) { + this.hash = hash; + this.size = size; + this.lastModified = lastModified; + } + } + + @PostConstruct + public void load() { + try { + File file = configService.getFileInAppData(HASH_FILE); + if (file.exists()) { + String content = Files.readString(file.toPath(), StandardCharsets.UTF_8); + JSONObject json = new JSONObject(content); + for (String path : json.keySet()) { + JSONObject entry = json.getJSONObject(path); + String hash = entry.getString("hash"); + long size = entry.optLong("size", 0); + long lm = entry.optLong("lastModified", 0); + + if (new File(path).exists()) { + cache.put(path, new CacheEntry(hash, size, lm)); + hashToPath.put(hash, path); + } + } + } + } catch (Exception e) { + System.err.println("Error loading hash registry: " + e.getMessage()); + } + } + + public synchronized void save() { + try { + JSONObject json = new JSONObject(); + for (Map.Entry e : cache.entrySet()) { + JSONObject obj = new JSONObject(); + obj.put("hash", e.getValue().hash); + obj.put("size", e.getValue().size); + obj.put("lastModified", e.getValue().lastModified); + json.put(e.getKey(), obj); + } + Files.writeString(configService.getFileInAppData(HASH_FILE).toPath(), json.toString(2), StandardCharsets.UTF_8); + } catch (Exception e) { + System.err.println("Error saving hash registry: " + e.getMessage()); + } + } + + public synchronized String getOrCalculateHash(File file) { + String path = file.getAbsolutePath(); + long currentSize = file.length(); + long currentLm = file.lastModified(); + + if (cache.containsKey(path)) { + CacheEntry entry = cache.get(path); + if (entry.size == currentSize && entry.lastModified == currentLm) { + return entry.hash; + } + } + + String hash = validator.calculateHash(file); + if (hash != null) { + cache.put(path, new CacheEntry(hash, currentSize, currentLm)); + hashToPath.put(hash, path); + save(); + } + return hash; + } + + public Optional findPathByHash(String hash) { + return Optional.ofNullable(hashToPath.get(hash)); + } + + public void unregister(File file) { + String path = file.getAbsolutePath(); + CacheEntry entry = cache.remove(path); + if (entry != null) { + hashToPath.remove(entry.hash); + save(); + } + } +} diff --git a/src/main/java/de/tki/comfymodels/service/impl/ModelSearchService.java b/src/main/java/de/tki/comfymodels/service/impl/ModelSearchService.java index c008926..fe4863b 100644 --- a/src/main/java/de/tki/comfymodels/service/impl/ModelSearchService.java +++ b/src/main/java/de/tki/comfymodels/service/impl/ModelSearchService.java @@ -83,6 +83,12 @@ public class ModelSearchService implements IModelSearchService { } } + @Autowired + private de.tki.comfymodels.service.impl.ModelHashRegistry hashRegistry; + + @Autowired + private de.tki.comfymodels.service.IModelValidator modelValidator; + private void performSearch(ModelInfo info, int index, String fileName, String workflowContext, BiConsumer onStatusUpdate, BiConsumer onModelFound) { @@ -93,6 +99,18 @@ public class ModelSearchService implements IModelSearchService { if (validateAndSetUrl(info, index, manualMatch.get().getUrl(), "📂 USER DEFINED", onStatusUpdate, onModelFound)) return; } + // Priority 2: Civitai Hash Lookup (if file exists locally but we want the source URL/Metadata) + // This is useful for re-downloading or finding updates + String modelsPath = configService.getModelsPath(); + java.io.File localFile = new java.io.File(modelsPath, (info.getSave_path() != null ? info.getSave_path() : (info.getType() != null ? info.getType() : "checkpoints")) + java.io.File.separator + info.getName()); + if (localFile.exists()) { + onStatusUpdate.accept(index, "🔍 Hashing for Civitai..."); + String hash = hashRegistry.getOrCalculateHash(localFile); + if (hash != null) { + if (fetchCivitaiUrlByHash(info, index, hash, onStatusUpdate, onModelFound)) return; + } + } + onStatusUpdate.accept(index, "✨ Gemini Scouting..."); String aiHint = geminiService.discoverBestRepo(info.getName(), fileName, workflowContext); if (aiHint != null && !aiHint.equalsIgnoreCase("UNKNOWN")) { @@ -275,6 +293,29 @@ public class ModelSearchService implements IModelSearchService { return false; } + private boolean fetchCivitaiUrlByHash(ModelInfo info, int rowIndex, String hash, + BiConsumer onStatusUpdate, + BiConsumer onModelFound) { + try { + String searchUrl = "https://civitai.com/api/v1/model-versions/by-hash/" + hash; + HttpResponse response = httpClient.send(HttpRequest.newBuilder().uri(URI.create(searchUrl)).build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + JSONObject version = new JSONObject(response.body()); + JSONArray files = version.optJSONArray("files"); + if (files != null) { + for (int j = 0; j < files.length(); j++) { + JSONObject file = files.getJSONObject(j); + // We check if the name matches or if it's the primary file for this version + if (file.optString("name").equalsIgnoreCase(info.getName()) || file.optBoolean("primary", false)) { + return validateAndSetUrl(info, rowIndex, file.optString("downloadUrl"), "🛡️ Civitai (Hash Match)", onStatusUpdate, onModelFound); + } + } + } + } + } catch (Exception e) {} + return false; + } + private boolean fetchCivitaiUrl(ModelInfo info, int rowIndex, String query, BiConsumer onStatusUpdate, BiConsumer onModelFound) { diff --git a/src/main/java/de/tki/comfymodels/service/impl/ModelValidator.java b/src/main/java/de/tki/comfymodels/service/impl/ModelValidator.java index eaabb75..c0c34c1 100644 --- a/src/main/java/de/tki/comfymodels/service/impl/ModelValidator.java +++ b/src/main/java/de/tki/comfymodels/service/impl/ModelValidator.java @@ -45,6 +45,29 @@ public class ModelValidator implements IModelValidator { return new ValidationResult(true, "OK", path); } + @Override + public String calculateHash(File file) { + if (!file.exists() || !file.isFile()) return null; + try (java.io.InputStream is = new java.io.BufferedInputStream(new java.io.FileInputStream(file), 1024 * 1024)) { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[1024 * 1024]; + int read; + while ((read = is.read(buffer)) != -1) { + digest.update(buffer, 0, read); + } + byte[] hash = digest.digest(); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + return hexString.toString(); + } catch (Exception e) { + return null; + } + } + private ValidationResult validateSafetensors(File file) { try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { if (raf.length() < 8) return new ValidationResult(false, "File too small to have a header", file.getAbsolutePath()); diff --git a/src/test/java/de/tki/comfymodels/service/ModelHashRegistryTest.java b/src/test/java/de/tki/comfymodels/service/ModelHashRegistryTest.java new file mode 100644 index 0000000..cf0eb83 --- /dev/null +++ b/src/test/java/de/tki/comfymodels/service/ModelHashRegistryTest.java @@ -0,0 +1,119 @@ +package de.tki.comfymodels.service; + +import de.tki.comfymodels.service.impl.ConfigService; +import de.tki.comfymodels.service.impl.ModelHashRegistry; +import de.tki.comfymodels.service.impl.ModelValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +public class ModelHashRegistryTest { + + @TempDir + Path tempDir; + + private ConfigService configService; + private IModelValidator validator; + private ModelHashRegistry registry; + private String lastCalculatedHash = "hash123"; + private int calculationCount = 0; + + @BeforeEach + public void setup() throws Exception { + configService = new ConfigService(null) { + @Override + public File getFileInAppData(String filename) { + return tempDir.resolve(filename).toFile(); + } + }; + + validator = new IModelValidator() { + @Override + public ValidationResult validateFile(File file) { return new ValidationResult(true, "OK", file.getAbsolutePath()); } + @Override + public String calculateHash(File file) { + calculationCount++; + return lastCalculatedHash; + } + }; + + registry = new ModelHashRegistry(); + + // Use reflection to set private fields since we aren't using @InjectMocks + setField(registry, "configService", configService); + setField(registry, "validator", validator); + registry.load(); + } + + private void setField(Object obj, String fieldName, Object value) throws Exception { + Field field = obj.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(obj, value); + } + + @Test + public void testGetOrCalculateHash() throws Exception { + Path file = tempDir.resolve("model.safetensors"); + Files.writeString(file, "content"); + File modelFile = file.toFile(); + + lastCalculatedHash = "hash123"; + calculationCount = 0; + + // First call: should calculate + String hash1 = registry.getOrCalculateHash(modelFile); + assertEquals("hash123", hash1); + assertEquals(1, calculationCount); + + // Second call: should use cache because size and timestamp are same + String hash2 = registry.getOrCalculateHash(modelFile); + assertEquals("hash123", hash2); + assertEquals(1, calculationCount); + + // Change content -> size stays same, but timestamp changes (usually) + // To be sure for the test, we wait a bit or just assume size change + Files.writeString(file, "content has changed now"); + File updatedFile = file.toFile(); + + lastCalculatedHash = "new_hash"; + String hash3 = registry.getOrCalculateHash(updatedFile); + assertEquals("new_hash", hash3); + assertEquals(2, calculationCount); // Re-calculation triggered! + } + + @Test + public void testFindPathByHash() throws Exception { + Path file = tempDir.resolve("duplicate.safetensors"); + Files.writeString(file, "duplicate content"); + File modelFile = file.toFile(); + + lastCalculatedHash = "duplicate_hash"; + registry.getOrCalculateHash(modelFile); + + Optional foundPath = registry.findPathByHash("duplicate_hash"); + assertTrue(foundPath.isPresent()); + assertEquals(modelFile.getAbsolutePath(), foundPath.get()); + } + + @Test + public void testUnregister() throws Exception { + Path file = tempDir.resolve("to_be_deleted.safetensors"); + Files.writeString(file, "soon gone"); + File modelFile = file.toFile(); + + lastCalculatedHash = "temp_hash"; + registry.getOrCalculateHash(modelFile); + assertTrue(registry.findPathByHash("temp_hash").isPresent()); + + registry.unregister(modelFile); + assertFalse(registry.findPathByHash("temp_hash").isPresent()); + } +} diff --git a/src/test/java/de/tki/comfymodels/service/ModelHashTest.java b/src/test/java/de/tki/comfymodels/service/ModelHashTest.java new file mode 100644 index 0000000..bc86f02 --- /dev/null +++ b/src/test/java/de/tki/comfymodels/service/ModelHashTest.java @@ -0,0 +1,47 @@ +package de.tki.comfymodels.service; + +import de.tki.comfymodels.service.impl.ModelValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +public class ModelHashTest { + + @TempDir + Path tempDir; + + @Test + public void testCalculateSHA256() throws Exception { + ModelValidator validator = new ModelValidator(); + Path testFile = tempDir.resolve("test_model.bin"); + byte[] data = "ComfyUI Model Data for Hashing Test".getBytes(java.nio.charset.StandardCharsets.UTF_8); + Files.write(testFile, data); + + String hash = validator.calculateHash(testFile.toFile()); + + assertNotNull(hash); + assertEquals(64, hash.length()); + + // echo -n "ComfyUI Model Data for Hashing Test" | sha256sum + String expectedHash = "5022ad6b25eac3358b18aa8ef8ee79d06833c43bed545bed1a5d45057d3054e2"; + assertEquals(expectedHash, hash); + } + + @Test + public void testEmptyFileHash() throws Exception { + ModelValidator validator = new ModelValidator(); + Path emptyFile = tempDir.resolve("empty.bin"); + Files.createFile(emptyFile); + + String hash = validator.calculateHash(emptyFile.toFile()); + + // SHA-256 for empty string + String expectedEmptyHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + assertEquals(expectedEmptyHash, hash); + } +}