summaryrefslogtreecommitdiffstats
path: root/src/js/diff-updater.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/js/diff-updater.js')
-rw-r--r--src/js/diff-updater.js288
1 files changed, 288 insertions, 0 deletions
diff --git a/src/js/diff-updater.js b/src/js/diff-updater.js
new file mode 100644
index 0000000..4e6ece1
--- /dev/null
+++ b/src/js/diff-updater.js
@@ -0,0 +1,288 @@
+/*******************************************************************************
+
+ uBlock Origin - a comprehensive, efficient content blocker
+ Copyright (C) 2014-present Raymond Hill
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see {http://www.gnu.org/licenses/}.
+
+ Home: https://github.com/gorhill/uBlock
+*/
+
+'use strict';
+
+// This module can be dynamically loaded or spun off as a worker.
+
+/******************************************************************************/
+
+const patches = new Map();
+const encoder = new TextEncoder();
+const reFileName = /([^\/]+?)(?:#.+)?$/;
+const EMPTYLINE = '';
+
+/******************************************************************************/
+
+const suffleArray = arr => {
+ const out = arr.slice();
+ for ( let i = 0, n = out.length; i < n; i++ ) {
+ const j = Math.floor(Math.random() * n);
+ if ( j === i ) { continue; }
+ [ out[j], out[i] ] = [ out[i], out[j] ];
+ }
+ return out;
+};
+
+const basename = url => {
+ const match = reFileName.exec(url);
+ return match && match[1] || '';
+};
+
+const resolveURL = (path, url) => {
+ try {
+ return new URL(path, url);
+ }
+ catch(_) {
+ }
+};
+
+const expectedTimeFromPatch = assetDetails => {
+ const match = /(\d+)\.(\d+)\.(\d+)\.(\d+)/.exec(assetDetails.patchPath);
+ if ( match === null ) { return 0; }
+ const date = new Date();
+ date.setUTCFullYear(
+ parseInt(match[1], 10),
+ parseInt(match[2], 10) - 1,
+ parseInt(match[3], 10)
+ );
+ date.setUTCHours(0, parseInt(match[4], 10), 0, 0);
+ return date.getTime() + assetDetails.diffExpires;
+};
+
+function parsePatch(patch) {
+ const patchDetails = new Map();
+ const diffLines = patch.split('\n');
+ let i = 0, n = diffLines.length;
+ while ( i < n ) {
+ const line = diffLines[i++];
+ if ( line.startsWith('diff ') === false ) { continue; }
+ const fields = line.split(/\s+/);
+ const diffBlock = {};
+ for ( let j = 0; j < fields.length; j++ ) {
+ const field = fields[j];
+ const pos = field.indexOf(':');
+ if ( pos === -1 ) { continue; }
+ const name = field.slice(0, pos);
+ if ( name === '' ) { continue; }
+ const value = field.slice(pos+1);
+ switch ( name ) {
+ case 'name':
+ case 'checksum':
+ diffBlock[name] = value;
+ break;
+ case 'lines':
+ diffBlock.lines = parseInt(value, 10);
+ break;
+ default:
+ break;
+ }
+ }
+ if ( diffBlock.name === undefined ) { return; }
+ if ( isNaN(diffBlock.lines) || diffBlock.lines <= 0 ) { return; }
+ if ( diffBlock.checksum === undefined ) { return; }
+ patchDetails.set(diffBlock.name, diffBlock);
+ diffBlock.diff = diffLines.slice(i, i + diffBlock.lines).join('\n');
+ i += diffBlock.lines;
+ }
+ if ( patchDetails.size === 0 ) { return; }
+ return patchDetails;
+}
+
+function applyPatch(text, diff) {
+ // Inspired from (Perl) "sub _patch" at:
+ // https://twiki.org/p/pub/Codev/RcsLite/RcsLite.pm
+ // Apparently authored by John Talintyre in Jan. 2002
+ // https://twiki.org/cgi-bin/view/Codev/RcsLite
+ const lines = text.split('\n');
+ const diffLines = diff.split('\n');
+ let iAdjust = 0;
+ let iDiff = 0, nDiff = diffLines.length;
+ while ( iDiff < nDiff ) {
+ const diffLine = diffLines[iDiff++];
+ if ( diffLine === '' ) { break; }
+ const diffParsed = /^([ad])(\d+) (\d+)$/.exec(diffLine);
+ if ( diffParsed === null ) { return; }
+ const op = diffParsed[1];
+ const iOp = parseInt(diffParsed[2], 10);
+ const nOp = parseInt(diffParsed[3], 10);
+ const iOpAdj = iOp + iAdjust;
+ if ( iOpAdj > lines.length ) { return; }
+ // Delete lines
+ if ( op === 'd' ) {
+ lines.splice(iOpAdj-1, nOp);
+ iAdjust -= nOp;
+ continue;
+ }
+ // Add lines: Don't use splice() to avoid stack limit issues
+ for ( let i = 0; i < nOp; i++ ) {
+ lines.push(EMPTYLINE);
+ }
+ lines.copyWithin(iOpAdj+nOp, iOpAdj);
+ for ( let i = 0; i < nOp; i++ ) {
+ lines[iOpAdj+i] = diffLines[iDiff+i];
+ }
+ iAdjust += nOp;
+ iDiff += nOp;
+ }
+ return lines.join('\n');
+}
+
+function hasPatchDetails(assetDetails) {
+ const { patchPath } = assetDetails;
+ const patchFile = basename(patchPath);
+ return patchFile !== '' && patches.has(patchFile);
+}
+
+/******************************************************************************/
+
+// Async
+
+async function applyPatchAndValidate(assetDetails, diffDetails) {
+ const { text } = assetDetails;
+ const { diff, checksum } = diffDetails;
+ const textAfter = applyPatch(text, diff);
+ if ( typeof textAfter !== 'string' ) {
+ assetDetails.error = 'baddiff';
+ return false;
+ }
+ const crypto = globalThis.crypto;
+ if ( typeof crypto !== 'object' ) {
+ assetDetails.error = 'nocrypto';
+ return false;
+ }
+ const arrayin = encoder.encode(textAfter);
+ const arraybuffer = await crypto.subtle.digest('SHA-1', arrayin);
+ const arrayout = new Uint8Array(arraybuffer);
+ const sha1Full = Array.from(arrayout).map(i =>
+ i.toString(16).padStart(2, '0')
+ ).join('');
+ if ( sha1Full.startsWith(checksum) === false ) {
+ assetDetails.error = `badchecksum: expected ${checksum}, computed ${sha1Full.slice(0, checksum.length)}`;
+ return false;
+ }
+ assetDetails.text = textAfter;
+ return true;
+}
+
+async function fetchPatchDetailsFromCDNs(assetDetails) {
+ const { patchPath, cdnURLs } = assetDetails;
+ if ( Array.isArray(cdnURLs) === false ) { return null; }
+ if ( cdnURLs.length === 0 ) { return null; }
+ for ( const cdnURL of suffleArray(cdnURLs) ) {
+ const patchURL = resolveURL(patchPath, cdnURL);
+ if ( patchURL === undefined ) { continue; }
+ const response = await fetch(patchURL).catch(reason => {
+ console.error(reason, patchURL);
+ });
+ if ( response === undefined ) { continue; }
+ if ( response.status === 404 ) { break; }
+ if ( response.ok !== true ) { continue; }
+ const patchText = await response.text();
+ const patchDetails = parsePatch(patchText);
+ if ( patchURL.hash.length > 1 ) {
+ assetDetails.diffName = patchURL.hash.slice(1);
+ patchURL.hash = '';
+ }
+ return {
+ patchURL: patchURL.href,
+ patchSize: `${(patchText.length / 1000).toFixed(1)} KB`,
+ patchDetails,
+ };
+ }
+ return null;
+}
+
+async function fetchPatchDetails(assetDetails) {
+ const { patchPath } = assetDetails;
+ const patchFile = basename(patchPath);
+ if ( patchFile === '' ) { return null; }
+ if ( patches.has(patchFile) ) {
+ return patches.get(patchFile);
+ }
+ const patchDetailsPromise = fetchPatchDetailsFromCDNs(assetDetails);
+ patches.set(patchFile, patchDetailsPromise);
+ return patchDetailsPromise;
+}
+
+async function fetchAndApplyAllPatches(assetDetails) {
+ if ( assetDetails.fetch === false ) {
+ if ( hasPatchDetails(assetDetails) === false ) {
+ assetDetails.status = 'nodiff';
+ return assetDetails;
+ }
+ }
+ // uBO-specific, to avoid pointless fetches which are likely to fail
+ // because the patch has not yet been created
+ const patchTime = expectedTimeFromPatch(assetDetails);
+ if ( patchTime > Date.now() ) {
+ assetDetails.status = 'nopatch-yet';
+ return assetDetails;
+ }
+ const patchData = await fetchPatchDetails(assetDetails);
+ if ( patchData === null ) {
+ assetDetails.status = (Date.now() - patchTime) < (4 * assetDetails.diffExpires)
+ ? 'nopatch-yet'
+ : 'nopatch';
+ return assetDetails;
+ }
+ const { patchDetails } = patchData;
+ if ( patchDetails instanceof Map === false ) {
+ assetDetails.status = 'nodiff';
+ return assetDetails;
+ }
+ const diffDetails = patchDetails.get(assetDetails.diffName);
+ if ( diffDetails === undefined ) {
+ assetDetails.status = 'nodiff';
+ return assetDetails;
+ }
+ if ( assetDetails.text === undefined ) {
+ assetDetails.status = 'needtext';
+ return assetDetails;
+ }
+ const outcome = await applyPatchAndValidate(assetDetails, diffDetails);
+ if ( outcome !== true ) { return assetDetails; }
+ assetDetails.status = 'updated';
+ assetDetails.patchURL = patchData.patchURL;
+ assetDetails.patchSize = patchData.patchSize;
+ return assetDetails;
+}
+
+/******************************************************************************/
+
+const bc = new globalThis.BroadcastChannel('diffUpdater');
+
+bc.onmessage = ev => {
+ const message = ev.data || {};
+ switch ( message.what ) {
+ case 'update':
+ fetchAndApplyAllPatches(message).then(response => {
+ bc.postMessage(response);
+ }).catch(error => {
+ bc.postMessage({ what: 'broken', error });
+ });
+ break;
+ }
+};
+
+bc.postMessage({ what: 'ready' });
+
+/******************************************************************************/