Documentation
diff --git a/src/index.ts b/src/index.ts
index 26b21b9..d51f6b4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,15 @@ import BsDiffPatch from './NativeBsDiffPatch';
export type BinaryInput = ArrayBuffer | ArrayBufferView | Blob;
+export interface BinaryOperationOptions {
+ /** Cancel the Web Worker operation. */
+ signal?: AbortSignal;
+ /** Reject when either input exceeds this number of bytes. */
+ maxInputBytes?: number;
+ /** Reject when the generated or restored output exceeds this number of bytes. */
+ maxOutputBytes?: number;
+}
+
/**
* generate new file from old file and patch file
* @param oldFile orignal file path
@@ -44,7 +53,8 @@ function rejectWebOnlyApi(methodName: string): Promise
{
*/
export function diffBytes(
_oldData: BinaryInput,
- _newData: BinaryInput
+ _newData: BinaryInput,
+ _options?: BinaryOperationOptions
): Promise {
return rejectWebOnlyApi('diffBytes');
}
@@ -54,7 +64,8 @@ export function diffBytes(
*/
export function patchBytes(
_oldData: BinaryInput,
- _patchData: BinaryInput
+ _patchData: BinaryInput,
+ _options?: BinaryOperationOptions
): Promise {
return rejectWebOnlyApi('patchBytes');
}
diff --git a/web/bsdiffpatch.mjs b/web/bsdiffpatch.mjs
index 106150e..0519c66 100644
Binary files a/web/bsdiffpatch.mjs and b/web/bsdiffpatch.mjs differ
diff --git a/web/index.d.mts b/web/index.d.mts
index 9958046..3ebf585 100644
--- a/web/index.d.mts
+++ b/web/index.d.mts
@@ -1,5 +1,11 @@
export type BinaryInput = ArrayBuffer | ArrayBufferView | Blob;
+export interface BinaryOperationOptions {
+ signal?: AbortSignal;
+ maxInputBytes?: number;
+ maxOutputBytes?: number;
+}
+
export function diff(
oldFile: string,
newFile: string,
@@ -14,10 +20,12 @@ export function patch(
export function diffBytes(
oldData: BinaryInput,
- newData: BinaryInput
+ newData: BinaryInput,
+ options?: BinaryOperationOptions
): Promise;
export function patchBytes(
oldData: BinaryInput,
- patchData: BinaryInput
+ patchData: BinaryInput,
+ options?: BinaryOperationOptions
): Promise;
diff --git a/web/index.mjs b/web/index.mjs
index afbb2ab..a77df4c 100644
--- a/web/index.mjs
+++ b/web/index.mjs
@@ -4,6 +4,48 @@ function createError(code, message) {
return error;
}
+function validateLimit(value, fieldName) {
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
+ throw createError(
+ 'EINVAL',
+ `${fieldName} must be a non-negative safe integer`
+ );
+ }
+}
+
+function inputByteLength(input) {
+ if (input instanceof ArrayBuffer || ArrayBuffer.isView(input)) {
+ return input.byteLength;
+ }
+ if (typeof Blob !== 'undefined' && input instanceof Blob) {
+ return input.size;
+ }
+ return undefined;
+}
+
+function enforceLimit(actualBytes, maximumBytes, fieldName) {
+ if (maximumBytes !== undefined && actualBytes > maximumBytes) {
+ throw createError(
+ 'ERESOURCE',
+ `${fieldName} is ${actualBytes} bytes and exceeds the ${maximumBytes} byte limit`
+ );
+ }
+}
+
+function patchOutputSize(patchData) {
+ if (patchData.byteLength < 24) {
+ return undefined;
+ }
+ let outputSize = 0n;
+ for (let index = 23; index >= 16; index -= 1) {
+ if (index === 23 && (patchData[index] & 0x80) !== 0) {
+ return undefined;
+ }
+ outputSize = outputSize * 256n + BigInt(patchData[index]);
+ }
+ return outputSize;
+}
+
async function toUint8Array(input, fieldName) {
if (input instanceof ArrayBuffer) {
return new Uint8Array(input.slice(0));
@@ -27,19 +69,104 @@ async function toUint8Array(input, fieldName) {
);
}
-async function runWorker(operation, oldInput, input) {
- if (typeof Worker === 'undefined') {
- throw createError(
- 'EUNSUPPORTED',
- 'Web Workers are required to run react-native-bs-diff-patch on Web'
- );
+let sharedWorker;
+let sharedRequestId = 0;
+const sharedRequests = new Map();
+
+function responseError(operation, workerError) {
+ return createError(
+ workerError && workerError.code ? workerError.code : 'EWEBASSEMBLY',
+ workerError && workerError.message
+ ? workerError.message
+ : `${operation} worker failed`
+ );
+}
+
+function resetSharedWorker(error) {
+ sharedWorker?.terminate();
+ sharedWorker = undefined;
+ for (const request of sharedRequests.values()) {
+ request.reject(error);
}
+ sharedRequests.clear();
+}
- const [oldFileData, inputFileData] = await Promise.all([
- toUint8Array(oldInput, 'oldData'),
- toUint8Array(input, operation === 'diff' ? 'newData' : 'patchData'),
- ]);
+function getSharedWorker() {
+ if (sharedWorker) {
+ return sharedWorker;
+ }
+
+ sharedWorker = new Worker(new URL('./worker.mjs', import.meta.url), {
+ type: 'module',
+ });
+ sharedWorker.onmessage = (event) => {
+ const request = sharedRequests.get(event.data && event.data.id);
+ if (!request) {
+ return;
+ }
+ sharedRequests.delete(event.data.id);
+
+ if (event.data.ok) {
+ try {
+ enforceLimit(
+ event.data.output.byteLength,
+ request.maxOutputBytes,
+ 'output'
+ );
+ request.resolve(event.data.output);
+ } catch (error) {
+ request.reject(error);
+ }
+ return;
+ }
+ request.reject(responseError(request.operation, event.data.error));
+ };
+ sharedWorker.onerror = (event) => {
+ resetSharedWorker(
+ createError(
+ 'EWEBASSEMBLY',
+ event.message || 'Shared Web Worker failed to load'
+ )
+ );
+ };
+ sharedWorker.onmessageerror = () => {
+ resetSharedWorker(
+ createError('EWEBASSEMBLY', 'Shared Web Worker response was invalid')
+ );
+ };
+ return sharedWorker;
+}
+
+function runSharedWorker(operation, oldFileData, inputFileData, options) {
+ const worker = getSharedWorker();
+ const id = ++sharedRequestId;
+
+ return new Promise((resolve, reject) => {
+ sharedRequests.set(id, {
+ maxOutputBytes: options.maxOutputBytes,
+ operation,
+ reject,
+ resolve,
+ });
+ try {
+ worker.postMessage(
+ {
+ id,
+ operation,
+ oldFileData,
+ inputFileData,
+ maxOutputBytes: options.maxOutputBytes,
+ },
+ [oldFileData.buffer, inputFileData.buffer]
+ );
+ } catch (error) {
+ sharedRequests.delete(id);
+ reject(error);
+ }
+ });
+}
+function runDedicatedWorker(operation, oldFileData, inputFileData, options) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./worker.mjs', import.meta.url), {
type: 'module',
@@ -51,29 +178,36 @@ async function runWorker(operation, oldInput, input) {
return;
}
settled = true;
+ options.signal?.removeEventListener('abort', abort);
worker.terminate();
callback();
};
+ const abort = () => {
+ finish(() => reject(createError('EABORTED', `${operation} was aborted`)));
+ };
+
+ options.signal?.addEventListener('abort', abort, { once: true });
+ if (options.signal?.aborted) {
+ abort();
+ return;
+ }
worker.onmessage = (event) => {
if (event.data && event.data.ok) {
- finish(() => resolve(event.data.output));
+ const output = event.data.output;
+ try {
+ enforceLimit(output.byteLength, options.maxOutputBytes, 'output');
+ finish(() => resolve(output));
+ } catch (error) {
+ finish(() => reject(error));
+ }
return;
}
- const workerError = event.data && event.data.error;
finish(() =>
- reject(
- createError(
- workerError && workerError.code ? workerError.code : 'EWEBASSEMBLY',
- workerError && workerError.message
- ? workerError.message
- : `${operation} worker failed`
- )
- )
+ reject(responseError(operation, event.data && event.data.error))
);
};
-
worker.onerror = (event) => {
finish(() =>
reject(
@@ -84,7 +218,6 @@ async function runWorker(operation, oldInput, input) {
)
);
};
-
worker.onmessageerror = () => {
finish(() =>
reject(
@@ -96,13 +229,72 @@ async function runWorker(operation, oldInput, input) {
);
};
- worker.postMessage({ operation, oldFileData, inputFileData }, [
- oldFileData.buffer,
- inputFileData.buffer,
- ]);
+ try {
+ worker.postMessage(
+ {
+ operation,
+ oldFileData,
+ inputFileData,
+ maxOutputBytes: options.maxOutputBytes,
+ },
+ [oldFileData.buffer, inputFileData.buffer]
+ );
+ } catch (error) {
+ finish(() => reject(error));
+ }
});
}
+async function runWorker(operation, oldInput, input, options = {}) {
+ if (typeof Worker === 'undefined') {
+ throw createError(
+ 'EUNSUPPORTED',
+ 'Web Workers are required to run react-native-bs-diff-patch on Web'
+ );
+ }
+
+ validateLimit(options.maxInputBytes, 'maxInputBytes');
+ validateLimit(options.maxOutputBytes, 'maxOutputBytes');
+ if (options.signal?.aborted) {
+ throw createError('EABORTED', `${operation} was aborted`);
+ }
+
+ const oldInputBytes = inputByteLength(oldInput);
+ const inputBytes = inputByteLength(input);
+ if (oldInputBytes !== undefined) {
+ enforceLimit(oldInputBytes, options.maxInputBytes, 'oldData');
+ }
+ if (inputBytes !== undefined) {
+ enforceLimit(
+ inputBytes,
+ options.maxInputBytes,
+ operation === 'diff' ? 'newData' : 'patchData'
+ );
+ }
+
+ const [oldFileData, inputFileData] = await Promise.all([
+ toUint8Array(oldInput, 'oldData'),
+ toUint8Array(input, operation === 'diff' ? 'newData' : 'patchData'),
+ ]);
+
+ if (operation === 'patch' && options.maxOutputBytes !== undefined) {
+ const declaredOutputSize = patchOutputSize(inputFileData);
+ if (
+ declaredOutputSize !== undefined &&
+ declaredOutputSize > BigInt(options.maxOutputBytes)
+ ) {
+ throw createError(
+ 'ERESOURCE',
+ `output exceeds the configured ${options.maxOutputBytes} byte limit`
+ );
+ }
+ }
+
+ return options.signal
+ ? runDedicatedWorker(operation, oldFileData, inputFileData, options)
+ : runSharedWorker(operation, oldFileData, inputFileData, options);
+}
+
function rejectPathApi(methodName) {
return Promise.reject(
createError(
@@ -120,10 +312,10 @@ export function patch() {
return rejectPathApi('patch');
}
-export function diffBytes(oldData, newData) {
- return runWorker('diff', oldData, newData);
+export function diffBytes(oldData, newData, options) {
+ return runWorker('diff', oldData, newData, options);
}
-export function patchBytes(oldData, patchData) {
- return runWorker('patch', oldData, patchData);
+export function patchBytes(oldData, patchData, options) {
+ return runWorker('patch', oldData, patchData, options);
}
diff --git a/web/operations.mjs b/web/operations.mjs
index a78a487..339654b 100644
--- a/web/operations.mjs
+++ b/web/operations.mjs
@@ -6,6 +6,31 @@ const OUTPUT_FILE = '/output-file';
const PATCH_MAGIC = new Uint8Array([
69, 78, 68, 83, 76, 69, 89, 47, 66, 83, 68, 73, 70, 70, 52, 51,
]);
+let modulePromise;
+
+function getModule() {
+ if (!modulePromise) {
+ const pendingModule = createBsDiffPatchModule({
+ print: () => {},
+ printErr: () => {},
+ });
+ modulePromise = pendingModule;
+ pendingModule.catch(() => {
+ if (modulePromise === pendingModule) {
+ modulePromise = undefined;
+ }
+ });
+ }
+ return modulePromise;
+}
+
+function removeFile(module, filePath) {
+ try {
+ module.FS.unlink(filePath);
+ } catch {
+ // The operation may have failed before creating every MEMFS file.
+ }
+}
function validatePatchHeader(patchData) {
if (patchData.byteLength < 24) {
@@ -21,6 +46,22 @@ function validatePatchHeader(patchData) {
if ((patchData[23] & 0x80) !== 0) {
throw new Error('corrupt patch output size');
}
+
+ let outputSize = 0n;
+ for (let index = 23; index >= 16; index -= 1) {
+ outputSize = outputSize * 256n + BigInt(patchData[index]);
+ }
+ return outputSize;
+}
+
+function enforceOutputLimit(outputSize, maxOutputBytes) {
+ if (maxOutputBytes !== undefined && outputSize > BigInt(maxOutputBytes)) {
+ const error = new Error(
+ `output exceeds the configured ${maxOutputBytes} byte limit`
+ );
+ error.code = 'ERESOURCE';
+ throw error;
+ }
}
function createOperationError(operation, error) {
@@ -29,41 +70,52 @@ function createOperationError(operation, error) {
? error.message
: String(error || 'unknown WebAssembly error');
const wrapped = new Error(`${operation} failed: ${message}`);
- wrapped.code = 'EWEBASSEMBLY';
+ wrapped.code = error && error.code ? error.code : 'EWEBASSEMBLY';
return wrapped;
}
-export async function runOperation(operation, oldFileData, inputFileData) {
+export async function runOperation(
+ operation,
+ oldFileData,
+ inputFileData,
+ options = {}
+) {
try {
if (operation === 'patch') {
- validatePatchHeader(inputFileData);
+ const outputSize = validatePatchHeader(inputFileData);
+ enforceOutputLimit(outputSize, options.maxOutputBytes);
}
- const module = await createBsDiffPatchModule({
- print: () => {},
- printErr: () => {},
- });
+ const module = await getModule();
- module.FS.writeFile(OLD_FILE, oldFileData);
- module.FS.writeFile(INPUT_FILE, inputFileData);
+ try {
+ module.FS.writeFile(OLD_FILE, oldFileData);
+ module.FS.writeFile(INPUT_FILE, inputFileData);
- const functionName = operation === 'diff' ? 'bsDiffFile' : 'bsPatchFile';
- const args =
- operation === 'diff'
- ? [OLD_FILE, INPUT_FILE, OUTPUT_FILE]
- : [OLD_FILE, OUTPUT_FILE, INPUT_FILE];
- const result = module.ccall(
- functionName,
- 'number',
- ['string', 'string', 'string'],
- args
- );
+ const functionName = operation === 'diff' ? 'bsDiffFile' : 'bsPatchFile';
+ const args =
+ operation === 'diff'
+ ? [OLD_FILE, INPUT_FILE, OUTPUT_FILE]
+ : [OLD_FILE, OUTPUT_FILE, INPUT_FILE];
+ const result = module.ccall(
+ functionName,
+ 'number',
+ ['string', 'string', 'string'],
+ args
+ );
- if (result !== 0) {
- throw new Error(`native function returned ${result}`);
- }
+ if (result !== 0) {
+ throw new Error(`native function returned ${result}`);
+ }
- return module.FS.readFile(OUTPUT_FILE).slice();
+ const output = module.FS.readFile(OUTPUT_FILE).slice();
+ enforceOutputLimit(BigInt(output.byteLength), options.maxOutputBytes);
+ return output;
+ } finally {
+ removeFile(module, OLD_FILE);
+ removeFile(module, INPUT_FILE);
+ removeFile(module, OUTPUT_FILE);
+ }
} catch (error) {
throw createOperationError(operation, error);
}
diff --git a/web/worker.mjs b/web/worker.mjs
index 9541493..8dfdc95 100644
--- a/web/worker.mjs
+++ b/web/worker.mjs
@@ -1,21 +1,29 @@
import { runOperation } from './operations.mjs';
-self.onmessage = async (event) => {
- const { operation, oldFileData, inputFileData } = event.data;
+let operationQueue = Promise.resolve();
- try {
- const output = await runOperation(operation, oldFileData, inputFileData);
- self.postMessage({ ok: true, output }, [output.buffer]);
- } catch (error) {
- self.postMessage({
- ok: false,
- error: {
- code: error && error.code ? error.code : 'EWEBASSEMBLY',
- message:
- error instanceof Error
- ? error.message
- : String(error || 'unknown error'),
- },
- });
- }
+self.onmessage = (event) => {
+ const { id, operation, oldFileData, inputFileData, maxOutputBytes } =
+ event.data;
+
+ operationQueue = operationQueue.then(async () => {
+ try {
+ const output = await runOperation(operation, oldFileData, inputFileData, {
+ maxOutputBytes,
+ });
+ self.postMessage({ id, ok: true, output }, [output.buffer]);
+ } catch (error) {
+ self.postMessage({
+ id,
+ ok: false,
+ error: {
+ code: error && error.code ? error.code : 'EWEBASSEMBLY',
+ message:
+ error instanceof Error
+ ? error.message
+ : String(error || 'unknown error'),
+ },
+ });
+ }
+ });
};
diff --git a/yarn.lock b/yarn.lock
index 238c828..532ee3e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11224,6 +11224,11 @@ __metadata:
peerDependencies:
react: "*"
react-native: "*"
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-native:
+ optional: true
languageName: unknown
linkType: soft