tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 7dd24a8c10ba43dc07c7f7b4f6b3601e8e10a37b
parent 7cf9370775df3194a7b68eb4a3b005258e1e90e1
Author: Randell Jesup <rjesup@mozilla.com>
Date:   Tue,  7 Oct 2025 17:56:02 +0000

Bug 1918741: Add unit tests for Compression Dictionaries r=necko-reviewers,kershaw,valentin

Differential Revision: https://phabricator.services.mozilla.com/D263730

Diffstat:
Mnetwerk/test/httpserver/NodeServer.sys.mjs | 1+
Mnetwerk/test/unit/head_cache.js | 25+++++++++++++++++++------
Anetwerk/test/unit/test_cache2_compression_dictionary.js | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anetwerk/test/unit/test_dictionary_compression_dcb.js | 1113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anetwerk/test/unit/test_dictionary_retrieval.js | 510+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anetwerk/test/unit/test_dictionary_storage.js | 610++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnetwerk/test/unit/xpcshell.toml | 8++++++++
7 files changed, 2393 insertions(+), 6 deletions(-)

diff --git a/netwerk/test/httpserver/NodeServer.sys.mjs b/netwerk/test/httpserver/NodeServer.sys.mjs @@ -243,6 +243,7 @@ class NodeHTTPSServerCode extends BaseNodeHTTPServerCode { const options = { key: fs.readFileSync(__dirname + "/http2-cert.key"), cert: fs.readFileSync(__dirname + "/http2-cert.pem"), + maxHeaderSize: 128 * 1024, }; const https = require("https"); global.server = https.createServer( diff --git a/netwerk/test/unit/head_cache.js b/netwerk/test/unit/head_cache.js @@ -4,7 +4,7 @@ var { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); -function evict_cache_entries(where) { +function evict_cache_entries(where, lci = null) { var clearDisk = !where || where == "disk" || where == "all"; var clearMem = !where || where == "memory" || where == "all"; @@ -12,14 +12,14 @@ function evict_cache_entries(where) { if (clearMem) { storage = Services.cache2.memoryCacheStorage( - Services.loadContextInfo.default + lci ? lci : Services.loadContextInfo.default ); storage.asyncEvictStorage(null); } if (clearDisk) { storage = Services.cache2.diskCacheStorage( - Services.loadContextInfo.default + lci ? lci : Services.loadContextInfo.default ); storage.asyncEvictStorage(null); } @@ -113,15 +113,28 @@ function get_device_entry_count(where, lci, continuation) { storage.asyncVisitStorage(visitor, false); } -function asyncCheckCacheEntryPresence(key, where, shouldExist, continuation) { +function asyncCheckCacheEntryPresence( + key, + where, + shouldExist, + lci, + continuation +) { asyncOpenCacheEntry( key, where, Ci.nsICacheStorage.OPEN_READONLY, - null, + lci, function (status, entry) { if (shouldExist) { - dump("TEST-INFO | checking cache key " + key + " exists @ " + where); + dump( + "TEST-INFO | status: " + + status + + " checking cache key " + + key + + " exists @ " + + where + ); Assert.equal(status, Cr.NS_OK); Assert.ok(!!entry); } else { diff --git a/netwerk/test/unit/test_cache2_compression_dictionary.js b/netwerk/test/unit/test_cache2_compression_dictionary.js @@ -0,0 +1,132 @@ +/* + * Tests for cache2 Compression Dictionary support (draft-ietf-httpbis-compression-dictionary-19) + * - Storing dictionaries via Use-As-Dictionary + * - Using Available-Dictionary for decompression + */ + +"use strict"; + +// Load cache helpers +Services.scriptloader.loadSubScript("resource://test/head_cache.js", this); + +const { NodeHTTPSServer } = ChromeUtils.importESModule( + "resource://testing-common/NodeServer.sys.mjs" +); + +var server = null; +// Keep these in sync with duplicates below! +const dictContent = "DICTIONARY_DATA"; +const decompressedContent = "COMPRESSED_DATA"; +const resourcePath = "/resource"; +const dictPath = "/dict"; + +function makeChan(url) { + let chan = NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, + }).QueryInterface(Ci.nsIHttpChannel); + return chan; +} + +function channelOpenPromise(chan) { + return new Promise(resolve => { + function finish(req, buffer) { + resolve([req, buffer]); + } + chan.asyncOpen(new ChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL)); + }); +} + +// Serve a dictionary with Use-As-Dictionary header +function serveDictionary(request, response) { + // the server can't see the global versions of these. + // Note: keep in sync with above! + let dict = "dict1"; + const dictContent = "DICTIONARY_DATA"; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match=\"*\", id=\"${dict}\", type=raw`, + "Cache-Control": "max-age=3600", + }); + response.end(dictContent, "binary"); +} + +// Serve a resource with Available-Dictionary header +function serveCompressedResource(request, response) { + // brotli compressed data is 4 byte magic + 32-byte SHA-256 hash (which we + // don't check) + const compressedContent = + "\xff\x44\x43\x42" + + "12345678901234567890123456789012" + + "\x21\x38\x00\x04COMPRESSED_DATA\x03"; + let availDict = request.headers["available-dictionary"]; + if (availDict != undefined) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Encoding": "dcb", + }); + response.end(compressedContent, "binary"); + } else { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + }); + response.end("UNCOMPRESSED_DATA", "binary"); + } +} + +add_setup(async function () { + if (!server) { + server = new NodeHTTPSServer(); + await server.start(); + registerCleanupFunction(async () => { + await server.stop(); + }); + + await server.registerPathHandler(dictPath, serveDictionary); + await server.registerPathHandler(resourcePath, serveCompressedResource); + } +}); + +add_task(async function test_resource_without_dictionary() { + let uri = `${server.origin()}${resourcePath}`; + let chan = makeChan(uri); + let [, data] = await channelOpenPromise(chan); + Assert.equal(data, "UNCOMPRESSED_DATA", "Received uncompressed data"); +}); + +add_task(async function test_store_dictionary() { + let uri = `${server.origin()}${dictPath}`; + let chan = makeChan(uri); + let [, data] = await channelOpenPromise(chan); + Assert.equal(data, dictContent, "Dictionary body matches"); + + await new Promise(resolve => { + // Check that dictionary is stored in cache (disk) + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + asyncCheckCacheEntryPresence(uri, "disk", true, lci, resolve); + }); +}); + +add_task(async function test_use_dictionary_for_resource() { + let uri = `${server.origin()}${resourcePath}`; + + let chan = makeChan(uri); + let [req, data] = await channelOpenPromise(chan); + // Check for expected uncompressed content + Assert.strictEqual( + data, + decompressedContent, + "Received compressed data (decompression not supported in test)" + ); + // Check response headers + Assert.equal( + req.getResponseHeader("Content-Encoding"), + "", + "Content-Encoding dcb was removed" + ); + let availdict = req.getRequestHeader("available-dictionary"); + Assert.equal(availdict, ":iFRBfhN7ePMquH3Lmw/oL4xRkaa8QjW43JQO+04KA7I=:"); +}); diff --git a/netwerk/test/unit/test_dictionary_compression_dcb.js b/netwerk/test/unit/test_dictionary_compression_dcb.js @@ -0,0 +1,1113 @@ +/** + * Tests for HTTP Compression Dictionary Brotli (dcb) compression functionality + * - Dictionary-based Brotli compression and decompression + * - Content integrity verification with dcb encoding + * - Available-Dictionary header integration for compression + * - Error handling for missing/invalid dictionaries + * - Compression window size limits and edge cases + */ + +"use strict"; + +// Load cache helpers +Services.scriptloader.loadSubScript("resource://test/head_cache.js", this); + +const { NodeHTTPSServer } = ChromeUtils.importESModule( + "resource://testing-common/NodeServer.sys.mjs" +); + +// Test dictionaries optimized for compression testing +// Since we're not actually brotli-encoding, all decodes will yield 15 bytes +const DCB_TEST_DICTIONARIES = { + html_common: { + id: "html-dict", + content: + '<html><head><title>Common HTML Template</title></head><body><div class="container"><p>', + expected_length: 15, + pattern: "*.html", + type: "raw", + }, + html_common_no_dictionary: { + id: "html-dict", + content: + '<html><head><title>Common HTML Template</title></head><body><div class="container"><p>', + expected_length: 196, + pattern: "*.html", + type: "raw", + }, + api_json: { + id: "api-dict", + content: + '{"status":"success","data":{"id":null,"name":"","created_at":"","updated_at":""},"message":"","errors":[]}', + expected_length: 15, + pattern: "/api/*", + type: "raw", + }, + api_v1: { + id: "longer-match-dict", + content: + '{"status":"success","data":{"id":null,"name":"","created_at":"","updated_at":""},"message":"","errors":[]}', + expected_length: 15, + pattern: "/api/v1/*", + type: "raw", + }, + js_common: { + id: "js-dict", + content: + "function(){return this;};var=function();const=function();let=function();", + expected_length: 15, + pattern: "*.js", + type: "raw", + }, + large_dict: { + id: "large-dict", + content: "REPEATED_PATTERN_".repeat(1000), // ~1.5MB dictionary + expected_length: 15, + pattern: "/large/*", + type: "raw", + }, +}; + +// Test content designed to compress well with dictionaries +const DCB_TEST_CONTENT = { + html_page: + '<html><head><title>Test Page</title></head><body><div class="container"><p>This is test content that should compress well with the HTML dictionary.</p><p>More content here.</p></div></body></html>', + + api_response: + '{"status":"success","data":{"id":12345,"name":"Test User","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T12:00:00Z"},"message":"User retrieved successfully","errors":[]}', + + api_v1: + '{"status":"success","data":{"id":12345,"name":"Test User","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T12:00:00Z"},"message":"User retrieved successfully","errors":[]}', + + js_code: + 'function testFunction(){return this.value;};var result=function(){console.log("test");};const API_URL=function(){return "https://api.example.com";};let userData=function(){return {id:1,name:"test"};}', + + large_content: "REPEATED_PATTERN_DATA_CHUNK_".repeat(50000), // Content that will compress well with large dictionary + + jpeg: "ARBITRARY_DATA_".repeat(1000), +}; + +let server = null; +let requestLog = []; // Track requests for verification + +// Create channel for dictionary requests +function makeChan(url) { + let chan = NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, + }).QueryInterface(Ci.nsIHttpChannel); + return chan; +} + +function channelOpenPromise(chan) { + return new Promise(resolve => { + function finish(req, buffer) { + resolve([req, buffer]); + } + // CL_EXPECT_GZIP is needed if we're transferring compressed data; else it asserts content-length + // equals the data length. (We could also not send content-length) + chan.asyncOpen( + new ChannelListener( + finish, + null, + CL_ALLOW_UNKNOWN_CL | CL_IGNORE_DELAYS | CL_EXPECT_GZIP + ) + ); + }); +} + +// Setup DCB test server with dictionaries and compressed content endpoints +async function setupDCBTestServer() { + let httpServer = new NodeHTTPSServer(); + await httpServer.start(); + + // Dictionary endpoints - store dictionaries for later compression use + await httpServer.registerPathHandler( + "/dict/html", + function (request, response) { + const DCB_TEST_DICTIONARIES = { + html_common: { + id: "html-dict", + content: + '<html><head><title>Common HTML Template</title></head><body><div class="container"><p>', + pattern: "*.html", + type: "raw", + }, + }; + let dict = DCB_TEST_DICTIONARIES.html_common; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + await httpServer.registerPathHandler( + "/dict/api", + function (request, response) { + const DCB_TEST_DICTIONARIES = { + api_json: { + id: "api-dict", + content: + '{"status":"success","data":{"id":null,"name":"","created_at":"","updated_at":""},"message":"","errors":[]}', + pattern: "/api/*", + type: "raw", + }, + }; + let dict = DCB_TEST_DICTIONARIES.api_json; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + await httpServer.registerPathHandler( + "/dict/js", + function (request, response) { + const DCB_TEST_DICTIONARIES = { + js_common: { + id: "js-dict", + content: + "function(){return this;};var=function();const=function();let=function();", + pattern: "*.js", + type: "raw", + }, + }; + let dict = DCB_TEST_DICTIONARIES.js_common; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + await httpServer.registerPathHandler( + "/dict/large", + function (request, response) { + const DCB_TEST_DICTIONARIES = { + large_dict: { + id: "large-dict", + content: "REPEATED_PATTERN_".repeat(1000), // ~1.5MB dictionary + pattern: "/large/*", + type: "raw", + }, + }; + let dict = DCB_TEST_DICTIONARIES.large_dict; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + // Basic dictionary with valid Use-As-Dictionary header + await httpServer.registerPathHandler( + "/dict/basic", + function (request, response) { + const TEST_DICTIONARIES = { + basic: { + id: "basic-dict", + content: "BASIC_DICTIONARY_DATA", + pattern: "/api/*", + type: "raw", + }, + }; + let dict = TEST_DICTIONARIES.basic; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + // Dictionary with longer match value + await httpServer.registerPathHandler( + "/dict/longer", + function (request, response) { + const TEST_DICTIONARIES = { + specific: { + id: "longer-match-dict", + content: + '{"status":"success","data":{"id":null,"name":"","created_at":"","updated_at":""},"message":"","errors":[]}', + pattern: "/api/v1/*", + type: "raw", + }, + }; + let dict = TEST_DICTIONARIES.specific; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + registerCleanupFunction(async () => { + try { + await httpServer.stop(); + } catch (e) { + // Ignore server stop errors during cleanup + } + }); + + return httpServer; +} + +async function sync_to_server() { + if (server.processId) { + await server.execute(`global.requestLog = ${JSON.stringify(requestLog)};`); + } else { + dump("Server not running?\n"); + } +} + +async function sync_from_server() { + if (server.processId) { + dump(`*** requestLog: ${JSON.stringify(requestLog)}\n`); + requestLog = await server.execute(`global.requestLog`); + } else { + dump("Server not running? (from)\n"); + } +} + +// Calculate expected SHA-256 hash for dictionary content +async function calculateDictionaryHash(content) { + const encoded = new TextEncoder().encode(content); + const digest = await crypto.subtle.digest("SHA-256", encoded); + return btoa(String.fromCharCode(...new Uint8Array(digest))); // base64 +} + +// Verify dcb decompression result +function verifyDCBResponse(channel, data, dictionary) { + // XXX verify decoded content once we use real Brotli encoding + Assert.equal(data.length, dictionary.expected_length); + + try { + // Note: since we remove dcb encoding in the parent process, we can't see + // it in Content-Encoding here + var contentEncoding; + channel.getOriginalResponseHeader("Content-Encoding", { + visitHeader: function visitOrg(aName, aValue) { + contentEncoding = aValue; + }, + }); + Assert.equal; + if (contentEncoding === "dcb") { + return true; + } + } catch (e) { + // Content-Encoding header not present or not dcb + } + return false; +} + +// Setup dcb-aware server endpoint +async function registerDCBEndpoint( + httpServer, + path, + dictionary, + content, + shouldCompress = true +) { + // We have to put all values and functions referenced in the handler into + // this string which will be turned into a function for the handler, because + // NodeHTTPSServer handlers can't access items in the local or global scopes of the + // containing file + let func = ` + let path = "${path}"; + let dictionary = ${JSON.stringify(dictionary)}; + let content = '${content}'; + let shouldCompress = ${shouldCompress}; + + let availableDict = ""; + let hasDictHeader = false; + + // Get content type based on file path + function getContentTypeForPath(path) { + if (path.endsWith('.html')) return 'text/html; charset=utf-8'; + if (path.endsWith('.js')) return 'application/javascript'; + if (path.includes('/api/')) return 'application/json'; + return 'text/plain; charset=utf-8'; + } + + // Calculate compression ratio + function calculateCompressionRatio(original, compressed) { + if (typeof original === 'string') original = original.length; + if (typeof compressed === 'string') compressed = compressed.length; + return original / compressed; + } + + // Simulate dcb compression (for server responses) + function simulateDCBCompression(content, dictionary) { + // Note: Real implementation would use actual Brotli compression + // For testing, we simulate with compression markers and realistic size reduction + let simulatedCompressedSize = Math.floor(content.length * 0.4); // Simulate 60% savings + // This needs to be something that the brotli decoder will correctly read, even though this + // will produce the wrong output + let compressedData = "\x21\x38\x00\x04COMPRESSED_DATA\x03"; + + return { + compressedData: "\xff\x44\x43\x42" + "12345678901234567890123456789012" + compressedData, + originalSize: content.length, + compressedSize: compressedData.length + 36, + compressionRatio: calculateCompressionRatio(content.length, simulatedCompressedSize + 36) + }; + } + + if (request.headers && request.headers['available-dictionary']) { + availableDict = request.headers['available-dictionary']; + hasDictHeader = true; + } else { + shouldCompress = false; + } + + // Log the request for analysis + global.requestLog[global.requestLog.length] = { + path: path, + hasAvailableDict: hasDictHeader, + availableDict: availableDict, + method: request.method + }; + + if (shouldCompress && hasDictHeader && availableDict.includes(dictionary.hash)) { + // Simulate dcb compression + let compressed = simulateDCBCompression(content, dictionary); + response.writeHead(200, { + "Content-Encoding": "dcb", + "Content-Type": getContentTypeForPath(path), + "Content-Length": compressed.compressedSize.toString(), + }); + + // In a real implementation, this would be actual compressed brotli data + // For testing, we simulate the compressed response + + // Note: these aren't real dictionaries; we've prepended a dummy header + // to pass the requirements for a Brotli dictionary - 4 byte magic number + // plus 32 bytes of hash (which we don't currently check, nor does Brotli). + response.end(compressed.compressedData, "binary"); + } else { + // Serve uncompressed + response.writeHead(200, { + "Content-Type": getContentTypeForPath(path), + "Content-Length": content.length, + }); + response.end(content, "binary"); + } + `; + let handler = new Function("request", "response", func); + return httpServer.registerPathHandler(path, handler); +} + +// Verify dictionary is stored in cache (reused from previous tests) +function verifyDictionaryStored(url, shouldExist, callback) { + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, callback); +} + +async function setupDicts() { + requestLog = []; + await sync_to_server(); + + // Store all test dictionaries and calculate their hashes + const dictPaths = [ + "/dict/html", + "/dict/api", + "/dict/js", + "/dict/large", + "/dict/longer", + ]; + const dictKeys = [ + "html_common", + "api_json", + "js_common", + "large_dict", + "api_v1", + ]; + + for (let i = 0; i < dictPaths.length; i++) { + let path = dictPaths[i]; + let dictKey = dictKeys[i]; + let url = `${server.origin()}${path}`; + dump( + `registering dictionary ${path} for match patter ${DCB_TEST_DICTIONARIES[dictKey].patterh}\n` + ); + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + // Calculate and store hash for later use + DCB_TEST_DICTIONARIES[dictKey].hash = + ":" + + (await calculateDictionaryHash(DCB_TEST_DICTIONARIES[dictKey].content)) + + ":"; + + // Verify dictionary content matches + Assert.equal( + data, + DCB_TEST_DICTIONARIES[dictKey].content, + `Dictionary content matches` + ); + + // Verify dictionary was stored + await new Promise(resolve => { + verifyDictionaryStored(url, true, resolve); + }); + } + + dump(`**** DCB test setup complete. Dictionaries stored with hashes.\n`); +} + +add_setup(async function () { + if (!server) { + server = await setupDCBTestServer(); + } + // Setup baseline dictionaries for compression testing + + // Clear any existing cache + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); + + await setupDicts(); +}); + +// Test basic dictionary-compressed Brotli functionality +add_task(async function test_basic_dcb_compression() { + dump("**** test_basic_dcb_compression\n"); + requestLog = []; + await sync_to_server(); + + // Setup DCB endpoint for HTML content + let dict = DCB_TEST_DICTIONARIES.html_common; + let content = DCB_TEST_CONTENT.html_page; + await registerDCBEndpoint(server, "/test.html", dict, content, true); + + let url = `${server.origin()}/test.html`; + let chan = makeChan(url); + let [request, data] = await channelOpenPromise(chan); + + // Check if DCB compression was used + let usedDCB = verifyDCBResponse( + request.QueryInterface(Ci.nsIHttpChannel), + data, + dict + ); + Assert.ok(usedDCB, "DCB compression should be used"); +}); + +// Test correct dictionary selection for dcb compression +add_task(async function test_dcb_dictionary_selection() { + requestLog = []; + await sync_to_server(); + + dump("**** Testing DCB dictionary selection\n"); + + // Test specific pattern matching for dictionary selection + let htmlDict = DCB_TEST_DICTIONARIES.html_common; + let apiDict = DCB_TEST_DICTIONARIES.api_json; + + // Register endpoints that should match different dictionaries + await registerDCBEndpoint( + server, + "/specific-test.html", + htmlDict, + DCB_TEST_CONTENT.html_page, + true + ); + await registerDCBEndpoint( + server, + "/api/specific-test", + apiDict, + DCB_TEST_CONTENT.api_response, + true + ); + + // Test HTML dictionary selection + let htmlUrl = `${server.origin()}/specific-test.html`; + let htmlChan = makeChan(htmlUrl); + let [, htmlData] = await channelOpenPromise(htmlChan); + + Assert.greater( + htmlData.length, + 0, + "HTML dictionary selection test should have content" + ); + + // Check if correct dictionary was used + await sync_from_server(); + let htmlLogEntry = requestLog.find( + entry => entry.path === "/specific-test.html" + ); + Assert.ok( + htmlLogEntry && htmlLogEntry.hasAvailableDict, + "Dictionary selection test: HTML endpoint received Available-Dictionary header" + ); + + // Test API dictionary selection + let apiUrl = `${server.origin()}/api/specific-test`; + let apiChan = makeChan(apiUrl); + let [, apiData] = await channelOpenPromise(apiChan); + + Assert.greater( + apiData.length, + 0, + "API dictionary selection test should have content" + ); + + // Check if correct dictionary was used + await sync_from_server(); + let apiLogEntry = requestLog.find( + entry => entry.path === "/api/specific-test" + ); + Assert.ok( + apiLogEntry && apiLogEntry.hasAvailableDict, + "Dictionary selection test: API endpoint received Available-Dictionary header" + ); +}); + +// Test behavior when dictionary is missing/unavailable +add_task(async function test_dcb_missing_dictionary() { + requestLog = []; + await sync_to_server(); + + dump("**** Testing DCB missing dictionary\n"); + + // Create a fake dictionary that won't be found + let fakeDict = { + id: "missing-dict", + hash: "fake_hash_that_does_not_exist", + content: "This dictionary was not stored", + expected_length: DCB_TEST_CONTENT.jpeg.length, + }; + + // *.jpeg Doesn't match any of the patterns in DCB_TEST_DICTIONARIES + await registerDCBEndpoint( + server, + "/missing-dict-test.jpeg", + fakeDict, + DCB_TEST_CONTENT.jpeg, + false + ); + + let url = `${server.origin()}/missing-dict-test.jpeg`; + let chan = makeChan(url); + let [request, data] = await channelOpenPromise(chan); + + // Should get uncompressed content when dictionary is missing + Assert.greater( + data.length, + 0, + "Missing dictionary test should still return content" + ); + + // Verify no dcb compression was applied + let usedDCB = verifyDCBResponse( + request.QueryInterface(Ci.nsIHttpChannel), + data, + fakeDict + ); + Assert.ok(!usedDCB, "We should not get DCB encoding for a fake item"); +}); + +// Test IETF spec compliance for dcb encoding +add_task(async function test_dcb_header_compliance() { + requestLog = []; + await sync_to_server(); + + dump("**** Testing DCB header compliance\n"); + + let dict = DCB_TEST_DICTIONARIES.api_json; + await registerDCBEndpoint( + server, + "/api/compliance-test", + dict, + DCB_TEST_CONTENT.api_response, + true + ); + + let url = `${server.origin()}/api/compliance-test`; + let chan = makeChan(url); + let [request, data] = await channelOpenPromise(chan); + + Assert.greater(data.length, 0, "IETF compliance test should have content"); + + let httpChannel = request.QueryInterface(Ci.nsIHttpChannel); + + // Verify proper Content-Type preservation + try { + let contentType = httpChannel.getResponseHeader("Content-Type"); + Assert.ok( + contentType.includes("application/json"), + "Content-Type should be preserved through compression" + ); + } catch (e) { + Assert.ok(false, "Content-Type header should be present"); + } + + // Check for proper dcb handling + await sync_from_server(); + let logEntry = requestLog.find( + entry => entry.path === "/api/compliance-test" + ); + Assert.ok( + logEntry && logEntry.hasAvailableDict, + "Must have available-dictionary in header compliance" + ); + // Verify Available-Dictionary follows IETF Structured Field Byte-Sequence format + // According to RFC 8941, byte sequences are enclosed in colons: :base64data: + let availableDict = logEntry.availableDict; + Assert.ok( + availableDict.startsWith(":"), + "Available-Dictionary should start with ':' (IETF Structured Field Byte-Sequence format)" + ); + Assert.ok( + availableDict.endsWith(":"), + "Available-Dictionary should end with ':' (IETF Structured Field Byte-Sequence format)" + ); + Assert.greater( + availableDict.length, + 2, + "Available-Dictionary should contain base64 data between colons" + ); + + // Extract the base64 content between the colons + let base64Content = availableDict.slice(1, -1); + Assert.greater( + base64Content.length, + 0, + "Available-Dictionary should have base64 content" + ); + + // Basic validation that it looks like base64 (contains valid base64 characters) + let base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; + Assert.ok( + base64Regex.test(base64Content), + "Available-Dictionary content should be valid base64" + ); + + dump(`**** IETF compliance test: Available-Dictionary = ${availableDict}\n`); +}); + +// Test that DCB compression stops working after dictionary cache eviction +add_task(async function test_dcb_compression_after_cache_eviction() { + requestLog = []; + await sync_to_server(); + + dump("**** Testing DCB compression after cache eviction\n"); + + // Use a specific dictionary for this test + let dict = DCB_TEST_DICTIONARIES.html_common; + let dict2 = DCB_TEST_DICTIONARIES.html_common_no_dictionary; + let testContent = DCB_TEST_CONTENT.html_page; + let testPath = "/cache-eviction-test.html"; + let dictUrl = `${server.origin()}/dict/html`; + let contentUrl = `${server.origin()}${testPath}`; + + // Step 1: Ensure dictionary is in cache by fetching it + dump("**** Step 1: Loading dictionary into cache\n"); + let dictChan = makeChan(dictUrl); + let [, dictData] = await channelOpenPromise(dictChan); + Assert.equal(dictData, dict.content, "Dictionary loaded successfully"); + + // Verify dictionary is cached + await new Promise(resolve => { + verifyDictionaryStored(dictUrl, true, () => { + resolve(); + }); + }); + + // Step 2: Set up DCB endpoint and test compression works + dump("**** Step 2: Testing DCB compression with cached dictionary\n"); + await registerDCBEndpoint(server, testPath, dict, testContent, true); + + // Clear request log before testing + requestLog = []; + await sync_to_server(); + + let chan1 = makeChan(contentUrl); + let [req1, data1] = await channelOpenPromise(chan1); + + Assert.greater(data1.length, 0, "Should receive content before eviction"); + + // Check if DCB compression was used (should be true with cached dictionary) + let usedDCB1 = verifyDCBResponse( + req1.QueryInterface(Ci.nsIHttpChannel), + data1, + dict + ); + Assert.ok(usedDCB1, "DCB compression should be used"); + + // Step 3: Evict the dictionary from cache + dump("**** Step 3: Evicting dictionary from cache\n"); + + // Evict the dictionary cache entry + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); + // Force cache sync to ensure everything is written + await new Promise(resolve => { + syncWithCacheIOThread(resolve, true); + }); + + dump("**** Step 3.5: verify no longer cache\n"); + // Verify dictionary is no longer cached + await new Promise(resolve => { + verifyDictionaryStored(dictUrl, false, () => { + resolve(); + }); + }); + + // Step 4: Test that compression no longer works after eviction + dump("**** Step 4: Testing DCB compression after dictionary eviction\n"); + + let chan2 = makeChan(contentUrl); + let [req2, data2] = await channelOpenPromise(chan2); + + Assert.greater( + data2.length, + 0, + "Should still receive content after eviction" + ); + // Check if DCB compression was used (should be false without cached dictionary) + Assert.ok( + !verifyDCBResponse(req2.QueryInterface(Ci.nsIHttpChannel), data2, dict2), + "DCB compression should not be used without dictionary" + ); + + // XXX We can only check this if we actually brotli-compress the data + // Content should still be delivered in both cases, just not compressed in the second case + //Assert.equal(data1.length, data2.length, + // "Content length should be the same whether compressed or not (in our test simulation)"); + + dump("**** Cache eviction test completed successfully\n"); +}); + +// Test HTTP redirect (302) with dictionary-compressed content +add_task(async function test_dcb_with_http_redirect() { + await setupDicts(); + + dump("**** Testing HTTP redirect (302) with dictionary-compressed content\n"); + + let dict = DCB_TEST_DICTIONARIES.html_common; + let content = DCB_TEST_CONTENT.html_page; + await registerDCBEndpoint(server, "/test.html", dict, content, true); + let originalPath = "/redirect/original"; + let finalPath = "/test.html"; + let originalUrl = `${server.origin()}${originalPath}`; + let finalUrl = `${server.origin()}${finalPath}`; + + // Step 1: Set up redirect handler that returns 302 to final URL + let redirectFunc = ` + let finalPath = "${finalPath}"; + + // Log the request for analysis + global.requestLog[global.requestLog.length] = { + path: "${originalPath}", + method: request.method, + redirectTo: finalPath, + hasAvailableDict: !!request.headers['available-dictionary'], + availableDict: request.headers['available-dictionary'] || null + }; + + response.writeHead(302, { + "Location": finalPath, + "Cache-Control": "no-cache" + }); + response.end("Redirecting..."); + `; + let redirectHandler = new Function("request", "response", redirectFunc); + await server.registerPathHandler(originalPath, redirectHandler); + + // Step 2: Set up final endpoint with DCB compression capability + await registerDCBEndpoint(server, finalPath, dict, content, true); + + // Clear request log before testing + requestLog = []; + await sync_to_server(); + + // Step 3: Request the original URL that redirects to potentially DCB-compressed content + let chan = makeChan(originalUrl); + let [req, data] = await channelOpenPromise(chan); + + // Step 4: Verify redirect worked correctly + let finalUri = req.QueryInterface(Ci.nsIHttpChannel).URI.spec; + Assert.equal( + finalUri, + finalUrl, + "Final URI should match the redirected URL after 302 redirect" + ); + + // Verify we received some content + Assert.greater(data.length, 0, "Should receive content after redirect"); + + // Step 5: Check request log to verify both requests were logged + await sync_from_server(); + + // Should have two entries: redirect request and final request + let redirectEntry = requestLog.find(entry => entry.path === originalPath); + let finalEntry = requestLog.find(entry => entry.path === finalPath); + + Assert.ok(redirectEntry, "Redirect request should be logged"); + Assert.ok(finalEntry, "Final request should be logged"); + + // Step 6: Verify Available-Dictionary header handling + // Note: The redirect request may or may not have Available-Dictionary header depending on implementation + // The important thing is that the final request has it + if (redirectEntry.hasAvailableDict) { + dump(`**** Redirect request includes Available-Dictionary header\n`); + } else { + dump( + `**** Redirect request does not include Available-Dictionary header (expected)\n` + ); + } + + // Note: With redirects, Available-Dictionary headers may not be preserved + Assert.ok( + finalEntry.hasAvailableDict, + "Final request includes Available-Dictionary header" + ); + + // Available-Dictionary header should contain the dictionary hash for final request + Assert.ok( + finalEntry.availableDict.includes(dict.hash), + "Final request Available-Dictionary should contain correct dictionary hash" + ); + + // Step 7: Check if DCB compression was applied + Assert.ok( + verifyDCBResponse(req.QueryInterface(Ci.nsIHttpChannel), data, dict), + "DCB compression successfully applied after redirect" + ); +}); + +// Test invalid Use-As-Dictionary headers - missing match parameter +add_task(async function test_use_as_dictionary_invalid_missing_match() { + // Invalid dictionary headers + await server.registerPathHandler( + "/dict/invalid-missing-match", + function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `id="missing-match-dict", type=raw`, + "Cache-Control": "max-age=3600", + }); + response.end("INVALID_MISSING_MATCH_DATA", "binary"); + } + ); + let url = `${server.origin()}/dict/invalid-missing-match`; + let chan = makeChan(url); + let [req, data] = await channelOpenPromise(chan); + // Verify dictionary content matches + Assert.equal( + data, + "INVALID_MISSING_MATCH_DATA", + "Set up missing match dictionary" + ); + + let dict = DCB_TEST_DICTIONARIES.html_common_no_dictionary; + let content = DCB_TEST_CONTENT.html_page; + await registerDCBEndpoint( + server, + "/invalid/missing-match", + dict, + content, + true + ); + + url = `https://localhost:${server.port()}/invalid/missing-match`; + + chan = makeChan(url); + [req, data] = await channelOpenPromise(chan); + + Assert.equal(data, content, "Content received"); + + // Verify invalid header was not processed as dictionary + Assert.ok( + !verifyDCBResponse(req.QueryInterface(Ci.nsIHttpChannel), data, dict), + "DCB compression should not be used when dictionary has no match=" + ); + + // Invalid dictionary should not be processed as dictionary + dump("**** Missing match parameter test complete\n"); +}); + +// Test invalid Use-As-Dictionary headers - empty id parameter +add_task(async function test_use_as_dictionary_invalid_empty_id() { + await server.registerPathHandler( + "/dict/invalid-empty-id", + function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="/invalid/*", id="", type=raw`, + "Cache-Control": "max-age=3600", + }); + response.end("INVALID_EMPTY_ID_DATA", "binary"); + } + ); + let url = `${server.origin()}/dict/invalid-empty-id`; + let chan = makeChan(url); + let [req, data] = await channelOpenPromise(chan); + // Verify dictionary content matches + Assert.equal(data, "INVALID_EMPTY_ID_DATA", "Set up empty id dictionary"); + + let dict = DCB_TEST_DICTIONARIES.html_common_no_dictionary; + let content = DCB_TEST_CONTENT.html_page; + await registerDCBEndpoint(server, "/invalid/empty-id", dict, content, true); + + url = `https://localhost:${server.port()}/invalid/empty-id`; + + chan = makeChan(url); + [req, data] = await channelOpenPromise(chan); + + Assert.equal(data, content, "non-compressed content received"); + + Assert.ok( + !verifyDCBResponse(req.QueryInterface(Ci.nsIHttpChannel), data, dict), + "DCB compression should not be used with dictionary with empty id" + ); + dump("**** Empty id parameter test complete\n"); +}); + +// Test Available-Dictionary request header generation +add_task(async function test_available_dictionary_header_generation() { + let url = `https://localhost:${server.port()}/api/test`; + requestLog = []; + await sync_to_server(); + + // Calculate expected hash for basic dictionary + let expectedHashB64 = await calculateDictionaryHash( + DCB_TEST_DICTIONARIES.api_json.content + ); + + // Setup DCB endpoint for HTML content + let dict = DCB_TEST_DICTIONARIES.html_common; + let content = DCB_TEST_CONTENT.api_response; + await registerDCBEndpoint(server, "/api/test", dict, content, true); + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.equal(data, DCB_TEST_CONTENT.api_response, "Resource content matches"); + + // Check request log to see if Available-Dictionary header was sent + await sync_from_server(); + let logEntry = requestLog.find(entry => entry.path === "/api/test"); + Assert.ok( + logEntry && logEntry.availableDict != null, + "Available-Dictionary header should be present" + ); + if (logEntry && logEntry.availableDict != null) { + // Verify IETF Structured Field Byte-Sequence format + Assert.ok( + logEntry.availableDict.startsWith(":"), + "Available-Dictionary should start with ':' (IETF Structured Field format)" + ); + Assert.ok( + logEntry.availableDict.endsWith(":"), + "Available-Dictionary should end with ':' (IETF Structured Field format)" + ); + + // Verify base64 content + let base64Content = logEntry.availableDict.slice(1, -1); + let base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; + Assert.ok( + base64Regex.test(base64Content), + "Available-Dictionary content should be valid base64" + ); + Assert.equal( + logEntry.availableDict, + ":" + expectedHashB64 + ":", + "Available-Dictionary has the right hash" + ); + } + dump("**** Available-Dictionary generation test complete\n"); +}); + +// Test Available-Dictionary header for specific pattern matching +add_task(async function test_available_dictionary_specific_patterns() { + let url = `https://localhost:${server.port()}/api/v1/test`; + requestLog = []; + await sync_to_server(); + + let dict = DCB_TEST_DICTIONARIES.api_v1; + let content = DCB_TEST_CONTENT.api_v1; + await registerDCBEndpoint(server, "/api/v1/test", dict, content, true); + + let chan = makeChan(url); + await channelOpenPromise(chan); + + // Check for Available-Dictionary header + await sync_from_server(); + let logEntry = requestLog.find(entry => entry.path === "/api/v1/test"); + Assert.ok( + logEntry && logEntry.availableDict != null, + "Available-Dictionary header should be present for /api/v1/*" + ); + + if (logEntry && logEntry.availableDict != null) { + // Should match both /api/v1/* (longer-dict) and /api/* (basic-dict) patterns + // It should always use the longer match, which would be /api/v1/* + Assert.equal( + logEntry.availableDict, + DCB_TEST_DICTIONARIES.api_v1.hash, + "Longer match pattern for a dictionary should be used" + ); + } + dump("**** Specific pattern matching test complete\n"); +}); + +// Test Available-Dictionary header absence for no matching patterns +add_task(async function test_available_dictionary_no_match() { + let url = `https://localhost:${server.port()}/nomatch/test`; + requestLog = []; + await sync_to_server(); + + let dict = DCB_TEST_DICTIONARIES.html_common; + let content = "NO MATCH TEST DATA"; + await registerDCBEndpoint(server, "/nomatch/test", dict, content, true); + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.equal(data, "NO MATCH TEST DATA", "No match content received"); + + // Check that no Available-Dictionary header was sent + await sync_from_server(); + let logEntry = requestLog.find(entry => entry.path === "/nomatch/test"); + Assert.ok(logEntry, "Request should be logged"); + + if (logEntry) { + Assert.equal( + logEntry.availableDict, + "", + "Available-Dictionary should be null for no match" + ); + } + dump("**** No match test complete\n"); +}); + +// Cleanup +add_task(async function cleanup() { + // Clear cache + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); + dump("**** DCB compression tests completed.\n"); +}); diff --git a/netwerk/test/unit/test_dictionary_retrieval.js b/netwerk/test/unit/test_dictionary_retrieval.js @@ -0,0 +1,510 @@ +/** + * Tests for HTTP Compression Dictionary retrieval functionality + * - Dictionary lookup by origin and pattern matching + * - Available-Dictionary header generation and formatting + * - Dictionary cache hit/miss scenarios + * - Dictionary precedence and selection logic + */ + +"use strict"; + +// Load cache helpers +Services.scriptloader.loadSubScript("resource://test/head_cache.js", this); + +const { NodeHTTPSServer } = ChromeUtils.importESModule( + "resource://testing-common/NodeServer.sys.mjs" +); + +// Test dictionaries with different patterns and priorities +const RETRIEVAL_TEST_DICTIONARIES = { + api_v1: { + id: "api-v1-dict", + content: "API_V1_COMMON_DATA", + pattern: "/api/v1/*", + type: "raw", + }, + api_generic: { + id: "api-generic-dict", + content: "API_GENERIC_DATA", + pattern: "/api/*", + type: "raw", + }, + wildcard: { + id: "wildcard-dict", + content: "WILDCARD_DATA", + pattern: "*", + type: "raw", + }, + js_files: { + id: "js-dict", + content: "JS_COMMON_CODE", + pattern: "*.js", + type: "raw", + }, +}; + +let server = null; +let requestLog = []; // Track requests for verification + +async function sync_to_server() { + if (server.processId) { + await server.execute(`global.requestLog = ${JSON.stringify(requestLog)};`); + } else { + dump("Server not running?\n"); + } +} + +async function sync_from_server() { + if (server.processId) { + requestLog = await server.execute(`global.requestLog`); + } else { + dump("Server not running? (from)\n"); + } +} + +add_setup(async function () { + if (!server) { + server = await setupServer(); + } + // Setup baseline dictionaries for compression testing + + // Clear any existing cache + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); +}); + +// Calculate expected SHA-256 hash for dictionary content +async function calculateDictionaryHash(content) { + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(Ci.nsICryptoHash.SHA256); + let bytes = new TextEncoder().encode(content); + hasher.update(bytes, bytes.length); + let hash = hasher.finish(false); + return btoa(hash); // Convert to base64 +} + +// Setup dictionary test server +async function setupServer() { + if (!server) { + server = new NodeHTTPSServer(); + await server.start(); + + registerCleanupFunction(async () => { + try { + await server.stop(); + } catch (e) { + // Ignore server stop errors during cleanup + } + }); + } + return server; +} + +// Create channel for dictionary requests +function makeChan(url) { + let chan = NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, + }).QueryInterface(Ci.nsIHttpChannel); + return chan; +} + +function channelOpenPromise(chan) { + return new Promise(resolve => { + function finish(req, buffer) { + resolve([req, buffer]); + } + chan.asyncOpen(new ChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL)); + }); +} + +// Verify dictionary is stored in cache +function verifyDictionaryStored(url, shouldExist, callback) { + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, callback); +} + +// Setup server endpoint that expects specific dictionary headers +async function registerDictionaryAwareEndpoint( + httpServer, + path, + responseContent +) { + // We have to put all values and functions referenced in the handler into + // this string which will be turned into a function for the handler, because + // NodeHTTPSServer handlers can't access items in the local or global scopes of the + // containing file + let func = ` + // Log the request for analysis + global.requestLog[global.requestLog.length] = { + path: "${path}", + hasAvailableDict: request.headers['available-dictionary'] !== undefined, + availableDict: request.headers['available-dictionary'] || null + }; + + response.writeHead(200, { + "Content-Type": "text/plain", + }); + response.end("${responseContent}", "binary"); +`; + let handler = new Function("request", "response", func); + return httpServer.registerPathHandler(path, handler); +} + +// Setup retrieval test server with dictionaries and resources +async function setupRetrievalTestServer() { + await setupServer(); + + // Dictionary endpoints - store dictionaries with different patterns + await server.registerPathHandler( + "/dict/api-v1", + function (request, response) { + const RETRIEVAL_TEST_DICTIONARIES = { + api_v1: { + id: "api-v1-dict", + content: "API_V1_COMMON_DATA", + pattern: "/api/v1/*", + type: "raw", + }, + }; + let dict = RETRIEVAL_TEST_DICTIONARIES.api_v1; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + await server.registerPathHandler( + "/dict/api-generic", + function (request, response) { + const RETRIEVAL_TEST_DICTIONARIES = { + api_generic: { + id: "api-generic-dict", + content: "API_GENERIC_DATA", + pattern: "/api/*", + type: "raw", + }, + }; + let dict = RETRIEVAL_TEST_DICTIONARIES.api_generic; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + await server.registerPathHandler( + "/dict/wildcard", + function (request, response) { + const RETRIEVAL_TEST_DICTIONARIES = { + wildcard: { + id: "wildcard-dict", + content: "WILDCARD_DATA", + pattern: "*", + type: "raw", + }, + }; + let dict = RETRIEVAL_TEST_DICTIONARIES.wildcard; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + await server.registerPathHandler("/dict/js", function (request, response) { + const RETRIEVAL_TEST_DICTIONARIES = { + js_files: { + id: "js-dict", + content: "JS_COMMON_CODE", + pattern: "*.js", + type: "raw", + }, + }; + let dict = RETRIEVAL_TEST_DICTIONARIES.js_files; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + }); + + // Resource endpoints that should trigger dictionary retrieval + await registerDictionaryAwareEndpoint( + server, + "/api/v1/users", + "API V1 USERS DATA" + ); + await registerDictionaryAwareEndpoint( + server, + "/api/v2/posts", + "API V2 POSTS DATA" + ); + await registerDictionaryAwareEndpoint( + server, + "/api/generic", + "GENERIC API DATA" + ); + await registerDictionaryAwareEndpoint(server, "/web/page", "WEB PAGE DATA"); + await registerDictionaryAwareEndpoint( + server, + "/scripts/app.js", + "JAVASCRIPT CODE" + ); + await registerDictionaryAwareEndpoint( + server, + "/styles/main.css", + "CSS STYLES" + ); + + return server; +} + +// Setup baseline dictionaries for retrieval testing +add_task(async function test_setup_dictionaries() { + await setupRetrievalTestServer(); + + // Clear any existing cache + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); + requestLog = []; + await sync_to_server(); + + // Store all test dictionaries + const dictPaths = [ + "/dict/api-v1", + "/dict/api-generic", + "/dict/wildcard", + "/dict/js", + ]; + + for (let path of dictPaths) { + let url = `https://localhost:${server.port()}${path}`; + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + dump(`**** Dictionary loaded: ${path}, data length: ${data.length}\n`); + + // Verify dictionary was stored + await new Promise(resolve => { + verifyDictionaryStored(url, true, resolve); + }); + } + dump("**** Setup complete\n"); +}); + +// Test basic dictionary lookup and Available-Dictionary header generation +add_task(async function test_basic_dictionary_retrieval() { + let url = `https://localhost:${server.port()}/api/v1/users`; + requestLog = []; + await sync_to_server(); + + // Calculate expected hash for api_v1 dictionary + let expectedHash = await calculateDictionaryHash( + RETRIEVAL_TEST_DICTIONARIES.api_v1.content + ); + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.equal(data, "API V1 USERS DATA", "Resource content matches"); + + // Check request log to see if Available-Dictionary header was sent + await sync_from_server(); + let logEntry = requestLog.find(entry => entry.path === "/api/v1/users"); + Assert.ok(logEntry && logEntry.hasAvailableDict, "Has Available-Dictionary"); + Assert.ok( + logEntry.availableDict.includes(expectedHash), + "Available-Dictionary header should contain expected hash" + ); + dump("**** Basic retrieval test complete\n"); +}); + +// Test URL pattern matching logic for dictionary selection +add_task(async function test_dictionary_pattern_matching() { + const patternMatchTests = [ + { url: "/api/v1/users", expectedPattern: "/api/v1/*", dictKey: "api_v1" }, + { url: "/api/v2/posts", expectedPattern: "/api/*", dictKey: "api_generic" }, + { url: "/api/generic", expectedPattern: "/api/*", dictKey: "api_generic" }, + { url: "/scripts/app.js", expectedPattern: "*.js", dictKey: "js_files" }, + { url: "/web/page", expectedPattern: "*", dictKey: "wildcard" }, // Only wildcard should match + { url: "/styles/main.css", expectedPattern: "*", dictKey: "wildcard" }, + ]; + + requestLog = []; + await sync_to_server(); + + for (let test of patternMatchTests) { + let url = `https://localhost:${server.port()}${test.url}`; + let expectedDict = RETRIEVAL_TEST_DICTIONARIES[test.dictKey]; + let expectedHash = await calculateDictionaryHash(expectedDict.content); + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.greater(data.length, 0, `Resource ${test.url} should have content`); + + // Check request log + await sync_from_server(); + let logEntry = requestLog.find(entry => entry.path === test.url); + Assert.ok( + logEntry && logEntry.hasAvailableDict, + `Available-Dictionary header should be present for ${test.url}` + ); + if (logEntry && logEntry.hasAvailableDict) { + Assert.ok( + logEntry.availableDict.includes(expectedHash), + `Available-Dictionary header should contain expected hash for ${test.url}` + ); + } + } +}); + +// Test dictionary precedence when multiple patterns match +add_task(async function test_dictionary_precedence() { + // Test URL that matches multiple patterns: /api/v1/users + // Should match: "/api/v1/*" (most specific), "/api/*", "*" (wildcard) + // Most specific pattern should take precedence + + let url = `https://localhost:${server.port()}/api/v1/users`; + requestLog = []; + await sync_to_server(); + + let mostSpecificHash = await calculateDictionaryHash( + RETRIEVAL_TEST_DICTIONARIES.api_v1.content + ); + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.equal(data, "API V1 USERS DATA", "Content should match"); + + // Check request log for precedence + await sync_from_server(); + let logEntry = requestLog.find(entry => entry.path === "/api/v1/users"); + Assert.ok( + logEntry && logEntry.hasAvailableDict, + "Available-Dictionary header should be present for precedence test" + ); + if (logEntry && logEntry.hasAvailableDict) { + // The most specific pattern (/api/v1/*) should be included + // Implementation may include multiple matching dictionaries + Assert.ok( + logEntry.availableDict.includes(mostSpecificHash), + "Available-Dictionary header should contain most specific pattern hash" + ); + } +}); + +// Test successful dictionary lookup and usage +add_task(async function test_dictionary_cache_hit() { + let url = `https://localhost:${server.port()}/api/generic`; + requestLog = []; + await sync_to_server(); + + let expectedHash = await calculateDictionaryHash( + RETRIEVAL_TEST_DICTIONARIES.api_generic.content + ); + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.equal(data, "GENERIC API DATA", "Content should match"); + + // Verify dictionary lookup succeeded + await sync_from_server(); + let logEntry = requestLog.find(entry => entry.path === "/api/generic"); + Assert.ok( + logEntry && logEntry.hasAvailableDict, + "Available-Dictionary header should be present for cache hit" + ); + if (logEntry && logEntry.hasAvailableDict) { + Assert.ok( + logEntry.availableDict.includes(expectedHash), + "Available-Dictionary header should contain expected hash for cache hit" + ); + } +}); + +// Test Available-Dictionary header hash format compliance +add_task(async function test_dictionary_hash_format() { + // Test that dictionary hashes follow IETF spec format: :base64hash: + + let testDict = RETRIEVAL_TEST_DICTIONARIES.api_v1; + let calculatedHash = await calculateDictionaryHash(testDict.content); + + // Verify hash is base64 format + Assert.greater(calculatedHash.length, 0, "Hash should not be empty"); + + // Verify base64 pattern (rough check) + let base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/; + Assert.ok(base64Pattern.test(calculatedHash), "Hash should be valid base64"); + + // The hash format should be structured field byte sequence: :base64: + let structuredFieldFormat = `:${calculatedHash}:`; + Assert.ok( + structuredFieldFormat.includes(calculatedHash), + "Hash should follow structured field format" + ); +}); + +// Test retrieval with multiple dictionary matches +add_task(async function test_multiple_dictionary_matches() { + // Create a request that could match multiple dictionaries + let url = `https://localhost:${server.port()}/api/test`; + requestLog = []; + await sync_to_server(); + + await registerDictionaryAwareEndpoint(server, "/api/test", "API TEST DATA"); + + let apiGenericHash = await calculateDictionaryHash( + RETRIEVAL_TEST_DICTIONARIES.api_generic.content + ); + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.equal(data, "API TEST DATA", "Content should match"); + + // Check for multiple dictionary hashes in Available-Dictionary header + await sync_from_server(); + let logEntry = requestLog.find(entry => entry.path === "/api/test"); + Assert.ok( + logEntry && logEntry.hasAvailableDict, + "Available-Dictionary header should be present for multiple matches" + ); + if (logEntry && logEntry.hasAvailableDict) { + // Could match both /api/* and * patterns - verify the longest pattern's hash is present + // (IETF spec says the longest match should be used) + let hasApiGenericHash = logEntry.availableDict.includes(apiGenericHash); + Assert.ok( + hasApiGenericHash, + "Available-Dictionary header should contain at least one expected hash for multiple matches" + ); + } +}); + +// Cleanup +add_task(async function cleanup() { + // Clear cache + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); +}); diff --git a/netwerk/test/unit/test_dictionary_storage.js b/netwerk/test/unit/test_dictionary_storage.js @@ -0,0 +1,610 @@ +/** + * Tests for HTTP Compression Dictionary storage functionality + * - Use-As-Dictionary header parsing and validation + * - Dictionary storage in cache with proper metadata + * - Pattern matching and hash validation + * - Error handling and edge cases + */ + +"use strict"; + +// Load cache helpers +Services.scriptloader.loadSubScript("resource://test/head_cache.js", this); + +const { NodeHTTPSServer } = ChromeUtils.importESModule( + "resource://testing-common/NodeServer.sys.mjs" +); + +// Test data constants +const TEST_DICTIONARIES = { + small: { + id: "test-dict-small", + content: "COMMON_PREFIX_DATA_FOR_COMPRESSION", + pattern: "/api/v1/*", + type: "raw", + }, + large: { + id: "test-dict-large", + content: "A".repeat(1024 * 100), // 100KB dictionary + pattern: "*.html", + type: "raw", + }, + large_url: { + id: "test-dict-large-url", + content: "large URL content", + pattern: "large", + type: "raw", + }, + too_large_url: { + id: "test-dict-too-large-url", + content: "too large URL content", + pattern: "too_large", + type: "raw", + }, +}; + +let server = null; + +add_setup(async function () { + if (!server) { + server = await setupServer(); + } + // Setup baseline dictionaries for compression testing + + // Clear any existing cache + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); +}); + +// Utility function to calculate SHA-256 hash +async function calculateSHA256(data) { + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(Ci.nsICryptoHash.SHA256); + + // Convert string to UTF-8 bytes + let bytes = new TextEncoder().encode(data); + hasher.update(bytes, bytes.length); + return hasher.finish(false); +} + +// Setup dictionary test server +async function setupServer() { + let httpServer = new NodeHTTPSServer(); + await httpServer.start(); + + // Basic dictionary endpoint + await httpServer.registerPathHandler( + "/dict/small", + function (request, response) { + // Test data constants + const TEST_DICTIONARIES = { + small: { + id: "test-dict-small", + content: "COMMON_PREFIX_DATA_FOR_COMPRESSION", + pattern: "/api/v1/*", + type: "raw", + }, + }; + + let dict = TEST_DICTIONARIES.small; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + // Dictionary with expiration + await httpServer.registerPathHandler( + "/dict/expires", + function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="expires/*", id="expires-dict", type=raw`, + "Cache-Control": "max-age=1", + }); + response.end("EXPIRING_DICTIONARY_DATA", "binary"); + } + ); + + // Dictionary with invalid header + await httpServer.registerPathHandler( + "/dict/invalid", + function (request, response) { + global.test = 1; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": "invalid-header-format", + }); + response.end("INVALID_DICTIONARY_DATA", "binary"); + } + ); + + // Large dictionary + await httpServer.registerPathHandler( + "/dict/large", + function (request, response) { + // Test data constants + const TEST_DICTIONARIES = { + large: { + id: "test-dict-large", + content: "A".repeat(1024 * 100), // 100KB dictionary + pattern: "*.html", + type: "raw", + }, + }; + + let dict = TEST_DICTIONARIES.large; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + // Large dictionary URL + await httpServer.registerPathHandler( + "/dict/large/" + "A".repeat(1024 * 20), + function (request, response) { + // Test data constants + const TEST_DICTIONARIES = { + large_url: { + id: "test-dict-large-url", + content: "large URL content", + pattern: "large", + type: "raw", + }, + }; + + let dict = TEST_DICTIONARIES.large_url; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + // Too Large dictionary URL + await httpServer.registerPathHandler( + "/dict/large/" + "B".repeat(1024 * 100), + function (request, response) { + // Test data constants + const TEST_DICTIONARIES = { + too_large_url: { + id: "test-dict-too-large-url", + content: "too large URL content", + pattern: "too_large", + type: "raw", + }, + }; + + let dict = TEST_DICTIONARIES.too_large_url; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, + "Cache-Control": "max-age=3600", + }); + response.end(dict.content, "binary"); + } + ); + + registerCleanupFunction(async () => { + try { + await httpServer.stop(); + } catch (e) { + // Ignore server stop errors during cleanup + } + }); + + return httpServer; +} + +// Verify dictionary is stored in cache +function verifyDictionaryStored(url, shouldExist, callback) { + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, callback); +} + +// Create channel for dictionary requests +function makeChan(url) { + let chan = NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, + }).QueryInterface(Ci.nsIHttpChannel); + return chan; +} + +function channelOpenPromise(chan) { + return new Promise(resolve => { + function finish(req, buffer) { + resolve([req, buffer]); + } + chan.asyncOpen(new ChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL)); + }); +} + +// Test basic dictionary storage with Use-As-Dictionary header +add_task(async function test_basic_dictionary_storage() { + // Clear any existing cache + evict_cache_entries("all"); + + let url = `https://localhost:${server.port()}/dict/small`; + let dict = TEST_DICTIONARIES.small; + + let chan = makeChan(url); + let [req, data] = await channelOpenPromise(chan); + + Assert.equal(data, dict.content, "Dictionary content matches"); + + // Verify Use-As-Dictionary header was processed + try { + let headerValue = req.getResponseHeader("Use-As-Dictionary"); + Assert.ok( + headerValue.includes(`id="${dict.id}"`), + "Header contains correct ID" + ); + Assert.ok( + headerValue.includes(`match="${dict.pattern}"`), + "Header contains correct pattern" + ); + } catch (e) { + Assert.ok(false, "Use-As-Dictionary header should be present"); + } + + // Check that dictionary is stored in cache + await new Promise(resolve => { + verifyDictionaryStored(url, true, resolve); + }); +}); + +// Test Use-As-Dictionary header parsing with various formats +add_task(async function test_dictionary_header_parsing() { + const headerTests = [ + { + header: 'match="*", id="dict1", type=raw', + valid: true, + description: "Basic valid header", + }, + { + header: 'match="/api/*", id="api-dict", type=raw', + valid: true, + description: "Path pattern header", + }, + { + header: 'match="*.js", id="js-dict"', + valid: true, + description: "Header without type (should default to raw)", + }, + { + header: 'id="dict1", type=raw', + valid: false, + description: "Missing match parameter", + }, + { + header: 'match="*"', + valid: false, + description: "Missing id parameter", + }, + { + header: 'match="*", id="", type=raw', + valid: false, + description: "Empty id parameter", + }, + ]; + + let testIndex = 0; + for (let test of headerTests) { + let testPath = `/dict/header-test-${testIndex++}`; + let func = ` + global.testIndex = 0; + let test = ${JSON.stringify(test)}; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": test.header, + }); + // We won't be using this, so it doesn't really matter + response.end("HEADER_TEST_DICT_" + global.testIndex++, "binary"); + `; + let handler = new Function("request", "response", func); + await server.registerPathHandler(testPath, handler); + + let url = `https://localhost:${server.port()}${testPath}`; + let chan = makeChan(url); + await channelOpenPromise(chan); + // XXX test if we have a dictionary entry. Need new APIs to let me test it, + // or we can read dict:<origin> and look for this entry + + // Note: Invalid dictionary headers still create regular cache entries, + // they just aren't processed as dictionaries. So all should exist in cache. + await new Promise(resolve => { + verifyDictionaryStored(url, true, resolve); + }); + } +}); + +// Test dictionary hash calculation and validation +add_task(async function test_dictionary_hash_calculation() { + dump("**** testing hashes\n"); + let url = `https://localhost:${server.port()}/dict/small`; + let dict = TEST_DICTIONARIES.small; + + // Calculate expected hash + let expectedHash = await calculateSHA256(dict.content); + Assert.greater(expectedHash.length, 0, "Hash should be calculated"); + + let chan = makeChan(url); + await channelOpenPromise(chan); + + // Calculate expected hash + let hashCalculatedHash = await calculateSHA256(dict.content); + Assert.greater(hashCalculatedHash.length, 0, "Hash should be calculated"); + + // Check cache entry exists + await new Promise(resolve => { + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + asyncOpenCacheEntry( + url, + "disk", + Ci.nsICacheStorage.OPEN_READONLY, + lci, + function (status, entry) { + Assert.equal(status, Cr.NS_OK, "Cache entry should exist"); + Assert.ok(entry, "Entry should not be null"); + + // Check if entry has dictionary metadata + try { + let metaData = entry.getMetaDataElement("use-as-dictionary"); + Assert.ok(metaData, "Dictionary metadata should exist"); + + // Verify metadata contains hash information + // Note: The exact format may vary based on implementation + Assert.ok( + metaData.includes(dict.id), + "Metadata should contain dictionary ID" + ); + } catch (e) { + // Dictionary metadata might be stored differently + dump(`Dictionary metadata access failed: ${e}\n`); + } + + resolve(); + } + ); + }); +}); + +// Test dictionary expiration handling +add_task(async function test_dictionary_expiration() { + dump("**** testing expiration\n"); + let url = `https://localhost:${server.port()}/dict/expires`; + + // Fetch dictionary with 1-second expiration + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.equal(data, "EXPIRING_DICTIONARY_DATA", "Dictionary content matches"); + + // Note: Testing actual expiration behavior requires waiting and is complex + // For now, just verify the dictionary was fetched + // XXX FIX! +}); + +// Test multiple dictionaries per origin with different patterns +add_task(async function test_multiple_dictionaries_per_origin() { + dump("**** test multiple dictionaries per origin\n"); + // Register multiple dictionary endpoints for same origin + await server.registerPathHandler("/dict/api", function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": 'match="/api/*", id="api-dict", type=raw', + }); + response.end("API_DICTIONARY_DATA", "binary"); + }); + + await server.registerPathHandler("/dict/web", function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": 'match="/web/*", id="web-dict", type=raw', + }); + response.end("WEB_DICTIONARY_DATA", "binary"); + }); + + let apiUrl = `https://localhost:${server.port()}/dict/api`; + let webUrl = `https://localhost:${server.port()}/dict/web`; + + // Fetch both dictionaries + let apiChan = makeChan(apiUrl); + let [, apiData] = await channelOpenPromise(apiChan); + Assert.equal( + apiData, + "API_DICTIONARY_DATA", + "API dictionary content matches" + ); + + let webChan = makeChan(webUrl); + let [, webData] = await channelOpenPromise(webChan); + Assert.equal( + webData, + "WEB_DICTIONARY_DATA", + "Web dictionary content matches" + ); + + // Verify both dictionaries are stored + await new Promise(resolve => { + verifyDictionaryStored(apiUrl, true, () => { + verifyDictionaryStored(webUrl, true, resolve); + }); + }); +}); + +// Test dictionary size limits and validation +add_task(async function test_dictionary_size_limits() { + dump("**** test size limits\n"); + let url = `https://localhost:${server.port()}/dict/large`; + let dict = TEST_DICTIONARIES.large; + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.equal(data, dict.content, "Large dictionary content matches"); + Assert.equal(data.length, dict.content.length, "Dictionary size correct"); + + // Verify large dictionary is stored + await new Promise(resolve => { + verifyDictionaryStored(url, true, resolve); + }); +}); + +// Test error handling with invalid dictionary headers +add_task(async function test_invalid_dictionary_headers() { + dump("**** test error handling\n"); + let url = `https://localhost:${server.port()}/dict/invalid`; + + let chan = makeChan(url); + let [, data] = await channelOpenPromise(chan); + + Assert.equal( + data, + "INVALID_DICTIONARY_DATA", + "Invalid dictionary content received" + ); + + // Invalid dictionary should not be stored as dictionary + // but the regular cache entry should exist + await new Promise(resolve => { + asyncOpenCacheEntry( + url, + "disk", + Ci.nsICacheStorage.OPEN_READONLY, + null, + function (status, entry) { + if (status === Cr.NS_OK && entry) { + // Regular cache entry should exist + // Note: Don't call entry.close() as it doesn't exist on this interface + } + // But it should not be processed as a dictionary + resolve(); + } + ); + }); +}); + +// Test cache integration and persistence +add_task(async function test_dictionary_cache_persistence() { + dump("**** test persistence\n"); + // Force cache sync to ensure everything is written + await new Promise(resolve => { + syncWithCacheIOThread(resolve, true); + }); + + // Get cache statistics before + await new Promise(resolve => { + get_device_entry_count("disk", null, entryCount => { + Assert.greater(entryCount, 0, "Cache should have entries"); + resolve(); + }); + }); + + // Verify our test dictionaries are still present + let smallUrl = `https://localhost:${server.port()}/dict/small`; + let chan = makeChan(smallUrl); + await channelOpenPromise(chan); + + await new Promise(resolve => { + verifyDictionaryStored(smallUrl, true, resolve); + }); +}); + +// Test very long url which should fit in metadata +add_task(async function test_long_dictionary_url() { + // Clear any existing cache + evict_cache_entries("all"); + + let url = + `https://localhost:${server.port()}/dict/large/` + "A".repeat(1024 * 20); + let dict = TEST_DICTIONARIES.large_url; + + let chan = makeChan(url); + let [req, data] = await channelOpenPromise(chan); + + Assert.equal(data, dict.content, "Dictionary content matches"); + + // Check that dictionary is stored in cache + await new Promise(resolve => { + verifyDictionaryStored(url, true, resolve); + }); + + // Verify Use-As-Dictionary header was processed and it's an active dictionary + url = `https://localhost:${server.port()}/large`; + chan = makeChan(url); + [req, data] = await channelOpenPromise(chan); + + try { + let headerValue = req.getRequestHeader("Available-Dictionary"); + Assert.ok(headerValue.includes(`:`), "Header contains a hash"); + } catch (e) { + Assert.ok( + false, + "Available-Dictionary header should be present with long URL for dictionary" + ); + } +}); + +// Test url too long to store in metadata +add_task(async function test_too_long_dictionary_url() { + // Clear any existing cache + evict_cache_entries("all"); + + let url = + `https://localhost:${server.port()}/dict/large/` + "B".repeat(1024 * 100); + let dict = TEST_DICTIONARIES.too_large_url; + + let chan = makeChan(url); + let [req, data] = await channelOpenPromise(chan); + + Assert.equal(data, dict.content, "Dictionary content matches"); + + // Check that dictionary is stored in cache (even if it's not a dictionary) + await new Promise(resolve => { + verifyDictionaryStored(url, true, resolve); + }); + + // Verify Use-As-Dictionary header was NOT processed and active due to 64K limit to metadata + // Since we can't store it on disk, we can't offer it as a dictionary. If we change the + // metadata limit, this will need to change + url = `https://localhost:${server.port()}/too_large`; + chan = makeChan(url); + [req, data] = await channelOpenPromise(chan); + + try { + let headerValue = req.getRequestHeader("Available-Dictionary"); + Assert.ok(false, "Too-long dictionary was offered in Available-Dictionary"); + } catch (e) { + Assert.ok( + true, + "Available-Dictionary header should not be present with a too-long URL for dictionary" + ); + } +}); + +// Cleanup +add_task(async function cleanup() { + // Clear cache + evict_cache_entries("all"); + dump("**** all done\n"); +}); diff --git a/netwerk/test/unit/xpcshell.toml b/netwerk/test/unit/xpcshell.toml @@ -469,6 +469,8 @@ run-sequentially = ["true"] # httpd server ["test_cache2_clear_with_usercontext_oa.js"] run-sequentially = ["true"] # httpd server +["test_cache2_compression_dictionary.js"] + ["test_cache2_nostore.js"] ["test_cache_204_response.js"] @@ -569,6 +571,12 @@ prefs = ["content.cors.use_triggering_principal=true"] # See bug 1982916. ["test_data_protocol.js"] +["test_dictionary_compression_dcb.js"] + +["test_dictionary_retrieval.js"] + +["test_dictionary_storage.js"] + ["test_defaultURI.js"] ["test_dns_by_type_resolve.js"]