/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
 * The AddonUpdateChecker is responsible for retrieving the update information
 * from an add-on's remote update manifest.
 */

const TIMEOUT = 60 * 1000;
const TOOLKIT_ID = "toolkit@mozilla.org";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
  AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
  Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
  CertUtils: "resource://gre/modules/CertUtils.sys.mjs",
  ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
});

import { Log } from "resource://gre/modules/Log.sys.mjs";

const LOGGER_ID = "addons.update-checker";

// Create a new logger for use by the Addons Update Checker
// (Requires AddonManager.sys.mjs)
var logger = Log.repository.getLogger(LOGGER_ID);

/**
 * Sanitizes the update URL in an update item, as returned by
 * parseRDFManifest and parseJSONManifest. Ensures that:
 *
 * - The URL is secure, or secured by a strong enough hash.
 * - The security principal of the update manifest has permission to
 *   load the URL.
 *
 * @param aUpdate
 *        The update item to sanitize.
 * @param aRequest
 *        The XMLHttpRequest used to load the manifest.
 * @param aHashPattern
 *        The regular expression used to validate the update hash.
 * @param aHashString
 *        The human-readable string specifying which hash functions
 *        are accepted.
 */
function sanitizeUpdateURL(aUpdate, aRequest, aHashPattern, aHashString) {
  if (aUpdate.updateURL) {
    let scriptSecurity = Services.scriptSecurityManager;
    let principal = scriptSecurity.getChannelURIPrincipal(aRequest.channel);
    try {
      // This logs an error on failure, so no need to log it a second time
      scriptSecurity.checkLoadURIStrWithPrincipal(
        principal,
        aUpdate.updateURL,
        scriptSecurity.DISALLOW_SCRIPT
      );
    } catch (e) {
      delete aUpdate.updateURL;
      return;
    }

    if (
      lazy.AddonManager.checkUpdateSecurity &&
      !aUpdate.updateURL.startsWith("https:") &&
      !aHashPattern.test(aUpdate.updateHash)
    ) {
      logger.warn(
        `Update link ${aUpdate.updateURL} is not secure and is not verified ` +
          `by a strong enough hash (needs to be ${aHashString}).`
      );
      delete aUpdate.updateURL;
      delete aUpdate.updateHash;
    }
  }
}

/**
 * Parses an JSON update manifest into an array of update objects.
 *
 * @param  aId
 *         The ID of the add-on being checked for updates
 * @param  aRequest
 *         The XMLHttpRequest that has retrieved the update manifest
 * @param  aManifestData
 *         The pre-parsed manifest, as a JSON object tree
 * @return an array of update objects
 * @throws if the update manifest is invalid in any way
 */
