From 2870a0677b7291e4d7c548d3e5c0127f38d8279f Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Wed, 18 Mar 2026 19:42:15 +0000 Subject: [PATCH] implements PR-025 asset cache model and repository --- .../packer/models/PackerAssetCacheEntry.java | 22 +++ .../p/packer/models/PackerFileCacheEntry.java | 45 +++++ .../models/PackerWorkspaceCacheState.java | 25 +++ .../FileSystemPackerCacheRepository.java | 165 ++++++++++++++++++ .../FileSystemPackerCacheRepositoryTest.java | 142 +++++++++++++++ 5 files changed, 399 insertions(+) create mode 100644 prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetCacheEntry.java create mode 100644 prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerFileCacheEntry.java create mode 100644 prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerWorkspaceCacheState.java create mode 100644 prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/FileSystemPackerCacheRepository.java create mode 100644 prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/FileSystemPackerCacheRepositoryTest.java diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetCacheEntry.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetCacheEntry.java new file mode 100644 index 00000000..87967559 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetCacheEntry.java @@ -0,0 +1,22 @@ +package p.packer.models; + +import java.util.*; + +public record PackerAssetCacheEntry( + int assetId, + List files) { + + public PackerAssetCacheEntry { + if (assetId <= 0) { + throw new IllegalArgumentException("assetId must be positive"); + } + files = List.copyOf(Objects.requireNonNull(files, "files")); + } + + public Optional findFile(String relativePath) { + final String safeRelativePath = Objects.requireNonNull(relativePath, "relativePath").trim().replace('\\', '/'); + return files.stream() + .filter(file -> file.relativePath().equals(safeRelativePath)) + .findFirst(); + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerFileCacheEntry.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerFileCacheEntry.java new file mode 100644 index 00000000..0d88f22b --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerFileCacheEntry.java @@ -0,0 +1,45 @@ +package p.packer.models; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public record PackerFileCacheEntry( + String relativePath, + String mimeType, + long size, + long lastModified, + String fingerprint, + Map metadata) { + + public PackerFileCacheEntry { + relativePath = normalizeRelativePath(relativePath); + mimeType = normalizeOptional(mimeType); + if (size < 0L) { + throw new IllegalArgumentException("size must be non-negative"); + } + if (lastModified < 0L) { + throw new IllegalArgumentException("lastModified must be non-negative"); + } + fingerprint = normalizeOptional(fingerprint); + metadata = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(metadata, "metadata"))); + } + + private static String normalizeRelativePath(String relativePath) { + final String safeRelativePath = Objects.requireNonNull(relativePath, "relativePath") + .trim() + .replace('\\', '/'); + if (safeRelativePath.isEmpty()) { + throw new IllegalArgumentException("relativePath must not be blank"); + } + return safeRelativePath; + } + + private static String normalizeOptional(String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerWorkspaceCacheState.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerWorkspaceCacheState.java new file mode 100644 index 00000000..62310f48 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerWorkspaceCacheState.java @@ -0,0 +1,25 @@ +package p.packer.models; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public record PackerWorkspaceCacheState( + int schemaVersion, + List assets) { + public static final int CURRENT_SCHEMA_VERSION = 1; + public static final PackerWorkspaceCacheState EMPTY = new PackerWorkspaceCacheState(CURRENT_SCHEMA_VERSION, List.of()); + + public PackerWorkspaceCacheState { + if (schemaVersion <= 0) { + throw new IllegalArgumentException("schemaVersion must be positive"); + } + assets = List.copyOf(Objects.requireNonNull(assets, "assets")); + } + + public Optional findAsset(int assetId) { + return assets.stream() + .filter(asset -> asset.assetId() == assetId) + .findFirst(); + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/FileSystemPackerCacheRepository.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/FileSystemPackerCacheRepository.java new file mode 100644 index 00000000..7521418c --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/FileSystemPackerCacheRepository.java @@ -0,0 +1,165 @@ +package p.packer.repositories; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import p.packer.PackerWorkspacePaths; +import p.packer.exceptions.PackerRegistryException; +import p.packer.messages.PackerProjectContext; +import p.packer.models.PackerAssetCacheEntry; +import p.packer.models.PackerFileCacheEntry; +import p.packer.models.PackerWorkspaceCacheState; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +public final class FileSystemPackerCacheRepository { + private final ObjectMapper mapper; + + public FileSystemPackerCacheRepository(ObjectMapper mapper) { + this.mapper = Objects.requireNonNull(mapper, "mapper"); + } + + public PackerWorkspaceCacheState load(PackerProjectContext project) { + final Path cachePath = PackerWorkspacePaths.cachePath(project); + if (!Files.isRegularFile(cachePath)) { + return PackerWorkspaceCacheState.EMPTY; + } + + try { + final CacheDocument document = mapper.readValue(cachePath.toFile(), CacheDocument.class); + final int schemaVersion = document.schemaVersion <= 0 + ? PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION + : document.schemaVersion; + if (schemaVersion != PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION) { + throw new PackerRegistryException("Unsupported cache schema_version: " + schemaVersion); + } + final List assets = new ArrayList<>(); + if (document.assets != null) { + for (CacheAssetDocument asset : document.assets) { + if (asset == null) { + continue; + } + final List files = new ArrayList<>(); + if (asset.files != null) { + for (CacheFileDocument file : asset.files) { + if (file == null) { + continue; + } + files.add(new PackerFileCacheEntry( + normalizeRelativePath(file.relativePath), + file.mimeType, + file.size, + file.lastModified, + file.fingerprint, + file.metadata == null ? Map.of() : file.metadata)); + } + } + validateAssetId(asset.assetId); + validateDuplicateRelativePaths(asset.assetId, files); + assets.add(new PackerAssetCacheEntry(asset.assetId, files)); + } + } + validateDuplicateAssetIds(assets); + return new PackerWorkspaceCacheState(schemaVersion, assets.stream() + .sorted(Comparator.comparingInt(PackerAssetCacheEntry::assetId)) + .toList()); + } catch (IOException exception) { + throw new PackerRegistryException("Unable to load cache: " + cachePath, exception); + } + } + + public void save(PackerProjectContext project, PackerWorkspaceCacheState state) { + final Path cachePath = PackerWorkspacePaths.cachePath(project); + final Path directory = PackerWorkspacePaths.registryDirectory(project); + final PackerWorkspaceCacheState safeState = Objects.requireNonNull(state, "state"); + validateDuplicateAssetIds(safeState.assets()); + safeState.assets().forEach(asset -> validateDuplicateRelativePaths(asset.assetId(), asset.files())); + try { + Files.createDirectories(directory); + final CacheDocument document = new CacheDocument(); + document.schemaVersion = safeState.schemaVersion(); + document.assets = safeState.assets().stream() + .map(asset -> new CacheAssetDocument( + asset.assetId(), + asset.files().stream() + .map(file -> new CacheFileDocument( + file.relativePath(), + file.mimeType(), + file.size(), + file.lastModified(), + file.fingerprint(), + file.metadata())) + .toList())) + .toList(); + mapper.writerWithDefaultPrettyPrinter().writeValue(cachePath.toFile(), document); + } catch (IOException exception) { + throw new PackerRegistryException("Unable to save cache: " + cachePath, exception); + } + } + + private void validateDuplicateAssetIds(List assets) { + final Set assetIds = new HashSet<>(); + for (PackerAssetCacheEntry asset : assets) { + validateAssetId(asset.assetId()); + if (!assetIds.add(asset.assetId())) { + throw new PackerRegistryException("Duplicate asset_id in cache: " + asset.assetId()); + } + } + } + + private void validateDuplicateRelativePaths(int assetId, List files) { + final Set relativePaths = new HashSet<>(); + for (PackerFileCacheEntry file : files) { + final String relativePath = normalizeRelativePath(file.relativePath()); + if (!relativePaths.add(relativePath)) { + throw new PackerRegistryException("Duplicate cached file path for asset_id " + assetId + ": " + relativePath); + } + } + } + + private void validateAssetId(int assetId) { + if (assetId <= 0) { + throw new PackerRegistryException("Cache asset_id must be positive"); + } + } + + private String normalizeRelativePath(String relativePath) { + final String normalized = Objects.requireNonNull(relativePath, "relativePath").trim().replace('\\', '/'); + if (normalized.isBlank()) { + throw new PackerRegistryException("Cached relative path must not be blank"); + } + final Path normalizedPath = Path.of(normalized).normalize(); + if (normalizedPath.isAbsolute() || normalizedPath.startsWith("..")) { + throw new PackerRegistryException("Cached relative path is outside the trusted asset boundary: " + normalized); + } + return normalizedPath.toString().replace('\\', '/'); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class CacheDocument { + @JsonProperty("schema_version") + public int schemaVersion; + + @JsonProperty("assets") + public List assets = List.of(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record CacheAssetDocument( + @JsonProperty("asset_id") int assetId, + @JsonProperty("files") List files) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record CacheFileDocument( + @JsonProperty("relative_path") String relativePath, + @JsonProperty("mime_type") String mimeType, + @JsonProperty("size") long size, + @JsonProperty("last_modified") long lastModified, + @JsonProperty("fingerprint") String fingerprint, + @JsonProperty("metadata") Map metadata) { + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/FileSystemPackerCacheRepositoryTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/FileSystemPackerCacheRepositoryTest.java new file mode 100644 index 00000000..22d5beca --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/FileSystemPackerCacheRepositoryTest.java @@ -0,0 +1,142 @@ +package p.packer.repositories; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.PackerWorkspacePaths; +import p.packer.exceptions.PackerRegistryException; +import p.packer.messages.PackerProjectContext; +import p.packer.models.PackerAssetCacheEntry; +import p.packer.models.PackerFileCacheEntry; +import p.packer.models.PackerWorkspaceCacheState; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +final class FileSystemPackerCacheRepositoryTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir + Path tempDir; + + @Test + void returnsEmptyStateWhenCacheIsAbsent() { + final var repository = new FileSystemPackerCacheRepository(MAPPER); + + final var state = repository.load(project(tempDir.resolve("project"))); + + assertEquals(PackerWorkspaceCacheState.EMPTY, state); + } + + @Test + void savesAndLoadsCacheRoundTrip() throws Exception { + final var repository = new FileSystemPackerCacheRepository(MAPPER); + final var project = project(tempDir.resolve("project")); + final var state = new PackerWorkspaceCacheState( + PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION, + List.of( + new PackerAssetCacheEntry(3, List.of( + new PackerFileCacheEntry( + "tiles/ui.png", + "image/png", + 128L, + 42L, + "abc123", + Map.of("tile_count", 4)), + new PackerFileCacheEntry( + "tiles/ui-2.png", + "image/png", + 256L, + 84L, + "def456", + Map.of()))), + new PackerAssetCacheEntry(1, List.of( + new PackerFileCacheEntry( + "audio/click.wav", + "audio/wav", + 512L, + 21L, + null, + Map.of("sample_count", 8)))))); + + repository.save(project, state); + final var loaded = repository.load(project); + + assertEquals(List.of(1, 3), loaded.assets().stream().map(PackerAssetCacheEntry::assetId).toList()); + assertTrue(loaded.findAsset(3).flatMap(asset -> asset.findFile("tiles/ui.png")).isPresent()); + assertEquals(128L, loaded.findAsset(3).orElseThrow().findFile("tiles/ui.png").orElseThrow().size()); + } + + @Test + void rejectsMalformedCacheFile() throws Exception { + final var repository = new FileSystemPackerCacheRepository(MAPPER); + final var project = project(tempDir.resolve("project")); + Files.createDirectories(PackerWorkspacePaths.registryDirectory(project)); + Files.writeString(PackerWorkspacePaths.cachePath(project), "{ nope "); + + final var exception = assertThrows(PackerRegistryException.class, () -> repository.load(project)); + + assertTrue(exception.getMessage().contains("Unable to load cache")); + } + + @Test + void rejectsUnsupportedSchemaVersion() throws Exception { + final var repository = new FileSystemPackerCacheRepository(MAPPER); + final var project = project(tempDir.resolve("project")); + Files.createDirectories(PackerWorkspacePaths.registryDirectory(project)); + Files.writeString(PackerWorkspacePaths.cachePath(project), """ + { + "schema_version": 999, + "assets": [] + } + """); + + final var exception = assertThrows(PackerRegistryException.class, () -> repository.load(project)); + + assertTrue(exception.getMessage().contains("Unsupported cache schema_version")); + } + + @Test + void alignsLookupByAssetId() { + final var state = new PackerWorkspaceCacheState( + PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION, + List.of( + new PackerAssetCacheEntry(7, List.of()), + new PackerAssetCacheEntry(9, List.of()))); + + assertTrue(state.findAsset(7).isPresent()); + assertTrue(state.findAsset(8).isEmpty()); + } + + @Test + void doesNotSerializeDiagnostics() throws Exception { + final var repository = new FileSystemPackerCacheRepository(MAPPER); + final var project = project(tempDir.resolve("project")); + final var state = new PackerWorkspaceCacheState( + PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION, + List.of(new PackerAssetCacheEntry(1, List.of( + new PackerFileCacheEntry( + "tiles/ui.png", + "image/png", + 128L, + 42L, + "abc123", + Map.of("warning_count", 2)))))); + + repository.save(project, state); + final String json = Files.readString(PackerWorkspacePaths.cachePath(project)); + + assertFalse(json.contains("diagnostic")); + assertFalse(json.contains("diagnostics")); + assertTrue(json.contains("\"asset_id\"")); + assertTrue(json.contains("\"metadata\"")); + } + + private PackerProjectContext project(Path rootPath) { + return new PackerProjectContext("main", rootPath); + } +}