Skip to content

Commit 9754454

Browse files
committed
v2.1.2: dedicated decode-failure preview, no more sentinel leakage into UI
The decode-failure sentinels INVALID_COMPRESSED_DATA_MESSAGE and MAX_DECOMPRESSED_SIZE_MESSAGE were never honoured by the consumers -- they leaked through getContent() / getDataUri() into Angular code viewers, JSON viewers, and base64 data URIs inside <img>/<audio>/<video> tags. The size-limit sentinel has been there for a long time; the invalid-data one is new in v2.1.1 -- both have the same downstream problem. This commit short-circuits both the parser preview functions to detect the sentinel and return a clean, dedicated "decode failure" iframe HTML with two distinct headlines: - 'Inscription too large to decode' (size-limit) - 'Inscription content cannot be decoded' (invalid-data) InscriptionPreviewService.getPreview() does the check at the top, so backend `/preview/:id` rendering automatically benefits without any backend code change. getContentTypeInstructions() gains a new 'decode-failure' return shape for the frontend's inline routing (text/code/json/yaml/css/js paths). The existing else branch in inscription-viewer.component.html already routes 'decode-failure' to <app-preview-viewer>, which calls getPreview() -> the new failure HTML. No frontend component changes needed. Other changes in this commit: - Added an exported helper `isDecodeFailureSentinel(content)` for consumers that want to detect the failure case directly. - Migrated all `// test here: http://localhost:...` URL comments in inscription-preview.service.ts to https://ordpool.space/... so they remain useful for anyone who doesn't have a local dev environment. Tests: 5 new (sentinel helper coverage + getContentTypeInstructions / getPreview behavior on the corrupt-brotli fixture, plus a regression guard that the sentinel string and its base64 don't leak into the preview HTML).
1 parent 3375003 commit 9754454

3 files changed

Lines changed: 187 additions & 12 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ordpool-parser",
3-
"version": "2.1.1",
3+
"version": "2.1.2",
44
"description": "Zero-dependency TypeScript parser for Bitcoin digital artifacts: Inscriptions, Runes, BRC-20, SRC-20, CAT-21, Atomicals, and Labitbu. Works in Node.js and browsers.",
55
"repository": {
66
"type": "git",

src/digital-artifact-analyser.service.corrupt-brotli.spec.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { DigitalArtifactsParserService } from './digital-artifacts-parser.servic
44
import { DigitalArtifactType } from './types/digital-artifact';
55
import { ParsedInscription } from './types/parsed-inscription';
66
import { OrdpoolTransactionFlags } from './types/ordpool-transaction-flags';
7-
import { INVALID_COMPRESSED_DATA_MESSAGE } from './lib/brotli-decode';
7+
import { INVALID_COMPRESSED_DATA_MESSAGE, MAX_DECOMPRESSED_SIZE_MESSAGE } from './lib/brotli-decode';
8+
import { InscriptionPreviewService, isDecodeFailureSentinel } from './inscription/inscription-preview.service';
89
import { IEsploraApi } from './types/mempool';
910

1011
/**
@@ -87,3 +88,68 @@ describe('analyseTransaction with malformed inscription compression', () => {
8788
expect(stats.amounts.inscriptionJson ?? 0).toBe(0);
8889
});
8990
});
91+
92+
describe('decode-failure sentinel detection helper', () => {
93+
94+
it('detects INVALID_COMPRESSED_DATA_MESSAGE as invalid-data', () => {
95+
expect(isDecodeFailureSentinel(INVALID_COMPRESSED_DATA_MESSAGE)).toBe('invalid-data');
96+
});
97+
98+
it('detects MAX_DECOMPRESSED_SIZE_MESSAGE as size-limit', () => {
99+
expect(isDecodeFailureSentinel(MAX_DECOMPRESSED_SIZE_MESSAGE)).toBe('size-limit');
100+
});
101+
102+
it('returns null for any other string', () => {
103+
expect(isDecodeFailureSentinel('Hello World!')).toBeNull();
104+
expect(isDecodeFailureSentinel('')).toBeNull();
105+
expect(isDecodeFailureSentinel('{"p":"brc-20"}')).toBeNull();
106+
// Substring of the sentinel must not match -- exact string only.
107+
expect(isDecodeFailureSentinel('Error: invalid')).toBeNull();
108+
});
109+
});
110+
111+
describe('InscriptionPreviewService on the corrupt-brotli inscription', () => {
112+
113+
function getCorruptInscription(): ParsedInscription {
114+
const tx = readCorruptBrotliFixture();
115+
const artifacts = DigitalArtifactsParserService.parse(tx);
116+
const inscription = artifacts.find(a => a.type === DigitalArtifactType.Inscription) as ParsedInscription | undefined;
117+
if (!inscription) throw new Error('fixture has no inscription');
118+
return inscription;
119+
}
120+
121+
it('getContentTypeInstructions returns whatToShow=decode-failure with reason=invalid-data', async () => {
122+
const inscription = getCorruptInscription();
123+
124+
const instructions = await InscriptionPreviewService.getContentTypeInstructions(inscription);
125+
126+
expect(instructions.whatToShow).toBe('decode-failure');
127+
expect(instructions.reason).toBe('invalid-data');
128+
expect(instructions.content).toBeUndefined();
129+
});
130+
131+
it('getPreview returns the decode-failure HTML and never the sentinel string', async () => {
132+
const inscription = getCorruptInscription();
133+
134+
const preview = await InscriptionPreviewService.getPreview(inscription);
135+
136+
// renderDirectly must be false -- we never let the iframe load /preview/ for
137+
// a malformed inscription, otherwise the browser would just render garbage
138+
// bytes (which is what we're trying to avoid).
139+
expect(preview.renderDirectly).toBe(false);
140+
141+
// Failure HTML contains the human-readable headline for invalid-data.
142+
expect(preview.previewContent).toContain('Inscription content cannot be decoded');
143+
144+
// The sentinel string itself MUST NOT leak into the iframe HTML -- if it
145+
// did, that would mean a downstream preview function embedded the sentinel
146+
// bytes into a data URI. The whole point of the short-circuit is to avoid
147+
// exactly that.
148+
expect(preview.previewContent).not.toContain(INVALID_COMPRESSED_DATA_MESSAGE);
149+
150+
// Same for the base64 of the sentinel bytes -- catches the case where
151+
// getDataUri()-based preview functions accidentally bypass the short-circuit.
152+
const sentinelBase64 = Buffer.from(INVALID_COMPRESSED_DATA_MESSAGE, 'utf8').toString('base64');
153+
expect(preview.previewContent).not.toContain(sentinelBase64);
154+
});
155+
});

src/inscription/inscription-preview.service.ts

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { INVALID_COMPRESSED_DATA_MESSAGE, MAX_DECOMPRESSED_SIZE, MAX_DECOMPRESSED_SIZE_MESSAGE } from '../lib/brotli-decode';
12
import { binaryStringToBase64, bytesToBinaryString, unicodeStringToBytes } from '../lib/conversions';
23
import { ParsedInscription } from '../types/parsed-inscription';
34

@@ -7,6 +8,22 @@ export interface PreviewInstructions {
78
renderDirectly: boolean;
89
}
910

11+
export type DecodeFailureReason = 'invalid-data' | 'size-limit';
12+
13+
/**
14+
* If `content` is one of the decoder's failure sentinels (returned by
15+
* brotliDecodeUint8Array / gzipDecode when the inscription's compressed
16+
* body cannot be decoded or would exceed the size limit), report which
17+
* one. Otherwise null. Used by the preview pipeline to short-circuit
18+
* downstream rendering -- e.g. avoid embedding 30 bytes of "Error:
19+
* invalid compressed data" as a base64 data URI inside an `<img>` tag.
20+
*/
21+
export function isDecodeFailureSentinel(content: string): DecodeFailureReason | null {
22+
if (content === INVALID_COMPRESSED_DATA_MESSAGE) return 'invalid-data';
23+
if (content === MAX_DECOMPRESSED_SIZE_MESSAGE) return 'size-limit';
24+
return null;
25+
}
26+
1027
/**
1128
* Takes a parsed inscription and returns a preview HTML
1229
* It tries to embed the dataUri of the inscription to save one network-request
@@ -30,6 +47,24 @@ export class InscriptionPreviewService {
3047
}
3148
}
3249

50+
// Decode-failure check first. If the body can't be decoded (corrupt
51+
// brotli/gzip stream) or would exceed the decompression size limit,
52+
// we must NOT continue into the per-content-type preview functions:
53+
// they call getDataUri() which would embed the sentinel string as a
54+
// base64 image/audio/etc, producing broken-icon previews. Show a
55+
// dedicated failure page instead, regardless of declared contentType.
56+
// Same outcome for renderDirectly types (image/svg+xml, text/html) --
57+
// we replace the would-be iframe-src with the failure page.
58+
const decodedContent = await inscription.getContent();
59+
const failureReason = isDecodeFailureSentinel(decodedContent);
60+
if (failureReason) {
61+
return {
62+
instructionsFor: inscription.inscriptionId,
63+
previewContent: getPreviewDecodeFailure(failureReason),
64+
renderDirectly: false
65+
};
66+
}
67+
3368
let previewFunction: (inscription: ParsedInscription) => Promise<string> = getPreviewUnknown;
3469
if (inscription.contentType && table[inscription.contentType]) {
3570
previewFunction = table[inscription.contentType];
@@ -78,7 +113,8 @@ export class InscriptionPreviewService {
78113
*/
79114
static async getContentTypeInstructions(inscription: ParsedInscription): Promise<{
80115
content: string | undefined,
81-
whatToShow: 'json' | 'code' | 'preview'
116+
whatToShow: 'json' | 'code' | 'preview' | 'decode-failure',
117+
reason?: DecodeFailureReason
82118
}> {
83119

84120
let content: string | undefined = undefined;
@@ -87,6 +123,21 @@ export class InscriptionPreviewService {
87123
inscription.contentType?.startsWith('application/json')) {
88124

89125
content = await inscription.getContent();
126+
127+
// Short-circuit on decode failure -- the sentinel string is not
128+
// valid JSON anyway, but if we let it fall through to the 'preview'
129+
// branch below (or to the 'code' branch for yaml/css/js) the
130+
// frontend would render the literal "Error: ..." text as the
131+
// inscription's content. Route to a dedicated failure UI instead.
132+
const failureReason = isDecodeFailureSentinel(content);
133+
if (failureReason) {
134+
return {
135+
content: undefined,
136+
whatToShow: 'decode-failure',
137+
reason: failureReason
138+
};
139+
}
140+
90141
const isValidJson = validateJson(content);
91142

92143
if (isValidJson) {
@@ -105,6 +156,15 @@ export class InscriptionPreviewService {
105156

106157
content = content || await inscription.getContent();
107158

159+
const failureReason = isDecodeFailureSentinel(content);
160+
if (failureReason) {
161+
return {
162+
content: undefined,
163+
whatToShow: 'decode-failure',
164+
reason: failureReason
165+
};
166+
}
167+
108168
return {
109169
content,
110170
whatToShow: 'code'
@@ -161,13 +221,13 @@ const table: { [key: string]: (inscription: ParsedInscription) => Promise<string
161221

162222

163223

164-
// test here: http://localhost:4200/tx/751007cf3090703f241894af5c057fc8850d650a577a800447d4f21f5d2cecde
224+
// test here: https://ordpool.space/tx/751007cf3090703f241894af5c057fc8850d650a577a800447d4f21f5d2cecde
165225
async function getPreviewIframe(_inscription: ParsedInscription): Promise<string> {
166226
// return decodeDataURI(dataUri);
167227
return ''
168228
}
169229

170-
// test here: http://localhost:4200/tx/ad99172fce60028406f62725b91b5c508edd95bf21310de5afeb0966ddd89be3
230+
// test here: https://ordpool.space/tx/ad99172fce60028406f62725b91b5c508edd95bf21310de5afeb0966ddd89be3
171231
async function getPreviewAudio(inscription: ParsedInscription): Promise<string> {
172232

173233
const dataUri = await inscription.getDataUri();
@@ -185,7 +245,7 @@ async function getPreviewAudio(inscription: ParsedInscription): Promise<string>
185245
</html>`;
186246
}
187247

188-
// test here http://localhost:4200/tx/6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799
248+
// test here https://ordpool.space/tx/6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799
189249
async function getPreviewImage(inscription: ParsedInscription): Promise<string> {
190250

191251
const dataUri = await inscription.getDataUri();
@@ -223,7 +283,7 @@ async function getPreviewImage(inscription: ParsedInscription): Promise<string>
223283
</html>`;
224284
}
225285

226-
// test here: http://localhost:4200/tx/c133c03e2ed44bb8ada79b1640b6649129de75a8f31d8e6ad573ede442f91cdb
286+
// test here: https://ordpool.space/tx/c133c03e2ed44bb8ada79b1640b6649129de75a8f31d8e6ad573ede442f91cdb
227287
async function getPreviewMarkdown(inscription: ParsedInscription): Promise<string> {
228288

229289
const dataUri = await inscription.getDataUri();
@@ -240,7 +300,7 @@ async function getPreviewMarkdown(inscription: ParsedInscription): Promise<strin
240300
</html>`;
241301
}
242302

243-
// test here: http://localhost:4200/tx/25013a3ab212e0ca5b3ccbd858ff988f506b77080c51963c948c055028af2051
303+
// test here: https://ordpool.space/tx/25013a3ab212e0ca5b3ccbd858ff988f506b77080c51963c948c055028af2051
244304
async function getPreviewModel(inscription: ParsedInscription): Promise<string> {
245305

246306
const dataUri = await inscription.getDataUri();
@@ -263,7 +323,7 @@ async function getPreviewModel(inscription: ParsedInscription): Promise<string>
263323
</html>`;
264324
}
265325

266-
// test here: http://localhost:4200/tx/85b10531435304cbe47d268106b58b57a4416c76573d4b50fa544432597ad670i0
326+
// test here: https://ordpool.space/tx/85b10531435304cbe47d268106b58b57a4416c76573d4b50fa544432597ad670i0
267327
// (shows only the first page)
268328
async function getPreviewPdf(inscription: ParsedInscription): Promise<string> {
269329

@@ -282,7 +342,7 @@ async function getPreviewPdf(inscription: ParsedInscription): Promise<string> {
282342
</html>`;
283343
}
284344

285-
// test here: http://localhost:4200/tx/430901147831e41111aced3895ee4b9742cf72ac3cffa132624bd38c551ef379
345+
// test here: https://ordpool.space/tx/430901147831e41111aced3895ee4b9742cf72ac3cffa132624bd38c551ef379
286346
async function getPreviewText(inscription: ParsedInscription): Promise<string> {
287347

288348
const instructions = await InscriptionPreviewService.getContentTypeInstructions(inscription);
@@ -354,7 +414,7 @@ async function getPreviewText(inscription: ParsedInscription): Promise<string> {
354414
</html>`;
355415
}
356416

357-
// test here: http://localhost:4200/tx/06158001c0be9d375c10a56266d8028b80ebe1ef5e2a9c9a4904dbe31b72e01c
417+
// test here: https://ordpool.space/tx/06158001c0be9d375c10a56266d8028b80ebe1ef5e2a9c9a4904dbe31b72e01c
358418
async function getPreviewUnknown(_inscription?: ParsedInscription): Promise<string> {
359419

360420
return `<!doctype html>
@@ -369,7 +429,56 @@ async function getPreviewUnknown(_inscription?: ParsedInscription): Promise<stri
369429
`;
370430
}
371431

372-
// test here: http://localhost:4200/tx/700f348e1acef6021cdee8bf09e4183d6a3f4d573b4dc5585defd54009a0148c
432+
/**
433+
* Iframe HTML shown when an inscription's compressed body cannot be
434+
* decoded (or would exceed the decompression size limit). Used by both
435+
* the parser's getPreview() and the frontend's `<app-preview-viewer>`
436+
* (via getContentTypeInstructions returning 'decode-failure', which
437+
* routes to the same component that renders this HTML).
438+
*
439+
* Self-contained -- no external CSS or fonts -- because it has to render
440+
* inside the sandboxed iframe used by `<app-preview-viewer>` AND inside
441+
* the backend's `/preview/:id` response. Two distinct messages so the
442+
* user knows whether it's a malformed inscription or a too-large one.
443+
*
444+
* test here (invalid-data: Content-Encoding: br body that is actually gzip):
445+
* https://ordpool.space/tx/5125c1269bd9c4605764fe76d253078d4c35897646004b8fa9837ad41e94a634
446+
* test here (size-limit): no real-data fixture yet -- a >1 MB-decompressing
447+
* brotli body is needed to exercise this branch via the live UI.
448+
*/
449+
function getPreviewDecodeFailure(reason: DecodeFailureReason): string {
450+
const headline = reason === 'size-limit'
451+
? 'Inscription too large to decode'
452+
: 'Inscription content cannot be decoded';
453+
454+
const detail = reason === 'size-limit'
455+
? `The decompressed content exceeds the ${MAX_DECOMPRESSED_SIZE / 1024 / 1024}&#160;MB safety limit. The data is preserved on chain and remains accessible via the raw content link.`
456+
: `The inscription declares a brotli or gzip Content-Encoding that doesn't match its actual body. The data is preserved on chain and remains accessible via the raw content link.`;
457+
458+
return `<!doctype html>
459+
<html lang='en'>
460+
<head>
461+
<meta charset='utf-8'>
462+
<style>
463+
html, body { margin: 0; padding: 0; height: 100%; background: #131516; color: #d3d4d5; font-family: system-ui, -apple-system, sans-serif; }
464+
.wrap { display: flex; align-items: center; justify-content: center; height: 100%; padding: 1.5rem; box-sizing: border-box; }
465+
.panel { max-width: 32rem; text-align: center; }
466+
.panel h1 { margin: 0 0 0.6rem; font-size: 1.05rem; font-weight: 600; color: #ff9f43; }
467+
.panel p { margin: 0; line-height: 1.5; font-size: 0.9rem; color: #b1b3b5; }
468+
</style>
469+
</head>
470+
<body>
471+
<div class="wrap">
472+
<div class="panel">
473+
<h1>${headline}</h1>
474+
<p>${detail}</p>
475+
</div>
476+
</div>
477+
</body>
478+
</html>`;
479+
}
480+
481+
// test here: https://ordpool.space/tx/700f348e1acef6021cdee8bf09e4183d6a3f4d573b4dc5585defd54009a0148c
373482
async function getPreviewVideo(inscription: ParsedInscription): Promise<string> {
374483

375484
const dataUri = await inscription.getDataUri();

0 commit comments

Comments
 (0)