From 9e27c7debba94d4380c5297aa911b2d385717f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Wed, 29 Apr 2026 21:36:45 +0200 Subject: [PATCH] filepart handling in IIP --- addons/addon-image/src/IIPHandler.ts | 71 +++++++-- .../addon-image/src/IIPHeaderParser.test.ts | 136 +++++++++++++++++- addons/addon-image/src/IIPHeaderParser.ts | 97 +++++++++++-- 3 files changed, 272 insertions(+), 32 deletions(-) diff --git a/addons/addon-image/src/IIPHandler.ts b/addons/addon-image/src/IIPHandler.ts index 76492c405..5872e29fa 100644 --- a/addons/addon-image/src/IIPHandler.ts +++ b/addons/addon-image/src/IIPHandler.ts @@ -7,7 +7,7 @@ import { ImageRenderer } from './ImageRenderer'; import { IIPImageStorage } from './IIPImageStorage'; import { CELL_SIZE_DEFAULT } from './ImageStorage'; import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; -import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser'; +import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser'; import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; // Local const enum mirror - esbuild can't inline const enums from external packages @@ -22,6 +22,7 @@ const enum DecoderConst { // default IIP header values const DEFAULT_HEADER: IHeaderFields = { + type: SequenceType.INVALID, name: 'Unnamed file', size: 0, width: 'auto', @@ -37,6 +38,8 @@ export class IIPHandler implements IOscHandler, IResetHandler { private _header: IHeaderFields = DEFAULT_HEADER; private _dec: Base64Decoder; private _metrics = UNSUPPORTED_TYPE; + private _isMultipart = false; + private _abortMulti = false; constructor( private readonly _opts: IImageAddonOptions, @@ -53,7 +56,6 @@ export class IIPHandler implements IOscHandler, IResetHandler { public start(): void { this._aborted = false; - this._header = DEFAULT_HEADER; this._metrics = UNSUPPORTED_TYPE; this._hp.reset(); } @@ -73,15 +75,27 @@ export class IIPHandler implements IOscHandler, IResetHandler { return; } if (dataPos > 0) { - this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields); - if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) { + const seqType = this._hp.fields.type; + if (seqType === SequenceType.FILE) { + if (this._isMultipart) { + this._isMultipart = false; + this._abortMulti = false; + this._dec.release(); + } + this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields); + if (!this._header.inline) { + this._aborted = true; + return; + } + this._dec.init(); + } else if (this._abortMulti) { this._aborted = true; return; } - this._dec.init(); if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) { this._dec.release(); this._aborted = true; + if (this._isMultipart) this._abortMulti = true; } } } @@ -90,6 +104,37 @@ export class IIPHandler implements IOscHandler, IResetHandler { public end(success: boolean): boolean | Promise { if (this._aborted) return true; + if (this._hp.state !== HeaderState.END) { + if (this._hp.end()) return true; + } + const seqType = this._hp.fields.type; + + if (seqType === SequenceType.FILEPART) return true; + + if (seqType === SequenceType.REPORTCELLSIZE) { + // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST + // TODO: fill stub with real values + const report = `\x1b]1337;ReportCellSize=[height];[width];[scale]\x1b\\`; + this._coreTerminal?._core.coreService.triggerDataEvent(report); + return true; + } + + if (seqType === SequenceType.MULTIPARTFILE) { + this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields); + this._isMultipart = true; + this._abortMulti = false; + this._dec.init(); + return true; + } + + if (seqType === SequenceType.FILEEND) { + if (!this._isMultipart) return true; + this._isMultipart = false; + if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true; + } + + // fallthrough for SequenceType.FILE & SequenceType.FILEEND + let w = 0; let h = 0; @@ -97,15 +142,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { let cond: number | boolean = true; if (cond = success) { if (cond = !this._dec.end()) { - if (cond = this._dec.data8.length === this._header.size) { - this._metrics = imageType(this._dec.data8); - if (cond = this._metrics.mime !== 'unsupported') { - w = this._metrics.width; - h = this._metrics.height; - if (cond = w && h && w * h < this._opts.pixelLimit) { - [w, h] = this._resize(w, h).map(Math.floor); - cond = w && h && w * h < this._opts.pixelLimit; - } + this._metrics = imageType(this._dec.data8); + if (cond = this._metrics.mime !== 'unsupported') { + w = this._metrics.width; + h = this._metrics.height; + if (cond = w && h && w * h < this._opts.pixelLimit) { + [w, h] = this._resize(w, h).map(Math.floor); + cond = w && h && w * h < this._opts.pixelLimit; } } } diff --git a/addons/addon-image/src/IIPHeaderParser.test.ts b/addons/addon-image/src/IIPHeaderParser.test.ts index e42782c14..ba6d5f052 100644 --- a/addons/addon-image/src/IIPHeaderParser.test.ts +++ b/addons/addon-image/src/IIPHeaderParser.test.ts @@ -4,19 +4,20 @@ */ import { assert } from 'chai'; -import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser'; +import { HeaderParser, HeaderState, IHeaderFields, SequenceType } from './IIPHeaderParser'; const CASES: [string, IHeaderFields][] = [ - ['File=size=123456;name=dGVzdA==:', {name: 'test', size: 123456}], - ['File=size=123456;name=dGVzdA:', {name: 'test', size: 123456}], + ['File=size=123456;name=dGVzdA==:', {type: SequenceType.FILE, name: 'test', size: 123456}], + ['File=size=123456;name=dGVzdA:', {type: SequenceType.FILE, name: 'test', size: 123456}], // utf-8 encoding in name - ['File=size=123456;name=w7xtbMOkdXTDnw==:', {name: 'ümläutß', size: 123456}], - ['File=size=123456;name=w7xtbMOkdXTDnw:', {name: 'ümläutß', size: 123456}], + ['File=size=123456;name=w7xtbMOkdXTDnw==:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}], + ['File=size=123456;name=w7xtbMOkdXTDnw:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}], // full header spec [ 'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:', { + type: SequenceType.FILE, inline: 1, width: '10px', height: '20%', @@ -28,6 +29,7 @@ const CASES: [string, IHeaderFields][] = [ [ 'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:', { + type: SequenceType.FILE, inline: 1, width: 'auto', height: '20', @@ -101,7 +103,7 @@ describe('IIPHeaderParser', () => { let res = hp.parse(inp, 0, inp.length); assert.strictEqual(res, inp.length); assert.strictEqual(hp.state, HeaderState.END); - assert.deepEqual(hp.fields, {name: 'test', size: 0}); + assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: 'test', size: 0}); hp.reset(); inp = fromBs(CASES[0][0]); res = hp.parse(inp, 0, inp.length); @@ -115,7 +117,7 @@ describe('IIPHeaderParser', () => { let res = hp.parse(inp, 0, inp.length); assert.strictEqual(res, inp.length); assert.strictEqual(hp.state, HeaderState.END); - assert.deepEqual(hp.fields, {name: '', size: 123456}); + assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: '', size: 123456}); hp.reset(); inp = fromBs(CASES[0][0]); res = hp.parse(inp, 0, inp.length); @@ -135,4 +137,124 @@ describe('IIPHeaderParser', () => { assert.strictEqual(hp.state, HeaderState.END); assert.deepEqual(hp.fields, CASES[0][1]); }); + it('FilePart sequence', () => { + const hp = new HeaderParser(); + const inp = fromBs('FilePart=w7xtbMOkdXTDnw'); + const res = hp.parse(inp, 0, inp.length); + assert.strictEqual(res, 9); + assert.strictEqual(hp.state, HeaderState.END); + assert.deepEqual(hp.fields.type, SequenceType.FILEPART); + }); + it('FilePart sequence - bytewise', () => { + const hp = new HeaderParser(); + const inp = fromBs('FilePart=w7xtbMOkdXTDnw'); + let res = -2; + let pos = 0; + while (res === -2 && pos < inp.length) { + res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1); + } + assert.strictEqual(res, 1); + assert.strictEqual(hp.state, HeaderState.END); + assert.deepEqual(hp.fields.type, SequenceType.FILEPART); + }); + it('MultipartFile sequence', () => { + const hp = new HeaderParser(); + const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw'); + let res = hp.parse(inp, 0, inp.length); + assert.strictEqual(res, -2); + assert.strictEqual(hp.state, HeaderState.VALUE); + res = hp.end(); + assert.strictEqual(res, 0); + assert.strictEqual(hp.state, HeaderState.END); + assert.deepEqual( + hp.fields, + { + type: SequenceType.MULTIPARTFILE, + inline: 1, + width: '10px', + height: '20%', + preserveAspectRatio: 1, + size: 123456, + name: 'ümläutß' + } + ); + }); + it('MultipartFile sequence - bytewise', () => { + const hp = new HeaderParser(); + const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw'); + let res = -2; + let pos = 0; + while (res === -2 && pos < inp.length) { + res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1); + } + assert.strictEqual(res, -2); + assert.strictEqual(hp.state, HeaderState.VALUE); + res = hp.end(); + assert.strictEqual(res, 0); + assert.strictEqual(hp.state, HeaderState.END); + assert.deepEqual( + hp.fields, + { + type: SequenceType.MULTIPARTFILE, + inline: 1, + width: '10px', + height: '20%', + preserveAspectRatio: 1, + size: 123456, + name: 'ümläutß' + } + ); + }); + it('FileEnd sequence', () => { + const hp = new HeaderParser(); + const inp = fromBs('FileEnd'); + let res = hp.parse(inp, 0, inp.length); + assert.strictEqual(res, -2); + assert.strictEqual(hp.state, HeaderState.START); + res = hp.end(); + assert.strictEqual(res, 0); + assert.strictEqual(hp.state, HeaderState.END); + assert.deepEqual(hp.fields.type, SequenceType.FILEEND); + }); + it('FileEnd sequence - bytewise', () => { + const hp = new HeaderParser(); + const inp = fromBs('FileEnd'); + let res = -2; + let pos = 0; + while (res === -2 && pos < inp.length) { + res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1); + } + assert.strictEqual(res, -2); + assert.strictEqual(hp.state, HeaderState.START); + res = hp.end(); + assert.strictEqual(res, 0); + assert.strictEqual(hp.state, HeaderState.END); + assert.deepEqual(hp.fields.type, SequenceType.FILEEND); + }); + it('ReportCellSize sequence', () => { + const hp = new HeaderParser(); + const inp = fromBs('ReportCellSize'); + let res = hp.parse(inp, 0, inp.length); + assert.strictEqual(res, -2); + assert.strictEqual(hp.state, HeaderState.START); + res = hp.end(); + assert.strictEqual(res, 0); + assert.strictEqual(hp.state, HeaderState.END); + assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE }); + }); + it('ReportCellSize sequence - bytewise', () => { + const hp = new HeaderParser(); + const inp = fromBs('ReportCellSize'); + let res = -2; + let pos = 0; + while (res === -2 && pos < inp.length) { + res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1); + } + assert.strictEqual(res, -2); + assert.strictEqual(hp.state, HeaderState.START); + res = hp.end(); + assert.strictEqual(res, 0); + assert.strictEqual(hp.state, HeaderState.END); + assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE }); + }); }); diff --git a/addons/addon-image/src/IIPHeaderParser.ts b/addons/addon-image/src/IIPHeaderParser.ts index af4e9a68d..9c20262b1 100644 --- a/addons/addon-image/src/IIPHeaderParser.ts +++ b/addons/addon-image/src/IIPHeaderParser.ts @@ -6,9 +6,27 @@ // eslint-disable-next-line declare const Buffer: any; +export const enum HeaderState { + START = 0, + ABORT = 1, + KEY = 2, + VALUE = 3, + END = 4 +} + +export const enum SequenceType { + INVALID = 0, + FILE = 1, + MULTIPARTFILE = 2, + FILEPART = 3, + FILEEND = 4, + REPORTCELLSIZE = 5 +} export interface IHeaderFields { [key: string]: number | string | Uint32Array | null | undefined; + // sequence type + type: SequenceType; // base-64 encoded filename. Defaults to "Unnamed file". name: string; // File size in bytes. The file transfer will be canceled if this size is exceeded. @@ -29,14 +47,6 @@ export interface IHeaderFields { inline?: number; } -export const enum HeaderState { - START = 0, - ABORT = 1, - KEY = 2, - VALUE = 3, - END = 4 -} - // field value decoders // ASCII bytes to string @@ -92,7 +102,19 @@ const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = { }; +// sequence type markers +// File const FILE_MARKER = [70, 105, 108, 101]; +// MultipartFile +const MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101]; +// FilePart +const FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116]; +// FileEnd +const FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100]; +// ReportCellSize +const REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101]; + +// max allowed chars for sequence header const MAX_FIELDCHARS = 1024; @@ -111,12 +133,43 @@ export class HeaderParser { this._key = ''; } + public end(): number { + if (this.state === HeaderState.START) { + if (this._position === FILEEND_MARKER.length) { + for (let k = 0; k < FILEEND_MARKER.length; ++k) { + if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a(); + } + this.fields['type'] = SequenceType.FILEEND; + this.state = HeaderState.END; + return 0; + } + if (this._position === REPORTCELLSIZE_MARKER.length) { + for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) { + if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a(); + } + this.fields['type'] = SequenceType.REPORTCELLSIZE; + this.state = HeaderState.END; + return 0; + } + return this._a(); + } + if (this.state === HeaderState.END) return 0; + if (this.state === HeaderState.VALUE + && this.fields.type === SequenceType.MULTIPARTFILE + ) { + if (!this._storeValue(this._position)) return this._a(); + this.state = HeaderState.END; + return 0; + } + return this._a(); + } + public parse(data: Uint32Array, start: number, end: number): number { let state = this.state; let pos = this._position; const buffer = this._buffer; if (state === HeaderState.ABORT || state === HeaderState.END) return -1; - if (state === HeaderState.START && pos > 6) return -1; + if (state === HeaderState.START && pos > 14) return -1; for (let i = start; i < end; ++i) { const c = data[i]; switch (c) { @@ -127,8 +180,29 @@ export class HeaderParser { break; case 61: // = if (state === HeaderState.START) { - for (let k = 0; k < FILE_MARKER.length; ++k) { - if (buffer[k] !== FILE_MARKER[k]) return this._a(); + if (buffer[0] === 70) { + // 'File' or 'FilePart' + let k = 0; + for (; k < FILE_MARKER.length; ++k) { + if (buffer[k] !== FILE_MARKER[k]) return this._a(); + } + this.fields['type'] = SequenceType.FILE; + if (pos === FILEPART_MARKER.length) { + for (; k < FILEPART_MARKER.length; ++k) { + if (buffer[k] !== FILEPART_MARKER[k]) return this._a(); + } + this.fields['type'] = SequenceType.FILEPART; + this.state = HeaderState.END; + return i + 1; + } + } else if (buffer[0] === 77) { + // 'MultipartFile' + for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) { + if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a(); + } + this.fields['type'] = SequenceType.MULTIPARTFILE; + } else { + return this._a(); } state = HeaderState.KEY; pos = 0; @@ -158,6 +232,7 @@ export class HeaderParser { } private _a(): number { + this.fields.type = SequenceType.INVALID; this.state = HeaderState.ABORT; return -1; }