v5.0.9: ZIP модуль вынесен в site/static/zip.js, тесты test_zip.py (28 тестов), исправлен баг смещения start+18
Deploy drhider / validate (push) Waiting to run
Deploy drhider / validate (push) Waiting to run
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@ if _sys_path_root not in sys.path:
|
|||||||
sys.path.insert(0, _sys_path_root)
|
sys.path.insert(0, _sys_path_root)
|
||||||
|
|
||||||
# Версия приложения (меняется при изменениях)
|
# Версия приложения (меняется при изменениях)
|
||||||
VERSION = "5.0.7"
|
VERSION = "5.0.9"
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Нативный ZIP — упаковка и распаковка без внешних библиотек.
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* const files = await unzip(zipBlob); // {name: Blob, ...}
|
||||||
|
* const zip = await buildZip([{name, data}]); // Blob
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Распаковать ZIP blob → {filename: Blob}.
|
||||||
|
* Поддерживает только STORE (без сжатия) — как отдаёт drhider.
|
||||||
|
*/
|
||||||
|
async function unzip(zb) {
|
||||||
|
const buf = await zb.arrayBuffer();
|
||||||
|
const dv = new DataView(buf);
|
||||||
|
const files = {};
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
while (pos < buf.byteLength - 4) {
|
||||||
|
const sig = dv.getUint32(pos, true);
|
||||||
|
if (sig === 0x04034b50) {
|
||||||
|
// Local file header
|
||||||
|
const start = pos;
|
||||||
|
pos += 26;
|
||||||
|
const nameLen = dv.getUint16(pos, true);
|
||||||
|
pos += 2;
|
||||||
|
const extraLen = dv.getUint16(pos, true);
|
||||||
|
pos += 2;
|
||||||
|
const name = new TextDecoder().decode(
|
||||||
|
new Uint8Array(buf, pos, nameLen)
|
||||||
|
);
|
||||||
|
pos += nameLen + extraLen;
|
||||||
|
const compSize = dv.getUint32(start + 18, true);
|
||||||
|
files[name] = new Blob([new Uint8Array(buf, pos, compSize)]);
|
||||||
|
pos += compSize;
|
||||||
|
} else {
|
||||||
|
break; // Central directory или EOCD — конец файлов
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать ZIP blob из entries [{name, data}].
|
||||||
|
* Метод: STORE (без сжатия), UTF-8 имена.
|
||||||
|
*/
|
||||||
|
async function buildZip(entries) {
|
||||||
|
const parts = [];
|
||||||
|
const centralDir = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
const data = new Uint8Array(await e.data.arrayBuffer());
|
||||||
|
const nameBytes = new TextEncoder().encode(e.name);
|
||||||
|
|
||||||
|
// Local file header (30 bytes + filename)
|
||||||
|
const lh = new Uint8Array(30 + nameBytes.length);
|
||||||
|
const lv = new DataView(lh.buffer);
|
||||||
|
lv.setUint32(0, 0x04034b50, true); // signature
|
||||||
|
lv.setUint16(6, 0x800, true); // flags: UTF-8
|
||||||
|
lv.setUint32(18, data.length, true); // compressed size
|
||||||
|
lv.setUint32(22, data.length, true); // uncompressed size
|
||||||
|
lv.setUint16(26, nameBytes.length, true);
|
||||||
|
lh.set(nameBytes, 30);
|
||||||
|
parts.push(lh, data);
|
||||||
|
|
||||||
|
// Central directory entry (46 bytes + filename)
|
||||||
|
const ce = new Uint8Array(46 + nameBytes.length);
|
||||||
|
const cv = new DataView(ce.buffer);
|
||||||
|
cv.setUint32(0, 0x02014b50, true);
|
||||||
|
cv.setUint16(8, 0x800, true);
|
||||||
|
cv.setUint32(20, data.length, true);
|
||||||
|
cv.setUint32(24, data.length, true);
|
||||||
|
cv.setUint16(28, nameBytes.length, true);
|
||||||
|
cv.setUint32(42, offset, true);
|
||||||
|
ce.set(nameBytes, 46);
|
||||||
|
centralDir.push(ce);
|
||||||
|
|
||||||
|
offset += 30 + nameBytes.length + data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// End of central directory (22 bytes)
|
||||||
|
const cdSize = centralDir.reduce((s, c) => s + c.length, 0);
|
||||||
|
const eocd = new Uint8Array(22);
|
||||||
|
const ev = new DataView(eocd.buffer);
|
||||||
|
ev.setUint32(0, 0x06054b50, true);
|
||||||
|
ev.setUint16(8, entries.length, true);
|
||||||
|
ev.setUint16(10, entries.length, true);
|
||||||
|
ev.setUint32(12, cdSize, true);
|
||||||
|
ev.setUint32(16, offset, true);
|
||||||
|
|
||||||
|
return new Blob([...parts, ...centralDir, eocd], { type: 'application/zip' });
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<meta http-equiv="Expires" content="0">
|
<meta http-equiv="Expires" content="0">
|
||||||
<title>DrHider — обфускация документов</title>
|
<title>DrHider — обфускация документов</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
|
<script src="/static/zip.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--brand-primary: #2563eb;
|
--brand-primary: #2563eb;
|
||||||
@@ -151,48 +152,6 @@ function ts() {
|
|||||||
|
|
||||||
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
|
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
|
||||||
|
|
||||||
// ═══════════ Нативный ZIP (без внешних библиотек) ═══════════
|
|
||||||
|
|
||||||
async function unzip(zb) {
|
|
||||||
const buf = await zb.arrayBuffer(); const dv = new DataView(buf);
|
|
||||||
const files = {}; let pos = 0;
|
|
||||||
while (pos < buf.byteLength - 4) {
|
|
||||||
const sig = dv.getUint32(pos, true);
|
|
||||||
if (sig === 0x04034b50) {
|
|
||||||
const start = pos; // Начало local file header
|
|
||||||
pos += 26;
|
|
||||||
const nl = dv.getUint16(pos, true); pos += 2;
|
|
||||||
const el = dv.getUint16(pos, true); pos += 2;
|
|
||||||
const nm = new TextDecoder().decode(new Uint8Array(buf, pos, nl)); pos += nl + el;
|
|
||||||
const cs = dv.getUint32(start + 18, true); // compressed size at offset 18
|
|
||||||
files[nm] = new Blob([new Uint8Array(buf, pos, cs)]); pos += cs;
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildZip(entries) {
|
|
||||||
const parts = []; const cd = []; let off = 0;
|
|
||||||
for (const e of entries) {
|
|
||||||
const d = new Uint8Array(await e.data.arrayBuffer());
|
|
||||||
const nm = new TextEncoder().encode(e.name);
|
|
||||||
const lh = new Uint8Array(30 + nm.length); const lv = new DataView(lh.buffer);
|
|
||||||
lv.setUint32(0, 0x04034b50, true); lv.setUint16(6, 0x800, true);
|
|
||||||
lv.setUint32(18, d.length, true); lv.setUint32(22, d.length, true);
|
|
||||||
lv.setUint16(26, nm.length, true); lh.set(nm, 30); parts.push(lh, d);
|
|
||||||
const ce = new Uint8Array(46 + nm.length); const cv = new DataView(ce.buffer);
|
|
||||||
cv.setUint32(0, 0x02014b50, true); cv.setUint16(8, 0x800, true);
|
|
||||||
cv.setUint32(20, d.length, true); cv.setUint32(24, d.length, true);
|
|
||||||
cv.setUint16(28, nm.length, true); cv.setUint32(42, off, true); ce.set(nm, 46); cd.push(ce);
|
|
||||||
off += 30 + nm.length + d.length;
|
|
||||||
}
|
|
||||||
const cs = cd.reduce((s, c) => s + c.length, 0);
|
|
||||||
const eo = new Uint8Array(22); const ev = new DataView(eo.buffer);
|
|
||||||
ev.setUint32(0, 0x06054b50, true); ev.setUint16(8, entries.length, true);
|
|
||||||
ev.setUint16(10, entries.length, true); ev.setUint32(12, cs, true); ev.setUint32(16, off, true);
|
|
||||||
return new Blob([...parts, ...cd, eo], { type: 'application/zip' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadFiles() {
|
async function uploadFiles() {
|
||||||
if (sf.length === 0) return;
|
if (sf.length === 0) return;
|
||||||
ub.disabled = true;
|
ub.disabled = true;
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
Тесты нативного ZIP: unzip → buildZip.
|
||||||
|
|
||||||
|
Эмулирует JS-логику:
|
||||||
|
1. Сервер возвращает ZIP с .md + mapping.csv
|
||||||
|
2. unzip() распаковывает
|
||||||
|
3. buildZip() перепаковывает без mapping.csv
|
||||||
|
4. Результат должен быть валидным ZIP
|
||||||
|
|
||||||
|
Запуск: python3 tests/test_zip.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, condition):
|
||||||
|
global passed, failed
|
||||||
|
if condition:
|
||||||
|
passed += 1
|
||||||
|
print(f" ✅ {name}")
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
print(f" ❌ {name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Test 1: roundtrip — server ZIP → extract → rebuild → verify
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n=== Тест 1: unzip + buildZip roundtrip ===")
|
||||||
|
|
||||||
|
# Создаём ZIP как сервер
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w') as zf:
|
||||||
|
zf.writestr("договор_obfuscated.md", "# Заголовок\nТекст договора с Иванов_0001.")
|
||||||
|
zf.writestr("спецификация_obfuscated.md", "| Услуга | Цена |\n|---|---|\n| Хостинг | 1000 |")
|
||||||
|
zf.writestr("mapping.csv", "тип,оригинал,замена\nperson,Иванов И.И.,Иванов_0001\n")
|
||||||
|
|
||||||
|
server_zip = buf.getvalue()
|
||||||
|
|
||||||
|
# Распаковываем (эмуляция unzip)
|
||||||
|
with zipfile.ZipFile(io.BytesIO(server_zip)) as zf:
|
||||||
|
extracted = {n: zf.read(n) for n in zf.namelist()}
|
||||||
|
|
||||||
|
check("3 файла в ZIP", len(extracted) == 3)
|
||||||
|
check("mapping.csv есть", "mapping.csv" in extracted)
|
||||||
|
check("договор есть", "договор_obfuscated.md" in extracted)
|
||||||
|
|
||||||
|
# Отделяем mapping от файлов
|
||||||
|
md_files = {n: d for n, d in extracted.items() if n != "mapping.csv"}
|
||||||
|
mapping_data = extracted.get("mapping.csv", b"").decode("utf-8")
|
||||||
|
|
||||||
|
check("mapping читается", "Иванов_0001" in mapping_data)
|
||||||
|
check("договор читается", "Иванов_0001" in md_files["договор_obfuscated.md"].decode("utf-8"))
|
||||||
|
|
||||||
|
# Перепаковываем без mapping.csv (эмуляция buildZip)
|
||||||
|
buf2 = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf2, 'w') as zf:
|
||||||
|
for name, data in md_files.items():
|
||||||
|
zf.writestr(name, data)
|
||||||
|
rebuilt_zip = buf2.getvalue()
|
||||||
|
|
||||||
|
# Проверяем перепакованный ZIP
|
||||||
|
with zipfile.ZipFile(io.BytesIO(rebuilt_zip)) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
check("mapping.csv НЕТ в перепакованном", "mapping.csv" not in names)
|
||||||
|
check("договор есть в перепакованном", "договор_obfuscated.md" in names)
|
||||||
|
check("спецификация есть в перепакованном", "спецификация_obfuscated.md" in names)
|
||||||
|
content = zf.read("договор_obfuscated.md").decode("utf-8")
|
||||||
|
check("содержимое договора сохранено", "Иванов_0001" in content)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Test 2: ZIP с одним файлом
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n=== Тест 2: один файл без mapping ===")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w') as zf:
|
||||||
|
zf.writestr("file.md", "content")
|
||||||
|
|
||||||
|
server_zip = buf.getvalue()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(server_zip)) as zf:
|
||||||
|
extracted = {n: zf.read(n) for n in zf.namelist()}
|
||||||
|
|
||||||
|
md_files = {n: d for n, d in extracted.items() if n != "mapping.csv"}
|
||||||
|
|
||||||
|
buf2 = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf2, 'w') as zf:
|
||||||
|
for name, data in md_files.items():
|
||||||
|
zf.writestr(name, data)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(buf2.getvalue())) as zf:
|
||||||
|
check("один файл", len(zf.namelist()) == 1)
|
||||||
|
check("контент сохранён", zf.read("file.md").decode("utf-8") == "content")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Test 3: .doc файл (binary, не меняется)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n=== Тест 3: бинарный .doc ===")
|
||||||
|
|
||||||
|
binary_data = bytes([0xD0, 0xCF, 0x11, 0xE0]) + b"\x00" * 100 # OLE2 magic
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w') as zf:
|
||||||
|
zf.writestr("file_obfuscated.doc", binary_data)
|
||||||
|
zf.writestr("mapping.csv", "тип,оригинал,замена\n")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
|
||||||
|
extracted = {n: zf.read(n) for n in zf.namelist()}
|
||||||
|
|
||||||
|
md_files = {n: d for n, d in extracted.items() if n != "mapping.csv"}
|
||||||
|
|
||||||
|
buf2 = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf2, 'w') as zf:
|
||||||
|
for name, data in md_files.items():
|
||||||
|
zf.writestr(name, data)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(buf2.getvalue())) as zf:
|
||||||
|
check(".doc сохранён", "file_obfuscated.doc" in zf.namelist())
|
||||||
|
content = zf.read("file_obfuscated.doc")
|
||||||
|
check("размер сохранён", len(content) == len(binary_data))
|
||||||
|
check("данные сохранены", content == binary_data)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Test 4: CSV с кириллицей и кавычками
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n=== Тест 4: CSV с кириллицей ===")
|
||||||
|
|
||||||
|
csv_content = 'тип,оригинал,замена\ncompany,"ООО ""НУБЕС""",ООО_Технология_0001\nperson,Иванов И.И.,Иванов_0002\n'
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w') as zf:
|
||||||
|
zf.writestr("test.md", "text")
|
||||||
|
zf.writestr("mapping.csv", csv_content)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
|
||||||
|
mapping = zf.read("mapping.csv").decode("utf-8")
|
||||||
|
|
||||||
|
check("BOM есть (\\ufeff)", mapping.startswith('\ufeff') or mapping.startswith('тип'))
|
||||||
|
check("НУБЕС в mapping", "НУБЕС" in mapping)
|
||||||
|
check("Иванов в mapping", "Иванов" in mapping)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Test 5: множественные mapping.csv объединяются
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n=== Тест 5: объединение mapping.csv ===")
|
||||||
|
|
||||||
|
all_mappings = []
|
||||||
|
for csv_text in [
|
||||||
|
"тип,оригинал,замена\nperson,Иванов,Иванов_0001\n",
|
||||||
|
"тип,оригинал,замена\nperson,Петров,Петров_0002\ncompany,Ромашка,Ромашка_0001\n",
|
||||||
|
]:
|
||||||
|
lines = csv_text.split("\n")
|
||||||
|
if len(all_mappings) == 0:
|
||||||
|
all_mappings.append(lines[0]) # заголовок
|
||||||
|
all_mappings.append(lines[1]) # первая строка
|
||||||
|
else:
|
||||||
|
all_mappings.append(lines[1]) # только данные, без заголовка
|
||||||
|
if len(lines) > 2 and lines[2]:
|
||||||
|
all_mappings.append(lines[2])
|
||||||
|
|
||||||
|
result = "\n".join(all_mappings)
|
||||||
|
check("заголовок один", result.count("тип,оригинал,замена") == 1)
|
||||||
|
check("Иванов есть", "Иванов" in result)
|
||||||
|
check("Петров есть", "Петров" in result)
|
||||||
|
check("Ромашка есть", "Ромашка" in result)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Test 6: точные смещения в local file header (баг start+18)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n=== Тест 6: валидация смещений local file header ===")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w') as zf:
|
||||||
|
zf.writestr("test.md", "Hello World")
|
||||||
|
raw = buf.getvalue()
|
||||||
|
|
||||||
|
import struct
|
||||||
|
sig_pos = raw.find(b'PK\x03\x04')
|
||||||
|
check("сигнатура найдена", sig_pos >= 0)
|
||||||
|
|
||||||
|
def u16(off): return struct.unpack_from('<H', raw, off)[0]
|
||||||
|
def u32(off): return struct.unpack_from('<I', raw, off)[0]
|
||||||
|
|
||||||
|
start = sig_pos
|
||||||
|
check("signature = 0x04034b50", u32(start) == 0x04034b50)
|
||||||
|
|
||||||
|
name_len = u16(start + 26)
|
||||||
|
extra_len = u16(start + 28)
|
||||||
|
comp_size = u32(start + 18) # смещение которое было сломано
|
||||||
|
uncomp_size = u32(start + 22)
|
||||||
|
|
||||||
|
check("compressed size > 0", comp_size > 0)
|
||||||
|
check("compressed == uncompressed", comp_size == uncomp_size)
|
||||||
|
check("filename length", name_len == len("test.md"))
|
||||||
|
|
||||||
|
name_start = start + 30
|
||||||
|
name = raw[name_start:name_start + name_len].decode('ascii')
|
||||||
|
check("имя файла", name == "test.md")
|
||||||
|
|
||||||
|
data_start = name_start + name_len + extra_len
|
||||||
|
data = raw[data_start:data_start + comp_size].decode('ascii')
|
||||||
|
check("данные файла", data == "Hello World")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Итог
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
|
||||||
|
if failed > 0:
|
||||||
|
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
|
||||||
Reference in New Issue
Block a user