current commit
261027.7 · zlib: add raw content read methods to ZipEntry
'use strict';

// `ZipEntry`: a single archive member. Reads (buffered and streaming),
// (de)serialization, and the `create()`/`createSync()`/`createStream()`/
// `createSymlink()` builders, plus the `createEntryMeta()` helper that
// normalizes their options into the internal metadata record.

const {
  ArrayPrototypePush,
  ArrayPrototypeSlice,
  ArrayPrototypeSort,
  Date,
  DateNow,
  JSONStringify,
  MathFloor,
  MathMin,
  NumberMAX_SAFE_INTEGER,
  StringPrototypeEndsWith,
  SymbolAsyncDispose,
  SymbolAsyncIterator,
  SymbolDispose,
  SymbolIterator,
} = primordials;

const {
  codes: {
    ERR_INVALID_ARG_TYPE,
    ERR_INVALID_ARG_VALUE,
    ERR_INVALID_STATE,
    ERR_ZIP_ENTRY_TOO_LARGE,
    ERR_ZIP_INVALID_ARCHIVE,
    ERR_ZIP_UNSUPPORTED_FEATURE,
  },
} = require('internal/errors');
const {
  validateInteger,
  validateString,
  validateUint32,
} = require('internal/validators');
const {
  isDate,
  isUint8Array,
} = require('internal/util/types');
const { Buffer, kMaxLength } = require('buffer');
const { crc32: crc32Native } = internalBinding('zlib');
const {
  EMPTY_BUFFER,
  SIG_LOCAL_FILE_HEADER,
  SENTINEL16,
  FLAG_DATA_DESCRIPTOR,
  FLAG_UTF8,
  MADE_BY_UNIX,
  METHOD_STORE,
  METHOD_DEFLATE,
  METHOD_ZSTD,
  S_IFREG,
  S_IFDIR,
  S_IFLNK,
  S_IFMT,
  READ_CHUNK_SIZE,
  kFinalize,
  kPromote,
} = require('internal/zip/constants');
const {
  toBuffer,
  validateArchiveRange,
} = require('internal/zip/binary');
const {
  extraFieldMtime,
  stripZip64Extra,
} = require('internal/zip/extra-fields');
const {
  decodeZipName,
  decodeZipText,
} = require('internal/zip/dos');
const {
  CentralFileHeader,
  LocalFileHeader,
  assertConsistentLocalHeader,
  findArchiveEnd,
} = require('internal/zip/headers');
const {
  buildLocalHeader,
  buildCentralHeader,
  buildDataDescriptor64,
} = require('internal/zip/header-builders');
const {
  deflateRawAsync,
  deflateRawSync,
  zstdCompressAsync,
  zstdCompressSync,
  deflateRawStream,
  zstdCompressStream,
  decodeMemberStream,
  decodeMemberAsync,
  decodeMemberSync,
} = require('internal/zip/compression');
const {
  readFdFully,
  readFdFullySync,
} = require('internal/zip/fs-util');
const { getMaxZipContentSize } = require('internal/zip/content-size');

// The whole (UTC) second to record in an extended-timestamp extra field when
// `mtimeMs` cannot be represented exactly by the 2-second-resolution,
// local-time DOS date/time fields (sub-second parts and odd seconds alike),
// or null when the DOS fields suffice or the value does not fit the extra
// field's signed 32-bit Unix-seconds range (through 2038). One rule, shared
// by createEntryMeta() and #finalizeMeta() so the two cannot drift.
function extendedMtimeSeconds(mtimeMs) {
  const seconds = MathFloor(mtimeMs / 1000);
  return (mtimeMs % 2000 !== 0 && seconds >= -2147483648 && seconds <= 2147483647) ?
    seconds : null;
}

// Normalize the public builder options into the internal metadata record:
// name/comment bytes, the UTF-8 flag, the Unix mode packed into the external
// attributes (sec. 4.4.15), and the DOS/extended-timestamp fields.
function createEntryMeta(filename, options) {
  validateString(filename, 'filename');
  const name = Buffer.from(filename, 'utf8');
  if (name.length === 0) {
    throw new ERR_INVALID_ARG_VALUE('filename', filename, 'must not be empty');
  }
  if (name.length > SENTINEL16) {
    throw new ERR_ZIP_ENTRY_TOO_LARGE(
      'the entry name must not exceed 65535 bytes when encoded as UTF-8');
  }
  let comment = EMPTY_BUFFER;
  if (options?.comment !== undefined) {
    validateString(options.comment, 'options.comment');
    comment = Buffer.from(options.comment, 'utf8');
    if (comment.length > SENTINEL16) {
      throw new ERR_ZIP_ENTRY_TOO_LARGE(
        'the entry comment must not exceed 65535 bytes when encoded as UTF-8');
    }
  }
  const isSymlink = options?.symlink === true;
  const isDirectory = !isSymlink && StringPrototypeEndsWith(filename, '/');
  const mode = options?.mode ?? (isSymlink ? 0o777 : isDirectory ? 0o755 : 0o644);
  validateUint32(mode, 'options.mode');
  // Default to the current time at the DOS fields' 2-second resolution, so a
  // default entry needs no extended-timestamp extra field (see below).
  const modified = options?.modified ?? new Date(MathFloor(DateNow() / 2000) * 2000);
  if (!isDate(modified)) {
    throw new ERR_INVALID_ARG_TYPE('options.modified', 'Date', modified);
  }
  if (options?.method !== undefined &&
      options.method !== 'deflate' && options.method !== 'store' && options.method !== 'zstd') {
    throw new ERR_INVALID_ARG_VALUE(
      'options.method', options.method, "must be 'deflate', 'store', or 'zstd'");
  }
  const typeBits = isSymlink ? S_IFLNK : isDirectory ? S_IFDIR : S_IFREG;
  const unixAttrs = (typeBits | (mode & 0o7777)) & SENTINEL16;
  const external = ((unixAttrs << 16) | (isDirectory ? 0x10 : 0)) >>> 0;
  // Record the whole (UTC) second in an extended-timestamp extra field when
  // the DOS fields cannot represent the time exactly; see
  // extendedMtimeSeconds().
  const extendedMtime = extendedMtimeSeconds(modified.getTime());
  return {
    name,
    comment,
    extra: EMPTY_BUFFER,
    flags: FLAG_UTF8,
    method: 0,
    crc: 0,
    compressedSize: 0,
    uncompressedSize: 0,
    modified,
    extendedMtime,
    external,
    internal: 0,
    madeBy: MADE_BY_UNIX,
    pending: true,
  };
}

