diff --git a/PROVIDER_PACKS.md b/PROVIDER_PACKS.md index cf7b6ac..6564cd9 100644 --- a/PROVIDER_PACKS.md +++ b/PROVIDER_PACKS.md @@ -24,9 +24,9 @@ The schema records `archiveUrl` as provenance. It is not permission for a runtim An icon ID has the form `:`, such as `aws:s3`, `gcp:cloud-run`, or `azure:storage-accounts`. The prefix must equal `provider.id`. Each icon preserves a stable subject, official product name, recommended Stack node kind, upstream archive path, processed local SVG path, integer view box, original and processed SHA-256 hashes, and an ordered transformation log. -The importer may perform only visual-preservation transformations needed for safe standalone SVG, such as removing metadata, converting stylesheet declarations to equivalent presentation attributes, removing unused identifiers, or normalizing XML. Recoloring, cropping, flipping, rotation, distortion, product substitution, or aspect-ratio changes are outside the contract. +The importer may perform only visual-preservation transformations needed for safe standalone SVG, such as removing metadata, converting stylesheet declarations to equivalent presentation attributes, removing unused identifiers, namespacing referenced gradient identifiers to prevent collisions, or normalizing XML. Recoloring, cropping, flipping, rotation, distortion, product substitution, or aspect-ratio changes are outside the contract. -An empty transformation list requires identical original and processed hashes. A changed hash requires at least one declared transformation. The processed SVG must pass the same script, event-handler, external-reference, executable URL, and viewport safety checks as core assets. +An empty transformation list requires identical original and processed hashes. A changed hash requires at least one declared transformation. The processed SVG must pass the same script, event-handler, external-reference, executable URL, and viewport safety checks as core assets. Gradients may use only locally declared, `stack-`-namespaced identifiers; stylesheets and external references remain forbidden. ## Terms and output diff --git a/crates/stack-theme/schema/provider-pack.schema.json b/crates/stack-theme/schema/provider-pack.schema.json index 5e19271..85659ed 100644 --- a/crates/stack-theme/schema/provider-pack.schema.json +++ b/crates/stack-theme/schema/provider-pack.schema.json @@ -232,6 +232,7 @@ "remove-metadata", "inline-styles", "remove-unused-identifiers", + "namespace-identifiers", "normalize-xml" ] }, diff --git a/crates/stack-theme/src/lib.rs b/crates/stack-theme/src/lib.rs index 95b2fd4..3a406df 100644 --- a/crates/stack-theme/src/lib.rs +++ b/crates/stack-theme/src/lib.rs @@ -445,6 +445,7 @@ pub enum ProviderPackTransformation { RemoveMetadata, InlineStyles, RemoveUnusedIdentifiers, + NamespaceIdentifiers, NormalizeXml, } diff --git a/packages/theme/catalog.generated.js b/packages/theme/catalog.generated.js index 2b680d6..84e3e48 100644 --- a/packages/theme/catalog.generated.js +++ b/packages/theme/catalog.generated.js @@ -2600,6 +2600,7 @@ const providerPackSchemaData = { "remove-metadata", "inline-styles", "remove-unused-identifiers", + "namespace-identifiers", "normalize-xml" ] }, diff --git a/packages/theme/index.d.ts b/packages/theme/index.d.ts index e82eb62..a5e1ac7 100644 --- a/packages/theme/index.d.ts +++ b/packages/theme/index.d.ts @@ -162,6 +162,7 @@ export type ProviderPackTransformation = | "remove-metadata" | "inline-styles" | "remove-unused-identifiers" + | "namespace-identifiers" | "normalize-xml"; export interface ProviderPack { diff --git a/packages/theme/schema/provider-pack.schema.json b/packages/theme/schema/provider-pack.schema.json index 5e19271..85659ed 100644 --- a/packages/theme/schema/provider-pack.schema.json +++ b/packages/theme/schema/provider-pack.schema.json @@ -232,6 +232,7 @@ "remove-metadata", "inline-styles", "remove-unused-identifiers", + "namespace-identifiers", "normalize-xml" ] }, diff --git a/schemas/provider-pack.schema.json b/schemas/provider-pack.schema.json index 5e19271..85659ed 100644 --- a/schemas/provider-pack.schema.json +++ b/schemas/provider-pack.schema.json @@ -232,6 +232,7 @@ "remove-metadata", "inline-styles", "remove-unused-identifiers", + "namespace-identifiers", "normalize-xml" ] }, diff --git a/scripts/catalog-lib.mjs b/scripts/catalog-lib.mjs index 080b3ce..edbb386 100644 --- a/scripts/catalog-lib.mjs +++ b/scripts/catalog-lib.mjs @@ -14,17 +14,23 @@ export const repositoryRoot = path.resolve( const allowedElements = new Set([ "circle", "desc", + "defs", "ellipse", "g", "line", + "linearGradient", "path", "polygon", "polyline", + "radialGradient", "rect", + "stop", "svg", "title", ]); +const gradientElements = new Set(["linearGradient", "radialGradient"]); + const allowedAttributes = new Set([ "aria-hidden", "clip-rule", @@ -34,7 +40,9 @@ const allowedAttributes = new Set([ "fill", "fill-rule", "height", + "id", "opacity", + "offset", "points", "r", "role", @@ -44,7 +52,11 @@ const allowedAttributes = new Set([ "stroke-linecap", "stroke-linejoin", "stroke-width", + "stop-color", + "stop-opacity", "transform", + "gradientTransform", + "gradientUnits", "viewBox", "width", "x", @@ -106,7 +118,14 @@ async function requireFile(filePath, label) { } } -function validateAttribute(element, name, value, assetPath) { +function validateAttribute( + element, + name, + value, + assetPath, + declaredIdentifiers, + referencedIdentifiers, +) { if (/^on/i.test(name)) { fail(`${assetPath}: event handler attribute ${name} is forbidden`); } @@ -122,6 +141,27 @@ function validateAttribute(element, name, value, assetPath) { } return; } + if (name === "id") { + if (!gradientElements.has(element)) { + fail(`${assetPath}: only gradient elements may declare identifiers`); + } + if (!/^stack-[a-z0-9][a-z0-9-]{0,126}$/.test(value)) { + fail(`${assetPath}: gradient identifier must use the stack- namespace`); + } + if (declaredIdentifiers.has(value)) { + fail(`${assetPath}: duplicate identifier ${value}`); + } + declaredIdentifiers.add(value); + return; + } + if (/url\s*\(/i.test(value)) { + const localReference = value.match(/^url\(#(stack-[a-z0-9][a-z0-9-]{0,126})\)$/); + if ((name !== "fill" && name !== "stroke") || localReference === null) { + fail(`${assetPath}: external or executable reference in ${name} is forbidden`); + } + referencedIdentifiers.add(localReference[1]); + return; + } if (/url\s*\(|javascript:|data:|https?:\/\/|\/\//i.test(value)) { fail(`${assetPath}: external or executable reference in ${name} is forbidden`); } @@ -134,6 +174,8 @@ export function validateSvgText(svg, assetPath, expectedViewBox) { const parser = new SaxesParser({ xmlns: false }); const elementStack = []; + const declaredIdentifiers = new Set(); + const referencedIdentifiers = new Set(); let rootCount = 0; let rootNamespace; let rootViewBox; @@ -153,8 +195,28 @@ export function validateSvgText(svg, assetPath, expectedViewBox) { } else if (tag.name === "svg") { fail(`${assetPath}: nested svg elements are forbidden`); } + const parent = elementStack.at(-1); + if (tag.name === "defs" && parent !== "svg") { + fail(`${assetPath}: defs must be a direct child of svg`); + } + if (gradientElements.has(tag.name) && parent !== "defs") { + fail(`${assetPath}: gradient elements must be direct children of defs`); + } + if (tag.name === "stop" && !gradientElements.has(parent)) { + fail(`${assetPath}: stop must be a direct child of a gradient`); + } + if (parent === "defs" && !gradientElements.has(tag.name)) { + fail(`${assetPath}: defs may contain only gradients`); + } for (const [name, value] of Object.entries(tag.attributes)) { - validateAttribute(tag.name, name, value, assetPath); + validateAttribute( + tag.name, + name, + value, + assetPath, + declaredIdentifiers, + referencedIdentifiers, + ); } elementStack.push(tag.name); }); @@ -188,6 +250,16 @@ export function validateSvgText(svg, assetPath, expectedViewBox) { if (rootNamespace !== "http://www.w3.org/2000/svg") { fail(`${assetPath}: root must declare the canonical SVG namespace`); } + for (const identifier of referencedIdentifiers) { + if (!declaredIdentifiers.has(identifier)) { + fail(`${assetPath}: local reference ${identifier} is not declared`); + } + } + for (const identifier of declaredIdentifiers) { + if (!referencedIdentifiers.has(identifier)) { + fail(`${assetPath}: identifier ${identifier} is unused`); + } + } const actualViewBox = rootViewBox ?.trim() diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs index 76b11d6..2e0824b 100644 --- a/tests/catalog.test.mjs +++ b/tests/catalog.test.mjs @@ -89,6 +89,38 @@ test("provider asset changes require a transformation record", async () => { ); }); +test("safe namespaced local gradients are accepted", () => { + validateSvgText( + '', + "safe-local-gradient.svg", + [0, 0, 24, 24], + ); +}); + +test("undeclared local gradients are rejected", () => { + assert.throws( + () => + validateSvgText( + '', + "missing-local-gradient.svg", + [0, 0, 24, 24], + ), + /local reference stack-missing is not declared/, + ); +}); + +test("stylesheets remain forbidden in provider SVG", () => { + assert.throws( + () => + validateSvgText( + '', + "stylesheet.svg", + [0, 0, 24, 24], + ), + /element style is not allowed/, + ); +}); + for (const [name, pattern] of [ ["unsafe-script.svg", /element script is not allowed/], ["unsafe-event.svg", /event handler attribute onload is forbidden/],