test_dictionary_compression_dcb.js (37175B)
1 /** 2 * Tests for HTTP Compression Dictionary Brotli (dcb) compression functionality 3 * - Dictionary-based Brotli compression and decompression 4 * - Content integrity verification with dcb encoding 5 * - Available-Dictionary header integration for compression 6 * - Error handling for missing/invalid dictionaries 7 * - Compression window size limits and edge cases 8 */ 9 10 "use strict"; 11 12 // Load cache helpers 13 Services.scriptloader.loadSubScript("resource://test/head_cache.js", this); 14 15 const { NodeHTTPSServer } = ChromeUtils.importESModule( 16 "resource://testing-common/NodeServer.sys.mjs" 17 ); 18 19 // Test dictionaries optimized for compression testing 20 // Since we're not actually brotli-encoding, all decodes will yield 15 bytes 21 const DCB_TEST_DICTIONARIES = { 22 html_common: { 23 id: "html-dict", 24 content: 25 '<html><head><title>Common HTML Template</title></head><body><div class="container"><p>', 26 expected_length: 15, 27 pattern: "*.html", 28 type: "raw", 29 }, 30 html_common_no_dictionary: { 31 id: "html-dict", 32 content: 33 '<html><head><title>Common HTML Template</title></head><body><div class="container"><p>', 34 expected_length: 196, 35 pattern: "*.html", 36 type: "raw", 37 }, 38 api_json: { 39 id: "api-dict", 40 content: 41 '{"status":"success","data":{"id":null,"name":"","created_at":"","updated_at":""},"message":"","errors":[]}', 42 expected_length: 15, 43 pattern: "/api/*", 44 type: "raw", 45 }, 46 api_v1: { 47 id: "longer-match-dict", 48 content: 49 '{"status":"success","data":{"id":null,"name":"","created_at":"","updated_at":""},"message":"","errors":[]}', 50 expected_length: 15, 51 pattern: "/api/v1/*", 52 type: "raw", 53 }, 54 js_common: { 55 id: "js-dict", 56 content: 57 "function(){return this;};var=function();const=function();let=function();", 58 expected_length: 15, 59 pattern: "*.js", 60 type: "raw", 61 }, 62 large_dict: { 63 id: "large-dict", 64 content: "REPEATED_PATTERN_".repeat(1000), // ~1.5MB dictionary 65 expected_length: 15, 66 pattern: "/large/*", 67 type: "raw", 68 }, 69 }; 70 71 // Test content designed to compress well with dictionaries 72 const DCB_TEST_CONTENT = { 73 html_page: 74 '<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>', 75 76 api_response: 77 '{"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":[]}', 78 79 api_v1: 80 '{"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":[]}', 81 82 js_code: 83 '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"};}', 84 85 large_content: "REPEATED_PATTERN_DATA_CHUNK_".repeat(50000), // Content that will compress well with large dictionary 86 87 jpeg: "ARBITRARY_DATA_".repeat(1000), 88 }; 89 90 let server = null; 91 let requestLog = []; // Track requests for verification 92 93 // Create channel for dictionary requests 94 function makeChan(url) { 95 let chan = NetUtil.newChannel({ 96 uri: url, 97 loadUsingSystemPrincipal: true, 98 contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, 99 }).QueryInterface(Ci.nsIHttpChannel); 100 return chan; 101 } 102 103 function channelOpenPromise(chan) { 104 return new Promise(resolve => { 105 function finish(req, buffer) { 106 resolve([req, buffer]); 107 } 108 // CL_EXPECT_GZIP is needed if we're transferring compressed data; else it asserts content-length 109 // equals the data length. (We could also not send content-length) 110 chan.asyncOpen( 111 new ChannelListener( 112 finish, 113 null, 114 CL_ALLOW_UNKNOWN_CL | CL_IGNORE_DELAYS | CL_EXPECT_GZIP 115 ) 116 ); 117 }); 118 } 119 120 // Setup DCB test server with dictionaries and compressed content endpoints 121 async function setupDCBTestServer() { 122 let httpServer = new NodeHTTPSServer(); 123 await httpServer.start(); 124 125 // Dictionary endpoints - store dictionaries for later compression use 126 await httpServer.registerPathHandler( 127 "/dict/html", 128 function (request, response) { 129 const DCB_TEST_DICTIONARIES = { 130 html_common: { 131 id: "html-dict", 132 content: 133 '<html><head><title>Common HTML Template</title></head><body><div class="container"><p>', 134 pattern: "*.html", 135 type: "raw", 136 }, 137 }; 138 let dict = DCB_TEST_DICTIONARIES.html_common; 139 response.writeHead(200, { 140 "Content-Type": "application/octet-stream", 141 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 142 "Cache-Control": "max-age=3600", 143 }); 144 response.end(dict.content, "binary"); 145 } 146 ); 147 148 await httpServer.registerPathHandler( 149 "/dict/api", 150 function (request, response) { 151 const DCB_TEST_DICTIONARIES = { 152 api_json: { 153 id: "api-dict", 154 content: 155 '{"status":"success","data":{"id":null,"name":"","created_at":"","updated_at":""},"message":"","errors":[]}', 156 pattern: "/api/*", 157 type: "raw", 158 }, 159 }; 160 let dict = DCB_TEST_DICTIONARIES.api_json; 161 response.writeHead(200, { 162 "Content-Type": "application/octet-stream", 163 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 164 "Cache-Control": "max-age=3600", 165 }); 166 response.end(dict.content, "binary"); 167 } 168 ); 169 170 await httpServer.registerPathHandler( 171 "/dict/js", 172 function (request, response) { 173 const DCB_TEST_DICTIONARIES = { 174 js_common: { 175 id: "js-dict", 176 content: 177 "function(){return this;};var=function();const=function();let=function();", 178 pattern: "*.js", 179 type: "raw", 180 }, 181 }; 182 let dict = DCB_TEST_DICTIONARIES.js_common; 183 response.writeHead(200, { 184 "Content-Type": "application/octet-stream", 185 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 186 "Cache-Control": "max-age=3600", 187 }); 188 response.end(dict.content, "binary"); 189 } 190 ); 191 192 await httpServer.registerPathHandler( 193 "/dict/large", 194 function (request, response) { 195 const DCB_TEST_DICTIONARIES = { 196 large_dict: { 197 id: "large-dict", 198 content: "REPEATED_PATTERN_".repeat(1000), // ~1.5MB dictionary 199 pattern: "/large/*", 200 type: "raw", 201 }, 202 }; 203 let dict = DCB_TEST_DICTIONARIES.large_dict; 204 response.writeHead(200, { 205 "Content-Type": "application/octet-stream", 206 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 207 "Cache-Control": "max-age=3600", 208 }); 209 response.end(dict.content, "binary"); 210 } 211 ); 212 213 // Basic dictionary with valid Use-As-Dictionary header 214 await httpServer.registerPathHandler( 215 "/dict/basic", 216 function (request, response) { 217 const TEST_DICTIONARIES = { 218 basic: { 219 id: "basic-dict", 220 content: "BASIC_DICTIONARY_DATA", 221 pattern: "/api/*", 222 type: "raw", 223 }, 224 }; 225 let dict = TEST_DICTIONARIES.basic; 226 response.writeHead(200, { 227 "Content-Type": "application/octet-stream", 228 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 229 "Cache-Control": "max-age=3600", 230 }); 231 response.end(dict.content, "binary"); 232 } 233 ); 234 235 // Dictionary with longer match value 236 await httpServer.registerPathHandler( 237 "/dict/longer", 238 function (request, response) { 239 const TEST_DICTIONARIES = { 240 specific: { 241 id: "longer-match-dict", 242 content: 243 '{"status":"success","data":{"id":null,"name":"","created_at":"","updated_at":""},"message":"","errors":[]}', 244 pattern: "/api/v1/*", 245 type: "raw", 246 }, 247 }; 248 let dict = TEST_DICTIONARIES.specific; 249 response.writeHead(200, { 250 "Content-Type": "application/octet-stream", 251 "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`, 252 "Cache-Control": "max-age=3600", 253 }); 254 response.end(dict.content, "binary"); 255 } 256 ); 257 258 registerCleanupFunction(async () => { 259 try { 260 await httpServer.stop(); 261 } catch (e) { 262 // Ignore server stop errors during cleanup 263 } 264 }); 265 266 return httpServer; 267 } 268 269 async function sync_to_server() { 270 if (server.processId) { 271 await server.execute(`global.requestLog = ${JSON.stringify(requestLog)};`); 272 } else { 273 dump("Server not running?\n"); 274 } 275 } 276 277 async function sync_from_server() { 278 if (server.processId) { 279 dump(`*** requestLog: ${JSON.stringify(requestLog)}\n`); 280 requestLog = await server.execute(`global.requestLog`); 281 } else { 282 dump("Server not running? (from)\n"); 283 } 284 } 285 286 // Calculate expected SHA-256 hash for dictionary content 287 async function calculateDictionaryHash(content) { 288 const encoded = new TextEncoder().encode(content); 289 const digest = await crypto.subtle.digest("SHA-256", encoded); 290 return btoa(String.fromCharCode(...new Uint8Array(digest))); // base64 291 } 292 293 // Verify dcb decompression result 294 function verifyDCBResponse(channel, data, dictionary) { 295 // XXX verify decoded content once we use real Brotli encoding 296 Assert.equal(data.length, dictionary.expected_length); 297 298 try { 299 // Note: since we remove dcb encoding in the parent process, we can't see 300 // it in Content-Encoding here 301 var contentEncoding; 302 channel.getOriginalResponseHeader("Content-Encoding", { 303 visitHeader: function visitOrg(aName, aValue) { 304 contentEncoding = aValue; 305 }, 306 }); 307 Assert.equal; 308 if (contentEncoding === "dcb") { 309 return true; 310 } 311 } catch (e) { 312 // Content-Encoding header not present or not dcb 313 } 314 return false; 315 } 316 317 // Setup dcb-aware server endpoint 318 async function registerDCBEndpoint( 319 httpServer, 320 path, 321 dictionary, 322 content, 323 shouldCompress = true 324 ) { 325 // We have to put all values and functions referenced in the handler into 326 // this string which will be turned into a function for the handler, because 327 // NodeHTTPSServer handlers can't access items in the local or global scopes of the 328 // containing file 329 let func = ` 330 let path = "${path}"; 331 let dictionary = ${JSON.stringify(dictionary)}; 332 let content = '${content}'; 333 let shouldCompress = ${shouldCompress}; 334 335 let availableDict = ""; 336 let hasDictHeader = false; 337 338 // Get content type based on file path 339 function getContentTypeForPath(path) { 340 if (path.endsWith('.html')) return 'text/html; charset=utf-8'; 341 if (path.endsWith('.js')) return 'application/javascript'; 342 if (path.includes('/api/')) return 'application/json'; 343 return 'text/plain; charset=utf-8'; 344 } 345 346 // Calculate compression ratio 347 function calculateCompressionRatio(original, compressed) { 348 if (typeof original === 'string') original = original.length; 349 if (typeof compressed === 'string') compressed = compressed.length; 350 return original / compressed; 351 } 352 353 // Simulate dcb compression (for server responses) 354 function simulateDCBCompression(content, dictionary) { 355 // Note: Real implementation would use actual Brotli compression 356 // For testing, we simulate with compression markers and realistic size reduction 357 let simulatedCompressedSize = Math.floor(content.length * 0.4); // Simulate 60% savings 358 // This needs to be something that the brotli decoder will correctly read, even though this 359 // will produce the wrong output 360 let compressedData = "\x21\x38\x00\x04COMPRESSED_DATA\x03"; 361 362 return { 363 compressedData: "\xff\x44\x43\x42" + "12345678901234567890123456789012" + compressedData, 364 originalSize: content.length, 365 compressedSize: compressedData.length + 36, 366 compressionRatio: calculateCompressionRatio(content.length, simulatedCompressedSize + 36) 367 }; 368 } 369 370 if (request.headers && request.headers['available-dictionary']) { 371 availableDict = request.headers['available-dictionary']; 372 hasDictHeader = true; 373 } else { 374 shouldCompress = false; 375 } 376 377 // Log the request for analysis 378 global.requestLog[global.requestLog.length] = { 379 path: path, 380 hasAvailableDict: hasDictHeader, 381 availableDict: availableDict, 382 method: request.method 383 }; 384 385 if (shouldCompress && hasDictHeader && availableDict.includes(dictionary.hash)) { 386 // Simulate dcb compression 387 let compressed = simulateDCBCompression(content, dictionary); 388 response.writeHead(200, { 389 "Content-Encoding": "dcb", 390 "Content-Type": getContentTypeForPath(path), 391 "Content-Length": compressed.compressedSize.toString(), 392 }); 393 394 // In a real implementation, this would be actual compressed brotli data 395 // For testing, we simulate the compressed response 396 397 // Note: these aren't real dictionaries; we've prepended a dummy header 398 // to pass the requirements for a Brotli dictionary - 4 byte magic number 399 // plus 32 bytes of hash (which we don't currently check, nor does Brotli). 400 response.end(compressed.compressedData, "binary"); 401 } else { 402 // Serve uncompressed 403 response.writeHead(200, { 404 "Content-Type": getContentTypeForPath(path), 405 "Content-Length": content.length, 406 }); 407 response.end(content, "binary"); 408 } 409 `; 410 let handler = new Function("request", "response", func); 411 return httpServer.registerPathHandler(path, handler); 412 } 413 414 // Verify dictionary is stored in cache (reused from previous tests) 415 function verifyDictionaryStored(url, shouldExist, callback) { 416 let lci = Services.loadContextInfo.custom(false, { 417 partitionKey: `(https,localhost)`, 418 }); 419 asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, callback); 420 } 421 422 async function setupDicts() { 423 requestLog = []; 424 await sync_to_server(); 425 426 // Store all test dictionaries and calculate their hashes 427 const dictPaths = [ 428 "/dict/html", 429 "/dict/api", 430 "/dict/js", 431 "/dict/large", 432 "/dict/longer", 433 ]; 434 const dictKeys = [ 435 "html_common", 436 "api_json", 437 "js_common", 438 "large_dict", 439 "api_v1", 440 ]; 441 442 for (let i = 0; i < dictPaths.length; i++) { 443 let path = dictPaths[i]; 444 let dictKey = dictKeys[i]; 445 let url = `${server.origin()}${path}`; 446 dump( 447 `registering dictionary ${path} for match pattern ${DCB_TEST_DICTIONARIES[dictKey].pattern}\n` 448 ); 449 450 let chan = makeChan(url); 451 let [, data] = await channelOpenPromise(chan); 452 // Calculate and store hash for later use 453 DCB_TEST_DICTIONARIES[dictKey].hash = 454 ":" + 455 (await calculateDictionaryHash(DCB_TEST_DICTIONARIES[dictKey].content)) + 456 ":"; 457 458 // Verify dictionary content matches 459 Assert.equal( 460 data, 461 DCB_TEST_DICTIONARIES[dictKey].content, 462 `Dictionary content matches` 463 ); 464 465 // Verify dictionary was stored 466 await new Promise(resolve => { 467 verifyDictionaryStored(url, true, resolve); 468 }); 469 } 470 471 dump(`**** DCB test setup complete. Dictionaries stored with hashes.\n`); 472 } 473 474 add_setup(async function () { 475 Services.prefs.setBoolPref("network.http.dictionaries.enable", true); 476 if (!server) { 477 server = await setupDCBTestServer(); 478 } 479 // Setup baseline dictionaries for compression testing 480 481 // Clear any existing cache 482 let lci = Services.loadContextInfo.custom(false, { 483 partitionKey: `(https,localhost)`, 484 }); 485 evict_cache_entries("all", lci); 486 487 await setupDicts(); 488 }); 489 490 // Test basic dictionary-compressed Brotli functionality 491 add_task(async function test_basic_dcb_compression() { 492 dump("**** test_basic_dcb_compression\n"); 493 requestLog = []; 494 await sync_to_server(); 495 496 // Setup DCB endpoint for HTML content 497 let dict = DCB_TEST_DICTIONARIES.html_common; 498 let content = DCB_TEST_CONTENT.html_page; 499 await registerDCBEndpoint(server, "/dict/test.html", dict, content, true); 500 501 let url = `${server.origin()}/dict/test.html`; 502 let chan = makeChan(url); 503 let [request, data] = await channelOpenPromise(chan); 504 505 // Check if DCB compression was used 506 let usedDCB = verifyDCBResponse( 507 request.QueryInterface(Ci.nsIHttpChannel), 508 data, 509 dict 510 ); 511 Assert.ok(usedDCB, "DCB compression should be used"); 512 }); 513 514 // Test correct use of URLPattern baseurl 515 add_task(async function test_baseurl() { 516 dump("**** test_baseurl\n"); 517 requestLog = []; 518 await sync_to_server(); 519 520 // Setup DCB endpoint for HTML content 521 let dict = DCB_TEST_DICTIONARIES.html_common; 522 let content = DCB_TEST_CONTENT.html_page; 523 await registerDCBEndpoint(server, "/test.html", dict, content, true); 524 525 let url = `${server.origin()}/test.html`; 526 let chan = makeChan(url); 527 let [request, data] = await channelOpenPromise(chan); 528 529 Assert.greater( 530 data.length, 531 0, 532 "Should still receive content without dictionary in use" 533 ); 534 // Check if DCB compression was used (should be false) 535 Assert.ok( 536 !verifyDCBResponse( 537 request.QueryInterface(Ci.nsIHttpChannel), 538 data, 539 DCB_TEST_DICTIONARIES.html_common_no_dictionary 540 ), 541 "DCB compression should not be used with the wrong path" 542 ); 543 }); 544 545 // Test correct dictionary selection for dcb compression 546 add_task(async function test_dcb_dictionary_selection() { 547 requestLog = []; 548 await sync_to_server(); 549 550 dump("**** Testing DCB dictionary selection\n"); 551 552 // Test specific pattern matching for dictionary selection 553 let htmlDict = DCB_TEST_DICTIONARIES.html_common; 554 let apiDict = DCB_TEST_DICTIONARIES.api_json; 555 556 // Register endpoints that should match different dictionaries 557 await registerDCBEndpoint( 558 server, 559 "/dict/specific-test.html", 560 htmlDict, 561 DCB_TEST_CONTENT.html_page, 562 true 563 ); 564 await registerDCBEndpoint( 565 server, 566 "/api/specific-test", 567 apiDict, 568 DCB_TEST_CONTENT.api_response, 569 true 570 ); 571 572 // Test HTML dictionary selection 573 let htmlUrl = `${server.origin()}/dict/specific-test.html`; 574 let htmlChan = makeChan(htmlUrl); 575 let [, htmlData] = await channelOpenPromise(htmlChan); 576 577 Assert.greater( 578 htmlData.length, 579 0, 580 "HTML dictionary selection test should have content" 581 ); 582 583 // Check if correct dictionary was used 584 await sync_from_server(); 585 let htmlLogEntry = requestLog.find( 586 entry => entry.path === "/dict/specific-test.html" 587 ); 588 Assert.ok( 589 htmlLogEntry && htmlLogEntry.hasAvailableDict, 590 "Dictionary selection test: HTML endpoint received Available-Dictionary header" 591 ); 592 593 // Test API dictionary selection 594 let apiUrl = `${server.origin()}/api/specific-test`; 595 let apiChan = makeChan(apiUrl); 596 let [, apiData] = await channelOpenPromise(apiChan); 597 598 Assert.greater( 599 apiData.length, 600 0, 601 "API dictionary selection test should have content" 602 ); 603 604 // Check if correct dictionary was used 605 await sync_from_server(); 606 let apiLogEntry = requestLog.find( 607 entry => entry.path === "/api/specific-test" 608 ); 609 Assert.ok( 610 apiLogEntry && apiLogEntry.hasAvailableDict, 611 "Dictionary selection test: API endpoint received Available-Dictionary header" 612 ); 613 }); 614 615 // Test behavior when dictionary is missing/unavailable 616 add_task(async function test_dcb_missing_dictionary() { 617 requestLog = []; 618 await sync_to_server(); 619 620 dump("**** Testing DCB missing dictionary\n"); 621 622 // Create a fake dictionary that won't be found 623 let fakeDict = { 624 id: "missing-dict", 625 hash: "fake_hash_that_does_not_exist", 626 content: "This dictionary was not stored", 627 expected_length: DCB_TEST_CONTENT.jpeg.length, 628 }; 629 630 // *.jpeg Doesn't match any of the patterns in DCB_TEST_DICTIONARIES 631 await registerDCBEndpoint( 632 server, 633 "/missing-dict-test.jpeg", 634 fakeDict, 635 DCB_TEST_CONTENT.jpeg, 636 false 637 ); 638 639 let url = `${server.origin()}/missing-dict-test.jpeg`; 640 let chan = makeChan(url); 641 let [request, data] = await channelOpenPromise(chan); 642 643 // Should get uncompressed content when dictionary is missing 644 Assert.greater( 645 data.length, 646 0, 647 "Missing dictionary test should still return content" 648 ); 649 650 // Verify no dcb compression was applied 651 let usedDCB = verifyDCBResponse( 652 request.QueryInterface(Ci.nsIHttpChannel), 653 data, 654 fakeDict 655 ); 656 Assert.ok(!usedDCB, "We should not get DCB encoding for a fake item"); 657 }); 658 659 // Test IETF spec compliance for dcb encoding 660 add_task(async function test_dcb_header_compliance() { 661 requestLog = []; 662 await sync_to_server(); 663 664 dump("**** Testing DCB header compliance\n"); 665 666 let dict = DCB_TEST_DICTIONARIES.api_json; 667 await registerDCBEndpoint( 668 server, 669 "/api/compliance-test", 670 dict, 671 DCB_TEST_CONTENT.api_response, 672 true 673 ); 674 675 let url = `${server.origin()}/api/compliance-test`; 676 let chan = makeChan(url); 677 let [request, data] = await channelOpenPromise(chan); 678 679 Assert.greater(data.length, 0, "IETF compliance test should have content"); 680 681 let httpChannel = request.QueryInterface(Ci.nsIHttpChannel); 682 683 // Verify proper Content-Type preservation 684 try { 685 let contentType = httpChannel.getResponseHeader("Content-Type"); 686 Assert.ok( 687 contentType.includes("application/json"), 688 "Content-Type should be preserved through compression" 689 ); 690 } catch (e) { 691 Assert.ok(false, "Content-Type header should be present"); 692 } 693 694 // Check for proper dcb handling 695 await sync_from_server(); 696 let logEntry = requestLog.find( 697 entry => entry.path === "/api/compliance-test" 698 ); 699 Assert.ok( 700 logEntry && logEntry.hasAvailableDict, 701 "Must have available-dictionary in header compliance" 702 ); 703 // Verify Available-Dictionary follows IETF Structured Field Byte-Sequence format 704 // According to RFC 8941, byte sequences are enclosed in colons: :base64data: 705 let availableDict = logEntry.availableDict; 706 Assert.ok( 707 availableDict.startsWith(":"), 708 "Available-Dictionary should start with ':' (IETF Structured Field Byte-Sequence format)" 709 ); 710 Assert.ok( 711 availableDict.endsWith(":"), 712 "Available-Dictionary should end with ':' (IETF Structured Field Byte-Sequence format)" 713 ); 714 Assert.greater( 715 availableDict.length, 716 2, 717 "Available-Dictionary should contain base64 data between colons" 718 ); 719 720 // Extract the base64 content between the colons 721 let base64Content = availableDict.slice(1, -1); 722 Assert.greater( 723 base64Content.length, 724 0, 725 "Available-Dictionary should have base64 content" 726 ); 727 728 // Basic validation that it looks like base64 (contains valid base64 characters) 729 let base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; 730 Assert.ok( 731 base64Regex.test(base64Content), 732 "Available-Dictionary content should be valid base64" 733 ); 734 735 dump(`**** IETF compliance test: Available-Dictionary = ${availableDict}\n`); 736 }); 737 738 // Test that DCB compression stops working after dictionary cache eviction 739 add_task(async function test_dcb_compression_after_cache_eviction() { 740 requestLog = []; 741 await sync_to_server(); 742 743 dump("**** Testing DCB compression after cache eviction\n"); 744 745 // Use a specific dictionary for this test 746 let dict = DCB_TEST_DICTIONARIES.html_common; 747 let dict2 = DCB_TEST_DICTIONARIES.html_common_no_dictionary; 748 let testContent = DCB_TEST_CONTENT.html_page; 749 let testPath = "/dict/cache-eviction-test.html"; 750 let dictUrl = `${server.origin()}/dict/html`; 751 let contentUrl = `${server.origin()}${testPath}`; 752 753 // Step 1: Ensure dictionary is in cache by fetching it 754 dump("**** Step 1: Loading dictionary into cache\n"); 755 let dictChan = makeChan(dictUrl); 756 let [, dictData] = await channelOpenPromise(dictChan); 757 Assert.equal(dictData, dict.content, "Dictionary loaded successfully"); 758 759 // Verify dictionary is cached 760 await new Promise(resolve => { 761 verifyDictionaryStored(dictUrl, true, () => { 762 resolve(); 763 }); 764 }); 765 766 // Step 2: Set up DCB endpoint and test compression works 767 dump("**** Step 2: Testing DCB compression with cached dictionary\n"); 768 await registerDCBEndpoint(server, testPath, dict, testContent, true); 769 770 // Clear request log before testing 771 requestLog = []; 772 await sync_to_server(); 773 774 let chan1 = makeChan(contentUrl); 775 let [req1, data1] = await channelOpenPromise(chan1); 776 777 Assert.greater(data1.length, 0, "Should receive content before eviction"); 778 779 // Check if DCB compression was used (should be true with cached dictionary) 780 let usedDCB1 = verifyDCBResponse( 781 req1.QueryInterface(Ci.nsIHttpChannel), 782 data1, 783 dict 784 ); 785 Assert.ok(usedDCB1, "DCB compression should be used"); 786 787 // Step 3: Evict the dictionary from cache 788 dump("**** Step 3: Evicting dictionary from cache\n"); 789 790 // Evict the dictionary cache entry 791 let lci = Services.loadContextInfo.custom(false, { 792 partitionKey: `(https,localhost)`, 793 }); 794 evict_cache_entries("all", lci); 795 // Force cache sync to ensure everything is written 796 await new Promise(resolve => { 797 syncWithCacheIOThread(resolve, true); 798 }); 799 800 dump("**** Step 3.5: verify no longer cache\n"); 801 // Verify dictionary is no longer cached 802 await new Promise(resolve => { 803 verifyDictionaryStored(dictUrl, false, () => { 804 resolve(); 805 }); 806 }); 807 808 // Step 4: Test that compression no longer works after eviction 809 dump("**** Step 4: Testing DCB compression after dictionary eviction\n"); 810 811 let chan2 = makeChan(contentUrl); 812 let [req2, data2] = await channelOpenPromise(chan2); 813 814 Assert.greater( 815 data2.length, 816 0, 817 "Should still receive content after eviction" 818 ); 819 // Check if DCB compression was used (should be false without cached dictionary) 820 Assert.ok( 821 !verifyDCBResponse(req2.QueryInterface(Ci.nsIHttpChannel), data2, dict2), 822 "DCB compression should not be used without dictionary" 823 ); 824 825 // XXX We can only check this if we actually brotli-compress the data 826 // Content should still be delivered in both cases, just not compressed in the second case 827 //Assert.equal(data1.length, data2.length, 828 // "Content length should be the same whether compressed or not (in our test simulation)"); 829 830 dump("**** Cache eviction test completed successfully\n"); 831 }); 832 833 // Test HTTP redirect (302) with dictionary-compressed content 834 add_task(async function test_dcb_with_http_redirect() { 835 await setupDicts(); 836 837 dump("**** Testing HTTP redirect (302) with dictionary-compressed content\n"); 838 839 let dict = DCB_TEST_DICTIONARIES.html_common; 840 let content = DCB_TEST_CONTENT.html_page; 841 await registerDCBEndpoint(server, "/dict/test.html", dict, content, true); 842 let originalPath = "/redirect/original"; 843 let finalPath = "/dict/test.html"; 844 let originalUrl = `${server.origin()}${originalPath}`; 845 let finalUrl = `${server.origin()}${finalPath}`; 846 847 // Step 1: Set up redirect handler that returns 302 to final URL 848 let redirectFunc = ` 849 let finalPath = "${finalPath}"; 850 851 // Log the request for analysis 852 global.requestLog[global.requestLog.length] = { 853 path: "${originalPath}", 854 method: request.method, 855 redirectTo: finalPath, 856 hasAvailableDict: !!request.headers['available-dictionary'], 857 availableDict: request.headers['available-dictionary'] || null 858 }; 859 860 response.writeHead(302, { 861 "Location": finalPath, 862 "Cache-Control": "no-cache" 863 }); 864 response.end("Redirecting..."); 865 `; 866 let redirectHandler = new Function("request", "response", redirectFunc); 867 await server.registerPathHandler(originalPath, redirectHandler); 868 869 // Step 2: Set up final endpoint with DCB compression capability 870 await registerDCBEndpoint(server, finalPath, dict, content, true); 871 872 // Clear request log before testing 873 requestLog = []; 874 await sync_to_server(); 875 876 // Step 3: Request the original URL that redirects to potentially DCB-compressed content 877 let chan = makeChan(originalUrl); 878 let [req, data] = await channelOpenPromise(chan); 879 880 // Step 4: Verify redirect worked correctly 881 let finalUri = req.QueryInterface(Ci.nsIHttpChannel).URI.spec; 882 Assert.equal( 883 finalUri, 884 finalUrl, 885 "Final URI should match the redirected URL after 302 redirect" 886 ); 887 888 // Verify we received some content 889 Assert.greater(data.length, 0, "Should receive content after redirect"); 890 891 // Step 5: Check request log to verify both requests were logged 892 await sync_from_server(); 893 894 // Should have two entries: redirect request and final request 895 let redirectEntry = requestLog.find(entry => entry.path === originalPath); 896 let finalEntry = requestLog.find(entry => entry.path === finalPath); 897 898 Assert.ok(redirectEntry, "Redirect request should be logged"); 899 Assert.ok(finalEntry, "Final request should be logged"); 900 901 // Step 6: Verify Available-Dictionary header handling 902 // Note: The redirect request may or may not have Available-Dictionary header depending on implementation 903 // The important thing is that the final request has it 904 if (redirectEntry.hasAvailableDict) { 905 dump(`**** Redirect request includes Available-Dictionary header\n`); 906 } else { 907 dump( 908 `**** Redirect request does not include Available-Dictionary header (expected)\n` 909 ); 910 } 911 912 // Note: With redirects, Available-Dictionary headers may not be preserved 913 Assert.ok( 914 finalEntry.hasAvailableDict, 915 "Final request includes Available-Dictionary header" 916 ); 917 918 // Available-Dictionary header should contain the dictionary hash for final request 919 Assert.ok( 920 finalEntry.availableDict.includes(dict.hash), 921 "Final request Available-Dictionary should contain correct dictionary hash" 922 ); 923 924 // Step 7: Check if DCB compression was applied 925 Assert.ok( 926 verifyDCBResponse(req.QueryInterface(Ci.nsIHttpChannel), data, dict), 927 "DCB compression successfully applied after redirect" 928 ); 929 }); 930 931 // Test invalid Use-As-Dictionary headers - missing match parameter 932 add_task(async function test_use_as_dictionary_invalid_missing_match() { 933 // Invalid dictionary headers 934 await server.registerPathHandler( 935 "/dict/invalid-missing-match", 936 function (request, response) { 937 response.writeHead(200, { 938 "Content-Type": "application/octet-stream", 939 "Use-As-Dictionary": `id="missing-match-dict", type=raw`, 940 "Cache-Control": "max-age=3600", 941 }); 942 response.end("INVALID_MISSING_MATCH_DATA", "binary"); 943 } 944 ); 945 let url = `${server.origin()}/dict/invalid-missing-match`; 946 let chan = makeChan(url); 947 let [req, data] = await channelOpenPromise(chan); 948 // Verify dictionary content matches 949 Assert.equal( 950 data, 951 "INVALID_MISSING_MATCH_DATA", 952 "Set up missing match dictionary" 953 ); 954 955 let dict = DCB_TEST_DICTIONARIES.html_common_no_dictionary; 956 let content = DCB_TEST_CONTENT.html_page; 957 await registerDCBEndpoint( 958 server, 959 "/invalid/missing-match", 960 dict, 961 content, 962 true 963 ); 964 965 url = `https://localhost:${server.port()}/invalid/missing-match`; 966 967 chan = makeChan(url); 968 [req, data] = await channelOpenPromise(chan); 969 970 Assert.equal(data, content, "Content received"); 971 972 // Verify invalid header was not processed as dictionary 973 Assert.ok( 974 !verifyDCBResponse(req.QueryInterface(Ci.nsIHttpChannel), data, dict), 975 "DCB compression should not be used when dictionary has no match=" 976 ); 977 978 // Invalid dictionary should not be processed as dictionary 979 dump("**** Missing match parameter test complete\n"); 980 }); 981 982 // Test invalid Use-As-Dictionary headers - empty id parameter 983 add_task(async function test_use_as_dictionary_invalid_empty_id() { 984 await server.registerPathHandler( 985 "/dict/invalid-empty-id", 986 function (request, response) { 987 response.writeHead(200, { 988 "Content-Type": "application/octet-stream", 989 "Use-As-Dictionary": `match="/invalid/*", id="", type=raw`, 990 "Cache-Control": "max-age=3600", 991 }); 992 response.end("INVALID_EMPTY_ID_DATA", "binary"); 993 } 994 ); 995 let url = `${server.origin()}/dict/invalid-empty-id`; 996 let chan = makeChan(url); 997 let [req, data] = await channelOpenPromise(chan); 998 // Verify dictionary content matches 999 Assert.equal(data, "INVALID_EMPTY_ID_DATA", "Set up empty id dictionary"); 1000 1001 let dict = DCB_TEST_DICTIONARIES.html_common_no_dictionary; 1002 let content = DCB_TEST_CONTENT.html_page; 1003 await registerDCBEndpoint(server, "/invalid/empty-id", dict, content, true); 1004 1005 url = `https://localhost:${server.port()}/invalid/empty-id`; 1006 1007 chan = makeChan(url); 1008 [req, data] = await channelOpenPromise(chan); 1009 1010 Assert.equal(data, content, "non-compressed content received"); 1011 1012 Assert.ok( 1013 !verifyDCBResponse(req.QueryInterface(Ci.nsIHttpChannel), data, dict), 1014 "DCB compression should not be used with dictionary with empty id" 1015 ); 1016 dump("**** Empty id parameter test complete\n"); 1017 }); 1018 1019 // Test Available-Dictionary request header generation 1020 add_task(async function test_available_dictionary_header_generation() { 1021 let url = `https://localhost:${server.port()}/api/test`; 1022 requestLog = []; 1023 await sync_to_server(); 1024 1025 // Calculate expected hash for basic dictionary 1026 let expectedHashB64 = await calculateDictionaryHash( 1027 DCB_TEST_DICTIONARIES.api_json.content 1028 ); 1029 1030 // Setup DCB endpoint for HTML content 1031 let dict = DCB_TEST_DICTIONARIES.html_common; 1032 let content = DCB_TEST_CONTENT.api_response; 1033 await registerDCBEndpoint(server, "/api/test", dict, content, true); 1034 1035 let chan = makeChan(url); 1036 let [, data] = await channelOpenPromise(chan); 1037 1038 Assert.equal(data, DCB_TEST_CONTENT.api_response, "Resource content matches"); 1039 1040 // Check request log to see if Available-Dictionary header was sent 1041 await sync_from_server(); 1042 let logEntry = requestLog.find(entry => entry.path === "/api/test"); 1043 Assert.ok( 1044 logEntry && logEntry.availableDict != null, 1045 "Available-Dictionary header should be present" 1046 ); 1047 if (logEntry && logEntry.availableDict != null) { 1048 // Verify IETF Structured Field Byte-Sequence format 1049 Assert.ok( 1050 logEntry.availableDict.startsWith(":"), 1051 "Available-Dictionary should start with ':' (IETF Structured Field format)" 1052 ); 1053 Assert.ok( 1054 logEntry.availableDict.endsWith(":"), 1055 "Available-Dictionary should end with ':' (IETF Structured Field format)" 1056 ); 1057 1058 // Verify base64 content 1059 let base64Content = logEntry.availableDict.slice(1, -1); 1060 let base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; 1061 Assert.ok( 1062 base64Regex.test(base64Content), 1063 "Available-Dictionary content should be valid base64" 1064 ); 1065 Assert.equal( 1066 logEntry.availableDict, 1067 ":" + expectedHashB64 + ":", 1068 "Available-Dictionary has the right hash" 1069 ); 1070 } 1071 dump("**** Available-Dictionary generation test complete\n"); 1072 }); 1073 1074 // Test Available-Dictionary header for specific pattern matching 1075 add_task(async function test_available_dictionary_specific_patterns() { 1076 let url = `https://localhost:${server.port()}/api/v1/test`; 1077 requestLog = []; 1078 await sync_to_server(); 1079 1080 let dict = DCB_TEST_DICTIONARIES.api_v1; 1081 let content = DCB_TEST_CONTENT.api_v1; 1082 await registerDCBEndpoint(server, "/api/v1/test", dict, content, true); 1083 1084 let chan = makeChan(url); 1085 await channelOpenPromise(chan); 1086 1087 // Check for Available-Dictionary header 1088 await sync_from_server(); 1089 let logEntry = requestLog.find(entry => entry.path === "/api/v1/test"); 1090 Assert.ok( 1091 logEntry && logEntry.availableDict != null, 1092 "Available-Dictionary header should be present for /api/v1/*" 1093 ); 1094 1095 if (logEntry && logEntry.availableDict != null) { 1096 // Should match both /api/v1/* (longer-dict) and /api/* (basic-dict) patterns 1097 // It should always use the longer match, which would be /api/v1/* 1098 Assert.equal( 1099 logEntry.availableDict, 1100 DCB_TEST_DICTIONARIES.api_v1.hash, 1101 "Longer match pattern for a dictionary should be used" 1102 ); 1103 } 1104 dump("**** Specific pattern matching test complete\n"); 1105 }); 1106 1107 // Test Available-Dictionary header absence for no matching patterns 1108 add_task(async function test_available_dictionary_no_match() { 1109 let url = `https://localhost:${server.port()}/nomatch/test`; 1110 requestLog = []; 1111 await sync_to_server(); 1112 1113 let dict = DCB_TEST_DICTIONARIES.html_common; 1114 let content = "NO MATCH TEST DATA"; 1115 await registerDCBEndpoint(server, "/nomatch/test", dict, content, true); 1116 1117 let chan = makeChan(url); 1118 let [, data] = await channelOpenPromise(chan); 1119 1120 Assert.equal(data, "NO MATCH TEST DATA", "No match content received"); 1121 1122 // Check that no Available-Dictionary header was sent 1123 await sync_from_server(); 1124 let logEntry = requestLog.find(entry => entry.path === "/nomatch/test"); 1125 Assert.ok(logEntry, "Request should be logged"); 1126 1127 if (logEntry) { 1128 Assert.equal( 1129 logEntry.availableDict, 1130 "", 1131 "Available-Dictionary should be null for no match" 1132 ); 1133 } 1134 dump("**** No match test complete\n"); 1135 }); 1136 1137 // Cleanup 1138 add_task(async function cleanup() { 1139 // Clear cache 1140 let lci = Services.loadContextInfo.custom(false, { 1141 partitionKey: `(https,localhost)`, 1142 }); 1143 evict_cache_entries("all", lci); 1144 dump("**** DCB compression tests completed.\n"); 1145 });