// Release one in-flight read on a shared ZipFile descriptor handle. When the
// last read settles, wake a close() that is waiting to release the fd (see
// ZipFile.close() in file.js), so a read never lands on a closed/reused fd.
function endHandleRead(handle) {
  if (--handle.reads === 0 && handle.drain !== null) {
    const drain = handle.drain;
    handle.drain = null;
    drain();
  }
}

/**
 * A single file or directory inside a ZIP archive: reading, writing, and
 * (de)serializing one archive member.
 */
class ZipEntry {
  #central;
  #local;
  #content;
  #source = null;
  #meta = null;
  #serialized = false;
  // When #fd is non-null the entry is "file-backed": it holds no content
  // buffer, only a descriptor *handle* ({ fd, closed }) shared with the
  // owning ZipFile plus the local-header offset, and reads its compressed
  // bytes from disk on demand (see #compressedBytes/#rawChunks). The shared
  // handle lets close() invalidate every outstanding entry at once, so a
  // read never falls through to a bare (possibly reused) fd number.
  // #contentOffset caches the resolved start of the compressed data (a
  // number, not a buffer) once the local header has been read.
  #fd = null;
  #localOffset = -1;
  #contentOffset = -1;

  /**
   * @private
   */
  constructor(central, local, content, fd = null, localOffset = -1) {
    this.#central = central;
    this.#local = local;
    this.#content = content;
    this.#fd = fd;
    this.#localOffset = localOffset;
  }

