/** * 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; }