// 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')
})