michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: /** michael@0: * validateManifest() warns of the following errors: michael@0: * - No manifest specified in page michael@0: * - Manifest is not utf-8 michael@0: * - Manifest mimetype not text/cache-manifest michael@0: * - Manifest does not begin with "CACHE MANIFEST" michael@0: * - Page modified since appcache last changed michael@0: * - Duplicate entries michael@0: * - Conflicting entries e.g. in both CACHE and NETWORK sections or in cache michael@0: * but blocked by FALLBACK namespace michael@0: * - Detect referenced files that are not available michael@0: * - Detect referenced files that have cache-control set to no-store michael@0: * - Wildcards used in a section other than NETWORK michael@0: * - Spaces in URI not replaced with %20 michael@0: * - Completely invalid URIs michael@0: * - Too many dot dot slash operators michael@0: * - SETTINGS section is valid michael@0: * - Invalid section name michael@0: * - etc. michael@0: */ michael@0: michael@0: "use strict"; michael@0: michael@0: const { classes: Cc, interfaces: Ci, utils: Cu } = Components; michael@0: michael@0: let { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}); michael@0: let { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); michael@0: let { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {}); michael@0: michael@0: this.EXPORTED_SYMBOLS = ["AppCacheUtils"]; michael@0: michael@0: function AppCacheUtils(documentOrUri) { michael@0: this._parseManifest = this._parseManifest.bind(this); michael@0: michael@0: if (documentOrUri) { michael@0: if (typeof documentOrUri == "string") { michael@0: this.uri = documentOrUri; michael@0: } michael@0: if (/HTMLDocument/.test(documentOrUri.toString())) { michael@0: this.doc = documentOrUri; michael@0: } michael@0: } michael@0: } michael@0: michael@0: AppCacheUtils.prototype = { michael@0: get cachePath() { michael@0: return ""; michael@0: }, michael@0: michael@0: validateManifest: function ACU_validateManifest() { michael@0: let deferred = promise.defer(); michael@0: this.errors = []; michael@0: // Check for missing manifest. michael@0: this._getManifestURI().then(manifestURI => { michael@0: this.manifestURI = manifestURI; michael@0: michael@0: if (!this.manifestURI) { michael@0: this._addError(0, "noManifest"); michael@0: deferred.resolve(this.errors); michael@0: } michael@0: michael@0: this._getURIInfo(this.manifestURI).then(uriInfo => { michael@0: this._parseManifest(uriInfo).then(() => { michael@0: // Sort errors by line number. michael@0: this.errors.sort(function(a, b) { michael@0: return a.line - b.line; michael@0: }); michael@0: deferred.resolve(this.errors); michael@0: }); michael@0: }); michael@0: }); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: _parseManifest: function ACU__parseManifest(uriInfo) { michael@0: let deferred = promise.defer(); michael@0: let manifestName = uriInfo.name; michael@0: let manifestLastModified = new Date(uriInfo.responseHeaders["Last-Modified"]); michael@0: michael@0: if (uriInfo.charset.toLowerCase() != "utf-8") { michael@0: this._addError(0, "notUTF8", uriInfo.charset); michael@0: } michael@0: michael@0: if (uriInfo.mimeType != "text/cache-manifest") { michael@0: this._addError(0, "badMimeType", uriInfo.mimeType); michael@0: } michael@0: michael@0: let parser = new ManifestParser(uriInfo.text, this.manifestURI); michael@0: let parsed = parser.parse(); michael@0: michael@0: if (parsed.errors.length > 0) { michael@0: this.errors.push.apply(this.errors, parsed.errors); michael@0: } michael@0: michael@0: // Check for duplicate entries. michael@0: let dupes = {}; michael@0: for (let parsedUri of parsed.uris) { michael@0: dupes[parsedUri.uri] = dupes[parsedUri.uri] || []; michael@0: dupes[parsedUri.uri].push({ michael@0: line: parsedUri.line, michael@0: section: parsedUri.section, michael@0: original: parsedUri.original michael@0: }); michael@0: } michael@0: for (let [uri, value] of Iterator(dupes)) { michael@0: if (value.length > 1) { michael@0: this._addError(0, "duplicateURI", uri, JSON.stringify(value)); michael@0: } michael@0: } michael@0: michael@0: // Loop through network entries making sure that fallback and cache don't michael@0: // contain uris starting with the network uri. michael@0: for (let neturi of parsed.uris) { michael@0: if (neturi.section == "NETWORK") { michael@0: for (let parsedUri of parsed.uris) { michael@0: if (parsedUri.uri.startsWith(neturi.uri)) { michael@0: this._addError(neturi.line, "networkBlocksURI", neturi.line, michael@0: neturi.original, parsedUri.line, parsedUri.original, michael@0: parsedUri.section); michael@0: } michael@0: } michael@0: } michael@0: } michael@0: michael@0: // Loop through fallback entries making sure that fallback and cache don't michael@0: // contain uris starting with the network uri. michael@0: for (let fb of parsed.fallbacks) { michael@0: for (let parsedUri of parsed.uris) { michael@0: if (parsedUri.uri.startsWith(fb.namespace)) { michael@0: this._addError(fb.line, "fallbackBlocksURI", fb.line, michael@0: fb.original, parsedUri.line, parsedUri.original, michael@0: parsedUri.section); michael@0: } michael@0: } michael@0: } michael@0: michael@0: // Check that all resources exist and that their cach-control headers are michael@0: // not set to no-store. michael@0: let current = -1; michael@0: for (let i = 0, len = parsed.uris.length; i < len; i++) { michael@0: let parsedUri = parsed.uris[i]; michael@0: this._getURIInfo(parsedUri.uri).then(uriInfo => { michael@0: current++; michael@0: michael@0: if (uriInfo.success) { michael@0: // Check that the resource was not modified after the manifest was last michael@0: // modified. If it was then the manifest file should be refreshed. michael@0: let resourceLastModified = michael@0: new Date(uriInfo.responseHeaders["Last-Modified"]); michael@0: michael@0: if (manifestLastModified < resourceLastModified) { michael@0: this._addError(parsedUri.line, "fileChangedButNotManifest", michael@0: uriInfo.name, manifestName, parsedUri.line); michael@0: } michael@0: michael@0: // If cache-control: no-store the file will not be added to the michael@0: // appCache. michael@0: if (uriInfo.nocache) { michael@0: this._addError(parsedUri.line, "cacheControlNoStore", michael@0: parsedUri.original, parsedUri.line); michael@0: } michael@0: } else { michael@0: this._addError(parsedUri.line, "notAvailable", michael@0: parsedUri.original, parsedUri.line); michael@0: } michael@0: michael@0: if (current == len - 1) { michael@0: deferred.resolve(); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: _getURIInfo: function ACU__getURIInfo(uri) { michael@0: let inputStream = Cc["@mozilla.org/scriptableinputstream;1"] michael@0: .createInstance(Ci.nsIScriptableInputStream); michael@0: let deferred = promise.defer(); michael@0: let channelCharset = ""; michael@0: let buffer = ""; michael@0: let channel = Services.io.newChannel(uri, null, null); michael@0: michael@0: // Avoid the cache: michael@0: channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; michael@0: channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; michael@0: michael@0: channel.asyncOpen({ michael@0: onStartRequest: function (request, context) { michael@0: // This empty method is needed in order for onDataAvailable to be michael@0: // called. michael@0: }, michael@0: michael@0: onDataAvailable: function (request, context, stream, offset, count) { michael@0: request.QueryInterface(Ci.nsIHttpChannel); michael@0: inputStream.init(stream); michael@0: buffer = buffer.concat(inputStream.read(count)); michael@0: }, michael@0: michael@0: onStopRequest: function onStartRequest(request, context, statusCode) { michael@0: if (statusCode == 0) { michael@0: request.QueryInterface(Ci.nsIHttpChannel); michael@0: michael@0: let result = { michael@0: name: request.name, michael@0: success: request.requestSucceeded, michael@0: status: request.responseStatus + " - " + request.responseStatusText, michael@0: charset: request.contentCharset || "utf-8", michael@0: mimeType: request.contentType, michael@0: contentLength: request.contentLength, michael@0: nocache: request.isNoCacheResponse() || request.isNoStoreResponse(), michael@0: prePath: request.URI.prePath + "/", michael@0: text: buffer michael@0: }; michael@0: michael@0: result.requestHeaders = {}; michael@0: request.visitRequestHeaders(function(header, value) { michael@0: result.requestHeaders[header] = value; michael@0: }); michael@0: michael@0: result.responseHeaders = {}; michael@0: request.visitResponseHeaders(function(header, value) { michael@0: result.responseHeaders[header] = value; michael@0: }); michael@0: michael@0: deferred.resolve(result); michael@0: } else { michael@0: deferred.resolve({ michael@0: name: request.name, michael@0: success: false michael@0: }); michael@0: } michael@0: } michael@0: }, null); michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: listEntries: function ACU_show(searchTerm) { michael@0: if (!Services.prefs.getBoolPref("browser.cache.disk.enable")) { michael@0: throw new Error(l10n.GetStringFromName("cacheDisabled")); michael@0: } michael@0: michael@0: let entries = []; michael@0: michael@0: Services.cache.visitEntries({ michael@0: visitDevice: function(deviceID, deviceInfo) { michael@0: return true; michael@0: }, michael@0: michael@0: visitEntry: function(deviceID, entryInfo) { michael@0: if (entryInfo.deviceID == "offline") { michael@0: let entry = {}; michael@0: let lowerKey = entryInfo.key.toLowerCase(); michael@0: michael@0: if (searchTerm && lowerKey.indexOf(searchTerm.toLowerCase()) == -1) { michael@0: return true; michael@0: } michael@0: michael@0: for (let [key, value] of Iterator(entryInfo)) { michael@0: if (key == "QueryInterface") { michael@0: continue; michael@0: } michael@0: if (key == "clientID") { michael@0: entry.key = entryInfo.key; michael@0: } michael@0: if (key == "expirationTime" || key == "lastFetched" || key == "lastModified") { michael@0: value = new Date(value * 1000); michael@0: } michael@0: entry[key] = value; michael@0: } michael@0: entries.push(entry); michael@0: } michael@0: return true; michael@0: } michael@0: }); michael@0: michael@0: if (entries.length == 0) { michael@0: throw new Error(l10n.GetStringFromName("noResults")); michael@0: } michael@0: return entries; michael@0: }, michael@0: michael@0: viewEntry: function ACU_viewEntry(key) { michael@0: let uri; michael@0: michael@0: Services.cache.visitEntries({ michael@0: visitDevice: function(deviceID, deviceInfo) { michael@0: return true; michael@0: }, michael@0: michael@0: visitEntry: function(deviceID, entryInfo) { michael@0: if (entryInfo.deviceID == "offline" && entryInfo.key == key) { michael@0: uri = "about:cache-entry?client=" + entryInfo.clientID + michael@0: "&sb=1&key=" + entryInfo.key; michael@0: return false; michael@0: } michael@0: return true; michael@0: } michael@0: }); michael@0: michael@0: if (uri) { michael@0: let wm = Cc["@mozilla.org/appshell/window-mediator;1"] michael@0: .getService(Ci.nsIWindowMediator); michael@0: let win = wm.getMostRecentWindow("navigator:browser"); michael@0: win.gBrowser.selectedTab = win.gBrowser.addTab(uri); michael@0: } else { michael@0: return l10n.GetStringFromName("entryNotFound"); michael@0: } michael@0: }, michael@0: michael@0: clearAll: function ACU_clearAll() { michael@0: Services.cache.evictEntries(Ci.nsICache.STORE_OFFLINE); michael@0: }, michael@0: michael@0: _getManifestURI: function ACU__getManifestURI() { michael@0: let deferred = promise.defer(); michael@0: michael@0: let getURI = node => { michael@0: let htmlNode = this.doc.querySelector("html[manifest]"); michael@0: if (htmlNode) { michael@0: let pageUri = this.doc.location ? this.doc.location.href : this.uri; michael@0: let origin = pageUri.substr(0, pageUri.lastIndexOf("/") + 1); michael@0: return origin + htmlNode.getAttribute("manifest"); michael@0: } michael@0: }; michael@0: michael@0: if (this.doc) { michael@0: let uri = getURI(this.doc); michael@0: return promise.resolve(uri); michael@0: } else { michael@0: this._getURIInfo(this.uri).then(uriInfo => { michael@0: if (uriInfo.success) { michael@0: let html = uriInfo.text; michael@0: let parser = _DOMParser; michael@0: this.doc = parser.parseFromString(html, "text/html"); michael@0: let uri = getURI(this.doc); michael@0: deferred.resolve(uri); michael@0: } else { michael@0: this.errors.push({ michael@0: line: 0, michael@0: msg: l10n.GetStringFromName("invalidURI") michael@0: }); michael@0: } michael@0: }); michael@0: } michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: _addError: function ACU__addError(line, l10nString, ...params) { michael@0: let msg; michael@0: michael@0: if (params) { michael@0: msg = l10n.formatStringFromName(l10nString, params, params.length); michael@0: } else { michael@0: msg = l10n.GetStringFromName(l10nString); michael@0: } michael@0: michael@0: this.errors.push({ michael@0: line: line, michael@0: msg: msg michael@0: }); michael@0: }, michael@0: }; michael@0: michael@0: /** michael@0: * We use our own custom parser because we need far more detailed information michael@0: * than the system manifest parser provides. michael@0: * michael@0: * @param {String} manifestText michael@0: * The text content of the manifest file. michael@0: * @param {String} manifestURI michael@0: * The URI of the manifest file. This is used in calculating the path of michael@0: * relative URIs. michael@0: */ michael@0: function ManifestParser(manifestText, manifestURI) { michael@0: this.manifestText = manifestText; michael@0: this.origin = manifestURI.substr(0, manifestURI.lastIndexOf("/") + 1) michael@0: .replace(" ", "%20"); michael@0: } michael@0: michael@0: ManifestParser.prototype = { michael@0: parse: function OCIMP_parse() { michael@0: let lines = this.manifestText.split(/\r?\n/); michael@0: let fallbacks = this.fallbacks = []; michael@0: let settings = this.settings = []; michael@0: let errors = this.errors = []; michael@0: let uris = this.uris = []; michael@0: michael@0: this.currSection = "CACHE"; michael@0: michael@0: for (let i = 0; i < lines.length; i++) { michael@0: let text = this.text = lines[i].replace(/^\s+|\s+$/g); michael@0: this.currentLine = i + 1; michael@0: michael@0: if (i == 0 && text != "CACHE MANIFEST") { michael@0: this._addError(1, "firstLineMustBeCacheManifest", 1); michael@0: } michael@0: michael@0: // Ignore comments michael@0: if (/^#/.test(text) || !text.length) { michael@0: continue; michael@0: } michael@0: michael@0: if (text == "CACHE MANIFEST") { michael@0: if (this.currentLine != 1) { michael@0: this._addError(this.currentLine, "cacheManifestOnlyFirstLine2", michael@0: this.currentLine); michael@0: } michael@0: continue; michael@0: } michael@0: michael@0: if (this._maybeUpdateSectionName()) { michael@0: continue; michael@0: } michael@0: michael@0: switch (this.currSection) { michael@0: case "CACHE": michael@0: case "NETWORK": michael@0: this.parseLine(); michael@0: break; michael@0: case "FALLBACK": michael@0: this.parseFallbackLine(); michael@0: break; michael@0: case "SETTINGS": michael@0: this.parseSettingsLine(); michael@0: break; michael@0: } michael@0: } michael@0: michael@0: return { michael@0: uris: uris, michael@0: fallbacks: fallbacks, michael@0: settings: settings, michael@0: errors: errors michael@0: }; michael@0: }, michael@0: michael@0: parseLine: function OCIMP_parseLine() { michael@0: let text = this.text; michael@0: michael@0: if (text.indexOf("*") != -1) { michael@0: if (this.currSection != "NETWORK" || text.length != 1) { michael@0: this._addError(this.currentLine, "asteriskInWrongSection2", michael@0: this.currSection, this.currentLine); michael@0: return; michael@0: } michael@0: } michael@0: michael@0: if (/\s/.test(text)) { michael@0: this._addError(this.currentLine, "escapeSpaces", this.currentLine); michael@0: text = text.replace(/\s/g, "%20") michael@0: } michael@0: michael@0: if (text[0] == "/") { michael@0: if (text.substr(0, 4) == "/../") { michael@0: this._addError(this.currentLine, "slashDotDotSlashBad", this.currentLine); michael@0: } else { michael@0: this.uris.push(this._wrapURI(this.origin + text.substring(1))); michael@0: } michael@0: } else if (text.substr(0, 2) == "./") { michael@0: this.uris.push(this._wrapURI(this.origin + text.substring(2))); michael@0: } else if (text.substr(0, 4) == "http") { michael@0: this.uris.push(this._wrapURI(text)); michael@0: } else { michael@0: let origin = this.origin; michael@0: let path = text; michael@0: michael@0: while (path.substr(0, 3) == "../" && /^https?:\/\/.*?\/.*?\//.test(origin)) { michael@0: let trimIdx = origin.substr(0, origin.length - 1).lastIndexOf("/") + 1; michael@0: origin = origin.substr(0, trimIdx); michael@0: path = path.substr(3); michael@0: } michael@0: michael@0: if (path.substr(0, 3) == "../") { michael@0: this._addError(this.currentLine, "tooManyDotDotSlashes", this.currentLine); michael@0: return; michael@0: } michael@0: michael@0: if (/^https?:\/\//.test(path)) { michael@0: this.uris.push(this._wrapURI(path)); michael@0: return; michael@0: } michael@0: this.uris.push(this._wrapURI(origin + path)); michael@0: } michael@0: }, michael@0: michael@0: parseFallbackLine: function OCIMP_parseFallbackLine() { michael@0: let split = this.text.split(/\s+/); michael@0: let origURI = this.text; michael@0: michael@0: if (split.length != 2) { michael@0: this._addError(this.currentLine, "fallbackUseSpaces", this.currentLine); michael@0: return; michael@0: } michael@0: michael@0: let [ namespace, fallback ] = split; michael@0: michael@0: if (namespace.indexOf("*") != -1) { michael@0: this._addError(this.currentLine, "fallbackAsterisk2", this.currentLine); michael@0: } michael@0: michael@0: if (/\s/.test(namespace)) { michael@0: this._addError(this.currentLine, "escapeSpaces", this.currentLine); michael@0: namespace = namespace.replace(/\s/g, "%20") michael@0: } michael@0: michael@0: if (namespace.substr(0, 4) == "/../") { michael@0: this._addError(this.currentLine, "slashDotDotSlashBad", this.currentLine); michael@0: } michael@0: michael@0: if (namespace.substr(0, 2) == "./") { michael@0: namespace = this.origin + namespace.substring(2); michael@0: } michael@0: michael@0: if (namespace.substr(0, 4) != "http") { michael@0: let origin = this.origin; michael@0: let path = namespace; michael@0: michael@0: while (path.substr(0, 3) == "../" && /^https?:\/\/.*?\/.*?\//.test(origin)) { michael@0: let trimIdx = origin.substr(0, origin.length - 1).lastIndexOf("/") + 1; michael@0: origin = origin.substr(0, trimIdx); michael@0: path = path.substr(3); michael@0: } michael@0: michael@0: if (path.substr(0, 3) == "../") { michael@0: this._addError(this.currentLine, "tooManyDotDotSlashes", this.currentLine); michael@0: } michael@0: michael@0: if (/^https?:\/\//.test(path)) { michael@0: namespace = path; michael@0: } else { michael@0: if (path[0] == "/") { michael@0: path = path.substring(1); michael@0: } michael@0: namespace = origin + path; michael@0: } michael@0: } michael@0: michael@0: this.text = fallback; michael@0: this.parseLine(); michael@0: michael@0: this.fallbacks.push({ michael@0: line: this.currentLine, michael@0: original: origURI, michael@0: namespace: namespace, michael@0: fallback: fallback michael@0: }); michael@0: }, michael@0: michael@0: parseSettingsLine: function OCIMP_parseSettingsLine() { michael@0: let text = this.text; michael@0: michael@0: if (this.settings.length == 1 || !/prefer-online|fast/.test(text)) { michael@0: this._addError(this.currentLine, "settingsBadValue", this.currentLine); michael@0: return; michael@0: } michael@0: michael@0: switch (text) { michael@0: case "prefer-online": michael@0: this.settings.push(this._wrapURI(text)); michael@0: break; michael@0: case "fast": michael@0: this.settings.push(this._wrapURI(text)); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: _wrapURI: function OCIMP__wrapURI(uri) { michael@0: return { michael@0: section: this.currSection, michael@0: line: this.currentLine, michael@0: uri: uri, michael@0: original: this.text michael@0: }; michael@0: }, michael@0: michael@0: _addError: function OCIMP__addError(line, l10nString, ...params) { michael@0: let msg; michael@0: michael@0: if (params) { michael@0: msg = l10n.formatStringFromName(l10nString, params, params.length); michael@0: } else { michael@0: msg = l10n.GetStringFromName(l10nString); michael@0: } michael@0: michael@0: this.errors.push({ michael@0: line: line, michael@0: msg: msg michael@0: }); michael@0: }, michael@0: michael@0: _maybeUpdateSectionName: function OCIMP__maybeUpdateSectionName() { michael@0: let text = this.text; michael@0: michael@0: if (text == text.toUpperCase() && text.charAt(text.length - 1) == ":") { michael@0: text = text.substr(0, text.length - 1); michael@0: michael@0: switch (text) { michael@0: case "CACHE": michael@0: case "NETWORK": michael@0: case "FALLBACK": michael@0: case "SETTINGS": michael@0: this.currSection = text; michael@0: return true; michael@0: default: michael@0: this._addError(this.currentLine, michael@0: "invalidSectionName", text, this.currentLine); michael@0: return false; michael@0: } michael@0: } michael@0: }, michael@0: }; michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "l10n", function() Services.strings michael@0: .createBundle("chrome://browser/locale/devtools/appcacheutils.properties")); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "appcacheservice", function() { michael@0: return Cc["@mozilla.org/network/application-cache-service;1"] michael@0: .getService(Ci.nsIApplicationCacheService); michael@0: michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "_DOMParser", function() { michael@0: return Cc["@mozilla.org/xmlextras/domparser;1"].createInstance(Ci.nsIDOMParser); michael@0: });