// modules/install.js — fetching, verifying and placing a module bundle.
//
// Phase 4, slice 1 of MODULE_SYSTEM.md §2.7.2. The rules under test are all
// refusals, and each one is a step an attacker would otherwise walk through:
// a non-https URL, a host that is not allowed, a REDIRECT to a host that is not
// allowed, a body larger than declared, a hash that does not match, an archive
// that does not agree with the manifest about what it is.
//
// `fetchImpl` is injected rather than a TLS server being stood up, for the same
// reason `replayFragments` takes a `query`: what is being tested is what this
// file decides about a response, and a real server would mostly test Node's
// certificate handling. The responses below are real `Response` objects with
// real bodies, so the streaming, the hashing and the byte cap are exercised for
// real — only the transport is stubbed.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

const crypto = require('crypto')
const fs = require('fs')
const os = require('os')
const path = require('path')
const zlib = require('zlib')

const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')

const db = require('../src/utils/db')

after(() => db.close())

const HOSTS = ['releases.example.com']
const MANIFEST_URL = 'https://releases.example.com/mod/uo-1.0.0.json'

let tmpRoot
let install

/**
 * A fresh install.js bound to a fresh modules directory.
 *
 * install.js reads `loader.dir()`, which is resolved once at require time from
 * MODULES_DIR — so both have to be re-required per test, exactly as
 * moduleLifecycle.test.js does.
 */
function freshInstall(dir) {
  process.env.MODULES_DIR = dir
  delete require.cache[require.resolve('../src/modules/loader')]
  delete require.cache[require.resolve('../src/modules/install')]
  // eslint-disable-next-line global-require
  return require('../src/modules/install')
}

// ── Building a bundle to serve ─────────────────────────────────────────────

const BLOCK = 512

function octal(value, width) {
  return Number(value).toString(8).padStart(width - 1, '0') + '\0'
}

function member({ name, type = '0', body = '' }) {
  const header = Buffer.alloc(BLOCK, 0)
  const data = Buffer.from(body, 'utf8')
  header.write(name, 0, 100, 'utf8')
  header.write(octal(0o644, 8), 100, 8, 'ascii')
  header.write(octal(0, 8), 108, 8, 'ascii')
  header.write(octal(0, 8), 116, 8, 'ascii')
  header.write(octal(data.length, 12), 124, 12, 'ascii')
  header.write(octal(0, 12), 136, 12, 'ascii')
  header.write('        ', 148, 8, 'ascii')
  header.write(type, 156, 1, 'ascii')
  header.write('ustar\0', 257, 6, 'ascii')
  header.write('00', 263, 2, 'ascii')
  let sum = 0
  for (const byte of header) sum += byte
  header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii')
  const padding = Buffer.alloc((BLOCK - (data.length % BLOCK)) % BLOCK, 0)
  return Buffer.concat([header, data, padding])
}

/** A bundle tarball whose root directory is named the way a release names it. */
function bundle({ id = 'uo', version = '1.0.0', extra = [] } = {}) {
  const root = `module-${id}-${version}`
  return zlib.gzipSync(Buffer.concat([
    member({ name: `${root}/`, type: '5' }),
    member({
      name: `${root}/module.json`,
      body: JSON.stringify({ id, name: 'Ultima Online', version, coreApi: '^1.0.0', server: 'server/index.js' }),
    }),
    member({ name: `${root}/server/`, type: '5' }),
    member({ name: `${root}/server/index.js`, body: 'module.exports = () => {}\n' }),
    ...extra.map(member),
    Buffer.alloc(BLOCK * 2, 0),
  ]))
}

const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex')

/**
 * A fake transport serving one manifest and one artifact.
 *
 * `routes` maps an absolute URL to either a Buffer/string body or
 * `{ status, location }` for a redirect, so a test can describe exactly what the
 * remote host does without describing how it does it.
 */
function fakeFetch(routes) {
  const seen = []
  const impl = async (url) => {
    const href = String(url)
    seen.push(href)
    const route = routes[href]
    if (route === undefined) return new Response('not found', { status: 404 })
    if (route && route.status) {
      return new Response(null, {
        status: route.status,
        headers: route.location ? { location: route.location } : {},
      })
    }
    return new Response(route, { status: 200 })
  }
  impl.seen = seen
  return impl
}

