All files / src archive.js

95.62% Statements 153/160
78.57% Branches 33/42
100% Functions 19/19
99.3% Lines 142/143

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259          144x 144x 144x 144x 144x     254x       238x       22x 22x 22x 22x 22x           26x 26x 22x   4x       56x 56x 56x   5x         40x         26x 26x 26x 26x 26x   26x 26x 26x 30x   30x 30x 30x 30x 30x 30x 30x 30x 30x 30x 30x     30x 30x 30x 30x 30x 30x 30x   30x 24x 6x 5x   1x   28x   18x       144x 36864x 294912x 36864x       181x 35844055x 181x           8x 8x 8x 8x   8x 12x 12x   12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x     12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x   12x     8x 8x 12x     8x 8x 8x 8x 8x 8x 8x   8x       10x 10x 2x   8x       11x     144x   144x     144x   5x 5x     3x       144x                   144x         23x     23x 23x   8x     15x   15x   15x 21x 21x 3x 3x   18x 4x 4x 4x   14x 14x     15x 1x     14x 14x                 19x       4x    
// Minimal ZIP extractor. Supports methods 0 (stored) and 8 (deflate); other
// methods (bzip2, lzma, etc.) will throw an error with the method number.
 
import { inflate as pakoInflate, inflateRaw as pakoInflateRaw, ungzip as pakoUngzip } from "pako";
 
const ZipLocalHeaderSig = 0x04034b50;
const ZipCentralDirSig = 0x02014b50;
const ZipEocdSig = 0x06054b50;
const ZipMethodStored = 0;
const ZipMethodDeflate = 8;
 
function readU16(buf, off) {
    return buf[off] | (buf[off + 1] << 8);
}
 
function readU32(buf, off) {
    return (buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24)) >>> 0;
}
 
function isValidEocd(buf, off) {
    const commentLen = readU16(buf, off + 20);
    Iif (off + 22 + commentLen !== buf.length) return false;
    const cdOff = readU32(buf, off + 16);
    const cdSize = readU32(buf, off + 12);
    return cdOff <= off && cdSize <= off - cdOff;
}
 
// Find the End of Central Directory record by scanning backwards.
// EOCD is in the last 65557 bytes (22-byte fixed record + up to 65535-byte comment).
function findEocd(buf) {
    const searchStart = Math.max(0, buf.length - 65557);
    for (let i = buf.length - 22; i >= searchStart; i--) {
        Eif (readU32(buf, i) === ZipEocdSig && isValidEocd(buf, i)) return i;
    }
    throw new Error("Not a ZIP file: EOCD not found");
}
 
function inflateWith(inflater, data, context) {
    Iif (!(data instanceof Uint8Array)) data = new Uint8Array(data);
    try {
        return inflater(data);
    } catch (cause) {
        throw new Error(`${context}: ${cause.message || cause}`, { cause });
    }
}
 
export function inflate(data) {
    return inflateWith(pakoInflate, data, "Unable to inflate");
}
 
// Extract all files from a ZIP archive.  Returns {filename: Uint8Array}.
export async function unzip(buf) {
    Iif (!(buf instanceof Uint8Array)) buf = new Uint8Array(buf);
    const eocdOff = findEocd(buf);
    const cdOff = readU32(buf, eocdOff + 16);
    const cdCount = readU16(buf, eocdOff + 10);
    const decoder = new TextDecoder();
 
    const files = Object.create(null);
    let pos = cdOff;
    for (let i = 0; i < cdCount; i++) {
        Iif (pos + 46 > buf.length || readU32(buf, pos) !== ZipCentralDirSig)
            throw new Error("Bad central directory entry");
        const flags = readU16(buf, pos + 8);
        Iif (flags & 0x0001) throw new Error("Encrypted ZIP entries are not supported");
        const method = readU16(buf, pos + 10);
        const expectedCrc = readU32(buf, pos + 16);
        const compressedSize = readU32(buf, pos + 20);
        const nameLen = readU16(buf, pos + 28);
        const extraLen = readU16(buf, pos + 30);
        const commentLen = readU16(buf, pos + 32);
        const localHeaderOff = readU32(buf, pos + 42);
        const name = decoder.decode(buf.subarray(pos + 46, pos + 46 + nameLen));
        pos += 46 + nameLen + extraLen + commentLen;
 
        // Read local file header to find actual data offset.
        Iif (readU32(buf, localHeaderOff) !== ZipLocalHeaderSig) throw new Error(`Bad local file header for ${name}`);
        const localNameLen = readU16(buf, localHeaderOff + 26);
        const localExtraLen = readU16(buf, localHeaderOff + 28);
        const dataOff = localHeaderOff + 30 + localNameLen + localExtraLen;
        const dataEnd = dataOff + compressedSize;
        Iif (dataEnd > buf.length) throw new Error(`Truncated ZIP entry data for ${name}`);
        const raw = buf.subarray(dataOff, dataEnd);
 
        if (method === ZipMethodStored) {
            files[name] = raw.slice();
        } else if (method === ZipMethodDeflate) {
            files[name] = inflateWith(pakoInflateRaw, raw, `Unable to inflate ZIP entry ${name}`);
        } else {
            throw new Error(`Unsupported ZIP compression method ${method} for ${name}`);
        }
        if (crc32(files[name]) >>> 0 !== expectedCrc) throw new Error(`Corrupt ZIP entry ${name}: CRC32 mismatch`);
    }
    return files;
}
 
