tor-browser

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

commit 10b61670450d05feb62452d3e459b15b49dbc1d5
parent 38a6c8cd9ce26478d2a63f0274a9fc33842edbb7
Author: Randell Jesup <rjesup@mozilla.com>
Date:   Sat, 13 Dec 2025 17:35:08 +0000

Bug 2005749: Fix problem with inability to remove dictionaries from origins after a restart r=necko-reviewers,valentin

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

Diffstat:
Mnetwerk/cache2/CacheStorageService.cpp | 10++++++++++
Mnetwerk/cache2/Dictionary.cpp | 17+++++++++--------
Mnetwerk/cache2/nsICacheTesting.idl | 5+++++
Anetwerk/test/unit/test_dictionary_replacement.js | 527+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnetwerk/test/unit/xpcshell.toml | 3+++
5 files changed, 554 insertions(+), 8 deletions(-)

diff --git a/netwerk/cache2/CacheStorageService.cpp b/netwerk/cache2/CacheStorageService.cpp @@ -2458,4 +2458,14 @@ CacheStorageService::Flush(nsIObserver* aObserver) { return thread->Dispatch(r, CacheIOThread::WRITE); } +NS_IMETHODIMP +CacheStorageService::ClearDictionaryCacheMemory() { + LOG(("CacheStorageService::ClearDictionaryCacheMemory")); + RefPtr<DictionaryCache> cache = DictionaryCache::GetInstance(); + if (cache) { + cache->Clear(); + } + return NS_OK; +} + } // namespace mozilla::net diff --git a/netwerk/cache2/Dictionary.cpp b/netwerk/cache2/Dictionary.cpp @@ -728,18 +728,16 @@ NS_IMETHODIMP DictionaryOriginReader::OnCacheEntryAvailable( return NS_OK; } + mOrigin->SetCacheEntry(aCacheEntry); AUTO_PROFILER_FLOW_MARKER("DictionaryOriginReader::VisitMetaData", NETWORK, Flow::FromPointer(this)); bool empty = false; aCacheEntry->GetIsEmpty(&empty); - if (empty) { - // New cache entry, set type - mOrigin->SetCacheEntry(aCacheEntry); - } else { + if (!empty) { // There's no data in the cache entry, just metadata nsCOMPtr<nsICacheEntryMetaDataVisitor> metadata(mOrigin); aCacheEntry->VisitMetaData(metadata); - } + } // else new cache entry // This list is the only thing keeping us alive RefPtr<DictionaryOriginReader> safety(this); @@ -905,9 +903,12 @@ nsresult DictionaryCache::RemoveEntry(nsIURI* aURI, const nsACString& aKey) { } void DictionaryCache::Clear() { - // There may be active Prefetch()es running, note, and active - // fetches using dictionaries. They will stay alive until the - // channels using them go away. + // There may be active Prefetch()es running, note, and active fetches + // using dictionaries. They will stay alive until the channels using + // them go away. However, no new requests will see them. + + // This can be used to make the cache return to the state it has at + // startup, especially useful for tests. mDictionaryCache.Clear(); } diff --git a/netwerk/cache2/nsICacheTesting.idl b/netwerk/cache2/nsICacheTesting.idl @@ -17,4 +17,9 @@ interface nsICacheTesting : nsISupports void suspendCacheIOThread(in uint32_t aLevel); void resumeCacheIOThread(); void flush(in nsIObserver aObserver); + /** + * Clear the in-memory DictionaryCache but keep disk data intact. + * This forces dictionary entries to be reloaded from disk on next access. + */ + void clearDictionaryCacheMemory(); }; diff --git a/netwerk/test/unit/test_dictionary_replacement.js b/netwerk/test/unit/test_dictionary_replacement.js @@ -0,0 +1,527 @@ +/** + * Tests for HTTP Compression Dictionary replacement functionality + * - Verify that when a dictionary resource is reloaded without Use-As-Dictionary, + * the dictionary metadata is properly removed + * - Test that Available-Dictionary header is no longer sent for matching resources + * after dictionary is replaced with non-dictionary content + * + * This tests the fix for the race condition in DictionaryOriginReader::OnCacheEntryAvailable + * where mEntry was not set for existing origins loaded from disk. + */ + +"use strict"; + +// Load cache helpers +Services.scriptloader.loadSubScript("resource://test/head_cache.js", this); + +const { NodeHTTPSServer } = ChromeUtils.importESModule( + "resource://testing-common/NodeServer.sys.mjs" +); + +const DICTIONARY_CONTENT = "DICTIONARY_CONTENT_FOR_COMPRESSION_TEST_DATA"; +const REPLACEMENT_CONTENT = "REPLACED_CONTENT_WITHOUT_DICTIONARY_HEADER"; + +let server = null; + +add_setup(async function () { + Services.prefs.setBoolPref("network.http.dictionaries.enable", true); + + server = new NodeHTTPSServer(); + await server.start(); + + // Clear any existing cache + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); + + registerCleanupFunction(async () => { + try { + await server.stop(); + } catch (e) { + // Ignore server stop errors during cleanup + } + }); +}); + +function makeChan(url, bypassCache = false) { + let chan = NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, + }).QueryInterface(Ci.nsIHttpChannel); + + if (bypassCache) { + chan.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + } + + 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)); + }); +} + +function verifyDictionaryStored(url, shouldExist) { + return new Promise(resolve => { + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, resolve); + }); +} + +function syncCache() { + return new Promise(resolve => { + syncWithCacheIOThread(resolve, true); + }); +} + +// Clear in-memory DictionaryCache and purge cache entries from memory. +// This forces dictionary origin entries to be reloaded from disk on next access, +// triggering DictionaryOriginReader::OnCacheEntryAvailable. +async function clearDictionaryCacheAndPurgeMemory() { + // Clear the DictionaryCache in-memory hashmap + let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting); + testingInterface.clearDictionaryCacheMemory(); + + // Force GC to release references to cache entries. Probably not strictly needed + gc(); +} + +/** + * Test that replacing a dictionary resource with non-dictionary content + * properly removes the dictionary metadata. + * + * Steps: + * 1. Load a resource with Use-As-Dictionary header (creates dictionary entry) + * 2. Verify Available-Dictionary is sent for matching resources + * 3. Force-reload the dictionary resource WITHOUT Use-As-Dictionary + * 4. Verify Available-Dictionary is NO LONGER sent for matching resources + */ +add_task(async function test_dictionary_replacement_removes_metadata() { + // Track Available-Dictionary headers received by server + let receivedAvailableDictionary = null; + + // Register dictionary endpoint that returns dictionary content + await server.registerPathHandler( + "/dict/resource", + function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": 'match="/matching/*", id="test-dict", type=raw', + "Cache-Control": "max-age=3600", + }); + response.end("DICTIONARY_CONTENT_FOR_COMPRESSION_TEST_DATA", "binary"); + } + ); + + // Register matching resource endpoint + await server.registerPathHandler( + "/matching/test", + function (request, response) { + // Store the Available-Dictionary header value in global for later retrieval + global.lastAvailableDictionary = + request.headers["available-dictionary"] || null; + response.writeHead(200, { + "Content-Type": "text/plain", + "Cache-Control": "no-cache", + }); + response.end("CONTENT_THAT_SHOULD_MATCH_DICTIONARY", "binary"); + } + ); + + dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n"); + + let dictUrl = `https://localhost:${server.port()}/dict/resource`; + let chan = makeChan(dictUrl); + let [, data] = await channelOpenPromise(chan); + + Assert.equal(data, DICTIONARY_CONTENT, "Dictionary content should match"); + + // Verify dictionary is stored in cache + await verifyDictionaryStored(dictUrl, true); + + // Sync to ensure everything is written to disk + await syncCache(); + + dump("**** Step 1.5: Clear in-memory caches to force reload from disk\n"); + + // Clear in-memory DictionaryCache and purge cache entries from memory. + // This forces dictionary entries to be reloaded from disk via + // DictionaryOriginReader::OnCacheEntryAvailable, which is the code path + // with the bug we're testing. + await clearDictionaryCacheAndPurgeMemory(); + + dump( + "**** Step 2: Verify Available-Dictionary is sent for matching resource\n" + ); + + let matchingUrl = `https://localhost:${server.port()}/matching/test`; + chan = makeChan(matchingUrl); + await channelOpenPromise(chan); + + // Get the Available-Dictionary value from the server + receivedAvailableDictionary = await server.execute( + "global.lastAvailableDictionary" + ); + + Assert.notStrictEqual( + receivedAvailableDictionary, + null, + "Available-Dictionary header should be sent for matching resource" + ); + Assert.ok( + receivedAvailableDictionary.includes(":"), + "Available-Dictionary should contain a hash" + ); + + dump(`**** Received Available-Dictionary: ${receivedAvailableDictionary}\n`); + + dump( + "**** Step 3: Force-reload dictionary resource WITHOUT Use-As-Dictionary\n" + ); + + // Re-register the dictionary endpoint to return content WITHOUT Use-As-Dictionary + await server.registerPathHandler( + "/dict/resource", + function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Cache-Control": "max-age=3600", + // No Use-As-Dictionary header! + }); + response.end("REPLACED_CONTENT_WITHOUT_DICTIONARY_HEADER", "binary"); + } + ); + + chan = makeChan(dictUrl, true /* bypassCache */); + [, data] = await channelOpenPromise(chan); + + Assert.equal(data, REPLACEMENT_CONTENT, "Replacement content should match"); + + // Sync to ensure cache operations complete + await syncCache(); + + dump("**** Step 4: Verify Available-Dictionary is NOT sent anymore\n"); + + // Reset the server's stored value + await server.execute("global.lastAvailableDictionary = null"); + + // Now request the matching resource again + chan = makeChan(matchingUrl); + await channelOpenPromise(chan); + + receivedAvailableDictionary = await server.execute( + "global.lastAvailableDictionary" + ); + + Assert.equal( + receivedAvailableDictionary, + null, + "Available-Dictionary header should NOT be sent after dictionary is replaced" + ); + + dump("**** Test passed: Dictionary metadata was properly removed\n"); +}); + +/** + * Test the same scenario but with gzip-compressed replacement content. + * This simulates the real-world case where a server might return + * compressed content without Use-As-Dictionary. + */ +add_task(async function test_dictionary_replacement_with_compressed_content() { + dump("**** Clear cache and start fresh\n"); + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); + + // Also clear in-memory DictionaryCache to start fresh + let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting); + testingInterface.clearDictionaryCacheMemory(); + + await syncCache(); + + let receivedAvailableDictionary = null; + + // Register dictionary endpoint + await server.registerPathHandler( + "/dict/compressed", + function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": + 'match="/compressed-match/*", id="compressed-dict", type=raw', + "Cache-Control": "max-age=3600", + }); + response.end("DICTIONARY_FOR_COMPRESSED_TEST", "binary"); + } + ); + + // Register matching resource endpoint + await server.registerPathHandler( + "/compressed-match/test", + function (request, response) { + global.lastCompressedAvailDict = + request.headers["available-dictionary"] || null; + response.writeHead(200, { + "Content-Type": "text/plain", + "Cache-Control": "no-cache", + }); + response.end("MATCHING_CONTENT", "binary"); + } + ); + + dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n"); + + let dictUrl = `https://localhost:${server.port()}/dict/compressed`; + let chan = makeChan(dictUrl); + let [, data] = await channelOpenPromise(chan); + + Assert.equal( + data, + "DICTIONARY_FOR_COMPRESSED_TEST", + "Dictionary content should match" + ); + await verifyDictionaryStored(dictUrl, true); + await syncCache(); + + dump("**** Step 1.5: Clear in-memory caches to force reload from disk\n"); + await clearDictionaryCacheAndPurgeMemory(); + + dump("**** Step 2: Verify Available-Dictionary is sent\n"); + + let matchingUrl = `https://localhost:${server.port()}/compressed-match/test`; + chan = makeChan(matchingUrl); + await channelOpenPromise(chan); + + receivedAvailableDictionary = await server.execute( + "global.lastCompressedAvailDict" + ); + Assert.notStrictEqual( + receivedAvailableDictionary, + null, + "Available-Dictionary should be sent initially" + ); + + dump( + "**** Step 3: Force-reload with gzip-compressed content (no Use-As-Dictionary)\n" + ); + + // Re-register to return gzip-compressed content without Use-As-Dictionary + await server.registerPathHandler( + "/dict/compressed", + function (request, response) { + // Gzip-compressed version of "GZIP_COMPRESSED_REPLACEMENT" + // Using Node.js zlib in the handler + const zlib = require("zlib"); + const compressed = zlib.gzipSync("GZIP_COMPRESSED_REPLACEMENT"); + + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Encoding": "gzip", + "Cache-Control": "max-age=3600", + // No Use-As-Dictionary header! + }); + response.end(compressed); + } + ); + + chan = makeChan(dictUrl, true /* bypassCache */); + [, data] = await channelOpenPromise(chan); + + // Content should be decompressed by the channel + Assert.equal( + data, + "GZIP_COMPRESSED_REPLACEMENT", + "Decompressed replacement content should match" + ); + + dump("**** Step 4: Verify Available-Dictionary is NOT sent anymore\n"); + + await server.execute("global.lastCompressedAvailDict = null"); + chan = makeChan(matchingUrl); + await channelOpenPromise(chan); + + receivedAvailableDictionary = await server.execute( + "global.lastCompressedAvailDict" + ); + + Assert.equal( + receivedAvailableDictionary, + null, + "Available-Dictionary should NOT be sent after dictionary replaced with compressed content" + ); + + dump( + "**** Test passed: Dictionary metadata removed even with compressed replacement\n" + ); +}); + +/** + * Test that multiple sequential replacements work correctly. + * Dictionary -> Non-dictionary -> Dictionary -> Non-dictionary + */ +add_task(async function test_dictionary_multiple_replacements() { + dump("**** Clear cache and start fresh\n"); + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); + + // Also clear in-memory DictionaryCache to start fresh + let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting); + testingInterface.clearDictionaryCacheMemory(); + + await syncCache(); + + let receivedAvailableDictionary = null; + + // Register matching resource endpoint + await server.registerPathHandler( + "/multi-match/test", + function (request, response) { + global.lastMultiAvailDict = + request.headers["available-dictionary"] || null; + response.writeHead(200, { + "Content-Type": "text/plain", + "Cache-Control": "no-cache", + }); + response.end("MULTI_MATCHING_CONTENT", "binary"); + } + ); + + let dictUrl = `https://localhost:${server.port()}/dict/multi`; + let matchingUrl = `https://localhost:${server.port()}/multi-match/test`; + + // === First: Load as dictionary === + dump("**** Load as dictionary (first time)\n"); + await server.registerPathHandler("/dict/multi", function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": + 'match="/multi-match/*", id="multi-dict-1", type=raw', + "Cache-Control": "max-age=3600", + }); + response.end("DICTIONARY_CONTENT_V1", "binary"); + }); + + let chan = makeChan(dictUrl); + await channelOpenPromise(chan); + await syncCache(); + + // Clear in-memory caches to force reload from disk + await clearDictionaryCacheAndPurgeMemory(); + + chan = makeChan(matchingUrl); + await channelOpenPromise(chan); + receivedAvailableDictionary = await server.execute( + "global.lastMultiAvailDict" + ); + Assert.notStrictEqual( + receivedAvailableDictionary, + null, + "Available-Dictionary should be sent (first dictionary)" + ); + + // === Second: Replace with non-dictionary === + dump("**** Replace with non-dictionary\n"); + await server.registerPathHandler("/dict/multi", function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Cache-Control": "max-age=3600", + }); + response.end("NON_DICTIONARY_CONTENT", "binary"); + }); + + chan = makeChan(dictUrl, true); + await channelOpenPromise(chan); + await syncCache(); + await new Promise(resolve => do_timeout(200, resolve)); + + await server.execute("global.lastMultiAvailDict = null"); + chan = makeChan(matchingUrl); + await channelOpenPromise(chan); + receivedAvailableDictionary = await server.execute( + "global.lastMultiAvailDict" + ); + Assert.equal( + receivedAvailableDictionary, + null, + "Available-Dictionary should NOT be sent (after first replacement)" + ); + + // === Third: Load as dictionary again === + dump("**** Load as dictionary (second time)\n"); + await server.registerPathHandler("/dict/multi", function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Use-As-Dictionary": + 'match="/multi-match/*", id="multi-dict-2", type=raw', + "Cache-Control": "max-age=3600", + }); + response.end("DICTIONARY_CONTENT_V2", "binary"); + }); + + chan = makeChan(dictUrl, true); + await channelOpenPromise(chan); + await syncCache(); + + // Clear in-memory caches to force reload from disk + await clearDictionaryCacheAndPurgeMemory(); + + chan = makeChan(matchingUrl); + await channelOpenPromise(chan); + receivedAvailableDictionary = await server.execute( + "global.lastMultiAvailDict" + ); + Assert.notStrictEqual( + receivedAvailableDictionary, + null, + "Available-Dictionary should be sent (second dictionary)" + ); + + // === Fourth: Replace with non-dictionary again === + dump("**** Replace with non-dictionary again\n"); + await server.registerPathHandler("/dict/multi", function (request, response) { + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Cache-Control": "max-age=3600", + }); + response.end("NON_DICTIONARY_CONTENT_V2", "binary"); + }); + + chan = makeChan(dictUrl, true); + await channelOpenPromise(chan); + await syncCache(); + await new Promise(resolve => do_timeout(200, resolve)); + + await server.execute("global.lastMultiAvailDict = null"); + chan = makeChan(matchingUrl); + await channelOpenPromise(chan); + receivedAvailableDictionary = await server.execute( + "global.lastMultiAvailDict" + ); + Assert.equal( + receivedAvailableDictionary, + null, + "Available-Dictionary should NOT be sent (after second replacement)" + ); + + dump("**** Test passed: Multiple replacements work correctly\n"); +}); + +// Cleanup +add_task(async function cleanup() { + let lci = Services.loadContextInfo.custom(false, { + partitionKey: `(https,localhost)`, + }); + evict_cache_entries("all", lci); + dump("**** All dictionary replacement tests completed\n"); +}); diff --git a/netwerk/test/unit/xpcshell.toml b/netwerk/test/unit/xpcshell.toml @@ -598,6 +598,9 @@ prefs = ["content.cors.use_triggering_principal=true"] # See bug 1982916. ["test_dictionary_compression_dcb.js"] run-sequentially = ["true"] +["test_dictionary_replacement.js"] +run-sequentially = ["true"] + ["test_dictionary_retrieval.js"] run-sequentially = ["true"]