/** The manifest a release publishes, with whatever a test wants to change. */
function manifestFor(tarball, overrides = {}) {
  return JSON.stringify({
    schema: 1,
    id: 'uo',
    name: 'Ultima Online',
    version: '1.0.0',
    coreApi: '^1.0.0',
    artifact: 'uo-1.0.0.tar.gz',
    url: 'https://releases.example.com/mod/uo-1.0.0.tar.gz',
    sha256: sha256(tarball),
    size: tarball.length,
    ...overrides,
  })
}

/** The happy-path pair: a valid manifest and the artifact it describes. */
function goodRoutes(options = {}) {
  const tarball = bundle(options)
  return {
    tarball,
    routes: {
      [MANIFEST_URL]: manifestFor(tarball, options.manifest || {}),
      'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
    },
  }
}

beforeEach(() => {
  tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-install-'))
  install = freshInstall(tmpRoot)
})

// ── The allowlist ──────────────────────────────────────────────────────────

test('parseHosts accepts comma and whitespace separation, and lower-cases', () => {
  assert.deepEqual(install.parseHosts('a.com, B.com\nc.com'), ['a.com', 'b.com', 'c.com'])
  assert.deepEqual(install.parseHosts(''), [])
  assert.deepEqual(install.parseHosts(null), [])
})

test('an empty allowlist forbids everything rather than allowing everything', () => {
  // The direction matters: a setting someone blanks by accident must stop
  // installs, not open the door to every host on the internet.
  assert.throws(() => install.checkUrl('https://releases.example.com/x.json', []), /no module source hosts are allowed/)
})

test('only https may be installed from', () => {
  // The sha256 is no help against a plaintext fetch: whoever can rewrite the
  // artifact in flight can rewrite the manifest that declares its hash.
  assert.throws(() => install.checkUrl('http://releases.example.com/x.json', HOSTS), /only https/)
  assert.throws(() => install.checkUrl('file:///etc/passwd', HOSTS), /only https/)
})

test('a host that is not on the allowlist is refused, and the message says which are', () => {
  assert.throws(
    () => install.checkUrl('https://evil.example.net/x.json', HOSTS),
    /"evil.example.net" is not an allowed module source host \(allowed: releases.example.com\)/,
  )
})

test('the allowlist is matched on the host, not on a substring of the URL', () => {
  // `https://evil.com/?x=releases.example.com` must not pass, and neither must a
  // subdomain nobody listed.
  assert.throws(() => install.checkUrl('https://evil.com/?releases.example.com', HOSTS), /not an allowed/)
  assert.throws(() => install.checkUrl('https://sub.releases.example.com/x', HOSTS), /not an allowed/)
  assert.throws(() => install.checkUrl('https://releases.example.com.evil.net/x', HOSTS), /not an allowed/)
})

// ── Redirects ──────────────────────────────────────────────────────────────

test('a redirect is followed, and re-checked against the allowlist', async () => {
  const { tarball } = goodRoutes()
  const impl = fakeFetch({
    [MANIFEST_URL]: { status: 302, location: 'https://releases.example.com/cdn/uo-1.0.0.json' },
    'https://releases.example.com/cdn/uo-1.0.0.json': manifestFor(tarball),
  })

  const manifest = await install.fetchManifest(MANIFEST_URL, HOSTS, impl)

  assert.equal(manifest.id, 'uo')
  assert.deepEqual(impl.seen, [MANIFEST_URL, 'https://releases.example.com/cdn/uo-1.0.0.json'])
})

test('a redirect to a host that is not allowed is refused', async () => {
  // The hole the allowlist exists to close. `fetch`'s own redirect following
  // would check the first hop and then go wherever it was pointed — which is
  // why get() follows them by hand.
  const impl = fakeFetch({
    [MANIFEST_URL]: { status: 302, location: 'http://169.254.169.254/latest/meta-data/' },
  })

  await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /only https/)
})

test('a redirect loop is bounded rather than followed forever', async () => {
  const impl = fakeFetch({
    [MANIFEST_URL]: { status: 302, location: MANIFEST_URL },
  })

  await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /too many redirects/)
})

// ── The install manifest ───────────────────────────────────────────────────

test('a manifest that is not JSON is refused with a readable reason', async () => {
  const impl = fakeFetch({ [MANIFEST_URL]: 'this is not json' })
  await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /not valid JSON/)
})

