// this[BUFFER] is the remainder of a chunk if we're waiting for
// the full 512 bytes of a header to come in.  We will Buffer.concat()
// it to the next write(), which is a mem copy, but a small one.
//
// this[QUEUE] is a list of entries that haven't been emitted
// yet this can only get filled up if the user keeps write()ing after
// a write() returns false, or does a write() with more than one entry
//
// We don't buffer chunks, we always parse them and either create an
// entry, or push it into the active entry.  The ReadEntry class knows
// to throw data away if .ignore=true
//
// Shift entry off the buffer when it emits 'end', and emit 'entry' for
// the next one in the list.
//
// At any time, we're pushing body chunks into the entry at WRITEENTRY,
// and waiting for 'end' on the entry at READENTRY
//
// ignored entries get .resume() called on them straight away
import { EventEmitter as EE } from 'events';
import { BrotliDecompress, Unzip, ZstdDecompress } from 'minizlib';
import { Header } from './header.js';
import { Pax } from './pax.js';
import { ReadEntry } from './read-entry.js';
import { warnMethod } from './warn-method.js';
const maxMetaEntrySize = 1024 * 1024;
const gzipHeader = Buffer.from([0x1f, 0x8b]);
const zstdHeader = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
const ZIP_HEADER_LEN = Math.max(gzipHeader.length, zstdHeader.length);
const STATE = Symbol('state');
const WRITEENTRY = Symbol('writeEntry');
const READENTRY = Symbol('readEntry');
const NEXTENTRY = Symbol('nextEntry');
const PROCESSENTRY = Symbol('processEntry');
const EX = Symbol('extendedHeader');
const GEX = Symbol('globalExtendedHeader');
const META = Symbol('meta');
const EMITMETA = Symbol('emitMeta');
const BUFFER = Symbol('buffer');
const QUEUE = Symbol('queue');
const ENDED = Symbol('ended');
const EMITTEDEND = Symbol('emittedEnd');
const EMIT = Symbol('emit');
const UNZIP = Symbol('unzip');
const CONSUMECHUNK = Symbol('consumeChunk');
const CONSUMECHUNKSUB = Symbol('consumeChunkSub');
const CONSUMEBODY = Symbol('consumeBody');
const CONSUMEMETA = Symbol('consumeMeta');
const CONSUMEHEADER = Symbol('consumeHeader');
const CONSUMING = Symbol('consuming');
const BUFFERCONCAT = Symbol('bufferConcat');
const MAYBEEND = Symbol('maybeEnd');
const WRITING = Symbol('writing');
const ABORTED = Symbol('aborted');
const DONE = Symbol('onDone');
const SAW_VALID_ENTRY = Symbol('sawValidEntry');
const SAW_NULL_BLOCK = Symbol('sawNullBlock');
const SAW_EOF = Symbol('sawEOF');
const CLOSESTREAM = Symbol('closeStream');
const MAX_DECOMPRESSION_RATIO = 1000;
const COMPRESSEDBYTESREAD = Symbol('compressedBytesRead');
const DECOMPRESSEDBYTESREAD = Symbol('decompressedBytesRead');
const CHECKDECOMPRESSIONRATIO = Symbol('checkDecompressionRatio');
const noop = () => true;
export class Parser extends EE {
    file;
    strict;
    maxMetaEntrySize;
    filter;
    brotli;
    zstd;
    maxDecompressionRatio;
    writable = true;
    readable = false;
    [QUEUE] = [];
    [BUFFER];
    [READENTRY];
    [WRITEENTRY];
    [STATE] = 'begin';
    [META] = '';
    [EX];
    [GEX];
    [ENDED] = false;
    [UNZIP];
    [ABORTED] = false;
    [SAW_VALID_ENTRY];
    [SAW_NULL_BLOCK] = false;
    [SAW_EOF] = false;
    [WRITING] = false;
    [CONSUMING] = false;
    [EMITTEDEND] = false;
    [COMPRESSEDBYTESREAD] = 0;
    [DECOMPRESSEDBYTESREAD] = 0;
    constructor(opt = {}) {
        super();
        this.file = opt.file || '';
        // these BADARCHIVE errors can't be detected early. listen on DONE.
        this.on(DONE, () => {
            if (this[STATE] === 'begin' || this[SAW_VALID_ENTRY] === false) {
                // either less than 1 block of data, or all entries were invalid.
                // Either way, probably not even a tarball.
                this.warn('TAR_BAD_ARCHIVE', 'Unrecognized archive format');
            }
        });
        if (opt.ondone) {
            this.on(DONE, opt.ondone);
        }
        else {
            this.on(DONE, () => {
                this.emit('prefinish');
                this.emit('finish');
                this.emit('end');
            });
        }
        this.strict = !!opt.strict;
        this.maxDecompressionRatio =
            typeof opt.maxDecompressionRatio === 'number' ?
                opt.maxDecompressionRatio
                : MAX_DECOMPRESSION_RATIO;
        this.maxMetaEntrySize = opt.maxMetaEntrySize || maxMetaEntrySize;
        this.filter = typeof opt.filter === 'function' ? opt.filter : noop;
        // Unlike gzip, brotli doesn't have any magic bytes to identify it
        // Users need to explicitly tell us they're extracting a brotli file
        // Or we infer from the file extension
        const isTBR = opt.file &&
            (opt.file.endsWith('.tar.br') || opt.file.endsWith('.tbr'));
        // if it's a tbr file it MIGHT be brotli, but we don't know until
        // we look at it and verify it's not a valid tar file.
        this.brotli =
            !(opt.gzip || opt.zstd) && opt.brotli !== undefined ? opt.brotli
                : isTBR ? undefined
                    : false;
        // zstd has magic bytes to identify it, but we also support explicit options
        // and file extension detection
        const isTZST = opt.file &&
            (opt.file.endsWith('.tar.zst') || opt.file.endsWith('.tzst'));
        this.zstd =
            !(opt.gzip || opt.brotli) && opt.zstd !== undefined ? opt.zstd
                : isTZST ? true
                    : undefined;
        // have to set this so that streams are ok piping into it
        this.on('end', () => this[CLOSESTREAM]());
        if (typeof opt.onwarn === 'function') {
            this.on('warn', opt.onwarn);
        }
        if (typeof opt.onReadEntry === 'function') {
            this.on('entry', opt.onReadEntry);
        }
    }
    warn(code, message, data = {}) {
        warnMethod(this, code, message, data);
    }
    [CONSUMEHEADER](chunk, position) {
        if (this[SAW_VALID_ENTRY] === undefined) {
            this[SAW_VALID_ENTRY] = false;
        }
        let header;
        try {
            header = new Header(chunk, position, this[EX], this[GEX]);
        }
        catch (er) {
            return this.warn('TAR_ENTRY_INVALID', er);
        }
        if (header.nullBlock) {
            if (this[SAW_NULL_BLOCK]) {
                this[SAW_EOF] = true;
                // ending an archive with no entries.  pointless, but legal.
                if (this[STATE] === 'begin') {
                    this[STATE] = 'header';
                }
                this[EMIT]('eof');
            }
            else {
                this[SAW_NULL_BLOCK] = true;
                this[EMIT]('nullBlock');
            }
        }
        else {
            this[SAW_NULL_BLOCK] = false;
            if (!header.cksumValid) {
                this.warn('TAR_ENTRY_INVALID', 'checksum failure', { header });
            }
            else if (!header.path) {
                this.warn('TAR_ENTRY_INVALID', 'path is required', { header });
            }
            else {
                const type = header.type;
                if (/^(Symbolic)?Link$/.test(type) && !header.linkpath) {
                    this.warn('TAR_ENTRY_INVALID', 'linkpath required', {
                        header,
                    });
                }
                else if (!/^(Symbolic)?Link$/.test(type) &&
                    !/^(Global)?ExtendedHeader$/.test(type) &&
                    header.linkpath) {
                    this.warn('TAR_ENTRY_INVALID', 'linkpath forbidden', {
                        header,
                    });
                }
                else {
                    const entry = (this[WRITEENTRY] = new ReadEntry(header, this[EX], this[GEX]));
                    // we do this for meta & ignored entries as well, because they
                    // are still valid tar, or else we wouldn't know to ignore them
                    if (!this[SAW_VALID_ENTRY]) {
                        if (entry.remain) {
                            // this might be the one!
                            const onend = () => {
                                if (!entry.invalid) {
                                    this[SAW_VALID_ENTRY] = true;
                                }
                            };
                            entry.on('end', onend);
                        }
                        else {
                            this[SAW_VALID_ENTRY] = true;
                        }
                    }
                    if (entry.meta) {
                        if (entry.size > this.maxMetaEntrySize) {
                            entry.ignore = true;
                            this[EMIT]('ignoredEntry', entry);
                            this[STATE] = 'ignore';
                            entry.resume();
                        }
                        else if (entry.size > 0) {
                            this[META] = '';
                            entry.on('data', c => (this[META] += c));
                            this[STATE] = 'meta';
                        }
                    }
                    else {
                        this[EX] = undefined;
                        entry.ignore = entry.ignore || !this.filter(entry.path, entry);
                        if (entry.ignore) {
                            // probably valid, just not something we care about
                            this[EMIT]('ignoredEntry', entry);
                            this[STATE] = entry.remain ? 'ignore' : 'header';
                            entry.resume();
                        }
                        else {
                            if (entry.remain) {
                                this[STATE] = 'body';
                            }
                            else {
                                this[STATE] = 'header';
                                entry.end();
                            }
                            if (!this[READENTRY]) {
                                this[QUEUE].push(entry);
                                this[NEXTENTRY]();
                            }
                            else {
                                this[QUEUE].push(entry);
                            }
                        }
                    }
                }
            }
        }
    }
    [CLOSESTREAM]() {
        queueMicrotask(() => this.emit('close'));
    }
    [PROCESSENTRY](entry) {
        let go = true;
        if (!entry) {
            this[READENTRY] = undefined;
            go = false;
        }
        else if (Array.isArray(entry)) {
            const [ev, ...args] = entry;
            this.emit(ev, ...args);
        }
        else {
            this[READENTRY] = entry;
            this.emit('entry', entry);
            if (!entry.emittedEnd) {
                entry.on('end', () => this[NEXTENTRY]());
                go = false;
            }
        }
        return go;
    }
    [NEXTENTRY]() {
        do { } while (this[PROCESSENTRY](this[QUEUE].shift()));
        if (this[QUEUE].length === 0) {
            // At this point, there's nothing in the queue, but we may have an
            // entry which is being consumed (readEntry).
            // If we don't, then we definitely can handle more data.
            // If we do, and either it's flowing, or it has never had any data
            // written to it, then it needs more.
            // The only other possibility is that it has returned false from a
            // write() call, so we wait for the next drain to continue.
            const re = this[READENTRY];
            const drainNow = !re || re.flowing || re.size === re.remain;
            if (drainNow) {
                if (!this[WRITING]) {
                    this.emit('drain');
                }
            }
            else {
                re.once('drain', () => this.emit('drain'));
            }
        }
    }
    [CONSUMEBODY](chunk, position) {
        // write up to but no  more than writeEntry.blockRemain
        const entry = this[WRITEENTRY];
        /* c8 ignore start */
        if (!entry) {
            throw new Error('attempt to consume body without entry??');
        }
        const br = entry.blockRemain ?? 0;
        /* c8 ignore stop */
        const c = br >= chunk.length && position === 0 ?
            chunk
            : chunk.subarray(position, position + br);
        entry.write(c);
        if (!entry.blockRemain) {
            this[STATE] = 'header';
            this[WRITEENTRY] = undefined;
            entry.end();
        }
        return c.length;
    }
    [CONSUMEMETA](chunk, position) {
        const entry = this[WRITEENTRY];
        const ret = this[CONSUMEBODY](chunk, position);
        // if we finished, then the entry is reset
        if (!this[WRITEENTRY] && entry) {
            this[EMITMETA](entry);
        }
        return ret;
    }
    [EMIT](ev, data, extra) {
        if (this[QUEUE].length === 0 && !this[READENTRY]) {
            this.emit(ev, data, extra);
        }
        else {
            this[QUEUE].push([ev, data, extra]);
        }
    }
    [EMITMETA](entry) {
        this[EMIT]('meta', this[META]);
        switch (entry.type) {
            case 'ExtendedHeader':
            case 'OldExtendedHeader':
                this[EX] = Pax.parse(this[META], this[EX], false);
                break;
            case 'GlobalExtendedHeader':
                this[GEX] = Pax.parse(this[META], this[GEX], true);
                break;
            case 'NextFileHasLongPath':
            case 'OldGnuLongPath': {
                const ex = this[EX] ?? Object.create(null);
                this[EX] = ex;
                ex.path = this[META].replace(/\0.*/, '');
                break;
            }
            case 'NextFileHasLongLinkpath': {
                const ex = this[EX] || Object.create(null);
                this[EX] = ex;
                ex.linkpath = this[META].replace(/\0.*/, '');
                break;
            }
            /* c8 ignore start */
            default:
                throw new Error('unknown meta: ' + entry.type);
            /* c8 ignore stop */
        }
    }
    abort(error) {
        if (this[ABORTED]) {
            return;
        }
        this[ABORTED] = true;
        this.emit('abort', error);
        // always throws, even in non-strict mode
        this.warn('TAR_ABORT', error, { recoverable: false });
    }
    [CHECKDECOMPRESSIONRATIO](chunk) {
        this[DECOMPRESSEDBYTESREAD] += chunk.length;
        const ratio = this[DECOMPRESSEDBYTESREAD] / this[COMPRESSEDBYTESREAD];
        if (ratio > this.maxDecompressionRatio) {
            this.abort(new Error(`max decompression ratio exceeded: ${ratio.toFixed(2)} > ${this.maxDecompressionRatio}`));
            return false;
        }
        return true;
    }
    write(chunk, encoding, cb) {
        if (typeof encoding === 'function') {
            cb = encoding;
            encoding = undefined;
        }
        if (typeof chunk === 'string') {
            chunk = Buffer.from(chunk,
            /* c8 ignore next */
            typeof encoding === 'string' ? encoding : 'utf8');
        }
        if (this[ABORTED]) {
            /* c8 ignore next */
            cb?.();
            return false;
        }
        // first write, might be gzipped, zstd, or brotli compressed
        const needSniff = this[UNZIP] === undefined ||
            (this.brotli === undefined && this[UNZIP] === false);
        if (needSniff && chunk) {
            if (this[BUFFER]) {
                chunk = Buffer.concat([this[BUFFER], chunk]);
                this[BUFFER] = undefined;
            }
            if (chunk.length < ZIP_HEADER_LEN) {
                this[BUFFER] = chunk;
                /* c8 ignore next */
                cb?.();
                return true;
            }
            // look for gzip header
            for (let i = 0; this[UNZIP] === undefined && i < gzipHeader.length; i++) {
                if (chunk[i] !== gzipHeader[i]) {
                    this[UNZIP] = false;
                }
            }
            // look for zstd header if gzip header not found
            let isZstd = false;
            if (this[UNZIP] === false && this.zstd !== false) {
                isZstd = true;
                for (let i = 0; i < zstdHeader.length; i++) {
                    if (chunk[i] !== zstdHeader[i]) {
                        isZstd = false;
                        break;
                    }
                }
            }
            const maybeBrotli = this.brotli === undefined && !isZstd;
            if (this[UNZIP] === false && maybeBrotli) {
                // read the first header to see if it's a valid tar file. If so,
                // we can safely assume that it's not actually brotli, despite the
                // .tbr or .tar.br file extension.
                // if we ended before getting a full chunk, yes, def brotli
                if (chunk.length < 512) {
                    if (this[ENDED]) {
                        this.brotli = true;
                    }
                    else {
                        this[BUFFER] = chunk;
                        /* c8 ignore next */
                        cb?.();
                        return true;
                    }
                }
                else {
                    // if it's tar, it's pretty reliably not brotli, chances of
                    // that happening are astronomical.
                    try {
                        new Header(chunk.subarray(0, 512));
                        this.brotli = false;
                    }
                    catch (_) {
                        this.brotli = true;
                    }
                }
            }
            if (this[UNZIP] === undefined ||
                (this[UNZIP] === false && (this.brotli || isZstd))) {
                const ended = this[ENDED];
                this[ENDED] = false;
                this[UNZIP] =
                    this[UNZIP] === undefined ? new Unzip({})
                        : isZstd ? new ZstdDecompress({})
                            : new BrotliDecompress({});
                this[UNZIP].on('data', chunk => {
                    if (this[CHECKDECOMPRESSIONRATIO](chunk)) {
                        this[CONSUMECHUNK](chunk);
                    }
                });
                this[UNZIP].on('error', er => {
                    if (!this[ABORTED]) {
                        this.abort(er);
                    }
                });
                this[UNZIP].on('end', () => {
                    this[ENDED] = true;
                    this[CONSUMECHUNK]();
                });
                this[WRITING] = true;
                this[COMPRESSEDBYTESREAD] += chunk.length;
                const ret = !!this[UNZIP][ended ? 'end' : 'write'](chunk);
                this[WRITING] = false;
                cb?.();
                return ret;
            }
        }
        this[WRITING] = true;
        if (this[UNZIP]) {
            this[COMPRESSEDBYTESREAD] += chunk.length;
            this[UNZIP].write(chunk);
        }
        else {
            this[CONSUMECHUNK](chunk);
        }
        this[WRITING] = false;
        // return false if there's a queue, or if the current entry isn't flowing
        const ret = this[QUEUE].length > 0 ? false
            : this[READENTRY] ? this[READENTRY].flowing
                : true;
        // if we have no queue, then that means a clogged READENTRY
        if (!ret && this[QUEUE].length === 0) {
            this[READENTRY]?.once('drain', () => this.emit('drain'));
        }
        /* c8 ignore next */
        cb?.();
        return ret;
    }
    [BUFFERCONCAT](c) {
        if (c && !this[ABORTED]) {
            this[BUFFER] = this[BUFFER] ? Buffer.concat([this[BUFFER], c]) : c;
        }
    }
    [MAYBEEND]() {
        if (this[ENDED] &&
            !this[EMITTEDEND] &&
            !this[ABORTED] &&
            !this[CONSUMING]) {
            this[EMITTEDEND] = true;
            const entry = this[WRITEENTRY];
            if (entry?.blockRemain) {
                // truncated, likely a damaged file
                const have = this[BUFFER] ? this[BUFFER].length : 0;
                this.warn('TAR_BAD_ARCHIVE', `Truncated input (needed ${entry.blockRemain} more bytes, only ${have} available)`, { entry });
                if (this[BUFFER]) {
                    entry.write(this[BUFFER]);
                }
                entry.end();
            }
            this[EMIT](DONE);
        }
    }
    [CONSUMECHUNK](chunk) {
        if (this[CONSUMING] && chunk) {
            this[BUFFERCONCAT](chunk);
        }
        else if (!chunk && !this[BUFFER]) {
            this[MAYBEEND]();
        }
        else if (chunk) {
            this[CONSUMING] = true;
            if (this[BUFFER]) {
                this[BUFFERCONCAT](chunk);
                const c = this[BUFFER];
                this[BUFFER] = undefined;
                this[CONSUMECHUNKSUB](c);
            }
            else {
                this[CONSUMECHUNKSUB](chunk);
            }
            while (this[BUFFER] &&
                this[BUFFER]?.length >= 512 &&
                !this[ABORTED] &&
                !this[SAW_EOF]) {
                const c = this[BUFFER];
                this[BUFFER] = undefined;
                this[CONSUMECHUNKSUB](c);
            }
            this[CONSUMING] = false;
        }
        if (!this[BUFFER] || this[ENDED]) {
            this[MAYBEEND]();
        }
    }
    [CONSUMECHUNKSUB](chunk) {
        // we know that we are in CONSUMING mode, so anything written goes into
        // the buffer.  Advance the position and put any remainder in the buffer.
        let position = 0;
        const length = chunk.length;
        while (position + 512 <= length && !this[ABORTED] && !this[SAW_EOF]) {
            switch (this[STATE]) {
                case 'begin':
                case 'header':
                    this[CONSUMEHEADER](chunk, position);
                    position += 512;
                    break;
                case 'ignore':
                case 'body':
                    position += this[CONSUMEBODY](chunk, position);
                    break;
                case 'meta':
                    position += this[CONSUMEMETA](chunk, position);
                    break;
                /* c8 ignore start */
                default:
                    throw new Error('invalid state: ' + this[STATE]);
                /* c8 ignore stop */
            }
        }
        if (position < length) {
            this[BUFFER] =
                this[BUFFER] ?
                    Buffer.concat([chunk.subarray(position), this[BUFFER]])
                    : chunk.subarray(position);
        }
    }
    end(chunk, encoding, cb) {
        if (typeof chunk === 'function') {
            cb = chunk;
            encoding = undefined;
            chunk = undefined;
        }
        if (typeof encoding === 'function') {
            cb = encoding;
            encoding = undefined;
        }
        if (typeof chunk === 'string') {
            chunk = Buffer.from(chunk, encoding);
        }
        if (cb)
            this.once('finish', cb);
        if (!this[ABORTED]) {
            if (this[UNZIP]) {
                /* c8 ignore start */
                if (chunk) {
                    this[COMPRESSEDBYTESREAD] += chunk.length;
                    this[UNZIP].write(chunk);
                }
                /* c8 ignore stop */
                this[UNZIP].end();
            }
            else {
                this[ENDED] = true;
                if (this.brotli === undefined || this.zstd === undefined)
                    chunk = chunk || Buffer.alloc(0);
                if (chunk)
                    this.write(chunk);
                this[MAYBEEND]();
            }
        }
        return this;
    }
}
//# sourceMappingURL=parse.js.map