implements packer PR-10 production trust and gates

This commit is contained in:
bQUARKz 2026-03-11 18:09:11 +00:00
parent 1c418a454b
commit 1506858b25
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
8 changed files with 338 additions and 6 deletions

View File

@ -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<PackerDiagnostic> 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<String, Object>) new com.fasterxml.jackson.databind.ObjectMapper().readValue(Files.readString(metadataJson), Map.class))
.getOrDefault("cache_key", "")
.toString();
final Map<String, Object> 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<PackerDiagnostic> validateExistingOutputs(PackerBuildRequest request, Path assetsArchive, Path metadataJson) {
final List<PackerDiagnostic> 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,

View File

@ -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,

View File

@ -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<PackerRegistryEntry> 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)

View File

@ -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(

View File

@ -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);

View File

@ -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")));
}
}

View File

@ -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");

View File

@ -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<StudioPackerOperationEvent> packerEvents = new ArrayList<>();
final List<StudioAssetsMutationAppliedEvent> 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;
}
}