@gmickel/gno 1.32.0 → 1.33.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.
- package/README.md +11 -2
- package/assets/skill/SKILL.md +11 -0
- package/assets/skill/cli-reference.md +10 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.33.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.33.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +5 -1
- package/spec/cli.md +16 -1
- package/spec/output-schemas/publish-artifact.schema.json +76 -1
- package/src/cli/commands/publish.ts +43 -7
- package/src/ingestion/strip.ts +152 -26
- package/src/publish/artifact-asset-codec.ts +75 -0
- package/src/publish/artifact-asset-contract.ts +152 -0
- package/src/publish/artifact-asset-parse.ts +401 -0
- package/src/publish/artifact-asset-sniff.ts +108 -0
- package/src/publish/artifact-asset-validate.ts +209 -0
- package/src/publish/artifact-assets.ts +58 -0
- package/src/publish/artifact-validation.ts +32 -6
- package/src/publish/artifact.ts +50 -3
- package/src/publish/attachment-bundle.ts +145 -0
- package/src/publish/attachment-discover.ts +203 -0
- package/src/publish/attachment-load.ts +133 -0
- package/src/publish/attachment-obsidian.ts +45 -0
- package/src/publish/attachment-path.ts +334 -0
- package/src/publish/attachment-raster.ts +852 -0
- package/src/publish/attachment-resolver.ts +280 -0
- package/src/publish/attachment-types.ts +54 -0
- package/src/publish/encrypted-export.ts +121 -44
- package/src/publish/export-attachments.ts +224 -0
- package/src/publish/export-service.ts +142 -80
- package/src/publish/obsidian-sanitize.ts +121 -13
- package/src/serve/routes/api.ts +2 -1
- package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared publish-asset contract vocabulary (producer/consumer).
|
|
3
|
+
*
|
|
4
|
+
* @module src/publish/artifact-asset-contract
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const MAX_PUBLISH_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Schema bounds for asset reference sourceRef (publish-artifact.schema.json). */
|
|
10
|
+
export const MIN_SOURCE_REF_LENGTH = 1;
|
|
11
|
+
export const MAX_SOURCE_REF_LENGTH = 1024;
|
|
12
|
+
|
|
13
|
+
export const BUNDLED_RASTER_ASSETS_CAPABILITY =
|
|
14
|
+
"bundled-raster-assets@1" as const;
|
|
15
|
+
|
|
16
|
+
export const KNOWN_PUBLISH_REQUIRED_CAPABILITIES = [
|
|
17
|
+
BUNDLED_RASTER_ASSETS_CAPABILITY,
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
/** Schema bounds for requiredCapabilities entries (publish-artifact.schema.json). */
|
|
21
|
+
export const MIN_REQUIRED_CAPABILITY_LENGTH = 1;
|
|
22
|
+
export const MAX_REQUIRED_CAPABILITY_LENGTH = 128;
|
|
23
|
+
|
|
24
|
+
export type KnownPublishRequiredCapability =
|
|
25
|
+
(typeof KNOWN_PUBLISH_REQUIRED_CAPABILITIES)[number];
|
|
26
|
+
|
|
27
|
+
export const SUPPORTED_RASTER_MEDIA_TYPES = [
|
|
28
|
+
"image/png",
|
|
29
|
+
"image/jpeg",
|
|
30
|
+
"image/gif",
|
|
31
|
+
"image/webp",
|
|
32
|
+
"image/avif",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export type SupportedRasterMediaType =
|
|
36
|
+
(typeof SUPPORTED_RASTER_MEDIA_TYPES)[number];
|
|
37
|
+
|
|
38
|
+
export const MAX_RASTER_DIMENSION_PX = 16_384;
|
|
39
|
+
export const MIN_RASTER_DIMENSION_PX = 1;
|
|
40
|
+
|
|
41
|
+
/** Strict sentinel: scheme + lowercase SHA-256 hex asset id. */
|
|
42
|
+
export const GNO_ASSET_SENTINEL_PATTERN = /^gno-asset:([a-f0-9]{64})$/u;
|
|
43
|
+
export const GNO_ASSET_SENTINEL_PREFIX = "gno-asset:";
|
|
44
|
+
|
|
45
|
+
export const PUBLISH_ASSET_NOTE_SLUG_PATTERN =
|
|
46
|
+
/^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/u;
|
|
47
|
+
|
|
48
|
+
export const PUBLISH_ASSET_VISIBILITY = {
|
|
49
|
+
public: {
|
|
50
|
+
class: "public",
|
|
51
|
+
delivery: "immutable-public-url",
|
|
52
|
+
storage: "private-until-public-commit",
|
|
53
|
+
notes:
|
|
54
|
+
"Validated assets may use immutable public URLs only after the share is classified public.",
|
|
55
|
+
},
|
|
56
|
+
"secret-link": {
|
|
57
|
+
class: "secret",
|
|
58
|
+
delivery: "capability-authorized",
|
|
59
|
+
storage: "private",
|
|
60
|
+
forbids: ["public-object-url", "presigned-url-as-sole-authorization"],
|
|
61
|
+
notes:
|
|
62
|
+
"Secret is an authorization boundary; private storage delivery requires the secret-share capability.",
|
|
63
|
+
},
|
|
64
|
+
encrypted: {
|
|
65
|
+
class: "encrypted",
|
|
66
|
+
delivery: "client-blob-url",
|
|
67
|
+
storage: "none-plaintext",
|
|
68
|
+
assetPlacement: "encrypted-client-payload",
|
|
69
|
+
notes:
|
|
70
|
+
"Plaintext image bytes remain inside the encrypted client payload; Blob URLs are revoked on replacement/unmount.",
|
|
71
|
+
},
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
export const PUBLISH_ASSET_LIFECYCLE_TERMINALS = [
|
|
75
|
+
"committed",
|
|
76
|
+
"rolled_back",
|
|
77
|
+
"deleted",
|
|
78
|
+
"orphan_cleaned",
|
|
79
|
+
"idempotent_noop",
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
export type PublishAssetLifecycleTerminal =
|
|
83
|
+
(typeof PUBLISH_ASSET_LIFECYCLE_TERMINALS)[number];
|
|
84
|
+
|
|
85
|
+
export const PUBLISH_ASSET_DIAGNOSTIC_CODES = [
|
|
86
|
+
"ASSET_MISSING",
|
|
87
|
+
"ASSET_CONFLICT",
|
|
88
|
+
"ASSET_SENTINEL_RAW",
|
|
89
|
+
"ASSET_SENTINEL_UNRESOLVED",
|
|
90
|
+
"ASSET_SENTINEL_INVALID",
|
|
91
|
+
"ASSET_TRAVERSAL",
|
|
92
|
+
"ASSET_MIME_SPOOF",
|
|
93
|
+
"ASSET_OVERSIZE",
|
|
94
|
+
"ASSET_CORRUPT",
|
|
95
|
+
"ASSET_UNSUPPORTED_FORMAT",
|
|
96
|
+
"ASSET_DIMENSION_INVALID",
|
|
97
|
+
"CAPABILITY_UNSUPPORTED",
|
|
98
|
+
"ENVELOPE_OVERSIZE",
|
|
99
|
+
] as const;
|
|
100
|
+
|
|
101
|
+
export type PublishAssetDiagnosticCode =
|
|
102
|
+
(typeof PUBLISH_ASSET_DIAGNOSTIC_CODES)[number];
|
|
103
|
+
|
|
104
|
+
export interface PublishArtifactAssetReference {
|
|
105
|
+
noteSlug: string;
|
|
106
|
+
sourceRef: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface PublishArtifactAsset {
|
|
110
|
+
byteLength: number;
|
|
111
|
+
data: string;
|
|
112
|
+
encoding: "base64";
|
|
113
|
+
height: number;
|
|
114
|
+
id: string;
|
|
115
|
+
mediaType: string;
|
|
116
|
+
references: Array<PublishArtifactAssetReference>;
|
|
117
|
+
sha256: string;
|
|
118
|
+
width: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface PublishAssetDiagnostic {
|
|
122
|
+
code: PublishAssetDiagnosticCode;
|
|
123
|
+
message: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type PublishAssetClassification =
|
|
127
|
+
| "asset-free"
|
|
128
|
+
| "bundled-raster-v1"
|
|
129
|
+
| "encrypted-client-payload";
|
|
130
|
+
|
|
131
|
+
export type PublishAssetContractResult =
|
|
132
|
+
| { ok: true; classification: PublishAssetClassification }
|
|
133
|
+
| { ok: false; diagnostic: PublishAssetDiagnostic };
|
|
134
|
+
|
|
135
|
+
export interface ValidatePublishAssetContractOptions {
|
|
136
|
+
/** Exact UTF-8 byte length of the final upload body when already serialized. */
|
|
137
|
+
serializedUploadBytes?: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const ASSET_DESCRIPTOR_KEYS = [
|
|
141
|
+
"byteLength",
|
|
142
|
+
"data",
|
|
143
|
+
"encoding",
|
|
144
|
+
"height",
|
|
145
|
+
"id",
|
|
146
|
+
"mediaType",
|
|
147
|
+
"references",
|
|
148
|
+
"sha256",
|
|
149
|
+
"width",
|
|
150
|
+
] as const;
|
|
151
|
+
|
|
152
|
+
export const ASSET_REFERENCE_KEYS = ["noteSlug", "sourceRef"] as const;
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closed-object parsing and sourceRef threat checks for publish assets.
|
|
3
|
+
*
|
|
4
|
+
* @module src/publish/artifact-asset-parse
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { decodeBase64ToBytes, sha256BytesHex } from "./artifact-asset-codec";
|
|
8
|
+
import {
|
|
9
|
+
ASSET_DESCRIPTOR_KEYS,
|
|
10
|
+
ASSET_REFERENCE_KEYS,
|
|
11
|
+
KNOWN_PUBLISH_REQUIRED_CAPABILITIES,
|
|
12
|
+
MAX_PUBLISH_UPLOAD_BYTES,
|
|
13
|
+
MAX_RASTER_DIMENSION_PX,
|
|
14
|
+
MAX_REQUIRED_CAPABILITY_LENGTH,
|
|
15
|
+
MAX_SOURCE_REF_LENGTH,
|
|
16
|
+
MIN_RASTER_DIMENSION_PX,
|
|
17
|
+
MIN_REQUIRED_CAPABILITY_LENGTH,
|
|
18
|
+
MIN_SOURCE_REF_LENGTH,
|
|
19
|
+
PUBLISH_ASSET_NOTE_SLUG_PATTERN,
|
|
20
|
+
SUPPORTED_RASTER_MEDIA_TYPES,
|
|
21
|
+
type PublishArtifactAsset,
|
|
22
|
+
type PublishArtifactAssetReference,
|
|
23
|
+
type PublishAssetDiagnostic,
|
|
24
|
+
type PublishAssetDiagnosticCode,
|
|
25
|
+
type SupportedRasterMediaType,
|
|
26
|
+
} from "./artifact-asset-contract";
|
|
27
|
+
import { parseGnoAssetSentinel } from "./artifact-asset-sniff";
|
|
28
|
+
import { discoverImageOccurrences } from "./attachment-discover";
|
|
29
|
+
import { validateRasterBytesStructural } from "./attachment-raster";
|
|
30
|
+
|
|
31
|
+
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/u;
|
|
32
|
+
const TRAVERSAL_PATTERN = /(?:^|[\\/])\.\.(?:[\\/]|$)|^\/|^[a-zA-Z]:[\\/]/u;
|
|
33
|
+
const ABSOLUTE_PATTERN = /^(?:\/|\\|[a-zA-Z]:[\\/])/u;
|
|
34
|
+
|
|
35
|
+
export type ContractFailure = {
|
|
36
|
+
ok: false;
|
|
37
|
+
diagnostic: PublishAssetDiagnostic;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const fail = (
|
|
41
|
+
code: PublishAssetDiagnosticCode,
|
|
42
|
+
message: string
|
|
43
|
+
): ContractFailure => ({
|
|
44
|
+
ok: false,
|
|
45
|
+
diagnostic: { code, message },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const isSupportedMediaType = (
|
|
49
|
+
value: string
|
|
50
|
+
): value is SupportedRasterMediaType =>
|
|
51
|
+
(SUPPORTED_RASTER_MEDIA_TYPES as ReadonlyArray<string>).includes(value);
|
|
52
|
+
|
|
53
|
+
const sortedKeysEqual = (
|
|
54
|
+
keys: Array<string>,
|
|
55
|
+
expected: ReadonlyArray<string>
|
|
56
|
+
): boolean =>
|
|
57
|
+
JSON.stringify([...keys].sort()) === JSON.stringify([...expected].sort());
|
|
58
|
+
|
|
59
|
+
export const collectMarkdownSentinels = (
|
|
60
|
+
markdown: string
|
|
61
|
+
): ContractFailure | { ok: true; ids: Set<string> } => {
|
|
62
|
+
const ids = new Set<string>();
|
|
63
|
+
for (const occurrence of discoverImageOccurrences(markdown, {
|
|
64
|
+
excludeFrontmatter: false,
|
|
65
|
+
})) {
|
|
66
|
+
const sourceRef = occurrence.sourceRef;
|
|
67
|
+
if (!sourceRef.startsWith("gno-asset:")) continue;
|
|
68
|
+
const parsed = parseGnoAssetSentinel(sourceRef);
|
|
69
|
+
if (!parsed.ok) {
|
|
70
|
+
return fail(
|
|
71
|
+
"ASSET_SENTINEL_INVALID",
|
|
72
|
+
`Invalid gno-asset sentinel grammar: "${sourceRef}"`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
ids.add(parsed.assetId);
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, ids };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const expandSourceRefForms = (sourceRef: string): Array<string> => {
|
|
81
|
+
const forms = [sourceRef];
|
|
82
|
+
let current = sourceRef;
|
|
83
|
+
for (let round = 0; round < 4; round += 1) {
|
|
84
|
+
if (!/%[0-9a-fA-F]{2}/u.test(current)) break;
|
|
85
|
+
try {
|
|
86
|
+
const decoded = decodeURIComponent(current);
|
|
87
|
+
if (decoded === current) break;
|
|
88
|
+
forms.push(decoded);
|
|
89
|
+
current = decoded;
|
|
90
|
+
} catch {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return forms;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const validateSourceRef = (
|
|
98
|
+
sourceRef: string,
|
|
99
|
+
field: string
|
|
100
|
+
): ContractFailure | null => {
|
|
101
|
+
if (typeof sourceRef !== "string") {
|
|
102
|
+
return fail("ASSET_CORRUPT", `${field} must be a string`);
|
|
103
|
+
}
|
|
104
|
+
// Enforce schema 1..1024 bound before percent-decode / traversal normalization.
|
|
105
|
+
// JSON Schema maxLength counts Unicode code points, not UTF-16 code units.
|
|
106
|
+
const sourceRefLength = Array.from(sourceRef).length;
|
|
107
|
+
if (
|
|
108
|
+
sourceRefLength < MIN_SOURCE_REF_LENGTH ||
|
|
109
|
+
sourceRefLength > MAX_SOURCE_REF_LENGTH
|
|
110
|
+
) {
|
|
111
|
+
return fail(
|
|
112
|
+
"ASSET_CORRUPT",
|
|
113
|
+
`${field} must be ${MIN_SOURCE_REF_LENGTH}..${MAX_SOURCE_REF_LENGTH} characters`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (sourceRef.trim().length === 0) {
|
|
117
|
+
return fail("ASSET_TRAVERSAL", `${field} must not be blank`);
|
|
118
|
+
}
|
|
119
|
+
for (const form of expandSourceRefForms(sourceRef)) {
|
|
120
|
+
if (
|
|
121
|
+
form.includes("\0") ||
|
|
122
|
+
TRAVERSAL_PATTERN.test(form) ||
|
|
123
|
+
ABSOLUTE_PATTERN.test(form)
|
|
124
|
+
) {
|
|
125
|
+
return fail(
|
|
126
|
+
"ASSET_TRAVERSAL",
|
|
127
|
+
`${field} escapes the approved collection root`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const readClosedReference = (
|
|
135
|
+
value: unknown,
|
|
136
|
+
field: string
|
|
137
|
+
): ContractFailure | { ok: true; reference: PublishArtifactAssetReference } => {
|
|
138
|
+
if (!(value && typeof value === "object" && !Array.isArray(value))) {
|
|
139
|
+
return fail("ASSET_CORRUPT", `${field} must be a closed object`);
|
|
140
|
+
}
|
|
141
|
+
const record = value as Record<string, unknown>;
|
|
142
|
+
if (!sortedKeysEqual(Object.keys(record), ASSET_REFERENCE_KEYS)) {
|
|
143
|
+
return fail("ASSET_CORRUPT", `${field} contains unknown or missing fields`);
|
|
144
|
+
}
|
|
145
|
+
if (typeof record.noteSlug !== "string" || record.noteSlug.trim() === "") {
|
|
146
|
+
return fail(
|
|
147
|
+
"ASSET_CORRUPT",
|
|
148
|
+
`${field}.noteSlug must be a non-empty string`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (!PUBLISH_ASSET_NOTE_SLUG_PATTERN.test(record.noteSlug)) {
|
|
152
|
+
return fail(
|
|
153
|
+
"ASSET_CORRUPT",
|
|
154
|
+
`${field}.noteSlug must match publish note slug syntax`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (typeof record.sourceRef !== "string") {
|
|
158
|
+
return fail("ASSET_CORRUPT", `${field}.sourceRef must be a string`);
|
|
159
|
+
}
|
|
160
|
+
const traversal = validateSourceRef(record.sourceRef, `${field}.sourceRef`);
|
|
161
|
+
if (traversal) return traversal;
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
reference: {
|
|
165
|
+
noteSlug: record.noteSlug,
|
|
166
|
+
sourceRef: record.sourceRef,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const readClosedAsset = (
|
|
172
|
+
value: unknown,
|
|
173
|
+
index: number
|
|
174
|
+
): ContractFailure | { ok: true; asset: PublishArtifactAsset } => {
|
|
175
|
+
const field = `assets[${index}]`;
|
|
176
|
+
if (!(value && typeof value === "object" && !Array.isArray(value))) {
|
|
177
|
+
return fail("ASSET_CORRUPT", `${field} must be a closed object`);
|
|
178
|
+
}
|
|
179
|
+
const record = value as Record<string, unknown>;
|
|
180
|
+
if (!sortedKeysEqual(Object.keys(record), ASSET_DESCRIPTOR_KEYS)) {
|
|
181
|
+
return fail("ASSET_CORRUPT", `${field} contains unknown or missing fields`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (typeof record.id !== "string" || typeof record.sha256 !== "string") {
|
|
185
|
+
return fail(
|
|
186
|
+
"ASSET_CORRUPT",
|
|
187
|
+
`${field} id/sha256 must be lowercase SHA-256 hex`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
!SHA256_HEX_PATTERN.test(record.id) ||
|
|
192
|
+
!SHA256_HEX_PATTERN.test(record.sha256)
|
|
193
|
+
) {
|
|
194
|
+
return fail(
|
|
195
|
+
"ASSET_CORRUPT",
|
|
196
|
+
`${field} id/sha256 must be lowercase SHA-256 hex`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (record.id !== record.sha256) {
|
|
200
|
+
return fail(
|
|
201
|
+
"ASSET_CONFLICT",
|
|
202
|
+
`${field} id must equal sha256 of payload bytes`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (record.encoding !== "base64") {
|
|
206
|
+
return fail("ASSET_CORRUPT", `${field}.encoding must be "base64"`);
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
typeof record.byteLength !== "number" ||
|
|
210
|
+
!Number.isSafeInteger(record.byteLength) ||
|
|
211
|
+
record.byteLength < 1
|
|
212
|
+
) {
|
|
213
|
+
return fail(
|
|
214
|
+
"ASSET_CORRUPT",
|
|
215
|
+
`${field}.byteLength must be a positive integer`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (record.byteLength > MAX_PUBLISH_UPLOAD_BYTES) {
|
|
219
|
+
return fail(
|
|
220
|
+
"ASSET_OVERSIZE",
|
|
221
|
+
`${field}.byteLength exceeds the ${MAX_PUBLISH_UPLOAD_BYTES} byte upload ceiling`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
typeof record.width !== "number" ||
|
|
226
|
+
typeof record.height !== "number" ||
|
|
227
|
+
!Number.isSafeInteger(record.width) ||
|
|
228
|
+
!Number.isSafeInteger(record.height) ||
|
|
229
|
+
record.width < MIN_RASTER_DIMENSION_PX ||
|
|
230
|
+
record.height < MIN_RASTER_DIMENSION_PX ||
|
|
231
|
+
record.width > MAX_RASTER_DIMENSION_PX ||
|
|
232
|
+
record.height > MAX_RASTER_DIMENSION_PX
|
|
233
|
+
) {
|
|
234
|
+
return fail(
|
|
235
|
+
"ASSET_DIMENSION_INVALID",
|
|
236
|
+
`${field} dimensions must be integers in ${MIN_RASTER_DIMENSION_PX}..${MAX_RASTER_DIMENSION_PX}`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (!Array.isArray(record.references) || record.references.length === 0) {
|
|
240
|
+
return fail(
|
|
241
|
+
"ASSET_CORRUPT",
|
|
242
|
+
`${field}.references must be a non-empty array`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const references: Array<PublishArtifactAssetReference> = [];
|
|
247
|
+
for (const [refIndex, entry] of record.references.entries()) {
|
|
248
|
+
const parsed = readClosedReference(
|
|
249
|
+
entry,
|
|
250
|
+
`${field}.references[${refIndex}]`
|
|
251
|
+
);
|
|
252
|
+
if (!parsed.ok) return parsed;
|
|
253
|
+
references.push(parsed.reference);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (typeof record.mediaType !== "string") {
|
|
257
|
+
return fail(
|
|
258
|
+
"ASSET_UNSUPPORTED_FORMAT",
|
|
259
|
+
`${field}.mediaType "${String(record.mediaType)}" is unsupported (SVG excluded)`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (record.mediaType === "image/svg+xml") {
|
|
263
|
+
return fail(
|
|
264
|
+
"ASSET_UNSUPPORTED_FORMAT",
|
|
265
|
+
`${field}.mediaType "image/svg+xml" is unsupported (SVG excluded)`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (!isSupportedMediaType(record.mediaType)) {
|
|
269
|
+
return fail(
|
|
270
|
+
"ASSET_UNSUPPORTED_FORMAT",
|
|
271
|
+
`${field}.mediaType "${record.mediaType}" is not a supported raster type`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (typeof record.data !== "string") {
|
|
275
|
+
return fail("ASSET_CORRUPT", `${field}.data must be valid base64`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const decoded = decodeBase64ToBytes(record.data);
|
|
279
|
+
if (!decoded.ok) {
|
|
280
|
+
return fail("ASSET_CORRUPT", `${field}.data must be valid base64`);
|
|
281
|
+
}
|
|
282
|
+
if (decoded.bytes.byteLength !== record.byteLength) {
|
|
283
|
+
return fail(
|
|
284
|
+
"ASSET_CORRUPT",
|
|
285
|
+
`${field}.byteLength does not match decoded payload length`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const digest = sha256BytesHex(decoded.bytes);
|
|
289
|
+
if (digest !== record.sha256) {
|
|
290
|
+
return fail(
|
|
291
|
+
"ASSET_CORRUPT",
|
|
292
|
+
`${field}.sha256 does not match payload bytes`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
// Closed-object parse stays synchronous: structural validation only.
|
|
296
|
+
// Producer/file-ingress paths must await validateRasterDecodable separately.
|
|
297
|
+
const validatedRaster = validateRasterBytesStructural(decoded.bytes);
|
|
298
|
+
if (!validatedRaster.ok) {
|
|
299
|
+
return fail(validatedRaster.code, `${field} ${validatedRaster.message}`);
|
|
300
|
+
}
|
|
301
|
+
if (validatedRaster.mediaType !== record.mediaType) {
|
|
302
|
+
return fail(
|
|
303
|
+
"ASSET_MIME_SPOOF",
|
|
304
|
+
`${field} declared ${record.mediaType} but bytes sniff as ${validatedRaster.mediaType}`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (
|
|
308
|
+
validatedRaster.width !== record.width ||
|
|
309
|
+
validatedRaster.height !== record.height
|
|
310
|
+
) {
|
|
311
|
+
return fail(
|
|
312
|
+
"ASSET_DIMENSION_INVALID",
|
|
313
|
+
`${field} declared ${record.width}x${record.height} but bytes are ${validatedRaster.width}x${validatedRaster.height}`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
asset: {
|
|
320
|
+
byteLength: record.byteLength,
|
|
321
|
+
data: record.data,
|
|
322
|
+
encoding: "base64",
|
|
323
|
+
height: record.height,
|
|
324
|
+
id: record.id,
|
|
325
|
+
mediaType: record.mediaType,
|
|
326
|
+
references,
|
|
327
|
+
sha256: record.sha256,
|
|
328
|
+
width: record.width,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export const readRequiredCapabilities = (
|
|
334
|
+
value: unknown
|
|
335
|
+
): ContractFailure | { ok: true; capabilities: Array<string> } => {
|
|
336
|
+
if (value === undefined) return { ok: true, capabilities: [] };
|
|
337
|
+
if (
|
|
338
|
+
!Array.isArray(value) ||
|
|
339
|
+
value.some((entry) => typeof entry !== "string")
|
|
340
|
+
) {
|
|
341
|
+
return fail(
|
|
342
|
+
"CAPABILITY_UNSUPPORTED",
|
|
343
|
+
"requiredCapabilities must be an array of strings when present"
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const capabilities = value as Array<string>;
|
|
347
|
+
const seen = new Set<string>();
|
|
348
|
+
for (const capability of capabilities) {
|
|
349
|
+
if (
|
|
350
|
+
capability.length < MIN_REQUIRED_CAPABILITY_LENGTH ||
|
|
351
|
+
capability.length > MAX_REQUIRED_CAPABILITY_LENGTH
|
|
352
|
+
) {
|
|
353
|
+
return fail(
|
|
354
|
+
"CAPABILITY_UNSUPPORTED",
|
|
355
|
+
`requiredCapabilities entries must be non-empty strings of at most ${MAX_REQUIRED_CAPABILITY_LENGTH} characters`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (seen.has(capability)) {
|
|
359
|
+
return fail(
|
|
360
|
+
"CAPABILITY_UNSUPPORTED",
|
|
361
|
+
`requiredCapabilities duplicates capability "${capability}"`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
seen.add(capability);
|
|
365
|
+
if (
|
|
366
|
+
!(KNOWN_PUBLISH_REQUIRED_CAPABILITIES as ReadonlyArray<string>).includes(
|
|
367
|
+
capability
|
|
368
|
+
)
|
|
369
|
+
) {
|
|
370
|
+
return fail(
|
|
371
|
+
"CAPABILITY_UNSUPPORTED",
|
|
372
|
+
`Unsupported required capability "${capability}"`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { ok: true, capabilities };
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
export const readAssets = (
|
|
380
|
+
value: unknown
|
|
381
|
+
): ContractFailure | { ok: true; assets: Array<PublishArtifactAsset> } => {
|
|
382
|
+
if (value === undefined) return { ok: true, assets: [] };
|
|
383
|
+
if (!Array.isArray(value)) {
|
|
384
|
+
return fail("ASSET_CORRUPT", "assets must be an array when present");
|
|
385
|
+
}
|
|
386
|
+
const assets: Array<PublishArtifactAsset> = [];
|
|
387
|
+
const seenIds = new Set<string>();
|
|
388
|
+
for (const [index, entry] of value.entries()) {
|
|
389
|
+
const parsed = readClosedAsset(entry, index);
|
|
390
|
+
if (!parsed.ok) return parsed;
|
|
391
|
+
if (seenIds.has(parsed.asset.id)) {
|
|
392
|
+
return fail(
|
|
393
|
+
"ASSET_CONFLICT",
|
|
394
|
+
`assets[${index}] duplicates asset id ${parsed.asset.id}; dedup requires one descriptor per content id`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
seenIds.add(parsed.asset.id);
|
|
398
|
+
assets.push(parsed.asset);
|
|
399
|
+
}
|
|
400
|
+
return { ok: true, assets };
|
|
401
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel grammar and raster byte sniffing for publish assets.
|
|
3
|
+
*
|
|
4
|
+
* @module src/publish/artifact-asset-sniff
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
GNO_ASSET_SENTINEL_PATTERN,
|
|
9
|
+
GNO_ASSET_SENTINEL_PREFIX,
|
|
10
|
+
type SupportedRasterMediaType,
|
|
11
|
+
} from "./artifact-asset-contract";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Capture the complete Markdown destination candidate, not merely a valid-looking
|
|
15
|
+
* prefix. Delimiters are excluded; every other suffix (percent escapes,
|
|
16
|
+
* fragments, query strings, Unicode, etc.) is deliberately retained so the
|
|
17
|
+
* strict sentinel parser can reject it rather than silently accepting a prefix.
|
|
18
|
+
* Bare `gno-asset:` (empty destination) is also captured so anchored parsing
|
|
19
|
+
* can fail closed with ASSET_SENTINEL_INVALID.
|
|
20
|
+
*/
|
|
21
|
+
const GNO_ASSET_TOKEN_PATTERN = /gno-asset:[^\s<>"'()[\]{}]*/giu;
|
|
22
|
+
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/u;
|
|
23
|
+
|
|
24
|
+
const asciiSlice = (bytes: Uint8Array, start: number, end: number): string =>
|
|
25
|
+
String.fromCharCode(...bytes.subarray(start, end));
|
|
26
|
+
|
|
27
|
+
const readU32BE = (bytes: Uint8Array, offset: number): number =>
|
|
28
|
+
(((bytes[offset] ?? 0) << 24) |
|
|
29
|
+
((bytes[offset + 1] ?? 0) << 16) |
|
|
30
|
+
((bytes[offset + 2] ?? 0) << 8) |
|
|
31
|
+
(bytes[offset + 3] ?? 0)) >>>
|
|
32
|
+
0;
|
|
33
|
+
|
|
34
|
+
export const formatGnoAssetSentinel = (assetId: string): string => {
|
|
35
|
+
if (!SHA256_HEX_PATTERN.test(assetId)) {
|
|
36
|
+
throw new Error("Asset id must be lowercase SHA-256 hex");
|
|
37
|
+
}
|
|
38
|
+
return `${GNO_ASSET_SENTINEL_PREFIX}${assetId}`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const parseGnoAssetSentinel = (
|
|
42
|
+
value: string
|
|
43
|
+
): { ok: true; assetId: string } | { ok: false; reason: "invalid" } => {
|
|
44
|
+
const match = GNO_ASSET_SENTINEL_PATTERN.exec(value.trim());
|
|
45
|
+
if (!match?.[1]) return { ok: false, reason: "invalid" };
|
|
46
|
+
return { ok: true, assetId: match[1] };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const matchGnoAssetTokens = (markdown: string): Array<string> =>
|
|
50
|
+
markdown.match(GNO_ASSET_TOKEN_PATTERN) ?? [];
|
|
51
|
+
|
|
52
|
+
export const sniffRasterMediaType = (
|
|
53
|
+
bytes: Uint8Array
|
|
54
|
+
): SupportedRasterMediaType | null => {
|
|
55
|
+
if (bytes.length >= 8) {
|
|
56
|
+
if (
|
|
57
|
+
bytes[0] === 0x89 &&
|
|
58
|
+
bytes[1] === 0x50 &&
|
|
59
|
+
bytes[2] === 0x4e &&
|
|
60
|
+
bytes[3] === 0x47 &&
|
|
61
|
+
bytes[4] === 0x0d &&
|
|
62
|
+
bytes[5] === 0x0a &&
|
|
63
|
+
bytes[6] === 0x1a &&
|
|
64
|
+
bytes[7] === 0x0a
|
|
65
|
+
) {
|
|
66
|
+
return "image/png";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
bytes.length >= 3 &&
|
|
71
|
+
bytes[0] === 0xff &&
|
|
72
|
+
bytes[1] === 0xd8 &&
|
|
73
|
+
bytes[2] === 0xff
|
|
74
|
+
) {
|
|
75
|
+
return "image/jpeg";
|
|
76
|
+
}
|
|
77
|
+
if (bytes.length >= 6) {
|
|
78
|
+
const header = asciiSlice(bytes, 0, 6);
|
|
79
|
+
if (header === "GIF87a" || header === "GIF89a") return "image/gif";
|
|
80
|
+
}
|
|
81
|
+
if (bytes.length >= 12) {
|
|
82
|
+
if (
|
|
83
|
+
asciiSlice(bytes, 0, 4) === "RIFF" &&
|
|
84
|
+
asciiSlice(bytes, 8, 12) === "WEBP"
|
|
85
|
+
) {
|
|
86
|
+
return "image/webp";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (bytes.length >= 12 && asciiSlice(bytes, 4, 8) === "ftyp") {
|
|
90
|
+
let boxSize = readU32BE(bytes, 0);
|
|
91
|
+
let headerSize = 8;
|
|
92
|
+
if (boxSize === 1) {
|
|
93
|
+
if (bytes.length < 24 || readU32BE(bytes, 8) !== 0) return null;
|
|
94
|
+
boxSize = readU32BE(bytes, 12);
|
|
95
|
+
headerSize = 16;
|
|
96
|
+
} else if (boxSize === 0) {
|
|
97
|
+
boxSize = bytes.length;
|
|
98
|
+
}
|
|
99
|
+
if (boxSize < headerSize + 8 || boxSize > bytes.length) return null;
|
|
100
|
+
const brand = asciiSlice(bytes, headerSize, headerSize + 4);
|
|
101
|
+
if (brand === "avif" || brand === "avis") return "image/avif";
|
|
102
|
+
for (let offset = headerSize + 8; offset + 4 <= boxSize; offset += 4) {
|
|
103
|
+
const compat = asciiSlice(bytes, offset, offset + 4);
|
|
104
|
+
if (compat === "avif" || compat === "avis") return "image/avif";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
};
|