uBlock/src/js/diff-updater.js

289 lines
9.6 KiB
JavaScript

/*******************************************************************************
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' });
/******************************************************************************/