// ── The hardened bundle extractor ──────────────────────────────────────────
//
// Phase 4, slice 1 of docs/website/MODULE_SYSTEM.md §2.7.2. This is the part of
// the install path that handles input an attacker chose, and it is separated
// from install.js so that it can be tested against crafted archives without a
// network, a database or a filesystem layout.
//
// **The download is not the dangerous part.** An allowlisted host, a declared
// sha256 and a size cap between them settle where the bytes came from and that
// they are the bytes that were published. Unpacking is different: the ARCHIVE
// chooses the filenames, and core writes into a directory bind-mounted from the
// host (MODULE_SYSTEM.md §2.5), so an escape is not confined to the container.
//
// The rule this file follows is **reject, never sanitise.** node-tar will
// happily strip a leading `/` and drop a `..` for you, which turns a hostile
// archive into a slightly different archive that then gets installed. An archive
// that needs correcting is an archive that should not be trusted, so anything on
// the list below fails the whole install and nothing is written.
//
//   - an absolute path, POSIX (`/etc/…`) or Windows (`C:\…`, `\\server\…`)
//   - any `..` segment, anywhere
//   - anything that is not a regular file or a directory — so no symlinks, no
//     hardlinks, no devices, no FIFOs. A module bundle has no legitimate use for
//     any of them, and every one is a documented escape primitive
//   - more than one top-level entry
//   - more entries, or more unpacked bytes, than the caps below
//
// That third rule deserves its own note, because it is why the `tar` dependency
// is pinned forward rather than merely present: **the majority of node-tar's
// published advisories are hardlink or symlink path traversal**, several of them
// through interpretation differences in PAX and GNU long-name headers rather
// than through anything the calling code did wrong. Refusing those entry types
// outright means this extractor is not relying on the library to get their
// containment right. The remaining advisories are parser denial-of-service, and
// those are what the caps and the two-pass shape address.
//
// Two passes, deliberately: `inspect()` reads the archive and decides, and only
// an archive that survived that is handed to `extract()`. A `filter` callback
// during extraction cannot reject — by the time it is asked about entry 400, the
// first 399 are already on disk.

const fs = require('fs')
const fsp = require('fs/promises')
const path = require('path')
const tar = require('tar')

// Caps. Generous against a real bundle (module-uo's is a few megabytes, a few
// hundred files) and small enough that a decompression bomb is refused rather
// than paged in. Both are checked DURING the inspect pass, not after it, so a
// hostile archive stops being read at the limit instead of at its end.
const MAX_BYTES = 128 * 1024 * 1024
const MAX_ENTRIES = 20000

// tar entry types worth naming. node-tar reports these as strings; anything not
// in this set is refused, including the ones no one has thought of, which is the
// point of an allowlist here rather than a list of the types known to be bad.
const ALLOWED_TYPES = new Set(['File', 'Directory'])

class ArchiveError extends Error {
  constructor(message) {
    super(message)
    this.name = 'ArchiveError'
  }
}

/**
 * Is this entry path safe to unpack anywhere?
 *
 * Returns a reason string, or null when the path is fine. Written as a reason
 * rather than a boolean because the operator pasting a URL needs to be told what
 * was wrong with what they were served, and "the archive is invalid" is not that.
 */
function pathProblem(entryPath) {
  const p = String(entryPath)

  // NUL is not a path character. node-tar has had uncaught-exception advisories
  // for NUL bytes inside PAX records, so this is checked here rather than left
  // to the parser.
  if (p.includes('\0')) return 'an entry path contains a NUL byte'

  // A backslash is a path separator on the platform this may be unpacked on, so
  // an entry that contains one is not the single path component it looks like.
  if (p.includes('\\')) return `an entry path contains a backslash: "${p}"`

  if (p.startsWith('/')) return `an entry path is absolute: "${p}"`
  // Drive-relative and UNC. Both are the subject of their own node-tar
  // advisories, and neither is refused by a leading-slash check.
  if (/^[a-zA-Z]:/.test(p)) return `an entry path is drive-absolute: "${p}"`

  const segments = p.split('/')
  if (segments.includes('..')) return `an entry path escapes upward: "${p}"`

  return null
}