// Standard CRC-32/ISO-HDLC.
const Crc32Table = new Uint32Array(256).map((_, index) => {
    let crc = index;
    for (let bit = 0; bit < 8; ++bit) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
    return crc;
});
 
export function crc32(data) {
    let crc = 0xffffffff;
    for (let i = 0; i < data.length; ++i) crc = (crc >>> 8) ^ Crc32Table[(crc ^ data[i]) & 0xff];
    return ~crc;
}
 
// Create a ZIP blob from an array of {name: string, data: Uint8Array} entries.
// Uses stored method (no compression) with CRC-32 for maximum compatibility.
export function createZipBlob(files) {
    const encoder = new TextEncoder();
    const localHeaders = [];
    const centralHeaders = [];
    let offset = 0;
 
    for (const { name, data } of files) {
        const nameBytes = encoder.encode(name);
        const crc = crc32(data);
        // Local file header (30 + nameLen + data)
        const local = new Uint8Array(30 + nameBytes.length + data.length);
        const lv = new DataView(local.buffer);
        lv.setUint32(0, 0x04034b50, true); // signature
        lv.setUint16(4, 20, true); // version needed
        lv.setUint16(8, 0, true); // method: stored
        lv.setUint32(14, crc, true); // CRC-32
        lv.setUint32(18, data.length, true); // compressed size
        lv.setUint32(22, data.length, true); // uncompressed size
        lv.setUint16(26, nameBytes.length, true); // name length
        local.set(nameBytes, 30);
        local.set(data, 30 + nameBytes.length);
        localHeaders.push(local);
 
        // Central directory entry (46 + nameLen)
        const central = new Uint8Array(46 + nameBytes.length);
        const cv = new DataView(central.buffer);
        cv.setUint32(0, 0x02014b50, true); // signature
        cv.setUint16(4, 20, true); // version made by
        cv.setUint16(6, 20, true); // version needed
        cv.setUint16(10, 0, true); // method: stored
        cv.setUint32(16, crc, true); // CRC-32
        cv.setUint32(20, data.length, true); // compressed size
        cv.setUint32(24, data.length, true); // uncompressed size
        cv.setUint16(28, nameBytes.length, true); // name length
        cv.setUint32(42, offset, true); // local header offset
        central.set(nameBytes, 46);
        centralHeaders.push(central);
 
        offset += local.length;
    }
 
    const cdOffset = offset;
    let cdSize = 0;
    for (const c of centralHeaders) cdSize += c.length;
 
    // End of central directory (22 bytes)
    const eocd = new Uint8Array(22);
    const ev = new DataView(eocd.buffer);
    ev.setUint32(0, 0x06054b50, true); // signature
    ev.setUint16(8, files.length, true); // entries on this disc
    ev.setUint16(10, files.length, true); // total entries
    ev.setUint32(12, cdSize, true); // central directory size
    ev.setUint32(16, cdOffset, true); // central directory offset
 
    return new Blob([...localHeaders, ...centralHeaders, eocd], { type: "application/zip" });
}
 
export function replaceOrAddExtension(name, newExt) {
    const lastDot = name.lastIndexOf(".");
    if (lastDot === -1) {
        return name + newExt;
    }
    return name.substring(0, lastDot) + newExt;
}
 
export async function ungzip(data) {
    return inflateWith(pakoUngzip, data, "Unable to ungzip");
}
 
const GzipMagic = [0x1f, 0x8b];
 
export const isGzip = (bytes) => GzipMagic.every((byte, index) => bytes[index] === byte);
 
/** Text to gzip bytes and back. */
export const gzipCodec = Object.freeze({
    async compress(text) {
        const stream = new Response(text).body.pipeThrough(new CompressionStream("gzip"));
        return new Uint8Array(await new Response(stream).arrayBuffer());
    },
    async decompress(bytes) {
        return new Response(new Response(bytes).body.pipeThrough(new DecompressionStream("gzip"))).text();
    },
});
 
const knownDiscExtensions = {
    uef: true,
    ssd: true,
    dsd: true,
    adf: true,
    adm: true,
    adl: true,
    hfe: true,
};
 
const knownRomExtensions = {
    rom: true,
};
 
async function unzipImage(data, knownExtensions) {
    console.log("Attempting to unzip");
 
    let files;
    try {
        files = await unzip(data);
    } catch (e) {
        throw new Error("Error unzipping " + e.message, { cause: e });
    }
 
    let uncompressed = null;
    let loadedFile;
    const ignored = [];
 
    for (const [filename, fileData] of Object.entries(files)) {
        const match = filename.match(/.*\.([a-z]+)/i);
        if (!match || !knownExtensions[match[1].toLowerCase()]) {
            console.log("Skipping file", filename);
            continue;
        }
        if (uncompressed) {
            console.log("Ignoring", filename, "as already found a file");
            ignored.push(filename);
            continue;
        }
        loadedFile = filename;
        uncompressed = fileData;
    }
 
    if (!uncompressed) {
        throw new Error("Couldn't find any compatible files in the archive");
    }
 
    console.log("Unzipped '" + loadedFile + "'");
    return { data: uncompressed, name: loadedFile, ignored };
}
 
/**
 * @param {Uint8Array|number[]} data a ZIP archive
 * @returns {Promise<{data: Uint8Array, name: string, ignored: string[]}>} the one file loaded, and the
 *     names of any other loadable files the archive held
 */
export async function unzipDiscImage(data) {
    return unzipImage(data, knownDiscExtensions);
}
 
export async function unzipRomImage(data) {
    return unzipImage(data, knownRomExtensions);
}