OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / release-download.test.js

338 lines · javascript ref:
// Regression: the release-artifact download route must actually serve bytes.
//
// It did not, for as long as the route has existed. `lib/web/router.js` did
// `const fs = await import('fs')` inline inside the handler. Bare cannot resolve
// the bare specifier 'fs' at all (only the '#node:*' import map in package.json
// can), so every single request threw
//
//     ModuleError: MODULE_NOT_FOUND: Cannot find module 'fs'
//         imported from .../lib/web/router.js   (at router.js:547)
//
// and the route 500'd. Verified live on the production forge 2026-08-07 against
// pgv3 0.1.200 — both backends, every repo. The bug was invisible because no test
// ever fetched a release artifact over HTTP; that gap is what this file closes.
//
// It also silently shadowed the module-scope `fs` from line 37, so deleting only
// the `path` import would have left the route broken in a different way.
//
// PASS = a published artifact downloads 200 with byte-identical content, a
// missing one 404s, and traversal is refused.

import test from 'brittle'
import { mkdtempSync, mkdirSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import http from '#node:http'
import net from '#node:net'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'

import { startPearGit } from '../index.js'

function tmpStorage () { return mkdtempSync(join(tmpdir(), 'pgv3-rel-')) }
function tmpSock () { return join(mkdtempSync(join(tmpdir(), 'pgv3-rel-sock-')), 'svc.sock') }

function fetchBytes (url, opts = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(url)
    const req = http.request({
      method: opts.method || 'GET',
      hostname: u.hostname,
      port: u.port,
      path: u.pathname + u.search,
      headers: opts.headers || {}
    }, (res) => {
      const parts = []
      res.on('data', c => parts.push(c))
      res.on('end', () => resolve({ status: res.statusCode, body: b4a.concat(parts), headers: res.headers }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.end()
  })
}

// HEAD has to be asserted at the wire level. bare-http1's CLIENT has no HEAD
// awareness on the response side — lib/client-request.js special-cases HEAD only
// for the REQUEST body — so it blocks forever waiting for a body that a correct
// HEAD response never sends. curl and Android's DownloadManager handle it fine;
// our own client cannot, so `http.request({method:'HEAD'})` would hang the test
// rather than measure the server. Speak HTTP directly instead.
//
// Returning the raw header keys in order also gives an exact duplicate check,
// which matters because the bug being guarded is a header emitted TWICE.
function rawHead (port, reqPath) {
  return new Promise((resolve, reject) => {
    // createConnection, not connect: bare-net exposes only the former.
    const socket = net.createConnection(port, '127.0.0.1')
    let buf = ''
    let settled = false
    socket.on('error', reject)
    socket.on('connect', () => {
      socket.write(b4a.from('HEAD ' + reqPath + ' HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'))
    })
    socket.on('data', (chunk) => {
      buf += b4a.toString(b4a.from(chunk))
      const i = buf.indexOf('\r\n\r\n')
      if (i === -1 || settled) return
      settled = true
      const lines = buf.slice(0, i).split('\r\n')
      const status = Number(lines[0].split(' ')[1])
      const headers = {}
      const rawKeys = []
      for (const line of lines.slice(1)) {
        const j = line.indexOf(':')
        if (j === -1) continue
        const k = line.slice(0, j).trim().toLowerCase()
        const v = line.slice(j + 1).trim()
        rawKeys.push(k)
        headers[k] = k in headers ? headers[k] + ', ' + v : v
      }
      socket.destroy()
      resolve({ status, headers, rawKeys, body: buf.slice(i + 4) })
    })
    socket.on('close', () => {
      if (!settled) reject(new Error('connection closed before headers'))
    })
  })
}

test('a published release artifact downloads over HTTP', async (t) => {
  const storage = tmpStorage()
  mkdirSync(storage, { recursive: true })

  const node = await startPearGit({
    storage,
    uds: tmpSock(),
    http: { port: 0 },
    swarm: false,
    autoImportSSH: false
  })
  t.teardown(async () => { try { await node.close() } catch {} })

  const svc = node.svc
  t.ok(svc._releases, 'release manager present without swarm (deploy is optional)')

  // Big enough to be a streamed body rather than a single write — the download
  // path pipes through pipeFile, which only became reachable once the
  // MODULE_NOT_FOUND above was fixed.
  const zip = crypto.randomBytes(1024 * 1024)
  const notes = 'release notes\n'

  await svc._releases.publish({
    repo: 'demo-repo',
    version: 'v1.2.3',
    artifacts: [
      { path: 'demo-1.2.3.zip', data: zip },
      { path: 'NOTES.txt', data: b4a.from(notes) }
    ],
    meta: { fromCommit: 'deadbeef', builtBy: 'test' }
  })

  const port = node.svc.http.server.address().port
  const base = 'http://127.0.0.1:' + port + '/releases/demo-repo/v1.2.3/'

  const got = await fetchBytes(base + 'demo-1.2.3.zip')
  t.is(got.status, 200, 'artifact served 200 — regression guard for MODULE_NOT_FOUND')
  t.ok(got.body.equals(zip), 'artifact bytes byte-identical through the pipe')

  const txt = await fetchBytes(base + 'NOTES.txt')
  t.is(txt.status, 200, 'second artifact served')
  t.ok(txt.body.equals(b4a.from(notes)), 'second artifact bytes match')

  const miss = await fetchBytes(base + 'nope.zip')
  t.is(miss.status, 404, 'unknown artifact → 404, not 500')

  const badVer = await fetchBytes('http://127.0.0.1:' + port + '/releases/demo-repo/v9.9.9/demo-1.2.3.zip')
  t.is(badVer.status, 404, 'unknown version → 404, not 500')

  // Traversal must be refused. The regex already constrains repo/version, so the
  // reachable attack surface is the artifact component.
  const esc = await fetchBytes('http://127.0.0.1:' + port + '/releases/demo-repo/v1.2.3/..%2F..%2F..%2Fmanifest.json')
  t.ok(esc.status === 403 || esc.status === 404, 'traversal escape refused, not served')
})

// The Releases tab renders TWO sections that must stay visually distinct: trees
// published by `pgh publish` (install-only, no file) and CI build artifacts
// (downloadable, node-local). They are different namespaces and must never be
// presented as joined.
//
// Also covers graceful degradation: `tree-registry-list` now asks the pgh-sidecar
// instead of opening its RocksDB store (which always failed with "File descriptor
// could not be locked" because the sidecar holds that lock). In this test there is
// no sidecar at all, so the section must render an explained empty state rather
// than 500 the whole page.
test('releases tab renders both sections, and survives an absent sidecar', async (t) => {
  const storage = tmpStorage()
  mkdirSync(storage, { recursive: true })

  const node = await startPearGit({
    storage,
    uds: tmpSock(),
    http: { port: 0 },
    swarm: false,
    autoImportSSH: false
  })
  t.teardown(async () => { try { await node.close() } catch {} })

  await node.svc._releases.publish({
    repo: 'demo-repo',
    version: 'v1.2.3',
    artifacts: [{ path: 'demo-1.2.3.zip', data: b4a.from('zip') }],
    meta: { fromCommit: 'deadbeef' }
  })

  const port = node.svc.http.server.address().port
  const page = await fetchBytes('http://127.0.0.1:' + port + '/repos/demo-repo/releases')
  t.is(page.status, 200, 'releases tab renders (did not 500 on the registry call)')

  const html = b4a.toString(page.body)
  t.ok(html.includes('Published to pgh'), 'tree-backed section present')
  t.ok(html.includes('Build artifacts'), 'file-backed section present')
  t.ok(html.includes('demo-1.2.3.zip'), 'the published artifact is listed')
  t.ok(html.includes('node-local'), 'file rows carry the replication caveat')

  // No sidecar in this harness, so the tree section must degrade, not explode.
  t.ok(/Registry unavailable|Nothing published to pgh/.test(html),
    'tree section degrades with an explanation instead of failing the page')
})

// Content-Type for release artifacts. An APK served as octet-stream still
// downloads, but Android Chrome merely saves it; with the right type it hands
// the file to the package installer. `.apk` was missing from mimeFor, which
// mattered once real binaries started being served (a 45 MB signed APK).
//
// Asserted through the real download route rather than by unit-testing mimeFor,
// so the header a client actually receives is what is checked.
test('release artifacts carry a useful Content-Type', async (t) => {
  const storage = tmpStorage()
  mkdirSync(storage, { recursive: true })

  const node = await startPearGit({
    storage, uds: tmpSock(), http: { port: 0 }, swarm: false, autoImportSSH: false
  })
  t.teardown(async () => { try { await node.close() } catch {} })

  await node.svc._releases.publish({
    repo: 'demo-repo',
    version: 'v1',
    artifacts: [
      { path: 'app.apk', data: b4a.from('apk') },
      { path: 'app.zip', data: b4a.from('zip') },
      { path: 'app.tar.gz', data: b4a.from('tgz') },
      { path: 'app.bin', data: b4a.from('bin') }
    ],
    meta: {}
  })

  const port = node.svc.http.server.address().port
  const base = 'http://127.0.0.1:' + port + '/releases/demo-repo/v1/'

  const apk = await fetchBytes(base + 'app.apk')
  t.is(apk.status, 200, 'apk served')
  t.is(apk.headers['content-type'], 'application/vnd.android.package-archive',
    'apk gets the Android package type, not octet-stream')

  // An APK is a zip; make sure the .zip case did not swallow it, and that .zip
  // itself still resolves correctly.
  const zip = await fetchBytes(base + 'app.zip')
  t.is(zip.headers['content-type'], 'application/zip', 'zip unaffected')

  const tgz = await fetchBytes(base + 'app.tar.gz')
  t.is(tgz.headers['content-type'], 'application/gzip', 'tar.gz unaffected')

  const bin = await fetchBytes(base + 'app.bin')
  t.is(bin.headers['content-type'], 'application/octet-stream',
    'unknown extensions still fall through to a binary download')
})

// Mobile downloads of a large artifact failed against the production forge
// (2026-08-25, a 148 MB APK over https://7rnx.net/git). Three defects, all here:
//
//   1. Every route was gated on `req.method === 'GET'`, so HEAD matched nothing
//      and fell through to the 404 handler. Android's DownloadManager preflights
//      with HEAD, so the download died before a byte moved. Measured from the
//      handset: HEAD hung for 25 s and returned 0 bytes.
//   2. Content-Length was omitted deliberately, so the body went out
//      Transfer-Encoding: chunked — no size, no progress, no completeness check.
//   3. No Range support: a Range request returned 200 and the whole stream, so
//      an interrupted mobile download restarted from zero every time.
//
// The duplicate-Content-Length hazard the old comment warned about is real, but
// it comes from bare-http1's own `_write()`, which sets Content-Length itself
// when a body fits in a single finishing write. writeHead preserves header case
// and setHeader lowercases, so writeHead('Content-Length') plus that internal
// call emits the header TWICE — for SMALL artifacts only, which is exactly what
// the favela-android OTA fetcher reads (tree.json). Hence setHeader throughout.
test('release artifacts support HEAD, Content-Length and Range', async (t) => {
  const storage = tmpStorage()
  mkdirSync(storage, { recursive: true })

  const node = await startPearGit({
    storage, uds: tmpSock(), http: { port: 0 }, swarm: false, autoImportSSH: false
  })
  t.teardown(async () => { try { await node.close() } catch {} })

  // Big enough to span multiple stream chunks, plus a tiny one that lands in a
  // single finishing write — the two sides of the duplicate-header trap.
  const big = crypto.randomBytes(512 * 1024)
  const small = b4a.from('{"entries":[]}')

  await node.svc._releases.publish({
    repo: 'demo-repo',
    version: 'v2',
    artifacts: [
      { path: 'big.apk', data: big },
      { path: 'tree.json', data: small }
    ],
    meta: {}
  })

  const port = node.svc.http.server.address().port
  const base = 'http://127.0.0.1:' + port + '/releases/demo-repo/v2/'

  // (2) GET advertises an exact length and range support.
  const got = await fetchBytes(base + 'big.apk')
  t.is(got.status, 200, 'GET still 200')
  t.is(got.headers['content-length'], String(big.length), 'GET carries an exact Content-Length')
  t.is(got.headers['accept-ranges'], 'bytes', 'GET advertises range support')
  t.ok(got.body.equals(big), 'GET body still byte-identical')

  // Duplicate headers arrive joined by ', ' — a comma means we emitted it twice.
  t.absent(String(got.headers['content-length']).includes(','),
    'exactly one Content-Length on a multi-chunk body')
  const smallGot = await fetchBytes(base + 'tree.json')
  t.is(smallGot.headers['content-length'], String(small.length), 'small artifact length correct')
  t.absent(String(smallGot.headers['content-length']).includes(','),
    'exactly one Content-Length on a single-write body — bare-http1 sets its own')
  t.ok(smallGot.body.equals(small), 'small artifact bytes intact (the OTA fetcher path)')

  // (1) HEAD answers with the size and no body, instead of 404.
  const head = await rawHead(port, '/releases/demo-repo/v2/big.apk')
  t.is(head.status, 200, 'HEAD is answered, not 404 — Android DownloadManager preflights with it')
  t.is(head.headers['content-length'], String(big.length), 'HEAD reports the real size, not 0')
  t.is(head.headers['accept-ranges'], 'bytes', 'HEAD advertises range support too')
  t.is(head.body.length, 0, 'HEAD carries no body')
  t.is(head.rawKeys.filter(k => k === 'content-length').length, 1,
    'exactly one Content-Length on HEAD — _final() must not append its own 0')

  const headMiss = await rawHead(port, '/releases/demo-repo/v2/nope.apk')
  t.is(headMiss.status, 404, 'HEAD on a missing artifact still 404s')

  // (3) Range yields 206 and the exact slice, so a dropped mobile download resumes.
  const part = await fetchBytes(base + 'big.apk', { headers: { Range: 'bytes=100-199' } })
  t.is(part.status, 206, 'Range request → 206 Partial Content')
  t.is(part.headers['content-range'], 'bytes 100-199/' + big.length, 'Content-Range describes the slice')
  t.is(part.headers['content-length'], '100', 'Content-Length is the slice length')
  t.ok(part.body.equals(big.subarray(100, 200)), 'the slice is the right bytes')

  // Open-ended form is what a resuming client actually sends.
  const tail = await fetchBytes(base + 'big.apk', { headers: { Range: 'bytes=' + (big.length - 10) + '-' } })
  t.is(tail.status, 206, 'open-ended range → 206')
  t.ok(tail.body.equals(big.subarray(big.length - 10)), 'open-ended range returns the tail')

  const unsat = await fetchBytes(base + 'big.apk', { headers: { Range: 'bytes=' + (big.length + 5) + '-' } })
  t.is(unsat.status, 416, 'unsatisfiable range → 416, not a silent full body')
  t.is(unsat.headers['content-range'], 'bytes */' + big.length, '416 reports the real size')
})