tor-browser

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

test_dictionary_storage.js (20538B)


      1 /**
      2 * Tests for HTTP Compression Dictionary storage functionality
      3 * - Use-As-Dictionary header parsing and validation
      4 * - Dictionary storage in cache with proper metadata
      5 * - Pattern matching and hash validation
      6 * - Error handling and edge cases
      7 */
      8 
      9 "use strict";
     10 
     11 // Load cache helpers
     12 Services.scriptloader.loadSubScript("resource://test/head_cache.js", this);
     13 
     14 const { NodeHTTPSServer } = ChromeUtils.importESModule(
     15  "resource://testing-common/NodeServer.sys.mjs"
     16 );
     17 
     18 // Test data constants
     19 const TEST_DICTIONARIES = {
     20  small: {
     21    id: "test-dict-small",
     22    content: "COMMON_PREFIX_DATA_FOR_COMPRESSION",
     23    pattern: "/api/v1/*",
     24    type: "raw",
     25  },
     26  large: {
     27    id: "test-dict-large",
     28    content: "C".repeat(1024 * 100), // 100KB dictionary
     29    pattern: "*.html",
     30    type: "raw",
     31  },
     32  large_url: {
     33    id: "test-dict-large-url",
     34    content: "large URL content",
     35    pattern: "file",
     36    type: "raw",
     37  },
     38  too_large_url: {
     39    id: "test-dict-too-large-url",
     40    content: "too large URL content",
     41    pattern: "too_large",
     42    type: "raw",
     43  },
     44  regexp_group: {
     45    id: "test-regexp-group",
     46    content: "content",
     47    pattern: "api/:version(v[0-9]+)/*",
     48    type: "raw",
     49  },
     50 };
     51 
     52 let server = null;
     53 
     54 add_setup(async function () {
     55  Services.prefs.setBoolPref("network.http.dictionaries.enable", true);
     56  if (!server) {
     57    server = await setupServer();
     58  }
     59  // Setup baseline dictionaries for compression testing
     60 
     61  // Clear any existing cache
     62  let lci = Services.loadContextInfo.custom(false, {
     63    partitionKey: `(https,localhost)`,
     64  });
     65  evict_cache_entries("all", lci);
     66 });
     67 
     68 // Utility function to calculate SHA-256 hash
     69 async function calculateSHA256(data) {
     70  let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
     71    Ci.nsICryptoHash
     72  );
     73  hasher.init(Ci.nsICryptoHash.SHA256);
     74 
     75  // Convert string to UTF-8 bytes
     76  let bytes = new TextEncoder().encode(data);
     77  hasher.update(bytes, bytes.length);
     78  return hasher.finish(false);
     79 }
     80 
     81 // Setup dictionary test server
     82 async function setupServer() {
     83  let httpServer = new NodeHTTPSServer();
     84  await httpServer.start();
     85 
     86  // Basic dictionary endpoint
     87  await httpServer.registerPathHandler(
     88    "/dict/small",
     89    function (request, response) {
     90      // Test data constants
     91      const TEST_DICTIONARIES = {
     92        small: {
     93          id: "test-dict-small",
     94          content: "COMMON_PREFIX_DATA_FOR_COMPRESSION",
     95          pattern: "/api/v1/*",
     96          type: "raw",
     97        },
     98      };
     99 
    100      let dict = TEST_DICTIONARIES.small;
    101      response.writeHead(200, {
    102        "Content-Type": "application/octet-stream",
    103        "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`,
    104        "Cache-Control": "max-age=3600",
    105      });
    106      response.end(dict.content, "binary");
    107    }
    108  );
    109 
    110  // Dictionary with expiration
    111  await httpServer.registerPathHandler(
    112    "/dict/expires",
    113    function (request, response) {
    114      response.writeHead(200, {
    115        "Content-Type": "application/octet-stream",
    116        "Use-As-Dictionary": `match="expires/*", id="expires-dict", type=raw`,
    117        "Cache-Control": "max-age=1",
    118      });
    119      response.end("EXPIRING_DICTIONARY_DATA", "binary");
    120    }
    121  );
    122 
    123  // Dictionary with invalid header
    124  await httpServer.registerPathHandler(
    125    "/dict/invalid",
    126    function (request, response) {
    127      global.test = 1;
    128      response.writeHead(200, {
    129        "Content-Type": "application/octet-stream",
    130        "Use-As-Dictionary": "invalid-header-format",
    131      });
    132      response.end("INVALID_DICTIONARY_DATA", "binary");
    133    }
    134  );
    135 
    136  // Large dictionary
    137  await httpServer.registerPathHandler(
    138    "/dict/large",
    139    function (request, response) {
    140      // Test data constants
    141      const TEST_DICTIONARIES = {
    142        large: {
    143          id: "test-dict-large",
    144          content: "C".repeat(1024 * 100), // 100KB dictionary
    145          pattern: "*.html",
    146          type: "raw",
    147        },
    148      };
    149 
    150      let dict = TEST_DICTIONARIES.large;
    151      response.writeHead(200, {
    152        "Content-Type": "application/octet-stream",
    153        "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`,
    154        "Cache-Control": "max-age=3600",
    155      });
    156      response.end(dict.content, "binary");
    157    }
    158  );
    159 
    160  // Large dictionary URL
    161  await httpServer.registerPathHandler(
    162    "/dict/large/" + "A".repeat(1024 * 20),
    163    function (request, response) {
    164      // Test data constants
    165      const TEST_DICTIONARIES = {
    166        large_url: {
    167          id: "test-dict-large-url",
    168          content: "large URL content",
    169          pattern: "file",
    170          type: "raw",
    171        },
    172      };
    173 
    174      let dict = TEST_DICTIONARIES.large_url;
    175      response.writeHead(200, {
    176        "Content-Type": "application/octet-stream",
    177        "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`,
    178        "Cache-Control": "max-age=3600",
    179      });
    180      response.end(dict.content, "binary");
    181    }
    182  );
    183 
    184  // Too Large dictionary URL
    185  await httpServer.registerPathHandler(
    186    "/dict/large/" + "B".repeat(1024 * 100),
    187    function (request, response) {
    188      // Test data constants
    189      const TEST_DICTIONARIES = {
    190        too_large_url: {
    191          id: "test-dict-too-large-url",
    192          content: "too large URL content",
    193          pattern: "too_large",
    194          type: "raw",
    195        },
    196      };
    197 
    198      let dict = TEST_DICTIONARIES.too_large_url;
    199      response.writeHead(200, {
    200        "Content-Type": "application/octet-stream",
    201        "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`,
    202        "Cache-Control": "max-age=3600",
    203      });
    204      response.end(dict.content, "binary");
    205    }
    206  );
    207 
    208  // pattern with a regexp (should be ignored)
    209  await httpServer.registerPathHandler(
    210    "/api/regexp",
    211    function (request, response) {
    212      // Test data constants
    213      const TEST_DICTIONARIES = {
    214        regexp_group: {
    215          id: "test-regexp-group",
    216          content: "content",
    217          pattern: "/api/:version(v[0-9]+)/*",
    218          type: "raw",
    219        },
    220      };
    221 
    222      let dict = TEST_DICTIONARIES.regexp_group;
    223      response.writeHead(200, {
    224        "Content-Type": "application/octet-stream",
    225        "Use-As-Dictionary": `match="${dict.pattern}", id="${dict.id}", type=${dict.type}`,
    226        "Cache-Control": "max-age=3600",
    227      });
    228      response.end(dict.content, "binary");
    229    }
    230  );
    231 
    232  registerCleanupFunction(async () => {
    233    try {
    234      await httpServer.stop();
    235    } catch (e) {
    236      // Ignore server stop errors during cleanup
    237    }
    238  });
    239 
    240  return httpServer;
    241 }
    242 
    243 // Verify dictionary is stored in cache
    244 function verifyDictionaryStored(url, shouldExist, callback) {
    245  let lci = Services.loadContextInfo.custom(false, {
    246    partitionKey: `(https,localhost)`,
    247  });
    248  asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, callback);
    249 }
    250 
    251 // Create channel for dictionary requests
    252 function makeChan(url) {
    253  let chan = NetUtil.newChannel({
    254    uri: url,
    255    loadUsingSystemPrincipal: true,
    256    contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT,
    257  }).QueryInterface(Ci.nsIHttpChannel);
    258  return chan;
    259 }
    260 
    261 function channelOpenPromise(chan) {
    262  return new Promise(resolve => {
    263    function finish(req, buffer) {
    264      resolve([req, buffer]);
    265    }
    266    chan.asyncOpen(new ChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL));
    267  });
    268 }
    269 
    270 // Test basic dictionary storage with Use-As-Dictionary header
    271 add_task(async function test_basic_dictionary_storage() {
    272  // Clear any existing cache
    273  evict_cache_entries("all");
    274 
    275  let url = `https://localhost:${server.port()}/dict/small`;
    276  let dict = TEST_DICTIONARIES.small;
    277 
    278  let chan = makeChan(url);
    279  let [req, data] = await channelOpenPromise(chan);
    280 
    281  Assert.equal(data, dict.content, "Dictionary content matches");
    282 
    283  // Verify Use-As-Dictionary header was processed
    284  try {
    285    let headerValue = req.getResponseHeader("Use-As-Dictionary");
    286    Assert.ok(
    287      headerValue.includes(`id="${dict.id}"`),
    288      "Header contains correct ID"
    289    );
    290    Assert.ok(
    291      headerValue.includes(`match="${dict.pattern}"`),
    292      "Header contains correct pattern"
    293    );
    294  } catch (e) {
    295    Assert.ok(false, "Use-As-Dictionary header should be present");
    296  }
    297 
    298  // Check that dictionary is stored in cache
    299  await new Promise(resolve => {
    300    verifyDictionaryStored(url, true, resolve);
    301  });
    302 });
    303 
    304 // Test Use-As-Dictionary header parsing with various formats
    305 add_task(async function test_dictionary_header_parsing() {
    306  const headerTests = [
    307    {
    308      header: 'match="*", id="dict1", type=raw',
    309      valid: true,
    310      description: "Basic valid header",
    311    },
    312    {
    313      header: 'match="/api/*", id="api-dict", type=raw',
    314      valid: true,
    315      description: "Path pattern header",
    316    },
    317    {
    318      header: 'match="*.js", id="js-dict"',
    319      valid: true,
    320      description: "Header without type (should default to raw)",
    321    },
    322    {
    323      header: 'id="dict1", type=raw',
    324      valid: false,
    325      description: "Missing match parameter",
    326    },
    327    {
    328      header: 'match="*"',
    329      valid: false,
    330      description: "Missing id parameter",
    331    },
    332    {
    333      header: 'match="*", id="", type=raw',
    334      valid: false,
    335      description: "Empty id parameter",
    336    },
    337  ];
    338 
    339  let testIndex = 0;
    340  for (let test of headerTests) {
    341    let testPath = `/dict/header-test-${testIndex++}`;
    342    let func = `
    343      global.testIndex = 0;
    344      let test = ${JSON.stringify(test)};
    345      response.writeHead(200, {
    346        "Content-Type": "application/octet-stream",
    347        "Use-As-Dictionary": test.header,
    348      });
    349      // We won't be using this, so it doesn't really matter
    350      response.end("HEADER_TEST_DICT_" + global.testIndex++, "binary");
    351    `;
    352    let handler = new Function("request", "response", func);
    353    await server.registerPathHandler(testPath, handler);
    354 
    355    let url = `https://localhost:${server.port()}${testPath}`;
    356    let chan = makeChan(url);
    357    await channelOpenPromise(chan);
    358    // XXX test if we have a dictionary entry.  Need new APIs to let me test it,
    359    // or we can read dict:<origin> and look for this entry
    360 
    361    // Note: Invalid dictionary headers still create regular cache entries,
    362    // they just aren't processed as dictionaries. So all should exist in cache.
    363    await new Promise(resolve => {
    364      verifyDictionaryStored(url, true, resolve);
    365    });
    366  }
    367 });
    368 
    369 // Test dictionary hash calculation and validation
    370 add_task(async function test_dictionary_hash_calculation() {
    371  dump("**** testing hashes\n");
    372  let url = `https://localhost:${server.port()}/dict/small`;
    373  let dict = TEST_DICTIONARIES.small;
    374 
    375  // Calculate expected hash
    376  let expectedHash = await calculateSHA256(dict.content);
    377  Assert.greater(expectedHash.length, 0, "Hash should be calculated");
    378 
    379  let chan = makeChan(url);
    380  await channelOpenPromise(chan);
    381 
    382  // Calculate expected hash
    383  let hashCalculatedHash = await calculateSHA256(dict.content);
    384  Assert.greater(hashCalculatedHash.length, 0, "Hash should be calculated");
    385 
    386  // Check cache entry exists
    387  await new Promise(resolve => {
    388    let lci = Services.loadContextInfo.custom(false, {
    389      partitionKey: `(https,localhost)`,
    390    });
    391    asyncOpenCacheEntry(
    392      url,
    393      "disk",
    394      Ci.nsICacheStorage.OPEN_READONLY,
    395      lci,
    396      function (status, entry) {
    397        Assert.equal(status, Cr.NS_OK, "Cache entry should exist");
    398        Assert.ok(entry, "Entry should not be null");
    399 
    400        // Check if entry has dictionary metadata
    401        try {
    402          let metaData = entry.getMetaDataElement("use-as-dictionary");
    403          Assert.ok(metaData, "Dictionary metadata should exist");
    404 
    405          // Verify metadata contains hash information
    406          // Note: The exact format may vary based on implementation
    407          Assert.ok(
    408            metaData.includes(dict.id),
    409            "Metadata should contain dictionary ID"
    410          );
    411        } catch (e) {
    412          // Dictionary metadata might be stored differently
    413          dump(`Dictionary metadata access failed: ${e}\n`);
    414        }
    415 
    416        resolve();
    417      }
    418    );
    419  });
    420 });
    421 
    422 // Test dictionary expiration handling
    423 add_task(async function test_dictionary_expiration() {
    424  dump("**** testing expiration\n");
    425  let url = `https://localhost:${server.port()}/dict/expires`;
    426 
    427  // Fetch dictionary with 1-second expiration
    428  let chan = makeChan(url);
    429  let [, data] = await channelOpenPromise(chan);
    430 
    431  Assert.equal(data, "EXPIRING_DICTIONARY_DATA", "Dictionary content matches");
    432 
    433  // Note: Testing actual expiration behavior requires waiting and is complex
    434  // For now, just verify the dictionary was fetched
    435  // XXX FIX!
    436 });
    437 
    438 // Test multiple dictionaries per origin with different patterns
    439 add_task(async function test_multiple_dictionaries_per_origin() {
    440  dump("**** test multiple dictionaries per origin\n");
    441  // Register multiple dictionary endpoints for same origin
    442  await server.registerPathHandler("/dict/api", function (request, response) {
    443    response.writeHead(200, {
    444      "Content-Type": "application/octet-stream",
    445      "Use-As-Dictionary": 'match="/api/*", id="api-dict", type=raw',
    446    });
    447    response.end("API_DICTIONARY_DATA", "binary");
    448  });
    449 
    450  await server.registerPathHandler("/dict/web", function (request, response) {
    451    response.writeHead(200, {
    452      "Content-Type": "application/octet-stream",
    453      "Use-As-Dictionary": 'match="/web/*", id="web-dict", type=raw',
    454    });
    455    response.end("WEB_DICTIONARY_DATA", "binary");
    456  });
    457 
    458  let apiUrl = `https://localhost:${server.port()}/dict/api`;
    459  let webUrl = `https://localhost:${server.port()}/dict/web`;
    460 
    461  // Fetch both dictionaries
    462  let apiChan = makeChan(apiUrl);
    463  let [, apiData] = await channelOpenPromise(apiChan);
    464  Assert.equal(
    465    apiData,
    466    "API_DICTIONARY_DATA",
    467    "API dictionary content matches"
    468  );
    469 
    470  let webChan = makeChan(webUrl);
    471  let [, webData] = await channelOpenPromise(webChan);
    472  Assert.equal(
    473    webData,
    474    "WEB_DICTIONARY_DATA",
    475    "Web dictionary content matches"
    476  );
    477 
    478  // Verify both dictionaries are stored
    479  await new Promise(resolve => {
    480    verifyDictionaryStored(apiUrl, true, () => {
    481      verifyDictionaryStored(webUrl, true, resolve);
    482    });
    483  });
    484 });
    485 
    486 // Test dictionary size limits and validation
    487 add_task(async function test_dictionary_size_limits() {
    488  dump("**** test size limits\n");
    489  let url = `https://localhost:${server.port()}/dict/large`;
    490  let dict = TEST_DICTIONARIES.large;
    491 
    492  let chan = makeChan(url);
    493  let [, data] = await channelOpenPromise(chan);
    494 
    495  Assert.equal(data, dict.content, "Large dictionary content matches");
    496  Assert.equal(data.length, dict.content.length, "Dictionary size correct");
    497 
    498  // Verify large dictionary is stored
    499  await new Promise(resolve => {
    500    verifyDictionaryStored(url, true, resolve);
    501  });
    502 });
    503 
    504 // Test error handling with invalid dictionary headers
    505 add_task(async function test_invalid_dictionary_headers() {
    506  dump("**** test error handling\n");
    507  let url = `https://localhost:${server.port()}/dict/invalid`;
    508 
    509  let chan = makeChan(url);
    510  let [, data] = await channelOpenPromise(chan);
    511 
    512  Assert.equal(
    513    data,
    514    "INVALID_DICTIONARY_DATA",
    515    "Invalid dictionary content received"
    516  );
    517 
    518  // Invalid dictionary should not be stored as dictionary
    519  // but the regular cache entry should exist
    520  await new Promise(resolve => {
    521    asyncOpenCacheEntry(
    522      url,
    523      "disk",
    524      Ci.nsICacheStorage.OPEN_READONLY,
    525      null,
    526      function (status, entry) {
    527        if (status === Cr.NS_OK && entry) {
    528          // Regular cache entry should exist
    529          // Note: Don't call entry.close() as it doesn't exist on this interface
    530        }
    531        // But it should not be processed as a dictionary
    532        resolve();
    533      }
    534    );
    535  });
    536 });
    537 
    538 // Test cache integration and persistence
    539 add_task(async function test_dictionary_cache_persistence() {
    540  dump("**** test persistence\n");
    541  // Force cache sync to ensure everything is written
    542  await new Promise(resolve => {
    543    syncWithCacheIOThread(resolve, true);
    544  });
    545 
    546  // Get cache statistics before
    547  await new Promise(resolve => {
    548    get_device_entry_count("disk", null, entryCount => {
    549      Assert.greater(entryCount, 0, "Cache should have entries");
    550      resolve();
    551    });
    552  });
    553 
    554  // Verify our test dictionaries are still present
    555  let smallUrl = `https://localhost:${server.port()}/dict/small`;
    556  let chan = makeChan(smallUrl);
    557  await channelOpenPromise(chan);
    558 
    559  await new Promise(resolve => {
    560    verifyDictionaryStored(smallUrl, true, resolve);
    561  });
    562 });
    563 
    564 // Test very long url which should fit in metadata
    565 add_task(async function test_long_dictionary_url() {
    566  // Clear any existing cache
    567  evict_cache_entries("all");
    568 
    569  let url =
    570    `https://localhost:${server.port()}/dict/large/` + "A".repeat(1024 * 20);
    571  let dict = TEST_DICTIONARIES.large_url;
    572 
    573  let chan = makeChan(url);
    574  let [req, data] = await channelOpenPromise(chan);
    575 
    576  Assert.equal(data, dict.content, "Dictionary content matches");
    577 
    578  // Check that dictionary is stored in cache
    579  await new Promise(resolve => {
    580    verifyDictionaryStored(url, true, resolve);
    581  });
    582 
    583  // Verify Use-As-Dictionary header was processed and it's an active dictionary
    584  url = `https://localhost:${server.port()}/dict/large/file`;
    585  chan = makeChan(url);
    586  [req, data] = await channelOpenPromise(chan);
    587 
    588  try {
    589    let headerValue = req.getRequestHeader("Available-Dictionary");
    590    Assert.ok(headerValue.includes(`:`), "Header contains a hash");
    591  } catch (e) {
    592    Assert.ok(
    593      false,
    594      "Available-Dictionary header should be present with long URL for dictionary"
    595    );
    596  }
    597 });
    598 
    599 // Test url too long to store in metadata
    600 add_task(async function test_too_long_dictionary_url() {
    601  // Clear any existing cache
    602  evict_cache_entries("all");
    603 
    604  let url =
    605    `https://localhost:${server.port()}/dict/large/` + "B".repeat(1024 * 100);
    606  let dict = TEST_DICTIONARIES.too_large_url;
    607 
    608  let chan = makeChan(url);
    609  let [req, data] = await channelOpenPromise(chan);
    610 
    611  Assert.equal(data, dict.content, "Dictionary content matches");
    612 
    613  // Check that dictionary is stored in cache (even if it's not a dictionary)
    614  await new Promise(resolve => {
    615    verifyDictionaryStored(url, true, resolve);
    616  });
    617 
    618  // Verify Use-As-Dictionary header was NOT processed and active due to 64K limit to metadata
    619  // Since we can't store it on disk, we can't offer it as a dictionary.  If we change the
    620  // metadata limit, this will need to change
    621  url = `https://localhost:${server.port()}/too_large`;
    622  chan = makeChan(url);
    623  [req, data] = await channelOpenPromise(chan);
    624 
    625  try {
    626    // we're just looking to see if it throws
    627    // eslint-disable-next-line no-unused-vars
    628    let headerValue = req.getRequestHeader("Available-Dictionary");
    629    Assert.ok(false, "Too-long dictionary was offered in Available-Dictionary");
    630  } catch (e) {
    631    Assert.ok(
    632      true,
    633      "Available-Dictionary header should not be present with a too-long URL for dictionary"
    634    );
    635  }
    636 });
    637 
    638 // Test that regexp groups cause it to not be stored as a dictionary
    639 add_task(async function test_regexp_group() {
    640  // Clear any existing cache
    641  evict_cache_entries("all");
    642 
    643  let url = `https://localhost:${server.port()}/api/regexp`;
    644  let dict = TEST_DICTIONARIES.regexp_group;
    645 
    646  let chan = makeChan(url);
    647  let [req, data] = await channelOpenPromise(chan);
    648 
    649  Assert.equal(data, dict.content, "Dictionary content matches");
    650 
    651  // Check that dictionary is stored in cache (even if it's not a dictionary)
    652  await new Promise(resolve => {
    653    verifyDictionaryStored(url, true, resolve);
    654  });
    655 
    656  // Verify Use-As-Dictionary header was NOT processed and active due to 64K limit to metadata
    657  // Since we can't store it on disk, we can't offer it as a dictionary.  If we change the
    658  // metadata limit, this will need to change
    659  url = `https://localhost:${server.port()}/api/v2/test.js`;
    660  chan = makeChan(url);
    661  [req, data] = await channelOpenPromise(chan);
    662 
    663  try {
    664    // we're just looking to see if it throws
    665    // eslint-disable-next-line no-unused-vars
    666    let headerValue = req.getRequestHeader("Available-Dictionary");
    667    Assert.ok(
    668      false,
    669      "Dictionary with regexp group was offered in Available-Dictionary"
    670    );
    671  } catch (e) {
    672    Assert.ok(
    673      true,
    674      "Available-Dictionary header should not be present for a dictionary with regexp groups"
    675    );
    676  }
    677 });
    678 
    679 // Cleanup
    680 add_task(async function cleanup() {
    681  // Clear cache
    682  evict_cache_entries("all");
    683  dump("**** all done\n");
    684 });