@adea-ai/plugins 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +74 -0
  3. package/dist/agent-plugins-catalog.d.ts +26 -0
  4. package/dist/agent-plugins-catalog.js +699 -0
  5. package/dist/agent-plugins-catalog.js.map +1 -0
  6. package/dist/agent-plugins-harness.d.ts +54 -0
  7. package/dist/agent-plugins-harness.js +212 -0
  8. package/dist/agent-plugins-harness.js.map +1 -0
  9. package/dist/agent-plugins-schema.d.ts +402 -0
  10. package/dist/agent-plugins-schema.js +200 -0
  11. package/dist/agent-plugins-schema.js.map +1 -0
  12. package/dist/consumer-index.d.ts +217 -0
  13. package/dist/consumer-index.js +358 -0
  14. package/dist/consumer-index.js.map +1 -0
  15. package/dist/display-name.d.ts +8 -0
  16. package/dist/display-name.js +160 -0
  17. package/dist/display-name.js.map +1 -0
  18. package/dist/harness.d.ts +13 -0
  19. package/dist/harness.js +169 -0
  20. package/dist/harness.js.map +1 -0
  21. package/dist/icon-mirror.d.ts +69 -0
  22. package/dist/icon-mirror.js +147 -0
  23. package/dist/icon-mirror.js.map +1 -0
  24. package/dist/icons.d.ts +65 -0
  25. package/dist/icons.js +268 -0
  26. package/dist/icons.js.map +1 -0
  27. package/dist/index.d.ts +304 -0
  28. package/dist/index.js +1877 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/portable-catalog.d.ts +9 -0
  31. package/dist/portable-catalog.js +175 -0
  32. package/dist/portable-catalog.js.map +1 -0
  33. package/dist/publication.d.ts +98 -0
  34. package/dist/publication.js +121 -0
  35. package/dist/publication.js.map +1 -0
  36. package/dist/schema.d.ts +705 -0
  37. package/dist/schema.js +267 -0
  38. package/dist/schema.js.map +1 -0
  39. package/dist/site-icons.d.ts +55 -0
  40. package/dist/site-icons.js +155 -0
  41. package/dist/site-icons.js.map +1 -0
  42. package/dist/sources.d.ts +54 -0
  43. package/dist/sources.js +306 -0
  44. package/dist/sources.js.map +1 -0
  45. package/package.json +102 -0
  46. package/schemas/agent-package.v1.json +387 -0
  47. package/schemas/catalog.v1.schema.json +407 -0
  48. package/schemas/compatibility.v1.schema.json +23 -0
  49. package/schemas/harness-profile.v1.json +75 -0
  50. package/schemas/installation-plan.v2.json +695 -0
  51. package/schemas/sources-lock.v1.schema.json +61 -0
  52. package/src/agent-plugins-catalog.test.ts +402 -0
  53. package/src/agent-plugins-catalog.ts +800 -0
  54. package/src/agent-plugins-harness.test.ts +272 -0
  55. package/src/agent-plugins-harness.ts +281 -0
  56. package/src/agent-plugins-schema.test.ts +27 -0
  57. package/src/agent-plugins-schema.ts +219 -0
  58. package/src/artifacts.test.ts +545 -0
  59. package/src/catalog-installation.test.ts +42 -0
  60. package/src/consumer-index.test.ts +465 -0
  61. package/src/consumer-index.ts +592 -0
  62. package/src/display-name.test.ts +56 -0
  63. package/src/display-name.ts +157 -0
  64. package/src/harness.ts +202 -0
  65. package/src/icon-mirror.ts +196 -0
  66. package/src/icons.test.ts +185 -0
  67. package/src/icons.ts +297 -0
  68. package/src/index.test.ts +888 -0
  69. package/src/index.ts +2538 -0
  70. package/src/portable-catalog.test.ts +186 -0
  71. package/src/portable-catalog.ts +203 -0
  72. package/src/publication.test.ts +117 -0
  73. package/src/publication.ts +171 -0
  74. package/src/schema.ts +309 -0
  75. package/src/site-icon-fallback.test.ts +267 -0
  76. package/src/site-icons.test.ts +171 -0
  77. package/src/site-icons.ts +161 -0
  78. package/src/sources.ts +360 -0
  79. package/src/upstream-headers.test.ts +148 -0
