diff --git a/src/handlers/resource.js b/src/handlers/resource.js index 752dfcc4..1fdcc216 100644 --- a/src/handlers/resource.js +++ b/src/handlers/resource.js @@ -622,11 +622,163 @@ export async function handleGet(request, reply) { return reply.send(content); } +// Cap on how many bytes HEAD will FULLY read to decide a content type. +// GET reads the whole file regardless (it has to send the body anyway), +// but a HEAD on a multi-GB file must not slurp it into memory just to +// report a header. Above the cap, HEAD degrades gracefully per-case +// (see negotiateHeadFileContentType) instead of reading. Bounded +// first-bytes sniffs (HEAD_SNIFF_CHUNK_BYTES via a ranged read) are +// allowed at ANY size — they cost O(1). +const HEAD_FULL_READ_MAX_BYTES = 1024 * 1024; +const HEAD_SNIFF_CHUNK_BYTES = 1024; + +// Read the first `bytes` of a file via a ranged stream — O(1) cost +// regardless of file size. Used by HEAD to run GET's "does it look +// like HTML?" sniffs without reading whole files. +function readFirstBytes(storagePath, bytes) { + return new Promise((resolve) => { + const result = storage.createReadStream(storagePath, { start: 0, end: bytes - 1 }); + if (!result) return resolve(null); + const chunks = []; + result.stream.on('data', (c) => chunks.push(c)); + result.stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + result.stream.on('error', () => resolve(null)); + }); +} + +/** + * Mirror handleGet's content-type decision for a FILE so HEAD emits the + * same Content-Type a GET with the same Accept header would (#552 — + * RFC 9110 §9.3.2: HEAD should send the same header fields as GET). + * + * GET's decision depends on file CONTENT in three places — the + * HTML-data-island sniff, the JSON-parse-success gate before conneg + * conversion, and the extensionless-file HTML sniff — so this may read + * the file, but only when the stored type makes content relevant and + * the file is within HEAD_FULL_READ_MAX_BYTES. + * + * Returns `{ contentType, converted }`. `converted: true` means GET + * would RE-SERIALIZE the body (Turtle conversion, or JSON-LD + * re-serialization through fromJsonLd) — its Content-Length would NOT + * be the on-disk size, so HEAD must omit Content-Length rather than + * claim stats.size for a body GET never sends. The extensionless HTML + * sniff only relabels the bytes (served as-is), so it is NOT a + * conversion. + * + * Large files (> HEAD_FULL_READ_MAX_BYTES) degrade per-case instead of + * being read in full: + * - RDF-stored: return the negotiated type WITHOUT the parse gate + * (optimistic). The gate only mirrors GET's corrupt-file fallback; + * a corrupt >1 MiB RDF document is far rarer than a valid one, so + * optimism keeps parity for the common case and confines the + * divergence to that corner. + * - HTML-looking content (any stored type) + Turtle-preferring + * Accept: stay at the stored type (conservative) — the data + * island can sit anywhere in the file, so its presence can't be + * checked without the full read this cap exists to avoid. + * - Extensionless: the HTML sniff only needs the first bytes, so it + * runs at ANY size via a bounded ranged read. + * + * Known residual divergences (deliberate, all need unusual documents): + * a parseable-but-unconvertible document (GET's fromJsonLd fails after + * JSON.parse succeeds → GET falls back to raw bytes), a corrupt + * >1 MiB RDF file (optimistic path above), and a >1 MiB HTML-looking + * file carrying a data island (conservative path above). + */ +async function negotiateHeadFileContentType({ storagePath, urlPath, stats, acceptHeader, connegEnabled }) { + const storedContentType = getContentType(storagePath); + const fitsFullRead = stats.size <= HEAD_FULL_READ_MAX_BYTES; + + if (connegEnabled) { + // Same negotiation as handleGet's file branch (#325 q-aware). + const negotiated = selectContentType(acceptHeader, true); + const wantsTurtle = urlPath.endsWith('.ttl') + || negotiated === RDF_TYPES.TURTLE + || negotiated === RDF_TYPES.N3 + || negotiated === 'application/n-triples'; + + if (isRdfContentType(storedContentType)) { + const targetType = wantsTurtle ? 'text/turtle' : selectContentType(acceptHeader, connegEnabled); + if (!fitsFullRead) { + // Optimistic large-file path — see docstring. + return { contentType: targetType, converted: true }; + } + const content = await storage.read(storagePath); + if (content !== null) { + try { + JSON.parse(content.toString()); // GET only converts when the body parses + return { contentType: targetType, converted: true }; + } catch { /* not valid JSON-LD → GET serves as-is; fall through */ } + } + return { contentType: storedContentType, converted: false }; + } + + // GET's data-island branch is gated on CONTENT ONLY — any file + // whose body starts with HEAD_SNIFF_CHUNK_BYTES) { + // The chunk was entirely whitespace and the file continues past + // it — GET trims the FULL body, so the HTML marker may sit + // beyond the chunk. The file already fits the full-read budget; + // read it and decide exactly like GET does. + const content = await storage.read(storagePath); + if (content !== null) { + contentStr = content.toString(); + const trimmed = contentStr.trimStart(); + looksHtml = trimmed.startsWith('([\s\S]*?)<\/script>/i); + if (jsonLdMatch) { + try { + JSON.parse(jsonLdMatch[1]); + return { contentType: 'text/turtle', converted: true }; + } catch { /* unparseable island → GET serves as-is; fall through */ } + } + } + } + // No island conversion → fall through to the as-is path below + // (HTML-looking extensionless files still get the relabel sniff). + } + } + + // As-is path: GET sniffs extensionless files for HTML by content. + // Only the first bytes matter, so the sniff runs at any file size + // via a bounded ranged read. Relabel only — no conversion. + if (storedContentType === 'application/octet-stream') { + const head = await readFirstBytes(storagePath, HEAD_SNIFF_CHUNK_BYTES); + if (head !== null) { + const t = head.trimStart(); + if (t.startsWith(' { + const srv = createNetServer(); + srv.on('error', reject); + srv.listen(0, TEST_HOST, () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + }); +} + +describe('HEAD/GET content-type parity (#552)', () => { + let server; + let baseUrl; + let originalDataRoot; + + before(async () => { + originalDataRoot = process.env.DATA_ROOT; + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + + const port = await getAvailablePort(); + baseUrl = `http://${TEST_HOST}:${port}`; + + server = createServer({ + logger: false, + root: DATA_DIR, + conneg: true, + public: true, + forceCloseConnections: true, + }); + await server.listen({ port, host: TEST_HOST }); + + // JSON-LD resource (the #552 reproduction target) + const put = await fetch(`${baseUrl}/public/parity.jsonld`, { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + body: JSON.stringify({ + '@context': { name: 'http://xmlns.com/foaf/0.1/name' }, + '@id': '#it', + name: 'head-parity-test', + }), + }); + assert.strictEqual(put.status, 201, 'prereq: PUT must succeed'); + + // Extensionless HTML file — exercises GET's octet-stream HTML sniff. + // Written directly to disk: PUT would route content-type by extension. + await fs.writeFile(path.join(DATA_DIR, 'public', 'noext-html'), + 'sniff me'); + }); + + after(async () => { + if (server) await server.close(); + if (originalDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = originalDataRoot; + await fs.remove(DATA_DIR); + }); + + const ACCEPT_VARIANTS = [ + ['application/ld+json, text/turtle, application/json', 'ld+json first'], + ['text/turtle, application/ld+json', 'turtle first'], + ['application/ld+json;q=0.5, text/turtle', 'turtle preferred via q'], + ['', 'no Accept header'], + ]; + + // Compare MEDIA TYPES (parameters stripped): Fastify appends + // `; charset=utf-8` when it serializes a string BODY, which HEAD + // never has — the charset parameter is a body-serialization + // artifact, not part of what negotiation decides. + const mediaType = (res) => (res.headers.get('content-type') || '').split(';')[0].trim(); + + it('HEAD media type and Cache-Control equal GET\'s for every Accept variant (RFC 9110 §9.3.2)', async () => { + for (const [accept, label] of ACCEPT_VARIANTS) { + const headers = accept ? { Accept: accept } : {}; + const getRes = await fetch(`${baseUrl}/public/parity.jsonld`, { headers }); + const headRes = await fetch(`${baseUrl}/public/parity.jsonld`, { method: 'HEAD', headers }); + assert.strictEqual(getRes.status, 200, `${label}: GET must 200`); + assert.strictEqual(headRes.status, 200, `${label}: HEAD must 200`); + assert.strictEqual(mediaType(headRes), mediaType(getRes), + `${label}: HEAD media type must equal GET's`); + // GET applies RDF_CACHE_CONTROL to RDF responses; HEAD must agree. + assert.strictEqual( + headRes.headers.get('cache-control'), + getRes.headers.get('cache-control'), + `${label}: HEAD Cache-Control must equal GET's`, + ); + } + }); + + it('HEAD Cache-Control matches GET on container listings too', async () => { + // GET applies RDF_CACHE_CONTROL uniformly wherever the response + // type is RDF — including container listings, not just files. + const getRes = await fetch(`${baseUrl}/public/`); + const headRes = await fetch(`${baseUrl}/public/`, { method: 'HEAD' }); + assert.strictEqual(getRes.status, 200); + assert.strictEqual(headRes.status, 200); + assert.strictEqual(mediaType(headRes), mediaType(getRes), + 'container HEAD media type must equal GET\'s'); + assert.strictEqual( + headRes.headers.get('cache-control'), + getRes.headers.get('cache-control'), + 'container HEAD Cache-Control must equal GET\'s', + ); + }); + + it('HEAD honors a Turtle-preferring Accept on a stored-JSON-LD file (the #552 bug)', async () => { + const res = await fetch(`${baseUrl}/public/parity.jsonld`, { + method: 'HEAD', + headers: { Accept: 'text/turtle, application/ld+json' }, + }); + assert.strictEqual(res.status, 200); + assert.match(res.headers.get('content-type'), /text\/turtle/, + 'HEAD must report the negotiated Turtle, not the stored JSON-LD'); + }); + + it('HEAD omits Content-Length when GET would convert the body', async () => { + // GET re-serializes JSON-LD → Turtle; its body length is not the + // on-disk size, so HEAD claiming stats.size would be a lie. + const res = await fetch(`${baseUrl}/public/parity.jsonld`, { + method: 'HEAD', + headers: { Accept: 'text/turtle' }, + }); + assert.strictEqual(res.headers.get('content-length'), null, + 'no Content-Length on a conneg-converted HEAD'); + }); + + it('HEAD keeps Content-Length for as-is responses', async () => { + // Extensionless HTML: GET relabels (text/html) but serves the raw + // bytes, so the on-disk size IS the body length. + const res = await fetch(`${baseUrl}/public/noext-html`, { method: 'HEAD' }); + assert.strictEqual(res.status, 200); + assert.match(res.headers.get('content-type'), /text\/html/, + 'extensionless HTML sniff must apply to HEAD like GET'); + assert.ok(res.headers.get('content-length'), + 'as-is responses keep Content-Length'); + }); + + it('HEAD negotiates large (>1 MiB) RDF files too — optimistic path, no full read', async () => { + // Copilot review case on the first draft: the full-read cap must + // not regress parity for large VALID RDF documents. Above the cap + // HEAD skips the parse gate and trusts the extension. + const big = { + '@context': { name: 'http://xmlns.com/foaf/0.1/name' }, + '@id': '#it', + name: 'x'.repeat(1024 * 1024 + 1024), // > HEAD_FULL_READ_MAX_BYTES + }; + await fs.writeFile(path.join(DATA_DIR, 'public', 'big.jsonld'), JSON.stringify(big)); + const res = await fetch(`${baseUrl}/public/big.jsonld`, { + method: 'HEAD', + headers: { Accept: 'text/turtle, application/ld+json' }, + }); + assert.strictEqual(res.status, 200); + assert.match(res.headers.get('content-type'), /text\/turtle/, + 'large valid RDF must still negotiate on HEAD'); + assert.strictEqual(res.headers.get('content-length'), null, + 'converted large-file HEAD must omit Content-Length'); + }); + + it('HEAD sniffs large (>1 MiB) extensionless HTML via bounded ranged read', async () => { + const filler = '' + 'y'.repeat(1024 * 1024 + 1024) + ''; + await fs.writeFile(path.join(DATA_DIR, 'public', 'big-noext'), filler); + const res = await fetch(`${baseUrl}/public/big-noext`, { method: 'HEAD' }); + assert.strictEqual(res.status, 200); + assert.match(res.headers.get('content-type'), /text\/html/, + 'the HTML sniff only needs the first bytes — size must not disable it'); + assert.ok(res.headers.get('content-length'), + 'as-is large file keeps Content-Length'); + }); + + it('island negotiation keys off content, not stored type (.xhtml file, pass-3 review case)', async () => { + // GET's data-island branch is gated on CONTENT only — a .xhtml + // file (stored type application/xhtml+xml) whose body starts with + // ' + + JSON.stringify({ '@context': { name: 'http://xmlns.com/foaf/0.1/name' }, '@id': '#it', name: 'xhtml-island' }) + + ''; + await fs.writeFile(path.join(DATA_DIR, 'public', 'island.xhtml'), xhtml); + + const headers = { Accept: 'text/turtle, application/ld+json' }; + const getRes = await fetch(`${baseUrl}/public/island.xhtml`, { headers }); + const headRes = await fetch(`${baseUrl}/public/island.xhtml`, { method: 'HEAD', headers }); + assert.strictEqual(getRes.status, 200); + assert.strictEqual(headRes.status, 200); + assert.strictEqual(mediaType(headRes), mediaType(getRes), + `xhtml island: HEAD (${mediaType(headRes)}) must equal GET (${mediaType(getRes)})`); + }); + + it('island detection survives >1 KiB of leading whitespace (pass-4 review case)', async () => { + // GET trims the FULL body before its ' + + ''; + await fs.writeFile(path.join(DATA_DIR, 'public', 'padded.xhtml'), padded); + + const headers = { Accept: 'text/turtle, application/ld+json' }; + const getRes = await fetch(`${baseUrl}/public/padded.xhtml`, { headers }); + const headRes = await fetch(`${baseUrl}/public/padded.xhtml`, { method: 'HEAD', headers }); + assert.strictEqual(getRes.status, 200); + assert.strictEqual(headRes.status, 200); + assert.strictEqual(mediaType(headRes), mediaType(getRes), + `whitespace-padded island: HEAD (${mediaType(headRes)}) must equal GET (${mediaType(getRes)})`); + }); + + it('HEAD with If-None-Match still returns 304 (negotiation must not break revalidation)', async () => { + const probe = await fetch(`${baseUrl}/public/parity.jsonld`, { method: 'HEAD' }); + const etag = probe.headers.get('etag'); + assert.ok(etag, 'prereq: ETag present'); + const res = await fetch(`${baseUrl}/public/parity.jsonld`, { + method: 'HEAD', + headers: { 'If-None-Match': etag }, + }); + assert.strictEqual(res.status, 304); + }); +});