| … | … | @@ -23,6 +23,7 @@ import { mkdtempSync, mkdirSync } from '#node:fs' |
| 23 | 23 | import { join } from '#node:path' |
| 24 | 24 | import { tmpdir } from '#node:os' |
| 25 | 25 | import http from '#node:http' |
| 26 | import net from '#node:net' |
| 26 | 27 | import b4a from 'b4a' |
| 27 | 28 | import crypto from 'hypercore-crypto' |
| 28 | 29 | |
| … | … | @@ -31,10 +32,16 @@ import { startPearGit } from '../index.js' |
| 31 | 32 | function tmpStorage () { return mkdtempSync(join(tmpdir(), 'pgv3-rel-')) } |
| 32 | 33 | function tmpSock () { return join(mkdtempSync(join(tmpdir(), 'pgv3-rel-sock-')), 'svc.sock') } |
| 33 | 34 | |
| 34 | | function fetchBytes (url) { |
| 35 | function fetchBytes (url, opts = {}) { |
| 35 | 36 | return new Promise((resolve, reject) => { |
| 36 | 37 | const u = new URL(url) |
| 37 | | const req = http.request({ method: 'GET', hostname: u.hostname, port: u.port, path: u.pathname + u.search }, (res) => { |
| 38 | const req = http.request({ |
| 39 | method: opts.method || 'GET', |
| 40 | hostname: u.hostname, |
| 41 | port: u.port, |
| 42 | path: u.pathname + u.search, |
| 43 | headers: opts.headers || {} |
| 44 | }, (res) => { |
| 38 | 45 | const parts = [] |
| 39 | 46 | res.on('data', c => parts.push(c)) |
| 40 | 47 | res.on('end', () => resolve({ status: res.statusCode, body: b4a.concat(parts), headers: res.headers })) |
| … | … | @@ -45,6 +52,51 @@ function fetchBytes (url) { |
| 45 | 52 | }) |
| 46 | 53 | } |
| 47 | 54 | |
| 55 | // HEAD has to be asserted at the wire level. bare-http1's CLIENT has no HEAD |
| 56 | // awareness on the response side — lib/client-request.js special-cases HEAD only |
| 57 | // for the REQUEST body — so it blocks forever waiting for a body that a correct |
| 58 | // HEAD response never sends. curl and Android's DownloadManager handle it fine; |
| 59 | // our own client cannot, so `http.request({method:'HEAD'})` would hang the test |
| 60 | // rather than measure the server. Speak HTTP directly instead. |
| 61 | // |
| 62 | // Returning the raw header keys in order also gives an exact duplicate check, |
| 63 | // which matters because the bug being guarded is a header emitted TWICE. |
| 64 | function rawHead (port, reqPath) { |
| 65 | return new Promise((resolve, reject) => { |
| 66 | // createConnection, not connect: bare-net exposes only the former. |
| 67 | const socket = net.createConnection(port, '127.0.0.1') |
| 68 | let buf = '' |
| 69 | let settled = false |
| 70 | socket.on('error', reject) |
| 71 | socket.on('connect', () => { |
| 72 | socket.write(b4a.from('HEAD ' + reqPath + ' HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n')) |
| 73 | }) |
| 74 | socket.on('data', (chunk) => { |
| 75 | buf += b4a.toString(b4a.from(chunk)) |
| 76 | const i = buf.indexOf('\r\n\r\n') |
| 77 | if (i === -1 || settled) return |
| 78 | settled = true |
| 79 | const lines = buf.slice(0, i).split('\r\n') |
| 80 | const status = Number(lines[0].split(' ')[1]) |
| 81 | const headers = {} |
| 82 | const rawKeys = [] |
| 83 | for (const line of lines.slice(1)) { |
| 84 | const j = line.indexOf(':') |
| 85 | if (j === -1) continue |
| 86 | const k = line.slice(0, j).trim().toLowerCase() |
| 87 | const v = line.slice(j + 1).trim() |
| 88 | rawKeys.push(k) |
| 89 | headers[k] = k in headers ? headers[k] + ', ' + v : v |
| 90 | } |
| 91 | socket.destroy() |
| 92 | resolve({ status, headers, rawKeys, body: buf.slice(i + 4) }) |
| 93 | }) |
| 94 | socket.on('close', () => { |
| 95 | if (!settled) reject(new Error('connection closed before headers')) |
| 96 | }) |
| 97 | }) |
| 98 | } |
| 99 | |
| 48 | 100 | test('a published release artifact downloads over HTTP', async (t) => { |
| 49 | 101 | const storage = tmpStorage() |
| 50 | 102 | mkdirSync(storage, { recursive: true }) |
| … | … | @@ -193,3 +245,93 @@ test('release artifacts carry a useful Content-Type', async (t) => { |
| 193 | 245 | t.is(bin.headers['content-type'], 'application/octet-stream', |
| 194 | 246 | 'unknown extensions still fall through to a binary download') |
| 195 | 247 | }) |
| 248 | |
| 249 | // Mobile downloads of a large artifact failed against the production forge |
| 250 | // (2026-08-25, a 148 MB APK over https://7rnx.net/git). Three defects, all here: |
| 251 | // |
| 252 | // 1. Every route was gated on `req.method === 'GET'`, so HEAD matched nothing |
| 253 | // and fell through to the 404 handler. Android's DownloadManager preflights |
| 254 | // with HEAD, so the download died before a byte moved. Measured from the |
| 255 | // handset: HEAD hung for 25 s and returned 0 bytes. |
| 256 | // 2. Content-Length was omitted deliberately, so the body went out |
| 257 | // Transfer-Encoding: chunked — no size, no progress, no completeness check. |
| 258 | // 3. No Range support: a Range request returned 200 and the whole stream, so |
| 259 | // an interrupted mobile download restarted from zero every time. |
| 260 | // |
| 261 | // The duplicate-Content-Length hazard the old comment warned about is real, but |
| 262 | // it comes from bare-http1's own `_write()`, which sets Content-Length itself |
| 263 | // when a body fits in a single finishing write. writeHead preserves header case |
| 264 | // and setHeader lowercases, so writeHead('Content-Length') plus that internal |
| 265 | // call emits the header TWICE — for SMALL artifacts only, which is exactly what |
| 266 | // the favela-android OTA fetcher reads (tree.json). Hence setHeader throughout. |
| 267 | test('release artifacts support HEAD, Content-Length and Range', async (t) => { |
| 268 | const storage = tmpStorage() |
| 269 | mkdirSync(storage, { recursive: true }) |
| 270 | |
| 271 | const node = await startPearGit({ |
| 272 | storage, uds: tmpSock(), http: { port: 0 }, swarm: false, autoImportSSH: false |
| 273 | }) |
| 274 | t.teardown(async () => { try { await node.close() } catch {} }) |
| 275 | |
| 276 | // Big enough to span multiple stream chunks, plus a tiny one that lands in a |
| 277 | // single finishing write — the two sides of the duplicate-header trap. |
| 278 | const big = crypto.randomBytes(512 * 1024) |
| 279 | const small = b4a.from('{"entries":[]}') |
| 280 | |
| 281 | await node.svc._releases.publish({ |
| 282 | repo: 'demo-repo', |
| 283 | version: 'v2', |
| 284 | artifacts: [ |
| 285 | { path: 'big.apk', data: big }, |
| 286 | { path: 'tree.json', data: small } |
| 287 | ], |
| 288 | meta: {} |
| 289 | }) |
| 290 | |
| 291 | const port = node.svc.http.server.address().port |
| 292 | const base = 'http://127.0.0.1:' + port + '/releases/demo-repo/v2/' |
| 293 | |
| 294 | // (2) GET advertises an exact length and range support. |
| 295 | const got = await fetchBytes(base + 'big.apk') |
| 296 | t.is(got.status, 200, 'GET still 200') |
| 297 | t.is(got.headers['content-length'], String(big.length), 'GET carries an exact Content-Length') |
| 298 | t.is(got.headers['accept-ranges'], 'bytes', 'GET advertises range support') |
| 299 | t.ok(got.body.equals(big), 'GET body still byte-identical') |
| 300 | |
| 301 | // Duplicate headers arrive joined by ', ' — a comma means we emitted it twice. |
| 302 | t.absent(String(got.headers['content-length']).includes(','), |
| 303 | 'exactly one Content-Length on a multi-chunk body') |
| 304 | const smallGot = await fetchBytes(base + 'tree.json') |
| 305 | t.is(smallGot.headers['content-length'], String(small.length), 'small artifact length correct') |
| 306 | t.absent(String(smallGot.headers['content-length']).includes(','), |
| 307 | 'exactly one Content-Length on a single-write body — bare-http1 sets its own') |
| 308 | t.ok(smallGot.body.equals(small), 'small artifact bytes intact (the OTA fetcher path)') |
| 309 | |
| 310 | // (1) HEAD answers with the size and no body, instead of 404. |
| 311 | const head = await rawHead(port, '/releases/demo-repo/v2/big.apk') |
| 312 | t.is(head.status, 200, 'HEAD is answered, not 404 — Android DownloadManager preflights with it') |
| 313 | t.is(head.headers['content-length'], String(big.length), 'HEAD reports the real size, not 0') |
| 314 | t.is(head.headers['accept-ranges'], 'bytes', 'HEAD advertises range support too') |
| 315 | t.is(head.body.length, 0, 'HEAD carries no body') |
| 316 | t.is(head.rawKeys.filter(k => k === 'content-length').length, 1, |
| 317 | 'exactly one Content-Length on HEAD — _final() must not append its own 0') |
| 318 | |
| 319 | const headMiss = await rawHead(port, '/releases/demo-repo/v2/nope.apk') |
| 320 | t.is(headMiss.status, 404, 'HEAD on a missing artifact still 404s') |
| 321 | |
| 322 | // (3) Range yields 206 and the exact slice, so a dropped mobile download resumes. |
| 323 | const part = await fetchBytes(base + 'big.apk', { headers: { Range: 'bytes=100-199' } }) |
| 324 | t.is(part.status, 206, 'Range request → 206 Partial Content') |
| 325 | t.is(part.headers['content-range'], 'bytes 100-199/' + big.length, 'Content-Range describes the slice') |
| 326 | t.is(part.headers['content-length'], '100', 'Content-Length is the slice length') |
| 327 | t.ok(part.body.equals(big.subarray(100, 200)), 'the slice is the right bytes') |
| 328 | |
| 329 | // Open-ended form is what a resuming client actually sends. |
| 330 | const tail = await fetchBytes(base + 'big.apk', { headers: { Range: 'bytes=' + (big.length - 10) + '-' } }) |
| 331 | t.is(tail.status, 206, 'open-ended range → 206') |
| 332 | t.ok(tail.body.equals(big.subarray(big.length - 10)), 'open-ended range returns the tail') |
| 333 | |
| 334 | const unsat = await fetchBytes(base + 'big.apk', { headers: { Range: 'bytes=' + (big.length + 5) + '-' } }) |
| 335 | t.is(unsat.status, 416, 'unsatisfiable range → 416, not a silent full body') |
| 336 | t.is(unsat.headers['content-range'], 'bytes */' + big.length, '416 reports the real size') |
| 337 | }) |