function parseJSONManifest(aId, aRequest, aManifestData) {
  let TYPE_CHECK = {
    array: val => Array.isArray(val),
    object: val => val && typeof val == "object" && !Array.isArray(val),
  };

  function getProperty(aObj, aProperty, aType, aDefault = undefined) {
    if (!(aProperty in aObj)) {
      return aDefault;
    }

    let value = aObj[aProperty];

    let matchesType =
      aType in TYPE_CHECK ? TYPE_CHECK[aType](value) : typeof value == aType;
    if (!matchesType) {
      throw Components.Exception(
        `Update manifest property '${aProperty}' has incorrect type (expected ${aType})`
      );
    }

    return value;
  }

  function getRequiredProperty(aObj, aProperty, aType) {
    let value = getProperty(aObj, aProperty, aType);
    if (value === undefined) {
      throw Components.Exception(
        `Update manifest is missing a required ${aProperty} property.`
      );
    }
    return value;
  }

  let manifest = aManifestData;

  if (!TYPE_CHECK.object(manifest)) {
    throw Components.Exception(
      "Root element of update manifest must be a JSON object literal"
    );
  }

  // The set of add-ons this manifest has updates for
  let addons = getProperty(manifest, "addons", "object");

  if (!addons) {
    let keys = Object.keys(manifest);
    if (keys.length) {
      // "addons" property is optional. The presence of other properties may be
      // a sign of a mistake, so print a warning to help with debugging.
      logger.warn(
        `Update manifest for ${aId} is missing the "addons" property, found ${keys} instead.`
      );
    } else {
      // If the add-on isn't listed, the update server may return an empty
      // response.
      logger.warn(`Received empty update manifest for ${aId}.`);
    }
    return [];
  }

  // The entry for this particular add-on
  let addon = getProperty(addons, aId, "object");

  // A missing entry doesn't count as a failure, just as no avialable update
  // information
  if (!addon) {
    logger.warn("Update manifest did not contain an entry for " + aId);
    return [];
  }

  // The list of available updates
  let updates = getProperty(addon, "updates", "array", []);

  let results = [];

  for (let update of updates) {
    let version = getRequiredProperty(update, "version", "string");

    logger.debug(`Found an update entry for ${aId} version ${version}`);

    let applications = getProperty(update, "applications", "object", {
      gecko: {},
    });

    // "gecko" is currently the only supported application entry. If
    // it's missing, skip this update.
    if (!("gecko" in applications)) {
      logger.debug(
        "gecko not in application entry, skipping update of ${addon}"
      );
      continue;
    }

    let app = getProperty(applications, "gecko", "object");

    let appEntry = {
      id: TOOLKIT_ID,
      minVersion: getProperty(
        app,
        "strict_min_version",
        "string",
        lazy.AddonManagerPrivate.webExtensionsMinPlatformVersion
      ),
      maxVersion: "*",
    };

    let result = {
      id: aId,
      version,
      updateURL: getProperty(update, "update_link", "string"),
      updateHash: getProperty(update, "update_hash", "string"),
      updateInfoURL: getProperty(update, "update_info_url", "string"),
      strictCompatibility: false,
      targetApplications: [appEntry],
    };

    if ("strict_max_version" in app) {
      if ("advisory_max_version" in app) {
        logger.warn(
          "Ignoring 'advisory_max_version' update manifest property for " +
            aId +
            " property since 'strict_max_version' also present"
        );
      }

      appEntry.maxVersion = getProperty(app, "strict_max_version", "string");
      result.strictCompatibility = appEntry.maxVersion != "*";
    } else if ("advisory_max_version" in app) {
      appEntry.maxVersion = getProperty(app, "advisory_max_version", "string");
    }

    // Add an app entry for the current API ID, too, so that it overrides any
    // existing app-specific entries, which would take priority over the toolkit
    // entry.
    //
    // Note: This currently only has any effect on legacy extensions (mainly
    // those used in tests), since WebExtensions cannot yet specify app-specific
    // compatibility ranges.
    result.targetApplications.push(
      Object.assign({}, appEntry, { id: Services.appinfo.ID })
    );

    // The JSON update protocol requires an SHA-2 hash. RDF still
    // supports SHA-1, for compatibility reasons.
    sanitizeUpdateURL(result, aRequest, /^sha(256|512):/, "sha256 or sha512");

    results.push(result);
  }
  return results;
}

/**
 * Starts downloading an update manifest and then passes it to an appropriate
 * parser to convert to an array of update objects
 *
 * @param  aId
 *         The ID of the add-on being checked for updates
 * @param  aUrl
 *         The URL of the update manifest
 * @param  aObserver
 *         An observer to pass results to
 */
function UpdateParser(aId, aUrl, aObserver) {
  this.id = aId;
  this.observer = aObserver;
  this.url = aUrl;

  logger.debug("Requesting " + aUrl);
  try {
    this.request = new lazy.ServiceRequest({ mozAnon: true });
    this.request.open("GET", this.url, true);
    this.request.channel.notificationCallbacks =
      new lazy.CertUtils.BadCertHandler(
        !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
      );
    this.request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
    // Prevent the request from writing to cache.
    this.request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
    this.request.overrideMimeType("text/plain");
    this.request.timeout = TIMEOUT;
    this.request.addEventListener("load", () => this.onLoad());
    this.request.addEventListener("error", () => this.onError());
    this.request.addEventListener("timeout", () => this.onTimeout());
    this.request.send(null);
  } catch (e) {
    logger.error(`Failed to request update manifest for ${this.id}`, e);
  }
}

UpdateParser.prototype = {
  id: null,
  observer: null,
  request: null,
  url: null,

  /**
   * Called when the manifest has been successfully loaded.
   */
  onLoad() {
    let request = this.request;
    this.request = null;
    this._doneAt = new Error("place holder");

    try {
      lazy.CertUtils.checkCert(
        request.channel,
        !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
      );
    } catch (e) {
      logger.warn("Request failed: " + this.url + " - " + e);
      this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
      return;
    }

    if (!Components.isSuccessCode(request.status)) {
      logger.warn("Request failed: " + this.url + " - " + request.status);
      this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
      return;
    }

    let channel = request.channel;
    if (channel instanceof Ci.nsIHttpChannel && !channel.requestSucceeded) {
      logger.warn(
        "Request failed: " +
          this.url +
          " - " +
          channel.responseStatus +
          ": " +
          channel.responseStatusText
      );
      this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
      return;
    }

    let results;
    try {
      let json = JSON.parse(request.responseText);
      results = parseJSONManifest(this.id, request, json);
    } catch (e) {
      logger.warn(
        `onUpdateCheckComplete failed to parse update manifest for ${this.id}`,
        e
      );
      this.notifyError(lazy.AddonManager.ERROR_PARSE_ERROR);
      return;
    }

    if ("onUpdateCheckComplete" in this.observer) {
      try {
        this.observer.onUpdateCheckComplete(results);
      } catch (e) {
        logger.warn(
          `onUpdateCheckComplete notification failed for ${this.id}`,
          e
        );
      }
    } else {
      logger.warn(
        "onUpdateCheckComplete may not properly cancel",
        new Error("stack marker")
      );
    }
  },

  /**
   * Called when the request times out
   */
  onTimeout() {
    this.request = null;
    this._doneAt = new Error("Timed out");
    logger.warn("Request for " + this.url + " timed out");
    this.notifyError(lazy.AddonManager.ERROR_TIMEOUT);
  },

  /**
   * Called when the manifest failed to load.
   */
  onError() {
    if (!Components.isSuccessCode(this.request.status)) {
      logger.warn("Request failed: " + this.url + " - " + this.request.status);
    } else if (this.request.channel instanceof Ci.nsIHttpChannel) {
      try {
        if (this.request.channel.requestSucceeded) {
          logger.warn(
            "Request failed: " +
              this.url +
              " - " +
              this.request.channel.responseStatus +
              ": " +
              this.request.channel.responseStatusText
          );
        }
      } catch (e) {
        logger.warn("HTTP Request failed for an unknown reason");
      }
    } else {
      logger.warn("Request failed for an unknown reason");
    }

    this.request = null;
    this._doneAt = new Error("UP_onError");

    this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
  },

  /**
   * Helper method to notify the observer that an error occurred.
   */
  notifyError(aStatus) {
    if ("onUpdateCheckError" in this.observer) {
      try {
        this.observer.onUpdateCheckError(aStatus);
      } catch (e) {
        logger.warn("onUpdateCheckError notification failed", e);
      }
    }
  },

  /**
   * Called to cancel an in-progress update check.
   */
  cancel() {
    if (!this.request) {
      logger.error("Trying to cancel already-complete request", this._doneAt);
      return;
    }
    this.request.abort();
    this.request = null;
    this._doneAt = new Error("UP_cancel");
    this.notifyError(lazy.AddonManager.ERROR_CANCELLED);
  },
};

/**
 * Tests if an update matches a version of the application or platform
 *
 * @param  aUpdate
 *         The available update
 * @param  aAppVersion
 *         The application version to use
 * @param  aPlatformVersion
 *         The platform version to use
 * @param  aIgnoreMaxVersion
 *         Ignore maxVersion when testing if an update matches. Optional.
 * @param  aIgnoreStrictCompat
 *         Ignore strictCompatibility when testing if an update matches. Optional.
 * @return true if the update is compatible with the application/platform
 */
function matchesVersions(
  aUpdate,
  aAppVersion,
  aPlatformVersion,
  aIgnoreMaxVersion,
  aIgnoreStrictCompat
) {
  if (aUpdate.strictCompatibility && !aIgnoreStrictCompat) {
    aIgnoreMaxVersion = false;
  }

  let result = false;
  for (let app of aUpdate.targetApplications) {
    if (app.id == Services.appinfo.ID) {
      return (
        Services.vc.compare(aAppVersion, app.minVersion) >= 0 &&
        (aIgnoreMaxVersion ||
          Services.vc.compare(aAppVersion, app.maxVersion) <= 0)
      );
    }
    if (app.id == TOOLKIT_ID) {
      result =
        Services.vc.compare(aPlatformVersion, app.minVersion) >= 0 &&
        (aIgnoreMaxVersion ||
          Services.vc.compare(aPlatformVersion, app.maxVersion) <= 0);
    }
  }
  return result;
}

export var AddonUpdateChecker = {
  /**
   * Retrieves the best matching compatibility update for the application from
   * a list of available update objects.
   *
   * @param  aUpdates
   *         An array of update objects
   * @param  aVersion
   *         The version of the add-on to get new compatibility information for
   * @param  aIgnoreCompatibility
   *         An optional parameter to get the first compatibility update that
   *         is compatible with any version of the application or toolkit
   * @param  aAppVersion
   *         The version of the application or null to use the current version
   * @param  aPlatformVersion
   *         The version of the platform or null to use the current version
   * @param  aIgnoreMaxVersion
   *         Ignore maxVersion when testing if an update matches. Optional.
   * @param  aIgnoreStrictCompat
   *         Ignore strictCompatibility when testing if an update matches. Optional.
   * @return an update object if one matches or null if not
   */
  getCompatibilityUpdate(
    aUpdates,
    aVersion,
    aIgnoreCompatibility,
    aAppVersion,
    aPlatformVersion,
    aIgnoreMaxVersion,
    aIgnoreStrictCompat
  ) {
    if (!aAppVersion) {
      aAppVersion = Services.appinfo.version;
    }
    if (!aPlatformVersion) {
      aPlatformVersion = Services.appinfo.platformVersion;
    }

    for (let update of aUpdates) {
      if (Services.vc.compare(update.version, aVersion) == 0) {
        if (aIgnoreCompatibility) {
          for (let targetApp of update.targetApplications) {
            let id = targetApp.id;
            if (id == Services.appinfo.ID || id == TOOLKIT_ID) {
              return update;
            }
          }
        } else if (
          matchesVersions(
            update,
            aAppVersion,
            aPlatformVersion,
            aIgnoreMaxVersion,
            aIgnoreStrictCompat
          )
        ) {
          return update;
        }
      }
    }
    return null;
  },

  /**
   * Asynchronously returns the newest available update from a list of update objects.
   *
   * @param  aUpdates
   *         An array of update objects
   * @param  aAddon
   *         The add-on that is being updated.
   * @param  aAppVersion
   *         The version of the application or null to use the current version
   * @param  aPlatformVersion
   *         The version of the platform or null to use the current version
   * @param  aIgnoreMaxVersion
   *         When determining compatible updates, ignore maxVersion. Optional.
   * @param  aIgnoreStrictCompat
   *         When determining compatible updates, ignore strictCompatibility. Optional.
   * @return an update object if one matches or null if not
   */
  async getNewestCompatibleUpdate(
    aUpdates,
    aAddon,
    aAppVersion,
    aPlatformVersion,
    aIgnoreMaxVersion,
    aIgnoreStrictCompat
  ) {
    if (!aAppVersion) {
      aAppVersion = Services.appinfo.version;
    }
    if (!aPlatformVersion) {
      aPlatformVersion = Services.appinfo.platformVersion;
    }

    let newestVersion = aAddon.version;
    let newest = null;
    let blocked = null;
    let blockedState;
    for (let update of aUpdates) {
      if (!update.updateURL) {
        continue;
      }
      if (Services.vc.compare(newestVersion, update.version) >= 0) {
        // Update older than add-on version or older than previous result.
        continue;
      }
      if (
        !matchesVersions(
          update,
          aAppVersion,
          aPlatformVersion,
          aIgnoreMaxVersion,
          aIgnoreStrictCompat
        )
      ) {
        continue;
      }
      let state = await lazy.Blocklist.getAddonBlocklistState(
        update,
        aAppVersion,
        aPlatformVersion
      );
      if (state != Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
        if (
          !blocked ||
          Services.vc.compare(blocked.version, update.version) < 0
        ) {
          blocked = update;
          blockedState = state;
        }
        continue;
      }
      newest = update;
      newestVersion = update.version;
    }
    if (
      blocked &&
      (!newest || Services.vc.compare(blocked.version, newestVersion) >= 0)
    ) {
      // If |newest| has a higher version than |blocked|, then the add-on would
      // not be considered for installation. But if |blocked| would otherwise
      // be eligible for installation, then report to telemetry that installation
      // has been blocked because of the blocklist.
      lazy.Blocklist.recordAddonBlockChangeTelemetry(
        {
          id: aAddon.id,
          version: blocked.version,
          blocklistState: blockedState,
        },
        "addon_update_check"
      );
    }
    return newest;
  },

  /**
   * Starts an update check.
   *
   * @param  aId
   *         The ID of the add-on being checked for updates
   * @param  aUrl
   *         The URL of the add-on's update manifest
   * @param  aObserver
   *         An observer to notify of results
   * @return UpdateParser so that the caller can use UpdateParser.cancel() to shut
   *         down in-progress update requests
   */
  checkForUpdates(aId, aUrl, aObserver) {
    return new UpdateParser(aId, aUrl, aObserver);
  },
};