@@ -0,0 +1,185 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ iconAssetName,
4
+ iconOverrideFor,
5
+ inspectIconBytes,
6
+ MAX_ICON_BYTES,
7
+ parseProductIconOverrides,
8
+ selectIconPath,
9
+ sniffContentType,
10
+ } from './icons.js'
11
+
12
+ const PNG = new Uint8Array([
13
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
14
+ ])
15
+ const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46])
16
+ const GIF = new Uint8Array([...'GIF89a'].map((character) => character.charCodeAt(0)))
17
+ const WEBP = new Uint8Array([
18
+ ...'RIFF'.split('').map((c) => c.charCodeAt(0)),
19
+ 0,
20
+ 0,
21
+ 0,
22
+ 0,
23
+ ...'WEBP'.split('').map((c) => c.charCodeAt(0)),
24
+ ])
25
+ const encode = (text: string): Uint8Array => new TextEncoder().encode(text)
26
+ const CLEAN_SVG =
27
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><rect width="8" height="8"/></svg>'
28
+
29
+ describe('icon file selection', () => {
30
+ test('prefers the conventional mark in a conventional location', () => {
31
+ expect(selectIconPath(['assets/logo.png', 'assets/icon.svg'])).toBe('assets/icon.svg')
32
+ expect(selectIconPath(['assets/logo.png', 'docs/media/logo.png'])).toBe('assets/logo.png')
33
+ expect(selectIconPath(['images/logo.png', 'assets/logo.png'])).toBe('assets/logo.png')
34
+ })
35
+
36
+ test('prefers a scalable variant and the larger size of the same stem', () => {
37
+ expect(selectIconPath(['assets/icon.png', 'assets/icon.svg'])).toBe('assets/icon.svg')
38
+ expect(selectIconPath(['assets/icon-small.png', 'assets/icon-large.png'])).toBe(
39
+ 'assets/icon-large.png'
40
+ )
41
+ })
42
+
43
+ test('accepts a vendor-named mark inside an asset directory', () => {
44
+ expect(selectIconPath(['codex-plugin/assets/atlan-logo.png'])).toBe(
45
+ 'codex-plugin/assets/atlan-logo.png'
46
+ )
47
+ expect(selectIconPath(['assets/databricks.svg'])).toBe('assets/databricks.svg')
48
+ })
49
+
50
+ test('never mistakes documentation or repository infrastructure for a mark', () => {
51
+ expect(selectIconPath(['assets/screenshot-1.png', 'docs/preview.png'])).toBeNull()
52
+ expect(selectIconPath(['.github/img/cpu.svg'])).toBeNull()
53
+ expect(selectIconPath(['docs/architecture.svg'])).toBeNull()
54
+ expect(selectIconPath(['assets/banner.png'])).toBeNull()
55
+ })
56
+
57
+ test('ignores unsupported files and escapes, and is order independent', () => {
58
+ expect(selectIconPath(['assets/logo.txt', 'assets/logo.svg'])).toBe('assets/logo.svg')
59
+ expect(selectIconPath(['../assets/logo.svg'])).toBeNull()
60
+ expect(selectIconPath([])).toBeNull()
61
+ // An unnamed image at the plugin root is as likely a screenshot as a mark.
62
+ expect(selectIconPath(['screenshot.png', 'readme.md'])).toBeNull()
63
+ expect(selectIconPath(['assets/logo.png', 'assets/logo.svg'])).toBe(
64
+ selectIconPath(['assets/logo.svg', 'assets/logo.png'])
65
+ )
66
+ })
67
+
68
+ test('keeps a bare mark at the plugin root when it is named like a mark', () => {
69
+ expect(selectIconPath(['logo.svg'])).toBe('logo.svg')
70
+ expect(selectIconPath(['icon.png', 'readme.png'])).toBe('icon.png')
71
+ })
72
+ })
73
+
74
+ describe('icon byte inspection', () => {
75
+ test('sniffs supported raster types from magic bytes', () => {
76
+ expect(sniffContentType(PNG)).toBe('image/png')
77
+ expect(sniffContentType(JPEG)).toBe('image/jpeg')
78
+ expect(sniffContentType(GIF)).toBe('image/gif')
79
+ expect(sniffContentType(WEBP)).toBe('image/webp')
80
+ expect(sniffContentType(encode(`<?xml version="1.0"?>${CLEAN_SVG}`))).toBe('image/svg+xml')
81
+ })
82
+
83
+ test('accepts a clean mark and reports its content type', () => {
84
+ expect(inspectIconBytes(PNG)).toEqual({ contentType: 'image/png' })
85
+ expect(inspectIconBytes(encode(CLEAN_SVG))).toEqual({ contentType: 'image/svg+xml' })
86
+ })
87
+
88
+ test('accepts an exporter prologue and leading comments before the root element', () => {
89
+ const illustrator = `<?xml version="1.0" encoding="utf-8"?>
90
+ <!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 -->
91
+ <!-- Second comment -->
92
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg"><rect width="8" height="8"/></svg>`
93
+ expect(sniffContentType(encode(illustrator))).toBe('image/svg+xml')
94
+ expect(inspectIconBytes(encode(illustrator))).toEqual({ contentType: 'image/svg+xml' })
95
+ // A document whose root element is not `<svg>` is still not an icon.
96
+ const html = `<!DOCTYPE html><html><body><svg></svg></body></html>`
97
+ expect(sniffContentType(encode(html))).toBeNull()
98
+ const declared = `<svg-not xmlns="http://www.w3.org/2000/svg"></svg-not>`
99
+ expect(sniffContentType(encode(declared))).toBeNull()
100
+ })
101
+
102
+ test('rejects empty, oversized and unsupported payloads', () => {
103
+ expect(inspectIconBytes(new Uint8Array())).toEqual({ rejected: 'EMPTY_FILE' })
104
+ expect(inspectIconBytes(new Uint8Array(MAX_ICON_BYTES + 1))).toEqual({
105
+ rejected: 'SIZE_LIMIT_EXCEEDED',
106
+ })
107
+ expect(inspectIconBytes(encode('#!/bin/sh\nrm -rf /'))).toEqual({
108
+ rejected: 'CONTENT_TYPE_UNSUPPORTED',
109
+ })
110
+ })
111
+
112
+ test('rejects SVG that could execute or reach the network', () => {
113
+ const cases = [
114
+ `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`,
115
+ `<svg xmlns="http://www.w3.org/2000/svg"><foreignObject/></svg>`,
116
+ `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>`,
117
+ `<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/x.png"/></svg>`,
118
+ `<svg xmlns="http://www.w3.org/2000/svg"><a xlink:href="//evil.example">x</a></svg>`,
119
+ `<svg xmlns="http://www.w3.org/2000/svg"><use href="//evil.example#x"/></svg>`,
120
+ `<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><svg>&xxe;</svg>`,
121
+ ]
122
+ for (const source of cases)
123
+ expect(inspectIconBytes(encode(source)), source.slice(0, 40)).toEqual({
124
+ rejected: expect.stringMatching(/^SVG_(ACTIVE_CONTENT|EXTERNAL_REFERENCE)$/) as never,
125
+ })
126
+ })
127
+ })
128
+
129
+ describe('icon asset names', () => {
130
+ test('is content addressed and keeps a usable extension', () => {
131
+ const name = iconAssetName(`sha256:${'a'.repeat(64)}`, 'image/png')
132
+ expect(name).toBe(`icon-${'a'.repeat(32)}.png`)
133
+ expect(iconAssetName(`sha256:${'b'.repeat(64)}`, 'image/svg+xml')).toBe(
134
+ `icon-${'b'.repeat(32)}.svg`
135
+ )
136
+ // Identical bytes always resolve to the same asset, so caches stay warm.
137
+ expect(iconAssetName(`sha256:${'c'.repeat(64)}`, 'image/webp')).toBe(
138
+ iconAssetName(`sha256:${'c'.repeat(64)}`, 'image/webp')
139
+ )
140
+ })
141
+
142
+ test('refuses a content type it cannot name', () => {
143
+ expect(() => iconAssetName(`sha256:${'d'.repeat(64)}`, 'text/html' as never)).toThrow(
144
+ 'ICON_CONTENT_TYPE_UNSUPPORTED'
145
+ )
146
+ })
147
+ })
148
+
149
+ describe('brand mark overrides', () => {
150
+ test('accepts a forced path, an explicit opt-out and a vendor site', () => {
151
+ const parsed = parseProductIconOverrides({
152
+ schemaVersion: 1,
153
+ overrides: {
154
+ github: 'assets/logo.svg',
155
+ legacy: null,
156
+ servicenow: 'https://www.servicenow.com/',
157
+ },
158
+ })
159
+ expect(iconOverrideFor(parsed, 'github')).toEqual({ forced: 'assets/logo.svg' })
160
+ expect(iconOverrideFor(parsed, 'legacy')).toEqual({ forced: null })
161
+ expect(iconOverrideFor(parsed, 'servicenow')).toEqual({ site: 'https://www.servicenow.com/' })
162
+ expect(iconOverrideFor(parsed, 'unlisted')).toBeUndefined()
163
+ expect(iconOverrideFor(undefined, 'github')).toBeUndefined()
164
+ })
165
+
166
+ test('rejects malformed configuration', () => {
167
+ expect(() => parseProductIconOverrides(null)).toThrow('PRODUCT_ICONS_INVALID')
168
+ expect(() => parseProductIconOverrides({ schemaVersion: 2, overrides: {} })).toThrow(
169
+ 'PRODUCT_ICONS_INVALID'
170
+ )
171
+ expect(() => parseProductIconOverrides({ schemaVersion: 1 })).toThrow('PRODUCT_ICONS_INVALID')
172
+ expect(() =>
173
+ parseProductIconOverrides({ schemaVersion: 1, overrides: { Github: null } })
174
+ ).toThrow('PRODUCT_ICONS_KEY_INVALID')
175
+ expect(() =>
176
+ parseProductIconOverrides({ schemaVersion: 1, overrides: { github: '/etc/passwd' } })
177
+ ).toThrow('PRODUCT_ICONS_PATH_INVALID')
178
+ expect(() =>
179
+ parseProductIconOverrides({ schemaVersion: 1, overrides: { github: '../../secret.png' } })
180
+ ).toThrow('PRODUCT_ICONS_PATH_INVALID')
181
+ expect(() =>
182
+ parseProductIconOverrides({ schemaVersion: 1, overrides: { github: 'https://localhost/' } })
183
+ ).toThrow('PRODUCT_ICONS_SITE_UNSUPPORTED')
184
+ })
185
+ })
package/src/icons.ts ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Compile-time icon resolution.
3
+ *
4
+ * Marketplaces do not declare an icon field, so a plugin's mark is a file in
5
+ * its own content (`assets/logo.svg`, `assets/icon-large.png`, ...). Resolving
6
+ * one at compile time removes a runtime guess from consumers, and mirroring the
7
+ * bytes at publish time removes the upstream fetch entirely.
8
+ */
9
+
10
+ /** Why a candidate file was rejected as an icon. */
11
+ export type IconRejection =
12
+ | 'CONTENT_TYPE_UNSUPPORTED'
13
+ | 'EMPTY_FILE'
14
+ | 'SIZE_LIMIT_EXCEEDED'
15
+ | 'SVG_ACTIVE_CONTENT'
16
+ | 'SVG_EXTERNAL_REFERENCE'
17
+
18
+ /** Content types the catalog will publish as a brand mark. */
19
+ export type IconContentType =
20
+ | 'image/png'
21
+ | 'image/jpeg'
22
+ | 'image/gif'
23
+ | 'image/webp'
24
+ | 'image/svg+xml'
25
+ | 'image/x-icon'
26
+
27
+ /** Icon file size ceiling; a brand mark is never a large asset. */
28
+ export const MAX_ICON_BYTES = 1024 * 1024
29
+
30
+ const SUPPORTED_EXTENSIONS = ['svg', 'png', 'webp', 'jpg', 'jpeg', 'gif', 'ico'] as const
31
+
32
+ /** Ranked basenames; earlier entries win. Variants sort by the rank of the stem. */
33
+ const STEM_RANK: Readonly<Record<string, number>> = {
34
+ icon: 0,
35
+ appicon: 0,
36
+ 'app-icon': 0,
37
+ avatar: 2,
38
+ logo: 3,
39
+ mark: 4,
40
+ brand: 4,
41
+ }
42
+ const EXTENSION_RANK: Readonly<Record<string, number>> = {
43
+ svg: 0,
44
+ png: 1,
45
+ webp: 2,
46
+ ico: 3,
47
+ jpg: 4,
48
+ jpeg: 5,
49
+ gif: 6,
50
+ }
51
+ /** Preferred containers, best first. Files outside these are considered last. */
52
+ const DIRECTORY_RANK: Readonly<Record<string, number>> = {
53
+ assets: 0,
54
+ '.assets': 1,
55
+ img: 2,
56
+ images: 3,
57
+ media: 4,
58
+ static: 5,
59
+ }
60
+ /** Files that are documentation, screenshots or brand collateral, not marks. */
61
+ const EXCLUDED =
62
+ /(screenshot|screen-shot|preview|example|sample|demo|mockup|diagram|architecture|flowchart|flow-|banner|hero|cover|thumbnail|chart|graph|illustration|photo)/
63
+ /** Repository infrastructure: CI images and templates, never a product mark. */
64
+ const EXCLUDED_DIRECTORIES = ['.github', '.gitlab']
65
+
66
+ const MARK_WORDS = new Set([
67
+ 'icon',
68
+ 'appicon',
69
+ 'avatar',
70
+ 'logo',
71
+ 'mark',
72
+ 'brand',
73
+ 'logomark',
74
+ 'wordmark',
75
+ ])
76
+ const VARIANT_WORDS = new Set([
77
+ 'large',
78
+ 'small',
79
+ 'light',
80
+ 'dark',
81
+ 'mono',
82
+ 'white',
83
+ 'black',
84
+ 'positive',
85
+ 'negative',
86
+ 'color',
87
+ 'colour',
88
+ 'primary',
89
+ 'secondary',
90
+ 'horizontal',
91
+ 'vertical',
92
+ 'square',
93
+ 'flat',
94
+ 'glyph',
95
+ ])
96
+
97
+ /**
98
+ * Splits a file name into mark tokens. Separators are normalised because
99
+ * vendors write marks as `logo.svg`, `atlan-logo.png`, `Dynatrace_Logo_color_positive.png`
100
+ * or `cs logo big.png` alike.
101
+ */
102
+ function splitName(basename: string): { stems: string[]; marks: boolean; size: number } {
103
+ const withoutExtension = basename.slice(0, basename.lastIndexOf('.'))
104
+ const words = withoutExtension
105
+ .split(/[\s._-]+/)
106
+ .filter(Boolean)
107
+ .map((word) => word.toLowerCase())
108
+ const size = words.some((word) => word === 'small') ? 1 : 0
109
+ const marks = words.some((word) => MARK_WORDS.has(word))
110
+ const stems = words.filter((word) => !MARK_WORDS.has(word) && !VARIANT_WORDS.has(word))
111
+ return { stems, marks, size }
112
+ }
113
+
114
+ /**
115
+ * Chooses one icon file from a release's file index, deterministically.
116
+ *
117
+ * Preference: `assets/icon.svg` before `assets/icon.png` before `assets/logo.*`,
118
+ * an `icon`/`logo` stem before `avatar`/`mark`/`brand`, an `assets` directory
119
+ * before other asset directories, SVG before raster for the same stem, and a
120
+ * `-large` variant before `-small`. Anything that looks like documentation or a
121
+ * screenshot is never selected. Ties break on path so the result is stable.
122
+ */
123
+ export function selectIconPath(fileIndex: readonly string[]): string | null {
124
+ let best: { path: string; score: readonly number[] } | null = null
125
+ for (const raw of fileIndex) {
126
+ const path = raw.replace(/^\.?\//, '')
127
+ if (path.includes('..')) continue
128
+ const segments = path.split('/')
129
+ const basename = segments.pop() ?? ''
130
+ const extension = basename.slice(basename.lastIndexOf('.') + 1).toLowerCase()
131
+ if (!(SUPPORTED_EXTENSIONS as readonly string[]).includes(extension)) continue
132
+ if (EXCLUDED.test(basename)) continue
133
+ const directories = segments.map((segment) => segment.toLowerCase())
134
+ if (directories.some((segment) => EXCLUDED.test(segment))) continue
135
+ if (directories.some((segment) => EXCLUDED_DIRECTORIES.includes(segment))) continue
136
+ const { stems, marks, size } = splitName(basename)
137
+ const single = stems.length === 1 ? stems[0]! : undefined
138
+ const stemRank = marks ? (single ? (STEM_RANK[single] ?? 0) : 0) : undefined
139
+ const nested = directories.filter((segment) => DIRECTORY_RANK[segment] !== undefined)
140
+ // A mark named as one — `logo.svg`, `atlan-logo.png`, `cs_logo_large.png` —
141
+ // is accepted anywhere, including the plugin root. An unnamed image must sit
142
+ // in a known asset directory, because a bare `banner.png` at the root is as
143
+ // likely to be documentation as a brand mark.
144
+ const directoryRank =
145
+ nested.length > 0
146
+ ? (DIRECTORY_RANK[nested[nested.length - 1]!] ?? 0)
147
+ : stemRank === undefined
148
+ ? Number.POSITIVE_INFINITY
149
+ : 0
150
+ if (!Number.isFinite(directoryRank)) continue
151
+ const score = [stemRank ?? 5, directoryRank, size, EXTENSION_RANK[extension] ?? 9, path.length]
152
+ if (!best || compareScore(score, best.score) < 0) best = { path, score }
153
+ }
154
+ return best ? best.path : null
155
+ }
156
+
157
+ function compareScore(left: readonly number[], right: readonly number[]): number {
158
+ for (let index = 0; index < left.length; index += 1) {
159
+ const difference = (left[index] ?? 0) - (right[index] ?? 0)
160
+ if (difference !== 0) return difference
161
+ }
162
+ return 0
163
+ }
164
+
165
+ const SVG_ACTIVE_CONTENT =
166
+ /<\s*script|<\s*foreignObject|<\s*iframe|<\s*use[^>]+href\s*=\s*["']?\s*(?:https?:)?\/\/|javascript:|<!ENTITY|<!DOCTYPE[^>]+\[|on(?:load|error|click|mouseover|focus|begin)\s*=/i
167
+ const SVG_EXTERNAL_REFERENCE = /(?:href|xlink:href|src|data|poster)\s*=\s*["']?\s*(?:https?:)?\/\//i
168
+
169
+ /** Sniffs a supported image type from magic bytes; SVG is detected as text. */
170
+ export function sniffContentType(bytes: Uint8Array): IconContentType | null {
171
+ const ascii = (offset: number, length: number): string =>
172
+ String.fromCharCode(...bytes.subarray(offset, offset + length))
173
+ if (bytes.length >= 8 && ascii(0, 8) === '\x89PNG\r\n\x1a\n') return 'image/png'
174
+ if (bytes.length >= 3 && ascii(0, 3) === '\xff\xd8\xff') return 'image/jpeg'
175
+ if (bytes.length >= 6 && ascii(0, 4) === 'GIF8') return 'image/gif'
176
+ if (bytes.length >= 12 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') return 'image/webp'
177
+ // Icon container: reserved(2), type 1 (icon) or 2 (cursor), count >= 1.
178
+ if (
179
+ bytes.length >= 6 &&
180
+ bytes[0] === 0 &&
181
+ bytes[1] === 0 &&
182
+ (bytes[2] === 1 || bytes[2] === 2) &&
183
+ bytes[3] === 0 &&
184
+ ((bytes[4] ?? 0) | ((bytes[5] ?? 0) << 8)) > 0
185
+ )
186
+ return 'image/x-icon'
187
+ if (bytes.length > 0) {
188
+ // An XML prologue, a doctype and leading comments may all precede the root
189
+ // element: exporters such as Adobe Illustrator and Figma emit them. What
190
+ // matters is that the document's root element is `<svg>`, not `<html>`.
191
+ const head = new TextDecoder()
192
+ .decode(bytes.subarray(0, Math.min(bytes.length, 4096)))
193
+ .trimStart()
194
+ .replace(/^<\?xml[\s\S]*?\?>/i, '')
195
+ .replace(/^<!DOCTYPE[^>]*?(?:\[[\s\S]*?\])?>/i, '')
196
+ .replace(/^(?:\s*<!--[\s\S]*?-->)+/, '')
197
+ .trimStart()
198
+ if (/^<svg[\s>]/i.test(head)) return 'image/svg+xml'
199
+ }
200
+ return null
201
+ }
202
+
203
+ /**
204
+ * Verifies icon bytes before they are published.
205
+ *
206
+ * SVG is text that a browser can execute when it is inlined, so active content
207
+ * and external references are rejected outright. Consumers must still render a
208
+ * mirrored SVG through an `<img>` element and never inline it into the DOM.
209
+ */
210
+ export function inspectIconBytes(
211
+ bytes: Uint8Array
212
+ ): { contentType: IconContentType } | { rejected: IconRejection } {
213
+ if (bytes.length === 0) return { rejected: 'EMPTY_FILE' }
214
+ if (bytes.length > MAX_ICON_BYTES) return { rejected: 'SIZE_LIMIT_EXCEEDED' }
215
+ const contentType = sniffContentType(bytes)
216
+ if (!contentType) return { rejected: 'CONTENT_TYPE_UNSUPPORTED' }
217
+ if (contentType === 'image/svg+xml') {
218
+ const text = new TextDecoder().decode(bytes)
219
+ if (SVG_ACTIVE_CONTENT.test(text)) return { rejected: 'SVG_ACTIVE_CONTENT' }
220
+ if (SVG_EXTERNAL_REFERENCE.test(text)) return { rejected: 'SVG_EXTERNAL_REFERENCE' }
221
+ }
222
+ return { contentType }
223
+ }
224
+
225
+ const EXTENSION_BY_CONTENT_TYPE: Readonly<Record<IconContentType, string>> = {
226
+ 'image/png': 'png',
227
+ 'image/jpeg': 'jpg',
228
+ 'image/gif': 'gif',
229
+ 'image/webp': 'webp',
230
+ 'image/svg+xml': 'svg',
231
+ 'image/x-icon': 'ico',
232
+ }
233
+
234
+ /**
235
+ * Content-addressed asset name. Release assets cannot contain `/`, so the
236
+ * digest prefix and extension form one flat name that any release can host.
237
+ */
238
+ export function iconAssetName(digest: string, contentType: IconContentType): string {
239
+ const extension = EXTENSION_BY_CONTENT_TYPE[contentType]
240
+ if (!extension) throw new Error(`ICON_CONTENT_TYPE_UNSUPPORTED: ${contentType}`)
241
+ const hex = digest.startsWith('sha256:') ? digest.slice('sha256:'.length) : digest
242
+ return `icon-${hex.slice(0, 32)}.${extension}`
243
+ }
244
+
245
+ /**
246
+ * Curation override for a product's brand mark, keyed by product key.
247
+ *
248
+ * `null` publishes no mark; a path forces a file inside the plugin content; an
249
+ * `https://` value names the vendor site to take the mark from. The override
250
+ * exists because the selection rule is a heuristic over vendor filenames: many
251
+ * products ship no conventional mark and declare nothing but a repository, and
252
+ * a curator may know the real brand source.
253
+ */
254
+ export interface ProductIconOverrides {
255
+ readonly schemaVersion: 1
256
+ readonly overrides: Readonly<Record<string, string | null>>
257
+ }
258
+
259
+ export function parseProductIconOverrides(input: unknown): ProductIconOverrides {
260
+ if (typeof input !== 'object' || input === null) throw new Error('PRODUCT_ICONS_INVALID')
261
+ const raw = input as Partial<ProductIconOverrides>
262
+ if (raw.schemaVersion !== 1) throw new Error('PRODUCT_ICONS_INVALID')
263
+ const overrides = raw.overrides
264
+ if (typeof overrides !== 'object' || overrides === null) throw new Error('PRODUCT_ICONS_INVALID')
265
+ for (const [productKey, value] of Object.entries(overrides)) {
266
+ if (!/^[a-z0-9][a-z0-9-]{1,127}$/.test(productKey))
267
+ throw new Error(`PRODUCT_ICONS_KEY_INVALID: ${productKey}`)
268
+ if (value === null) continue
269
+ if (typeof value !== 'string' || value.length === 0 || value.length > 1024)
270
+ throw new Error(`PRODUCT_ICONS_PATH_INVALID: ${productKey}`)
271
+ if (value.startsWith('https://')) {
272
+ // Only the shape is checked here; site-icons applies the full host policy
273
+ // when it resolves the mark, so a bad host simply yields no icon.
274
+ if (!/^https:\/\/[a-z0-9.-]+\.[a-z]{2,}/i.test(value))
275
+ throw new Error(`PRODUCT_ICONS_SITE_UNSUPPORTED: ${productKey}`)
276
+ continue
277
+ }
278
+ if (value.startsWith('/') || value.includes('..'))
279
+ throw new Error(`PRODUCT_ICONS_PATH_INVALID: ${productKey}`)
280
+ }
281
+ return { schemaVersion: 1, overrides: { ...overrides } }
282
+ }
283
+
284
+ /**
285
+ * Reads the override for a product: `forced` names a content path or `null` to
286
+ * publish no mark, and `site` names a vendor site to take the mark from.
287
+ */
288
+ export function iconOverrideFor(
289
+ overrides: ProductIconOverrides | undefined,
290
+ productKey: string
291
+ ): { readonly forced?: string | null; readonly site?: string } | undefined {
292
+ if (!overrides || !(productKey in overrides.overrides)) return undefined
293
+ const value = overrides.overrides[productKey]
294
+ if (value === null) return { forced: null }
295
+ if (typeof value !== 'string') return undefined
296
+ return value.startsWith('https://') ? { site: value } : { forced: value }
297
+ }