Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,15 +253,20 @@ await crypto.decryptMessage(...)
import { downloadUnsealed } from "@yeez-tech/meta-encryptor";

await downloadUnsealed({
url: "https://example.com/encrypted file",
privateKeyHex: "YOUR_PRIVATE_KEY_HEX",
url: "https://example.com/encrypted-file",
privateKey: "YOUR_PRIVATE_KEY_HEX",
filename: "decrypted-file.txt",
progressHandler: (totalItems, processedItems, readBytes, writeBytes) => {
console.log(`Progress: ${processedItems}/${totalItems}`);
onProgress: (total, processed, readBytes, writeBytes) => {
console.log(`Progress: ${processed}/${total}`);
},
// 可选:覆盖明文大小上限(字节);默认桌面 1 GiB、移动端 200 MiB
// desktopLimit: 2 * 1024 * 1024 * 1024,
// mobileLimit: 500 * 1024 * 1024,
});
```

更完整的参数说明见 [`src/browser/README.md`](./src/browser/README.md)。

##### sealedFileVersion

返回封装文件的版本号。
Expand Down
1 change: 1 addition & 0 deletions jest.config.browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default {
"<rootDir>/test/downloadFunctions.spec.mjs",
"<rootDir>/test/downloadCallbacks.spec.mjs",
"<rootDir>/test/downloadUnsealedCallbacks.spec.mjs",
"<rootDir>/test/downloadUnsealedSizeLimit.spec.mjs",
"<rootDir>/test/progress.spec.mjs",
],

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@yeez-tech/meta-encryptor",
"version": "5.0.7",
"version": "5.1.0",
"description": "Data Seal/Unseal for Fidelius",
"main": "./build/commonjs/index.node.cjs",
"module": "./build/es/index.browser.js",
Expand Down
17 changes: 17 additions & 0 deletions src/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ npm install @yeez-tech/meta-encryptor
| `onDownloadReady` | function | ❌ | 下载就绪回调:HTTP 流首个数据块进入管道时触发(HEAD+tail 完成后),可用于关闭准备蒙层 |
| `onSuccess` | function | ❌ | 成功回调 `(data: { filename }) => void` |
| `onError` | function | ❌ | 错误回调 `(error: Error) => void` |
| `desktopLimit` | number | ❌ | 桌面端明文大小上限(字节),默认 `1 GiB`(`1024 * 1024 * 1024`) |
| `mobileLimit` | number | ❌ | 移动端明文大小上限(字节),默认 `200 MiB`(`200 * 1024 * 1024`) |

超过对应上限时抛出 `MetaEncryptorError`(`code: ERR_FILE_TOO_LARGE`)。不传或非法值时回退到平台默认值。

#### 返回值

Expand Down Expand Up @@ -88,6 +92,19 @@ try {

## 使用示例

### 自定义大小上限

```javascript
await downloadUnsealed({
url,
privateKey,
filename,
// 可选:覆盖默认上限(字节)
desktopLimit: 2 * 1024 * 1024 * 1024, // 桌面 2 GiB
mobileLimit: 500 * 1024 * 1024, // 移动端 500 MiB
});
```

### 示例 1:基本使用

```javascript
Expand Down
13 changes: 11 additions & 2 deletions src/browser/downloadUnsealed.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ async function inspectSealed(url, log) {
* @param {Function} [options.onDownloadReady] - HTTP 流首个数据块进入管道时触发(可关蒙层)
* @param {Function} [options.onSuccess]
* @param {Function} [options.onError]
* @param {number} [options.desktopLimit] - desktop plaintext size limit in bytes (default 1 GiB)
* @param {number} [options.mobileLimit] - mobile plaintext size limit in bytes (default 200 MiB)
* @returns {Promise<void>}
*/
export async function downloadUnsealed({
Expand All @@ -98,7 +100,11 @@ export async function downloadUnsealed({
onProgress,
onDownloadReady,
onSuccess,
onError
onError,
/** Override desktop plaintext size limit (bytes). Default 1 GiB. */
desktopLimit,
/** Override mobile plaintext size limit (bytes). Default 200 MiB. */
mobileLimit,
}) {
const log = makeLogger(onLog);
const key = privateKey.trim()
Expand All @@ -116,7 +122,10 @@ export async function downloadUnsealed({
log(`Plaintext size=${meta.plaintextSize} bytes, sealed=${meta.sealedContentSize} bytes, totalSize=${meta.totalSize}`);

const mobile = isMobile()
const limit = mobile ? MOBILE_LIMIT : DESKTOP_LIMIT
const configured = mobile ? mobileLimit : desktopLimit
const platformDefault = mobile ? MOBILE_LIMIT : DESKTOP_LIMIT
const limit =
typeof configured === 'number' && configured > 0 ? configured : platformDefault
const mode = mobile ? 'mobile' : 'desktop'
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a';
log(`Detected ${mode} (ua=${ua}), limit ${(limit / 1024 / 1024).toFixed(0)} MB`);
Expand Down
7 changes: 5 additions & 2 deletions src/browser/stream_download.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { HttpSealedFileStream } from './HttpSealedFileStream.js'
import { MetaEncryptorError } from '../common/errors.js';
import { createProgressTransformer, createDownloadReadyTransformer } from '../common/progress.js';

const STREAMSAVER_LOAD_TIMEOUT_MS = 8000;

async function ensureStreamSaver(log) {
if (typeof window === 'undefined') return null;
if (window.streamSaver?.createWriteStream) return window.streamSaver;
Expand All @@ -12,8 +14,9 @@ async function ensureStreamSaver(log) {
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js';
s.onload = resolve;
s.onerror = reject;
const timer = setTimeout(() => reject(new Error('StreamSaver load timeout')), STREAMSAVER_LOAD_TIMEOUT_MS);
s.onload = () => { clearTimeout(timer); resolve(); };
s.onerror = (err) => { clearTimeout(timer); reject(err); };
document.head.appendChild(s);
});
if (window.streamSaver?.createWriteStream) {
Expand Down
4 changes: 2 additions & 2 deletions src/common/locale.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import en from '../locales/en.json';
import zhCN from '../locales/zh-CN.json';
import en from '../locales/en.json' with { type: 'json' };
import zhCN from '../locales/zh-CN.json' with { type: 'json' };

const _bundled = { en, 'zh-CN': zhCN };
let _messages = null;
Expand Down
28 changes: 21 additions & 7 deletions test/downloadFunctions.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('blobDownloadAndDecrypt', () => {
);

expect(result).toEqual({ ok: true });
expect(logs.some(m => m.includes('下载完成'))).toBe(true);
expect(logs.some(m => m.includes('Download complete'))).toBe(true);
expect(blobContent).not.toBeNull();
} finally {
URL.createObjectURL = origCOU;
Expand All @@ -80,7 +80,7 @@ describe('blobDownloadAndDecrypt', () => {
await expect(
blobDownloadAndDecrypt('http://mock/file', 'a'.repeat(64), 'out.bin', { log: (m) => logs.push(m) })
).rejects.toThrow('fetch fail');
expect(logs.some(m => m.includes('失败'))).toBe(true);
expect(logs.some(m => /failed/i.test(m))).toBe(true);
});
});

Expand All @@ -98,16 +98,30 @@ describe('streamDownloadAndDecrypt', () => {
);

expect(result).toEqual({ ok: true });
expect(logs.some(m => m.includes('下载完成'))).toBe(true);
expect(logs.some(m => m.includes('Download complete'))).toBe(true);
expect(chunks.length).toBe(1);
expect(new TextDecoder().decode(chunks[0])).toBe('hello from mock');
});

test('throws when no writable and no StreamSaver', async () => {
// jsdom does not load external <script> tags; force onerror so we do not hang.
const appendChild = document.head.appendChild.bind(document.head);
document.head.appendChild = (node) => {
if (node?.tagName === 'SCRIPT') {
queueMicrotask(() => node.onerror?.(new Event('error')));
return node;
}
return appendChild(node);
};

const logs = [];
await expect(
streamDownloadAndDecrypt('http://mock/file', 'a'.repeat(64), 'out.bin', { log: (m) => logs.push(m) })
).rejects.toThrow('StreamSaver');
expect(logs.some(m => m.includes('失败'))).toBe(true);
try {
await expect(
streamDownloadAndDecrypt('http://mock/file', 'a'.repeat(64), 'out.bin', { log: (m) => logs.push(m) })
).rejects.toMatchObject({ code: 'ERR_NO_STREAM_WRITABLE' });
expect(logs.some(m => /failed/i.test(m))).toBe(true);
} finally {
document.head.appendChild = appendChild;
}
});
});
112 changes: 112 additions & 0 deletions test/downloadUnsealedSizeLimit.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* downloadUnsealed size-limit overrides.
*
* node --experimental-vm-modules node_modules/.bin/jest --config jest.config.browser.mjs test/downloadUnsealedSizeLimit.spec.mjs
*/
import { jest } from '@jest/globals';
import { webcrypto as nodeWebcrypto } from 'crypto';
import { HeaderSize } from '../src/common/limits.js';

globalThis.crypto = nodeWebcrypto;

jest.unstable_mockModule('../src/browser/stream_download.js', () => ({
streamDownloadAndDecrypt: jest.fn(async () => {}),
}));
jest.unstable_mockModule('../src/browser/blob_download.js', () => ({
blobDownloadAndDecrypt: jest.fn(async () => {}),
}));

const { downloadUnsealed } = await import('../src/browser/downloadUnsealed.js');
const { MetaEncryptorError } = await import('../src/common/errors.js');
const { streamDownloadAndDecrypt } = await import('../src/browser/stream_download.js');

function makeInspectDiskBuf(contentBytes = 200) {
const header = Buffer.alloc(HeaderSize);
Buffer.from('1fe2ef7f3ed18847', 'hex').copy(header, 0);
header.writeUInt32LE(2, 8);
header.writeUInt32LE(0, 16);
header.writeUInt32LE(1, 24);
return Buffer.concat([Buffer.alloc(contentBytes, 0xab), header]);
}

function createMockFetch(diskBuf) {
const totalSize = diskBuf.length;
return jest.fn(async (url, init = {}) => {
if (init.method === 'HEAD') {
return {
ok: true,
status: 200,
url,
headers: {
get: (k) => {
const key = String(k).toLowerCase();
if (key === 'content-length') return String(totalSize);
if (key === 'accept-ranges') return 'bytes';
return null;
},
},
};
}
const range = init.headers?.Range || init.headers?.range || '';
const m = /bytes=(\d+)-(\d+)/.exec(range);
const start = m ? Number(m[1]) : 0;
const end = m ? Number(m[2]) : totalSize - 1;
const slice = diskBuf.subarray(start, end + 1);
return {
ok: true,
status: 206,
url,
headers: {
get: (k) => {
const key = String(k).toLowerCase();
if (key === 'content-length') return String(slice.length);
if (key === 'content-range') return `bytes ${start}-${end}/${totalSize}`;
return null;
},
},
arrayBuffer: async () => slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength),
};
});
}

describe('downloadUnsealed size limit config', () => {
const key = 'a'.repeat(64);

beforeEach(() => {
streamDownloadAndDecrypt.mockClear();
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Mozilla/5.0 (Macintosh)' },
configurable: true,
});
});

test('rejects when plaintext exceeds default desktop 1GiB', async () => {
// plaintextSize = sealedContentSize - itemNumber*100
// sealedContentSize = totalSize - HeaderSize - blockNumber*BlockInfoSize
// Make a large content so plaintextSize > 1GiB is hard in unit test;
// instead spy by using a tiny custom desktopLimit.
const diskBuf = makeInspectDiskBuf(500);
globalThis.fetch = createMockFetch(diskBuf);
await expect(
downloadUnsealed({
url: 'https://example.com/file',
privateKey: key,
filename: 'out.bin',
desktopLimit: 1, // 1 byte — force reject
})
).rejects.toMatchObject({ code: 'ERR_FILE_TOO_LARGE' });
expect(streamDownloadAndDecrypt).not.toHaveBeenCalled();
});

test('allows oversized (vs default) when desktopLimit raised', async () => {
const diskBuf = makeInspectDiskBuf(500);
globalThis.fetch = createMockFetch(diskBuf);
await downloadUnsealed({
url: 'https://example.com/file',
privateKey: key,
filename: 'out.bin',
desktopLimit: 10 * 1024 * 1024 * 1024,
});
expect(streamDownloadAndDecrypt).toHaveBeenCalled();
});
});
Loading