收紧导入结果消费边界

将文本、文档、图片和音频导入结果归一化提升到共享 HostBridge 契约
让 H5 facade 消费宿主导入结果前复核文件名、MIME、字节数和 base64 载荷
移除 H5 facade 本地导入 MIME Set 并用总门禁反查共享 normalizer
增加导入结果边界测试和超限结果回退覆盖
同步宿主壳协议文档和共享决策记录
This commit is contained in:
2026-06-19 16:34:35 +08:00
parent a68377596a
commit d7af659aa5
7 changed files with 388 additions and 153 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -44,6 +44,10 @@ import {
normalizeHostBridgeExternalUrl,
normalizeHostBridgeExternalUrlPayload,
normalizeHostBridgeHapticsImpactStyle,
normalizeHostBridgeImportAudioResult,
normalizeHostBridgeImportDocumentResult,
normalizeHostBridgeImportImageResult,
normalizeHostBridgeImportTextResult,
normalizeHostBridgeLifecycleState,
normalizeHostBridgeLocalNotification,
normalizeHostBridgeQrCodeValue,
@@ -418,6 +422,91 @@ describe('HostBridge shared contract helpers', () => {
).toBeNull();
});
test('归一化宿主文件导入结果', () => {
expect(
normalizeHostBridgeImportTextResult({
action: 'selected',
fileName: ' 剧情.md ',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
}),
).toEqual({
action: 'selected',
fileName: '剧情.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
});
expect(
normalizeHostBridgeImportDocumentResult({
action: 'selected',
fileName: ' 世界设定.docx ',
base64Data: ' UEsDBGRvY3g= ',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
}),
).toEqual({
action: 'selected',
fileName: '世界设定.docx',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
expect(
normalizeHostBridgeImportImageResult({
action: 'dropped',
fileName: ' 参考图.png ',
base64Data: ' aW1hZ2U= ',
mimeType: 'image/png',
bytes: 5,
position: { x: 12, y: 24 },
}),
).toEqual({
action: 'dropped',
fileName: '参考图.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
position: { x: 12, y: 24 },
});
expect(
normalizeHostBridgeImportAudioResult({
action: 'selected',
fileName: ' 敲击音效.webm ',
base64Data: ' YXVkaW8= ',
mimeType: 'audio/webm',
bytes: 5,
}),
).toEqual({
action: 'selected',
fileName: '敲击音效.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
});
expect(
normalizeHostBridgeImportTextResult({
action: 'selected',
fileName: 'bad.bin',
content: 'content',
mimeType: 'application/octet-stream',
bytes: 7,
}),
).toBeNull();
expect(
normalizeHostBridgeImportAudioResult({
action: 'selected',
fileName: 'huge.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 20 * 1024 * 1024 + 1,
}),
).toBeNull();
});
test('归一化宿主本地通知内容', () => {
expect(
normalizeHostBridgeLocalNotification({
+198
View File
@@ -708,6 +708,204 @@ export function normalizeHostBridgeExportTextPayload(
};
}
function normalizeHostBridgeImportedBytes(rawBytes: unknown, maxBytes: number) {
if (
typeof rawBytes !== 'number' ||
!Number.isInteger(rawBytes) ||
rawBytes <= 0 ||
rawBytes > maxBytes
) {
return null;
}
return rawBytes;
}
function normalizeHostBridgeImportFileName(rawFileName: unknown) {
if (typeof rawFileName !== 'string') {
return null;
}
const fileName = rawFileName.trim();
if (!fileName || hasHostBridgeControlCharacter(fileName)) {
return null;
}
return fileName;
}
export function normalizeHostBridgeImportTextResult(
payload: unknown,
): FileImportTextResult | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const candidate = payload as Partial<FileImportTextResult>;
if (candidate.action !== 'selected') {
return null;
}
const fileName = normalizeHostBridgeImportFileName(candidate.fileName);
const bytes = normalizeHostBridgeImportedBytes(
candidate.bytes,
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES,
);
const mimeType = candidate.mimeType as HostBridgeTextMimeType;
if (
!fileName ||
bytes === null ||
!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType) ||
typeof candidate.content !== 'string' ||
estimateHostBridgeUtf8Bytes(candidate.content) >
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES
) {
return null;
}
return {
action: 'selected',
fileName,
content: candidate.content,
mimeType,
bytes,
};
}
export function normalizeHostBridgeImportDocumentResult(
payload: unknown,
): FileImportDocumentResult | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const candidate = payload as Partial<FileImportDocumentResult>;
if (candidate.action !== 'selected') {
return null;
}
const fileName = normalizeHostBridgeImportFileName(candidate.fileName);
const bytes = normalizeHostBridgeImportedBytes(
candidate.bytes,
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
);
const mimeType = candidate.mimeType as HostBridgeDocumentMimeType;
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
if (
!fileName ||
bytes === null ||
!HOST_BRIDGE_DOCUMENT_MIME_TYPES.includes(mimeType) ||
!base64Data ||
estimateHostBridgeBase64Bytes(base64Data) >
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES
) {
return null;
}
return {
action: 'selected',
fileName,
base64Data,
mimeType,
bytes,
};
}
export function normalizeHostBridgeImportImageResult(
payload: unknown,
): FileImportImageResult | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const candidate = payload as Partial<FileImportImageResult>;
if (
candidate.action !== 'selected' &&
candidate.action !== 'dropped' &&
candidate.action !== 'captured'
) {
return null;
}
const fileName = normalizeHostBridgeImportFileName(candidate.fileName);
const bytes = normalizeHostBridgeImportedBytes(
candidate.bytes,
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES,
);
const mimeType = candidate.mimeType as HostBridgeImageMimeType;
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
if (
!fileName ||
bytes === null ||
!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType) ||
!base64Data ||
estimateHostBridgeBase64Bytes(base64Data) >
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES
) {
return null;
}
const position =
candidate.position &&
typeof candidate.position.x === 'number' &&
Number.isFinite(candidate.position.x) &&
typeof candidate.position.y === 'number' &&
Number.isFinite(candidate.position.y)
? {
x: candidate.position.x,
y: candidate.position.y,
}
: undefined;
return {
action: candidate.action,
fileName,
base64Data,
mimeType,
bytes,
...(position ? { position } : {}),
};
}
export function normalizeHostBridgeImportAudioResult(
payload: unknown,
): FileImportAudioResult | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const candidate = payload as Partial<FileImportAudioResult>;
if (candidate.action !== 'selected') {
return null;
}
const fileName = normalizeHostBridgeImportFileName(candidate.fileName);
const bytes = normalizeHostBridgeImportedBytes(
candidate.bytes,
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES,
);
const mimeType = candidate.mimeType as HostBridgeAudioMimeType;
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
if (
!fileName ||
bytes === null ||
!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType) ||
!base64Data ||
estimateHostBridgeBase64Bytes(base64Data) >
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES
) {
return null;
}
return {
action: 'selected',
fileName,
base64Data,
mimeType,
bytes,
};
}
function normalizeHostBridgeBase64Data(rawData: unknown) {
if (typeof rawData !== 'string') {
return null;
+48 -4
View File
@@ -972,10 +972,6 @@ function assertH5HostBridgePayloadBoundaries() {
);
for (const sharedBoundary of [
'HOST_BRIDGE_TEXT_MIME_TYPES',
'HOST_BRIDGE_DOCUMENT_MIME_TYPES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_AUDIO_MIME_TYPES',
'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS',
'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS',
'HOST_BRIDGE_SCANNER_TIMEOUT_MS',
@@ -1132,6 +1128,54 @@ function assertH5HostBridgePayloadBoundaries() {
'H5 HostBridge facade must normalize app.setTitle payloads with the shared title boundary',
);
}
for (const importBoundary of [
{
functionName: 'importHostTextFile',
normalizer: 'normalizeHostBridgeImportTextResult',
},
{
functionName: 'importHostDocumentFile',
normalizer: 'normalizeHostBridgeImportDocumentResult',
},
{
functionName: 'importHostImageFile',
normalizer: 'normalizeHostBridgeImportImageResult',
},
{
functionName: 'captureHostImageFile',
normalizer: 'normalizeHostBridgeImportImageResult',
},
{
functionName: 'importHostAudioFile',
normalizer: 'normalizeHostBridgeImportAudioResult',
},
{
functionName: 'subscribeHostImageDrop',
normalizer: 'normalizeHostBridgeImportImageResult',
},
]) {
const functionSource = extractFunctionSource(
h5HostBridgeSource,
importBoundary.functionName,
);
if (!functionSource.includes(`${importBoundary.normalizer}(`)) {
throw new Error(
`${importBoundary.functionName} must normalize imported file results with shared ${importBoundary.normalizer}`,
);
}
}
for (const localMimeSet of [
'HOST_BRIDGE_TEXT_MIME_TYPE_SET',
'HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET',
'HOST_BRIDGE_IMAGE_MIME_TYPE_SET',
'HOST_BRIDGE_AUDIO_MIME_TYPE_SET',
]) {
if (h5HostBridgeSource.includes(localMimeSet)) {
throw new Error(
`H5 HostBridge facade must use shared import normalizers instead of local ${localMimeSet}`,
);
}
}
}
function assertH5NativeAppTransportTimeoutBoundaries() {
@@ -2052,6 +2052,39 @@ describe('hostBridge', () => {
await expect(importHostDocumentFile()).resolves.toBe(false);
});
test('原生 App 宿主导入结果超出共享边界时回退为 false', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string } }).request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: true,
result: {
action: 'selected',
fileName: 'too-large.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 20 * 1024 * 1024 + 1,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importAudio']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostAudioFile()).resolves.toBe(false);
});
test('原生 App 宿主取消导入图片时回退为 false', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
+15 -144
View File
@@ -48,6 +48,10 @@ import {
normalizeHostBridgeExportTextPayload,
normalizeHostBridgeExternalUrlPayload,
normalizeHostBridgeHapticsImpactStyle,
normalizeHostBridgeImportAudioResult,
normalizeHostBridgeImportDocumentResult,
normalizeHostBridgeImportImageResult,
normalizeHostBridgeImportTextResult,
normalizeHostBridgeLifecycleState,
normalizeHostBridgeLocalNotification,
normalizeHostBridgeQrCodeValue,
@@ -907,64 +911,19 @@ export async function exportHostAudioFile(
}
}
const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set(HOST_BRIDGE_IMAGE_MIME_TYPES);
function normalizeHostImageImportResult(
payload: FileImportImageResult | null | undefined,
): FileImportImageResult | false {
if (
!payload ||
typeof payload !== 'object' ||
payload.action !== 'selected' &&
payload.action !== 'dropped' &&
payload.action !== 'captured' ||
!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(payload.mimeType) ||
typeof payload.fileName !== 'string' ||
!payload.fileName.trim() ||
typeof payload.base64Data !== 'string' ||
!payload.base64Data.trim() ||
typeof payload.bytes !== 'number' ||
!Number.isInteger(payload.bytes) ||
payload.bytes <= 0
) {
return false;
}
const position =
payload.position &&
typeof payload.position.x === 'number' &&
Number.isFinite(payload.position.x) &&
typeof payload.position.y === 'number' &&
Number.isFinite(payload.position.y)
? {
x: payload.position.x,
y: payload.position.y,
}
: undefined;
return {
action: payload.action,
fileName: payload.fileName.trim(),
base64Data: payload.base64Data,
mimeType: payload.mimeType,
bytes: payload.bytes,
...(position ? { position } : {}),
};
}
export async function importHostImageFile() {
if (!canUseNativeHostCapability('file.importImage')) {
return false;
}
try {
return normalizeHostImageImportResult(
return normalizeHostBridgeImportImageResult(
await requestNativeAppHostBridge<FileImportImageResult>(
'file.importImage',
undefined,
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
) ?? false;
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
@@ -979,13 +938,13 @@ export async function captureHostImageFile() {
}
try {
return normalizeHostImageImportResult(
return normalizeHostBridgeImportImageResult(
await requestNativeAppHostBridge<FileImportImageResult>(
'file.captureImage',
undefined,
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
) ?? false;
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
@@ -1018,49 +977,19 @@ export async function scanHostQrCode() {
}
}
const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set(HOST_BRIDGE_TEXT_MIME_TYPES);
const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set(HOST_BRIDGE_DOCUMENT_MIME_TYPES);
function normalizeHostTextImportResult(
payload: FileImportTextResult | null | undefined,
): FileImportTextResult | false {
if (
!payload ||
typeof payload !== 'object' ||
payload.action !== 'selected' ||
!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(payload.mimeType) ||
typeof payload.fileName !== 'string' ||
!payload.fileName.trim() ||
typeof payload.content !== 'string' ||
typeof payload.bytes !== 'number' ||
!Number.isInteger(payload.bytes) ||
payload.bytes <= 0
) {
return false;
}
return {
action: 'selected',
fileName: payload.fileName.trim(),
content: payload.content,
mimeType: payload.mimeType,
bytes: payload.bytes,
};
}
export async function importHostTextFile() {
if (!canUseNativeHostCapability('file.importText')) {
return false;
}
try {
return normalizeHostTextImportResult(
return normalizeHostBridgeImportTextResult(
await requestNativeAppHostBridge<FileImportTextResult>(
'file.importText',
undefined,
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
) ?? false;
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
@@ -1069,47 +998,19 @@ export async function importHostTextFile() {
}
}
function normalizeHostDocumentImportResult(
payload: FileImportDocumentResult | null | undefined,
): FileImportDocumentResult | false {
if (
!payload ||
typeof payload !== 'object' ||
payload.action !== 'selected' ||
!HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET.has(payload.mimeType) ||
typeof payload.fileName !== 'string' ||
!payload.fileName.trim() ||
typeof payload.base64Data !== 'string' ||
!payload.base64Data.trim() ||
typeof payload.bytes !== 'number' ||
!Number.isInteger(payload.bytes) ||
payload.bytes <= 0
) {
return false;
}
return {
action: 'selected',
fileName: payload.fileName.trim(),
base64Data: payload.base64Data,
mimeType: payload.mimeType,
bytes: payload.bytes,
};
}
export async function importHostDocumentFile() {
if (!canUseNativeHostCapability('file.importDocument')) {
return false;
}
try {
return normalizeHostDocumentImportResult(
return normalizeHostBridgeImportDocumentResult(
await requestNativeAppHostBridge<FileImportDocumentResult>(
'file.importDocument',
undefined,
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
) ?? false;
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
@@ -1118,49 +1019,19 @@ export async function importHostDocumentFile() {
}
}
const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set(HOST_BRIDGE_AUDIO_MIME_TYPES);
function normalizeHostAudioImportResult(
payload: FileImportAudioResult | null | undefined,
): FileImportAudioResult | false {
if (
!payload ||
typeof payload !== 'object' ||
payload.action !== 'selected' ||
!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(payload.mimeType) ||
typeof payload.fileName !== 'string' ||
!payload.fileName.trim() ||
typeof payload.base64Data !== 'string' ||
!payload.base64Data.trim() ||
typeof payload.bytes !== 'number' ||
!Number.isInteger(payload.bytes) ||
payload.bytes <= 0
) {
return false;
}
return {
action: 'selected',
fileName: payload.fileName.trim(),
base64Data: payload.base64Data,
mimeType: payload.mimeType,
bytes: payload.bytes,
};
}
export async function importHostAudioFile() {
if (!canUseNativeHostCapability('file.importAudio')) {
return false;
}
try {
return normalizeHostAudioImportResult(
return normalizeHostBridgeImportAudioResult(
await requestNativeAppHostBridge<FileImportAudioResult>(
'file.importAudio',
undefined,
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
) ?? false;
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
@@ -1365,7 +1236,7 @@ export function subscribeHostImageDrop(
return subscribeNativeAppHostBridgeEvent<FileImportImageResult>(
'file.imageDropped',
(payload) => {
const normalizedPayload = normalizeHostImageImportResult(payload);
const normalizedPayload = normalizeHostBridgeImportImageResult(payload);
if (normalizedPayload) {
listener(normalizedPayload);
}