/**
 * Read an archive without unpacking it, and decide whether it may be unpacked.
 *
 * @param {string} file absolute path to the .tar.gz
 * @returns {Promise<{root: string, entries: number, bytes: number}>} the single
 *   top-level directory name, and what is inside it.
 * @throws {ArchiveError} on anything in this file's header list.
 */
async function inspect(file) {
  const roots = new Set()
  let entries = 0
  let bytes = 0
  // The first problem found, kept rather than thrown from inside the callback:
  // throwing out of `onentry` escapes through the parser's stream machinery and
  // arrives as an unhelpful wrapped error, when it arrives at all.
  let problem = null

  const note = (message) => {
    if (!problem) problem = message
  }

  await tar.t({
    file,
    onentry(entry) {
      if (problem) return

      entries += 1
      if (entries > MAX_ENTRIES) {
        note(`the archive has more than ${MAX_ENTRIES} entries`)
        return
      }

      if (!ALLOWED_TYPES.has(entry.type)) {
        // The message names the type because "symbolic link" is a much more
        // useful thing for an operator to read than "invalid entry".
        note(`the archive contains a ${entry.type} ("${entry.path}") — a module bundle may only contain files and directories`)
        return
      }

      const bad = pathProblem(entry.path)
      if (bad) {
        note(bad)
        return
      }

      // A negative or absurd size is a parser-confusion primitive in its own
      // right; clamping at zero keeps the running total honest.
      bytes += Math.max(0, Number(entry.size) || 0)
      if (bytes > MAX_BYTES) {
        note(`the archive unpacks to more than ${Math.round(MAX_BYTES / 1024 / 1024)} MB`)
        return
      }

      const [root] = String(entry.path).split('/')
      if (root) roots.add(root)
    },
  })

  if (problem) throw new ArchiveError(problem)
  if (entries === 0) throw new ArchiveError('the archive is empty')
  if (roots.size !== 1) {
    // A bundle is one directory. More than one top-level entry means `strip: 1`
    // below would silently merge or discard things, and a bundle that needs
    // interpreting is not a bundle.
    throw new ArchiveError(
      `the archive must contain exactly one top-level directory, found ${roots.size}` +
        (roots.size > 1 ? ` (${[...roots].slice(0, 4).join(', ')})` : ''),
    )
  }

  return { root: [...roots][0], entries, bytes }
}

/**
 * Unpack an inspected archive into `dest`, stripping its single top level.
 *
 * The top-level directory is stripped rather than kept, because its name belongs
 * to whoever published the bundle — module-uo's release workflow packs
 * `module-uo-<version>/`, not `uo/` — while the directory it lands in is core's
 * decision and has to be the module id the loader scans for. What is inside the
 * bundle is the module; what the wrapper is called is packaging.
 *
 * `dest` must not exist. Callers unpack to a temporary directory and move it
 * into place, so a failure halfway through leaves nothing for the next boot's
 * scan to find.
 */
async function extract(file, dest) {
  if (fs.existsSync(dest)) throw new ArchiveError(`extraction target already exists: ${dest}`)
  await fsp.mkdir(dest, { recursive: true })

  await tar.x({
    file,
    cwd: dest,
    strip: 1,
    // Belt and braces on top of inspect(): the library's own refusal to write
    // outside cwd, and its refusal to overwrite through a link. Neither is
    // load-bearing here — inspect() has already rejected every entry that could
    // exercise them — but a second lock on a door that is already locked costs
    // nothing, and this is the door.
    preservePaths: false,
    // Reject rather than warn on anything the parser itself objects to.
    strict: true,
  })

  return dest
}

/**
 * Inspect, then extract. The only entry point install.js uses.
 */
async function unpack(file, dest) {
  const stats = await inspect(file)
  await extract(file, dest)
  return stats
}

module.exports = {
  ArchiveError,
  inspect,
  extract,
  unpack,
  pathProblem,
  MAX_BYTES,
  MAX_ENTRIES,
  ALLOWED_TYPES,
}