test('a manifest with an invalid module id is refused', async () => {
  // The loader would refuse to scan a directory called `../etc`, but the point
  // is that one is never created: the id becomes a path segment.
  for (const id of ['../etc', 'UO', '', 'x', 'a'.repeat(40)]) {
    const impl = fakeFetch({ [MANIFEST_URL]: JSON.stringify({ id, name: 'n', version: '1', sha256: 'a'.repeat(64) }) })
    // eslint-disable-next-line no-await-in-loop
    await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /invalid module id/, `id ${JSON.stringify(id)}`)
  }
})

test('a manifest with no usable sha256 is refused', async () => {
  const impl = fakeFetch({
    [MANIFEST_URL]: JSON.stringify({ id: 'uo', name: 'n', version: '1.0.0', sha256: 'not-a-hash' }),
  })
  await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /no valid sha256/)
})

test('the artifact URL is resolved relative to the manifest when it carries no absolute one', async () => {
  const { tarball } = goodRoutes()
  const impl = fakeFetch({ [MANIFEST_URL]: manifestFor(tarball, { url: undefined }) })

  const manifest = await install.fetchManifest(MANIFEST_URL, HOSTS, impl)

  // Release assets sit beside their manifest, so a manifest that travelled
  // without its absolute URL still resolves to the right place.
  assert.equal(manifest.artifactUrl, 'https://releases.example.com/mod/uo-1.0.0.tar.gz')
})

// ── The install ────────────────────────────────────────────────────────────

test('a good bundle installs into modules/<id>, stripped of its wrapper', async () => {
  const { routes, tarball } = goodRoutes()

  const result = await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) })

  assert.equal(result.id, 'uo')
  assert.equal(result.version, '1.0.0')
  assert.equal(result.sha256, sha256(tarball))
  assert.equal(result.source, MANIFEST_URL)
  assert.equal(result.replaced, false)

  // The directory is named for the module id, not for the archive's root — the
  // loader scans for the former and the publisher chose the latter.
  const dir = path.join(tmpRoot, 'uo')
  assert.ok(fs.existsSync(path.join(dir, 'module.json')))
  assert.ok(fs.existsSync(path.join(dir, 'server', 'index.js')))
  assert.ok(!fs.existsSync(path.join(tmpRoot, 'module-uo-1.0.0')))
})

test('a hash that does not match refuses the install and writes nothing', async () => {
  const { routes } = goodRoutes()
  // The bytes are fine; the manifest lies about them. Which is the same thing an
  // artifact swapped after publication looks like.
  routes[MANIFEST_URL] = manifestFor(Buffer.from('different'), {})

  await assert.rejects(
    () => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
    /does not match the sha256/,
  )
  assert.deepEqual(fs.readdirSync(tmpRoot), [], 'nothing may be left on the volume')
})

test('a bundle whose module.json disagrees with the manifest is refused', async () => {
  // A manifest promising `uo` and delivering something else would otherwise be
  // installed into `modules/uo/` under a name it is not.
  const tarball = bundle({ id: 'rust', version: '1.0.0' })
  const routes = {
    [MANIFEST_URL]: manifestFor(tarball),
    'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
  }

  await assert.rejects(
    () => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
    /declares module id "rust" but the install manifest promised "uo"/,
  )
  assert.deepEqual(fs.readdirSync(tmpRoot), [])
})

test('a bundle whose version disagrees with the manifest is refused', async () => {
  const tarball = bundle({ id: 'uo', version: '9.9.9' })
  const routes = {
    [MANIFEST_URL]: manifestFor(tarball),
    'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
  }

  await assert.rejects(
    () => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
    /declares version "9.9.9"/,
  )
})

test('an artifact whose length differs from the declared size is refused', async () => {
  const tarball = bundle()
  const routes = {
    [MANIFEST_URL]: manifestFor(tarball, { size: tarball.length + 10 }),
    'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
  }

  await assert.rejects(
    () => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
    /but the manifest declares/,
  )
})

test('a hostile archive is refused, and nothing of it reaches the volume', async () => {
  // The case node-tar alone does NOT cover: it throws on the escaping member,
  // but only once it reaches it — the members before it are already on disk.
  // archive.inspect() decides before extract() runs, and the unpack happens in a
  // scratch directory that is removed either way.
  const tarball = bundle({
    extra: [
      { name: 'module-uo-1.0.0/../../ESCAPED.txt', body: 'escaped' },
    ],
  })
  const routes = {
    [MANIFEST_URL]: manifestFor(tarball),
    'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
  }

  await assert.rejects(
    () => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
    /escapes upward/,
  )
  assert.deepEqual(fs.readdirSync(tmpRoot), [], 'no scratch directory, no partial module, no escape')
})

