204 lines
8.9 KiB
JavaScript
204 lines
8.9 KiB
JavaScript
/**
|
|
* zip.js — Minimal pure-Node.js ZIP builder / reader.
|
|
* No external dependencies — uses only Node's built-in zlib.
|
|
*/
|
|
import zlib from 'zlib';
|
|
|
|
// ── CRC-32 ────────────────────────────────────────────────────────────────
|
|
const _crcTable = (() => {
|
|
const t = new Uint32Array(256);
|
|
for (let i = 0; i < 256; i++) {
|
|
let c = i;
|
|
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
t[i] = c;
|
|
}
|
|
return t;
|
|
})();
|
|
|
|
function crc32(buf) {
|
|
let crc = 0xFFFFFFFF;
|
|
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ _crcTable[(crc ^ buf[i]) & 0xFF];
|
|
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
}
|
|
|
|
// ── DOS date/time ─────────────────────────────────────────────────────────
|
|
function dosDateTime(d = new Date()) {
|
|
const time = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF;
|
|
const date = (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF;
|
|
return { time, date };
|
|
}
|
|
|
|
// ── Nettoyage d'un segment de chemin ZIP ────────────────────────────────────
|
|
// Retire les caractères interdits dans un nom de fichier/dossier cross-platform
|
|
// (\ / : * ? " < > | + caractères de contrôle) tout en conservant accents et
|
|
// espaces. Partagé par tous les exports ZIP de l'app (documents, investissements...)
|
|
// pour que la même règle de nommage s'applique partout.
|
|
export function sanitizeZipPart(name) {
|
|
return String(name).replace(/[\\/:*?"<>|\u0000-\u001F]/g, ' ').replace(/\s+/g, ' ').trim() || 'Sans nom';
|
|
}
|
|
|
|
/**
|
|
* createZip(entries) → Buffer
|
|
* entries: [{ name: string, data: Buffer|string }]
|
|
*/
|
|
export function createZip(entries) {
|
|
const chunks = [];
|
|
const centralHeaders = [];
|
|
let offset = 0;
|
|
const { time: modTime, date: modDate } = dosDateTime();
|
|
|
|
for (let { name, data } of entries) {
|
|
if (typeof data === 'string') data = Buffer.from(data, 'utf8');
|
|
const nameBuf = Buffer.from(name, 'utf8');
|
|
const crc = crc32(data);
|
|
const uncompSize = data.length;
|
|
|
|
// Try deflate; use stored if compressed is larger
|
|
const deflated = zlib.deflateRawSync(data);
|
|
const useDeflate = deflated.length < uncompSize;
|
|
const compData = useDeflate ? deflated : data;
|
|
const method = useDeflate ? 8 : 0;
|
|
|
|
// Local file header
|
|
const lh = Buffer.alloc(30 + nameBuf.length);
|
|
lh.writeUInt32LE(0x04034b50, 0);
|
|
lh.writeUInt16LE(20, 4);
|
|
// Bit 11 (0x0800) = "Language encoding flag (EFS)" — indique que le nom
|
|
// de fichier est encodé en UTF-8. Sans ce bit, les extracteurs qui ne
|
|
// devinent pas l'UTF-8 par défaut (Windows Explorer notamment) réinterprètent
|
|
// les octets UTF-8 des noms accentués avec la page de code système, d'où le
|
|
// mojibake constaté par Olivier ("Billet_électronique" -> "Billet_ Ⓡlectronique").
|
|
// Les noms sont toujours écrits en UTF-8 ci-dessus (Buffer.from(name, 'utf8')),
|
|
// ce bit est donc toujours correct à poser.
|
|
lh.writeUInt16LE(0x0800, 6);
|
|
lh.writeUInt16LE(method, 8);
|
|
lh.writeUInt16LE(modTime, 10);
|
|
lh.writeUInt16LE(modDate, 12);
|
|
lh.writeUInt32LE(crc, 14);
|
|
lh.writeUInt32LE(compData.length, 18);
|
|
lh.writeUInt32LE(uncompSize, 22);
|
|
lh.writeUInt16LE(nameBuf.length, 26);
|
|
lh.writeUInt16LE(0, 28);
|
|
nameBuf.copy(lh, 30);
|
|
|
|
chunks.push(lh, compData);
|
|
centralHeaders.push({ name: nameBuf, crc, method, modTime, modDate, compSize: compData.length, uncompSize, offset });
|
|
offset += lh.length + compData.length;
|
|
}
|
|
|
|
// Central directory
|
|
const cdStart = offset;
|
|
for (const h of centralHeaders) {
|
|
const cd = Buffer.alloc(46 + h.name.length);
|
|
cd.writeUInt32LE(0x02014b50, 0);
|
|
cd.writeUInt16LE(20, 4);
|
|
cd.writeUInt16LE(20, 6);
|
|
cd.writeUInt16LE(0x0800, 8); // même flag UTF-8 (EFS) que l'en-tête local, cf. commentaire ci-dessus
|
|
cd.writeUInt16LE(h.method, 10);
|
|
cd.writeUInt16LE(h.modTime, 12);
|
|
cd.writeUInt16LE(h.modDate, 14);
|
|
cd.writeUInt32LE(h.crc, 16);
|
|
cd.writeUInt32LE(h.compSize, 20);
|
|
cd.writeUInt32LE(h.uncompSize, 24);
|
|
cd.writeUInt16LE(h.name.length, 28);
|
|
cd.writeUInt16LE(0, 30);
|
|
cd.writeUInt16LE(0, 32);
|
|
cd.writeUInt16LE(0, 34);
|
|
cd.writeUInt16LE(0, 36);
|
|
cd.writeUInt32LE(0, 38);
|
|
cd.writeUInt32LE(h.offset, 42);
|
|
h.name.copy(cd, 46);
|
|
chunks.push(cd);
|
|
offset += cd.length;
|
|
}
|
|
|
|
const cdSize = offset - cdStart;
|
|
|
|
// End of central directory
|
|
const eocd = Buffer.alloc(22);
|
|
eocd.writeUInt32LE(0x06054b50, 0);
|
|
eocd.writeUInt16LE(0, 4);
|
|
eocd.writeUInt16LE(0, 6);
|
|
eocd.writeUInt16LE(centralHeaders.length, 8);
|
|
eocd.writeUInt16LE(centralHeaders.length, 10);
|
|
eocd.writeUInt32LE(cdSize, 12);
|
|
eocd.writeUInt32LE(cdStart, 16);
|
|
eocd.writeUInt16LE(0, 20);
|
|
chunks.push(eocd);
|
|
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
// ── Protection contre les "zip bombs" ───────────────────────────────────
|
|
// readZip() est utilisé pour relire des zips produits en interne par l'appli
|
|
// (exports/sauvegardes admin — contenu maîtrisé) MAIS AUSSI pour des zips
|
|
// envoyés directement par un utilisateur (import de dossier d'investissement,
|
|
// import PFU/plateformes/référentiel) — donc potentiellement malveillants.
|
|
// Un zip conçu avec des données très répétitives peut annoncer quelques Ko
|
|
// compressés pour plusieurs Go décompressés (ratio extrême), ce qui saturerait
|
|
// la mémoire du process si rien ne le limite. Deux garde-fous complémentaires :
|
|
// 1. Rejet précoce si la taille décompressée ANNONCÉE dans l'en-tête central
|
|
// dépasse déjà la limite (évite même de lancer la décompression).
|
|
// 2. `maxOutputLength` passé à zlib : filet de sécurité si l'en-tête ment sur
|
|
// la taille annoncée — zlib interrompt la décompression dès que la sortie
|
|
// réelle dépasse la limite, sans jamais allouer plus que nécessaire.
|
|
// Limites généreuses par rapport à l'usage réel de l'appli (sauvegarde complète
|
|
// = base + logos/icons/documents, quelques dizaines de Mo en pratique) tout en
|
|
// bornant strictement le pire cas.
|
|
export const MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES = 500 * 1024 * 1024; // 500 Mo par entrée
|
|
export const MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024; // 1 Go cumulé par zip
|
|
|
|
/**
|
|
* readZip(buffer) → [{ name: string, data: Buffer }]
|
|
*/
|
|
export function readZip(buffer) {
|
|
// Scan backwards for EOCD signature
|
|
let eocdOffset = -1;
|
|
for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65558); i--) {
|
|
if (buffer.readUInt32LE(i) === 0x06054b50) { eocdOffset = i; break; }
|
|
}
|
|
if (eocdOffset === -1) throw new Error('ZIP invalide : signature EOCD introuvable');
|
|
|
|
const entryCount = buffer.readUInt16LE(eocdOffset + 8);
|
|
const cdOffset = buffer.readUInt32LE(eocdOffset + 16);
|
|
|
|
const entries = [];
|
|
let pos = cdOffset;
|
|
let totalUncompressed = 0;
|
|
|
|
for (let i = 0; i < entryCount; i++) {
|
|
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('ZIP invalide : signature Central Directory incorrecte');
|
|
const method = buffer.readUInt16LE(pos + 10);
|
|
const compSize = buffer.readUInt32LE(pos + 20);
|
|
const uncompSize = buffer.readUInt32LE(pos + 24);
|
|
const nameLen = buffer.readUInt16LE(pos + 28);
|
|
const extraLen = buffer.readUInt16LE(pos + 30);
|
|
const commentLen = buffer.readUInt16LE(pos + 32);
|
|
const localOffset = buffer.readUInt32LE(pos + 42);
|
|
const name = buffer.toString('utf8', pos + 46, pos + 46 + nameLen);
|
|
|
|
if (uncompSize > MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES) {
|
|
throw new Error(`ZIP invalide : l'entrée "${name}" dépasse la taille décompressée maximale autorisée (${Math.round(MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`);
|
|
}
|
|
totalUncompressed += uncompSize;
|
|
if (totalUncompressed > MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES) {
|
|
throw new Error(`ZIP invalide : la taille décompressée totale dépasse la limite autorisée (${Math.round(MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`);
|
|
}
|
|
|
|
// Read local file header to get actual extra field length
|
|
const localNameLen = buffer.readUInt16LE(localOffset + 26);
|
|
const localExtraLen = buffer.readUInt16LE(localOffset + 28);
|
|
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
|
|
|
|
const compData = buffer.subarray(dataStart, dataStart + compSize);
|
|
// maxOutputLength : filet de sécurité si l'en-tête ment sur uncompSize —
|
|
// voir commentaire au-dessus des constantes MAX_ZIP_*.
|
|
const data = method === 0 ? compData : zlib.inflateRawSync(compData, { maxOutputLength: MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES });
|
|
|
|
entries.push({ name, data: Buffer.from(data) });
|
|
pos += 46 + nameLen + extraLen + commentLen;
|
|
}
|
|
|
|
return entries;
|
|
}
|