diff --git a/src/domain/definitions/definition.ts b/src/domain/definitions/definition.ts index 553936bc..eda77fae 100644 --- a/src/domain/definitions/definition.ts +++ b/src/domain/definitions/definition.ts @@ -149,12 +149,19 @@ export const DefinitionSchema = z.object({ ), id: z.string().uuid(), name: z.string().min(1).refine( - (name) => - !name.includes("..") && !name.includes("/") && !name.includes("\\") && - !name.includes("\0"), + (name) => { + if (name.includes("..") || name.includes("\\") || name.includes("\0")) { + return false; + } + if (name.includes("/")) { + // Allow '/' only in scoped @collective/name extension names + return /^@[a-z0-9_-]+\/[a-z0-9_-]+(\/[a-z0-9_-]+)*$/.test(name); + } + return true; + }, { message: - "Definition name must not contain '..', '/', '\\', or null bytes (path traversal)", + "Definition name must not contain '..', '\\', or null bytes (path traversal). '/' is only allowed in scoped @collective/name patterns.", }, ), version: z.number().int().positive(), diff --git a/src/domain/definitions/definition_test.ts b/src/domain/definitions/definition_test.ts index 4399ffec..670b69a7 100644 --- a/src/domain/definitions/definition_test.ts +++ b/src/domain/definitions/definition_test.ts @@ -140,6 +140,33 @@ Deno.test("Definition.create throws on name with null bytes", () => { ); }); +// Scoped @collective/name tests + +Deno.test("Definition.create accepts scoped @collective/name", () => { + const def = Definition.create({ name: "@john/pod-inventory" }); + assertEquals(def.name, "@john/pod-inventory"); +}); + +Deno.test("Definition.create accepts scoped name with multiple segments", () => { + const def = Definition.create({ name: "@swamp/aws/ec2" }); + assertEquals(def.name, "@swamp/aws/ec2"); +}); + +Deno.test("Definition.create rejects malformed scoped names", () => { + assertThrows( + () => Definition.create({ name: "@/" }), + Error, + ); + assertThrows( + () => Definition.create({ name: "@scope/" }), + Error, + ); + assertThrows( + () => Definition.create({ name: "@UPPER/case" }), + Error, + ); +}); + Deno.test("Definition.create throws on invalid version", () => { assertThrows( () => Definition.create({ name: "test", version: 0 }), diff --git a/src/domain/workflows/workflow.ts b/src/domain/workflows/workflow.ts index c9b86005..19bf7aa4 100644 --- a/src/domain/workflows/workflow.ts +++ b/src/domain/workflows/workflow.ts @@ -39,12 +39,19 @@ import { export const WorkflowSchema = z.object({ id: z.string().uuid(), name: z.string().min(1).refine( - (name) => - !name.includes("..") && !name.includes("/") && !name.includes("\\") && - !name.includes("\0"), + (name) => { + if (name.includes("..") || name.includes("\\") || name.includes("\0")) { + return false; + } + if (name.includes("/")) { + // Allow '/' only in scoped @collective/name extension names + return /^@[a-z0-9_-]+\/[a-z0-9_-]+(\/[a-z0-9_-]+)*$/.test(name); + } + return true; + }, { message: - "Workflow name must not contain '..', '/', '\\', or null bytes (path traversal)", + "Workflow name must not contain '..', '\\', or null bytes (path traversal). '/' is only allowed in scoped @collective/name patterns.", }, ), description: z.string().optional(), diff --git a/src/domain/workflows/workflow_test.ts b/src/domain/workflows/workflow_test.ts index 2e8a374a..95c55013 100644 --- a/src/domain/workflows/workflow_test.ts +++ b/src/domain/workflows/workflow_test.ts @@ -439,6 +439,33 @@ Deno.test("Workflow.create rejects path traversal even without jobs", () => { ); }); +// Scoped @collective/name tests + +Deno.test("Workflow.create accepts scoped @collective/name", () => { + const workflow = Workflow.create({ name: "@john/pod-inventory" }); + assertEquals(workflow.name, "@john/pod-inventory"); +}); + +Deno.test("Workflow.create accepts scoped name with multiple segments", () => { + const workflow = Workflow.create({ name: "@swamp/aws/ec2" }); + assertEquals(workflow.name, "@swamp/aws/ec2"); +}); + +Deno.test("Workflow.create rejects malformed scoped names", () => { + assertThrows( + () => Workflow.create({ name: "@/" }), + Error, + ); + assertThrows( + () => Workflow.create({ name: "@scope/" }), + Error, + ); + assertThrows( + () => Workflow.create({ name: "@UPPER/case" }), + Error, + ); +}); + // Driver field tests Deno.test("Workflow.create defaults driver to undefined", () => {