implements packer PR-10 production trust and gates
This commit is contained in:
parent
1c418a454b
commit
1506858b25
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user