All files / src archive.js

95.39% Statements 145/152
78.57% Branches 33/42
100% Functions 15/15
99.27% Lines 136/137

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          56x 56x 56x 56x 56x     256x       242x       23x 23x 23x 23x 23x           26x 26x 23x   3x       26x 26x 26x   5x         9x         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   19x       56x 14336x 114688x 14336x       139x 21890054x 139x           8x 8x 8x 8x   8x 10x 10x   10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x     10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x   10x     8x 8x 10x     8x 8x 8x 8x 8x 8x 8x   8x       7x 7x 2x   5x       12x     56x                   56x         21x     21x 21x   7x     14x   14x   14x 19x 19x 3x 3x   16x 3x 3x 3x   13x 13x     14x 1x     13x 13x                 18x       3x    
// 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 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);
}