  // Whether the content is stored in compressed form, whatever the method.
  get compressed() { return this.method !== METHOD_STORE; }
  get method() {
    return this.#meta ? this.#meta.method : this.#central.compressionMethod;
  }
  get flags() {
    return this.#meta ? this.#meta.flags : (this.#local ?? this.#central).flags;
  }
  get crc32() {
    if (this.#meta) {
      this.#assertNotPending();
      return this.#meta.crc;
    }
    return this.#central.crc32;
  }
  get name() {
    // The central directory is authoritative; a mismatched local-header name
    // is deliberately ignored (defends against parser-confusion attacks).
    // Both branches use the same decoding (Unicode Path extra, then
    // UTF-8/CP437), so serialization - which snapshots the raw bytes into
    // #meta - never changes what this getter reports.
    return this.#meta ?
      decodeZipName(this.#meta.name, this.#meta.flags, this.#meta.extra) :
      this.#central.fileName;
  }
  get nameBuffer() {
    return this.#meta ? this.#meta.name : this.#central.fileNameBuffer;
  }
  get comment() {
    return this.#meta ?
      decodeZipText(this.#meta.comment, this.#meta.flags) :
      this.#central.fileComment;
  }
  get size() {
    if (this.#meta) {
      this.#assertNotPending();
      return this.#meta.uncompressedSize;
    }
    return this.#central.uncompressedSize;
  }
  get compressedSize() {
    if (this.#meta) {
      this.#assertNotPending();
      return this.#meta.compressedSize;
    }
    return this.#central.compressedSize;
  }
  get modified() {
    if (this.#meta) return this.#meta.modified;
    // Prefer an extra-field timestamp (absolute, higher-resolution) over the
    // coarse local-time DOS date/time fields when a foreign archive carries
    // one; consult both the central and local headers. A file-backed entry
    // starts out with only its central header, but some tools (7-Zip, for
    // one) write their high-fidelity timestamp only into the local header -
    // resolve it lazily (a small, one-time positioned read) rather than
    // silently reporting a coarser time than the archive carries, and fall
    // back to the central data when the local header cannot be read.
    if (this.#fd !== null && this.#local === null) {
      try {
        this.#resolveLocalHeaderSync();
      } catch {
        // A malformed local header fails loudly on the content read paths;
        // for metadata, the central directory alone has to do.
      }
    }
    return extraFieldMtime(this.#central.extraField, this.#local?.extraField) ??
      this.#central.lastModified;
  }
  get mode() {
    // The external attributes' high 16 bits hold Unix permissions only when
    // the entry was made by a Unix host (sec. 4.4.2/4.4.15) - the same rule
    // as `CentralFileHeader.prototype.mode`, which the non-meta branch
    // defers to.
    if (this.#meta) {
      return this.#meta.madeBy === MADE_BY_UNIX ?
        (this.#meta.external >>> 16) & 0o7777 : 0;
    }
    return this.#central.mode;
  }
  get isSymlink() {
    // Derived from the made-by host and the external attributes' Unix type
    // bits in both branches, so it survives serialization (which preserves
    // both verbatim); this also makes a fresh `createSymlink()` entry report
    // itself as one.
    if (this.#meta) {
      return this.#meta.madeBy === MADE_BY_UNIX &&
        ((this.#meta.external >>> 16) & S_IFMT) === S_IFLNK;
    }
    return this.#central.isSymlink;
  }
  get isFile() { return !this.isDirectory && !this.isSymlink; }
  get isDirectory() { return StringPrototypeEndsWith(this.name, '/'); }

  // Guard: reject metadata/content reads on a write-streaming entry whose
  // sizes and CRC are not yet known (still pending serialization).
  #assertNotPending() {
    if (this.#meta?.pending) {
      throw new ERR_INVALID_STATE(
        'this streaming entry has not finished serializing yet');
    }
  }

  // Snapshot this (parsed) entry's central-directory data into a
  // re-serializable #meta record, memoized. Needed so a round-tripped entry
  // can be re-emitted from stable, extra-field-aware values rather than raw
  // header bytes; clears the data-descriptor flag (see below).
  #finalizeMeta() {
    if (this.#meta) {
      this.#assertNotPending();
      return this.#meta;
    }
    const central = this.#central;
    // Descriptor entries (bit 3) are re-emitted with known sizes/CRC and bit
    // 3 cleared: full re-serialization emits a fresh local header from this
    // same record, so it never reproduces a bit-3 local header without a
    // data descriptor. (This invariant does NOT hold for `ZipFile`'s
    // in-place central-directory rewrite, which leaves local headers on disk
    // untouched - that path re-asserts bit 3 via `[kFinalize]`; see there.)
    // Sizes come from the central directory
    // (Zip64-aware); Zip64 extras are regenerated as needed, and all other
    // extra-field records (Unicode Path, NTFS/UT timestamps, ...) are
    // preserved so a re-serialized entry keeps its name encoding and
    // timestamps.
    const extra = stripZip64Extra(central.extraField);
    // Snapshot the resolved (extra-field-aware) time, not the raw DOS field,
    // so serialization does not degrade what `modified` reports. When that
    // time cannot be represented exactly in the DOS fields and no preserved
    // extra record carries it, record it in an extended-timestamp extra
    // (see extendedMtimeSeconds(); same rule as createEntryMeta()).
    const modified = this.modified;
    const extendedMtime = extraFieldMtime(extra) === null ?
      extendedMtimeSeconds(modified.getTime()) : null;
    const meta = {
      name: central.fileNameBuffer,
      comment: central.fileCommentBuffer,
      extra,
      flags: central.flags & ~FLAG_DATA_DESCRIPTOR,
      method: central.compressionMethod,
      crc: central.crc32,
      compressedSize: central.compressedSize,
      uncompressedSize: central.uncompressedSize,
      modified,
      extendedMtime,
      external: central.externalFileAttributes,
      internal: central.internalFileAttributes,
      // Preserve the creator's host byte (sec. 4.4.2): the external
      // attributes are only interpretable relative to it.
      madeBy: central.version >>> 8,
      pending: false,
    };
    this.#meta = meta;
    return meta;
  }

  // The live numeric descriptor for a file-backed entry, or a clean state
  // error if the ZipFile that owns it has since been closed - never a raw
  // (possibly OS-reused) fd number. Returns null for an in-memory entry.
  // A file-backed entry holds a descriptor *handle* ({ fd, closed }) shared
  // with its ZipFile, not a bare fd, so close() is observable here.
  #liveDescriptor() {
    const handle = this.#fd;
    if (handle === null) return null;
    if (handle.closed) {
      throw new ERR_INVALID_STATE(
        'cannot read a ZipEntry after its backing ZipFile has been closed');
    }
    return handle.fd;
  }

  // Read (and cache) this file-backed entry's local file header - whose
  // length (fixed 30 bytes plus variable name/extra fields) is only known
  // from the file itself, not from the central directory. Resolving it also
  // yields the offset where the compressed data begins, and gives the
  // `modified` getter access to a local-header-only timestamp extra.
  async #resolveLocalHeader() {
    if (this.#local !== null) return this.#local;
    const fd = this.#liveDescriptor();
    const fixed = Buffer.allocUnsafe(30);
    await readFdFully(fd, fixed, this.#localOffset);
    if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) {
      throw new ERR_ZIP_INVALID_ARCHIVE(
        `entry ${JSONStringify(this.name)} has an invalid local file header`);
    }
    const length = LocalFileHeader.length(fixed, 0);
    const full = Buffer.allocUnsafe(length);
    fixed.copy(full, 0);
    if (length > 30) {
      await readFdFully(fd, full.subarray(30), this.#localOffset + 30);
    }
    const local = new LocalFileHeader(full, 0);
    if (this.#central !== null) {
      assertConsistentLocalHeader(local, this.#central);
    }
    const contentOffset = this.#localOffset + length;
    this.#local = local;
    this.#contentOffset = contentOffset;
    return local;
  }
  // Sync counterpart of #resolveLocalHeader().
  #resolveLocalHeaderSync() {
    if (this.#local !== null) return this.#local;
    const fd = this.#liveDescriptor();
    const fixed = Buffer.allocUnsafe(30);
    readFdFullySync(fd, fixed, this.#localOffset);
    if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) {
      throw new ERR_ZIP_INVALID_ARCHIVE(
        `entry ${JSONStringify(this.name)} has an invalid local file header`);
    }
    const length = LocalFileHeader.length(fixed, 0);
    const full = Buffer.allocUnsafe(length);
    fixed.copy(full, 0);
    if (length > 30) {
      readFdFullySync(fd, full.subarray(30), this.#localOffset + 30);
    }
    const local = new LocalFileHeader(full, 0);
    if (this.#central !== null) {
      assertConsistentLocalHeader(local, this.#central);
    }
    const contentOffset = this.#localOffset + length;
    this.#local = local;
    this.#contentOffset = contentOffset;
    return local;
  }
  // The offset where this file-backed entry's compressed data begins, reading
  // the local header first if that has not been resolved yet.
  async #resolveContentOffset() {
    if (this.#contentOffset < 0) await this.#resolveLocalHeader();
    return this.#contentOffset;
  }
  // Sync counterpart of #resolveContentOffset().
  #resolveContentOffsetSync() {
    if (this.#contentOffset < 0) this.#resolveLocalHeaderSync();
    return this.#contentOffset;
  }
  // The in-memory raw bytes, or a clean state error when there are none - a
  // write-streaming entry (`createStream()`) has no readable content until it
  // has been serialized into a backing archive (after which `addEntry()`
  // promotes it to file-backed; see [kPromote]).
  #inMemoryCompressed() {
    if (this.#content === null) {
      throw new ERR_INVALID_STATE(
        'the content of a streaming entry is not available for reading');
    }
    return this.#content;
  }
  // The entry's raw (still-compressed) bytes. For an in-memory entry this is
  // the entry's retained buffer - shared memory, NOT a copy: it may alias
  // the source archive (`ZipEntry.read()`) or the caller's original `data`
  // (`create()` when storing). For a file-backed entry it is freshly read
  // from disk and caller-owned. Paths that hand these bytes out undecoded
  // (the store method) must copy the in-memory case; see `content()`.
  async #compressedBytes() {
    if (this.#fd === null) return this.#inMemoryCompressed();
    const size = this.compressedSize;
    // A member at or beyond the maximum Buffer length cannot be materialized
    // in one allocation; stream it instead. (kMaxLength equals the safe
    // integer ceiling on 64-bit, so a larger size fails to parse anyway.)
    if (size >= kMaxLength) {
      throw new ERR_ZIP_ENTRY_TOO_LARGE(
        `entry ${JSONStringify(this.name)} is too large to buffer ` +
        `(${size} compressed bytes); use contentIterator() instead`);
    }
    const start = await this.#resolveContentOffset();
    const compressed = Buffer.allocUnsafe(size);
    await readFdFully(this.#liveDescriptor(), compressed, start);
    return compressed;
  }
  // Sync counterpart of #compressedBytes().
  #compressedBytesSync() {
    if (this.#fd === null) return this.#inMemoryCompressed();
    const size = this.compressedSize;
    if (size >= kMaxLength) {
      throw new ERR_ZIP_ENTRY_TOO_LARGE(
        `entry ${JSONStringify(this.name)} is too large to buffer ` +
        `(${size} compressed bytes); use contentIterator() instead`);
    }
    const start = this.#resolveContentOffsetSync();
    const compressed = Buffer.allocUnsafe(size);
    readFdFullySync(this.#liveDescriptor(), compressed, start);
    return compressed;
  }
  // The entry's raw compressed bytes as a bounded-memory chunk stream, read
  // straight from disk (file-backed entries only). Nothing is retained.
  async *#rawChunks() {
    // Count active reads, but not time paused at a yield.
    const handle = this.#fd;
    if (handle.closing) {
      throw new ERR_INVALID_STATE(
        'cannot read a ZipEntry after its backing ZipFile has been closed');
    }
    handle.reads++;
    let reading = true;
    try {
      this.#liveDescriptor();
      let pos = await this.#resolveContentOffset();
      let remaining = this.compressedSize;
      while (remaining > 0) {
        const take = MathMin(READ_CHUNK_SIZE, remaining);
        const chunk = Buffer.allocUnsafe(take);
        // Re-check per chunk: the ZipFile may be closed mid-stream.
        await readFdFully(this.#liveDescriptor(), chunk, pos);
        pos += take;
        remaining -= take;
        reading = false;
        endHandleRead(handle);
        yield chunk;
        if (handle.closing) {
          throw new ERR_INVALID_STATE(
            'cannot read a ZipEntry after its backing ZipFile has been closed');
        }
        handle.reads++;
        reading = true;
      }
    } finally {
      if (reading) endHandleRead(handle);
    }
  }
  // Sync counterpart of #rawChunks().
  *#rawChunksSync() {
    this.#liveDescriptor();
    let pos = this.#resolveContentOffsetSync();
    let remaining = this.compressedSize;
    while (remaining > 0) {
      const take = MathMin(READ_CHUNK_SIZE, remaining);
      const chunk = Buffer.allocUnsafe(take);
      // Re-check per chunk: the ZipFile may be closed mid-stream.
      readFdFullySync(this.#liveDescriptor(), chunk, pos);
      pos += take;
      remaining -= take;
      yield chunk;
    }
  }

  /**
   * Reads, decompresses, and (by default) CRC-32-verifies the whole entry
   * into a single `Buffer`, enforcing the declared-size and `maxSize` limits.
   * @param {{ verify?: boolean, maxSize?: number }} [options]
   * @returns {Promise<Buffer>}
   */
  async content(options) {
    const declared = this.size;
    const maxSize = options?.maxSize ?? getMaxZipContentSize();
    if (declared > maxSize) {
      throw new ERR_ZIP_ENTRY_TOO_LARGE(
        `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` +
        `exceeding the ${maxSize} byte limit`);
    }
    // Count this read on the shared handle so a concurrent close() waits for it
    // rather than releasing the fd mid-read; a read begun after close() was
    // requested is rejected up front (in-memory entries have no fd).
    const handle = this.#fd;
    if (handle !== null) {
      if (handle.closing) {
        throw new ERR_INVALID_STATE(
          'cannot read a ZipEntry after its backing ZipFile has been closed');
      }
      handle.reads++;
    }
    try {
      const compressed = await this.#compressedBytes();
      const data = await decodeMemberAsync(compressed, {
        name: this.name,
        flags: this.flags,
        method: this.method,
        crc32: this.crc32,
        uncompressedSize: declared,
      }, { verify: options?.verify, maxSize });
      // `data === compressed` only on the store path; copy the in-memory case
      // (the entry's retained buffer, see #compressedBytes()) so the result is
      // caller-owned on every path.
      return data === compressed && this.#fd === null ? Buffer.from(data) : data;
    } finally {
      if (handle !== null) endHandleRead(handle);
    }
  }

  /**
   * The synchronous counterpart of `content()`. Blocks the event loop and
   * further JavaScript execution until the whole entry has been read and, if
   * applicable, inflated - use only where synchronous I/O is appropriate
   * (for example, short-lived scripts or startup code), not in code that
   * must stay responsive.
   * @param {{ verify?: boolean, maxSize?: number }} [options]
   * @returns {Buffer}
   */
  contentSync(options) {
    const declared = this.size;
    const maxSize = options?.maxSize ?? getMaxZipContentSize();
    if (declared > maxSize) {
      throw new ERR_ZIP_ENTRY_TOO_LARGE(
        `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` +
        `exceeding the ${maxSize} byte limit`);
    }
    const compressed = this.#compressedBytesSync();
    const data = decodeMemberSync(compressed, {
      name: this.name,
      flags: this.flags,
      method: this.method,
      crc32: this.crc32,
      uncompressedSize: declared,
    }, { verify: options?.verify, maxSize });
    // `data === compressed` only on the store path; copy the in-memory case
    // so the result is caller-owned on every path (see content()).
    return data === compressed && this.#fd === null ? Buffer.from(data) : data;
  }

  // The raw (still-compressed) bytes as an async iterable, without buffering
  // the whole member: straight from disk for a file-backed entry, or the
  // single in-memory buffer otherwise. Throws synchronously for a pending
  // write-streaming entry, whose content is not yet available for reading.
  #rawSource() {
    if (this.#fd !== null) return this.#rawChunks();
    const content = this.#inMemoryCompressed();
    return (async function* () {
      if (content.length) yield content;
    })();
  }

  /**
   * Yields the entry's decompressed content as a bounded-memory async
   * iterator of `Buffer` chunks, decompressing on the way and (by default)
   * verifying CRC-32. For a file-backed entry (from `ZipFile.get()`) the
   * compressed bytes are read from disk as they are consumed and nothing is
   * retained.
   * @param {{ verify?: boolean, maxSize?: number }} [options]
   * @returns {AsyncGenerator<Buffer>}
   */
  contentIterator(options) {
    // No default maxSize here, unlike content(): streaming is the
    // bounded-memory path for arbitrarily large members, so imposing content()'s
    // buffer-oriented ceiling would defeat its purpose (and cap legitimate
    // multi-gigabyte reads). Output is still bounded per chunk to the declared
    // uncompressed size; a caller that wants an explicit cap passes
    // options.maxSize.
    return decodeMemberStream(this.#rawSource(), {
      name: this.name,
      flags: this.flags,
      method: this.method,
      crc32: this.crc32,
      uncompressedSize: this.size,
    }, options);
  }

  #assertRawWithin(maxSize) {
    const declared = this.compressedSize;
    if (declared > maxSize) {
      throw new ERR_ZIP_ENTRY_TOO_LARGE(
        `entry ${JSONStringify(this.name)} declares ${declared} compressed ` +
        `bytes, exceeding the ${maxSize} byte limit`);
    }
  }

  /**
   * Reads the entry's raw (still-compressed) content into a single `Buffer`,
   * without decompressing or CRC-32-verifying it.
   * @param {{ maxSize?: number }} [options]
   * @returns {Promise<Buffer>}
   */
  async rawContent(options) {
    this.#assertRawWithin(options?.maxSize ?? getMaxZipContentSize());
    const handle = this.#fd;
    if (handle === null) return Buffer.from(this.#inMemoryCompressed());
    if (handle.closing) {
      throw new ERR_INVALID_STATE(
        'cannot read a ZipEntry after its backing ZipFile has been closed');
    }
    handle.reads++;
    try {
      return await this.#compressedBytes();
    } finally {
      endHandleRead(handle);
    }
  }

  /**
   * The synchronous counterpart of `rawContent()`.
   * @param {{ maxSize?: number }} [options]
   * @returns {Buffer}
   */
  rawContentSync(options) {
    this.#assertRawWithin(options?.maxSize ?? getMaxZipContentSize());
    const compressed = this.#compressedBytesSync();
    return this.#fd === null ? Buffer.from(compressed) : compressed;
  }

  /**
   * Yields the entry's raw (still-compressed) content as a bounded-memory
   * async iterator of `Buffer` chunks, without decompressing or verifying it.
   * @param {{ maxSize?: number }} [options]
   * @returns {AsyncGenerator<Buffer>}
   */
  rawContentIterator(options) {
    if (options?.maxSize !== undefined) this.#assertRawWithin(options.maxSize);
    return this.#rawSource();
  }

  // Synchronous serialization: emit the local file header (sec. 4.3.7)
  // followed by the raw (already-compressed) content bytes. Streaming
  // (source-backed) entries cannot be serialized this way.
  *[SymbolIterator]() {
    if (this.#source) {
      throw new ERR_INVALID_STATE('a streaming entry cannot be serialized synchronously');
    }
    const meta = this.#finalizeMeta();
    yield buildLocalHeader(meta);
    if (this.#fd !== null) {
      yield* this.#rawChunksSync();
    } else if (this.#content?.length) {
      yield this.#content;
    }
  }

  // Asynchronous serialization. For a write-streaming entry, drain the
  // source once, computing CRC-32 and sizes on the fly and emitting a Zip64
  // data descriptor after the content (sec. 4.3.9); otherwise defer to the
  // buffered/file-backed paths.
  async *[SymbolAsyncIterator]() {
    const source = this.#source;
    if (!source) {
      if (this.#fd !== null) {
        yield buildLocalHeader(this.#finalizeMeta());
        yield* this.#rawChunks();
        return;
      }
      yield* this[SymbolIterator]();
      return;
    }
    if (this.#serialized) {
      throw new ERR_INVALID_STATE('a streaming entry can only be serialized once');
    }
    this.#serialized = true;
    const meta = this.#meta;
    yield buildLocalHeader(meta);
    let state = 0;
    let uncompressedSize = 0;
    let compressedSize = 0;
    const counted = (async function* () {
      for await (const chunk of source) {
        if (!isUint8Array(chunk)) {
          throw new ERR_INVALID_ARG_TYPE('chunk', 'Uint8Array', chunk);
        }
        if (!chunk.length) continue;
        state = crc32Native(chunk, state);
        uncompressedSize += chunk.length;
        yield chunk;
      }
    })();
    const output = meta.method === METHOD_DEFLATE ? deflateRawStream(counted) :
      meta.method === METHOD_ZSTD ? zstdCompressStream(counted) : counted;
    for await (const chunk of output) {
      compressedSize += chunk.length;
      yield chunk;
    }
    meta.crc = state;
    meta.uncompressedSize = uncompressedSize;
    meta.compressedSize = compressedSize;
    meta.pending = false;
    yield buildDataDescriptor64(meta.crc, compressedSize, uncompressedSize);
  }

  // Release the entry's write-source, if it has one. Only a streaming entry
  // (`createStream()`) owns a source - a caller-supplied `AsyncIterable`,
  // often a file read stream holding a descriptor - and once that entry is
  // handed to `createZipArchive()` the caller can no longer reach it, so the
  // archive machinery disposes it when the archive finishes or its output
  // stream is destroyed early (see `generateZipArchive()`); a caller holding
  // an unused streaming entry can dispose it directly. In-memory and
  // file-backed entries hold no source - a file-backed entry's descriptor
  // belongs to the `ZipFile`, never to the entry - so disposing them is a
  // no-op. Disposal is idempotent and marks the entry spent, so a disposed
  // streaming entry can no longer be serialized.
  #releaseSource() {
    const source = this.#source;
    this.#source = null;
    this.#serialized = true;
    return source;
  }
  [SymbolDispose]() {
    const source = this.#releaseSource();
    if (source === null) return;
    if (typeof source.destroy === 'function') source.destroy();
    else if (typeof source.return === 'function') source.return();
  }
  async [SymbolAsyncDispose]() {
    const source = this.#releaseSource();
    if (source === null) return;
    if (typeof source[SymbolAsyncDispose] === 'function') await source[SymbolAsyncDispose]();
    else if (typeof source.destroy === 'function') source.destroy();
    else if (typeof source.return === 'function') await source.return();
  }

  /**
   * Builds this entry's central-directory header (sec. 4.3.12) recording its
   * local-header start; called by the archive writer once the offset is fixed.
   *
   * `preserveDescriptorFlag` is set by `ZipFile`'s in-place
   * central-directory rewrite, which never regenerates local headers: when
   * the on-disk local header advertises a data descriptor (bit 3, which
   * `#finalizeMeta()` clears for full re-serialization), the rebuilt central
   * header must keep advertising it too, or the two headers would contradict
   * each other (sec. 4.3.12 expects them to agree). Full re-serialization
   * emits a fresh, bit-3-free local header from the same meta, so there the
   * cleared flag is the consistent one.
   * @private
   * @param {number} localOffset
   * @param {boolean} [preserveDescriptorFlag]
   * @returns {Buffer}
   */
  [kFinalize](localOffset, preserveDescriptorFlag = false) {
    validateInteger(localOffset, 'localOffset', 0, NumberMAX_SAFE_INTEGER);
    const meta = this.#finalizeMeta();
    if (preserveDescriptorFlag && this.#central !== null &&
        (this.#central.flags & FLAG_DATA_DESCRIPTOR) !== 0) {
      return buildCentralHeader(
        { ...meta, flags: meta.flags | FLAG_DATA_DESCRIPTOR }, localOffset);
    }
    return buildCentralHeader(meta, localOffset);
  }

  // Rebind a just-serialized write-streaming entry to its on-disk copy so it
  // stops being dead weight: after `addEntry()`/`addEntrySync()` writes the
  // entry into `fd` at `localOffset` (its local-header start), the spent
  // source is dropped and the entry becomes a readable, re-serializable
  // file-backed entry (valid while `fd` stays open). Only a spent stream
  // entry - one with neither an in-memory buffer nor an existing backing fd -
  // is promoted; in-memory and already-file-backed entries are left as they
  // are. The kept `#meta` still supplies the (now-final) name/sizes/crc.
  [kPromote](fd, localOffset) {
    if (this.#content !== null || this.#fd !== null) return;
    this.#fd = fd;
    this.#localOffset = localOffset;
    this.#contentOffset = -1;
    this.#source = null;
    this.#serialized = false;
    // The descriptor flag has served its purpose: the sizes and CRC are
    // known now, and the fd-backed serialization path emits plain headers
    // and never writes a descriptor. Left set, a re-serialization would
    // advertise (bit 3) a data descriptor that never follows - a corrupt
    // archive for any reader that honors the flag.
    this.#meta.flags &= ~FLAG_DATA_DESCRIPTOR;
  }

  /**
   * Parses an in-memory archive, walking the central directory (sec. 4.3.12)
   * and each referenced local header (sec. 4.3.7), and yields one read-only
   * `ZipEntry` per member.
   * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer
   * @yields {ZipEntry}
   */
  static *read(buffer) {
    const buf = toBuffer(buffer, 'buffer');
    yield* readArchiveEntries(buf, findArchiveEnd(buf));
  }

  /**
   * Builds a ready-to-serialize entry from in-memory `data`, compressing it
   * with the chosen method (falling back to store when that does not shrink
   * it) and recording the CRC-32 and sizes. When the entry ends up stored
   * (explicitly or via the fallback) it retains `data`'s memory rather than
   * copying it, and the CRC-32 is recorded now - mutating `data` afterwards
   * would corrupt the entry on write.
   * @param {string} filename
   * @param {Buffer | TypedArray | DataView | ArrayBuffer} data
   * @param {{
   *   comment?: string,
   *   mode?: number,
   *   modified?: Date,
   *   method?: 'deflate' | 'store' | 'zstd',
   * }} [options]
   * @returns {Promise<ZipEntry>}
   */
  // Shared head of create()/createSync(): validate, snapshot the CRC-32 and
  // uncompressed size, and pick the compression method (store is forced for
  // directories and empty content). The compression pass itself is the only
  // thing the async/sync builders do differently.
  static #prepareCreate(filename, data, options) {
    const meta = createEntryMeta(filename, options);
    const content = toBuffer(data, 'data');
    const isDirectory = StringPrototypeEndsWith(filename, '/');
    if (isDirectory && content.length) {
      throw new ERR_INVALID_ARG_VALUE('data', data, 'must be empty for a directory entry');
    }
    meta.crc = crc32Native(content, 0);
    meta.uncompressedSize = content.length;
    const method =
      isDirectory || content.length === 0 || options?.method === 'store' ? METHOD_STORE :
        options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE;
    return { meta, content, method };
  }

  // Shared tail of create()/createSync(): keep the compressed bytes only
  // when the pass actually shrank the content (otherwise store the original,
  // which also retains the caller's memory - see create()'s JSDoc), fill in
  // the final sizes, and construct the entry. `compressed` is null when
  // `method` is store.
  static #finishCreate(meta, content, method, compressed) {
    let finalContent = content;
    if (compressed !== null && compressed.length < content.length) {
      finalContent = compressed;
    } else if (compressed !== null) {
      method = METHOD_STORE; // Compression did not help; fall back to storing
    }
    meta.method = method;
    meta.compressedSize = finalContent.length;
    meta.pending = false;
    const entry = new ZipEntry(null, null, finalContent);
    entry.#meta = meta;
    return entry;
  }

  static async create(filename, data, options) {
    const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options);
    const compressed =
      method === METHOD_DEFLATE ? await deflateRawAsync(content) :
        method === METHOD_ZSTD ? await zstdCompressAsync(content) : null;
    return ZipEntry.#finishCreate(meta, content, method, compressed);
  }

  /**
   * The synchronous counterpart of `create()`. Blocks the event loop and
   * further JavaScript execution until done (including the deflate pass);
   * see `contentSync()`. Retains `data`'s memory when storing, same as
   * `create()`.
   * @param {string} filename
   * @param {Buffer | TypedArray | DataView | ArrayBuffer} data
   * @param {{
   *   comment?: string,
   *   mode?: number,
   *   modified?: Date,
   *   method?: 'deflate' | 'store' | 'zstd',
   * }} [options]
   * @returns {ZipEntry}
   */
  static createSync(filename, data, options) {
    const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options);
    const compressed =
      method === METHOD_DEFLATE ? deflateRawSync(content) :
        method === METHOD_ZSTD ? zstdCompressSync(content) : null;
    return ZipEntry.#finishCreate(meta, content, method, compressed);
  }

  /**
   * Builds a write-streaming entry whose content is drained from `source`
   * only at serialization time; its sizes/CRC are unknown until then, so it
   * sets the data-descriptor flag (bit 3, sec. 4.3.9) and stays pending.
   * @param {string} filename
   * @param {AsyncIterable<Buffer>} source
   * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options]
   * @returns {ZipEntry}
   */
  static createStream(filename, source, options) {
    const meta = createEntryMeta(filename, options);
    if (StringPrototypeEndsWith(filename, '/')) {
      throw new ERR_INVALID_ARG_VALUE('filename', filename, 'a directory entry cannot be streamed');
    }
    meta.flags |= FLAG_DATA_DESCRIPTOR;
    meta.method = options?.method === 'store' ? METHOD_STORE :
      options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE;
    meta.pending = true;
    const entry = new ZipEntry(null, null, null);
    entry.#meta = meta;
    entry.#source = source;
    return entry;
  }

  /**
   * Creates a symbolic-link entry: a stored entry whose content is the link
   * target and whose Unix mode type bits are `S_IFLNK`.
   * @param {string} filename
   * @param {string} target The link target path.
   * @param {{ comment?: string, mode?: number, modified?: Date }} [options]
   * @returns {ZipEntry}
   */
  static createSymlink(filename, target, options) {
    validateString(target, 'target');
    const meta = createEntryMeta(filename, {
      __proto__: null,
      comment: options?.comment,
      mode: options?.mode,
      modified: options?.modified,
      symlink: true,
    });
    const content = Buffer.from(target, 'utf8');
    meta.crc = crc32Native(content, 0);
    meta.uncompressedSize = content.length;
    meta.method = METHOD_STORE;
    meta.compressedSize = content.length;
    meta.pending = false;
    const entry = new ZipEntry(null, null, content);
    entry.#meta = meta;
    return entry;
  }
}

// Walk the central directory described by `end` (a findArchiveEnd() result
// for `buf`), yielding one read-only ZipEntry per member. Split from
// `ZipEntry.read()` so `ZipBuffer` - which also needs the archive-end record
// for its comment - can locate the archive end once and share the result
// instead of scanning for it twice. All records are parsed and their member
// ranges cross-checked before the first entry is yielded: members must be
// disjoint and precede the central directory, or one small file could quote
// the same data region from N records - the "quoted overlap" zip-bomb shape
// (CVE-2024-0450 in other implementations). Here the local headers have
// been read, so the check uses each member's exact end (`ZipFile` applies
// the same rule with a lower bound; see `validateMemberBounds()`).
function* readArchiveEntries(buf, end) {
  let pos = end.centralDirectoryOffset;
  const cdEnd = end.centralDirectoryOffset + end.centralDirectorySize;
  const parsed = [];
  for (let index = 0; index < end.totalRecords; index++) {
    const central = new CentralFileHeader(buf, pos);
    if (pos + central.byteLength > cdEnd) {
      throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is out of bounds');
    }
    if (central.diskNumber !== 0) {
      throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported');
    }
    const localOffset = central.localFileHeaderOffset + end.prefix;
    const local = new LocalFileHeader(buf, localOffset);
    assertConsistentLocalHeader(local, central);
    const dataStart = localOffset + local.byteLength;
    const length = central.compressedSize;
    validateArchiveRange(buf, dataStart, length, 'entry data');
    const content = length ? buf.subarray(dataStart, dataStart + length) : EMPTY_BUFFER;
    ArrayPrototypePush(parsed, {
      entry: new ZipEntry(central, local, content),
      start: localOffset,
      dataEnd: dataStart + length,
    });
    pos = central.byteOffset + central.byteLength;
  }
  if (pos !== cdEnd) {
    throw new ERR_ZIP_INVALID_ARCHIVE(
      'central directory record count is inconsistent with its size');
  }
  // Sort a separate range list; entries themselves are yielded in central
  // directory order.
  const ranges = ArrayPrototypeSort(ArrayPrototypeSlice(parsed), (a, b) => a.start - b.start);
  for (let i = 0; i < ranges.length; i++) {
    const bound = i + 1 < ranges.length ? ranges[i + 1].start : end.centralDirectoryOffset;
    if (ranges[i].dataEnd > bound) {
      throw new ERR_ZIP_INVALID_ARCHIVE(
        `entry ${JSONStringify(ranges[i].entry.name)} overlaps the next ` +
        'entry or the central directory (possible zip bomb)');
    }
  }
  for (let i = 0; i < parsed.length; i++) yield parsed[i].entry;
}

module.exports = {
  createEntryMeta,
  readArchiveEntries,
  ZipEntry,
};