Integrate Obfuscator file picker into Contracts
Deploy contracts-flask / validate (push) Canceled after 0s

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Repinoid
2026-10-30 10:24:23 +03:00
co-authored by Copilot
parent 83002b9644
commit 6e6fc4db8e
41 changed files with 3746 additions and 1485 deletions
+83 -317
View File
@@ -1,344 +1,110 @@
// Юнит-тесты фронта модуля upload: zip (кириллица, вложенный zip, не-doc) + дедуп/лимиты.
// Запуск: node upload/frontend/test_upload_frontend.mjs
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
import { zipSync } from 'fflate';
import { listZipFiles } from './zip/list_zip_files.js';
import { addFileWithDedup } from './table/add_file_with_dedup.js';
import { flattenFiles, render } from './table/render.js';
import { lstFilter } from './table/lst_filter.js';
import { esc } from './table/esc.js';
// ── Полифилл File (Node 18 не имеет global File) ──
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, opts) {
super(parts, opts);
constructor(parts, name, options = {}) {
super(parts, options);
this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now();
this.lastModified = options.lastModified || Date.now();
}
};
}
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-picker-id' };
import { listZipFiles } from './zip/list_zip_files.js';
import { parseZip } from './zip/parse_zip.js';
import { decodeZipName } from './zip/decode_zip_name.js';
import { addFileWithDedup } from './table/add_file_with_dedup.js';
import { esc } from './table/esc.js';
import { fs } from './table/fs.js';
import { fmtSec } from './table/fmt_sec.js';
import { estForFile } from './table/est_for_file.js';
const ALLOWED = ['.pdf', '.doc', '.docx', '.txt', '.md'];
// ── CRC32 (для сборки тестовых ZIP) ──
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
function bytes(text) {
return new TextEncoder().encode(text);
}
// ── Сборка минимального ZIP (method 0, UTF-8-флаг) ──
function makeZip(entries) {
const enc = new TextEncoder();
const chunks = [];
const central = [];
let offset = 0;
const utf8Flag = 0x0800;
for (const e of entries) {
const nameB = enc.encode(e.name);
const dataB = typeof e.data === 'string' ? enc.encode(e.data) : e.data;
const crc = crc32(dataB);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 0, true);
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, dataB.length, true);
lh.setUint32(22, dataB.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(dataB));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 0, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, dataB.length, true);
cd.setUint32(24, dataB.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + dataB.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
function withRelativePath(file, path) {
Object.defineProperty(file, 'webkitRelativePath', { value: path });
return file;
}
// ── Сборка ZIP с методом 8 (deflate) — для проверки inflateRaw ──
async function deflateRaw(bytes) {
// zlib.deflateRawSync — deflate без zlib-заголовка (то, что ждёт inflateRaw)
return deflateRawSync(Buffer.from(bytes));
}
async function makeZipDeflate(entries) {
const enc = new TextEncoder();
const chunks = [];
const central = [];
let offset = 0;
const utf8Flag = 0x0800;
for (const e of entries) {
const nameB = enc.encode(e.name);
const raw = enc.encode(e.data);
const comp = await deflateRaw(raw);
const crc = crc32(raw);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 8, true); // method 8 deflate
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, comp.length, true);
lh.setUint32(22, raw.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(comp));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 8, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, comp.length, true);
cd.setUint32(24, raw.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + comp.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
}
// ── Тесты decodeZipName ──
function testDecodeZipName() {
const utf8 = new TextEncoder().encode('договор.pdf');
assert.equal(decodeZipName(utf8, true), 'договор.pdf');
// Без UTF-8-флага, но байты — валидный UTF-8 с кириллицей → эвристика берёт как есть
assert.equal(decodeZipName(utf8, false), 'договор.pdf');
console.log(' decodeZipName: ok');
}
// ── Тесты listZipFiles ──
async function testZipCyrillic() {
const zip = makeZip([{ name: 'договор.pdf', data: '%PDF-1.4 fake' }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'договор.pdf');
console.log(' zip кириллица: ok');
}
async function testZipNested() {
const inner = makeZip([{ name: 'inner.txt', data: 'hi' }]);
const outer = makeZip([
{ name: 'inner.zip', data: inner },
{ name: 'skip.txt', data: 'x' },
async function testRecursiveZipTree() {
const nested = zipSync({ 'inside.pdf': bytes('nested') });
const archive = zipSync({
'contracts/one.pdf': bytes('one'),
'contracts/ignore.txt': bytes('text'),
'nested.zip': nested,
'../escape.pdf': bytes('unsafe'),
});
const tree = await listZipFiles(new File([archive], 'outer.zip'), ['.pdf', '.doc', '.docx']);
const files = flattenFiles([tree]);
assert.deepEqual(files.map((file) => file.path).sort(), [
'outer.zip/contracts/one.pdf',
'outer.zip/nested.zip/inside.pdf',
]);
const files = await listZipFiles(new File([outer], 'outer.zip'), ALLOWED);
const names = files.map(f => f.name).sort();
assert.deepEqual(names, ['inner.txt', 'skip.txt']);
console.log(' вложенный zip: ok');
assert.equal(files.find((file) => file.name === 'inside.pdf').file.name, 'inside.pdf');
}
async function testZipNonDoc() {
const zip = makeZip([
{ name: 'doc.pdf', data: 'pdf' },
{ name: 'img.png', data: 'png' },
{ name: 'readme.txt', data: 'readme' },
]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
const names = files.map(f => f.name).sort();
assert.deepEqual(names, ['doc.pdf', 'readme.txt']);
console.log(' не-doc фильтр: ok');
function testPathAndSizeDeduplication() {
const state = { nodes: [], fileKeys: new Set() };
const file = new File(['same'], 'a.pdf');
const node = { id: 'one', kind: 'file', name: 'a.pdf', path: 'a.pdf', file, children: [] };
assert.equal(addFileWithDedup(state, node), true);
assert.equal(addFileWithDedup(state, {
...node, id: 'two', file: new File(['same'], 'a.pdf'),
}), false);
assert.equal(addFileWithDedup(state, {
...node, id: 'three', path: 'folder/a.pdf',
}), true);
assert.deepEqual(flattenFiles(state.nodes).map((entry) => entry.path), ['a.pdf', 'folder/a.pdf']);
}
async function testZipDeflate() {
// deflate-raw требует DecompressionStream('deflate-raw') — в браузере есть, в Node 18 нет
let supported = true;
try { new DecompressionStream('deflate-raw'); } catch (e) { supported = false; }
if (!supported) {
console.log(' zip deflate (метод 8): skip — Node 18 не поддерживает deflate-raw');
return;
}
const content = 'Hello deflate world '.repeat(50);
const zip = await makeZipDeflate([{ name: 'deflated.txt', data: content }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'deflated.txt');
assert.equal(await files[0].text(), content);
console.log(' zip deflate (метод 8): ok');
async function testObscureManifest() {
const manifest = withRelativePath(new File(['contracts/**\n!**/skip.docx\n'], 'obscure.lst'), 'root/obscure.lst');
const included = withRelativePath(new File(['a'], 'a.pdf'), 'root/contracts/a.pdf');
const excluded = withRelativePath(new File(['b'], 'skip.docx'), 'root/contracts/skip.docx');
const outside = withRelativePath(new File(['c'], 'outside.pdf'), 'root/other/outside.pdf');
const result = await lstFilter([manifest, included, excluded, outside], ['.pdf', '.doc', '.docx', '.zip']);
assert.equal(result.manifest, manifest);
assert.deepEqual(result.files, [included]);
assert.deepEqual(result.diagnostics, []);
}
async function testParseZipNotZip() {
await assert.rejects(() => parseZip(new Uint8Array([1, 2, 3])), /Не ZIP/);
console.log(' не-ZIP бросок: ok');
function testEscaping() {
assert.equal(esc(`<script title="x">&'`), '&lt;script title=&quot;x&quot;&gt;&amp;&#39;');
}
// ── Тесты addFileWithDedup ──
function newState() {
return { files: [], fileMeta: new Map(), overNames: new Set(), busy: false, proc: null };
function testStatusAndDocumentActionsRenderSafely() {
const node = {
id: 'file-1',
kind: 'file',
name: '<img src=x>',
path: '<img src=x>',
file: new File(['x'], '<img src=x>'),
children: [],
expanded: true,
};
const state = {
nodes: [node],
statuses: new Map([['file-1', { kind: 'error', text: '<script>bad</script>' }]]),
documents: new Map([['file-1', 'doc-1']]),
};
const elements = { tableBodyEl: { innerHTML: '' }, countEl: { textContent: '' } };
render(state, elements);
assert.match(elements.tableBodyEl.innerHTML, /data-details="file-1"/);
assert.match(elements.tableBodyEl.innerHTML, /data-text="file-1"/);
assert.match(elements.tableBodyEl.innerHTML, /&lt;script&gt;bad&lt;\/script&gt;/);
assert.doesNotMatch(elements.tableBodyEl.innerHTML, /<script>bad/);
assert.match(elements.tableBodyEl.innerHTML, /&lt;img src=x&gt;/);
}
function testDedup() {
const st = newState();
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
// одинаковое имя+размер → дедуп (false), список не растёт
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), true);
assert.equal(st.files.length, 1);
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), false);
assert.equal(st.files.length, 1);
// имя то же, размер другой → суффикс _2
assert.equal(addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c), true);
assert.equal(st.files.length, 2);
assert.equal(st.files[1].name, 'a_2.txt');
console.log(' дедуп/суффиксы: ok');
}
function testLimits() {
// лимит на один файл
const st = newState();
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
addFileWithDedup(st, new File([new Uint8Array(150)], 'big.bin'), c);
assert.ok(st.overNames.has('big.bin'));
assert.equal(st.files.length, 1);
// суммарный лимит сессии
const st2 = newState();
const c2 = { maxFileBytes: 1000, maxSessionBytes: 200 };
addFileWithDedup(st2, new File([new Uint8Array(150)], 'f1.bin'), c2);
addFileWithDedup(st2, new File([new Uint8Array(60)], 'f2.bin'), c2); // 150+60=210 > 200 → over
assert.ok(st2.overNames.has('f2.bin'));
console.log(' лимиты over: ok');
}
function testSuffixes() {
const st = newState();
const c = { maxFileBytes: 10000, maxSessionBytes: 100000 };
addFileWithDedup(st, new File(['aaa'], 'a.txt'), c);
addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c); // a_2.txt
addFileWithDedup(st, new File(['ccccc'], 'a.txt'), c); // a_3.txt
assert.deepEqual(st.files.map(f => f.name), ['a.txt', 'a_2.txt', 'a_3.txt']);
console.log(' суффиксы _2/_3: ok');
}
function testEsc() {
assert.equal(esc('<a href="x">&\'</a>'), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;');
console.log(' esc (XSS-экранирование): ok');
}
function testFs() {
assert.equal(fs(0), '0 B');
assert.equal(fs(1023), '1023 B');
assert.equal(fs(1024), '1.0 KB');
assert.equal(fs(1048576), '1.0 MB');
console.log(' fs (размер): ok');
}
function testFmtSec() {
assert.equal(fmtSec(0), '0с');
assert.equal(fmtSec(59), '59с');
assert.equal(fmtSec(60), '1м 0с');
assert.equal(fmtSec(125), '2м 5с');
console.log(' fmtSec: ok');
}
function testEstForFile() {
assert.equal(estForFile({ size: 1048576 }, 12), 12); // 1 МБ × 12 = 12с
assert.equal(estForFile(null, 12), 0);
console.log(' estForFile: ok');
}
// ── Прогон ──
const TESTS = [
['decodeZipName', testDecodeZipName],
['zip кириллица', testZipCyrillic],
['вложенный zip', testZipNested],
['не-doc фильтр', testZipNonDoc],
['zip deflate (метод 8)', testZipDeflate],
['не-ZIP бросок', testParseZipNotZip],
['дедуп/суффиксы', testDedup],
['лимиты over', testLimits],
['суффиксы _2/_3', testSuffixes],
['esc XSS', testEsc],
['fs размер', testFs],
['fmtSec', testFmtSec],
['estForFile', testEstForFile],
const tests = [
['recursive ZIP tree and unsafe paths', testRecursiveZipTree],
['deduplication by path and size', testPathAndSizeDeduplication],
['obscure.lst selection filter', testObscureManifest],
['HTML escaping', testEscaping],
['status and document action rendering', testStatusAndDocumentActionsRenderSafely],
];
let failed = 0;
for (const [name, fn] of TESTS) {
try {
await fn();
console.log('PASS ' + name);
} catch (err) {
failed++;
console.error('FAIL ' + name + ': ' + err.message);
}
for (const [name, test] of tests) {
await test();
console.log(`PASS ${name}`);
}
if (failed) {
console.error(failed + ' тестов упало');
process.exit(1);
}
console.log('Все фронт-тесты прошли');