/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ // This test ensures that the nsIUrlClassifierHashCompleter works as expected // and simulates an HTTP server to provide completions. // // In order to test completions, each group of completions sent as one request // to the HTTP server is called a completion set. There is currently not // support for multiple requests being sent to the server at once, in this test. // This tests makes a request for each element of |completionSets|, waits for // a response and then moves to the next element. // Each element of |completionSets| is an array of completions, and each // completion is an object with the properties: // hash: complete hash for the completion. Automatically right-padded // to be COMPLETE_LENGTH. // expectCompletion: boolean indicating whether the server should respond // with a full hash. // forceServerError: boolean indicating whether the server should respond // with a 503. // table: name of the table that the hash corresponds to. Only needs to be set // if a completion is expected. // chunkId: positive integer corresponding to the chunk that the hash belongs // to. Only needs to be set if a completion is expected. // multipleCompletions: boolean indicating whether the server should respond // with more than one full hash. If this is set to true // then |expectCompletion| must also be set to true and // |hash| must have the same prefix as all |completions|. // completions: an array of completions (objects with a hash, table and // chunkId property as described above). This property is only // used when |multipleCompletions| is set to true. // Basic prefixes with 2/3 completions. var basicCompletionSet = [ { hash: "abcdefgh", expectCompletion: true, table: "test", chunkId: 1234, }, { hash: "1234", expectCompletion: false, }, { hash: "\u0000\u0000\u000012312", expectCompletion: true, table: "test", chunkId: 1234, }, ]; // 3 prefixes with 0 completions to test HashCompleter handling a 204 status. var falseCompletionSet = [ { hash: "1234", expectCompletion: false, }, { hash: "", expectCompletion: false, }, { hash: "abc", expectCompletion: false, }, ]; // The current implementation (as of Mar 2011) sometimes sends duplicate // entries to HashCompleter and even expects responses for duplicated entries. var dupedCompletionSet = [ { hash: "1234", expectCompletion: true, table: "test", chunkId: 1, }, { hash: "5678", expectCompletion: false, table: "test2", chunkId: 2, }, { hash: "1234", expectCompletion: true, table: "test", chunkId: 1, }, { hash: "5678", expectCompletion: false, table: "test2", chunkId: 2, }, ]; // It is possible for a hash completion request to return with multiple // completions, the HashCompleter should return all of these. var multipleResponsesCompletionSet = [ { hash: "1234", expectCompletion: true, multipleCompletions: true, completions: [ { hash: "123456", table: "test1", chunkId: 3, }, { hash: "123478", table: "test2", chunkId: 4, }, ], }, ]; function buildCompletionRequest(aCompletionSet) { let prefixes = []; let prefixSet = new Set(); aCompletionSet.forEach(s => { let prefix = s.hash.substring(0, 4); if (prefixSet.has(prefix)) { return; } prefixSet.add(prefix); prefixes.push(prefix); }); return 4 + ":" + 4 * prefixes.length + "\n" + prefixes.join(""); } function parseCompletionRequest(aRequest) { // Format: [partial_length]:[num_of_prefix * partial_length]\n[prefixes_data] let tokens = /(\d):(\d+)/.exec(aRequest); if (tokens.length < 3) { dump("Request format error."); return null; } let partialLength = parseInt(tokens[1]); let payloadStart = tokens[1].length + // partial length 1 + // ':' tokens[2].length + // payload length 1; // '\n' let prefixSet = []; for (let i = payloadStart; i < aRequest.length; i += partialLength) { let prefix = aRequest.substr(i, partialLength); if (prefix.length !== partialLength) { dump("Header info not correct: " + aRequest.substr(0, payloadStart)); return null; } prefixSet.push(prefix); } prefixSet.sort(); return prefixSet; } // Compare the requests in string format. function compareCompletionRequest(aRequest1, aRequest2) { let prefixSet1 = parseCompletionRequest(aRequest1); let prefixSet2 = parseCompletionRequest(aRequest2); return equal(JSON.stringify(prefixSet1), JSON.stringify(prefixSet2)); } // The fifth completion set is added at runtime by getRandomCompletionSet. // Each completion in the set only has one response and its purpose is to // provide an easy way to test the HashCompleter handling an arbitrarily large // completion set (determined by SIZE_OF_RANDOM_SET). const SIZE_OF_RANDOM_SET = 16; function getRandomCompletionSet(forceServerError) { let completionSet = []; let hashPrefixes = []; let seed = Math.floor(Math.random() * Math.pow(2, 32)); dump("Using seed of " + seed + " for random completion set.\n"); let rand = new LFSRgenerator(seed); for (let i = 0; i < SIZE_OF_RANDOM_SET; i++) { let completion = { expectCompletion: false, forceServerError: false, _finished: false, }; // Generate a random 256 bit hash. First we get a random number and then // convert it to a string. let hash; let prefix; do { hash = ""; let length = 1 + rand.nextNum(5); for (let j = 0; j < length; j++) { hash += String.fromCharCode(rand.nextNum(8)); } prefix = hash.substring(0, 4); } while (hashPrefixes.includes(prefix)); hashPrefixes.push(prefix); completion.hash = hash; if (!forceServerError) { completion.expectCompletion = rand.nextNum(1) == 1; } else { completion.forceServerError = true; } if (completion.expectCompletion) { // Generate a random alpha-numeric string of length start with "test" for the // table name. completion.table = "test" + rand.nextNum(31).toString(36); completion.chunkId = rand.nextNum(16); } completionSet.push(completion); } return completionSet; } var completionSets = [ basicCompletionSet, falseCompletionSet, dupedCompletionSet, multipleResponsesCompletionSet, ]; var currentCompletionSet = -1; var finishedCompletions = 0; const SERVER_PATH = "/hash-completer"; var server; // Completion hashes are automatically right-padded with null chars to have a // length of COMPLETE_LENGTH. // Taken from nsUrlClassifierDBService.h const COMPLETE_LENGTH = 32; var completer = Cc["@mozilla.org/url-classifier/hashcompleter;1"].getService( Ci.nsIUrlClassifierHashCompleter ); var gethashUrl; // Expected highest completion set for which the server sends a response. var expectedMaxServerCompletionSet = 0; var maxServerCompletionSet = 0; function run_test() { // This test case exercises the backoff functionality so we can't leave it disabled. Services.prefs.setBoolPref( "browser.safebrowsing.provider.test.disableBackoff", false ); // Generate a random completion set that return successful responses. completionSets.push(getRandomCompletionSet(false)); // We backoff after receiving an error, so requests shouldn't reach the // server after that. expectedMaxServerCompletionSet = completionSets.length; // Generate some completion sets that return 503s. for (let j = 0; j < 10; ++j) { completionSets.push(getRandomCompletionSet(true)); } // Fix up the completions before running the test. for (let completionSet of completionSets) { for (let completion of completionSet) { // Pad the right of each |hash| so that the length is COMPLETE_LENGTH. if (completion.multipleCompletions) { for (let responseCompletion of completion.completions) { let numChars = COMPLETE_LENGTH - responseCompletion.hash.length; responseCompletion.hash += new Array(numChars + 1).join("\u0000"); } } else { let numChars = COMPLETE_LENGTH - completion.hash.length; completion.hash += new Array(numChars + 1).join("\u0000"); } } } do_test_pending(); server = new HttpServer(); server.registerPathHandler(SERVER_PATH, hashCompleterServer); server.start(-1); const SERVER_PORT = server.identity.primaryPort; gethashUrl = "http://localhost:" + SERVER_PORT + SERVER_PATH; runNextCompletion(); } function runNextCompletion() { // The server relies on currentCompletionSet to send the correct response, so // don't increment it until we start the new set of callbacks. currentCompletionSet++; if (currentCompletionSet >= completionSets.length) { finish(); return; } dump( "Now on completion set index " + currentCompletionSet + ", length " + completionSets[currentCompletionSet].length + "\n" ); // Number of finished completions for this set. finishedCompletions = 0; for (let completion of completionSets[currentCompletionSet]) { completer.complete( completion.hash.substring(0, 4), gethashUrl, "test-phish-shavar", // Could be arbitrary v2 table name. new callback(completion) ); } } function hashCompleterServer(aRequest, aResponse) { let stream = aRequest.bodyInputStream; let wrapperStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( Ci.nsIBinaryInputStream ); wrapperStream.setInputStream(stream); let len = stream.available(); let data = wrapperStream.readBytes(len); // Check if we got the expected completion request. let expectedRequest = buildCompletionRequest( completionSets[currentCompletionSet] ); compareCompletionRequest(data, expectedRequest); // To avoid a response with duplicate hash completions, we keep track of all // completed hash prefixes so far. let completedHashes = []; let responseText = ""; function responseForCompletion(x) { return x.table + ":" + x.chunkId + ":" + x.hash.length + "\n" + x.hash; } // As per the spec, a server should response with a 204 if there are no // full-length hashes that match the prefixes. let httpStatus = 204; for (let completion of completionSets[currentCompletionSet]) { if ( completion.expectCompletion && !completedHashes.includes(completion.hash) ) { completedHashes.push(completion.hash); if (completion.multipleCompletions) { responseText += completion.completions .map(responseForCompletion) .join(""); } else { responseText += responseForCompletion(completion); } } if (completion.forceServerError) { httpStatus = 503; } } dump("Server sending response for " + currentCompletionSet + "\n"); maxServerCompletionSet = currentCompletionSet; if (responseText && httpStatus != 503) { aResponse.write(responseText); } else { aResponse.setStatusLine(null, httpStatus, null); } } function callback(completion) { this._completion = completion; } callback.prototype = { completionV2: function completionV2(hash, table, chunkId, trusted) { Assert.ok(this._completion.expectCompletion); if (this._completion.multipleCompletions) { for (let completion of this._completion.completions) { if (completion.hash == hash) { Assert.equal(JSON.stringify(hash), JSON.stringify(completion.hash)); Assert.equal(table, completion.table); Assert.equal(chunkId, completion.chunkId); completion._completed = true; if (this._completion.completions.every(x => x._completed)) { this._completed = true; } break; } } } else { // Hashes are not actually strings and can contain arbitrary data. Assert.equal(JSON.stringify(hash), JSON.stringify(this._completion.hash)); Assert.equal(table, this._completion.table); Assert.equal(chunkId, this._completion.chunkId); this._completed = true; } }, completionFinished: function completionFinished(status) { finishedCompletions++; Assert.equal(!!this._completion.expectCompletion, !!this._completed); this._completion._finished = true; // currentCompletionSet can mutate before all of the callbacks are complete. if ( currentCompletionSet < completionSets.length && finishedCompletions == completionSets[currentCompletionSet].length ) { runNextCompletion(); } }, }; function finish() { Services.prefs.clearUserPref( "browser.safebrowsing.provider.test.disableBackoff" ); Assert.equal(expectedMaxServerCompletionSet, maxServerCompletionSet); server.stop(function () { do_test_finished(); }); }