SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
promises as fsPromises,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
AUDIO_OUT_FORMATS,
|
||||
convertAudioBytes,
|
||||
isAudioOutFormat,
|
||||
} from '@snowluma/protocol/highway/ffmpeg-addon';
|
||||
|
||||
// convertAudioBytes is the pure (#165) transcode seam: write input → the
|
||||
// addon's dedicated audio transcoder → read output as base64 → always clean up
|
||||
// temp files. Unit tests inject a fake; native/audio-transcode.test.ts covers
|
||||
// each bundled release-platform addon in the build matrices.
|
||||
|
||||
const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'sl-audio-test-'));
|
||||
afterAll(() => rmSync(tmpDir, { recursive: true, force: true }));
|
||||
|
||||
/** Fake addon: writes deterministic bytes to the output path and reports ok. */
|
||||
const fakeOk = {
|
||||
decodeAudioToFmt: async (_in: string, out: string, format: string) => {
|
||||
writeFileSync(out, Buffer.from('WAVDATA')); // stand-in converted payload
|
||||
return { result: true, sampleRate: 24_000, channels: 1, format };
|
||||
},
|
||||
};
|
||||
|
||||
describe('isAudioOutFormat / AUDIO_OUT_FORMATS', () => {
|
||||
it("accepts exactly NapCat's allowlist", () => {
|
||||
expect([...AUDIO_OUT_FORMATS]).toEqual([
|
||||
'mp3',
|
||||
'amr',
|
||||
'wma',
|
||||
'm4a',
|
||||
'spx',
|
||||
'ogg',
|
||||
'wav',
|
||||
'flac',
|
||||
]);
|
||||
expect(isAudioOutFormat('wav')).toBe(true);
|
||||
expect(isAudioOutFormat('WAV')).toBe(false); // case-sensitive
|
||||
expect(isAudioOutFormat('opus')).toBe(false);
|
||||
expect(isAudioOutFormat('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertAudioBytes', () => {
|
||||
it('accepts the QQ AI SILK container without mutating the caller bytes', async () => {
|
||||
const input = Buffer.concat([
|
||||
Buffer.from([0x03]),
|
||||
Buffer.from('#!SILK_V3', 'ascii'),
|
||||
Buffer.from([0x04, 0x00, 0x11, 0x22, 0x33, 0x44]),
|
||||
]);
|
||||
const originalInput = Buffer.from(input);
|
||||
let stagedInput: Buffer | null = null;
|
||||
const addon = {
|
||||
decodeAudioToFmt: async (inputPath: string, outputPath: string, format: string) => {
|
||||
stagedInput = readFileSync(inputPath);
|
||||
writeFileSync(outputPath, Buffer.from('MP3DATA'));
|
||||
return { result: true, sampleRate: 24_000, channels: 1, format };
|
||||
},
|
||||
};
|
||||
|
||||
await convertAudioBytes(input, 'mp3', { addon, tmpDir });
|
||||
|
||||
expect(stagedInput?.subarray(0, 10)).toEqual(
|
||||
Buffer.concat([Buffer.from([0x02]), Buffer.from('#!SILK_V3', 'ascii')]),
|
||||
);
|
||||
expect(stagedInput?.subarray(10)).toEqual(input.subarray(10));
|
||||
expect(input).toEqual(originalInput);
|
||||
});
|
||||
|
||||
it('transcodes and returns base64 + size of the addon output', async () => {
|
||||
const r = await convertAudioBytes(new Uint8Array([1, 2, 3]), 'wav', {
|
||||
addon: fakeOk,
|
||||
tmpDir,
|
||||
});
|
||||
expect(Buffer.from(r.base64, 'base64').toString()).toBe('WAVDATA');
|
||||
expect(r.size).toBe('WAVDATA'.length);
|
||||
});
|
||||
|
||||
it('rejects an unsupported format before touching the addon', async () => {
|
||||
let called = false;
|
||||
const spy = {
|
||||
decodeAudioToFmt: async (_in: string, _out: string, format: string) => {
|
||||
called = true;
|
||||
return { result: true, sampleRate: 24_000, channels: 1, format };
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([1]), 'opus', { addon: spy, tmpDir }),
|
||||
).rejects.toThrow(/unsupported out_format/);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates a native conversion rejection and cleans up', async () => {
|
||||
const fakeFail = {
|
||||
decodeAudioToFmt: async () => {
|
||||
throw new Error('native conversion failed');
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([9]), 'mp3', { addon: fakeFail, tmpDir }),
|
||||
).rejects.toThrow(/native conversion failed/);
|
||||
// no leftover temp files for this run
|
||||
const leftover = readdirSync(tmpDir).filter((n) => n.endsWith('.mp3') || n.endsWith('.in'));
|
||||
expect(leftover).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects (and cleans up) when the output exceeds the cap', async () => {
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([1]), 'wav', {
|
||||
addon: fakeOk,
|
||||
tmpDir,
|
||||
maxOutputBytes: 3,
|
||||
}),
|
||||
).rejects.toThrow(/too large/);
|
||||
const leftover = readdirSync(tmpDir).filter((n) => n.endsWith('.wav') || n.endsWith('.in'));
|
||||
expect(leftover).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans up the temp input + output on success too', async () => {
|
||||
await convertAudioBytes(new Uint8Array([4, 5]), 'flac', { addon: fakeOk, tmpDir });
|
||||
const leftover = readdirSync(tmpDir).filter(
|
||||
(n) => n.endsWith('.flac') || n.endsWith('.in'),
|
||||
);
|
||||
expect(leftover).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces temporary-file cleanup failures', async () => {
|
||||
const rmSpy = vi.spyOn(fsPromises, 'rm').mockRejectedValue(new Error('cleanup denied'));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([6]), 'wav', { addon: fakeOk, tmpDir }),
|
||||
).rejects.toThrow(/temporary audio file cleanup failed/);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports both the conversion and cleanup causes when both fail', async () => {
|
||||
const nativeFailure = {
|
||||
decodeAudioToFmt: async () => {
|
||||
throw new Error('native conversion failed');
|
||||
},
|
||||
};
|
||||
const rmSpy = vi.spyOn(fsPromises, 'rm').mockRejectedValue(new Error('cleanup denied'));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([7]), 'mp3', { addon: nativeFailure, tmpDir }),
|
||||
).rejects.toThrow(/native conversion failed.*cleanup denied/);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@snowluma/common/native-path', () => ({
|
||||
resolveRuntimeNativePath: () => '/test/ffmpeg.node',
|
||||
}));
|
||||
|
||||
const addon = {
|
||||
getDuration: vi.fn<(file: string) => Promise<number>>(),
|
||||
convertToNTSilkTct: vi.fn<(input: string, output: string) => Promise<void>>(),
|
||||
};
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
addon.getDuration.mockReset().mockResolvedValue(2.5);
|
||||
addon.convertToNTSilkTct.mockReset().mockImplementation(async (_input, output) => {
|
||||
await fs.promises.writeFile(output, '\x02#!SILK_V3payload');
|
||||
});
|
||||
vi.spyOn(process, 'dlopen').mockImplementation((module) => {
|
||||
(module as { exports: object }).exports = addon;
|
||||
});
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-encode-silk-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('SILK encoding', () => {
|
||||
it('rejects unreadable files instead of treating them as a different audio format', async () => {
|
||||
const { isSilkFile } = await import('../../src/highway/ffmpeg-addon');
|
||||
expect(() => isSilkFile(path.join(root, 'missing'))).toThrow(/ENOENT/);
|
||||
});
|
||||
|
||||
it('keeps native duration failures visible instead of guessing from SILK file size', async () => {
|
||||
const input = path.join(root, 'invalid.silk');
|
||||
fs.writeFileSync(input, '#!SILK_V3truncated');
|
||||
const failure = new Error('truncated SILK packet');
|
||||
addon.getDuration.mockRejectedValue(failure);
|
||||
const { encodeSilk } = await import('../../src/highway/ffmpeg-addon');
|
||||
await expect(encodeSilk(input, root)).rejects.toBe(failure);
|
||||
expect(addon.convertToNTSilkTct).not.toHaveBeenCalled();
|
||||
expect(fs.readdirSync(root)).toEqual(['invalid.silk']);
|
||||
});
|
||||
|
||||
it('reuses a valid SILK input without creating a temporary file', async () => {
|
||||
const input = path.join(root, 'voice.silk');
|
||||
fs.writeFileSync(input, '\x02#!SILK_V3payload');
|
||||
const { encodeSilk } = await import('../../src/highway/ffmpeg-addon');
|
||||
await expect(encodeSilk(input, root)).resolves.toEqual({
|
||||
path: input,
|
||||
duration: 3,
|
||||
converted: false,
|
||||
});
|
||||
expect(addon.convertToNTSilkTct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the converted file for caller-owned cleanup', async () => {
|
||||
const input = path.join(root, 'voice.wav');
|
||||
fs.writeFileSync(input, 'RIFF');
|
||||
const { encodeSilk } = await import('../../src/highway/ffmpeg-addon');
|
||||
const result = await encodeSilk(input, root);
|
||||
expect(result.converted).toBe(true);
|
||||
expect(result.duration).toBe(3);
|
||||
expect(fs.readFileSync(result.path, 'utf8')).toBe('\x02#!SILK_V3payload');
|
||||
expect(addon.convertToNTSilkTct).toHaveBeenCalledWith(input, result.path);
|
||||
});
|
||||
|
||||
it('removes partial output when native conversion fails, preserving the source and error', async () => {
|
||||
const input = path.join(root, 'voice.wav');
|
||||
fs.writeFileSync(input, 'RIFF');
|
||||
const failure = new Error('conversion interrupted');
|
||||
addon.convertToNTSilkTct.mockImplementation(async (_input, output) => {
|
||||
await fs.promises.writeFile(output, 'partial');
|
||||
throw failure;
|
||||
});
|
||||
const { encodeSilk } = await import('../../src/highway/ffmpeg-addon');
|
||||
await expect(encodeSilk(input, root)).rejects.toBe(failure);
|
||||
expect(fs.readdirSync(root)).toEqual(['voice.wav']);
|
||||
expect(fs.readFileSync(input, 'utf8')).toBe('RIFF');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
// Streaming hasher must be byte-identical to the buffered helpers over the
|
||||
// same bytes: computeHashes + computeVideoSha1Blocks (+ computeMd5 of the
|
||||
// head) for hashFileStreaming, and computeHashes + computeSha1StateV for
|
||||
// hashFlashFileStreaming. Flash Sha1StateV is not video sha1Blocks.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { hashFileStreaming, hashFlashFileStreaming } from '@snowluma/protocol/highway/hash-file';
|
||||
import { computeHashes, computeMd5 } from '@snowluma/protocol/highway/utils';
|
||||
import { computeSha1StateV, Sha1Stream } from '@snowluma/protocol/highway/sha1-stream';
|
||||
import { computeVideoSha1Blocks } from '../../src/highway/video-upload';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
// Same generator as video-sha1-blocks.test.ts (and its golden-capture script).
|
||||
function det(len: number): Uint8Array {
|
||||
const b = new Uint8Array(len);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
b[i] = (i * 31 + 7) & 0xff;
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
function hex(u: Uint8Array): string {
|
||||
return Buffer.from(u).toString('hex');
|
||||
}
|
||||
|
||||
function nodeMd5Hex(data: Uint8Array): string {
|
||||
return createHash('md5').update(Buffer.from(data)).digest('hex');
|
||||
}
|
||||
|
||||
function nodeSha1Hex(data: Uint8Array): string {
|
||||
return createHash('sha1').update(Buffer.from(data)).digest('hex');
|
||||
}
|
||||
|
||||
describe('hashFileStreaming', () => {
|
||||
let dir: string;
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'sl-hash-file-'));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeBin(name: string, data: Uint8Array): Promise<string> {
|
||||
const file = join(dir, name);
|
||||
await writeFile(file, data);
|
||||
return file;
|
||||
}
|
||||
|
||||
it('empty file: well-known empty digests and a single finalized sha1 block', async () => {
|
||||
const file = await writeBin('empty.bin', new Uint8Array(0));
|
||||
const streamed = await hashFileStreaming(file);
|
||||
|
||||
expect(streamed.md5Hex).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(streamed.sha1Hex).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
expect(hex(streamed.md5)).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(hex(streamed.sha1)).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
expect(streamed.headMd5).toBeUndefined();
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(['da39a3ee5e6b4b0d3255bfef95601890afd80709']);
|
||||
});
|
||||
|
||||
it('empty file with headLimit still yields md5 of zero bytes', async () => {
|
||||
const file = await writeBin('empty-head.bin', new Uint8Array(0));
|
||||
const streamed = await hashFileStreaming(file, { headLimit: 10 });
|
||||
expect(hex(streamed.headMd5!)).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(streamed.md5Hex).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
});
|
||||
|
||||
it('sub-1 MiB file matches RFC hashes and has only the finalized sha1 block', async () => {
|
||||
const data = Buffer.from('abc', 'utf8');
|
||||
const file = await writeBin('abc.bin', data);
|
||||
const streamed = await hashFileStreaming(file);
|
||||
|
||||
expect(streamed.md5Hex).toBe('900150983cd24fb0d6963f7d28e17f72');
|
||||
expect(streamed.sha1Hex).toBe('a9993e364706816aba3e25717850c26c9cd0d89d');
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(['a9993e364706816aba3e25717850c26c9cd0d89d']);
|
||||
expect(streamed.headMd5).toBeUndefined();
|
||||
});
|
||||
|
||||
it('det(1000) matches computeHashes + computeVideoSha1Blocks + Node crypto', async () => {
|
||||
const data = det(1000);
|
||||
const file = await writeBin('det-1000.bin', data);
|
||||
const streamed = await hashFileStreaming(file);
|
||||
const buffered = computeHashes(data);
|
||||
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(data));
|
||||
expect(streamed.sha1Hex).toBe(nodeSha1Hex(data));
|
||||
expect(hex(streamed.md5)).toBe(hex(buffered.md5));
|
||||
expect(hex(streamed.sha1)).toBe(hex(buffered.sha1));
|
||||
expect(streamed.md5Hex).toBe(buffered.md5Hex);
|
||||
expect(streamed.sha1Hex).toBe(buffered.sha1Hex);
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(computeVideoSha1Blocks(data).map(hex));
|
||||
expect(streamed.sha1Blocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('exact 1 MiB emits an un-finalized boundary state then the overall sha1', async () => {
|
||||
const data = det(MB);
|
||||
const file = await writeBin('exact-1m.bin', data);
|
||||
const streamed = await hashFileStreaming(file);
|
||||
const overall = nodeSha1Hex(data);
|
||||
const prefixDigest = nodeSha1Hex(data); // whole file *is* the first slice
|
||||
const manual = new Sha1Stream();
|
||||
manual.update(data);
|
||||
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(data));
|
||||
expect(streamed.sha1Hex).toBe(overall);
|
||||
expect(streamed.sha1Blocks).toHaveLength(2);
|
||||
expect(hex(streamed.sha1Blocks[0]!)).toBe(hex(manual.hash(true)));
|
||||
expect(hex(streamed.sha1Blocks[0]!)).not.toBe(prefixDigest);
|
||||
expect(hex(streamed.sha1Blocks[1]!)).toBe(overall);
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(computeVideoSha1Blocks(data).map(hex));
|
||||
});
|
||||
|
||||
it('2 MiB + 12345 reproduces the video-sha1-blocks golden states', async () => {
|
||||
const data = det(2 * MB + 12345);
|
||||
const file = await writeBin('golden-2m.bin', data);
|
||||
const streamed = await hashFileStreaming(file);
|
||||
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual([
|
||||
'ab9a20fd6c98fc1f8f0c985cd552ff14c6feacfd',
|
||||
'2ccc5923b75f46ebd8ccb78e8c413c18025178b9',
|
||||
'dd9cfd4648ab460260afa820c98bfec1b17655d4',
|
||||
]);
|
||||
expect(streamed.sha1Hex).toBe('dd9cfd4648ab460260afa820c98bfec1b17655d4');
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(data));
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(computeVideoSha1Blocks(data).map(hex));
|
||||
});
|
||||
|
||||
it('omitting opts leaves headMd5 undefined', async () => {
|
||||
const file = await writeBin('no-opts.bin', det(64));
|
||||
const streamed = await hashFileStreaming(file);
|
||||
expect(streamed.headMd5).toBeUndefined();
|
||||
});
|
||||
|
||||
it('headLimit 0 is present and is the empty-md5', async () => {
|
||||
const file = await writeBin('head-0.bin', det(200));
|
||||
const streamed = await hashFileStreaming(file, { headLimit: 0 });
|
||||
expect(hex(streamed.headMd5!)).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(det(200)));
|
||||
});
|
||||
|
||||
it('headMd5 is computeMd5 of the first N bytes inside the first 1 MiB chunk', async () => {
|
||||
const data = det(8000);
|
||||
const file = await writeBin('head-inside.bin', data);
|
||||
const streamed = await hashFileStreaming(file, { headLimit: 10 });
|
||||
expect(hex(streamed.headMd5!)).toBe(hex(computeMd5(data.subarray(0, 10))));
|
||||
expect(hex(streamed.headMd5!)).toBe(nodeMd5Hex(data.subarray(0, 10)));
|
||||
});
|
||||
|
||||
it('headMd5 across a 1 MiB boundary matches computeMd5 of the clamped prefix', async () => {
|
||||
const data = det(MB + 500);
|
||||
const file = await writeBin('head-span.bin', data);
|
||||
const cases: Array<[number, number]> = [
|
||||
[MB - 1, MB - 1],
|
||||
[MB, MB],
|
||||
[MB + 1, MB + 1],
|
||||
[data.length + 1, data.length],
|
||||
];
|
||||
|
||||
for (const [limit, take] of cases) {
|
||||
const streamed = await hashFileStreaming(file, { headLimit: limit });
|
||||
expect(hex(streamed.headMd5!)).toBe(hex(computeMd5(data.subarray(0, take))));
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(data));
|
||||
expect(streamed.sha1Hex).toBe(nodeSha1Hex(data));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a missing path from stat before hashing', async () => {
|
||||
await expect(hashFileStreaming(join(dir, 'no-such.bin'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashFlashFileStreaming', () => {
|
||||
let dir: string;
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'sl-flash-hash-file-'));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeBin(name: string, data: Uint8Array): Promise<string> {
|
||||
const file = join(dir, name);
|
||||
await writeFile(file, data);
|
||||
return file;
|
||||
}
|
||||
|
||||
it('empty file: empty digests, sliceCount 0, no Sha1StateV entries', async () => {
|
||||
const file = await writeBin('empty.bin', new Uint8Array(0));
|
||||
const streamed = await hashFlashFileStreaming(file);
|
||||
|
||||
expect(streamed.md5Hex).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(streamed.sha1Hex).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
expect(streamed.sliceCount).toBe(0);
|
||||
expect(streamed.sha1StateV).toEqual([]);
|
||||
});
|
||||
|
||||
it('sub-1 MiB file is a single finalized slice', async () => {
|
||||
const data = Buffer.from('abc', 'utf8');
|
||||
const file = await writeBin('abc.bin', data);
|
||||
const streamed = await hashFlashFileStreaming(file);
|
||||
|
||||
expect(streamed.sliceCount).toBe(1);
|
||||
expect(streamed.md5Hex).toBe('900150983cd24fb0d6963f7d28e17f72');
|
||||
expect(streamed.sha1Hex).toBe('a9993e364706816aba3e25717850c26c9cd0d89d');
|
||||
expect(streamed.sha1StateV.map(hex)).toEqual(['a9993e364706816aba3e25717850c26c9cd0d89d']);
|
||||
});
|
||||
|
||||
it('exact 1 MiB omits the extra un-finalized last-slice state video sha1Blocks keeps', async () => {
|
||||
const data = det(MB);
|
||||
const file = await writeBin('exact-1m.bin', data);
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
const video = await hashFileStreaming(file);
|
||||
const overall = nodeSha1Hex(data);
|
||||
const refState = computeSha1StateV(data, 1, MB);
|
||||
|
||||
expect(flash.sliceCount).toBe(1);
|
||||
expect(flash.sha1StateV).toHaveLength(1);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(overall);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(hex(refState[0]!));
|
||||
expect(video.sha1Blocks).toHaveLength(2);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(hex(video.sha1Blocks[1]!));
|
||||
expect(hex(flash.sha1StateV[0]!)).not.toBe(hex(video.sha1Blocks[0]!));
|
||||
expect(flash.md5Hex).toBe(nodeMd5Hex(data));
|
||||
});
|
||||
|
||||
it('1 MiB + 1: first state is the un-finalized 1 MiB snapshot', async () => {
|
||||
const data = det(MB + 1);
|
||||
const file = await writeBin('1m-plus.bin', data);
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
const manual = new Sha1Stream();
|
||||
manual.update(data.subarray(0, MB));
|
||||
const refState = computeSha1StateV(data, 2, MB);
|
||||
|
||||
expect(flash.sliceCount).toBe(2);
|
||||
expect(flash.sha1StateV).toHaveLength(2);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(hex(manual.hash(true)));
|
||||
expect(hex(flash.sha1StateV[0]!)).not.toBe(nodeSha1Hex(data.subarray(0, MB)));
|
||||
expect(hex(flash.sha1StateV[1]!)).toBe(nodeSha1Hex(data));
|
||||
expect(flash.sha1StateV.map(hex)).toEqual(refState.map(hex));
|
||||
});
|
||||
|
||||
it('exact 2 MiB: flash has 2 states, video sha1Blocks has 3', async () => {
|
||||
const data = det(2 * MB);
|
||||
const file = await writeBin('exact-2m.bin', data);
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
const video = await hashFileStreaming(file);
|
||||
|
||||
expect(flash.sliceCount).toBe(2);
|
||||
expect(flash.sha1StateV).toHaveLength(2);
|
||||
expect(video.sha1Blocks).toHaveLength(3);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(hex(video.sha1Blocks[0]!));
|
||||
expect(hex(flash.sha1StateV[1]!)).toBe(hex(video.sha1Blocks[2]!));
|
||||
expect(hex(flash.sha1StateV[1]!)).toBe(nodeSha1Hex(data));
|
||||
expect(flash.sha1StateV.map(hex)).toEqual(computeSha1StateV(data, 2, MB).map(hex));
|
||||
expect(flash.md5Hex).toBe(computeHashes(data).md5Hex);
|
||||
});
|
||||
|
||||
it('2 MiB + 12345 Sha1StateV matches computeSha1StateV and Node overall sha1', async () => {
|
||||
const data = det(2 * MB + 12345);
|
||||
const file = await writeBin('golden-2m.bin', data);
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
|
||||
expect(flash.sliceCount).toBe(3);
|
||||
expect(flash.sha1Hex).toBe('dd9cfd4648ab460260afa820c98bfec1b17655d4');
|
||||
expect(hex(flash.sha1StateV[2]!)).toBe('dd9cfd4648ab460260afa820c98bfec1b17655d4');
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe('ab9a20fd6c98fc1f8f0c985cd552ff14c6feacfd');
|
||||
expect(hex(flash.sha1StateV[1]!)).toBe('2ccc5923b75f46ebd8ccb78e8c413c18025178b9');
|
||||
expect(flash.sha1StateV.map(hex)).toEqual(computeSha1StateV(data, 3, MB).map(hex));
|
||||
});
|
||||
|
||||
it('rejects a missing path from stat before hashing', async () => {
|
||||
await expect(hashFlashFileStreaming(join(dir, 'no-such.bin'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { MessageElement } from '../../src/events';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { NotOnlineImagePbReserve } from '@snowluma/proto-defs/element';
|
||||
import type {
|
||||
EncodableMediaMsgInfo,
|
||||
NTV2UploadRichMediaReq,
|
||||
NTV2UploadRichMediaResp,
|
||||
} from '@snowluma/proto-defs/highway';
|
||||
import type { BridgeContext } from '../../src/bridge-context';
|
||||
import { uploadImageMsgInfo } from '../../src/highway/image-upload';
|
||||
|
||||
const png =
|
||||
'base64://iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jFZkAAAAASUVORK5CYII=';
|
||||
|
||||
describe('image sticker presentation (#468)', () => {
|
||||
it.each([true, false])(
|
||||
'carries sticker reserves through actual wire encoding, group=%s',
|
||||
async (isGroup) => {
|
||||
const requests: NTV2UploadRichMediaReq[] = [];
|
||||
const sendRawPacket = vi.fn(async (_command: string, body: Uint8Array) => {
|
||||
requests.push(protobuf_decode<OidbBase<NTV2UploadRichMediaReq>>(body).body!);
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
responseData: protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
|
||||
body: {
|
||||
upload: {
|
||||
msgInfo: {
|
||||
msgInfoBody: [{ index: { fileUuid: 'cached-image' } }],
|
||||
extBizInfo: {
|
||||
pic: { bizType: 0, textSummary: '[图片]', oldFileId: 123 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
const bridge = {
|
||||
identity: { uin: '10001' },
|
||||
sendRawPacket,
|
||||
} as unknown as BridgeContext;
|
||||
const element: MessageElement = {
|
||||
type: 'image',
|
||||
url: png,
|
||||
subType: 1,
|
||||
summary: '[收藏表情]',
|
||||
};
|
||||
const bytes = await uploadImageMsgInfo(
|
||||
bridge,
|
||||
isGroup,
|
||||
isGroup ? 123 : 'u_peer',
|
||||
element,
|
||||
);
|
||||
const imageBytes = Buffer.from(png.slice('base64://'.length), 'base64');
|
||||
expect(element).toMatchObject({
|
||||
md5Hex: createHash('md5').update(imageBytes).digest('hex'),
|
||||
sha1Hex: createHash('sha1').update(imageBytes).digest('hex'),
|
||||
fileSize: imageBytes.length,
|
||||
width: 1,
|
||||
height: 1,
|
||||
picFormat: 1001,
|
||||
imageUrl: `https://multimedia.nt.qq.com.cn/download?appid=${isGroup ? 1407 : 1406}&fileid=cached-image`,
|
||||
});
|
||||
expect(element.fileId).toBe(element.fileName);
|
||||
const sent = protobuf_decode<EncodableMediaMsgInfo>(bytes);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
expect(sent.msgInfoBody?.[0]?.index?.fileUuid).toBe('cached-image');
|
||||
expect(sent.extBizInfo?.pic?.oldFileId).toBe(123);
|
||||
|
||||
for (const pic of [requests[0].upload?.extBizInfo?.pic, sent.extBizInfo?.pic]) {
|
||||
expect(pic?.bizType).toBe(1);
|
||||
expect(pic?.textSummary).toBe('[收藏表情]');
|
||||
|
||||
if (isGroup) {
|
||||
expect(pic?.extData).toMatchObject({ subType: 1, textSummary: '[收藏表情]' });
|
||||
} else {
|
||||
expect(
|
||||
protobuf_decode<NotOnlineImagePbReserve>(pic!.bytesPbReserveC2c!),
|
||||
).toMatchObject({
|
||||
subType: 1,
|
||||
summary: '[收藏表情]',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
EncodableMediaMsgInfo,
|
||||
HighwayMsgInfoBody,
|
||||
NTV2UploadRichMediaResp,
|
||||
} from '@snowluma/proto-defs/highway';
|
||||
import type { BridgeContext } from '../../src/bridge-context';
|
||||
import type { MessageElement } from '../../src/events';
|
||||
import { uploadImageMsgInfo } from '../../src/highway/image-upload';
|
||||
|
||||
const webp = 'base64://UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA';
|
||||
|
||||
function bridge(body: HighwayMsgInfoBody): BridgeContext {
|
||||
return {
|
||||
identity: { uin: '10001' },
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
responseData: protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
|
||||
body: { upload: { msgInfo: { msgInfoBody: [body] } } },
|
||||
}),
|
||||
})),
|
||||
} as unknown as BridgeContext;
|
||||
}
|
||||
|
||||
describe('uploaded image download references (#659)', () => {
|
||||
it.each([false, true])(
|
||||
'retains the returned host, original parameters and WebP filename (group=%s)',
|
||||
async (isGroup) => {
|
||||
const picture = {
|
||||
domain: 'image.example',
|
||||
urlPath: '/download?fileid=server-resource',
|
||||
ext: { originalParameter: '&spec=0', thumbParameter: '&spec=small' },
|
||||
};
|
||||
const element: MessageElement = { type: 'image', url: webp };
|
||||
const encoded = await uploadImageMsgInfo(
|
||||
bridge({ index: { fileUuid: 'resource' }, picture }),
|
||||
isGroup,
|
||||
isGroup ? 20002 : 'u_peer',
|
||||
element,
|
||||
);
|
||||
expect(element).toMatchObject({
|
||||
imageUrl: 'https://image.example/download?fileid=server-resource&spec=0',
|
||||
picFormat: 1002,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
expect(element.fileName).toBe(`${element.md5Hex}.webp`);
|
||||
expect(element.fileId).toBe(element.fileName);
|
||||
expect(
|
||||
protobuf_decode<EncodableMediaMsgInfo>(encoded).msgInfoBody?.[0]?.picture,
|
||||
).toMatchObject(picture);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'uses an encoded upload resource id when the server omits the URL (group=%s)',
|
||||
async (isGroup) => {
|
||||
const element: MessageElement = { type: 'image', url: webp };
|
||||
await uploadImageMsgInfo(
|
||||
bridge({ index: { fileUuid: 'resource/+=' } }),
|
||||
isGroup,
|
||||
isGroup ? 20002 : 'u_peer',
|
||||
element,
|
||||
);
|
||||
expect(element.imageUrl).toBe(
|
||||
`https://multimedia.nt.qq.com.cn/download?appid=${isGroup ? 1407 : 1406}&fileid=resource%2F%2B%3D`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the standard image host when a wire response omits the domain', async () => {
|
||||
const element: MessageElement = { type: 'image', url: webp };
|
||||
await uploadImageMsgInfo(
|
||||
bridge({ picture: { urlPath: '/download?fileid=resource' } }),
|
||||
false,
|
||||
'u_peer',
|
||||
element,
|
||||
);
|
||||
expect(element.imageUrl).toBe('https://multimedia.nt.qq.com.cn/download?fileid=resource');
|
||||
});
|
||||
|
||||
it('rejects an unusable upload receipt before publishing incomplete send metadata', async () => {
|
||||
const element: MessageElement = { type: 'image', url: webp };
|
||||
await expect(uploadImageMsgInfo(bridge({}), false, 'u_peer', element)).rejects.toThrow(
|
||||
'missing a download reference',
|
||||
);
|
||||
expect(element).toEqual({ type: 'image', url: webp });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { EncodableMediaMsgInfo } from '@snowluma/proto-defs/highway';
|
||||
import { finalizeMediaMsgInfo } from '@snowluma/protocol/highway/pipeline';
|
||||
import {
|
||||
PTT_WAVEFORM_BINS,
|
||||
PTT_WAVEFORM_SILENCE,
|
||||
amplitudesFromPcmS16le,
|
||||
decodePttWaveform,
|
||||
encodePttWaveform,
|
||||
mergePttWaveform,
|
||||
pcmS16leFromWav,
|
||||
} from '@snowluma/protocol/highway/ptt-waveform';
|
||||
|
||||
function le16(value: number): number[] {
|
||||
return [value & 0xff, (value >> 8) & 0xff];
|
||||
}
|
||||
|
||||
function le32(value: number): number[] {
|
||||
return [value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff, (value >> 24) & 0xff];
|
||||
}
|
||||
|
||||
function ascii(text: string): number[] {
|
||||
return [...text].map((ch) => ch.charCodeAt(0));
|
||||
}
|
||||
|
||||
function s16leBytes(samples: number[]): Uint8Array {
|
||||
const out = new Uint8Array(samples.length * 2);
|
||||
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const v = samples[i]! < 0 ? samples[i]! + 0x10000 : samples[i]!;
|
||||
out[i * 2] = v & 0xff;
|
||||
out[i * 2 + 1] = (v >> 8) & 0xff;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Minimal PCM WAV: 16-bit, little-endian, one data chunk. */
|
||||
function wavBytes(samples: number[], channels = 1, sampleRate = 24_000): Uint8Array {
|
||||
const pcm = s16leBytes(samples);
|
||||
const fmtSize = 16;
|
||||
const dataSize = pcm.length;
|
||||
const riffSize = 4 + (8 + fmtSize) + (8 + dataSize);
|
||||
const out = new Uint8Array(12 + 8 + fmtSize + 8 + dataSize);
|
||||
let off = 0;
|
||||
|
||||
const put = (bytes: number[]) => {
|
||||
out.set(bytes, off);
|
||||
off += bytes.length;
|
||||
};
|
||||
|
||||
put(ascii('RIFF'));
|
||||
put(le32(riffSize));
|
||||
put(ascii('WAVE'));
|
||||
put(ascii('fmt '));
|
||||
put(le32(fmtSize));
|
||||
put(le16(1));
|
||||
put(le16(channels));
|
||||
put(le32(sampleRate));
|
||||
put(le32(sampleRate * channels * 2));
|
||||
put(le16(channels * 2));
|
||||
put(le16(16));
|
||||
put(ascii('data'));
|
||||
put(le32(dataSize));
|
||||
out.set(pcm, off);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('pcmS16leFromWav', () => {
|
||||
it('reads s16le PCM and channel count from a plain WAV', () => {
|
||||
const wav = wavBytes([0, 32767, -32768], 1, 16_000);
|
||||
const parsed = pcmS16leFromWav(wav);
|
||||
expect(parsed.channels).toBe(1);
|
||||
expect(parsed.sampleRate).toBe(16_000);
|
||||
expect(parsed.pcm).toEqual(s16leBytes([0, 32767, -32768]));
|
||||
});
|
||||
|
||||
it('rejects a non-PCM WAV', () => {
|
||||
const wav = wavBytes([1, 2, 3]);
|
||||
wav[20] = 7;
|
||||
expect(() => pcmS16leFromWav(wav)).toThrow(/not PCM/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('amplitudesFromPcmS16le', () => {
|
||||
it('uses the official 30 bars and scales a full-scale peak to 255', () => {
|
||||
const loud = new Array(30).fill(0);
|
||||
loud[0] = 32767;
|
||||
const amps = amplitudesFromPcmS16le(s16leBytes(loud));
|
||||
expect(amps.length).toBe(PTT_WAVEFORM_BINS);
|
||||
expect(amps[0]).toBe(255);
|
||||
});
|
||||
|
||||
it('keeps quiet windows at 0 so the bar has valleys', () => {
|
||||
const samples = new Array(60).fill(0);
|
||||
samples[0] = 32767;
|
||||
const amps = amplitudesFromPcmS16le(s16leBytes(samples), { bins: 2 });
|
||||
expect([...amps]).toEqual([255, 0]);
|
||||
});
|
||||
|
||||
it('uses the official silence floor when the clip is empty', () => {
|
||||
const amps = amplitudesFromPcmS16le(new Uint8Array(0), { bins: 4 });
|
||||
expect([...amps]).toEqual(new Array(4).fill(PTT_WAVEFORM_SILENCE));
|
||||
});
|
||||
|
||||
it('peaks stereo frames from either channel', () => {
|
||||
const pcm = s16leBytes([0, 32767, 0, 0]);
|
||||
const amps = amplitudesFromPcmS16le(pcm, { channels: 2, bins: 1 });
|
||||
expect(amps[0]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodePttWaveform / decodePttWaveform', () => {
|
||||
it('roundtrips with matching count and amplitudes', () => {
|
||||
const amplitudes = new Uint8Array([25, 40, 80, 10]);
|
||||
const encoded = encodePttWaveform(amplitudes);
|
||||
expect(decodePttWaveform(encoded)).toEqual({ size: 4, amplitudes });
|
||||
});
|
||||
|
||||
it('rejects an empty amplitude list', () => {
|
||||
expect(() => encodePttWaveform(new Uint8Array(0))).toThrow(/empty/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergePttWaveform', () => {
|
||||
it('writes waveform onto msgInfo so finalize keeps it', () => {
|
||||
const waveform = encodePttWaveform(new Uint8Array(PTT_WAVEFORM_BINS).fill(40));
|
||||
const upload = {
|
||||
msgInfo: {
|
||||
msgInfoBody: [],
|
||||
extBizInfo: { ptt: { bytesReserve: new Uint8Array([1]) } },
|
||||
},
|
||||
};
|
||||
mergePttWaveform(upload, waveform);
|
||||
const decoded: any = protobuf_decode<EncodableMediaMsgInfo>(finalizeMediaMsgInfo(upload));
|
||||
expect(decoded.extBizInfo.ptt.waveform).toEqual(waveform);
|
||||
expect(decoded.extBizInfo.ptt.bytesReserve).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
|
||||
it('no-ops when waveform is missing', () => {
|
||||
const upload = { msgInfo: { msgInfoBody: [], extBizInfo: {} } };
|
||||
mergePttWaveform(upload, undefined);
|
||||
expect(upload.msgInfo.extBizInfo).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Sha1Stream, computeSha1StateV } from '@snowluma/protocol/highway/sha1-stream';
|
||||
|
||||
// SHA1 标准填充:msg + 0x80 + 0x00...(k 个) + 8 字节大端 bit 长度,总长为 64 倍数。
|
||||
// 用于把任意输入变成「已 finalize」的完整 block 序列——这样 Sha1Stream 对它逐 block
|
||||
// transform 后取大端 state,应当等于 Node crypto 对原始 msg 的 digest。
|
||||
function sha1Pad(msg: Uint8Array): Uint8Array {
|
||||
const msgLen = msg.length;
|
||||
const k = (56 - ((msgLen + 1) % 64) + 64) % 64;
|
||||
const padded = new Uint8Array(msgLen + 1 + k + 8);
|
||||
padded.set(msg, 0);
|
||||
padded[msgLen] = 0x80;
|
||||
const view = new DataView(padded.buffer, padded.byteOffset + padded.byteLength - 8, 8);
|
||||
view.setBigUint64(0, BigInt(msgLen) * 8n, false); // 大端 bit 长度
|
||||
return padded;
|
||||
}
|
||||
|
||||
// padding oracle 的核心:Node crypto 的 SHA1 digest 内部就是对 padding 后的输入逐 block
|
||||
// transform、取最终 state 的大端表示。Sha1Stream 自行实现 transform 且不 finalize,
|
||||
// 因此对「已 padding 的输入」transform 后大端输出必须等于 digest。Node crypto 走
|
||||
// OpenSSL,与本实现完全独立,是验证 transform 正确性的可靠参照。
|
||||
function expectSha1StreamMatchesCrypto(msg: Uint8Array): void {
|
||||
const expected = createHash('sha1').update(Buffer.from(msg)).digest();
|
||||
const stream = new Sha1Stream();
|
||||
stream.update(sha1Pad(msg));
|
||||
const bigEndian = stream.hash(false);
|
||||
expect(Buffer.from(bigEndian).equals(expected)).toBe(true);
|
||||
}
|
||||
|
||||
describe('Sha1Stream transform (padding oracle vs Node crypto)', () => {
|
||||
it('matches for empty input (single padded block)', () => {
|
||||
expectSha1StreamMatchesCrypto(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('matches for "abc" (single padded block)', () => {
|
||||
expectSha1StreamMatchesCrypto(Buffer.from('abc', 'utf8'));
|
||||
});
|
||||
|
||||
it('matches across the 56-byte boundary (forces a second padded block)', () => {
|
||||
// 56 字节:0x80 后只剩 0 字节给 zeros 不够放 8 字节长度,padding 溢出到第二块。
|
||||
expectSha1StreamMatchesCrypto(Buffer.alloc(56, 0x41));
|
||||
});
|
||||
|
||||
it('matches for exactly one block (64 bytes → two padded blocks)', () => {
|
||||
expectSha1StreamMatchesCrypto(Buffer.alloc(64, 0x42));
|
||||
});
|
||||
|
||||
it('matches for multi-block input (200 bytes → four padded blocks)', () => {
|
||||
expectSha1StreamMatchesCrypto(Buffer.alloc(200, 0x43));
|
||||
});
|
||||
|
||||
it('matches for pseudo-random multi-block input', () => {
|
||||
const data = Buffer.alloc(313, 0);
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = (i * 1103515245 + 12345) & 0xff;
|
||||
}
|
||||
|
||||
expectSha1StreamMatchesCrypto(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sha1Stream update chunk-equivalence', () => {
|
||||
// update 内部维护 64 字节 buffer + 128 位 bit 计数;分块喂入必须与一次性喂入
|
||||
// 产生相同的内部 state。切分点覆盖 block 边界附近以暴露 buffer/count 管理 bug。
|
||||
it('two-chunk split at various boundaries yields the same state as one update', () => {
|
||||
const data = Buffer.alloc(200, 0x44);
|
||||
const full = new Sha1Stream();
|
||||
full.update(data);
|
||||
const fullHash = Buffer.from(full.hash(true));
|
||||
|
||||
for (const split of [0, 1, 32, 63, 64, 65, 127, 128, 129, 199]) {
|
||||
const s = new Sha1Stream();
|
||||
s.update(data.subarray(0, split));
|
||||
s.update(data.subarray(split));
|
||||
expect(Buffer.from(s.hash(true)).equals(fullHash)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('three-chunk split yields the same state as one update', () => {
|
||||
const data = Buffer.alloc(300, 0x45);
|
||||
const full = new Sha1Stream();
|
||||
full.update(data);
|
||||
const fullHash = Buffer.from(full.hash(true));
|
||||
const s = new Sha1Stream();
|
||||
s.update(data.subarray(0, 100));
|
||||
s.update(data.subarray(100, 200));
|
||||
s.update(data.subarray(200));
|
||||
expect(Buffer.from(s.hash(true)).equals(fullHash)).toBe(true);
|
||||
});
|
||||
|
||||
it('empty updates do not change the state', () => {
|
||||
const data = Buffer.alloc(130, 0x46);
|
||||
const full = new Sha1Stream();
|
||||
full.update(data);
|
||||
const fullHash = Buffer.from(full.hash(true));
|
||||
const s = new Sha1Stream();
|
||||
s.update(Buffer.alloc(0));
|
||||
s.update(data);
|
||||
s.update(Buffer.alloc(0));
|
||||
expect(Buffer.from(s.hash(true)).equals(fullHash)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sha1Stream hash endianness', () => {
|
||||
// hash(true) 小端与 hash(false) 大端是同一组 5 个 state word 的不同字节序表示。
|
||||
// 结合上面的 padding oracle(大端 == digest),即可间接确认小端中间 state 的值。
|
||||
it('little-endian is the per-word byte-swap of big-endian', () => {
|
||||
const s = new Sha1Stream();
|
||||
s.update(Buffer.alloc(64, 0x47));
|
||||
const le = Buffer.from(s.hash(true));
|
||||
const be = Buffer.from(s.hash(false));
|
||||
expect(le.length).toBe(20);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(le.readUInt32LE(i * 4)).toBe(be.readUInt32BE(i * 4));
|
||||
}
|
||||
});
|
||||
|
||||
it('reset restores the initial SHA1 state', () => {
|
||||
const s = new Sha1Stream();
|
||||
s.update(Buffer.alloc(200, 0x48));
|
||||
s.reset();
|
||||
const initial = Buffer.from(s.hash(false));
|
||||
const expected = Buffer.from([
|
||||
0x67, 0x45, 0x23, 0x01, 0xef, 0xcd, 0xab, 0x89, 0x98, 0xba, 0xdc, 0xfe, 0x10, 0x32,
|
||||
0x54, 0x76, 0xc3, 0xd2, 0xe1, 0xf0,
|
||||
]);
|
||||
expect(initial.equals(expected)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSha1StateV', () => {
|
||||
const SLICE = 1024 * 1024; // 1 MB,与上传链路一致,且是 64 的倍数
|
||||
|
||||
it('returns one 20-byte state per slice', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x49); // 2 片
|
||||
const states = computeSha1StateV(data, 2, SLICE);
|
||||
expect(states.length).toBe(2);
|
||||
|
||||
for (const st of states) {
|
||||
expect(st.length).toBe(20);
|
||||
}
|
||||
});
|
||||
|
||||
// 末片定义为「标准整文件 SHA1(有 finalize/padding)」,直接对齐 Node crypto。
|
||||
it('last slice equals the standard full-file SHA1 digest', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x4a);
|
||||
const states = computeSha1StateV(data, 2, SLICE);
|
||||
const expected = createHash('sha1').update(data).digest();
|
||||
expect(Buffer.from(states[1]).equals(expected)).toBe(true);
|
||||
});
|
||||
|
||||
it('last slice equals full-file SHA1 for a three-slice file', () => {
|
||||
const data = Buffer.alloc(SLICE * 2 + 1, 0x4b);
|
||||
const states = computeSha1StateV(data, 3, SLICE);
|
||||
expect(Buffer.from(states[2]).equals(createHash('sha1').update(data).digest())).toBe(true);
|
||||
});
|
||||
|
||||
// 非末片是「累积 SHA1 中间 state(不 finalize,小端)」。若误用标准 finalize
|
||||
// 计算,会得到 file[0:slice] 的完整 digest,与本实现必然不等——以此锁定
|
||||
// 「中间 state 而非独立片 digest」的语义。
|
||||
it('non-last slice is NOT the finalized digest of the prefix', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x4c);
|
||||
const states = computeSha1StateV(data, 2, SLICE);
|
||||
const prefixDigest = createHash('sha1').update(data.subarray(0, SLICE)).digest();
|
||||
expect(Buffer.from(states[0]).equals(prefixDigest)).toBe(false);
|
||||
});
|
||||
|
||||
// 非末片中间 state 的正确性来自 transform(padding oracle 已验)与 update
|
||||
// 分块等价(已验)。这里再做一次直接对照:computeSha1StateV 对首片的处理
|
||||
// 等价于手动 reset + update(prefix) + 小端输出。
|
||||
it('non-last slice equals manual Sha1Stream reset+update of the prefix', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x4d);
|
||||
const states = computeSha1StateV(data, 2, SLICE);
|
||||
const manual = new Sha1Stream();
|
||||
manual.update(data.subarray(0, SLICE));
|
||||
expect(Buffer.from(states[0]).equals(Buffer.from(manual.hash(true)))).toBe(true);
|
||||
});
|
||||
|
||||
// 单片文件只有末片,直接是整文件 digest。
|
||||
it('single-slice file has only the finalized digest', () => {
|
||||
const data = Buffer.alloc(100, 0x4e);
|
||||
const states = computeSha1StateV(data, 1, SLICE);
|
||||
expect(states.length).toBe(1);
|
||||
expect(Buffer.from(states[0]).equals(createHash('sha1').update(data).digest())).toBe(true);
|
||||
});
|
||||
|
||||
// 中间 state 与「把它当末片算出的 digest」必须不同——这是 sliceupload 服务端
|
||||
// 校验「累积 state 而非独立片 digest」的核心区分点。
|
||||
it('non-last slice differs from a single-slice digest of the same prefix', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x4f);
|
||||
const multiFirst = computeSha1StateV(data, 2, SLICE)[0];
|
||||
const singleAsLast = computeSha1StateV(data.subarray(0, SLICE), 1, SLICE)[0];
|
||||
expect(Buffer.from(multiFirst).equals(Buffer.from(singleAsLast))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs, { promises as fsp } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
const stageRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-proto-stage-root-'));
|
||||
const tmpdir = vi.spyOn(os, 'tmpdir').mockReturnValue(stageRoot);
|
||||
const { STAGE_DIR, stageSourceToDisk, sweepStagedUploads } =
|
||||
await import('@snowluma/protocol/highway/stage');
|
||||
tmpdir.mockRestore();
|
||||
|
||||
const DOWNLOAD_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
||||
const STAGE_TTL_MS = 30 * 60 * 1000;
|
||||
const SNOW_BYTES = Buffer.from([0x53, 0x4e, 0x4f, 0x57]);
|
||||
|
||||
const live: Array<{ cleanup(): Promise<void> }> = [];
|
||||
let scratch = '';
|
||||
|
||||
function hold<T extends { cleanup(): Promise<void> }>(staged: T): T {
|
||||
live.push(staged);
|
||||
return staged;
|
||||
}
|
||||
|
||||
async function stageNames(): Promise<string[]> {
|
||||
try {
|
||||
return await fsp.readdir(STAGE_DIR);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function newPartNames(before: readonly string[]): Promise<string[]> {
|
||||
const prior = new Set(before);
|
||||
return (await stageNames()).filter((name) => name.endsWith('.part') && !prior.has(name));
|
||||
}
|
||||
|
||||
function expectStagedPath(filePath: string, ext: string): void {
|
||||
expect(filePath.startsWith(`${STAGE_DIR}${path.sep}`)).toBe(true);
|
||||
expect(path.basename(filePath)).toMatch(
|
||||
new RegExp(
|
||||
`^stage-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}${ext.replaceAll('.', '\\.')}$`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-proto-stage-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
while (live.length > 0) {
|
||||
await live.pop()!.cleanup();
|
||||
}
|
||||
|
||||
await fsp.rm(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fsp.rm(stageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('STAGE_DIR', () => {
|
||||
it('is os.tmpdir()/media-stage', () => {
|
||||
expect(STAGE_DIR).toBe(path.join(stageRoot, 'media-stage'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — empty / classification', () => {
|
||||
it('rejects an empty source before touching disk', async () => {
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('', 64)).rejects.toThrow('stage source is empty');
|
||||
expect(await stageNames()).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects a data URL that is missing the comma separator', async () => {
|
||||
await expect(stageSourceToDisk('data:image/png;base64', 64)).rejects.toThrow(
|
||||
'data URL source is missing its payload separator',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a data URL that is not base64-encoded', async () => {
|
||||
await expect(stageSourceToDisk('data:text/plain,hello', 64)).rejects.toThrow(
|
||||
'data URL source must use base64 encoding',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a missing local path via realpath', async () => {
|
||||
const missing = path.join(scratch, 'no-such-file.bin');
|
||||
await expect(stageSourceToDisk(missing, 64)).rejects.toThrow(/ENOENT/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — base64', () => {
|
||||
it('writes base64:// bytes to a .bin stage file with an empty fileName', async () => {
|
||||
const staged = hold(await stageSourceToDisk('base64://U05PVw==', 64));
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expect(staged.fileName).toBe('');
|
||||
expectStagedPath(staged.filePath, '.bin');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(SNOW_BYTES);
|
||||
});
|
||||
|
||||
it('accepts a case-insensitive BASE64:// prefix and one-byte padding', async () => {
|
||||
const staged = hold(await stageSourceToDisk('BASE64:///w==', 1));
|
||||
expect(staged.fileSize).toBe(1);
|
||||
expect(staged.fileName).toBe('');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0xff]));
|
||||
});
|
||||
|
||||
it('accepts two-byte and three-byte payloads at the decoded-size ceiling', async () => {
|
||||
const two = hold(await stageSourceToDisk('base64://AAA=', 2));
|
||||
const three = hold(await stageSourceToDisk('base64://AAAA', 3));
|
||||
expect(two.fileSize).toBe(2);
|
||||
expect(three.fileSize).toBe(3);
|
||||
expect(await fsp.readFile(two.filePath)).toEqual(Buffer.from([0x00, 0x00]));
|
||||
expect(await fsp.readFile(three.filePath)).toEqual(Buffer.from([0x00, 0x00, 0x00]));
|
||||
});
|
||||
|
||||
it('stages an RFC 2397 data URL and keeps fileName empty', async () => {
|
||||
const staged = hold(await stageSourceToDisk('data:image/png;base64,U05PVw==', 4));
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expect(staged.fileName).toBe('');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(SNOW_BYTES);
|
||||
});
|
||||
|
||||
it('accepts DATA:…;BASE64, payloads (case-insensitive metadata)', async () => {
|
||||
const staged = hold(
|
||||
await stageSourceToDisk('DATA:application/octet-stream;BASE64,AA==', 1),
|
||||
);
|
||||
expect(staged.fileSize).toBe(1);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x00]));
|
||||
});
|
||||
|
||||
it('strips non-base64 characters before the decoded-length check', async () => {
|
||||
const staged = hold(await stageSourceToDisk('base64://U05P\nVw==', 4));
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(SNOW_BYTES);
|
||||
});
|
||||
|
||||
it('stages an empty base64:// payload as a zero-byte file', async () => {
|
||||
const staged = hold(await stageSourceToDisk('base64://', 0));
|
||||
expect(staged.fileSize).toBe(0);
|
||||
expect(staged.fileName).toBe('');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('rejects oversized base64 on decoded length and leaves no new .part', async () => {
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('base64://aGVsbG8=', 4)).rejects.toThrow(
|
||||
'stage source too large: 5 > 4',
|
||||
);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an oversized data URL on decoded length and leaves no new .part', async () => {
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('data:audio/ogg;base64,aGVsbG8=', 4)).rejects.toThrow(
|
||||
'stage source too large: 5 > 4',
|
||||
);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('unlinks the .part when finalize rename fails', async () => {
|
||||
const before = await stageNames();
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValueOnce(new Error('rename failed'));
|
||||
await expect(stageSourceToDisk('base64://U05PVw==', 64)).rejects.toThrow('rename failed');
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — http', () => {
|
||||
it('streams the body to disk and uses the decoded URL basename', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(Uint8Array.from([0x10, 0x20, 0x30, 0x40]), { status: 200 }),
|
||||
),
|
||||
);
|
||||
const staged = hold(
|
||||
await stageSourceToDisk('https://media.example.test/packs/clip%20v2.mp4?sig=9', 64),
|
||||
);
|
||||
expect(staged.fileName).toBe('clip v2.mp4');
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expectStagedPath(staged.filePath, '.bin');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x10, 0x20, 0x30, 0x40]));
|
||||
});
|
||||
|
||||
it('keeps fileName empty when the URL has no basename', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(Uint8Array.from([0x7f]), { status: 200 })),
|
||||
);
|
||||
const staged = hold(await stageSourceToDisk('https://media.example.test/', 8));
|
||||
expect(staged.fileName).toBe('');
|
||||
expect(staged.fileSize).toBe(1);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x7f]));
|
||||
});
|
||||
|
||||
it('creates an empty staged file for an empty HTTP body', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(new Uint8Array(0), { status: 200 })),
|
||||
);
|
||||
const staged = hold(await stageSourceToDisk('HTTP://cdn.example.test/empty.bin', 8));
|
||||
expect(staged.fileName).toBe('empty.bin');
|
||||
expect(staged.fileSize).toBe(0);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('rejects a declared Content-Length overflow without retrying and leaves no .part', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(new Uint8Array(80), {
|
||||
status: 200,
|
||||
headers: { 'content-length': '80' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('https://cdn.example.test/huge.bin', 16)).rejects.toThrow(
|
||||
'stage too large: 80 > 16',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a streamed overflow after the sink opened and discards the .part', async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(10).fill(0x11));
|
||||
controller.enqueue(new Uint8Array(10).fill(0x22));
|
||||
controller.enqueue(new Uint8Array(10).fill(0x33));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(stream, { status: 200 })),
|
||||
);
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('https://cdn.example.test/stream.bin', 16)).rejects.toThrow(
|
||||
'stage too large: > 16',
|
||||
);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('retries once with Referer after a non-size failure and stages the retry body', async () => {
|
||||
const url = 'https://media.example.test/packs/retry.bin';
|
||||
const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
|
||||
if (!headers.Referer) {
|
||||
throw new TypeError('fetch failed');
|
||||
}
|
||||
|
||||
expect(headers.Referer).toBe(url);
|
||||
expect(headers['User-Agent']).toBe(DOWNLOAD_UA);
|
||||
expect(headers.Accept).toBe('*/*');
|
||||
expect(init?.redirect).toBe('follow');
|
||||
return new Response(Uint8Array.from([0xaa, 0xbb, 0xcc]), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const staged = hold(await stageSourceToDisk(url, 32));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(staged.fileSize).toBe(3);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0xaa, 0xbb, 0xcc]));
|
||||
});
|
||||
|
||||
it('surfaces the first HTTP status error when the Referer retry also fails', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 503 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await expect(stageSourceToDisk('https://cdn.example.test/down.bin', 32)).rejects.toThrow(
|
||||
'HTTP download failed: 503',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('unlinks the http .part when rename after download fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(Uint8Array.from([0x01, 0x02]), { status: 200 })),
|
||||
);
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValueOnce(new Error('http rename failed'));
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('https://cdn.example.test/r.bin', 32)).rejects.toThrow(
|
||||
'http rename failed',
|
||||
);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — local files', () => {
|
||||
it('hardlinks a raw path, preserves basename, and pins the inode after source unlink', async () => {
|
||||
const src = path.join(scratch, 'payload.dat');
|
||||
await fsp.writeFile(src, Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
const srcStat = await fsp.stat(src);
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
expect(staged.fileName).toBe('payload.dat');
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expectStagedPath(staged.filePath, '.dat');
|
||||
const stagedStat = await fsp.stat(staged.filePath);
|
||||
expect(stagedStat.ino).toBe(srcStat.ino);
|
||||
expect(stagedStat.nlink).toBe(2);
|
||||
await fsp.unlink(src);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
});
|
||||
|
||||
it('accepts a file:// URL and uses the pre-resolution basename', async () => {
|
||||
const src = path.join(scratch, 'video.mp4');
|
||||
await fsp.writeFile(src, Buffer.from([0x00, 0x00, 0x00, 0x18]));
|
||||
const staged = hold(await stageSourceToDisk(pathToFileURL(src).href, 64));
|
||||
expect(staged.fileName).toBe('video.mp4');
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expectStagedPath(staged.filePath, '.mp4');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x00, 0x00, 0x00, 0x18]));
|
||||
});
|
||||
|
||||
it('resolves a symlink target but keeps the symlink basename and extension', async () => {
|
||||
const target = path.join(scratch, 'blob-real.bin');
|
||||
const linkPath = path.join(scratch, 'alias.mov');
|
||||
await fsp.writeFile(target, Buffer.from([0x6d, 0x6f, 0x6f, 0x76]));
|
||||
await fsp.symlink(target, linkPath);
|
||||
const staged = hold(await stageSourceToDisk(linkPath, 64));
|
||||
expect(staged.fileName).toBe('alias.mov');
|
||||
expectStagedPath(staged.filePath, '.mov');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x6d, 0x6f, 0x6f, 0x76]));
|
||||
});
|
||||
|
||||
it('uses .bin when the local path has no extension', async () => {
|
||||
const src = path.join(scratch, 'noext');
|
||||
await fsp.writeFile(src, Buffer.from([0x42]));
|
||||
const staged = hold(await stageSourceToDisk(src, 8));
|
||||
expect(staged.fileName).toBe('noext');
|
||||
expectStagedPath(staged.filePath, '.bin');
|
||||
});
|
||||
|
||||
it('preserves a mixed-case extension on the staged path', async () => {
|
||||
const src = path.join(scratch, 'Photo.JPEG');
|
||||
await fsp.writeFile(src, Buffer.from([0xff, 0xd8]));
|
||||
const staged = hold(await stageSourceToDisk(src, 8));
|
||||
expect(staged.fileName).toBe('Photo.JPEG');
|
||||
expectStagedPath(staged.filePath, '.JPEG');
|
||||
});
|
||||
|
||||
it('allows a local file whose size equals maxBytes', async () => {
|
||||
const src = path.join(scratch, 'exact.bin');
|
||||
await fsp.writeFile(src, Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]));
|
||||
const staged = hold(await stageSourceToDisk(src, 8));
|
||||
expect(staged.fileSize).toBe(8);
|
||||
});
|
||||
|
||||
it('stages an empty regular file', async () => {
|
||||
const src = path.join(scratch, 'zero.dat');
|
||||
await fsp.writeFile(src, Buffer.alloc(0));
|
||||
const staged = hold(await stageSourceToDisk(src, 0));
|
||||
expect(staged.fileSize).toBe(0);
|
||||
expect(staged.fileName).toBe('zero.dat');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('rejects a directory before creating a stage file', async () => {
|
||||
await expect(stageSourceToDisk(scratch, 64)).rejects.toThrow(
|
||||
`stage source is not a regular file: ${scratch}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an oversized local file before staging', async () => {
|
||||
const src = path.join(scratch, 'huge.bin');
|
||||
await fsp.writeFile(src, Buffer.alloc(80, 0xab));
|
||||
await expect(stageSourceToDisk(src, 16)).rejects.toThrow('stage source too large: 80 > 16');
|
||||
});
|
||||
|
||||
it('copies when link() fails and the copy survives source deletion', async () => {
|
||||
const src = path.join(scratch, 'xfs.bin');
|
||||
const payload = Buffer.from([0xc0, 0xff, 0xee, 0x11]);
|
||||
await fsp.writeFile(src, payload);
|
||||
const srcIno = (await fsp.stat(src)).ino;
|
||||
const linkSpy = vi
|
||||
.spyOn(fsp, 'link')
|
||||
.mockRejectedValueOnce(Object.assign(new Error('EXDEV'), { code: 'EXDEV' }));
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
expect(linkSpy).toHaveBeenCalledOnce();
|
||||
expect(staged.fileName).toBe('xfs.bin');
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expect((await fsp.stat(staged.filePath)).ino).not.toBe(srcIno);
|
||||
await fsp.unlink(src);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('cleans temps when both link() and the copy fallback fail', async () => {
|
||||
const src = path.join(scratch, 'fail.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0x01]));
|
||||
vi.spyOn(fsp, 'link').mockRejectedValueOnce(
|
||||
Object.assign(new Error('EXDEV'), { code: 'EXDEV' }),
|
||||
);
|
||||
vi.spyOn(fsp, 'copyFile').mockRejectedValueOnce(new Error('ENOSPC: copy failed'));
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk(src, 64)).rejects.toThrow('ENOSPC: copy failed');
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans temps when link() fails and the copy rename fails', async () => {
|
||||
const src = path.join(scratch, 'rename-fail.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0x02, 0x03]));
|
||||
vi.spyOn(fsp, 'link').mockRejectedValueOnce(
|
||||
Object.assign(new Error('EPERM'), { code: 'EPERM' }),
|
||||
);
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValueOnce(new Error('copy rename failed'));
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk(src, 64)).rejects.toThrow('copy rename failed');
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — cleanup', () => {
|
||||
it('removes only the staged temp, never the caller file, and is idempotent', async () => {
|
||||
const src = path.join(scratch, 'keep-me.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
expect(fs.existsSync(staged.filePath)).toBe(true);
|
||||
await staged.cleanup();
|
||||
expect(fs.existsSync(staged.filePath)).toBe(false);
|
||||
expect(fs.existsSync(src)).toBe(true);
|
||||
expect(await fsp.readFile(src)).toEqual(Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
await expect(staged.cleanup()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('tolerates the staged path already being gone', async () => {
|
||||
const src = path.join(scratch, 'gone.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0x09]));
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
await fsp.unlink(staged.filePath);
|
||||
await expect(staged.cleanup()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sweepStagedUploads', () => {
|
||||
it('returns 0 when the stage directory does not exist', async () => {
|
||||
const parked = `${STAGE_DIR}.park-${randomUUID()}`;
|
||||
let moved = false;
|
||||
|
||||
try {
|
||||
try {
|
||||
await fsp.rename(STAGE_DIR, parked);
|
||||
moved = true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(sweepStagedUploads(2_000_000_000_000)).resolves.toBe(0);
|
||||
} finally {
|
||||
if (moved) {
|
||||
await fsp.rm(STAGE_DIR, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fsp.rename(parked, STAGE_DIR);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reaps a temp older than 30 minutes, keeps a TTL-edge temp, and skips an active staged file', async () => {
|
||||
const src = path.join(scratch, 'inflight.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0x51, 0x52]));
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
|
||||
await fsp.mkdir(STAGE_DIR, { recursive: true });
|
||||
const now = 2_000_000_000_000;
|
||||
const aged = path.join(STAGE_DIR, `stage-${randomUUID()}.aged`);
|
||||
const edge = path.join(STAGE_DIR, `stage-${randomUUID()}.edge`);
|
||||
const recent = path.join(STAGE_DIR, `stage-${randomUUID()}.recent`);
|
||||
await fsp.writeFile(aged, Buffer.from('aged-orphan'));
|
||||
await fsp.writeFile(edge, Buffer.from('ttl-edge'));
|
||||
await fsp.writeFile(recent, Buffer.from('fresh-orphan'));
|
||||
// Age by whole seconds: some filesystems store mtime at 1s resolution, so a
|
||||
// 1ms-past-TTL stamp can round back onto the keep side of `> 30min`.
|
||||
await fsp.utimes(aged, new Date(now), new Date(now - STAGE_TTL_MS - 1000));
|
||||
await fsp.utimes(edge, new Date(now), new Date(now - STAGE_TTL_MS));
|
||||
await fsp.utimes(recent, new Date(now), new Date(now));
|
||||
await fsp.utimes(
|
||||
staged.filePath,
|
||||
new Date(now - STAGE_TTL_MS - 1000),
|
||||
new Date(now - STAGE_TTL_MS - 1000),
|
||||
);
|
||||
|
||||
try {
|
||||
const removed = await sweepStagedUploads(now);
|
||||
expect(fs.existsSync(aged)).toBe(false);
|
||||
expect(fs.existsSync(edge)).toBe(true);
|
||||
expect(fs.existsSync(recent)).toBe(true);
|
||||
expect(fs.existsSync(staged.filePath)).toBe(true);
|
||||
expect(removed).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await fsp.rm(edge, { force: true });
|
||||
await fsp.rm(recent, { force: true });
|
||||
await fsp.rm(aged, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses Date.now() when now is omitted', async () => {
|
||||
await fsp.mkdir(STAGE_DIR, { recursive: true });
|
||||
const orphan = path.join(STAGE_DIR, `stage-${randomUUID()}.epoch`);
|
||||
await fsp.writeFile(orphan, Buffer.from('epoch-orphan'));
|
||||
await fsp.utimes(
|
||||
orphan,
|
||||
new Date('1990-01-01T00:00:00.000Z'),
|
||||
new Date('1990-01-01T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
try {
|
||||
const removed = await sweepStagedUploads();
|
||||
expect(fs.existsSync(orphan)).toBe(false);
|
||||
expect(removed).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await fsp.rm(orphan, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { UploadNodeHistory } from '../../src/highway/upload-nodes';
|
||||
|
||||
const a = { host: '192.0.2.1', port: 14000 };
|
||||
const b = { host: '192.0.2.1', port: 80 };
|
||||
const c = { host: '192.0.2.2', port: 80 };
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('upload node observations', () => {
|
||||
it('orders current candidates by confirmed success, unknown, then recent failure', () => {
|
||||
const history = new UploadNodeHistory();
|
||||
history.record(1004, a, false);
|
||||
history.record(1004, b, true);
|
||||
const original = [a, c, b, a];
|
||||
expect(history.order(1004, original)).toEqual([b, c, a]);
|
||||
expect(original).toEqual([a, c, b, a]);
|
||||
expect(history.order(1003, [a, c, b])).toEqual([a, c, b]);
|
||||
expect(history.order(1004, [c])).toEqual([c]);
|
||||
});
|
||||
|
||||
it('expires failures and successes and still allows every failed candidate', () => {
|
||||
vi.useFakeTimers();
|
||||
const history = new UploadNodeHistory();
|
||||
history.record(1004, a, false);
|
||||
history.record(1004, b, false);
|
||||
expect(history.order(1004, [a, b])).toEqual([a, b]);
|
||||
expect(history.order(1004, [a, c])).toEqual([c, a]);
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(history.order(1004, [a, c])).toEqual([a, c]);
|
||||
history.record(1004, b, true);
|
||||
expect(history.order(1004, [a, b])).toEqual([b, a]);
|
||||
vi.advanceTimersByTime(5 * 60_000);
|
||||
expect(history.order(1004, [a, b])).toEqual([a, b]);
|
||||
});
|
||||
|
||||
it('lets a new failure replace success and a subsequent success recover', () => {
|
||||
const history = new UploadNodeHistory();
|
||||
history.record(1004, b, true);
|
||||
history.record(1004, b, false);
|
||||
expect(history.order(1004, [b, a])).toEqual([a, b]);
|
||||
history.record(1004, b, true);
|
||||
expect(history.order(1004, [a, b])).toEqual([b, a]);
|
||||
});
|
||||
|
||||
it('evicts old observations when many distinct endpoints arrive', () => {
|
||||
const history = new UploadNodeHistory();
|
||||
history.record(1004, a, false);
|
||||
|
||||
for (let port = 10000; port < 10200; port++) {
|
||||
history.record(1004, { host: '192.0.2.3', port }, false);
|
||||
}
|
||||
|
||||
expect(history.order(1004, [a, c])).toEqual([a, c]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highwayUploadTimeouts } from '../../src/highway/upload-settings';
|
||||
|
||||
describe('highway upload timeouts', () => {
|
||||
it('retains the idle default and supplies a finite whole-file deadline', () => {
|
||||
expect(highwayUploadTimeouts({})).toEqual({ idleMs: 30_000, totalMs: 1_800_000 });
|
||||
});
|
||||
|
||||
it('reads independent millisecond budgets without clamping', () => {
|
||||
expect(
|
||||
highwayUploadTimeouts({
|
||||
SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS: ' 5000 ',
|
||||
SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS: '120000',
|
||||
}),
|
||||
).toEqual({ idleMs: 5000, totalMs: 120000 });
|
||||
expect(highwayUploadTimeouts({ SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS: '1' }).totalMs).toBe(1);
|
||||
expect(
|
||||
highwayUploadTimeouts({ SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS: '2147483647' }).totalMs,
|
||||
).toBe(2147483647);
|
||||
});
|
||||
|
||||
it.each(['', ' ', '0', '-1', '1.5', 'NaN', 'Infinity', '1e3', '200ms', '2147483648'])(
|
||||
'rejects invalid configuration %j',
|
||||
(value) => {
|
||||
for (const name of [
|
||||
'SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS',
|
||||
'SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS',
|
||||
]) {
|
||||
expect(() => highwayUploadTimeouts({ [name]: value })).toThrow(name);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import net from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BridgeContext } from '../../src/bridge-context';
|
||||
import {
|
||||
BufferChunkSource,
|
||||
uploadHighwayHttp,
|
||||
type HighwayServer,
|
||||
} from '../../src/highway/highway-client';
|
||||
import { packHighwayFrame } from '../../src/highway/utils';
|
||||
|
||||
const servers: net.Server[] = [];
|
||||
const sockets = new Set<net.Socket>();
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
async function listen(
|
||||
reply: boolean,
|
||||
): Promise<{ address: HighwayServer; connections: () => number }> {
|
||||
let connections = 0;
|
||||
const server = net.createServer((socket) => {
|
||||
connections++;
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
let received = Buffer.alloc(0);
|
||||
socket.on('data', (bytes: Buffer) => {
|
||||
received = Buffer.concat([received, bytes]);
|
||||
const end = received.indexOf('\r\n\r\n');
|
||||
|
||||
if (!reply || end < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const header = received.subarray(0, end).toString('ascii');
|
||||
const length = Number(header.match(/Content-Length: (\d+)/i)?.[1]);
|
||||
|
||||
if (received.length < end + 4 + length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = packHighwayFrame(new Uint8Array(), new Uint8Array());
|
||||
socket.write(
|
||||
Buffer.concat([
|
||||
Buffer.from(`HTTP/1.1 200 OK\r\nContent-Length: ${frame.byteLength}\r\n\r\n`),
|
||||
frame,
|
||||
]),
|
||||
);
|
||||
received = received.subarray(end + 4 + length);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
const address = server.address();
|
||||
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('missing loopback address');
|
||||
}
|
||||
|
||||
return {
|
||||
address: { host: address.address, port: address.port },
|
||||
connections: () => connections,
|
||||
};
|
||||
}
|
||||
|
||||
function bridge(uin: string): BridgeContext {
|
||||
return { identity: { uin } } as unknown as BridgeContext;
|
||||
}
|
||||
|
||||
function send(
|
||||
owner: BridgeContext,
|
||||
candidates: HighwayServer[],
|
||||
source = new BufferChunkSource(new Uint8Array([1, 2, 3])),
|
||||
) {
|
||||
return uploadHighwayHttp(
|
||||
owner,
|
||||
{ sigSession: new Uint8Array([1]), sessionKey: new Uint8Array(), servers: candidates },
|
||||
1004,
|
||||
source,
|
||||
new Uint8Array(16),
|
||||
new Uint8Array(),
|
||||
);
|
||||
}
|
||||
|
||||
describe('highway upload over real loopback sockets', () => {
|
||||
it('switches idle ports and remembers only confirmed success for that account', async () => {
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS', '100');
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS', '5000');
|
||||
const idle = await listen(false);
|
||||
const healthy = await listen(true);
|
||||
const owner = bridge('10001');
|
||||
const candidates = [idle.address, healthy.address];
|
||||
await send(owner, candidates);
|
||||
await send(owner, candidates);
|
||||
expect(idle.connections()).toBe(1);
|
||||
expect(healthy.connections()).toBe(2);
|
||||
await send(bridge('10002'), candidates);
|
||||
expect(idle.connections()).toBe(2);
|
||||
expect(healthy.connections()).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects an unresponsive peer at the total deadline and closes its source once', async () => {
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS', '10000');
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS', '100');
|
||||
const idle = await listen(false);
|
||||
const source = new BufferChunkSource(new Uint8Array([1, 2, 3]));
|
||||
const close = vi.spyOn(source, 'close');
|
||||
await expect(send(bridge('10003'), [idle.address], source)).rejects.toThrow(
|
||||
'timed out after 100ms',
|
||||
);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(idle.connections()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectImageFormat } from '@snowluma/protocol/highway/utils';
|
||||
|
||||
// Regression coverage for detectImageFormat (was previously untested). The
|
||||
// header-parsing is all hand-rolled byte math, so each format's offset AND
|
||||
// endianness is pinned here. The VP8X cases exist because that branch read the
|
||||
// canvas width/height big-endian when the WebP spec stores them 24-bit
|
||||
// little-endian (issue #112: a 1920×1080 image came back as 8324865×3605505,
|
||||
// cropping the QQ thumbnail). VP8 / VP8L were already little-endian.
|
||||
|
||||
// ── little-endian byte writers (match how the formats store dimensions) ──
|
||||
function le16(v: number): number[] {
|
||||
return [v & 0xff, (v >> 8) & 0xff];
|
||||
}
|
||||
|
||||
function le24(v: number): number[] {
|
||||
return [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff];
|
||||
}
|
||||
|
||||
function le32(v: number): number[] {
|
||||
return [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff];
|
||||
}
|
||||
|
||||
function be16(v: number): number[] {
|
||||
return [(v >> 8) & 0xff, v & 0xff];
|
||||
}
|
||||
|
||||
function be32(v: number): number[] {
|
||||
return [(v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff];
|
||||
}
|
||||
|
||||
function ascii(s: string): number[] {
|
||||
return [...s].map((c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
/** Build a buffer from segments placed at explicit offsets; gaps are 0. */
|
||||
function buf(len: number, ...segs: Array<[number, number[]]>): Uint8Array {
|
||||
const b = new Uint8Array(len);
|
||||
|
||||
for (const [off, bytes] of segs) {
|
||||
b.set(bytes, off);
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
describe('detectImageFormat', () => {
|
||||
it('PNG — IHDR width/height (big-endian @16/@20) → 1001', () => {
|
||||
const b = buf(24, [0, [0x89, 0x50, 0x4e, 0x47]], [16, be32(800)], [20, be32(600)]);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1001, width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('GIF — logical screen width/height (little-endian @6/@8) → 2000', () => {
|
||||
const b = buf(10, [0, ascii('GIF89a')], [6, le16(320)], [8, le16(240)]);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 2000, width: 320, height: 240 });
|
||||
// GIF87a header variant is also accepted.
|
||||
const b87 = buf(10, [0, ascii('GIF87a')], [6, le16(48)], [8, le16(16)]);
|
||||
expect(detectImageFormat(b87)).toEqual({ format: 2000, width: 48, height: 16 });
|
||||
});
|
||||
|
||||
it('BMP — DIB width/height (little-endian @18/@22) → 1005', () => {
|
||||
const b = buf(26, [0, ascii('BM')], [18, le32(1024)], [22, le32(768)]);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1005, width: 1024, height: 768 });
|
||||
});
|
||||
|
||||
it('WebP VP8 (lossy) — little-endian @26/@28 → 1002', () => {
|
||||
const b = buf(
|
||||
32,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8 ')],
|
||||
[26, le16(640)],
|
||||
[28, le16(480)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it('WebP VP8L (lossless) — 14-bit fields packed in LE32 @21 → 1002', () => {
|
||||
// width-1 in bits[0..13], height-1 in bits[14..27].
|
||||
const bits = (100 - 1) | ((200 - 1) << 14);
|
||||
const b = buf(
|
||||
32,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8L')],
|
||||
[21, le32(bits >>> 0)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('WebP VP8X (extended) — canvas width/height are 24-bit LITTLE-endian +1 @24/@27 → 1002 (issue #112)', () => {
|
||||
const b = buf(
|
||||
34,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8X')],
|
||||
[24, le24(1920 - 1)],
|
||||
[27, le24(1080 - 1)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it('WebP VP8X — asymmetric dims pin byte order (BE misread would give huge/swapped values)', () => {
|
||||
// 256×1: width-1=255 → LE bytes [ff,00,00]; a BE read of those = 16711680.
|
||||
// height-1=0 → [00,00,00]. Distinct w≠h also catches an offset swap.
|
||||
const b = buf(
|
||||
34,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8X')],
|
||||
[24, le24(256 - 1)],
|
||||
[27, le24(1 - 1)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 256, height: 1 });
|
||||
});
|
||||
|
||||
it('WebP VP8X — exact bytes from issue #112 decode to 1920×1080, not 8324865×3605505', () => {
|
||||
// The reported broken values came from these literal canvas bytes.
|
||||
const b = buf(
|
||||
34,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8X')],
|
||||
[24, [0x7f, 0x07, 0x00]],
|
||||
[27, [0x37, 0x04, 0x00]],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it('JPEG — dimensions from the SOF0 segment (big-endian, height before width) → 1000', () => {
|
||||
// FFD8 SOI, then SOF0 (FFC0) segment: len(BE16)=17, precision, height@+5, width@+7.
|
||||
// Buffer must hold the whole declared 17-byte segment (offset 2..20) or the
|
||||
// parser's bounds-check bails before reading — hence length 24, not 20.
|
||||
const b = buf(
|
||||
24,
|
||||
[0, [0xff, 0xd8]],
|
||||
[2, [0xff, 0xc0]],
|
||||
[4, be16(17)],
|
||||
[6, [8]],
|
||||
[7, be16(720)],
|
||||
[9, be16(1280)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1000, width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
it('JPEG — skips a DHT (0xFFC4) segment before SOF0 (must not misread Huffman bytes)', () => {
|
||||
// SOI, DHT(FFC4) len=4 (2 payload bytes), then SOF0 with real 640×480.
|
||||
const b = buf(
|
||||
28,
|
||||
[0, [0xff, 0xd8]],
|
||||
[2, [0xff, 0xc4]],
|
||||
[4, be16(4)],
|
||||
[6, [0xde, 0xad]],
|
||||
[8, [0xff, 0xc0]],
|
||||
[10, be16(17)],
|
||||
[12, [8]],
|
||||
[13, be16(480)],
|
||||
[15, be16(640)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1000, width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it('unknown bytes fall back to {1000, 0, 0} without throwing', () => {
|
||||
expect(detectImageFormat(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual({
|
||||
format: 1000,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('a truncated WebP header (<30 bytes) does not crash and falls through', () => {
|
||||
const b = buf(20, [0, ascii('RIFF')], [8, ascii('WEBP')], [12, ascii('VP8X')]);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1000, width: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { computeVideoSha1Blocks } from '../../src/highway/video-upload';
|
||||
|
||||
// Deterministic input generator (matches the golden-capture script).
|
||||
function det(len: number): Uint8Array {
|
||||
const b = new Uint8Array(len);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
b[i] = (i * 31 + 7) & 0xff;
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
const hex = (u: Uint8Array): string => Buffer.from(u).toString('hex');
|
||||
const nodeSha1 = (b: Uint8Array): string => createHash('sha1').update(Buffer.from(b)).digest('hex');
|
||||
|
||||
describe('computeVideoSha1Blocks', () => {
|
||||
// GOLDEN: captured from the previous hand-rolled Sha1StreamState implementation
|
||||
// before the F3 rewrite (Sha1Stream + Node crypto), so this locks the rewrite
|
||||
// byte-identical for the block states AND the overall digest.
|
||||
it('reproduces the pre-refactor golden 1MB-block states + overall sha1', () => {
|
||||
const input = det(2 * 1024 * 1024 + 12345); // 2 full 1MB blocks + a partial tail
|
||||
expect(computeVideoSha1Blocks(input).map(hex)).toEqual([
|
||||
'ab9a20fd6c98fc1f8f0c985cd552ff14c6feacfd', // intermediate un-finalized state @1MB (little-endian)
|
||||
'2ccc5923b75f46ebd8ccb78e8c413c18025178b9', // intermediate un-finalized state @2MB
|
||||
'dd9cfd4648ab460260afa820c98bfec1b17655d4', // overall (finalized)
|
||||
]);
|
||||
});
|
||||
|
||||
it('the final block equals the standard full-file SHA1', () => {
|
||||
const input = det(2 * 1024 * 1024 + 12345);
|
||||
const blocks = computeVideoSha1Blocks(input);
|
||||
expect(hex(blocks[blocks.length - 1]!)).toBe(nodeSha1(input));
|
||||
});
|
||||
|
||||
it('a file shorter than 1MB yields just the overall sha1', () => {
|
||||
const input = det(1000);
|
||||
const blocks = computeVideoSha1Blocks(input);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(hex(blocks[0]!)).toBe(nodeSha1(input));
|
||||
});
|
||||
|
||||
it('a file that is an exact 1MB multiple emits the boundary state then the overall', () => {
|
||||
const input = det(1024 * 1024);
|
||||
const blocks = computeVideoSha1Blocks(input);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(hex(blocks[1]!)).toBe(nodeSha1(input)); // last is the finalized overall
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user