From 1506858b25225c9ea9da5af49b1da2132c0aff05 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Wed, 11 Mar 2026 18:09:11 +0000 Subject: [PATCH] implements packer PR-10 production trust and gates --- .../FileSystemPackerBuildService.java | 81 ++++++++++- .../PackerAssetDeclarationParser.java | 17 ++- .../FileSystemPackerRegistryRepository.java | 10 +- .../FileSystemPackerMutationService.java | 7 +- .../FileSystemPackerBuildServiceTest.java | 31 ++++ .../PackerAssetDeclarationParserTest.java | 27 ++++ .../PackerWorkspaceFoundationTest.java | 38 +++++ .../assets/PackerStudioIntegrationTest.java | 133 ++++++++++++++++++ 8 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java diff --git a/prometeu-packer/src/main/java/p/packer/building/FileSystemPackerBuildService.java b/prometeu-packer/src/main/java/p/packer/building/FileSystemPackerBuildService.java index ea174684..2bba79c3 100644 --- a/prometeu-packer/src/main/java/p/packer/building/FileSystemPackerBuildService.java +++ b/prometeu-packer/src/main/java/p/packer/building/FileSystemPackerBuildService.java @@ -6,6 +6,8 @@ import p.packer.api.building.PackerBuildRequest; import p.packer.api.building.PackerBuildResult; import p.packer.api.building.PackerBuildService; import p.packer.api.diagnostics.PackerDiagnostic; +import p.packer.api.diagnostics.PackerDiagnosticCategory; +import p.packer.api.diagnostics.PackerDiagnosticSeverity; import p.packer.api.events.PackerEventKind; import p.packer.api.events.PackerEventSink; import p.packer.api.events.PackerProgress; @@ -60,6 +62,17 @@ public final class FileSystemPackerBuildService implements PackerBuildService { final Path assetTableJson = buildDirectory.resolve("asset_table.json").toAbsolutePath().normalize(); final Path preloadJson = buildDirectory.resolve("preload.json").toAbsolutePath().normalize(); final Path metadataJson = buildDirectory.resolve("asset_table_metadata.json").toAbsolutePath().normalize(); + final List trustDiagnostics = new ArrayList<>(validateExistingOutputs(buildRequest, assetsArchive, metadataJson)); + if (!trustDiagnostics.isEmpty()) { + final String summary = trustDiagnostics.getFirst().message(); + events.emit(PackerEventKind.BUILD_FINISHED, summary, new PackerProgress(1.0d, false), List.of()); + return new PackerBuildResult( + PackerOperationStatus.FAILED, + summary, + assetsArchive, + Map.of(), + trustDiagnostics); + } events.emit(PackerEventKind.BUILD_STARTED, "Build started.", new PackerProgress(0.0d, false), List.of()); final PackerBuildPlanResult planResult = buildPlanner.plan(buildRequest.project()); if (planResult.plan() == null) { @@ -175,14 +188,76 @@ public final class FileSystemPackerBuildService implements PackerBuildService { return null; } try { - return ((Map) new com.fasterxml.jackson.databind.ObjectMapper().readValue(Files.readString(metadataJson), Map.class)) - .getOrDefault("cache_key", "") - .toString(); + final Map metadata = new com.fasterxml.jackson.databind.ObjectMapper().readValue(Files.readString(metadataJson), Map.class); + final Object schema = metadata.get("schema_version"); + if (!(schema instanceof Number) || ((Number) schema).intValue() != SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported build metadata schema_version: " + schema); + } + return metadata.getOrDefault("cache_key", "").toString(); } catch (IOException exception) { return null; } } + private List validateExistingOutputs(PackerBuildRequest request, Path assetsArchive, Path metadataJson) { + final List diagnostics = new ArrayList<>(); + if (Files.isRegularFile(metadataJson)) { + try { + final Map metadata = new com.fasterxml.jackson.databind.ObjectMapper().readValue(Files.readString(metadataJson), Map.class); + final Object schemaVersion = metadata.get("schema_version"); + if (!(schemaVersion instanceof Number) || ((Number) schemaVersion).intValue() != SCHEMA_VERSION) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.VERSIONING, + "Unsupported build metadata schema_version: " + schemaVersion, + metadataJson, + true)); + } + } catch (IOException exception) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.VERSIONING, + "Unable to read build metadata for version validation.", + metadataJson, + true)); + } + } + if (request.incremental() && Files.isRegularFile(assetsArchive)) { + try { + final byte[] prelude = Files.readAllBytes(assetsArchive); + if (prelude.length < PRELUDE_SIZE) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.VERSIONING, + "Existing assets.pa prelude is truncated.", + assetsArchive, + true)); + } else { + final ByteBuffer buffer = ByteBuffer.wrap(prelude, 0, PRELUDE_SIZE).order(ByteOrder.BIG_ENDIAN); + final byte[] magic = new byte[4]; + buffer.get(magic); + final int schemaVersion = buffer.getInt(); + if (!java.util.Arrays.equals(magic, MAGIC) || schemaVersion != SCHEMA_VERSION) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.VERSIONING, + "Existing assets.pa uses an unsupported writer-side schema surface.", + assetsArchive, + true)); + } + } + } catch (IOException exception) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.VERSIONING, + "Unable to inspect existing assets.pa for incremental trust validation.", + assetsArchive, + true)); + } + } + return diagnostics; + } + private record EmittedArchive( byte[] bytes, String assetTableJson, diff --git a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParser.java b/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParser.java index dd23ac5a..1e2494cb 100644 --- a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParser.java +++ b/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParser.java @@ -122,13 +122,28 @@ public final class PackerAssetDeclarationParser { true)); return; } - values.add(value.asText().trim()); + final String relativePath = value.asText().trim(); + if (!isTrustedRelativePath(relativePath)) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Input role '" + entry.getKey() + "' contains an untrusted path outside the asset root.", + manifestPath, + true)); + return; + } + values.add(relativePath); }); result.put(entry.getKey(), List.copyOf(values)); }); return Map.copyOf(result); } + private boolean isTrustedRelativePath(String value) { + final Path path = Path.of(value).normalize(); + return !path.isAbsolute() && !path.startsWith(".."); + } + private PackerDiagnostic missingOrInvalid(String fieldName, String expected, Path manifestPath) { return new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, diff --git a/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java b/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java index b18036ad..a000a674 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java +++ b/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java @@ -28,6 +28,9 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR 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) { @@ -94,7 +97,12 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR if (root == null || root.isBlank()) { throw new PackerRegistryException("Registry asset root must not be blank"); } - return root.trim().replace('\\', '/'); + 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) diff --git a/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java b/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java index 6a8c2e3a..b64b03ea 100644 --- a/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java +++ b/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java @@ -369,7 +369,12 @@ public final class FileSystemPackerMutationService implements PackerMutationServ if (preview.targetAssetRoot() == null) { throw new PackerMutationException("Mutation preview does not define a target asset root"); } - return preview.targetAssetRoot(); + final Path targetRoot = preview.targetAssetRoot(); + final Path projectRoot = preview.request().project().rootPath().toAbsolutePath().normalize(); + if (!targetRoot.toAbsolutePath().normalize().startsWith(projectRoot.resolve("assets").toAbsolutePath().normalize())) { + throw new PackerMutationException("Mutation target root is outside the trusted assets boundary"); + } + return targetRoot; } private void emit( diff --git a/prometeu-packer/src/test/java/p/packer/building/FileSystemPackerBuildServiceTest.java b/prometeu-packer/src/test/java/p/packer/building/FileSystemPackerBuildServiceTest.java index 8ee8376d..40d36b50 100644 --- a/prometeu-packer/src/test/java/p/packer/building/FileSystemPackerBuildServiceTest.java +++ b/prometeu-packer/src/test/java/p/packer/building/FileSystemPackerBuildServiceTest.java @@ -90,6 +90,37 @@ final class FileSystemPackerBuildServiceTest { assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.BUILD_FINISHED)); } + @Test + void buildFailsOnUnsupportedMetadataSchema() throws Exception { + final Path projectRoot = createProject(tempDir.resolve("unsupported-metadata")); + Files.createDirectories(projectRoot.resolve("build")); + Files.writeString(projectRoot.resolve("build/asset_table_metadata.json"), """ + { + "schema_version": 99, + "cache_key": "legacy" + } + """); + final FileSystemPackerBuildService service = new FileSystemPackerBuildService(); + + final var result = service.build(new PackerBuildRequest(new PackerProjectContext("unsupported-metadata", projectRoot), false)); + + assertEquals(PackerOperationStatus.FAILED, result.status()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Unsupported build metadata schema_version"))); + } + + @Test + void incrementalBuildFailsOnUnsupportedExistingArchivePrelude() throws Exception { + final Path projectRoot = createProject(tempDir.resolve("unsupported-archive")); + Files.createDirectories(projectRoot.resolve("build")); + Files.write(projectRoot.resolve("build/assets.pa"), "BAD!".getBytes(StandardCharsets.UTF_8)); + final FileSystemPackerBuildService service = new FileSystemPackerBuildService(); + + final var result = service.build(new PackerBuildRequest(new PackerProjectContext("unsupported-archive", projectRoot), true)); + + assertEquals(PackerOperationStatus.FAILED, result.status()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("prelude is truncated"))); + } + private ParsedArchive parseArchive(Path archivePath) throws Exception { final byte[] bytes = Files.readAllBytes(archivePath); final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); diff --git a/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDeclarationParserTest.java b/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDeclarationParserTest.java index 799cfb43..0d28dbd8 100644 --- a/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDeclarationParserTest.java +++ b/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDeclarationParserTest.java @@ -1,14 +1,21 @@ package p.packer.declarations; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import p.packer.api.diagnostics.PackerDiagnosticCategory; import p.packer.testing.PackerFixtureLocator; +import java.nio.file.Files; +import java.nio.file.Path; + import static org.junit.jupiter.api.Assertions.*; final class PackerAssetDeclarationParserTest { private final PackerAssetDeclarationParser parser = new PackerAssetDeclarationParser(); + @TempDir + Path tempDir; + @Test void parsesValidDeclarationFixture() { final var result = parser.parse(PackerFixtureLocator.fixtureRoot("workspaces/managed-basic/assets/ui/atlas/asset.json")); @@ -48,4 +55,24 @@ final class PackerAssetDeclarationParserTest { assertFalse(result.valid()); assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.category() == PackerDiagnosticCategory.VERSIONING)); } + + @Test + void rejectsUntrustedInputPaths() throws Exception { + final Path manifest = tempDir.resolve("asset.json"); + Files.writeString(manifest, """ + { + "schema_version": 1, + "name": "bad_asset", + "type": "image_bank", + "inputs": { "sprites": ["../outside.png"] }, + "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, + "preload": { "enabled": true } + } + """); + + final var result = parser.parse(manifest); + + assertFalse(result.valid()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("untrusted path"))); + } } diff --git a/prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java b/prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java index 7e2d573d..07877354 100644 --- a/prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java +++ b/prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java @@ -106,6 +106,44 @@ final class PackerWorkspaceFoundationTest { assertTrue(exception.getMessage().contains("Unable to load registry")); } + @Test + void unsupportedRegistrySchemaFailsClearly() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ + { + "schema_version": 99, + "next_asset_id": 1, + "assets": [] + } + """); + final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository(); + + final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot))); + + assertTrue(exception.getMessage().contains("Unsupported registry schema_version")); + } + + @Test + void untrustedRegistryRootFailsClearly() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ + { + "schema_version": 1, + "next_asset_id": 2, + "assets": [ + { "asset_id": 1, "asset_uuid": "uuid-1", "root": "../escape" } + ] + } + """); + final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository(); + + final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot))); + + assertTrue(exception.getMessage().contains("trusted assets boundary")); + } + @Test void lookupResolvesByIdUuidAndRootAndFailsOnMissingRoot() throws Exception { final Path projectRoot = tempDir.resolve("main"); diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java new file mode 100644 index 00000000..d1db00e1 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java @@ -0,0 +1,133 @@ +package p.studio.workspaces.assets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.building.FileSystemPackerBuildService; +import p.packer.api.PackerProjectContext; +import p.packer.api.building.PackerBuildRequest; +import p.packer.api.doctor.PackerDoctorMode; +import p.packer.api.doctor.PackerDoctorRequest; +import p.packer.declarations.PackerAssetDeclarationParser; +import p.packer.declarations.PackerAssetDetailsService; +import p.packer.doctor.FileSystemPackerDoctorService; +import p.packer.foundation.PackerWorkspaceFoundation; +import p.packer.mutations.PackerProjectWriteCoordinator; +import p.packer.workspace.FileSystemPackerWorkspaceService; +import p.studio.events.StudioAssetsMutationAppliedEvent; +import p.studio.events.StudioEventBus; +import p.studio.events.StudioPackerEventAdapter; +import p.studio.events.StudioPackerOperationEvent; +import p.studio.events.StudioWorkspaceEventBus; +import p.studio.projects.ProjectReference; +import p.studio.workspaces.WorkspaceId; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class PackerStudioIntegrationTest { + @TempDir + Path tempDir; + + @Test + void packerServicesAndStudioAdapterWorkTogetherEndToEnd() throws Exception { + final Path projectRoot = createProject(tempDir.resolve("main")); + final ProjectReference project = new ProjectReference("Main", "1.0.0", "pbs", 1, projectRoot); + final StudioEventBus globalBus = new StudioEventBus(); + final StudioWorkspaceEventBus workspaceBus = new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus); + final StudioPackerEventAdapter packerAdapter = new StudioPackerEventAdapter(workspaceBus, project); + final List packerEvents = new ArrayList<>(); + final List mutationEvents = new ArrayList<>(); + globalBus.subscribe(StudioPackerOperationEvent.class, packerEvents::add); + globalBus.subscribe(StudioAssetsMutationAppliedEvent.class, mutationEvents::add); + + final PackerBackedAssetWorkspaceService readService = new PackerBackedAssetWorkspaceService( + new FileSystemPackerWorkspaceService( + new PackerWorkspaceFoundation(), + new PackerAssetDeclarationParser(), + packerAdapter)); + final PackerBackedAssetWorkspaceMutationService mutationService = new PackerBackedAssetWorkspaceMutationService( + workspaceBus, + new PackerWorkspaceFoundation(), + new PackerAssetDetailsService(), + new PackerProjectWriteCoordinator()); + final FileSystemPackerDoctorService doctorService = new FileSystemPackerDoctorService( + new FileSystemPackerWorkspaceService( + new PackerWorkspaceFoundation(), + new PackerAssetDeclarationParser(), + packerAdapter), + new PackerAssetDetailsService(), + packerAdapter); + final FileSystemPackerBuildService buildService = new FileSystemPackerBuildService( + new p.packer.building.PackerBuildPlanner(), + packerAdapter); + + final AssetWorkspaceSnapshot snapshot = readService.loadWorkspace(project); + assertEquals(2, snapshot.assets().size()); + + final AssetWorkspaceAssetSummary orphan = snapshot.assets().stream() + .filter(asset -> asset.state() == AssetWorkspaceAssetState.ORPHAN) + .findFirst() + .orElseThrow(); + final AssetWorkspaceMutationPreview preview = mutationService.preview(project, orphan, AssetWorkspaceAction.REGISTER); + mutationService.apply(project, preview); + + final var doctor = doctorService.doctor(new PackerDoctorRequest( + new PackerProjectContext(project.name(), project.rootPath()), + PackerDoctorMode.EXPANDED_WORKSPACE, + true)); + final var build = buildService.build(new PackerBuildRequest(new PackerProjectContext(project.name(), project.rootPath()), false)); + + assertTrue(mutationEvents.stream().anyMatch(event -> event.action() == AssetWorkspaceAction.REGISTER)); + assertTrue(doctor.status() == p.packer.api.PackerOperationStatus.SUCCESS || doctor.status() == p.packer.api.PackerOperationStatus.PARTIAL); + assertTrue(Files.isRegularFile(build.assetsArchive())); + assertTrue(packerEvents.stream().anyMatch(event -> event.kind() == p.packer.api.events.PackerEventKind.BUILD_FINISHED)); + } + + private Path createProject(Path projectRoot) throws Exception { + final Path managedRoot = projectRoot.resolve("assets/ui/atlas"); + final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds"); + Files.createDirectories(managedRoot.resolve("sprites")); + Files.createDirectories(orphanRoot.resolve("sources")); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.writeString(managedRoot.resolve("asset.json"), """ + { + "schema_version": 1, + "name": "ui_atlas", + "type": "image_bank", + "inputs": { "sprites": ["sprites/confirm.png"] }, + "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, + "preload": { "enabled": true } + } + """); + Files.writeString(orphanRoot.resolve("asset.json"), """ + { + "schema_version": 1, + "name": "ui_sounds", + "type": "sound_bank", + "inputs": { "sources": ["sources/confirm.wav"] }, + "output": { "format": "SOUND/bank_v1", "codec": "RAW" }, + "preload": { "enabled": false } + } + """); + Files.writeString(managedRoot.resolve("sprites/confirm.png"), "png"); + Files.writeString(orphanRoot.resolve("sources/confirm.wav"), "wav"); + Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ + { + "schema_version": 1, + "next_asset_id": 2, + "assets": [ + { + "asset_id": 1, + "asset_uuid": "uuid-1", + "root": "ui/atlas" + } + ] + } + """); + return projectRoot; + } +}