commit fef96f00d667e39e0160298d7fcd0cda840e98da
parent dd77492425eb3746d216d214b97cc0be6dc08844
Author: Randell Jesup <rjesup@mozilla.com>
Date: Wed, 1 Oct 2025 18:46:02 +0000
Bug 1918741: Add unit tests for Compression Dictionaries r=necko-reviewers,kershaw,valentin
Differential Revision: https://phabricator.services.mozilla.com/D263730
Diffstat:
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
@@ -468,6 +468,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"]
@@ -568,6 +570,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"]