直连移动壳载荷边界契约
移动壳文件能力直接导入共享 HostBridge MIME 和大小边界 移动壳配置检查禁止重新本地声明文件载荷边界 更新原生壳方案和共享决策记录
This commit is contained in:
@@ -182,52 +182,6 @@ function extractNumberConstExport(source, exportName) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function evaluateNumberExpression(expression) {
|
||||
const tokens = expression
|
||||
.split('*')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
if (
|
||||
tokens.length === 0 ||
|
||||
tokens.some((token) => !/^\d+$/.test(token))
|
||||
) {
|
||||
throw new Error(`unsupported numeric expression ${expression}`);
|
||||
}
|
||||
|
||||
return tokens.reduce((value, token) => value * Number(token), 1);
|
||||
}
|
||||
|
||||
function extractNumberExpressionConstExport(source, exportName) {
|
||||
const match = source.match(
|
||||
new RegExp(`export const ${exportName}\\s*=\\s*([^;]+);`),
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`unable to read ${exportName}`);
|
||||
}
|
||||
|
||||
return evaluateNumberExpression(match[1]);
|
||||
}
|
||||
|
||||
function extractLocalNumberConst(source, constName) {
|
||||
const match = source.match(new RegExp(`const ${constName}\\s*=\\s*([^;]+);`));
|
||||
if (!match) {
|
||||
throw new Error(`unable to read local const ${constName}`);
|
||||
}
|
||||
|
||||
return evaluateNumberExpression(match[1]);
|
||||
}
|
||||
|
||||
function extractStringSetConst(source, constName) {
|
||||
const match = source.match(
|
||||
new RegExp(`const ${constName}[^=]*= new Set[^\\[]*\\[([\\s\\S]*?)\\]\\);`),
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`unable to read string Set ${constName}`);
|
||||
}
|
||||
|
||||
return [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]);
|
||||
}
|
||||
|
||||
function extractMobileBridgeHandledMethods(source) {
|
||||
const match = source.match(
|
||||
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
|
||||
@@ -533,58 +487,6 @@ const sharedHostBridgeVersion = extractNumberConstExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_VERSION',
|
||||
);
|
||||
const sharedHostBridgePayloadLimits = {
|
||||
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractNumberExpressionConstExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractNumberExpressionConstExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractNumberExpressionConstExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractNumberExpressionConstExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractNumberExpressionConstExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractNumberExpressionConstExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
|
||||
),
|
||||
};
|
||||
const mobileHostBridgePayloadLimits = {
|
||||
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractLocalNumberConst(
|
||||
hostBridgeSource,
|
||||
'EXPORT_TEXT_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractLocalNumberConst(
|
||||
hostBridgeSource,
|
||||
'IMPORT_TEXT_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractLocalNumberConst(
|
||||
hostBridgeSource,
|
||||
'EXPORT_IMAGE_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractLocalNumberConst(
|
||||
hostBridgeSource,
|
||||
'IMPORT_IMAGE_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractLocalNumberConst(
|
||||
hostBridgeSource,
|
||||
'EXPORT_AUDIO_MAX_BYTES',
|
||||
),
|
||||
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractLocalNumberConst(
|
||||
hostBridgeSource,
|
||||
'IMPORT_AUDIO_MAX_BYTES',
|
||||
),
|
||||
};
|
||||
const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource);
|
||||
const mobileCapabilities = extractStringArrayExport(
|
||||
hostBridgeSource,
|
||||
@@ -597,32 +499,49 @@ const iosMobileCapabilities = extractStringArrayExport(
|
||||
const mobileCapabilitySet = new Set(mobileCapabilities);
|
||||
const iosMobileCapabilitySet = new Set(iosMobileCapabilities);
|
||||
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
|
||||
for (const [limitName, sharedLimit] of Object.entries(
|
||||
sharedHostBridgePayloadLimits,
|
||||
)) {
|
||||
const mobileLimit = mobileHostBridgePayloadLimits[limitName];
|
||||
if (mobileLimit !== sharedLimit) {
|
||||
const sharedPayloadBoundaryImports = [
|
||||
'HOST_BRIDGE_AUDIO_MIME_TYPES',
|
||||
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
|
||||
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
|
||||
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
|
||||
'HOST_BRIDGE_IMAGE_MIME_TYPES',
|
||||
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
|
||||
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
|
||||
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
|
||||
'HOST_BRIDGE_TEXT_MIME_TYPES',
|
||||
];
|
||||
for (const boundaryImport of sharedPayloadBoundaryImports) {
|
||||
if (!hostBridgeSource.includes(boundaryImport)) {
|
||||
throw new Error(
|
||||
`mobile shell ${limitName} drifted: expected ${sharedLimit} but got ${mobileLimit}`,
|
||||
`mobile shell must import shared HostBridge payload boundary ${boundaryImport}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertSameList(
|
||||
extractStringSetConst(hostBridgeSource, 'HOST_BRIDGE_TEXT_MIME_TYPES'),
|
||||
extractStringArrayExport(sharedContractSource, 'HOST_BRIDGE_TEXT_MIME_TYPES'),
|
||||
'mobile shell text MIME types',
|
||||
);
|
||||
assertSameList(
|
||||
extractStringSetConst(hostBridgeSource, 'HOST_BRIDGE_IMAGE_MIME_TYPES'),
|
||||
extractStringArrayExport(sharedContractSource, 'HOST_BRIDGE_IMAGE_MIME_TYPES'),
|
||||
'mobile shell image MIME types',
|
||||
);
|
||||
assertSameList(
|
||||
extractStringSetConst(hostBridgeSource, 'HOST_BRIDGE_AUDIO_MIME_TYPES'),
|
||||
extractStringArrayExport(sharedContractSource, 'HOST_BRIDGE_AUDIO_MIME_TYPES'),
|
||||
'mobile shell audio MIME types',
|
||||
);
|
||||
const forbiddenLocalPayloadBoundaryDeclarations = [
|
||||
'EXPORT_TEXT_MAX_BYTES',
|
||||
'IMPORT_TEXT_MAX_BYTES',
|
||||
'EXPORT_IMAGE_MAX_BYTES',
|
||||
'IMPORT_IMAGE_MAX_BYTES',
|
||||
'EXPORT_AUDIO_MAX_BYTES',
|
||||
'IMPORT_AUDIO_MAX_BYTES',
|
||||
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
|
||||
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
|
||||
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
|
||||
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
|
||||
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
|
||||
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
|
||||
'HOST_BRIDGE_TEXT_MIME_TYPES',
|
||||
'HOST_BRIDGE_IMAGE_MIME_TYPES',
|
||||
'HOST_BRIDGE_AUDIO_MIME_TYPES',
|
||||
];
|
||||
for (const localBoundary of forbiddenLocalPayloadBoundaryDeclarations) {
|
||||
if (new RegExp(`const ${localBoundary}\\s*=`).test(hostBridgeSource)) {
|
||||
throw new Error(
|
||||
`mobile shell must not redeclare HostBridge payload boundary ${localBoundary}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const unknownHandledMobileMethods = handledMobileMethods.filter(
|
||||
(method) => !sharedMethods.includes(method),
|
||||
|
||||
@@ -17,41 +17,31 @@ import {
|
||||
type HostBridgeError,
|
||||
type HostBridgeImageMimeType,
|
||||
type HostBridgeTextMimeType,
|
||||
HOST_BRIDGE_AUDIO_MIME_TYPES,
|
||||
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES,
|
||||
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES,
|
||||
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES,
|
||||
HOST_BRIDGE_IMAGE_MIME_TYPES,
|
||||
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES,
|
||||
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES,
|
||||
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES,
|
||||
HOST_BRIDGE_TEXT_MIME_TYPES,
|
||||
normalizeHostBridgeExportFileName,
|
||||
} from '../../../../packages/shared/src/contracts/hostBridge';
|
||||
import { invalidRequest } from './protocol';
|
||||
|
||||
const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const HOST_BRIDGE_TEXT_MIME_TYPES = new Set<HostBridgeTextMimeType>([
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'text/csv',
|
||||
'application/json',
|
||||
]);
|
||||
const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set<HostBridgeImageMimeType>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
]);
|
||||
const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set<HostBridgeAudioMimeType>([
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
]);
|
||||
const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set<HostBridgeTextMimeType>(
|
||||
HOST_BRIDGE_TEXT_MIME_TYPES,
|
||||
);
|
||||
const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set<HostBridgeImageMimeType>(
|
||||
HOST_BRIDGE_IMAGE_MIME_TYPES,
|
||||
);
|
||||
const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set<HostBridgeAudioMimeType>(
|
||||
HOST_BRIDGE_AUDIO_MIME_TYPES,
|
||||
);
|
||||
export const MOBILE_AUDIO_DOCUMENT_PICKER_TYPES = [
|
||||
'audio/*',
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
...HOST_BRIDGE_AUDIO_MIME_TYPES,
|
||||
];
|
||||
const BASE64_ALPHABET =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
@@ -247,7 +237,7 @@ export async function exportTextFile(
|
||||
}
|
||||
|
||||
const bytes = utf8ByteLength(content);
|
||||
if (bytes > EXPORT_TEXT_MAX_BYTES) {
|
||||
if (bytes > HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES) {
|
||||
throw invalidRequest('content exceeds file export size limit');
|
||||
}
|
||||
|
||||
@@ -282,7 +272,7 @@ function normalizeImportedTextMimeType(
|
||||
): HostBridgeTextMimeType | null {
|
||||
if (typeof value === 'string') {
|
||||
const mimeType = value.toLowerCase();
|
||||
if (HOST_BRIDGE_TEXT_MIME_TYPES.has(mimeType as HostBridgeTextMimeType)) {
|
||||
if (HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)) {
|
||||
return mimeType as HostBridgeTextMimeType;
|
||||
}
|
||||
}
|
||||
@@ -331,7 +321,7 @@ export async function importTextFile(): Promise<FileImportTextResult> {
|
||||
}
|
||||
if (
|
||||
typeof asset.size === 'number' &&
|
||||
(asset.size <= 0 || asset.size > IMPORT_TEXT_MAX_BYTES)
|
||||
(asset.size <= 0 || asset.size > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES)
|
||||
) {
|
||||
throw invalidRequest('text exceeds file import size limit');
|
||||
}
|
||||
@@ -339,7 +329,7 @@ export async function importTextFile(): Promise<FileImportTextResult> {
|
||||
const file = new File(asset.uri);
|
||||
const content = await file.text();
|
||||
const bytes = utf8ByteLength(content);
|
||||
if (bytes <= 0 || bytes > IMPORT_TEXT_MAX_BYTES) {
|
||||
if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) {
|
||||
throw invalidRequest('text exceeds file import size limit');
|
||||
}
|
||||
|
||||
@@ -359,7 +349,7 @@ export async function exportImageFile(
|
||||
const mimeType = exportPayload?.mimeType;
|
||||
if (
|
||||
typeof mimeType !== 'string' ||
|
||||
!HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType)
|
||||
!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType as HostBridgeImageMimeType)
|
||||
) {
|
||||
throw invalidRequest('mimeType must be an allowed image type');
|
||||
}
|
||||
@@ -369,7 +359,7 @@ export async function exportImageFile(
|
||||
throw invalidRequest('base64Data is required');
|
||||
}
|
||||
const bytes = base64DecodedByteLength(base64Data);
|
||||
if (bytes > EXPORT_IMAGE_MAX_BYTES) {
|
||||
if (bytes > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES) {
|
||||
throw invalidRequest('image exceeds file export size limit');
|
||||
}
|
||||
ensureImageBytesMatchMimeType(base64Data, mimeType as HostBridgeImageMimeType);
|
||||
@@ -409,7 +399,7 @@ function normalizeImportedImageMimeType(
|
||||
}
|
||||
|
||||
const mimeType = value.toLowerCase();
|
||||
return HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType)
|
||||
return HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType as HostBridgeImageMimeType)
|
||||
? (mimeType as HostBridgeImageMimeType)
|
||||
: null;
|
||||
}
|
||||
@@ -451,13 +441,13 @@ function imagePickerResultToImportPayload(
|
||||
}
|
||||
|
||||
const bytes = base64DecodedByteLength(base64Data);
|
||||
if (bytes <= 0 || bytes > IMPORT_IMAGE_MAX_BYTES) {
|
||||
if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) {
|
||||
throw invalidRequest('image exceeds file import size limit');
|
||||
}
|
||||
ensureImageBytesMatchMimeType(base64Data, mimeType);
|
||||
if (
|
||||
typeof asset.fileSize === 'number' &&
|
||||
asset.fileSize > IMPORT_IMAGE_MAX_BYTES
|
||||
asset.fileSize > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES
|
||||
) {
|
||||
throw invalidRequest('image exceeds file import size limit');
|
||||
}
|
||||
@@ -547,7 +537,7 @@ function normalizeImportedAudioMimeType(
|
||||
): HostBridgeAudioMimeType | null {
|
||||
if (typeof value === 'string') {
|
||||
const mimeType = value.toLowerCase();
|
||||
if (HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType)) {
|
||||
if (HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType as HostBridgeAudioMimeType)) {
|
||||
return mimeType as HostBridgeAudioMimeType;
|
||||
}
|
||||
}
|
||||
@@ -579,7 +569,7 @@ export async function exportAudioFile(
|
||||
const mimeType = exportPayload?.mimeType;
|
||||
if (
|
||||
typeof mimeType !== 'string' ||
|
||||
!HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType)
|
||||
!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType as HostBridgeAudioMimeType)
|
||||
) {
|
||||
throw invalidRequest('mimeType must be an allowed audio type');
|
||||
}
|
||||
@@ -589,7 +579,7 @@ export async function exportAudioFile(
|
||||
throw invalidRequest('base64Data is required');
|
||||
}
|
||||
const bytes = base64DecodedByteLength(base64Data);
|
||||
if (bytes <= 0 || bytes > EXPORT_AUDIO_MAX_BYTES) {
|
||||
if (bytes <= 0 || bytes > HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES) {
|
||||
throw invalidRequest('audio exceeds file export size limit');
|
||||
}
|
||||
ensureAudioBytesMatchMimeType(base64Data, mimeType as HostBridgeAudioMimeType);
|
||||
@@ -648,7 +638,7 @@ export async function importAudioFile(): Promise<FileImportAudioResult> {
|
||||
}
|
||||
if (
|
||||
typeof asset.size === 'number' &&
|
||||
(asset.size <= 0 || asset.size > IMPORT_AUDIO_MAX_BYTES)
|
||||
(asset.size <= 0 || asset.size > HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES)
|
||||
) {
|
||||
throw invalidRequest('audio exceeds file import size limit');
|
||||
}
|
||||
@@ -659,7 +649,7 @@ export async function importAudioFile(): Promise<FileImportAudioResult> {
|
||||
throw invalidRequest('base64Data is required');
|
||||
}
|
||||
const bytes = base64DecodedByteLength(base64Data);
|
||||
if (bytes <= 0 || bytes > IMPORT_AUDIO_MAX_BYTES) {
|
||||
if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES) {
|
||||
throw invalidRequest('audio exceeds file import size limit');
|
||||
}
|
||||
ensureAudioBytesMatchMimeType(base64Data, mimeType);
|
||||
|
||||
Reference in New Issue
Block a user