test('an upgrade replaces the directory and reports that it did', async () => {
  const first = goodRoutes()
  await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
  fs.writeFileSync(path.join(tmpRoot, 'uo', 'STALE.txt'), 'from the old version')

  const second = goodRoutes({ version: '2.0.0', manifest: { version: '2.0.0' } })
  second.routes[MANIFEST_URL] = manifestFor(second.tarball, { version: '2.0.0' })
  const result = await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(second.routes) })

  assert.equal(result.replaced, true)
  assert.equal(result.version, '2.0.0')
  // A replace, not a merge: a file the previous version left behind must not
  // survive into the new one, or an upgrade quietly keeps dead code loadable.
  assert.ok(!fs.existsSync(path.join(tmpRoot, 'uo', 'STALE.txt')))
  assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'], 'the aside copy is cleaned up')
})

test('a failed upgrade leaves the previous version in place', async () => {
  const first = goodRoutes()
  await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })

  const badTarball = bundle({ id: 'uo', version: '2.0.0', extra: [{ name: 'evil/', type: '5' }] })
  await assert.rejects(() => install.install({
    url: MANIFEST_URL,
    hosts: HOSTS,
    fetchImpl: fakeFetch({
      [MANIFEST_URL]: manifestFor(badTarball, { version: '2.0.0' }),
      'https://releases.example.com/mod/uo-1.0.0.tar.gz': badTarball,
    }),
  }))

  // The installed module is untouched: the swap is the last step, so a bundle
  // rejected before it never got near the live directory.
  assert.ok(fs.existsSync(path.join(tmpRoot, 'uo', 'module.json')))
  assert.equal(JSON.parse(fs.readFileSync(path.join(tmpRoot, 'uo', 'module.json'), 'utf8')).version, '1.0.0')
  assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
})

// ── The volume ─────────────────────────────────────────────────────────────

test('moduleDir refuses an id that is not one', () => {
  for (const id of ['../escape', 'a/b', '', 'UO', '.']) {
    assert.throws(() => install.moduleDir(id), /invalid module id/, `id ${JSON.stringify(id)}`)
  }
  assert.equal(install.moduleDir('uo'), path.join(tmpRoot, 'uo'))
})

test('isInstalled and removeDir report what they did', async () => {
  const { routes } = goodRoutes()
  assert.equal(install.isInstalled('uo'), false)

  await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) })
  assert.equal(install.isInstalled('uo'), true)

  assert.equal(await install.removeDir('uo'), true)
  assert.equal(install.isInstalled('uo'), false)
  // Removing what is not there is not an error — an uninstall of a module whose
  // directory was already deleted by hand should still tidy up the row.
  assert.equal(await install.removeDir('uo'), false)
})

test('purgeFile resolves the manifest declaration, and refuses one that escapes', async () => {
  const dir = path.join(tmpRoot, 'uo')
  fs.mkdirSync(path.join(dir, 'server', 'db'), { recursive: true })
  fs.writeFileSync(path.join(dir, 'server', 'db', 'purge.sql'), 'DROP TABLE IF EXISTS x;')

  const write = (purge) => fs.writeFileSync(
    path.join(dir, 'module.json'),
    JSON.stringify({ id: 'uo', name: 'UO', version: '1.0.0', purge }),
  )

  write('server/db/purge.sql')
  assert.equal(install.purgeFile('uo'), path.join(dir, 'server', 'db', 'purge.sql'))

  // The same containment rule the loader applies to client.entry: a manifest may
  // not point core at a file outside the module it belongs to.
  write('../../../../etc/passwd')
  assert.equal(install.purgeFile('uo'), null)

  // Declared but absent, and not declared at all, are both "nothing to run".
  write('server/db/missing.sql')
  assert.equal(install.purgeFile('uo'), null)
  write(undefined)
  assert.equal(install.purgeFile('uo'), null)
})

test('purgeFile is null for a module that is not on the volume', () => {
  assert.equal(install.purgeFile('nothere'), null)
})
