package p.packer.foundation; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; import p.packer.api.PackerProjectContext; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; public final class FileSystemPackerRegistryRepository implements PackerRegistryRepository { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final int REGISTRY_SCHEMA_VERSION = 1; @Override public PackerRegistryState load(PackerProjectContext project) { final Path registryPath = PackerWorkspacePaths.registryPath(project); if (!Files.isRegularFile(registryPath)) { return emptyState(); } try { final RegistryDocument document = MAPPER.readValue(registryPath.toFile(), RegistryDocument.class); final int schemaVersion = document.schemaVersion <= 0 ? REGISTRY_SCHEMA_VERSION : document.schemaVersion; if (schemaVersion != REGISTRY_SCHEMA_VERSION) { throw new PackerRegistryException("Unsupported registry schema_version: " + schemaVersion); } final List entries = new ArrayList<>(); if (document.assets != null) { for (RegistryAssetDocument asset : document.assets) { if (asset == null) { continue; } entries.add(new PackerRegistryEntry( asset.assetId, asset.assetUuid, normalizeRoot(asset.root), asset.includedInBuild == null || asset.includedInBuild)); } } validateEntries(entries); final int nextAssetId = document.nextAssetId > 0 ? document.nextAssetId : entries.stream().mapToInt(PackerRegistryEntry::assetId).max().orElse(0) + 1; return new PackerRegistryState( schemaVersion, nextAssetId, entries.stream().sorted(Comparator.comparingInt(PackerRegistryEntry::assetId)).toList()); } catch (IOException exception) { throw new PackerRegistryException("Unable to load registry: " + registryPath, exception); } } @Override public void save(PackerProjectContext project, PackerRegistryState state) { final Path registryDirectory = PackerWorkspacePaths.registryDirectory(project); final Path registryPath = PackerWorkspacePaths.registryPath(project); validateEntries(state.assets()); try { Files.createDirectories(registryDirectory); final RegistryDocument document = new RegistryDocument(); document.schemaVersion = state.schemaVersion(); document.nextAssetId = state.nextAssetId(); document.assets = state.assets().stream() .map(entry -> new RegistryAssetDocument( entry.assetId(), entry.assetUuid(), entry.root(), entry.includedInBuild())) .toList(); MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryPath.toFile(), document); } catch (IOException exception) { throw new PackerRegistryException("Unable to save registry: " + registryPath, exception); } } private PackerRegistryState emptyState() { return new PackerRegistryState(REGISTRY_SCHEMA_VERSION, 1, List.of()); } private void validateEntries(List entries) { final Set assetIds = new HashSet<>(); final Set assetUuids = new HashSet<>(); final Set roots = new HashSet<>(); for (PackerRegistryEntry entry : entries) { if (!assetIds.add(entry.assetId())) { throw new PackerRegistryException("Duplicate asset_id in registry: " + entry.assetId()); } if (!assetUuids.add(entry.assetUuid())) { throw new PackerRegistryException("Duplicate asset_uuid in registry: " + entry.assetUuid()); } if (!roots.add(entry.root())) { throw new PackerRegistryException("Duplicate asset root in registry: " + entry.root()); } } } private String normalizeRoot(String root) { if (root == null || root.isBlank()) { throw new PackerRegistryException("Registry asset root must not be blank"); } final String normalized = root.trim().replace('\\', '/'); final Path normalizedPath = Path.of(normalized).normalize(); if (normalizedPath.isAbsolute() || normalizedPath.startsWith("..")) { throw new PackerRegistryException("Registry asset root is outside the trusted assets boundary: " + normalized); } return normalized; } @JsonIgnoreProperties(ignoreUnknown = true) private static final class RegistryDocument { @JsonProperty("schema_version") public int schemaVersion; @JsonProperty("next_asset_id") public int nextAssetId; @JsonProperty("assets") public List assets = List.of(); } @JsonIgnoreProperties(ignoreUnknown = true) private record RegistryAssetDocument( @JsonProperty("asset_id") int assetId, @JsonProperty("asset_uuid") String assetUuid, @JsonProperty("root") String root, @JsonProperty("included_in_build") Boolean includedInBuild) { } }