diff --git a/.quixos-subtree-source.json b/.quixos-subtree-source.json index 7d02465..8d00114 100644 --- a/.quixos-subtree-source.json +++ b/.quixos-subtree-source.json @@ -1,7 +1,7 @@ { "version": 1, "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", - "sourceCommit": "dd7527da2714f3b0941ed908357fd82e98f45bc4", + "sourceCommit": "452f3c48aadceaf08e56ae9c226e98a7f9df4900", "sourcePath": "quixos-protocol", "exportName": "quixos-protocol", "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" diff --git a/src/capability-model/validation.ts b/src/capability-model/validation.ts index 518dff0..7a7fe5d 100644 --- a/src/capability-model/validation.ts +++ b/src/capability-model/validation.ts @@ -1526,7 +1526,6 @@ const validateBoundDependencies = ( ); break; } - validateAttachmentAccess(issues, attachment, params.conformance, `${path}.binding.edgeTypeId`); const projection = edgeProjection(attachment.attachment, edgeBinding.projectionId); if (!projection) { issue( @@ -1537,6 +1536,12 @@ const validateBoundDependencies = ( ); break; } + // Exporting traversal never exports writes or subscription ownership. + const publicRead = + projection.endpoint.publicTraversal && + requirement.primitives.length > 0 && + requirement.primitives.every((primitive) => primitive === "resolve"); + if (!publicRead) validateAttachmentAccess(issues, attachment, params.conformance, `${path}.binding.edgeTypeId`); if (projection.target.keyType && requirement.primitives.includes("connect")) issue( issues, diff --git a/test/capability-model.test.ts b/test/capability-model.test.ts index 28a3248..fffbae5 100644 --- a/test/capability-model.test.ts +++ b/test/capability-model.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { compileWorkspaceRevision, + capabilityId, computeCapabilityClosure, resolveOperationPlan, validateWorkspaceRevision, @@ -197,6 +198,62 @@ test("public traversal permits a native inverse read without exporting mutation expectIssue(workspace, "private-attachment-access"); }); +test("public traversal exports only the selected read-only package edge port", () => { + const workspace = makeValidCapabilityWorkspace(); + const exportDefinition = workspace.packageImports + .flatMap((pkg) => pkg.exports) + .find((entry) => entry.id === fixtureId.summaryGetExport)!; + const port = { + id: capabilityId.dependencyPort("port:summary:owner"), + displayName: "owner", + requirement: { + kind: "edge" as const, + target: { kind: "interface" as const, interfaceRevisionId: fixtureId.namedV1 }, + cardinality: "exactly-one" as const, + primitives: ["resolve" as const], + }, + }; + exportDefinition.dependencyPorts.push(port); + const binding = conformance(workspace, fixtureId.projectSummaryConformance).operationBindings[0].binding; + assert.ok(binding.kind === "package"); + binding.dependencies.push({ + portId: port.id, + binding: { + kind: "edge", + edgeTypeId: fixtureId.projectOwner, + projectionId: fixtureId.projectOwnerProjection, + }, + }); + const edge = conformance(workspace, fixtureId.projectOwnedConformance).privateAttachments.find( + (entry) => entry.id === fixtureId.projectOwner, + ); + assert.ok(edge?.kind === "edge"); + const endpoint = edge.endpoints.find((entry) => entry.projectionId === fixtureId.projectOwnerProjection)!; + expectIssue(workspace, "private-attachment-access"); + + // Publicity of the opposite projection does not grant this receiver access. + edge.endpoints.find((entry) => entry !== endpoint)!.publicTraversal = true; + expectIssue(workspace, "private-attachment-access"); + endpoint.publicTraversal = true; + assert.deepEqual(validateWorkspaceRevision(workspace), []); + const compiled = compileWorkspaceRevision(workspace); + assert.ok(compiled.ok); + const plan = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.summaryV1, fixtureId.summaryGet); + assert.ok(plan?.kind === "package"); + assert.deepEqual(plan.dependencies.find((entry) => entry.port.id === port.id)?.port.requirement, port.requirement); + + for (const primitive of ["connect", "disconnect", "watch-start", "watch-stop"] as const) { + const mutated = structuredClone(workspace); + const requirement = mutated.packageImports + .flatMap((pkg) => pkg.exports) + .find((entry) => entry.id === fixtureId.summaryGetExport)! + .dependencyPorts.find((entry) => entry.id === port.id)!.requirement; + assert.ok(requirement.kind === "edge"); + requirement.primitives.push(primitive); + expectIssue(mutated, "private-attachment-access"); + } +}); + test("native state and edge providers must match operation shape", () => { const stateWorkspace = makeValidCapabilityWorkspace(); const state = conformance(stateWorkspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding;