diff options
Diffstat (limited to 'uriloader/exthandler')
169 files changed, 27906 insertions, 0 deletions
diff --git a/uriloader/exthandler/ContentHandlerService.cpp b/uriloader/exthandler/ContentHandlerService.cpp new file mode 100644 index 0000000000..599185bcd0 --- /dev/null +++ b/uriloader/exthandler/ContentHandlerService.cpp @@ -0,0 +1,263 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#include "ContentHandlerService.h" +#include "HandlerServiceChild.h" +#include "ContentChild.h" +#include "nsIMutableArray.h" +#include "nsIMIMEInfo.h" +#include "nsIStringEnumerator.h" +#include "nsReadableUtils.h" +#include "nsMIMEInfoImpl.h" +#include "nsMIMEInfoChild.h" + +using mozilla::dom::ContentChild; +using mozilla::dom::HandlerInfo; +using mozilla::dom::PHandlerServiceChild; + +namespace mozilla { +namespace dom { + +NS_IMPL_ISUPPORTS(ContentHandlerService, nsIHandlerService) + +ContentHandlerService::ContentHandlerService() {} + +/* static */ already_AddRefed<nsIHandlerService> +ContentHandlerService::Create() { + if (XRE_IsContentProcess()) { + RefPtr service = new ContentHandlerService(); + if (NS_SUCCEEDED(service->Init())) { + return service.forget(); + } + return nullptr; + } + + nsCOMPtr<nsIHandlerService> service = + do_GetService("@mozilla.org/uriloader/handler-service-parent;1"); + return service.forget(); +} + +nsresult ContentHandlerService::Init() { + if (!XRE_IsContentProcess()) { + return NS_ERROR_FAILURE; + } + ContentChild* cpc = ContentChild::GetSingleton(); + + mHandlerServiceChild = new HandlerServiceChild(); + if (!cpc->SendPHandlerServiceConstructor(mHandlerServiceChild)) { + mHandlerServiceChild = nullptr; + return NS_ERROR_UNEXPECTED; + } + return NS_OK; +} + +void ContentHandlerService::nsIHandlerInfoToHandlerInfo( + nsIHandlerInfo* aInfo, HandlerInfo* aHandlerInfo) { + nsCString type; + aInfo->GetType(type); + nsCOMPtr<nsIMIMEInfo> mimeInfo = do_QueryInterface(aInfo); + bool isMIMEInfo = !!mimeInfo; + nsString description; + aInfo->GetDescription(description); + bool alwaysAskBeforeHandling; + aInfo->GetAlwaysAskBeforeHandling(&alwaysAskBeforeHandling); + nsCOMPtr<nsIHandlerApp> app; + aInfo->GetPreferredApplicationHandler(getter_AddRefs(app)); + nsString name; + nsString detailedDescription; + if (app) { + app->GetName(name); + app->GetDetailedDescription(detailedDescription); + } + HandlerApp happ(name, detailedDescription); + nsTArray<HandlerApp> happs; + nsCOMPtr<nsIMutableArray> apps; + aInfo->GetPossibleApplicationHandlers(getter_AddRefs(apps)); + if (apps) { + unsigned int length; + apps->GetLength(&length); + for (unsigned int i = 0; i < length; i++) { + apps->QueryElementAt(i, NS_GET_IID(nsIHandlerApp), getter_AddRefs(app)); + app->GetName(name); + app->GetDetailedDescription(detailedDescription); + happs.AppendElement(HandlerApp(name, detailedDescription)); + } + } + + nsTArray<nsCString> extensions; + + if (isMIMEInfo) { + nsCOMPtr<nsIUTF8StringEnumerator> extensionsIter; + mimeInfo->GetFileExtensions(getter_AddRefs(extensionsIter)); + if (extensionsIter) { + bool hasMore = false; + while (NS_SUCCEEDED(extensionsIter->HasMore(&hasMore)) && hasMore) { + nsAutoCString extension; + if (NS_SUCCEEDED(extensionsIter->GetNext(extension))) { + extensions.AppendElement(std::move(extension)); + } + } + } + } + + nsHandlerInfoAction action; + aInfo->GetPreferredAction(&action); + HandlerInfo info(type, isMIMEInfo, description, alwaysAskBeforeHandling, + std::move(extensions), happ, happs, action); + *aHandlerInfo = info; +} + +NS_IMETHODIMP RemoteHandlerApp::GetName(nsAString& aName) { + aName.Assign(mAppChild.name()); + return NS_OK; +} + +NS_IMETHODIMP RemoteHandlerApp::SetName(const nsAString& aName) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP RemoteHandlerApp::GetDetailedDescription( + nsAString& aDetailedDescription) { + aDetailedDescription.Assign(mAppChild.detailedDescription()); + return NS_OK; +} + +NS_IMETHODIMP RemoteHandlerApp::SetDetailedDescription( + const nsAString& aDetailedDescription) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP RemoteHandlerApp::Equals(nsIHandlerApp* aHandlerApp, + bool* _retval) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP RemoteHandlerApp::LaunchWithURI( + nsIURI* aURI, BrowsingContext* aBrowsingContext) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMPL_ISUPPORTS(RemoteHandlerApp, nsIHandlerApp) + +static inline void CopyHandlerInfoTonsIHandlerInfo( + const HandlerInfo& info, nsIHandlerInfo* aHandlerInfo) { + HandlerApp preferredApplicationHandler = info.preferredApplicationHandler(); + nsCOMPtr<nsIHandlerApp> preferredApp( + new RemoteHandlerApp(preferredApplicationHandler)); + aHandlerInfo->SetPreferredApplicationHandler(preferredApp); + nsCOMPtr<nsIMutableArray> possibleHandlers; + aHandlerInfo->GetPossibleApplicationHandlers( + getter_AddRefs(possibleHandlers)); + possibleHandlers->AppendElement(preferredApp); + + aHandlerInfo->SetPreferredAction(info.preferredAction()); + aHandlerInfo->SetAlwaysAskBeforeHandling(info.alwaysAskBeforeHandling()); + + if (info.isMIMEInfo()) { + nsCOMPtr<nsIMIMEInfo> mimeInfo(do_QueryInterface(aHandlerInfo)); + MOZ_ASSERT(mimeInfo, + "parent and child don't agree on whether this is a MIME info"); + mimeInfo->SetFileExtensions(StringJoin(","_ns, info.extensions())); + } +} + +ContentHandlerService::~ContentHandlerService() {} + +NS_IMETHODIMP ContentHandlerService::AsyncInit() { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP ContentHandlerService::Enumerate(nsISimpleEnumerator** _retval) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP ContentHandlerService::FillHandlerInfo( + nsIHandlerInfo* aHandlerInfo, const nsACString& aOverrideType) { + HandlerInfo info, returnedInfo; + nsIHandlerInfoToHandlerInfo(aHandlerInfo, &info); + mHandlerServiceChild->SendFillHandlerInfo(info, aOverrideType, &returnedInfo); + CopyHandlerInfoTonsIHandlerInfo(returnedInfo, aHandlerInfo); + return NS_OK; +} + +NS_IMETHODIMP ContentHandlerService::GetMIMEInfoFromOS( + const nsACString& aMIMEType, const nsACString& aFileExt, bool* aFound, + nsIMIMEInfo** aMIMEInfo) { + nsresult rv = NS_ERROR_FAILURE; + HandlerInfo returnedInfo; + if (!mHandlerServiceChild->SendGetMIMEInfoFromOS(aMIMEType, aFileExt, &rv, + &returnedInfo, aFound)) { + return NS_ERROR_FAILURE; + } + + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + RefPtr<nsChildProcessMIMEInfo> mimeInfo = + new nsChildProcessMIMEInfo(returnedInfo.type()); + CopyHandlerInfoTonsIHandlerInfo(returnedInfo, mimeInfo); + mimeInfo.forget(aMIMEInfo); + return NS_OK; +} + +NS_IMETHODIMP ContentHandlerService::Store(nsIHandlerInfo* aHandlerInfo) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP ContentHandlerService::Exists(nsIHandlerInfo* aHandlerInfo, + bool* _retval) { + HandlerInfo info; + nsIHandlerInfoToHandlerInfo(aHandlerInfo, &info); + mHandlerServiceChild->SendExists(info, _retval); + return NS_OK; +} + +NS_IMETHODIMP ContentHandlerService::Remove(nsIHandlerInfo* aHandlerInfo) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ContentHandlerService::ExistsForProtocolOS(const nsACString& aProtocolScheme, + bool* aRetval) { + if (!mHandlerServiceChild->SendExistsForProtocolOS(aProtocolScheme, + aRetval)) { + return NS_ERROR_FAILURE; + } + return NS_OK; +} + +NS_IMETHODIMP +ContentHandlerService::ExistsForProtocol(const nsACString& aProtocolScheme, + bool* aRetval) { + if (!mHandlerServiceChild->SendExistsForProtocol(aProtocolScheme, aRetval)) { + return NS_ERROR_FAILURE; + } + return NS_OK; +} + +NS_IMETHODIMP ContentHandlerService::GetTypeFromExtension( + const nsACString& aFileExtension, nsACString& _retval) { + _retval.Assign(*mExtToTypeMap.LookupOrInsertWith(aFileExtension, [&] { + nsCString type; + mHandlerServiceChild->SendGetTypeFromExtension(aFileExtension, &type); + return MakeUnique<nsCString>(type); + })); + return NS_OK; +} + +NS_IMETHODIMP ContentHandlerService::GetApplicationDescription( + const nsACString& aProtocolScheme, nsAString& aRetVal) { + nsresult rv = NS_ERROR_FAILURE; + nsAutoCString scheme(aProtocolScheme); + nsAutoString desc; + mHandlerServiceChild->SendGetApplicationDescription(scheme, &rv, &desc); + aRetVal.Assign(desc); + return rv; +} + +} // namespace dom +} // namespace mozilla diff --git a/uriloader/exthandler/ContentHandlerService.h b/uriloader/exthandler/ContentHandlerService.h new file mode 100644 index 0000000000..650dbd9115 --- /dev/null +++ b/uriloader/exthandler/ContentHandlerService.h @@ -0,0 +1,55 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef ContentHandlerService_h +#define ContentHandlerService_h + +#include "mozilla/dom/PHandlerService.h" +#include "nsIHandlerService.h" +#include "nsClassHashtable.h" +#include "nsIMIMEInfo.h" + +namespace mozilla { + +class HandlerServiceChild; + +namespace dom { + +class PHandlerServiceChild; + +class ContentHandlerService : public nsIHandlerService { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIHANDLERSERVICE + + ContentHandlerService(); + [[nodiscard]] nsresult Init(); + static void nsIHandlerInfoToHandlerInfo(nsIHandlerInfo* aInfo, + HandlerInfo* aHandlerInfo); + + static already_AddRefed<nsIHandlerService> Create(); + + private: + virtual ~ContentHandlerService(); + RefPtr<HandlerServiceChild> mHandlerServiceChild; + nsClassHashtable<nsCStringHashKey, nsCString> mExtToTypeMap; +}; + +class RemoteHandlerApp : public nsIHandlerApp { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIHANDLERAPP + + explicit RemoteHandlerApp(HandlerApp aAppChild) : mAppChild(aAppChild) {} + + private: + virtual ~RemoteHandlerApp() {} + HandlerApp mAppChild; +}; + +} // namespace dom +} // namespace mozilla +#endif diff --git a/uriloader/exthandler/DBusHelpers.h b/uriloader/exthandler/DBusHelpers.h new file mode 100644 index 0000000000..4f4f64309d --- /dev/null +++ b/uriloader/exthandler/DBusHelpers.h @@ -0,0 +1,84 @@ +/* -*- Mode: c++; c-basic-offset: 2; indent-tabs-mode: nil; tab-width: 40 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_DBusHelpers_h +#define mozilla_DBusHelpers_h + +#include <dbus/dbus.h> +#include "mozilla/UniquePtr.h" +#include "mozilla/RefPtr.h" + +namespace mozilla { + +template <> +struct RefPtrTraits<DBusMessage> { + static void AddRef(DBusMessage* aMessage) { + MOZ_ASSERT(aMessage); + dbus_message_ref(aMessage); + } + static void Release(DBusMessage* aMessage) { + MOZ_ASSERT(aMessage); + dbus_message_unref(aMessage); + } +}; + +template <> +struct RefPtrTraits<DBusPendingCall> { + static void AddRef(DBusPendingCall* aPendingCall) { + MOZ_ASSERT(aPendingCall); + dbus_pending_call_ref(aPendingCall); + } + static void Release(DBusPendingCall* aPendingCall) { + MOZ_ASSERT(aPendingCall); + dbus_pending_call_unref(aPendingCall); + } +}; + +/* + * |RefPtrTraits<DBusConnection>| specializes |RefPtrTraits<>| + * for managing |DBusConnection| with |RefPtr|. + * + * |RefPtrTraits<DBusConnection>| will _not_ close the DBus + * connection upon the final unref. The caller is responsible + * for closing the connection. + */ +template <> +struct RefPtrTraits<DBusConnection> { + static void AddRef(DBusConnection* aConnection) { + MOZ_ASSERT(aConnection); + dbus_connection_ref(aConnection); + } + static void Release(DBusConnection* aConnection) { + MOZ_ASSERT(aConnection); + dbus_connection_unref(aConnection); + } +}; + +/* + * |DBusConnectionDelete| is a deleter for managing instances + * of |DBusConnection| in |UniquePtr|. Upon destruction, it + * will close an open connection before unref'ing the data + * structure. + * + * Do not use |UniquePtr| with shared DBus connections. For + * shared connections, use |RefPtr|. + */ +class DBusConnectionDelete { + public: + constexpr DBusConnectionDelete() {} + + void operator()(DBusConnection* aConnection) const { + MOZ_ASSERT(aConnection); + if (dbus_connection_get_is_connected(aConnection)) { + dbus_connection_close(aConnection); + } + dbus_connection_unref(aConnection); + } +}; + +} // namespace mozilla + +#endif // mozilla_DBusHelpers_h diff --git a/uriloader/exthandler/ExtHandlerService.sys.mjs b/uriloader/exthandler/ExtHandlerService.sys.mjs new file mode 100644 index 0000000000..aad2c0b8d9 --- /dev/null +++ b/uriloader/exthandler/ExtHandlerService.sys.mjs @@ -0,0 +1,757 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const { + saveToDisk, + alwaysAsk, + useHelperApp, + handleInternally, + useSystemDefault, +} = Ci.nsIHandlerInfo; + +const TOPIC_PDFJS_HANDLER_CHANGED = "pdfjs:handlerChanged"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + kHandlerList: "resource://gre/modules/handlers/HandlerList.sys.mjs", + kHandlerListVersion: "resource://gre/modules/handlers/HandlerList.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "externalProtocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "MIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); + +export function HandlerService() { + // Observe handlersvc-json-replace so we can switch to the datasource + Services.obs.addObserver(this, "handlersvc-json-replace", true); +} + +HandlerService.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsISupportsWeakReference", + "nsIHandlerService", + "nsIObserver", + ]), + + __store: null, + get _store() { + if (!this.__store) { + this.__store = new lazy.JSONFile({ + path: PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "handlers.json" + ), + dataPostProcessor: this._dataPostProcessor.bind(this), + }); + } + + // Always call this even if this.__store was set, since it may have been + // set by asyncInit, which might not have completed yet. + this._ensureStoreInitialized(); + return this.__store; + }, + + __storeInitialized: false, + _ensureStoreInitialized() { + if (!this.__storeInitialized) { + this.__storeInitialized = true; + this.__store.ensureDataReady(); + + this._injectDefaultProtocolHandlersIfNeeded(); + this._migrateProtocolHandlersIfNeeded(); + + Services.obs.notifyObservers(null, "handlersvc-store-initialized"); + } + }, + + _dataPostProcessor(data) { + return data.defaultHandlersVersion + ? data + : { + defaultHandlersVersion: {}, + mimeTypes: {}, + schemes: {}, + isDownloadsImprovementsAlreadyMigrated: false, + }; + }, + + /** + * Injects new default protocol handlers if the version in the preferences is + * newer than the one in the data store. + */ + _injectDefaultProtocolHandlersIfNeeded() { + try { + let defaultHandlersVersion = Services.prefs.getIntPref( + "gecko.handlerService.defaultHandlersVersion", + 0 + ); + if (defaultHandlersVersion < lazy.kHandlerListVersion) { + this._injectDefaultProtocolHandlers(); + Services.prefs.setIntPref( + "gecko.handlerService.defaultHandlersVersion", + lazy.kHandlerListVersion + ); + // Now save the result: + this._store.saveSoon(); + } + } catch (ex) { + console.error(ex); + } + }, + + _injectDefaultProtocolHandlers() { + let locale = Services.locale.appLocaleAsBCP47; + + // Initialize handlers to default and update based on locale. + let localeHandlers = lazy.kHandlerList.default; + if (lazy.kHandlerList[locale]) { + for (let scheme in lazy.kHandlerList[locale].schemes) { + localeHandlers.schemes[scheme] = + lazy.kHandlerList[locale].schemes[scheme]; + } + } + + // Now, we're going to cheat. Terribly. The idiologically correct way + // of implementing the following bit of code would be to fetch the + // handler info objects from the protocol service, manipulate those, + // and then store each of them. + // However, that's expensive. It causes us to talk to the OS about + // default apps, which causes the OS to go hit the disk. + // All we're trying to do is insert some web apps into the list. We + // don't care what's already in the file, we just want to do the + // equivalent of appending into the database. So let's just go do that: + for (let scheme of Object.keys(localeHandlers.schemes)) { + if (scheme == "mailto" && AppConstants.MOZ_APP_NAME == "thunderbird") { + // Thunderbird IS a mailto handler, it doesn't need handlers added. + continue; + } + + let existingSchemeInfo = this._store.data.schemes[scheme]; + if (!existingSchemeInfo) { + // Haven't seen this scheme before. Default to asking which app the + // user wants to use: + existingSchemeInfo = { + // Signal to future readers that we didn't ask the OS anything. + // When the entry is first used, get the info from the OS. + stubEntry: true, + // The first item in the list is the preferred handler, and + // there isn't one, so we fill in null: + handlers: [null], + }; + this._store.data.schemes[scheme] = existingSchemeInfo; + } + let { handlers } = existingSchemeInfo; + for (let newHandler of localeHandlers.schemes[scheme].handlers) { + if (!newHandler.uriTemplate) { + console.error( + `Ignoring protocol handler for ${scheme} without a uriTemplate!` + ); + continue; + } + if (!newHandler.uriTemplate.startsWith("https://")) { + console.error( + `Ignoring protocol handler for ${scheme} with insecure template URL ${newHandler.uriTemplate}.` + ); + continue; + } + if (!newHandler.uriTemplate.toLowerCase().includes("%s")) { + console.error( + `Ignoring protocol handler for ${scheme} with invalid template URL ${newHandler.uriTemplate}.` + ); + continue; + } + // If there is already a handler registered with the same template + // URL, ignore the new one: + let matchingTemplate = handler => + handler && handler.uriTemplate == newHandler.uriTemplate; + if (!handlers.some(matchingTemplate)) { + handlers.push(newHandler); + } + } + } + }, + + /** + * Execute any migrations. Migrations are defined here for any changes or removals for + * existing handlers. Additions are still handled via the localized prefs infrastructure. + * + * This depends on the browser.handlers.migrations pref being set by migrateUI in + * nsBrowserGlue (for Fx Desktop) or similar mechanisms for other products. + * This is a comma-separated list of identifiers of migrations that need running. + * This avoids both re-running older migrations and keeping an additional + * pref around permanently. + */ + _migrateProtocolHandlersIfNeeded() { + const kMigrations = { + "30boxes": () => { + const k30BoxesRegex = + /^https?:\/\/(?:www\.)?30boxes.com\/external\/widget/i; + let webcalHandler = + lazy.externalProtocolService.getProtocolHandlerInfo("webcal"); + if (this.exists(webcalHandler)) { + this.fillHandlerInfo(webcalHandler, ""); + let shouldStore = false; + // First remove 30boxes from possible handlers. + let handlers = webcalHandler.possibleApplicationHandlers; + for (let i = handlers.length - 1; i >= 0; i--) { + let app = handlers.queryElementAt(i, Ci.nsIHandlerApp); + if ( + app instanceof Ci.nsIWebHandlerApp && + k30BoxesRegex.test(app.uriTemplate) + ) { + shouldStore = true; + handlers.removeElementAt(i); + } + } + // Then remove as a preferred handler. + if (webcalHandler.preferredApplicationHandler) { + let app = webcalHandler.preferredApplicationHandler; + if ( + app instanceof Ci.nsIWebHandlerApp && + k30BoxesRegex.test(app.uriTemplate) + ) { + webcalHandler.preferredApplicationHandler = null; + shouldStore = true; + } + } + // Then store, if we changed anything. + if (shouldStore) { + this.store(webcalHandler); + } + } + }, + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1526890 for context. + "secure-mail": () => { + const kSubstitutions = new Map([ + [ + "http://compose.mail.yahoo.co.jp/ym/Compose?To=%s", + "https://mail.yahoo.co.jp/compose/?To=%s", + ], + [ + "http://www.inbox.lv/rfc2368/?value=%s", + "https://mail.inbox.lv/compose?to=%s", + ], + [ + "http://poczta.interia.pl/mh/?mailto=%s", + "https://poczta.interia.pl/mh/?mailto=%s", + ], + [ + "http://win.mail.ru/cgi-bin/sentmsg?mailto=%s", + "https://e.mail.ru/cgi-bin/sentmsg?mailto=%s", + ], + ]); + + function maybeReplaceURL(app) { + if (app instanceof Ci.nsIWebHandlerApp) { + let { uriTemplate } = app; + let sub = kSubstitutions.get(uriTemplate); + if (sub) { + app.uriTemplate = sub; + return true; + } + } + return false; + } + let mailHandler = + lazy.externalProtocolService.getProtocolHandlerInfo("mailto"); + if (this.exists(mailHandler)) { + this.fillHandlerInfo(mailHandler, ""); + let handlers = mailHandler.possibleApplicationHandlers; + let shouldStore = false; + for (let i = handlers.length - 1; i >= 0; i--) { + let app = handlers.queryElementAt(i, Ci.nsIHandlerApp); + // Note: will evaluate the RHS because it's a binary rather than + // logical or. + shouldStore |= maybeReplaceURL(app); + } + // Then check the preferred handler. + if (mailHandler.preferredApplicationHandler) { + shouldStore |= maybeReplaceURL( + mailHandler.preferredApplicationHandler + ); + } + // Then store, if we changed anything. Note that store() handles + // duplicates, so we don't have to. + if (shouldStore) { + this.store(mailHandler); + } + } + }, + }; + let migrationsToRun = Services.prefs.getCharPref( + "browser.handlers.migrations", + "" + ); + migrationsToRun = migrationsToRun ? migrationsToRun.split(",") : []; + for (let migration of migrationsToRun) { + migration.trim(); + try { + kMigrations[migration](); + } catch (ex) { + console.error(ex); + } + } + + if (migrationsToRun.length) { + Services.prefs.clearUserPref("browser.handlers.migrations"); + } + }, + + _onDBChange() { + return (async () => { + if (this.__store) { + await this.__store.finalize(); + } + this.__store = null; + this.__storeInitialized = false; + })().catch(console.error); + }, + + // nsIObserver + observe(subject, topic, data) { + if (topic != "handlersvc-json-replace") { + return; + } + let promise = this._onDBChange(); + promise.then(() => { + Services.obs.notifyObservers(null, "handlersvc-json-replace-complete"); + }); + }, + + // nsIHandlerService + asyncInit() { + if (!this.__store) { + this.__store = new lazy.JSONFile({ + path: PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "handlers.json" + ), + dataPostProcessor: this._dataPostProcessor.bind(this), + }); + this.__store + .load() + .then(() => { + // __store can be null if we called _onDBChange in the mean time. + if (this.__store) { + this._ensureStoreInitialized(); + } + }) + .catch(console.error); + } + }, + + // nsIHandlerService + enumerate() { + let handlers = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + for (let [type, typeInfo] of Object.entries(this._store.data.mimeTypes)) { + let primaryExtension = typeInfo.extensions?.[0] ?? null; + let handler = lazy.MIMEService.getFromTypeAndExtension( + type, + primaryExtension + ); + handlers.appendElement(handler); + } + for (let type of Object.keys(this._store.data.schemes)) { + // nsIExternalProtocolService.getProtocolHandlerInfo can be expensive + // on Windows, so we return a proxy to delay retrieving the nsIHandlerInfo + // until one of its properties is accessed. + // + // Note: our caller still needs to yield periodically when iterating + // the enumerator and accessing handler properties to avoid monopolizing + // the main thread. + // + let handler = new Proxy( + { + QueryInterface: ChromeUtils.generateQI(["nsIHandlerInfo"]), + type, + get _handlerInfo() { + delete this._handlerInfo; + return (this._handlerInfo = + lazy.externalProtocolService.getProtocolHandlerInfo(type)); + }, + }, + { + get(target, name) { + return target[name] || target._handlerInfo[name]; + }, + set(target, name, value) { + target._handlerInfo[name] = value; + }, + } + ); + handlers.appendElement(handler); + } + return handlers.enumerate(Ci.nsIHandlerInfo); + }, + + // nsIHandlerService + store(handlerInfo) { + let handlerList = this._getHandlerListByHandlerInfoType(handlerInfo); + + // Retrieve an existing entry if present, instead of creating a new one, so + // that we preserve unknown properties for forward compatibility. + let storedHandlerInfo = handlerList[handlerInfo.type]; + if (!storedHandlerInfo) { + storedHandlerInfo = {}; + handlerList[handlerInfo.type] = storedHandlerInfo; + } + + // Only a limited number of preferredAction values is allowed. + if ( + handlerInfo.preferredAction == saveToDisk || + handlerInfo.preferredAction == useSystemDefault || + handlerInfo.preferredAction == handleInternally || + // For files (ie mimetype rather than protocol handling info), ensure + // we can store the "always ask" state, too: + (handlerInfo.preferredAction == alwaysAsk && + this._isMIMEInfo(handlerInfo)) + ) { + storedHandlerInfo.action = handlerInfo.preferredAction; + } else { + storedHandlerInfo.action = useHelperApp; + } + + if (handlerInfo.alwaysAskBeforeHandling) { + storedHandlerInfo.ask = true; + } else { + delete storedHandlerInfo.ask; + } + + // Build a list of unique nsIHandlerInfo instances to process later. + let handlers = []; + if (handlerInfo.preferredApplicationHandler) { + handlers.push(handlerInfo.preferredApplicationHandler); + } + for (let handler of handlerInfo.possibleApplicationHandlers.enumerate( + Ci.nsIHandlerApp + )) { + // If the caller stored duplicate handlers, we save them only once. + if (!handlers.some(h => h.equals(handler))) { + handlers.push(handler); + } + } + + // If any of the nsIHandlerInfo instances cannot be serialized, it is not + // included in the final list. The first element is always the preferred + // handler, or null if there is none. + let serializableHandlers = handlers + .map(h => this.handlerAppToSerializable(h)) + .filter(h => h); + if (serializableHandlers.length) { + if (!handlerInfo.preferredApplicationHandler) { + serializableHandlers.unshift(null); + } + storedHandlerInfo.handlers = serializableHandlers; + } else { + delete storedHandlerInfo.handlers; + } + + if (this._isMIMEInfo(handlerInfo)) { + let extensions = storedHandlerInfo.extensions || []; + for (let extension of handlerInfo.getFileExtensions()) { + extension = extension.toLowerCase(); + // If the caller stored duplicate extensions, we save them only once. + if (!extensions.includes(extension)) { + extensions.push(extension); + } + } + if (extensions.length) { + storedHandlerInfo.extensions = extensions; + } else { + delete storedHandlerInfo.extensions; + } + } + + // If we're saving *anything*, it stops being a stub: + delete storedHandlerInfo.stubEntry; + + this._store.saveSoon(); + + // Now notify PDF.js. This is hacky, but a lot better than expecting all + // the consumers to do it... + if (handlerInfo.type == "application/pdf") { + Services.obs.notifyObservers(null, TOPIC_PDFJS_HANDLER_CHANGED); + } + }, + + // nsIHandlerService + fillHandlerInfo(handlerInfo, overrideType) { + let type = overrideType || handlerInfo.type; + let storedHandlerInfo = + this._getHandlerListByHandlerInfoType(handlerInfo)[type]; + if (!storedHandlerInfo) { + throw new Components.Exception( + "handlerSvc fillHandlerInfo: don't know this type", + Cr.NS_ERROR_NOT_AVAILABLE + ); + } + + let isStub = !!storedHandlerInfo.stubEntry; + // In the normal case, this is not a stub, so we can just read stored info + // and write to the handlerInfo object we were passed. + if (!isStub) { + handlerInfo.preferredAction = storedHandlerInfo.action; + handlerInfo.alwaysAskBeforeHandling = !!storedHandlerInfo.ask; + } else { + // If we've got a stub, ensure the defaults are still set: + lazy.externalProtocolService.setProtocolHandlerDefaults( + handlerInfo, + handlerInfo.hasDefaultHandler + ); + if ( + handlerInfo.preferredAction == alwaysAsk && + handlerInfo.alwaysAskBeforeHandling + ) { + // `store` will default to `useHelperApp` because `alwaysAsk` is + // not one of the 3 recognized options; for compatibility, do + // the same here. + handlerInfo.preferredAction = useHelperApp; + } + } + // If it *is* a stub, don't override alwaysAskBeforeHandling or the + // preferred actions. Instead, just append the stored handlers, without + // overriding the preferred app, and then schedule a task to store proper + // info for this handler. + this._appendStoredHandlers(handlerInfo, storedHandlerInfo.handlers, isStub); + + if (this._isMIMEInfo(handlerInfo) && storedHandlerInfo.extensions) { + for (let extension of storedHandlerInfo.extensions) { + handlerInfo.appendExtension(extension); + } + } else if (this._mockedHandler) { + this._insertMockedHandler(handlerInfo); + } + }, + + /** + * Private method to inject stored handler information into an nsIHandlerInfo + * instance. + * @param handlerInfo the nsIHandlerInfo instance to write to + * @param storedHandlers the stored handlers + * @param keepPreferredApp whether to keep the handlerInfo's + * preferredApplicationHandler or override it + * (default: false, ie override it) + */ + _appendStoredHandlers(handlerInfo, storedHandlers, keepPreferredApp) { + // If the first item is not null, it is also the preferred handler. Since + // we cannot modify the stored array, use a boolean to keep track of this. + let isFirstItem = true; + for (let handler of storedHandlers || [null]) { + let handlerApp = this.handlerAppFromSerializable(handler || {}); + if (isFirstItem) { + isFirstItem = false; + // Do not overwrite the preferred app unless that's allowed + if (!keepPreferredApp) { + handlerInfo.preferredApplicationHandler = handlerApp; + } + } + if (handlerApp) { + handlerInfo.possibleApplicationHandlers.appendElement(handlerApp); + } + } + }, + + /** + * @param handler + * A nsIHandlerApp handler app + * @returns Serializable representation of a handler app object. + */ + handlerAppToSerializable(handler) { + if (handler instanceof Ci.nsILocalHandlerApp) { + return { + name: handler.name, + path: handler.executable.path, + }; + } else if (handler instanceof Ci.nsIWebHandlerApp) { + return { + name: handler.name, + uriTemplate: handler.uriTemplate, + }; + } else if (handler instanceof Ci.nsIDBusHandlerApp) { + return { + name: handler.name, + service: handler.service, + method: handler.method, + objectPath: handler.objectPath, + dBusInterface: handler.dBusInterface, + }; + } else if (handler instanceof Ci.nsIGIOMimeApp) { + return { + name: handler.name, + command: handler.command, + }; + } + // If the handler is an unknown handler type, return null. + // Android default application handler is the case. + return null; + }, + + /** + * @param handlerObj + * Serializable representation of a handler object. + * @returns {nsIHandlerApp} the handler app, if any; otherwise null + */ + handlerAppFromSerializable(handlerObj) { + let handlerApp; + if ("path" in handlerObj) { + try { + let file = new lazy.FileUtils.File(handlerObj.path); + if (!file.exists()) { + return null; + } + handlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + handlerApp.executable = file; + } catch (ex) { + return null; + } + } else if ("uriTemplate" in handlerObj) { + handlerApp = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + handlerApp.uriTemplate = handlerObj.uriTemplate; + } else if ("service" in handlerObj) { + handlerApp = Cc[ + "@mozilla.org/uriloader/dbus-handler-app;1" + ].createInstance(Ci.nsIDBusHandlerApp); + handlerApp.service = handlerObj.service; + handlerApp.method = handlerObj.method; + handlerApp.objectPath = handlerObj.objectPath; + handlerApp.dBusInterface = handlerObj.dBusInterface; + } else if ("command" in handlerObj && "@mozilla.org/gio-service;1" in Cc) { + try { + handlerApp = Cc["@mozilla.org/gio-service;1"] + .getService(Ci.nsIGIOService) + .createAppFromCommand(handlerObj.command, handlerObj.name); + } catch (ex) { + return null; + } + } else { + return null; + } + + handlerApp.name = handlerObj.name; + return handlerApp; + }, + + /** + * The function returns a reference to the "mimeTypes" or "schemes" object + * based on which type of handlerInfo is provided. + */ + _getHandlerListByHandlerInfoType(handlerInfo) { + return this._isMIMEInfo(handlerInfo) + ? this._store.data.mimeTypes + : this._store.data.schemes; + }, + + /** + * Determines whether an nsIHandlerInfo instance represents a MIME type. + */ + _isMIMEInfo(handlerInfo) { + // We cannot rely only on the instanceof check because on Android both MIME + // types and protocols are instances of nsIMIMEInfo. We still do the check + // so that properties of nsIMIMEInfo become available to the callers. + return ( + handlerInfo instanceof Ci.nsIMIMEInfo && handlerInfo.type.includes("/") + ); + }, + + // nsIHandlerService + exists(handlerInfo) { + return ( + handlerInfo.type in this._getHandlerListByHandlerInfoType(handlerInfo) + ); + }, + + // nsIHandlerService + remove(handlerInfo) { + delete this._getHandlerListByHandlerInfoType(handlerInfo)[handlerInfo.type]; + this._store.saveSoon(); + }, + + // nsIHandlerService + getTypeFromExtension(fileExtension) { + let extension = fileExtension.toLowerCase(); + let mimeTypes = this._store.data.mimeTypes; + for (let type of Object.keys(mimeTypes)) { + if ( + mimeTypes[type].extensions && + mimeTypes[type].extensions.includes(extension) + ) { + return type; + } + } + return ""; + }, + + _mockedHandler: null, + _mockedProtocol: null, + + _insertMockedHandler(handlerInfo) { + if (handlerInfo.type == this._mockedProtocol) { + handlerInfo.preferredApplicationHandler = this._mockedHandler; + handlerInfo.possibleApplicationHandlers.insertElementAt( + this._mockedHandler, + 0 + ); + } + }, + + // test-only: mock the handler instance for a particular protocol/scheme + mockProtocolHandler(protocol) { + if (!protocol) { + this._mockedProtocol = null; + this._mockedHandler = null; + return; + } + this._mockedProtocol = protocol; + this._mockedHandler = { + QueryInterface: ChromeUtils.generateQI([Ci.nsILocalHandlerApp]), + launchWithURI(uri, context) { + Services.obs.notifyObservers(uri, "mocked-protocol-handler"); + }, + name: "Mocked handler", + detailedDescription: "Mocked handler for tests", + equals(x) { + return x == this; + }, + get executable() { + if (AppConstants.platform == "macosx") { + // We need an app path that isn't us, nor in our app bundle, and + // Apple no longer allows us to read the default-shipped apps + // in /Applications/ - except for Safari, it would appear! + let f = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + f.initWithPath("/Applications/Safari.app"); + return f; + } + return Services.dirsvc.get("XCurProcD", Ci.nsIFile); + }, + parameterCount: 0, + clearParameters() {}, + appendParameter() {}, + getParameter() {}, + parameterExists() { + return false; + }, + }; + }, +}; diff --git a/uriloader/exthandler/ExternalHelperAppChild.cpp b/uriloader/exthandler/ExternalHelperAppChild.cpp new file mode 100644 index 0000000000..569d42e7a4 --- /dev/null +++ b/uriloader/exthandler/ExternalHelperAppChild.cpp @@ -0,0 +1,93 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* 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/. */ + +#include "ExternalHelperAppChild.h" +#include "mozilla/dom/BrowserChild.h" +#include "nsIInputStream.h" +#include "nsIRequest.h" +#include "nsIResumableChannel.h" +#include "nsIPropertyBag2.h" +#include "nsNetUtil.h" + +namespace mozilla { +namespace dom { + +NS_IMPL_ISUPPORTS(ExternalHelperAppChild, nsIStreamListener, nsIRequestObserver) + +ExternalHelperAppChild::ExternalHelperAppChild() : mStatus(NS_OK) {} + +ExternalHelperAppChild::~ExternalHelperAppChild() {} + +//----------------------------------------------------------------------------- +// nsIStreamListener +//----------------------------------------------------------------------------- +NS_IMETHODIMP +ExternalHelperAppChild::OnDataAvailable(nsIRequest* request, + nsIInputStream* input, uint64_t offset, + uint32_t count) { + if (NS_FAILED(mStatus)) return mStatus; + + static uint32_t const kCopyChunkSize = 128 * 1024; + uint32_t toRead = std::min<uint32_t>(count, kCopyChunkSize); + + nsCString data; + + while (count) { + nsresult rv = NS_ReadInputStreamToString(input, data, toRead); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + if (NS_WARN_IF(!SendOnDataAvailable(data, offset, toRead))) { + return NS_ERROR_UNEXPECTED; + } + + count -= toRead; + offset += toRead; + toRead = std::min<uint32_t>(count, kCopyChunkSize); + } + + return NS_OK; +} + +////////////////////////////////////////////////////////////////////////////// +// nsIRequestObserver +////////////////////////////////////////////////////////////////////////////// + +NS_IMETHODIMP +ExternalHelperAppChild::OnStartRequest(nsIRequest* request) { + nsresult rv = mHandler->OnStartRequest(request); + NS_ENSURE_SUCCESS(rv, NS_ERROR_UNEXPECTED); + + nsCString entityID; + nsCOMPtr<nsIResumableChannel> resumable(do_QueryInterface(request)); + if (resumable) { + resumable->GetEntityID(entityID); + } + SendOnStartRequest(entityID); + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppChild::OnStopRequest(nsIRequest* request, nsresult status) { + // mHandler can be null if we diverted the request to the parent + if (mHandler) { + nsresult rv = mHandler->OnStopRequest(request, status); + SendOnStopRequest(status); + NS_ENSURE_SUCCESS(rv, NS_ERROR_UNEXPECTED); + } + + return NS_OK; +} + +mozilla::ipc::IPCResult ExternalHelperAppChild::RecvCancel( + const nsresult& aStatus) { + mStatus = aStatus; + return IPC_OK(); +} + +} // namespace dom +} // namespace mozilla diff --git a/uriloader/exthandler/ExternalHelperAppChild.h b/uriloader/exthandler/ExternalHelperAppChild.h new file mode 100644 index 0000000000..2b189499fe --- /dev/null +++ b/uriloader/exthandler/ExternalHelperAppChild.h @@ -0,0 +1,44 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_ExternalHelperAppChild_h +#define mozilla_dom_ExternalHelperAppChild_h + +#include "mozilla/dom/PExternalHelperAppChild.h" +#include "nsExternalHelperAppService.h" +#include "nsIStreamListener.h" + +class nsIDivertableChannel; + +namespace mozilla::dom { + +class BrowserChild; + +class ExternalHelperAppChild : public PExternalHelperAppChild, + public nsIStreamListener { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSISTREAMLISTENER + NS_DECL_NSIREQUESTOBSERVER + + ExternalHelperAppChild(); + + // Give the listener a real nsExternalAppHandler to complete processing on + // the child. + void SetHandler(nsExternalAppHandler* handler) { mHandler = handler; } + + mozilla::ipc::IPCResult RecvCancel(const nsresult& aStatus); + + private: + virtual ~ExternalHelperAppChild(); + + RefPtr<nsExternalAppHandler> mHandler; + nsresult mStatus; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_ExternalHelperAppChild_h diff --git a/uriloader/exthandler/ExternalHelperAppParent.cpp b/uriloader/exthandler/ExternalHelperAppParent.cpp new file mode 100644 index 0000000000..96c7444aba --- /dev/null +++ b/uriloader/exthandler/ExternalHelperAppParent.cpp @@ -0,0 +1,477 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* 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/. */ + +#include "mozilla/DebugOnly.h" + +#include "ExternalHelperAppParent.h" +#include "nsExternalHelperAppService.h" +#include "nsIContent.h" +#include "nsCExternalHandlerService.h" +#include "nsIExternalHelperAppService.h" +#include "mozilla/dom/ContentParent.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/BrowserParent.h" +#include "nsStringStream.h" +#include "mozilla/ipc/URIUtils.h" +#include "nsNetUtil.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/CanonicalBrowsingContext.h" +#include "mozilla/dom/WindowGlobalParent.h" +#include "nsQueryObject.h" + +#include "mozilla/Unused.h" + +using namespace mozilla::ipc; + +namespace mozilla { +namespace dom { + +NS_IMPL_ISUPPORTS_INHERITED(ExternalHelperAppParent, nsHashPropertyBag, + nsIRequest, nsIChannel, nsIMultiPartChannel, + nsIPrivateBrowsingChannel, nsIResumableChannel, + nsIStreamListener, nsIExternalHelperAppParent) + +ExternalHelperAppParent::ExternalHelperAppParent( + nsIURI* uri, const int64_t& aContentLength, const bool& aWasFileChannel, + const nsACString& aContentDispositionHeader, + const uint32_t& aContentDispositionHint, + const nsAString& aContentDispositionFilename) + : mURI(uri), + mPending(false), + mIPCClosed(false), + mLoadFlags(0), + mStatus(NS_OK), + mCanceled(false), + mContentLength(aContentLength), + mWasFileChannel(aWasFileChannel) { + mContentDispositionHeader = aContentDispositionHeader; + if (!mContentDispositionHeader.IsEmpty()) { + NS_GetFilenameFromDisposition(mContentDispositionFilename, + mContentDispositionHeader); + mContentDisposition = + NS_GetContentDispositionFromHeader(mContentDispositionHeader, this); + } else { + mContentDisposition = aContentDispositionHint; + mContentDispositionFilename = aContentDispositionFilename; + } +} + +bool ExternalHelperAppParent::Init( + const mozilla::net::LoadInfoArgs& aLoadInfoArgs, + const nsACString& aMimeContentType, const bool& aForceSave, + nsIURI* aReferrer, BrowsingContext* aContext, + const bool& aShouldCloseWindow) { + nsresult rv = mozilla::ipc::LoadInfoArgsToLoadInfo( + aLoadInfoArgs, ContentParent::Cast(Manager())->GetRemoteType(), + getter_AddRefs(mLoadInfo)); + if (NS_FAILED(rv)) { + return false; + } + + nsCOMPtr<nsIExternalHelperAppService> helperAppService = + do_GetService(NS_EXTERNALHELPERAPPSERVICE_CONTRACTID); + NS_ASSERTION(helperAppService, "No Helper App Service!"); + + if (aReferrer) { + SetPropertyAsInterface(u"docshell.internalReferrer"_ns, aReferrer); + } + + if (aContext) { + WindowGlobalParent* parent = + aContext->Canonical()->GetCurrentWindowGlobal(); + if (parent) { + RefPtr<BrowserParent> browser = parent->GetBrowserParent(); + if (browser) { + bool isPrivate = false; + nsCOMPtr<nsILoadContext> loadContext = browser->GetLoadContext(); + loadContext->GetUsePrivateBrowsing(&isPrivate); + SetPrivate(isPrivate); + } + } + } + + helperAppService->CreateListener(aMimeContentType, this, aContext, aForceSave, + nullptr, getter_AddRefs(mListener)); + if (!mListener) { + return false; + } + + if (aShouldCloseWindow) { + RefPtr<nsExternalAppHandler> handler = do_QueryObject(mListener); + if (handler) { + handler->SetShouldCloseWindow(); + } + } + + return true; +} + +void ExternalHelperAppParent::ActorDestroy(ActorDestroyReason why) { + mIPCClosed = true; +} + +void ExternalHelperAppParent::Delete() { + if (!mIPCClosed) { + Unused << Send__delete__(this); + } +} + +mozilla::ipc::IPCResult ExternalHelperAppParent::RecvOnStartRequest( + const nsACString& entityID) { + mEntityID = entityID; + mPending = true; + mStatus = mListener->OnStartRequest(this); + return IPC_OK(); +} + +mozilla::ipc::IPCResult ExternalHelperAppParent::RecvOnDataAvailable( + const nsACString& data, const uint64_t& offset, const uint32_t& count) { + if (NS_FAILED(mStatus)) { + return IPC_OK(); + } + + MOZ_ASSERT(mPending, "must be pending!"); + + nsCOMPtr<nsIInputStream> stringStream; + DebugOnly<nsresult> rv = NS_NewByteInputStream( + getter_AddRefs(stringStream), Span(data).To(count), NS_ASSIGNMENT_DEPEND); + NS_ASSERTION(NS_SUCCEEDED(rv), "failed to create dependent string!"); + mStatus = mListener->OnDataAvailable(this, stringStream, offset, count); + + return IPC_OK(); +} + +mozilla::ipc::IPCResult ExternalHelperAppParent::RecvOnStopRequest( + const nsresult& code) { + mPending = false; + mListener->OnStopRequest( + this, (NS_SUCCEEDED(code) && NS_FAILED(mStatus)) ? mStatus : code); + Delete(); + return IPC_OK(); +} + +// +// nsIStreamListener +// + +NS_IMETHODIMP +ExternalHelperAppParent::OnDataAvailable(nsIRequest* request, + nsIInputStream* input, uint64_t offset, + uint32_t count) { + return mListener->OnDataAvailable(request, input, offset, count); +} + +NS_IMETHODIMP +ExternalHelperAppParent::OnStartRequest(nsIRequest* request) { + return mListener->OnStartRequest(request); +} + +NS_IMETHODIMP +ExternalHelperAppParent::OnStopRequest(nsIRequest* request, nsresult status) { + nsresult rv = mListener->OnStopRequest(request, status); + Delete(); + return rv; +} + +ExternalHelperAppParent::~ExternalHelperAppParent() {} + +// +// nsIRequest implementation... +// + +NS_IMETHODIMP +ExternalHelperAppParent::GetName(nsACString& aResult) { + if (!mURI) { + aResult.Truncate(); + return NS_ERROR_NOT_AVAILABLE; + } + mURI->GetAsciiSpec(aResult); + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::IsPending(bool* aResult) { + *aResult = mPending; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetStatus(nsresult* aResult) { + *aResult = mStatus; + return NS_OK; +} + +NS_IMETHODIMP ExternalHelperAppParent::SetCanceledReason( + const nsACString& aReason) { + return SetCanceledReasonImpl(aReason); +} + +NS_IMETHODIMP ExternalHelperAppParent::GetCanceledReason(nsACString& aReason) { + return GetCanceledReasonImpl(aReason); +} + +NS_IMETHODIMP ExternalHelperAppParent::CancelWithReason( + nsresult aStatus, const nsACString& aReason) { + return CancelWithReasonImpl(aStatus, aReason); +} + +NS_IMETHODIMP +ExternalHelperAppParent::Cancel(nsresult aStatus) { + mCanceled = true; + mStatus = aStatus; + Unused << SendCancel(aStatus); + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetCanceled(bool* aCanceled) { + *aCanceled = mCanceled; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::Suspend() { return NS_ERROR_NOT_IMPLEMENTED; } + +NS_IMETHODIMP +ExternalHelperAppParent::Resume() { return NS_ERROR_NOT_IMPLEMENTED; } + +// +// nsIChannel implementation +// + +NS_IMETHODIMP +ExternalHelperAppParent::GetOriginalURI(nsIURI** aURI) { + NS_IF_ADDREF(*aURI = mURI); + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetOriginalURI(nsIURI* aURI) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetURI(nsIURI** aURI) { + NS_IF_ADDREF(*aURI = mURI); + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::Open(nsIInputStream** aResult) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::AsyncOpen(nsIStreamListener* aListener) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetLoadFlags(nsLoadFlags* aLoadFlags) { + *aLoadFlags = mLoadFlags; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetLoadFlags(nsLoadFlags aLoadFlags) { + mLoadFlags = aLoadFlags; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetTRRMode(nsIRequest::TRRMode* aTRRMode) { + return GetTRRModeImpl(aTRRMode); +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetTRRMode(nsIRequest::TRRMode aTRRMode) { + return SetTRRModeImpl(aTRRMode); +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetIsDocument(bool* aIsDocument) { + return NS_GetIsDocumentChannel(this, aIsDocument); +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetLoadGroup(nsILoadGroup** aLoadGroup) { + *aLoadGroup = nullptr; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetLoadGroup(nsILoadGroup* aLoadGroup) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetOwner(nsISupports** aOwner) { + *aOwner = nullptr; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetOwner(nsISupports* aOwner) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetLoadInfo(nsILoadInfo** aLoadInfo) { + NS_IF_ADDREF(*aLoadInfo = mLoadInfo); + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetLoadInfo(nsILoadInfo* aLoadInfo) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetNotificationCallbacks( + nsIInterfaceRequestor** aCallbacks) { + *aCallbacks = nullptr; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetNotificationCallbacks( + nsIInterfaceRequestor* aCallbacks) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetSecurityInfo( + nsITransportSecurityInfo** aSecurityInfo) { + *aSecurityInfo = nullptr; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetContentType(nsACString& aContentType) { + aContentType.Truncate(); + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetContentType(const nsACString& aContentType) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetContentCharset(nsACString& aContentCharset) { + aContentCharset.Truncate(); + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetContentCharset(const nsACString& aContentCharset) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetContentDisposition(uint32_t* aContentDisposition) { + // NB: mContentDisposition may or may not be set to a non UINT32_MAX value in + // nsExternalHelperAppService::DoContentContentProcessHelper + if (mContentDispositionHeader.IsEmpty() && mContentDisposition == UINT32_MAX) + return NS_ERROR_NOT_AVAILABLE; + + *aContentDisposition = mContentDisposition; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetContentDisposition(uint32_t aContentDisposition) { + mContentDisposition = aContentDisposition; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetContentDispositionFilename( + nsAString& aContentDispositionFilename) { + if (mContentDispositionFilename.IsEmpty()) { + return NS_ERROR_NOT_AVAILABLE; + } + + aContentDispositionFilename = mContentDispositionFilename; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetContentDispositionFilename( + const nsAString& aContentDispositionFilename) { + mContentDispositionFilename = aContentDispositionFilename; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetContentDispositionHeader( + nsACString& aContentDispositionHeader) { + if (mContentDispositionHeader.IsEmpty()) { + return NS_ERROR_NOT_AVAILABLE; + } + + aContentDispositionHeader = mContentDispositionHeader; + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetContentLength(int64_t* aContentLength) { + if (mContentLength < 0) { + *aContentLength = -1; + } else { + *aContentLength = mContentLength; + } + return NS_OK; +} + +NS_IMETHODIMP +ExternalHelperAppParent::SetContentLength(int64_t aContentLength) { + mContentLength = aContentLength; + return NS_OK; +} + +// +// nsIResumableChannel implementation +// + +NS_IMETHODIMP +ExternalHelperAppParent::ResumeAt(uint64_t startPos, + const nsACString& entityID) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetEntityID(nsACString& aEntityID) { + aEntityID = mEntityID; + return NS_OK; +} + +// +// nsIMultiPartChannel implementation +// + +NS_IMETHODIMP +ExternalHelperAppParent::GetBaseChannel(nsIChannel** aChannel) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetPartID(uint32_t* aPartID) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetIsFirstPart(bool* aIsLastPart) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +ExternalHelperAppParent::GetIsLastPart(bool* aIsLastPart) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +} // namespace dom +} // namespace mozilla diff --git a/uriloader/exthandler/ExternalHelperAppParent.h b/uriloader/exthandler/ExternalHelperAppParent.h new file mode 100644 index 0000000000..540889e648 --- /dev/null +++ b/uriloader/exthandler/ExternalHelperAppParent.h @@ -0,0 +1,118 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_dom_ExternalHelperAppParent_h +#define mozilla_dom_ExternalHelperAppParent_h + +#include "mozilla/dom/PExternalHelperAppParent.h" +#include "mozilla/ipc/BackgroundUtils.h" +#include "nsIChannel.h" +#include "nsIMultiPartChannel.h" +#include "nsIResumableChannel.h" +#include "nsIStreamListener.h" +#include "nsHashPropertyBag.h" +#include "mozilla/net/PrivateBrowsingChannel.h" + +namespace IPC { +class URI; +} // namespace IPC + +class nsExternalAppHandler; + +namespace mozilla { + +namespace net { +class PChannelDiverterParent; +} // namespace net + +namespace dom { + +#define NS_IEXTERNALHELPERAPPPARENT_IID \ + { \ + 0x127a01bc, 0x2a49, 0x46a8, { \ + 0x8c, 0x63, 0x4b, 0x5d, 0x3c, 0xa4, 0x07, 0x9c \ + } \ + } + +class nsIExternalHelperAppParent : public nsISupports { + public: + NS_DECLARE_STATIC_IID_ACCESSOR(NS_IEXTERNALHELPERAPPPARENT_IID) + + /** + * Returns true if this fake channel represented a file channel in the child. + */ + virtual bool WasFileChannel() = 0; +}; + +NS_DEFINE_STATIC_IID_ACCESSOR(nsIExternalHelperAppParent, + NS_IEXTERNALHELPERAPPPARENT_IID) + +class ContentParent; +class PBrowserParent; + +class ExternalHelperAppParent + : public PExternalHelperAppParent, + public nsHashPropertyBag, + public nsIChannel, + public nsIMultiPartChannel, + public nsIResumableChannel, + public nsIStreamListener, + public net::PrivateBrowsingChannel<ExternalHelperAppParent>, + public nsIExternalHelperAppParent { + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_NSIREQUEST + NS_DECL_NSICHANNEL + NS_DECL_NSIMULTIPARTCHANNEL + NS_DECL_NSIRESUMABLECHANNEL + NS_DECL_NSISTREAMLISTENER + NS_DECL_NSIREQUESTOBSERVER + + mozilla::ipc::IPCResult RecvOnStartRequest( + const nsACString& entityID) override; + mozilla::ipc::IPCResult RecvOnDataAvailable(const nsACString& data, + const uint64_t& offset, + const uint32_t& count) override; + mozilla::ipc::IPCResult RecvOnStopRequest(const nsresult& code) override; + + bool WasFileChannel() override { return mWasFileChannel; } + + ExternalHelperAppParent(nsIURI* uri, const int64_t& contentLength, + const bool& wasFileChannel, + const nsACString& aContentDispositionHeader, + const uint32_t& aContentDispositionHint, + const nsAString& aContentDispositionFilename); + bool Init(const mozilla::net::LoadInfoArgs& aLoadInfoArgs, + const nsACString& aMimeContentType, const bool& aForceSave, + nsIURI* aReferrer, BrowsingContext* aContext, + const bool& aShouldCloseWindow); + + protected: + virtual ~ExternalHelperAppParent(); + + virtual void ActorDestroy(ActorDestroyReason why) override; + void Delete(); + + private: + RefPtr<nsIStreamListener> mListener; + nsCOMPtr<nsIURI> mURI; + nsCOMPtr<nsILoadInfo> mLoadInfo; + bool mPending; + bool mIPCClosed; + nsLoadFlags mLoadFlags; + nsresult mStatus; + bool mCanceled; + int64_t mContentLength; + bool mWasFileChannel; + uint32_t mContentDisposition; + nsString mContentDispositionFilename; + nsCString mContentDispositionHeader; + nsCString mEntityID; +}; + +} // namespace dom +} // namespace mozilla +#endif diff --git a/uriloader/exthandler/HandlerList.sys.mjs b/uriloader/exthandler/HandlerList.sys.mjs new file mode 100644 index 0000000000..e95d627c07 --- /dev/null +++ b/uriloader/exthandler/HandlerList.sys.mjs @@ -0,0 +1,206 @@ +/* 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/. */ + +export const kHandlerListVersion = 1; + +export const kHandlerList = { + default: { + schemes: { + mailto: { + handlers: [ + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + ], + }, + }, + }, + cs: { + schemes: { + mailto: { + handlers: [ + { + name: "Seznam", + uriTemplate: "https://email.seznam.cz/newMessageScreen?mailto=%s", + }, + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + ], + }, + }, + }, + "es-CL": { + schemes: { + mailto: { + handlers: [ + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + { + name: "Outlook", + uriTemplate: + "https://outlook.live.com/default.aspx?rru=compose&to=%s", + }, + ], + }, + }, + }, + "ja-JP-mac": { + schemes: { + mailto: { + handlers: [ + { + name: "Yahoo!メール", + uriTemplate: "https://mail.yahoo.co.jp/compose/?To=%s", + }, + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + ], + }, + }, + }, + ja: { + schemes: { + mailto: { + handlers: [ + { + name: "Yahoo!メール", + uriTemplate: "https://mail.yahoo.co.jp/compose/?To=%s", + }, + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + ], + }, + }, + }, + kk: { + schemes: { + mailto: { + handlers: [ + { + name: "Яндекс.Почта", + uriTemplate: "https://mail.yandex.ru/compose?mailto=%s", + }, + { + name: "Mail.Ru", + uriTemplate: "https://e.mail.ru/cgi-bin/sentmsg?mailto=%s", + }, + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + ], + }, + }, + }, + ltg: { + schemes: { + mailto: { + handlers: [ + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + { + name: "inbox.lv mail", + uriTemplate: "https://mail.inbox.lv/compose?to=%s", + }, + ], + }, + }, + }, + lv: { + schemes: { + mailto: { + handlers: [ + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + { + name: "inbox.lv mail", + uriTemplate: "https://mail.inbox.lv/compose?to=%s", + }, + ], + }, + }, + }, + pl: { + schemes: { + mailto: { + handlers: [ + { + name: "Poczta Interia.pl", + uriTemplate: "https://poczta.interia.pl/mh/?mailto=%s", + }, + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + ], + }, + }, + }, + ru: { + schemes: { + mailto: { + handlers: [ + { + name: "Яндекс.Почту", + uriTemplate: "https://mail.yandex.ru/compose?mailto=%s", + }, + { + name: "Mail.Ru", + uriTemplate: "https://e.mail.ru/cgi-bin/sentmsg?mailto=%s", + }, + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + ], + }, + }, + }, + uk: { + schemes: { + mailto: { + handlers: [ + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + { + name: "Outlook", + uriTemplate: + "https://outlook.live.com/default.aspx?rru=compose&to=%s", + }, + ], + }, + }, + }, + uz: { + schemes: { + mailto: { + handlers: [ + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + { + name: "Mail.Ru", + uriTemplate: "https://e.mail.ru/cgi-bin/sentmsg?mailto=%s", + }, + ], + }, + }, + }, +}; diff --git a/uriloader/exthandler/HandlerServiceChild.h b/uriloader/exthandler/HandlerServiceChild.h new file mode 100644 index 0000000000..5c58a3c230 --- /dev/null +++ b/uriloader/exthandler/HandlerServiceChild.h @@ -0,0 +1,25 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef handler_service_child_h +#define handler_service_child_h + +#include "mozilla/dom/PHandlerServiceChild.h" + +namespace mozilla { + +class HandlerServiceChild final : public mozilla::dom::PHandlerServiceChild { + public: + NS_INLINE_DECL_REFCOUNTING(HandlerServiceChild, final) + HandlerServiceChild() {} + + private: + virtual ~HandlerServiceChild() {} +}; + +} // namespace mozilla + +#endif diff --git a/uriloader/exthandler/HandlerServiceParent.cpp b/uriloader/exthandler/HandlerServiceParent.cpp new file mode 100644 index 0000000000..ab77657dd5 --- /dev/null +++ b/uriloader/exthandler/HandlerServiceParent.cpp @@ -0,0 +1,381 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#include "mozilla/ipc/ProtocolUtils.h" +#include "mozilla/Logging.h" +#include "HandlerServiceParent.h" +#include "nsIHandlerService.h" +#include "nsIMIMEInfo.h" +#include "nsIMIMEService.h" +#include "ContentHandlerService.h" +#include "nsIExternalProtocolService.h" +#include "nsStringEnumerator.h" +#include "nsIMutableArray.h" +#include "nsCExternalHandlerService.h" +#include "nsComponentManagerUtils.h" +#include "nsServiceManagerUtils.h" +#ifdef MOZ_WIDGET_GTK +# include "unix/nsGNOMERegistry.h" +#endif + +using mozilla::dom::ContentHandlerService; +using mozilla::dom::HandlerApp; +using mozilla::dom::HandlerInfo; +using mozilla::dom::RemoteHandlerApp; + +namespace { + +class ProxyHandlerInfo final : public nsIHandlerInfo { + public: + explicit ProxyHandlerInfo(const HandlerInfo& aHandlerInfo); + NS_DECL_ISUPPORTS; + NS_DECL_NSIHANDLERINFO; + + nsTArray<nsCString>& Extensions() { return mHandlerInfo.extensions(); } + + protected: + ~ProxyHandlerInfo() {} + HandlerInfo mHandlerInfo; + nsHandlerInfoAction mPrefAction; + nsCOMPtr<nsIMutableArray> mPossibleApps; +}; + +NS_IMPL_ISUPPORTS(ProxyHandlerInfo, nsIHandlerInfo) + +ProxyHandlerInfo::ProxyHandlerInfo(const HandlerInfo& aHandlerInfo) + : mHandlerInfo(aHandlerInfo), + mPrefAction(nsIHandlerInfo::alwaysAsk), + mPossibleApps(do_CreateInstance(NS_ARRAY_CONTRACTID)) { + for (auto& happ : aHandlerInfo.possibleApplicationHandlers()) { + mPossibleApps->AppendElement(new RemoteHandlerApp(happ)); + } +} + +/* readonly attribute ACString type; */ +NS_IMETHODIMP ProxyHandlerInfo::GetType(nsACString& aType) { + aType.Assign(mHandlerInfo.type()); + return NS_OK; +} + +/* attribute AString description; */ +NS_IMETHODIMP ProxyHandlerInfo::GetDescription(nsAString& aDescription) { + return NS_ERROR_NOT_IMPLEMENTED; +} +NS_IMETHODIMP ProxyHandlerInfo::SetDescription(const nsAString& aDescription) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* attribute nsIHandlerApp preferredApplicationHandler; */ +NS_IMETHODIMP ProxyHandlerInfo::GetPreferredApplicationHandler( + nsIHandlerApp** aPreferredApplicationHandler) { + *aPreferredApplicationHandler = + new RemoteHandlerApp(mHandlerInfo.preferredApplicationHandler()); + NS_IF_ADDREF(*aPreferredApplicationHandler); + return NS_OK; +} + +NS_IMETHODIMP ProxyHandlerInfo::SetPreferredApplicationHandler( + nsIHandlerApp* aApp) { + nsString name; + nsString detailedDescription; + if (aApp) { + aApp->GetName(name); + aApp->GetDetailedDescription(detailedDescription); + } + + mHandlerInfo.preferredApplicationHandler() = + HandlerApp(name, detailedDescription); + return NS_OK; +} + +/* readonly attribute nsIMutableArray possibleApplicationHandlers; */ +NS_IMETHODIMP ProxyHandlerInfo::GetPossibleApplicationHandlers( + nsIMutableArray** aPossibleApplicationHandlers) { + *aPossibleApplicationHandlers = mPossibleApps; + NS_IF_ADDREF(*aPossibleApplicationHandlers); + return NS_OK; +} + +/* readonly attribute boolean hasDefaultHandler; */ +NS_IMETHODIMP ProxyHandlerInfo::GetHasDefaultHandler(bool* aHasDefaultHandler) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* readonly attribute AString defaultDescription; */ +NS_IMETHODIMP ProxyHandlerInfo::GetDefaultDescription( + nsAString& aDefaultDescription) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* void launchWithURI (in nsIURI aURI, + [optional] in BrowsingContext aBrowsingContext); */ +NS_IMETHODIMP ProxyHandlerInfo::LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext* aBrowsingContext) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* attribute ProxyHandlerInfoAction preferredAction; */ +NS_IMETHODIMP ProxyHandlerInfo::GetPreferredAction( + nsHandlerInfoAction* aPreferredAction) { + *aPreferredAction = mPrefAction; + return NS_OK; +} +NS_IMETHODIMP ProxyHandlerInfo::SetPreferredAction( + nsHandlerInfoAction aPreferredAction) { + mHandlerInfo.preferredAction() = aPreferredAction; + mPrefAction = aPreferredAction; + return NS_OK; +} + +/* attribute boolean alwaysAskBeforeHandling; */ +NS_IMETHODIMP ProxyHandlerInfo::GetAlwaysAskBeforeHandling( + bool* aAlwaysAskBeforeHandling) { + *aAlwaysAskBeforeHandling = mHandlerInfo.alwaysAskBeforeHandling(); + return NS_OK; +} +NS_IMETHODIMP ProxyHandlerInfo::SetAlwaysAskBeforeHandling( + bool aAlwaysAskBeforeHandling) { + mHandlerInfo.alwaysAskBeforeHandling() = aAlwaysAskBeforeHandling; + return NS_OK; +} + +class ProxyMIMEInfo : public nsIMIMEInfo { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIMIMEINFO + NS_FORWARD_NSIHANDLERINFO(mProxyHandlerInfo->); + + explicit ProxyMIMEInfo(const HandlerInfo& aHandlerInfo) + : mProxyHandlerInfo(new ProxyHandlerInfo(aHandlerInfo)) {} + + private: + virtual ~ProxyMIMEInfo() {} + RefPtr<ProxyHandlerInfo> mProxyHandlerInfo; + + protected: + /* additional members */ +}; + +NS_IMPL_ISUPPORTS(ProxyMIMEInfo, nsIMIMEInfo, nsIHandlerInfo) + +/* nsIUTF8StringEnumerator getFileExtensions (); */ +NS_IMETHODIMP ProxyMIMEInfo::GetFileExtensions( + nsIUTF8StringEnumerator** _retval) { + return NS_NewUTF8StringEnumerator(_retval, &mProxyHandlerInfo->Extensions(), + this); +} + +/* void setFileExtensions (in AUTF8String aExtensions); */ +NS_IMETHODIMP ProxyMIMEInfo::SetFileExtensions(const nsACString& aExtensions) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* boolean extensionExists (in AUTF8String aExtension); */ +NS_IMETHODIMP ProxyMIMEInfo::ExtensionExists(const nsACString& aExtension, + bool* _retval) { + *_retval = mProxyHandlerInfo->Extensions().Contains( + aExtension, nsCaseInsensitiveCStringArrayComparator()); + return NS_OK; +} + +/* void appendExtension (in AUTF8String aExtension); */ +NS_IMETHODIMP ProxyMIMEInfo::AppendExtension(const nsACString& aExtension) { + if (!aExtension.IsEmpty() && + !mProxyHandlerInfo->Extensions().Contains( + aExtension, nsCaseInsensitiveCStringArrayComparator())) { + mProxyHandlerInfo->Extensions().AppendElement(aExtension); + } + return NS_OK; +} + +/* attribute AUTF8String primaryExtension; */ +NS_IMETHODIMP ProxyMIMEInfo::GetPrimaryExtension( + nsACString& aPrimaryExtension) { + const auto& extensions = mProxyHandlerInfo->Extensions(); + if (extensions.IsEmpty()) { + aPrimaryExtension.Truncate(); + return NS_ERROR_FAILURE; + } + aPrimaryExtension = extensions[0]; + return NS_OK; +} + +NS_IMETHODIMP ProxyMIMEInfo::SetPrimaryExtension( + const nsACString& aPrimaryExtension) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* readonly attribute ACString MIMEType; */ +NS_IMETHODIMP ProxyMIMEInfo::GetMIMEType(nsACString& aMIMEType) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* boolean equals (in nsIMIMEInfo aMIMEInfo); */ +NS_IMETHODIMP ProxyMIMEInfo::Equals(nsIMIMEInfo* aMIMEInfo, bool* _retval) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* readonly attribute nsIArray possibleLocalHandlers; */ +NS_IMETHODIMP ProxyMIMEInfo::GetPossibleLocalHandlers( + nsIArray** aPossibleLocalHandlers) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* void launchWithFile (in nsIFile aFile); */ +NS_IMETHODIMP ProxyMIMEInfo::LaunchWithFile(nsIFile* aFile) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/* boolean isCurrentAppOSDefault(); */ +NS_IMETHODIMP ProxyMIMEInfo::IsCurrentAppOSDefault(bool* _retval) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +static already_AddRefed<nsIHandlerInfo> WrapHandlerInfo( + const HandlerInfo& aHandlerInfo) { + nsCOMPtr<nsIHandlerInfo> info; + if (aHandlerInfo.isMIMEInfo()) { + info = new ProxyMIMEInfo(aHandlerInfo); + } else { + info = new ProxyHandlerInfo(aHandlerInfo); + } + return info.forget(); +} + +} // anonymous namespace + +HandlerServiceParent::HandlerServiceParent() {} + +HandlerServiceParent::~HandlerServiceParent() {} + +mozilla::ipc::IPCResult HandlerServiceParent::RecvFillHandlerInfo( + const HandlerInfo& aHandlerInfoData, const nsACString& aOverrideType, + HandlerInfo* handlerInfoData) { + nsCOMPtr<nsIHandlerInfo> info(WrapHandlerInfo(aHandlerInfoData)); + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID); + handlerSvc->FillHandlerInfo(info, aOverrideType); + ContentHandlerService::nsIHandlerInfoToHandlerInfo(info, handlerInfoData); + return IPC_OK(); +} + +mozilla::ipc::IPCResult HandlerServiceParent::RecvGetMIMEInfoFromOS( + const nsACString& aMIMEType, const nsACString& aExtension, nsresult* aRv, + HandlerInfo* aHandlerInfoData, bool* aFound) { + *aFound = false; + if (aMIMEType.Length() > MAX_MIMETYPE_LENGTH || + aExtension.Length() > MAX_EXT_LENGTH) { + *aRv = NS_OK; + return IPC_OK(); + } + + nsCOMPtr<nsIMIMEService> mimeService = + do_GetService(NS_MIMESERVICE_CONTRACTID, aRv); + if (NS_WARN_IF(NS_FAILED(*aRv))) { + return IPC_OK(); + } + + nsCOMPtr<nsIMIMEInfo> mimeInfo; + *aRv = mimeService->GetMIMEInfoFromOS(aMIMEType, aExtension, aFound, + getter_AddRefs(mimeInfo)); + if (NS_WARN_IF(NS_FAILED(*aRv))) { + return IPC_OK(); + } + + if (mimeInfo) { + ContentHandlerService::nsIHandlerInfoToHandlerInfo(mimeInfo, + aHandlerInfoData); + } + + return IPC_OK(); +} + +mozilla::ipc::IPCResult HandlerServiceParent::RecvExists( + const HandlerInfo& aHandlerInfo, bool* exists) { + nsCOMPtr<nsIHandlerInfo> info(WrapHandlerInfo(aHandlerInfo)); + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID); + handlerSvc->Exists(info, exists); + return IPC_OK(); +} + +mozilla::ipc::IPCResult HandlerServiceParent::RecvExistsForProtocolOS( + const nsACString& aProtocolScheme, bool* aHandlerExists) { + if (aProtocolScheme.Length() > MAX_SCHEME_LENGTH) { + *aHandlerExists = false; + return IPC_OK(); + } +#ifdef MOZ_WIDGET_GTK + // Check the GNOME registry for a protocol handler + *aHandlerExists = + nsGNOMERegistry::HandlerExists(PromiseFlatCString(aProtocolScheme).get()); +#else + *aHandlerExists = false; +#endif + return IPC_OK(); +} + +/* + * Check if a handler exists for the provided protocol. Check the datastore + * first and then fallback to checking the OS for a handler. + */ +mozilla::ipc::IPCResult HandlerServiceParent::RecvExistsForProtocol( + const nsACString& aProtocolScheme, bool* aHandlerExists) { + if (aProtocolScheme.Length() > MAX_SCHEME_LENGTH) { + *aHandlerExists = false; + return IPC_OK(); + } + // Check the datastore and fallback to an OS check. + // ExternalProcotolHandlerExists() does the fallback. + nsresult rv; + nsCOMPtr<nsIExternalProtocolService> protoSvc = + do_GetService(NS_EXTERNALPROTOCOLSERVICE_CONTRACTID, &rv); + if (NS_WARN_IF(NS_FAILED(rv))) { + *aHandlerExists = false; + return IPC_OK(); + } + rv = protoSvc->ExternalProtocolHandlerExists( + PromiseFlatCString(aProtocolScheme).get(), aHandlerExists); + + if (NS_WARN_IF(NS_FAILED(rv))) { + *aHandlerExists = false; + } + return IPC_OK(); +} + +mozilla::ipc::IPCResult HandlerServiceParent::RecvGetTypeFromExtension( + const nsACString& aFileExtension, nsCString* type) { + if (aFileExtension.Length() > MAX_EXT_LENGTH) { + return IPC_OK(); + } + + nsresult rv; + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID, &rv); + if (NS_WARN_IF(NS_FAILED(rv))) { + return IPC_OK(); + } + + rv = handlerSvc->GetTypeFromExtension(aFileExtension, *type); + mozilla::Unused << NS_WARN_IF(NS_FAILED(rv)); + + return IPC_OK(); +} + +mozilla::ipc::IPCResult HandlerServiceParent::RecvGetApplicationDescription( + const nsACString& aScheme, nsresult* aRv, nsString* aDescription) { + if (aScheme.Length() > MAX_SCHEME_LENGTH) { + *aRv = NS_ERROR_NOT_AVAILABLE; + return IPC_OK(); + } + nsCOMPtr<nsIExternalProtocolService> protoSvc = + do_GetService(NS_EXTERNALPROTOCOLSERVICE_CONTRACTID); + NS_ASSERTION(protoSvc, "No Helper App Service!"); + *aRv = protoSvc->GetApplicationDescription(aScheme, *aDescription); + return IPC_OK(); +} + +void HandlerServiceParent::ActorDestroy(ActorDestroyReason aWhy) {} diff --git a/uriloader/exthandler/HandlerServiceParent.h b/uriloader/exthandler/HandlerServiceParent.h new file mode 100644 index 0000000000..a8952f743e --- /dev/null +++ b/uriloader/exthandler/HandlerServiceParent.h @@ -0,0 +1,67 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef handler_service_parent_h +#define handler_service_parent_h + +#include "mozilla/dom/PHandlerServiceParent.h" +#include "nsIMIMEInfo.h" + +class nsIHandlerApp; + +class HandlerServiceParent final : public mozilla::dom::PHandlerServiceParent { + public: + HandlerServiceParent(); + NS_INLINE_DECL_REFCOUNTING(HandlerServiceParent, final) + + private: + virtual ~HandlerServiceParent(); + void ActorDestroy(ActorDestroyReason aWhy) override; + + mozilla::ipc::IPCResult RecvFillHandlerInfo( + const HandlerInfo& aHandlerInfoData, const nsACString& aOverrideType, + HandlerInfo* handlerInfoData) override; + + mozilla::ipc::IPCResult RecvGetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aExtension, + nsresult* aRv, + HandlerInfo* aHandlerInfoData, + bool* aFound) override; + + mozilla::ipc::IPCResult RecvExists(const HandlerInfo& aHandlerInfo, + bool* exists) override; + + mozilla::ipc::IPCResult RecvGetTypeFromExtension( + const nsACString& aFileExtension, nsCString* type) override; + + mozilla::ipc::IPCResult RecvExistsForProtocolOS( + const nsACString& aProtocolScheme, bool* aHandlerExists) override; + + mozilla::ipc::IPCResult RecvExistsForProtocol( + const nsACString& aProtocolScheme, bool* aHandlerExists) override; + + mozilla::ipc::IPCResult RecvGetApplicationDescription( + const nsACString& aScheme, nsresult* aRv, + nsString* aDescription) override; + + /* + * Limit the length of MIME types, filename extensions, and protocol + * schemes we'll consider. + */ + static const size_t MAX_MIMETYPE_LENGTH = 129; /* Per RFC 6838, type and + subtype should be limited + to 64 characters. We add + one more to account for + a '/' separator. */ + static const size_t MAX_EXT_LENGTH = 64; /* Arbitratily chosen to be + longer than any known + extension */ + static const size_t MAX_SCHEME_LENGTH = 1024; /* Arbitratily chosen to be + longer than any known + protocol scheme */ +}; + +#endif diff --git a/uriloader/exthandler/PExternalHelperApp.ipdl b/uriloader/exthandler/PExternalHelperApp.ipdl new file mode 100644 index 0000000000..1a57de7cc0 --- /dev/null +++ b/uriloader/exthandler/PExternalHelperApp.ipdl @@ -0,0 +1,29 @@ +/* vim: set ft=cpp: */ +/* 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/. */ + +include protocol PBrowser; +include protocol PContent; + +namespace mozilla { +namespace dom { + +[ParentImpl=virtual] +protocol PExternalHelperApp +{ + manager PContent; + +parent: + async OnStartRequest(nsCString entityID); + async OnDataAvailable(nsCString data, uint64_t offset, uint32_t count); + async OnStopRequest(nsresult code); + +child: + async Cancel(nsresult aStatus); + async __delete__(); +}; + + +} // namespace dom +} // namespace mozilla diff --git a/uriloader/exthandler/PHandlerService.ipdl b/uriloader/exthandler/PHandlerService.ipdl new file mode 100644 index 0000000000..bcd1e74668 --- /dev/null +++ b/uriloader/exthandler/PHandlerService.ipdl @@ -0,0 +1,62 @@ +/* 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/. */ + +include protocol PContent; + +namespace mozilla { +namespace dom { + +struct HandlerApp { + nsString name; + nsString detailedDescription; +}; + +struct HandlerInfo { + nsCString type; + bool isMIMEInfo; + nsString description; + bool alwaysAskBeforeHandling; + nsCString[] extensions; + HandlerApp preferredApplicationHandler; + HandlerApp[] possibleApplicationHandlers; + int32_t preferredAction; +}; + +[ChildImpl=virtual, ParentImpl=virtual] +sync protocol PHandlerService +{ + manager PContent; + +parent: + sync FillHandlerInfo(HandlerInfo aHandlerInfoData, + nsCString aOverrideType) + returns (HandlerInfo handlerInfoData); + + /* + * Check if an OS handler exists for the given protocol scheme. + */ + sync ExistsForProtocolOS(nsCString aProtocolScheme) + returns (bool exists); + + /* + * Check if a handler exists for the given protocol scheme. Check + * the datastore first and then fallback to an OS handler check. + */ + sync ExistsForProtocol(nsCString aProtocolScheme) + returns (bool exists); + + sync Exists(HandlerInfo aHandlerInfo) + returns (bool exists); + sync GetTypeFromExtension(nsCString aFileExtension) + returns (nsCString type); + sync GetMIMEInfoFromOS(nsCString aMIMEType, nsCString aExtension) + returns (nsresult rv, HandlerInfo handlerInfoData, bool found); + sync GetApplicationDescription(nsCString aScheme) + returns (nsresult rv, nsString description); + async __delete__(); +}; + + +} // namespace dom +} // namespace mozilla diff --git a/uriloader/exthandler/WebHandlerApp.sys.mjs b/uriloader/exthandler/WebHandlerApp.sys.mjs new file mode 100644 index 0000000000..229561c64d --- /dev/null +++ b/uriloader/exthandler/WebHandlerApp.sys.mjs @@ -0,0 +1,185 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +export function nsWebHandlerApp() {} + +nsWebHandlerApp.prototype = { + classDescription: "A web handler for protocols and content", + classID: Components.ID("8b1ae382-51a9-4972-b930-56977a57919d"), + contractID: "@mozilla.org/uriloader/web-handler-app;1", + QueryInterface: ChromeUtils.generateQI(["nsIWebHandlerApp", "nsIHandlerApp"]), + + _name: null, + _detailedDescription: null, + _uriTemplate: null, + + // nsIHandlerApp + + get name() { + return this._name; + }, + + set name(aName) { + this._name = aName; + }, + + get detailedDescription() { + return this._detailedDescription; + }, + + set detailedDescription(aDesc) { + this._detailedDescription = aDesc; + }, + + equals(aHandlerApp) { + if (!aHandlerApp) { + throw Components.Exception("", Cr.NS_ERROR_NULL_POINTER); + } + + if ( + aHandlerApp instanceof Ci.nsIWebHandlerApp && + aHandlerApp.uriTemplate && + this.uriTemplate && + aHandlerApp.uriTemplate == this.uriTemplate + ) { + return true; + } + return false; + }, + + launchWithURI(aURI, aBrowsingContext) { + // XXX need to strip passwd & username from URI to handle, as per the + // WhatWG HTML5 draft. nsSimpleURL, which is what we're going to get, + // can't do this directly. Ideally, we'd fix nsStandardURL to make it + // possible to turn off all of its quirks handling, and use that... + + let { scheme } = aURI; + if (scheme == "ftp" || scheme == "ftps" || scheme == "sftp") { + // FTP URLs are parsed by nsStandardURL, so clearing the username and + // password does not throw. + aURI = aURI.mutate().setUserPass("").finalize(); + } + + // encode the URI to be handled + var escapedUriSpecToHandle = encodeURIComponent(aURI.spec); + + // insert the encoded URI and create the object version. + var uriToSend = Services.io.newURI( + this.uriTemplate.replace("%s", escapedUriSpecToHandle) + ); + + let policy = WebExtensionPolicy.getByURI(uriToSend); + let privateAllowed = !policy || policy.privateBrowsingAllowed; + + if (!scheme.startsWith("web+") && !scheme.startsWith("ext+")) { + // If we're in a frame, check if we're a built-in scheme, in which case, + // override the target browsingcontext. It's not a good idea to try to + // load mail clients or other apps with potential for logged in data into + // iframes, and in any case it's unlikely to work due to framing + // restrictions employed by the target site. + if (aBrowsingContext && aBrowsingContext != aBrowsingContext.top) { + aBrowsingContext = null; + } + + // Unset the browsing context when in a pinned tab and changing hosts + // to force loading the mail handler in a new unpinned tab. + if (aBrowsingContext?.top.isAppTab) { + let docURI = aBrowsingContext.currentWindowGlobal.documentURI; + let docHost, sendHost; + + try { + docHost = docURI?.host; + sendHost = uriToSend?.host; + } catch (e) {} + + // Special case: ignore "www" prefix if it is part of host string + if (docHost?.startsWith("www.")) { + docHost = docHost.replace(/^www\./, ""); + } + if (sendHost?.startsWith("www.")) { + sendHost = sendHost.replace(/^www\./, ""); + } + + if (docHost != sendHost) { + aBrowsingContext = null; + } + } + } + + // if we have a context, use the URI loader to load there + if (aBrowsingContext) { + if (aBrowsingContext.usePrivateBrowsing && !privateAllowed) { + throw Components.Exception( + "Extension not allowed in private windows.", + Cr.NS_ERROR_FILE_NOT_FOUND + ); + } + + let triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + Services.tm.dispatchToMainThread(() => + aBrowsingContext.loadURI(uriToSend, { triggeringPrincipal }) + ); + return; + } + + // The window type depends on the app. + const windowType = + AppConstants.MOZ_APP_NAME == "thunderbird" + ? "mail:3pane" + : "navigator:browser"; + let win = Services.wm.getMostRecentWindow(windowType); + + // If this is an extension handler, check private browsing access. + if (!privateAllowed && lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + throw Components.Exception( + "Extension not allowed in private windows.", + Cr.NS_ERROR_FILE_NOT_FOUND + ); + } + + // If we get an exception, there are several possible reasons why: + // a) this gecko embedding doesn't provide an nsIBrowserDOMWindow + // implementation (i.e. doesn't support browser-style functionality), + // so we need to kick the URL out to the OS default browser. This is + // the subject of bug 394479. + // b) this embedding does provide an nsIBrowserDOMWindow impl, but + // there doesn't happen to be a browser window open at the moment; one + // should be opened. It's not clear whether this situation will really + // ever occur in real life. If it does, the only API that I can find + // that seems reasonably likely to work for most embedders is the + // command line handler. + // c) something else went wrong + // + // It's not clear how one would differentiate between the three cases + // above, so for now we don't catch the exception. + + // openURI + win.browserDOMWindow.openURI( + uriToSend, + null, // no window.opener + Ci.nsIBrowserDOMWindow.OPEN_DEFAULTWINDOW, + Ci.nsIBrowserDOMWindow.OPEN_NEW, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }, + + // nsIWebHandlerApp + + get uriTemplate() { + return this._uriTemplate; + }, + + set uriTemplate(aURITemplate) { + this._uriTemplate = aURITemplate; + }, +}; diff --git a/uriloader/exthandler/android/nsMIMEInfoAndroid.cpp b/uriloader/exthandler/android/nsMIMEInfoAndroid.cpp new file mode 100644 index 0000000000..86049a55fb --- /dev/null +++ b/uriloader/exthandler/android/nsMIMEInfoAndroid.cpp @@ -0,0 +1,340 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*- + * 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/. */ +#include "nsMIMEInfoAndroid.h" +#include "nsArrayUtils.h" +#include "nsISupportsUtils.h" + +using namespace mozilla; + +NS_IMPL_ISUPPORTS(nsMIMEInfoAndroid, nsIMIMEInfo, nsIHandlerInfo) + +NS_IMETHODIMP +nsMIMEInfoAndroid::LaunchDefaultWithFile(nsIFile* aFile) { + return LaunchWithFile(aFile); +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::LoadUriInternal(nsIURI* aURI) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +bool nsMIMEInfoAndroid::GetMimeInfoForMimeType(const nsACString& aMimeType, + nsMIMEInfoAndroid** aMimeInfo) { + RefPtr<nsMIMEInfoAndroid> info = new nsMIMEInfoAndroid(aMimeType); + info.forget(aMimeInfo); + return false; +} + +bool nsMIMEInfoAndroid::GetMimeInfoForFileExt(const nsACString& aFileExt, + nsMIMEInfoAndroid** aMimeInfo) { + nsCString mimeType; + if (jni::IsAvailable()) { + auto javaString = java::GeckoAppShell::GetMimeTypeFromExtensions(aFileExt); + if (javaString) { + mimeType = javaString->ToCString(); + } + } + + // "*/*" means that the bridge didn't know. + if (mimeType.Equals(nsDependentCString("*/*"), + nsCaseInsensitiveCStringComparator)) { + return false; + } + + bool found = GetMimeInfoForMimeType(mimeType, aMimeInfo); + (*aMimeInfo)->SetPrimaryExtension(aFileExt); + return found; +} + +/** + * Returns MIME info for the aURL, which may contain the whole URL or only a + * protocol + */ +nsresult nsMIMEInfoAndroid::GetMimeInfoForURL(const nsACString& aURL, + bool* found, + nsIHandlerInfo** info) { + nsMIMEInfoAndroid* mimeinfo = new nsMIMEInfoAndroid(aURL); + NS_ADDREF(*info = mimeinfo); + *found = false; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetType(nsACString& aType) { + aType.Assign(mType); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetDescription(nsAString& aDesc) { + aDesc.Assign(mDescription); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::SetDescription(const nsAString& aDesc) { + mDescription.Assign(aDesc); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetPreferredApplicationHandler(nsIHandlerApp** aApp) { + *aApp = mPrefApp; + NS_IF_ADDREF(*aApp); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::SetPreferredApplicationHandler(nsIHandlerApp* aApp) { + mPrefApp = aApp; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetPossibleApplicationHandlers( + nsIMutableArray** aHandlerApps) { + if (!mHandlerApps) mHandlerApps = do_CreateInstance(NS_ARRAY_CONTRACTID); + + if (!mHandlerApps) return NS_ERROR_OUT_OF_MEMORY; + + *aHandlerApps = mHandlerApps; + NS_IF_ADDREF(*aHandlerApps); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetHasDefaultHandler(bool* aHasDefault) { + uint32_t len; + *aHasDefault = false; + if (!mHandlerApps) return NS_OK; + + if (NS_FAILED(mHandlerApps->GetLength(&len))) return NS_OK; + + if (len == 0) return NS_OK; + + *aHasDefault = true; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetDefaultDescription(nsAString& aDesc) { + aDesc.Assign(u""_ns); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext* aBrowsingContext) { + return mPrefApp->LaunchWithURI(aURI, aBrowsingContext); +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetPreferredAction(nsHandlerInfoAction* aPrefAction) { + *aPrefAction = mPrefAction; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::SetPreferredAction(nsHandlerInfoAction aPrefAction) { + mPrefAction = aPrefAction; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetAlwaysAskBeforeHandling(bool* aAlwaysAsk) { + *aAlwaysAsk = mAlwaysAsk; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::SetAlwaysAskBeforeHandling(bool aAlwaysAsk) { + mAlwaysAsk = aAlwaysAsk; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetFileExtensions(nsIUTF8StringEnumerator** aResult) { + return NS_NewUTF8StringEnumerator(aResult, &mExtensions, this); +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::SetFileExtensions(const nsACString& aExtensions) { + mExtensions.Clear(); + nsACString::const_iterator start, end; + aExtensions.BeginReading(start); + aExtensions.EndReading(end); + while (start != end) { + nsACString::const_iterator cursor = start; + mozilla::Unused << FindCharInReadable(',', cursor, end); + AddUniqueExtension(Substring(start, cursor)); + // If a comma was found, skip it for the next search. + start = cursor != end ? ++cursor : cursor; + } + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::ExtensionExists(const nsACString& aExtension, + bool* aRetVal) { + NS_ASSERTION(!aExtension.IsEmpty(), "no extension"); + + nsCString mimeType; + if (jni::IsAvailable()) { + auto javaString = + java::GeckoAppShell::GetMimeTypeFromExtensions(aExtension); + if (javaString) { + mimeType = javaString->ToCString(); + } + } + + // "*/*" means the bridge didn't find anything (i.e., extension doesn't + // exist). + *aRetVal = !mimeType.Equals(nsDependentCString("*/*"), + nsCaseInsensitiveCStringComparator); + return NS_OK; +} + +void nsMIMEInfoAndroid::AddUniqueExtension(const nsACString& aExtension) { + if (!aExtension.IsEmpty() && + !mExtensions.Contains(aExtension, + nsCaseInsensitiveCStringArrayComparator())) { + mExtensions.AppendElement(aExtension); + } +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::AppendExtension(const nsACString& aExtension) { + MOZ_ASSERT(!aExtension.IsEmpty(), "No extension"); + AddUniqueExtension(aExtension); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetPrimaryExtension(nsACString& aPrimaryExtension) { + if (!mExtensions.Length()) { + aPrimaryExtension.Truncate(); + return NS_ERROR_NOT_INITIALIZED; + } + aPrimaryExtension = mExtensions[0]; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::SetPrimaryExtension(const nsACString& aExtension) { + if (MOZ_UNLIKELY(aExtension.IsEmpty())) { + // Don't assert since Java may return an empty extension for unknown types. + return NS_ERROR_INVALID_ARG; + } + int32_t i = mExtensions.IndexOf(aExtension, 0, + nsCaseInsensitiveCStringArrayComparator()); + if (i != -1) { + mExtensions.RemoveElementAt(i); + } + mExtensions.InsertElementAt(0, aExtension); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetMIMEType(nsACString& aMIMEType) { + aMIMEType.Assign(mType); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::Equals(nsIMIMEInfo* aMIMEInfo, bool* aRetVal) { + if (!aMIMEInfo) return NS_ERROR_NULL_POINTER; + + nsAutoCString type; + nsresult rv = aMIMEInfo->GetMIMEType(type); + if (NS_FAILED(rv)) return rv; + + *aRetVal = mType.Equals(type); + + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::GetPossibleLocalHandlers(nsIArray** aPossibleLocalHandlers) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::LaunchWithFile(nsIFile* aFile) { + nsCOMPtr<nsIURI> uri; + NS_NewFileURI(getter_AddRefs(uri), aFile); + return LoadUriInternal(uri); +} + +NS_IMETHODIMP +nsMIMEInfoAndroid::IsCurrentAppOSDefault(bool* aRetVal) { + // FIXME: this should in theory be meaningfully implemented. However, android + // implements its own version of nsIHandlerApp instances which internally + // have package and class names - but do not expose those. So to meaningfully + // compare the handler app would require access to those and knowing what + // our own package/class names are, and it's not clear how to do that. + // It also seems less important to do this right on Android, given that + // Android UI normally limits what apps you can associate with what files, so + // it shouldn't be possible to get into the same kind of loop as on desktop. + *aRetVal = false; + return NS_OK; +} + +nsMIMEInfoAndroid::nsMIMEInfoAndroid(const nsACString& aMIMEType) + : mType(aMIMEType), + mAlwaysAsk(true), + mPrefAction(nsIMIMEInfo::useHelperApp) { + mPrefApp = new nsMIMEInfoAndroid::SystemChooser(this); + nsresult rv; + mHandlerApps = do_CreateInstance(NS_ARRAY_CONTRACTID, &rv); + mHandlerApps->AppendElement(mPrefApp); +} + +#define SYSTEMCHOOSER_NAME u"Android chooser" +#define SYSTEMCHOOSER_DESCRIPTION \ + u"Android's default handler application chooser" + +NS_IMPL_ISUPPORTS(nsMIMEInfoAndroid::SystemChooser, nsIHandlerApp) + +nsresult nsMIMEInfoAndroid::SystemChooser::GetName(nsAString& aName) { + aName.AssignLiteral(SYSTEMCHOOSER_NAME); + return NS_OK; +} + +nsresult nsMIMEInfoAndroid::SystemChooser::SetName(const nsAString&) { + return NS_OK; +} + +nsresult nsMIMEInfoAndroid::SystemChooser::GetDetailedDescription( + nsAString& aDesc) { + aDesc.AssignLiteral(SYSTEMCHOOSER_DESCRIPTION); + return NS_OK; +} + +nsresult nsMIMEInfoAndroid::SystemChooser::SetDetailedDescription( + const nsAString&) { + return NS_OK; +} + +nsresult nsMIMEInfoAndroid::SystemChooser::Equals(nsIHandlerApp* aHandlerApp, + bool* aRetVal) { + *aRetVal = false; + if (!aHandlerApp) { + return NS_OK; + } + + nsAutoString name; + nsAutoString detailedDescription; + aHandlerApp->GetName(name); + aHandlerApp->GetDetailedDescription(detailedDescription); + + *aRetVal = name.Equals(SYSTEMCHOOSER_NAME) && + detailedDescription.Equals(SYSTEMCHOOSER_DESCRIPTION); + return NS_OK; +} + +nsresult nsMIMEInfoAndroid::SystemChooser::LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext*) { + return mOuter->LoadUriInternal(aURI); +} diff --git a/uriloader/exthandler/android/nsMIMEInfoAndroid.h b/uriloader/exthandler/android/nsMIMEInfoAndroid.h new file mode 100644 index 0000000000..48693fd99c --- /dev/null +++ b/uriloader/exthandler/android/nsMIMEInfoAndroid.h @@ -0,0 +1,62 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*- + * 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/. */ + +#ifndef nsMIMEInfoAndroid_h +#define nsMIMEInfoAndroid_h + +#include "nsMIMEInfoImpl.h" +#include "nsIMutableArray.h" +#include "mozilla/java/GeckoAppShellWrappers.h" + +class nsMIMEInfoAndroid final : public nsIMIMEInfo { + public: + [[nodiscard]] static bool GetMimeInfoForMimeType( + const nsACString& aMimeType, nsMIMEInfoAndroid** aMimeInfo); + [[nodiscard]] static bool GetMimeInfoForFileExt( + const nsACString& aFileExt, nsMIMEInfoAndroid** aMimeInfo); + + [[nodiscard]] static nsresult GetMimeInfoForURL(const nsACString& aURL, + bool* found, + nsIHandlerInfo** info); + + NS_DECL_ISUPPORTS + NS_DECL_NSIMIMEINFO + NS_DECL_NSIHANDLERINFO + + explicit nsMIMEInfoAndroid(const nsACString& aMIMEType); + + private: + ~nsMIMEInfoAndroid() {} + + /** + * Internal helper to avoid adding duplicates. + */ + void AddUniqueExtension(const nsACString& aExtension); + + [[nodiscard]] virtual nsresult LaunchDefaultWithFile(nsIFile* aFile); + [[nodiscard]] virtual nsresult LoadUriInternal(nsIURI* aURI); + nsCOMPtr<nsIMutableArray> mHandlerApps; + nsCString mType; + nsTArray<nsCString> mExtensions; + bool mAlwaysAsk; + nsHandlerInfoAction mPrefAction; + nsString mDescription; + nsCOMPtr<nsIHandlerApp> mPrefApp; + + public: + class SystemChooser final : public nsIHandlerApp { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIHANDLERAPP + explicit SystemChooser(nsMIMEInfoAndroid* aOuter) : mOuter(aOuter) {} + + private: + ~SystemChooser() {} + + nsMIMEInfoAndroid* mOuter; + }; +}; + +#endif /* nsMIMEInfoAndroid_h */ diff --git a/uriloader/exthandler/android/nsOSHelperAppService.cpp b/uriloader/exthandler/android/nsOSHelperAppService.cpp new file mode 100644 index 0000000000..29634af7b6 --- /dev/null +++ b/uriloader/exthandler/android/nsOSHelperAppService.cpp @@ -0,0 +1,61 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*- + * 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/. */ + +#include "nsOSHelperAppService.h" +#include "nsMIMEInfoAndroid.h" + +nsOSHelperAppService::nsOSHelperAppService() : nsExternalHelperAppService() {} + +nsOSHelperAppService::~nsOSHelperAppService() {} + +nsresult nsOSHelperAppService::GetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aFileExt, + bool* aFound, + nsIMIMEInfo** aMIMEInfo) { + RefPtr<nsMIMEInfoAndroid> mimeInfo; + *aFound = false; + if (!aMIMEType.IsEmpty()) + *aFound = nsMIMEInfoAndroid::GetMimeInfoForMimeType( + aMIMEType, getter_AddRefs(mimeInfo)); + if (!*aFound) + *aFound = nsMIMEInfoAndroid::GetMimeInfoForFileExt( + aFileExt, getter_AddRefs(mimeInfo)); + + // Code that calls this requires an object regardless if the OS has + // something for us, so we return the empty object. + if (!*aFound) mimeInfo = new nsMIMEInfoAndroid(aMIMEType); + + mimeInfo.forget(aMIMEInfo); + return NS_OK; +} + +nsresult nsOSHelperAppService::OSProtocolHandlerExists(const char* aScheme, + bool* aExists) { + // Support any URI barring a couple schemes we use in testing; let the + // app decide what to do with them. + nsAutoCString scheme(aScheme); + *aExists = + !scheme.Equals("unsupported"_ns) && !scheme.Equals("unknownextproto"_ns); + return NS_OK; +} + +NS_IMETHODIMP +nsOSHelperAppService::GetApplicationDescription(const nsACString& aScheme, + nsAString& _retval) { + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP +nsOSHelperAppService::IsCurrentAppOSDefaultForProtocol( + const nsACString& aScheme, bool* _retval) { + return NS_ERROR_NOT_AVAILABLE; +} + +nsresult nsOSHelperAppService::GetProtocolHandlerInfoFromOS( + const nsACString& aScheme, bool* found, nsIHandlerInfo** info) { + // We don't want to get protocol handlers from the OS in GV; the app + // should take care of that in NavigationDelegate.onLoadRequest(). + return NS_ERROR_NOT_IMPLEMENTED; +} diff --git a/uriloader/exthandler/android/nsOSHelperAppService.h b/uriloader/exthandler/android/nsOSHelperAppService.h new file mode 100644 index 0000000000..a333c4bcd3 --- /dev/null +++ b/uriloader/exthandler/android/nsOSHelperAppService.h @@ -0,0 +1,38 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*- + * 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/. */ + +#ifndef nsOSHelperAppService_h +#define nsOSHelperAppService_h + +#include "nsCExternalHandlerService.h" +#include "nsExternalHelperAppService.h" + +class nsOSHelperAppService : public nsExternalHelperAppService { + public: + nsOSHelperAppService(); + virtual ~nsOSHelperAppService(); + + nsresult GetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aFileExt, bool* aFound, + nsIMIMEInfo** aMIMEInfo) override; + + [[nodiscard]] nsresult OSProtocolHandlerExists(const char* aScheme, + bool* aExists) override; + + NS_IMETHOD GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval) override; + NS_IMETHOD GetApplicationDescription(const nsACString& aScheme, + nsAString& _retval) override; + NS_IMETHOD IsCurrentAppOSDefaultForProtocol(const nsACString& aScheme, + bool* _retval) override; + + static nsIHandlerApp* CreateAndroidHandlerApp( + const nsAString& aName, const nsAString& aDescription, + const nsAString& aPackageName, const nsAString& aClassName, + const nsACString& aMimeType, const nsAString& aAction = u""_ns); +}; + +#endif /* nsOSHelperAppService_h */ diff --git a/uriloader/exthandler/components.conf b/uriloader/exthandler/components.conf new file mode 100644 index 0000000000..5c3b738c10 --- /dev/null +++ b/uriloader/exthandler/components.conf @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [ + { + 'cid': '{220cc253-b60f-41f6-b9cf-fdcb325f970f}', + 'contract_ids': ['@mozilla.org/uriloader/handler-service-parent;1'], + 'esModule': 'resource://gre/modules/ExtHandlerService.sys.mjs', + 'constructor': 'HandlerService', + }, + { + 'cid': '{8b1ae382-51a9-4972-b930-56977a57919d}', + 'contract_ids': ['@mozilla.org/uriloader/web-handler-app;1'], + 'esModule': 'resource://gre/modules/WebHandlerApp.sys.mjs', + 'constructor': 'nsWebHandlerApp', + }, +] diff --git a/uriloader/exthandler/docs/index.rst b/uriloader/exthandler/docs/index.rst new file mode 100644 index 0000000000..40088ab6d3 --- /dev/null +++ b/uriloader/exthandler/docs/index.rst @@ -0,0 +1,76 @@ +.. _external_helper_app_service: + +External Helper App Service +=========================== + +The external helper app service is responsible for deciding how to handle an +attempt to load come content that cannot be loaded by the browser itself. + +Part of this involves using the Handler Service which manages the users +preferences for what to do by default with different content. + +When a Link is Clicked +---------------------- + +When a link in a page is clicked (or a form submitted) ``nsDocShell`` tests +whether the target protocol can be loaded by the browser itself, this is based +on the preferences under ``network.protocol-handler``. When the browser cannot +load the protocol it calls into ``nsExternalHelperAppService::LoadURI``. + +Some validation checks are performed but ultimateley we look for a registered +protocol handler. First the OS is queried for an app registration for the +protocol and then the handler server is asked to fill in any user settings from +the internal database. If there were no settings from the handler service then +some defaults are applied in ``nsExternalHelperAppService::SetProtocolHandlerDefaults``. + +If there is a default handler app chosen and the settings say to use it without +asking then that happens. If not a dialog s shown asking the user what they +want to do. + +During a Load +------------- + +When content is already being loaded the :ref:`URI Loader Service <uri_loader_service>` +determines whether the browser can handle the content or not. If not it calls +into the external helper app server through ``nsExternalHelperAppService::DoContent``. + +The content type of the loading content is retrieved from the channel. A file +extension is also generated using the Content-Disposition header or if the load +is not a HTTP POST request the file extension is generated from the requested URL. + +We then query the MIME Service for an nsIMIMEInfo to find information about +apps that can handle the content type or file extension based on OS and user +settings, :ref:`see below <mime_service>` for further details. The result is +used to create a ``nsExternalAppHandler`` which is then used as a stream listener +for the content. + +The MIME info object contains settings that control whether to prompt the user +before doing anything and what the default action should be. If we need to ask +the user then a dialog is shown offering to let users cancel the load, save the +content to disk or send it to a registered application handler. + +Assuming the load isn't canceled the content is streamed to disk using a background +file saver with a target ``nsITransfer``. The ``nsITransfer`` is responsible for +showing the download in the UI. + +If the user opted to open the file with an application then once the transfer is +complete then ``nsIMIMEInfo::LaunchWithFile`` is used to +`launch the application <https://searchfox.org/mozilla-central/search?q=nsIMIMEInfo%3A%3ALaunchWithFile&path=>`_. + +.. _mime_service: + +MIME Service +------------ + +The MIME service is responsible for getting an ``nsIMIMEInfo`` object for a +content type or file extension: + +1. Fills out an ``nsIMIMEInfo`` based on OS provided information. This is platform + specific but should try to find the default application registered to handle + the content. +2. Ask the handler service to fill out the ``nsIMIMEInfo`` with information held + in browser settings. This will not overwrite a any application found from + the OS. +3. If one has not been found already then try to find a type description from + a `lookup table <https://searchfox.org/mozilla-central/search?q=extraMimeEntries[]&path=>`_ + or just by appending " File" to the file extension. diff --git a/uriloader/exthandler/mac/nsDecodeAppleFile.cpp b/uriloader/exthandler/mac/nsDecodeAppleFile.cpp new file mode 100644 index 0000000000..69f13ab8c8 --- /dev/null +++ b/uriloader/exthandler/mac/nsDecodeAppleFile.cpp @@ -0,0 +1,361 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsDecodeAppleFile.h" +#include "nsCRT.h" + +NS_IMPL_ADDREF(nsDecodeAppleFile) +NS_IMPL_RELEASE(nsDecodeAppleFile) + +NS_INTERFACE_MAP_BEGIN(nsDecodeAppleFile) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIOutputStream) + NS_INTERFACE_MAP_ENTRY(nsIOutputStream) +NS_INTERFACE_MAP_END + +nsDecodeAppleFile::nsDecodeAppleFile() { + m_state = parseHeaders; + m_dataBufferLength = 0; + m_dataBuffer = (unsigned char*)malloc(MAX_BUFFERSIZE); + m_entries = nullptr; + m_rfRefNum = -1; + m_totalDataForkWritten = 0; + m_totalResourceForkWritten = 0; + m_headerOk = false; + + m_comment[0] = 0; + memset(&m_dates, 0, sizeof(m_dates)); + memset(&m_finderInfo, 0, sizeof(m_dates)); + memset(&m_finderExtraInfo, 0, sizeof(m_dates)); +} + +nsDecodeAppleFile::~nsDecodeAppleFile() { + free(m_dataBuffer); + m_dataBuffer = nullptr; + if (m_entries) delete[] m_entries; +} + +NS_IMETHODIMP nsDecodeAppleFile::Initialize(nsIOutputStream* output, + nsIFile* file) { + m_output = output; + + nsCOMPtr<nsILocalFileMac> macFile = do_QueryInterface(file); + macFile->GetTargetFSSpec(&m_fsFileSpec); + + m_offset = 0; + m_dataForkOffset = 0; + + return NS_OK; +} + +NS_IMETHODIMP nsDecodeAppleFile::Close(void) { + nsresult rv; + rv = m_output->Close(); + + int32_t i; + + if (m_rfRefNum != -1) FSClose(m_rfRefNum); + + /* Check if the file is complete and if it's the case, write file attributes + */ + if (m_headerOk) { + bool dataOk = true; /* It's ok if the file doesn't have a datafork, + therefore set it to true by default. */ + if (m_headers.magic == APPLESINGLE_MAGIC) { + for (i = 0; i < m_headers.entriesCount; i++) + if (ENT_DFORK == m_entries[i].id) { + dataOk = (bool)(m_totalDataForkWritten == m_entries[i].length); + break; + } + } + + bool resourceOk = FALSE; + for (i = 0; i < m_headers.entriesCount; i++) + if (ENT_RFORK == m_entries[i].id) { + resourceOk = (bool)(m_totalResourceForkWritten == m_entries[i].length); + break; + } + + if (dataOk && resourceOk) { + HFileInfo* fpb; + CInfoPBRec cipbr; + + fpb = (HFileInfo*)&cipbr; + fpb->ioVRefNum = m_fsFileSpec.vRefNum; + fpb->ioDirID = m_fsFileSpec.parID; + fpb->ioNamePtr = m_fsFileSpec.name; + fpb->ioFDirIndex = 0; + PBGetCatInfoSync(&cipbr); + + /* set finder info */ + memcpy(&fpb->ioFlFndrInfo, &m_finderInfo, sizeof(FInfo)); + memcpy(&fpb->ioFlXFndrInfo, &m_finderExtraInfo, sizeof(FXInfo)); + fpb->ioFlFndrInfo.fdFlags &= + 0xfc00; /* clear flags maintained by finder */ + + /* set file dates */ + fpb->ioFlCrDat = m_dates.create - CONVERT_TIME; + fpb->ioFlMdDat = m_dates.modify - CONVERT_TIME; + fpb->ioFlBkDat = m_dates.backup - CONVERT_TIME; + + /* update file info */ + fpb->ioDirID = fpb->ioFlParID; + PBSetCatInfoSync(&cipbr); + + /* set comment */ + IOParam vinfo; + GetVolParmsInfoBuffer vp; + DTPBRec dtp; + + memset((void*)&vinfo, 0, sizeof(vinfo)); + vinfo.ioVRefNum = fpb->ioVRefNum; + vinfo.ioBuffer = (Ptr)&vp; + vinfo.ioReqCount = sizeof(vp); + if (PBHGetVolParmsSync((HParmBlkPtr)&vinfo) == noErr && + ((vp.vMAttrib >> bHasDesktopMgr) & 1)) { + memset((void*)&dtp, 0, sizeof(dtp)); + dtp.ioVRefNum = fpb->ioVRefNum; + if (PBDTGetPath(&dtp) == noErr) { + dtp.ioDTBuffer = (Ptr)&m_comment[1]; + dtp.ioNamePtr = fpb->ioNamePtr; + dtp.ioDirID = fpb->ioDirID; + dtp.ioDTReqCount = m_comment[0]; + if (PBDTSetCommentSync(&dtp) == noErr) PBDTFlushSync(&dtp); + } + } + } + } + + return rv; +} + +NS_IMETHODIMP nsDecodeAppleFile::Flush(void) { return m_output->Flush(); } + +NS_IMETHODIMP nsDecodeAppleFile::StreamStatus(void) { + return m_output->StreamStatus(); +} + +NS_IMETHODIMP nsDecodeAppleFile::WriteFrom(nsIInputStream* inStr, + uint32_t count, uint32_t* _retval) { + return m_output->WriteFrom(inStr, count, _retval); +} + +NS_IMETHODIMP nsDecodeAppleFile::WriteSegments(nsReadSegmentFun reader, + void* closure, uint32_t count, + uint32_t* _retval) { + return m_output->WriteSegments(reader, closure, count, _retval); +} + +NS_IMETHODIMP nsDecodeAppleFile::IsNonBlocking(bool* aNonBlocking) { + return m_output->IsNonBlocking(aNonBlocking); +} + +NS_IMETHODIMP nsDecodeAppleFile::Write(const char* buffer, uint32_t bufferSize, + uint32_t* writeCount) { + /* WARNING: to simplify my life, I presume that I should get all appledouble + headers in the first block, else I would have to implement a buffer */ + + const char* buffPtr = buffer; + uint32_t dataCount; + int32_t i; + nsresult rv = NS_OK; + + *writeCount = 0; + + while (bufferSize > 0 && NS_SUCCEEDED(rv)) { + switch (m_state) { + case parseHeaders: + dataCount = sizeof(ap_header) - m_dataBufferLength; + if (dataCount > bufferSize) dataCount = bufferSize; + memcpy(&m_dataBuffer[m_dataBufferLength], buffPtr, dataCount); + m_dataBufferLength += dataCount; + + if (m_dataBufferLength == sizeof(ap_header)) { + memcpy(&m_headers, m_dataBuffer, sizeof(ap_header)); + + /* Check header to be sure we are dealing with the right kind of data, + * else just write it to the data fork. */ + if ((m_headers.magic == APPLEDOUBLE_MAGIC || + m_headers.magic == APPLESINGLE_MAGIC) && + m_headers.version == VERSION && m_headers.entriesCount) { + /* Just to be sure, the filler must contains only 0 */ + for (i = 0; i < 4 && m_headers.fill[i] == 0L; i++) + ; + if (i == 4) m_state = parseEntries; + } + m_dataBufferLength = 0; + + if (m_state == parseHeaders) { + dataCount = 0; + m_state = parseWriteThrough; + } + } + break; + + case parseEntries: + if (!m_entries) { + m_entries = new ap_entry[m_headers.entriesCount]; + if (!m_entries) return NS_ERROR_OUT_OF_MEMORY; + } + uint32_t entriesSize = sizeof(ap_entry) * m_headers.entriesCount; + dataCount = entriesSize - m_dataBufferLength; + if (dataCount > bufferSize) dataCount = bufferSize; + memcpy(&m_dataBuffer[m_dataBufferLength], buffPtr, dataCount); + m_dataBufferLength += dataCount; + + if (m_dataBufferLength == entriesSize) { + for (i = 0; i < m_headers.entriesCount; i++) { + memcpy(&m_entries[i], &m_dataBuffer[i * sizeof(ap_entry)], + sizeof(ap_entry)); + if (m_headers.magic == APPLEDOUBLE_MAGIC) { + uint32_t offset = m_entries[i].offset + m_entries[i].length; + if (offset > m_dataForkOffset) m_dataForkOffset = offset; + } + } + m_headerOk = true; + m_state = parseLookupPart; + } + break; + + case parseLookupPart: + /* which part are we parsing? */ + m_currentPartID = -1; + for (i = 0; i < m_headers.entriesCount; i++) + if (m_offset == m_entries[i].offset && m_entries[i].length) { + m_currentPartID = m_entries[i].id; + m_currentPartLength = m_entries[i].length; + m_currentPartCount = 0; + + switch (m_currentPartID) { + case ENT_DFORK: + m_state = parseDataFork; + break; + case ENT_RFORK: + m_state = parseResourceFork; + break; + + case ENT_COMMENT: + case ENT_DATES: + case ENT_FINFO: + m_dataBufferLength = 0; + m_state = parsePart; + break; + + default: + m_state = parseSkipPart; + break; + } + break; + } + + if (m_currentPartID == -1) { + /* maybe is the datafork of an appledouble file? */ + if (m_offset == m_dataForkOffset) { + m_currentPartID = ENT_DFORK; + m_currentPartLength = -1; + m_currentPartCount = 0; + m_state = parseDataFork; + } else + dataCount = 1; + } + break; + + case parsePart: + dataCount = m_currentPartLength - m_dataBufferLength; + if (dataCount > bufferSize) dataCount = bufferSize; + memcpy(&m_dataBuffer[m_dataBufferLength], buffPtr, dataCount); + m_dataBufferLength += dataCount; + + if (m_dataBufferLength == m_currentPartLength) { + switch (m_currentPartID) { + case ENT_COMMENT: + m_comment[0] = + m_currentPartLength > 255 ? 255 : m_currentPartLength; + memcpy(&m_comment[1], buffPtr, m_comment[0]); + break; + case ENT_DATES: + if (m_currentPartLength == sizeof(m_dates)) + memcpy(&m_dates, buffPtr, m_currentPartLength); + break; + case ENT_FINFO: + if (m_currentPartLength == + (sizeof(m_finderInfo) + sizeof(m_finderExtraInfo))) { + memcpy(&m_finderInfo, buffPtr, sizeof(m_finderInfo)); + memcpy(&m_finderExtraInfo, buffPtr + sizeof(m_finderInfo), + sizeof(m_finderExtraInfo)); + } + break; + } + m_state = parseLookupPart; + } + break; + + case parseSkipPart: + dataCount = m_currentPartLength - m_currentPartCount; + if (dataCount > bufferSize) + dataCount = bufferSize; + else + m_state = parseLookupPart; + break; + + case parseDataFork: + if (m_headers.magic == APPLEDOUBLE_MAGIC) + dataCount = bufferSize; + else { + dataCount = m_currentPartLength - m_currentPartCount; + if (dataCount > bufferSize) + dataCount = bufferSize; + else + m_state = parseLookupPart; + } + + if (m_output) { + uint32_t writeCount; + rv = m_output->Write((const char*)buffPtr, dataCount, &writeCount); + if (dataCount != writeCount) rv = NS_ERROR_FAILURE; + m_totalDataForkWritten += dataCount; + } + + break; + + case parseResourceFork: + dataCount = m_currentPartLength - m_currentPartCount; + if (dataCount > bufferSize) + dataCount = bufferSize; + else + m_state = parseLookupPart; + + if (m_rfRefNum == -1) { + if (noErr != FSpOpenRF(&m_fsFileSpec, fsWrPerm, &m_rfRefNum)) + return NS_ERROR_FAILURE; + } + + long count = dataCount; + if (noErr != FSWrite(m_rfRefNum, &count, buffPtr) || count != dataCount) + return NS_ERROR_FAILURE; + m_totalResourceForkWritten += dataCount; + break; + + case parseWriteThrough: + dataCount = bufferSize; + if (m_output) { + uint32_t writeCount; + rv = m_output->Write((const char*)buffPtr, dataCount, &writeCount); + if (dataCount != writeCount) rv = NS_ERROR_FAILURE; + } + break; + } + + if (dataCount) { + *writeCount += dataCount; + bufferSize -= dataCount; + buffPtr += dataCount; + m_currentPartCount += dataCount; + m_offset += dataCount; + dataCount = 0; + } + } + + return rv; +} diff --git a/uriloader/exthandler/mac/nsDecodeAppleFile.h b/uriloader/exthandler/mac/nsDecodeAppleFile.h new file mode 100644 index 0000000000..22279a8a49 --- /dev/null +++ b/uriloader/exthandler/mac/nsDecodeAppleFile.h @@ -0,0 +1,116 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#ifndef nsDecodeAppleFile_h__ +#define nsDecodeAppleFile_h__ + +#include "nscore.h" +#include "nsCOMPtr.h" +#include "nsIFile.h" +#include "nsIOutputStream.h" + +/* +** applefile definitions used +*/ +#if PRAGMA_STRUCT_ALIGN +# pragma options align = mac68k +#endif + +#define APPLESINGLE_MAGIC 0x00051600L +#define APPLEDOUBLE_MAGIC 0x00051607L +#define VERSION 0x00020000 + +#define NUM_ENTRIES 6 + +#define ENT_DFORK 1L +#define ENT_RFORK 2L +#define ENT_NAME 3L +#define ENT_COMMENT 4L +#define ENT_DATES 8L +#define ENT_FINFO 9L + +#define CONVERT_TIME 1265437696L + +/* +** data type used in the header decoder. +*/ +typedef struct ap_header { + int32_t magic; + int32_t version; + int32_t fill[4]; + int16_t entriesCount; + +} ap_header; + +typedef struct ap_entry { + int32_t id; + int32_t offset; + int32_t length; + +} ap_entry; + +typedef struct ap_dates { + int32_t create, modify, backup, access; + +} ap_dates; + +#if PRAGMA_STRUCT_ALIGN +# pragma options align = reset +#endif + +/* +**Error codes +*/ +enum { errADNotEnoughData = -12099, errADNotSupported, errADBadVersion }; + +class nsDecodeAppleFile : public nsIOutputStream { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIOUTPUTSTREAM + + nsDecodeAppleFile(); + virtual ~nsDecodeAppleFile(); + + [[nodiscard]] nsresult Initialize(nsIOutputStream* output, nsIFile* file); + + private: +#define MAX_BUFFERSIZE 1024 + enum ParserState { + parseHeaders, + parseEntries, + parseLookupPart, + parsePart, + parseSkipPart, + parseDataFork, + parseResourceFork, + parseWriteThrough + }; + + nsCOMPtr<nsIOutputStream> m_output; + FSSpec m_fsFileSpec; + SInt16 m_rfRefNum; + + unsigned char* m_dataBuffer; + int32_t m_dataBufferLength; + ParserState m_state; + ap_header m_headers; + ap_entry* m_entries; + int32_t m_offset; + int32_t m_dataForkOffset; + int32_t m_totalDataForkWritten; + int32_t m_totalResourceForkWritten; + bool m_headerOk; + + int32_t m_currentPartID; + int32_t m_currentPartLength; + int32_t m_currentPartCount; + + Str255 m_comment; + ap_dates m_dates; + FInfo m_finderInfo; + FXInfo m_finderExtraInfo; +}; + +#endif diff --git a/uriloader/exthandler/mac/nsLocalHandlerAppMac.h b/uriloader/exthandler/mac/nsLocalHandlerAppMac.h new file mode 100644 index 0000000000..7c77091b42 --- /dev/null +++ b/uriloader/exthandler/mac/nsLocalHandlerAppMac.h @@ -0,0 +1,26 @@ +/* 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/. */ + +#ifndef NSLOCALHANDLERAPPMAC_H_ +#define NSLOCALHANDLERAPPMAC_H_ + +#include "nsLocalHandlerApp.h" + +class nsLocalHandlerAppMac : public nsLocalHandlerApp { + public: + nsLocalHandlerAppMac() {} + + nsLocalHandlerAppMac(const char16_t* aName, nsIFile* aExecutable) + : nsLocalHandlerApp(aName, aExecutable) {} + + nsLocalHandlerAppMac(const nsAString& aName, nsIFile* aExecutable) + : nsLocalHandlerApp(aName, aExecutable) {} + virtual ~nsLocalHandlerAppMac() {} + + NS_IMETHOD LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext* aBrowsingContext) override; + NS_IMETHOD GetName(nsAString& aName) override; +}; + +#endif /*NSLOCALHANDLERAPPMAC_H_*/ diff --git a/uriloader/exthandler/mac/nsLocalHandlerAppMac.mm b/uriloader/exthandler/mac/nsLocalHandlerAppMac.mm new file mode 100644 index 0000000000..07f0276666 --- /dev/null +++ b/uriloader/exthandler/mac/nsLocalHandlerAppMac.mm @@ -0,0 +1,81 @@ +/* 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/. */ + +#import <CoreFoundation/CoreFoundation.h> +#import <ApplicationServices/ApplicationServices.h> + +#include "nsObjCExceptions.h" +#include "nsLocalHandlerAppMac.h" +#include "nsILocalFileMac.h" +#include "nsIURI.h" + +// We override this to make sure app bundles display their pretty name (without +// .app suffix) +NS_IMETHODIMP nsLocalHandlerAppMac::GetName(nsAString& aName) { + if (mExecutable) { + nsCOMPtr<nsILocalFileMac> macFile = do_QueryInterface(mExecutable); + if (macFile) { + bool isPackage; + (void)macFile->IsPackage(&isPackage); + if (isPackage) return macFile->GetBundleDisplayName(aName); + } + } + + return nsLocalHandlerApp::GetName(aName); +} + +/** + * mostly copy/pasted from nsMacShellService.cpp (which is in browser/, + * so we can't depend on it here). This code probably really wants to live + * somewhere more central (see bug 389922). + */ +NS_IMETHODIMP +nsLocalHandlerAppMac::LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext* aBrowsingContext) { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + nsresult rv; + nsCOMPtr<nsILocalFileMac> lfm(do_QueryInterface(mExecutable, &rv)); + NS_ENSURE_SUCCESS(rv, rv); + + CFURLRef appURL; + rv = lfm->GetCFURL(&appURL); + if (NS_FAILED(rv)) return rv; + + nsAutoCString uriSpec; + aURI->GetAsciiSpec(uriSpec); + + const UInt8* uriString = reinterpret_cast<const UInt8*>(uriSpec.get()); + CFURLRef uri = ::CFURLCreateWithBytes(NULL, uriString, uriSpec.Length(), + kCFStringEncodingUTF8, NULL); + if (!uri) { + ::CFRelease(appURL); + return NS_ERROR_OUT_OF_MEMORY; + } + + CFArrayRef uris = + ::CFArrayCreate(NULL, reinterpret_cast<const void**>(&uri), 1, NULL); + if (!uris) { + ::CFRelease(uri); + ::CFRelease(appURL); + return NS_ERROR_OUT_OF_MEMORY; + } + + LSLaunchURLSpec launchSpec; + launchSpec.appURL = appURL; + launchSpec.itemURLs = uris; + launchSpec.passThruParams = NULL; + launchSpec.launchFlags = kLSLaunchDefaults; + launchSpec.asyncRefCon = NULL; + + OSErr err = ::LSOpenFromURLSpec(&launchSpec, NULL); + + ::CFRelease(uris); + ::CFRelease(uri); + ::CFRelease(appURL); + + return err != noErr ? NS_ERROR_FAILURE : NS_OK; + + NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE); +} diff --git a/uriloader/exthandler/mac/nsMIMEInfoMac.h b/uriloader/exthandler/mac/nsMIMEInfoMac.h new file mode 100644 index 0000000000..2b87601573 --- /dev/null +++ b/uriloader/exthandler/mac/nsMIMEInfoMac.h @@ -0,0 +1,33 @@ +/* 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/. */ + +#ifndef nsMIMEInfoMac_h_ +#define nsMIMEInfoMac_h_ + +#include "nsMIMEInfoImpl.h" + +class nsMIMEInfoMac : public nsMIMEInfoImpl { + public: + explicit nsMIMEInfoMac(const char* aMIMEType = "") + : nsMIMEInfoImpl(aMIMEType) {} + explicit nsMIMEInfoMac(const nsACString& aMIMEType) + : nsMIMEInfoImpl(aMIMEType) {} + nsMIMEInfoMac(const nsACString& aType, HandlerClass aClass) + : nsMIMEInfoImpl(aType, aClass) {} + + NS_IMETHOD LaunchWithFile(nsIFile* aFile) override; + + protected: + [[nodiscard]] virtual nsresult LoadUriInternal(nsIURI* aURI) override; +#ifdef DEBUG + [[nodiscard]] virtual nsresult LaunchDefaultWithFile( + nsIFile* aFile) override { + MOZ_ASSERT_UNREACHABLE("do not call this method, use LaunchWithFile"); + return NS_ERROR_UNEXPECTED; + } +#endif + NS_IMETHOD GetDefaultDescription(nsAString& aDefaultDescription) override; +}; + +#endif diff --git a/uriloader/exthandler/mac/nsMIMEInfoMac.mm b/uriloader/exthandler/mac/nsMIMEInfoMac.mm new file mode 100644 index 0000000000..33dd9d1ee0 --- /dev/null +++ b/uriloader/exthandler/mac/nsMIMEInfoMac.mm @@ -0,0 +1,111 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#import <ApplicationServices/ApplicationServices.h> + +#include "nsComponentManagerUtils.h" +#include "nsObjCExceptions.h" +#include "nsMIMEInfoMac.h" +#include "nsILocalFileMac.h" + +// We override this to make sure app bundles display their pretty name (without +// .app suffix) +NS_IMETHODIMP nsMIMEInfoMac::GetDefaultDescription( + nsAString& aDefaultDescription) { + nsCOMPtr<nsIFile> defaultApp = GetDefaultApplication(); + if (defaultApp) { + nsCOMPtr<nsILocalFileMac> macFile = do_QueryInterface(defaultApp); + if (macFile) { + bool isPackage; + (void)macFile->IsPackage(&isPackage); + if (isPackage) return macFile->GetBundleDisplayName(aDefaultDescription); + } + } + + return nsMIMEInfoImpl::GetDefaultDescription(aDefaultDescription); +} + +NS_IMETHODIMP +nsMIMEInfoMac::LaunchWithFile(nsIFile* aFile) { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + nsCOMPtr<nsIFile> application; + nsresult rv; + + NS_ASSERTION(mClass == eMIMEInfo, "only MIME infos are currently allowed" + "to pass content by value"); + + if (AutomationOnlyCheckIfLaunchStubbed(aFile)) { + return NS_OK; + } + + if (mPreferredAction == useHelperApp) { + // we don't yet support passing content by value (rather than reference) + // to web apps. at some point, we will probably want to. + nsCOMPtr<nsILocalHandlerApp> localHandlerApp = + do_QueryInterface(mPreferredApplication, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + rv = localHandlerApp->GetExecutable(getter_AddRefs(application)); + NS_ENSURE_SUCCESS(rv, rv); + + } else if (mPreferredAction == useSystemDefault) { + application = GetDefaultApplication(); + } else + return NS_ERROR_INVALID_ARG; + + // if we've already got an app, just QI so we have the launchWithDoc method + nsCOMPtr<nsILocalFileMac> app; + if (application) { + app = do_QueryInterface(application, &rv); + if (NS_FAILED(rv)) return rv; + } else { + // otherwise ask LaunchServices for an app directly + nsCOMPtr<nsILocalFileMac> tempFile = do_QueryInterface(aFile, &rv); + if (NS_FAILED(rv)) return rv; + + FSRef tempFileRef; + tempFile->GetFSRef(&tempFileRef); + + FSRef appFSRef; + if (::LSGetApplicationForItem(&tempFileRef, kLSRolesAll, &appFSRef, + nullptr) == noErr) { + app = (do_CreateInstance("@mozilla.org/file/local;1")); + if (!app) return NS_ERROR_FAILURE; + app->InitWithFSRef(&appFSRef); + } else { + return NS_ERROR_FAILURE; + } + } + return app->LaunchWithDoc(aFile, false); + + NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE); +} + +nsresult nsMIMEInfoMac::LoadUriInternal(nsIURI* aURI) { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + NS_ENSURE_ARG_POINTER(aURI); + + nsresult rv = NS_ERROR_FAILURE; + + nsAutoCString uri; + aURI->GetSpec(uri); + if (!uri.IsEmpty()) { + CFURLRef myURLRef = + ::CFURLCreateWithBytes(kCFAllocatorDefault, (const UInt8*)uri.get(), + strlen(uri.get()), kCFStringEncodingUTF8, NULL); + if (myURLRef) { + OSStatus status = ::LSOpenCFURLRef(myURLRef, NULL); + if (status == noErr) rv = NS_OK; + ::CFRelease(myURLRef); + } + } + + return rv; + + NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE); +} diff --git a/uriloader/exthandler/mac/nsOSHelperAppService.h b/uriloader/exthandler/mac/nsOSHelperAppService.h new file mode 100644 index 0000000000..aa97fee5b4 --- /dev/null +++ b/uriloader/exthandler/mac/nsOSHelperAppService.h @@ -0,0 +1,53 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#ifndef nsOSHelperAppService_h__ +#define nsOSHelperAppService_h__ + +// The OS helper app service is a subclass of nsExternalHelperAppService and is +// implemented on each platform. It contains platform specific code for finding +// helper applications for a given mime type in addition to launching those +// applications. This is the Mac version. + +#include "nsExternalHelperAppService.h" +#include "nsCExternalHandlerService.h" +#include "nsCOMPtr.h" + +class nsIMimeInfo; + +class nsOSHelperAppService : public nsExternalHelperAppService { + public: + virtual ~nsOSHelperAppService(); + + // override nsIExternalProtocolService methods + NS_IMETHOD GetApplicationDescription(const nsACString& aScheme, + nsAString& _retval) override; + + NS_IMETHOD IsCurrentAppOSDefaultForProtocol(const nsACString& aScheme, + bool* _retval) override; + + nsresult GetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aFileExt, bool* aFound, + nsIMIMEInfo** aMIMEInfo) override; + + NS_IMETHOD GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval) override; + + // GetFileTokenForPath must be implemented by each platform. + // platformAppPath --> a platform specific path to an application that we got + // out of the rdf data source. This can be a mac file + // spec, a unix path or a windows path depending on the + // platform + // aFile --> an nsIFile representation of that platform application path. + [[nodiscard]] nsresult GetFileTokenForPath(const char16_t* platformAppPath, + nsIFile** aFile) override; + + [[nodiscard]] nsresult OSProtocolHandlerExists(const char* aScheme, + bool* aHandlerExists) override; +}; + +#endif // nsOSHelperAppService_h__ diff --git a/uriloader/exthandler/mac/nsOSHelperAppService.mm b/uriloader/exthandler/mac/nsOSHelperAppService.mm new file mode 100644 index 0000000000..602c2a89d5 --- /dev/null +++ b/uriloader/exthandler/mac/nsOSHelperAppService.mm @@ -0,0 +1,613 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#include <sys/types.h> +#include <sys/stat.h> +#include "mozilla/net/NeckoCommon.h" +#include "mozilla/StaticPrefs_browser.h" +#include "nsComponentManagerUtils.h" +#include "nsOSHelperAppService.h" +#include "nsObjCExceptions.h" +#include "nsISupports.h" +#include "nsString.h" +#include "nsTArray.h" +#include "nsIFile.h" +#include "nsILocalFileMac.h" +#include "nsMimeTypes.h" +#include "nsCRT.h" +#include "nsMIMEInfoMac.h" +#include "nsEmbedCID.h" + +#import <CoreFoundation/CoreFoundation.h> +#import <ApplicationServices/ApplicationServices.h> + +// chrome URL's +#define HELPERAPPLAUNCHER_BUNDLE_URL \ + "chrome://global/locale/helperAppLauncher.properties" +#define BRAND_BUNDLE_URL "chrome://branding/locale/brand.properties" + +nsresult GetDefaultBundleURL(const nsACString& aScheme, CFURLRef* aBundleURL) { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + nsresult rv = NS_ERROR_NOT_AVAILABLE; + + CFStringRef schemeCFString = ::CFStringCreateWithBytes( + kCFAllocatorDefault, (const UInt8*)PromiseFlatCString(aScheme).get(), + aScheme.Length(), kCFStringEncodingUTF8, false); + + if (schemeCFString) { + CFStringRef lookupCFString = + ::CFStringCreateWithFormat(NULL, NULL, CFSTR("%@:"), schemeCFString); + + if (lookupCFString) { + CFURLRef lookupCFURL = + ::CFURLCreateWithString(NULL, lookupCFString, NULL); + + if (lookupCFURL) { + *aBundleURL = + ::LSCopyDefaultApplicationURLForURL(lookupCFURL, kLSRolesAll, NULL); + if (*aBundleURL) { + rv = NS_OK; + } + + ::CFRelease(lookupCFURL); + } + + ::CFRelease(lookupCFString); + } + + ::CFRelease(schemeCFString); + } + + return rv; + + NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE); +} + +using mozilla::LogLevel; + +/* This is an undocumented interface (in the Foundation framework) that has + * been stable since at least 10.2.8 and is still present on SnowLeopard. + * Furthermore WebKit has three public methods (in WebKitSystemInterface.h) + * that are thin wrappers around this interface's last three methods. So + * it's unlikely to change anytime soon. Now that we're no longer using + * Internet Config Services, this is the only way to look up a MIME type + * from an extension, or vice versa. + */ +@class NSURLFileTypeMappingsInternal; + +@interface NSURLFileTypeMappings : NSObject { + NSURLFileTypeMappingsInternal* _internal; +} + ++ (NSURLFileTypeMappings*)sharedMappings; +- (NSString*)MIMETypeForExtension:(NSString*)aString; +- (NSString*)preferredExtensionForMIMEType:(NSString*)aString; +- (NSArray*)extensionsForMIMEType:(NSString*)aString; +@end + +nsOSHelperAppService::~nsOSHelperAppService() {} + +nsresult nsOSHelperAppService::OSProtocolHandlerExists( + const char* aProtocolScheme, bool* aHandlerExists) { + // CFStringCreateWithBytes() can fail even if we're not out of memory -- + // for example if the 'bytes' parameter is something very weird (like + // "\xFF\xFF~"), or possibly if it can't be interpreted as using what's + // specified in the 'encoding' parameter. See bug 548719. + CFStringRef schemeString = ::CFStringCreateWithBytes( + kCFAllocatorDefault, (const UInt8*)aProtocolScheme, + strlen(aProtocolScheme), kCFStringEncodingUTF8, false); + if (schemeString) { + // LSCopyDefaultHandlerForURLScheme() can fail to find the default handler + // for aProtocolScheme when it's never been explicitly set (using + // LSSetDefaultHandlerForURLScheme()). For example, Safari is the default + // handler for the "http" scheme on a newly installed copy of OS X. But + // this (presumably) wasn't done using LSSetDefaultHandlerForURLScheme(), + // so LSCopyDefaultHandlerForURLScheme() will fail to find Safari. To get + // around this we use LSCopyAllHandlersForURLScheme() instead -- which seems + // never to fail. + // http://lists.apple.com/archives/Carbon-dev/2007/May/msg00349.html + // http://www.realsoftware.com/listarchives/realbasic-nug/2008-02/msg00119.html + CFArrayRef handlerArray = ::LSCopyAllHandlersForURLScheme(schemeString); + *aHandlerExists = !!handlerArray; + if (handlerArray) ::CFRelease(handlerArray); + ::CFRelease(schemeString); + } else { + *aHandlerExists = false; + } + return NS_OK; +} + +NS_IMETHODIMP nsOSHelperAppService::GetApplicationDescription( + const nsACString& aScheme, nsAString& _retval) { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + nsresult rv = NS_ERROR_NOT_AVAILABLE; + + CFURLRef handlerBundleURL; + rv = GetDefaultBundleURL(aScheme, &handlerBundleURL); + + if (NS_SUCCEEDED(rv) && handlerBundleURL) { + CFBundleRef handlerBundle = CFBundleCreate(NULL, handlerBundleURL); + if (!handlerBundle) { + ::CFRelease(handlerBundleURL); + return NS_ERROR_OUT_OF_MEMORY; + } + + // Get the human-readable name of the bundle + CFStringRef bundleName = + (CFStringRef)::CFBundleGetValueForInfoDictionaryKey(handlerBundle, + kCFBundleNameKey); + + if (bundleName) { + AutoTArray<UniChar, 255> buffer; + CFIndex bundleNameLength = ::CFStringGetLength(bundleName); + buffer.SetLength(bundleNameLength); + ::CFStringGetCharacters(bundleName, CFRangeMake(0, bundleNameLength), + buffer.Elements()); + _retval.Assign(reinterpret_cast<char16_t*>(buffer.Elements()), + bundleNameLength); + rv = NS_OK; + } + ::CFRelease(handlerBundle); + ::CFRelease(handlerBundleURL); + } + + return rv; + + NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE); +} + +NS_IMETHODIMP nsOSHelperAppService::IsCurrentAppOSDefaultForProtocol( + const nsACString& aScheme, bool* _retval) { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + nsresult rv = NS_ERROR_NOT_AVAILABLE; + + CFURLRef handlerBundleURL; + rv = GetDefaultBundleURL(aScheme, &handlerBundleURL); + if (NS_SUCCEEDED(rv) && handlerBundleURL) { + // Ensure we don't accidentally return success if we can't get an app + // bundle. + rv = NS_ERROR_NOT_AVAILABLE; + CFBundleRef appBundle = ::CFBundleGetMainBundle(); + if (appBundle) { + CFURLRef selfURL = ::CFBundleCopyBundleURL(appBundle); + *_retval = ::CFEqual(selfURL, handlerBundleURL); + rv = NS_OK; + ::CFRelease(selfURL); + } + ::CFRelease(handlerBundleURL); + } + + return rv; + + NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE); +} + +nsresult nsOSHelperAppService::GetFileTokenForPath( + const char16_t* aPlatformAppPath, nsIFile** aFile) { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + nsresult rv; + nsCOMPtr<nsILocalFileMac> localFile( + do_CreateInstance(NS_LOCAL_FILE_CONTRACTID, &rv)); + NS_ENSURE_SUCCESS(rv, rv); + + CFURLRef pathAsCFURL; + CFStringRef pathAsCFString = ::CFStringCreateWithCharacters( + NULL, reinterpret_cast<const UniChar*>(aPlatformAppPath), + NS_strlen(aPlatformAppPath)); + if (!pathAsCFString) return NS_ERROR_OUT_OF_MEMORY; + + if (::CFStringGetCharacterAtIndex(pathAsCFString, 0) == '/') { + // we have a Posix path + pathAsCFURL = ::CFURLCreateWithFileSystemPath(nullptr, pathAsCFString, + kCFURLPOSIXPathStyle, false); + if (!pathAsCFURL) { + ::CFRelease(pathAsCFString); + return NS_ERROR_OUT_OF_MEMORY; + } + } else { + // if it doesn't start with a / it's not an absolute Posix path + // let's check if it's a HFS path left over from old preferences + + // If it starts with a ':' char, it's not an absolute HFS path + // so bail for that, and also if it's empty + if (::CFStringGetLength(pathAsCFString) == 0 || + ::CFStringGetCharacterAtIndex(pathAsCFString, 0) == ':') { + ::CFRelease(pathAsCFString); + return NS_ERROR_FILE_UNRECOGNIZED_PATH; + } + + pathAsCFURL = ::CFURLCreateWithFileSystemPath(nullptr, pathAsCFString, + kCFURLHFSPathStyle, false); + if (!pathAsCFURL) { + ::CFRelease(pathAsCFString); + return NS_ERROR_OUT_OF_MEMORY; + } + } + + rv = localFile->InitWithCFURL(pathAsCFURL); + ::CFRelease(pathAsCFString); + ::CFRelease(pathAsCFURL); + if (NS_FAILED(rv)) return rv; + *aFile = localFile; + NS_IF_ADDREF(*aFile); + + return NS_OK; + + NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE); +} + +// Returns the MIME types an application bundle explicitly claims to handle. +// Returns NULL if aAppRef doesn't explicitly claim to handle any MIME types. +// If the return value is non-NULL, the caller is responsible for freeing it. +// This isn't necessarily the same as the MIME types the application bundle +// is registered to handle in the Launch Services database. (For example +// the Preview application is normally registered to handle the application/pdf +// MIME type, even though it doesn't explicitly claim to handle *any* MIME +// types in its Info.plist. This is probably because Preview does explicitly +// claim to handle the com.adobe.pdf UTI, and Launch Services somehow +// translates this into a claim to support the application/pdf MIME type. +// Launch Services doesn't provide any APIs (documented or undocumented) to +// query which MIME types a given application is registered to handle. So any +// app that wants this information (e.g. the Default Apps pref pane) needs to +// iterate through the entire Launch Services database -- a process which can +// take several seconds.) +static CFArrayRef GetMIMETypesHandledByApp(FSRef* aAppRef) { + CFURLRef appURL = ::CFURLCreateFromFSRef(kCFAllocatorDefault, aAppRef); + if (!appURL) { + return NULL; + } + CFDictionaryRef infoDict = ::CFBundleCopyInfoDictionaryForURL(appURL); + ::CFRelease(appURL); + if (!infoDict) { + return NULL; + } + CFTypeRef cfObject = + ::CFDictionaryGetValue(infoDict, CFSTR("CFBundleDocumentTypes")); + if (!cfObject || (::CFGetTypeID(cfObject) != ::CFArrayGetTypeID())) { + ::CFRelease(infoDict); + return NULL; + } + + CFArrayRef docTypes = static_cast<CFArrayRef>(cfObject); + CFIndex docTypesCount = ::CFArrayGetCount(docTypes); + if (docTypesCount == 0) { + ::CFRelease(infoDict); + return NULL; + } + + CFMutableArrayRef mimeTypes = + ::CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks); + for (CFIndex i = 0; i < docTypesCount; ++i) { + cfObject = ::CFArrayGetValueAtIndex(docTypes, i); + if (!cfObject || (::CFGetTypeID(cfObject) != ::CFDictionaryGetTypeID())) { + continue; + } + CFDictionaryRef typeDict = static_cast<CFDictionaryRef>(cfObject); + + // When this key is present (on OS X 10.5 and later), its contents + // take precedence over CFBundleTypeMIMETypes (and CFBundleTypeExtensions + // and CFBundleTypeOSTypes). + cfObject = ::CFDictionaryGetValue(typeDict, CFSTR("LSItemContentTypes")); + if (cfObject && (::CFGetTypeID(cfObject) == ::CFArrayGetTypeID())) { + continue; + } + + cfObject = ::CFDictionaryGetValue(typeDict, CFSTR("CFBundleTypeMIMETypes")); + if (!cfObject || (::CFGetTypeID(cfObject) != ::CFArrayGetTypeID())) { + continue; + } + CFArrayRef mimeTypeHolder = static_cast<CFArrayRef>(cfObject); + CFArrayAppendArray(mimeTypes, mimeTypeHolder, + ::CFRangeMake(0, ::CFArrayGetCount(mimeTypeHolder))); + } + + ::CFRelease(infoDict); + if (!::CFArrayGetCount(mimeTypes)) { + ::CFRelease(mimeTypes); + mimeTypes = NULL; + } + return mimeTypes; +} + +nsresult nsOSHelperAppService::GetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aFileExt, + bool* aFound, + nsIMIMEInfo** aMIMEInfo) { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + MOZ_ASSERT(XRE_IsParentProcess()); + + *aFound = false; + + const nsCString& flatType = PromiseFlatCString(aMIMEType); + const nsCString& flatExt = PromiseFlatCString(aFileExt); + + MOZ_LOG(sLog, LogLevel::Debug, + ("Mac: HelperAppService lookup for type '%s' ext '%s'\n", + flatType.get(), flatExt.get())); + + // Create a Mac-specific MIME info so we can use Mac-specific members. + RefPtr<nsMIMEInfoMac> mimeInfoMac = new nsMIMEInfoMac(aMIMEType); + + NSAutoreleasePool* localPool = [[NSAutoreleasePool alloc] init]; + + OSStatus err; + bool haveAppForType = false; + bool haveAppForExt = false; + bool typeIsOctetStream = false; + bool typeAppIsDefault = false; + bool extAppIsDefault = false; + FSRef typeAppFSRef; + FSRef extAppFSRef; + + CFStringRef cfMIMEType = NULL; + + if (!aMIMEType.IsEmpty()) { + typeIsOctetStream = + aMIMEType.LowerCaseEqualsLiteral(APPLICATION_OCTET_STREAM); + CFURLRef appURL = NULL; + // CFStringCreateWithCString() can fail even if we're not out of memory -- + // for example if the 'cStr' parameter is something very weird (like + // "\xFF\xFF~"), or possibly if it can't be interpreted as using what's + // specified in the 'encoding' parameter. See bug 548719. + cfMIMEType = ::CFStringCreateWithCString(NULL, flatType.get(), + kCFStringEncodingUTF8); + if (cfMIMEType) { + err = ::LSCopyApplicationForMIMEType(cfMIMEType, kLSRolesAll, &appURL); + if ((err == noErr) && appURL && ::CFURLGetFSRef(appURL, &typeAppFSRef)) { + haveAppForType = true; + MOZ_LOG(sLog, LogLevel::Debug, + ("LSCopyApplicationForMIMEType found a default application\n")); + } + if (appURL) { + ::CFRelease(appURL); + } + } + } + if (!aFileExt.IsEmpty()) { + // CFStringCreateWithCString() can fail even if we're not out of memory -- + // for example if the 'cStr' parameter is something very weird (like + // "\xFF\xFF~"), or possibly if it can't be interpreted as using what's + // specified in the 'encoding' parameter. See bug 548719. + CFStringRef cfExt = + ::CFStringCreateWithCString(NULL, flatExt.get(), kCFStringEncodingUTF8); + if (cfExt) { + err = ::LSGetApplicationForInfo(kLSUnknownType, kLSUnknownCreator, cfExt, + kLSRolesAll, &extAppFSRef, nullptr); + if (err == noErr) { + haveAppForExt = true; + MOZ_LOG(sLog, LogLevel::Debug, + ("LSGetApplicationForInfo found a default application\n")); + } + ::CFRelease(cfExt); + } + } + + if (haveAppForType && haveAppForExt) { + // Do aMIMEType and aFileExt match? + if (::FSCompareFSRefs((const FSRef*)&typeAppFSRef, + (const FSRef*)&extAppFSRef) == noErr) { + typeAppIsDefault = true; + *aFound = true; + } + } else if (haveAppForType) { + // If aFileExt isn't empty, it doesn't match aMIMEType. + if (aFileExt.IsEmpty()) { + typeAppIsDefault = true; + *aFound = true; + } + } + + // If we have an app for the extension, and either don't have one for the + // type, or the type is application/octet-stream (ie "binary blob"), rely + // on the file extension. + if ((!haveAppForType || (!*aFound && typeIsOctetStream)) && haveAppForExt) { + // If aMIMEType isn't empty, it doesn't match aFileExt, which should mean + // that we haven't found a matching app. But make an exception for an app + // that also explicitly claims to handle aMIMEType, or which doesn't claim + // to handle any MIME types. This helps work around the following Apple + // design flaw: + // + // Launch Services is somewhat unreliable about registering Apple apps to + // handle MIME types. Probably this is because Apple has officially + // deprecated support for MIME types (in favor of UTIs). As a result, + // most of Apple's own apps don't explicitly claim to handle any MIME + // types (instead they claim to handle one or more UTIs). So Launch + // Services must contain logic to translate support for a given UTI into + // support for one or more MIME types, and it doesn't always do this + // correctly. For example DiskImageMounter isn't (by default) registered + // to handle the application/x-apple-diskimage MIME type. See bug 675356. + // + // Apple has also deprecated support for file extensions, and Apple apps + // also don't register to handle them. But for some reason Launch Services + // is (apparently) better about translating support for a given UTI into + // support for one or more file extensions. It's not at all clear why. + if (aMIMEType.IsEmpty() || typeIsOctetStream) { + extAppIsDefault = true; + *aFound = true; + } else { + CFArrayRef extAppMIMETypes = GetMIMETypesHandledByApp(&extAppFSRef); + if (extAppMIMETypes) { + if (cfMIMEType) { + if (::CFArrayContainsValue( + extAppMIMETypes, + ::CFRangeMake(0, ::CFArrayGetCount(extAppMIMETypes)), + cfMIMEType)) { + extAppIsDefault = true; + *aFound = true; + } + } + ::CFRelease(extAppMIMETypes); + } else { + extAppIsDefault = true; + *aFound = true; + } + } + } + + if (cfMIMEType) { + ::CFRelease(cfMIMEType); + } + + if (aMIMEType.IsEmpty()) { + if (haveAppForExt) { + // If aMIMEType is empty and we've found a default app for aFileExt, try + // to get the MIME type from aFileExt. (It might also be worth doing + // this when aMIMEType isn't empty but haveAppForType is false -- but + // the doc for this method says that if we have a MIME type (in + // aMIMEType), we need to give it preference.) + NSURLFileTypeMappings* map = [NSURLFileTypeMappings sharedMappings]; + NSString* extStr = [NSString stringWithCString:flatExt.get() + encoding:NSASCIIStringEncoding]; + NSString* typeStr = map ? [map MIMETypeForExtension:extStr] : NULL; + if (typeStr) { + nsAutoCString mimeType; + mimeType.Assign( + (char*)[typeStr cStringUsingEncoding:NSASCIIStringEncoding]); + mimeInfoMac->SetMIMEType(mimeType); + haveAppForType = true; + } else { + // Sometimes the OS won't give us a MIME type for an extension that's + // registered with Launch Services and has a default app: For example + // Real Player registers itself for the "ogg" extension and for the + // audio/x-ogg and application/x-ogg MIME types, but + // MIMETypeForExtension returns nil for the "ogg" extension even on + // systems where Real Player is installed. This is probably an Apple + // bug. But bad things happen if we return an nsIMIMEInfo structure + // with an empty MIME type and set *aFound to true. So in this + // case we need to set it to false here. + haveAppForExt = false; + extAppIsDefault = false; + *aFound = false; + } + } else { + // Otherwise set the MIME type to a reasonable fallback. + mimeInfoMac->SetMIMEType(nsLiteralCString(APPLICATION_OCTET_STREAM)); + } + } + + if (typeAppIsDefault || extAppIsDefault) { + if (haveAppForExt) mimeInfoMac->AppendExtension(aFileExt); + + nsresult rv; + nsCOMPtr<nsILocalFileMac> app( + do_CreateInstance(NS_LOCAL_FILE_CONTRACTID, &rv)); + if (NS_FAILED(rv)) { + [localPool release]; + return rv; + } + + CFStringRef cfAppName = NULL; + if (typeAppIsDefault) { + app->InitWithFSRef(&typeAppFSRef); + ::LSCopyItemAttribute((const FSRef*)&typeAppFSRef, kLSRolesAll, + kLSItemDisplayName, (CFTypeRef*)&cfAppName); + } else { + app->InitWithFSRef(&extAppFSRef); + ::LSCopyItemAttribute((const FSRef*)&extAppFSRef, kLSRolesAll, + kLSItemDisplayName, (CFTypeRef*)&cfAppName); + } + if (cfAppName) { + AutoTArray<UniChar, 255> buffer; + CFIndex appNameLength = ::CFStringGetLength(cfAppName); + buffer.SetLength(appNameLength); + ::CFStringGetCharacters(cfAppName, CFRangeMake(0, appNameLength), + buffer.Elements()); + nsAutoString appName; + appName.Assign(reinterpret_cast<char16_t*>(buffer.Elements()), + appNameLength); + mimeInfoMac->SetDefaultDescription(appName); + ::CFRelease(cfAppName); + } + + mimeInfoMac->SetDefaultApplication(app); + mimeInfoMac->SetPreferredAction(nsIMIMEInfo::saveToDisk); + + } else { + mimeInfoMac->SetPreferredAction(nsIMIMEInfo::saveToDisk); + } + + nsAutoCString mimeType; + mimeInfoMac->GetMIMEType(mimeType); + if (*aFound && !mimeType.IsEmpty()) { + // If we have a MIME type, make sure its extension list is included in our + // list. + NSURLFileTypeMappings* map = [NSURLFileTypeMappings sharedMappings]; + NSString* typeStr = [NSString stringWithCString:mimeType.get() + encoding:NSASCIIStringEncoding]; + NSArray* extensionsList = map ? [map extensionsForMIMEType:typeStr] : NULL; + if (extensionsList) { + for (NSString* extension in extensionsList) { + nsAutoCString ext; + ext.Assign( + (char*)[extension cStringUsingEncoding:NSASCIIStringEncoding]); + mimeInfoMac->AppendExtension(ext); + } + } + + if (CFStringRef cfType = ::CFStringCreateWithCString( + NULL, mimeType.get(), kCFStringEncodingUTF8)) { + if (CFStringRef cfTypeDesc = ::UTTypeCopyDescription(cfType)) { + AutoTArray<UniChar, 255> buffer; + CFIndex typeDescLength = ::CFStringGetLength(cfTypeDesc); + buffer.SetLength(typeDescLength); + ::CFStringGetCharacters(cfTypeDesc, CFRangeMake(0, typeDescLength), + buffer.Elements()); + nsAutoString typeDesc; + typeDesc.Assign(reinterpret_cast<char16_t*>(buffer.Elements()), + typeDescLength); + mimeInfoMac->SetDescription(typeDesc); + ::CFRelease(cfTypeDesc); + } + ::CFRelease(cfType); + } + } + + MOZ_LOG(sLog, LogLevel::Debug, + ("OS gave us: type '%s' found '%i'\n", mimeType.get(), *aFound)); + + [localPool release]; + mimeInfoMac.forget(aMIMEInfo); + return NS_OK; + + NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE); +} + +NS_IMETHODIMP +nsOSHelperAppService::GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval) { + NS_ASSERTION(!aScheme.IsEmpty(), "No scheme was specified!"); + + nsresult rv = + OSProtocolHandlerExists(nsPromiseFlatCString(aScheme).get(), found); + if (NS_FAILED(rv)) return rv; + + nsMIMEInfoMac* handlerInfo = + new nsMIMEInfoMac(aScheme, nsMIMEInfoBase::eProtocolInfo); + NS_ENSURE_TRUE(handlerInfo, NS_ERROR_OUT_OF_MEMORY); + NS_ADDREF(*_retval = handlerInfo); + + if (!*found) { + // Code that calls this requires an object regardless if the OS has + // something for us, so we return the empty object. + return NS_OK; + } + + // As a workaround for the OS X problem described in bug 1391186, don't + // attempt to get/set the application description from the child process. + if (!mozilla::net::IsNeckoChild()) { + nsAutoString desc; + rv = GetApplicationDescription(aScheme, desc); + NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "GetApplicationDescription failed"); + handlerInfo->SetDefaultDescription(desc); + } + + return NS_OK; +} diff --git a/uriloader/exthandler/moz.build b/uriloader/exthandler/moz.build new file mode 100644 index 0000000000..cb96c690a6 --- /dev/null +++ b/uriloader/exthandler/moz.build @@ -0,0 +1,143 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +SPHINX_TREES["/uriloader/exthandler"] = "docs" + +with Files("**"): + BUG_COMPONENT = ("Firefox", "File Handling") + +TEST_DIRS += ["tests"] + +XPIDL_SOURCES += [ + "nsCExternalHandlerService.idl", + "nsIContentDispatchChooser.idl", + "nsIExternalHelperAppService.idl", + "nsIExternalProtocolService.idl", + "nsIHandlerService.idl", + "nsIHelperAppLauncherDialog.idl", + "nsISharingHandlerApp.idl", +] + +XPIDL_MODULE = "exthandler" + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows": + osdir = "win" + LOCAL_INCLUDES += ["win"] +elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa": + osdir = "mac" +elif CONFIG["MOZ_WIDGET_TOOLKIT"] in ("android", "uikit"): + osdir = CONFIG["MOZ_WIDGET_TOOLKIT"] +else: + osdir = "unix" + +EXPORTS += [osdir + "/nsOSHelperAppService.h"] + +EXPORTS += [ + "ContentHandlerService.h", + "nsExternalHelperAppService.h", + "nsMIMEInfoChild.h", + "nsOSHelperAppServiceChild.h", +] + +EXPORTS += [ + "nsLocalHandlerApp.h", +] + +EXPORTS.mozilla.dom += [ + "ExternalHelperAppChild.h", + "ExternalHelperAppParent.h", +] + +UNIFIED_SOURCES += [ + "ContentHandlerService.cpp", + "ExternalHelperAppChild.cpp", + "ExternalHelperAppParent.cpp", + "HandlerServiceParent.cpp", + "nsExternalHelperAppService.cpp", + "nsExternalProtocolHandler.cpp", + "nsLocalHandlerApp.cpp", + "nsMIMEInfoImpl.cpp", + "nsOSHelperAppServiceChild.cpp", +] + +if CONFIG["MOZ_BUILD_APP"] == "browser": + DEFINES["MOZ_BUILD_APP_IS_BROWSER"] = True + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa": + UNIFIED_SOURCES += [ + "mac/nsLocalHandlerAppMac.mm", + "mac/nsMIMEInfoMac.mm", + "mac/nsOSHelperAppService.mm", + ] +elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "uikit": + UNIFIED_SOURCES += [ + "uikit/nsLocalHandlerAppUIKit.mm", + "uikit/nsMIMEInfoUIKit.mm", + "uikit/nsOSHelperAppService.mm", + ] +else: + # These files can't be built in unified mode because they redefine LOG. + SOURCES += [ + osdir + "/nsOSHelperAppService.cpp", + ] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk": + UNIFIED_SOURCES += [ + "unix/nsGNOMERegistry.cpp", + "unix/nsMIMEInfoUnix.cpp", + ] +elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "android": + UNIFIED_SOURCES += [ + "android/nsMIMEInfoAndroid.cpp", + ] +elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows": + UNIFIED_SOURCES += [ + "win/nsMIMEInfoWin.cpp", + ] + +if CONFIG["MOZ_ENABLE_DBUS"]: + UNIFIED_SOURCES += [ + "nsDBusHandlerApp.cpp", + ] + EXPORTS.mozilla += [ + "DBusHelpers.h", + ] + +EXTRA_JS_MODULES += [ + "ExtHandlerService.sys.mjs", + "WebHandlerApp.sys.mjs", +] + +EXTRA_JS_MODULES.handlers += [ + "HandlerList.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +IPDL_SOURCES += [ + "PExternalHelperApp.ipdl", + "PHandlerService.ipdl", +] + +include("/ipc/chromium/chromium-config.mozbuild") + +FINAL_LIBRARY = "xul" + +LOCAL_INCLUDES += [ + "/docshell/base", + "/dom/base", + "/dom/ipc", + "/netwerk/base", + "/netwerk/protocol/http", +] + +if CONFIG["MOZ_ENABLE_DBUS"]: + CXXFLAGS += CONFIG["MOZ_DBUS_CFLAGS"] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk": + CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"] diff --git a/uriloader/exthandler/nsCExternalHandlerService.idl b/uriloader/exthandler/nsCExternalHandlerService.idl new file mode 100644 index 0000000000..b6aa83e285 --- /dev/null +++ b/uriloader/exthandler/nsCExternalHandlerService.idl @@ -0,0 +1,32 @@ +/* -*- Mode: IDL; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#include "nsIExternalHelperAppService.idl" + +/* +nsCExternalHelperApp implements: +------------------------- +nsIExternalHelperAppService +*/ + +%{ C++ + +#define NS_EXTERNALHELPERAPPSERVICE_CONTRACTID \ +"@mozilla.org/uriloader/external-helper-app-service;1" + +#define NS_HANDLERSERVICE_CONTRACTID \ +"@mozilla.org/uriloader/handler-service;1" + +#define NS_EXTERNALPROTOCOLSERVICE_CONTRACTID \ +"@mozilla.org/uriloader/external-protocol-service;1" + +#define NS_MIMESERVICE_CONTRACTID \ +"@mozilla.org/mime;1" + +#define NS_LOCALHANDLERAPP_CONTRACTID \ +"@mozilla.org/uriloader/local-handler-app;1" + +%} diff --git a/uriloader/exthandler/nsContentHandlerApp.h b/uriloader/exthandler/nsContentHandlerApp.h new file mode 100644 index 0000000000..f372d8735e --- /dev/null +++ b/uriloader/exthandler/nsContentHandlerApp.h @@ -0,0 +1,30 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#ifndef __nsContentHandlerAppImpl_h__ +#define __nsContentHandlerAppImpl_h__ + +#include <contentaction/contentaction.h> +#include "nsString.h" +#include "nsIMIMEInfo.h" + +class nsContentHandlerApp : public nsIHandlerApp { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIHANDLERAPP + + nsContentHandlerApp(nsString aName, nsCString aType, + ContentAction::Action& aAction); + virtual ~nsContentHandlerApp() {} + + protected: + nsString mName; + nsCString mType; + nsString mDetailedDescription; + + ContentAction::Action mAction; +}; +#endif diff --git a/uriloader/exthandler/nsDBusHandlerApp.cpp b/uriloader/exthandler/nsDBusHandlerApp.cpp new file mode 100644 index 0000000000..6155a1a951 --- /dev/null +++ b/uriloader/exthandler/nsDBusHandlerApp.cpp @@ -0,0 +1,164 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#include <dbus/dbus.h> +#include "mozilla/Components.h" +#include "mozilla/DBusHelpers.h" +#include "nsDBusHandlerApp.h" +#include "nsIURI.h" +#include "nsIClassInfoImpl.h" +#include "nsCOMPtr.h" +#include "nsCExternalHandlerService.h" + +using namespace mozilla; + +// XXX why does nsMIMEInfoImpl have a threadsafe nsISupports? do we need one +// here too? +NS_IMPL_CLASSINFO(nsDBusHandlerApp, nullptr, 0, + components::DBusHandlerApp::CID()) +NS_IMPL_ISUPPORTS_CI(nsDBusHandlerApp, nsIDBusHandlerApp, nsIHandlerApp) + +//////////////////////////////////////////////////////////////////////////////// +//// nsIHandlerApp + +NS_IMETHODIMP nsDBusHandlerApp::GetName(nsAString& aName) { + aName.Assign(mName); + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::SetName(const nsAString& aName) { + mName.Assign(aName); + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::SetDetailedDescription( + const nsAString& aDescription) { + mDetailedDescription.Assign(aDescription); + + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::GetDetailedDescription( + nsAString& aDescription) { + aDescription.Assign(mDetailedDescription); + + return NS_OK; +} + +NS_IMETHODIMP +nsDBusHandlerApp::Equals(nsIHandlerApp* aHandlerApp, bool* _retval) { + NS_ENSURE_ARG_POINTER(aHandlerApp); + + // If the handler app isn't a dbus handler app, then it's not the same app. + nsCOMPtr<nsIDBusHandlerApp> dbusHandlerApp = do_QueryInterface(aHandlerApp); + if (!dbusHandlerApp) { + *_retval = false; + return NS_OK; + } + nsAutoCString service; + nsAutoCString method; + + nsresult rv = dbusHandlerApp->GetService(service); + if (NS_FAILED(rv)) { + *_retval = false; + return NS_OK; + } + rv = dbusHandlerApp->GetMethod(method); + if (NS_FAILED(rv)) { + *_retval = false; + return NS_OK; + } + + *_retval = service.Equals(mService) && method.Equals(mMethod); + return NS_OK; +} + +NS_IMETHODIMP +nsDBusHandlerApp::LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext* aBrowsingContext) { + nsAutoCString spec; + nsresult rv = aURI->GetAsciiSpec(spec); + NS_ENSURE_SUCCESS(rv, rv); + const char* uri = spec.get(); + + DBusError err; + dbus_error_init(&err); + + mozilla::UniquePtr<DBusConnection, mozilla::DBusConnectionDelete> connection( + dbus_bus_get_private(DBUS_BUS_SESSION, &err)); + + if (dbus_error_is_set(&err)) { + dbus_error_free(&err); + return NS_ERROR_FAILURE; + } + if (nullptr == connection) { + return NS_ERROR_FAILURE; + } + dbus_connection_set_exit_on_disconnect(connection.get(), false); + + RefPtr<DBusMessage> msg = + already_AddRefed<DBusMessage>(dbus_message_new_method_call( + mService.get(), mObjpath.get(), mInterface.get(), mMethod.get())); + + if (!msg) { + return NS_ERROR_FAILURE; + } + dbus_message_set_no_reply(msg, true); + + DBusMessageIter iter; + dbus_message_iter_init_append(msg, &iter); + dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &uri); + + if (dbus_connection_send(connection.get(), msg, nullptr)) { + dbus_connection_flush(connection.get()); + } else { + return NS_ERROR_FAILURE; + } + return NS_OK; +} + +//////////////////////////////////////////////////////////////////////////////// +//// nsIDBusHandlerApp + +NS_IMETHODIMP nsDBusHandlerApp::GetService(nsACString& aService) { + aService.Assign(mService); + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::SetService(const nsACString& aService) { + mService.Assign(aService); + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::GetMethod(nsACString& aMethod) { + aMethod.Assign(mMethod); + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::SetMethod(const nsACString& aMethod) { + mMethod.Assign(aMethod); + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::GetDBusInterface(nsACString& aInterface) { + aInterface.Assign(mInterface); + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::SetDBusInterface(const nsACString& aInterface) { + mInterface.Assign(aInterface); + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::GetObjectPath(nsACString& aObjpath) { + aObjpath.Assign(mObjpath); + return NS_OK; +} + +NS_IMETHODIMP nsDBusHandlerApp::SetObjectPath(const nsACString& aObjpath) { + mObjpath.Assign(aObjpath); + return NS_OK; +} diff --git a/uriloader/exthandler/nsDBusHandlerApp.h b/uriloader/exthandler/nsDBusHandlerApp.h new file mode 100644 index 0000000000..f45f917446 --- /dev/null +++ b/uriloader/exthandler/nsDBusHandlerApp.h @@ -0,0 +1,31 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#ifndef __nsDBusHandlerAppImpl_h__ +#define __nsDBusHandlerAppImpl_h__ + +#include "nsString.h" +#include "nsIMIMEInfo.h" + +class nsDBusHandlerApp : public nsIDBusHandlerApp { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIHANDLERAPP + NS_DECL_NSIDBUSHANDLERAPP + + nsDBusHandlerApp() {} + + protected: + virtual ~nsDBusHandlerApp() {} + + nsString mName; + nsString mDetailedDescription; + nsCString mService; + nsCString mMethod; + nsCString mInterface; + nsCString mObjpath; +}; +#endif diff --git a/uriloader/exthandler/nsExternalHelperAppService.cpp b/uriloader/exthandler/nsExternalHelperAppService.cpp new file mode 100644 index 0000000000..4573e28470 --- /dev/null +++ b/uriloader/exthandler/nsExternalHelperAppService.cpp @@ -0,0 +1,3745 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#include "base/basictypes.h" + +/* This must occur *after* base/basictypes.h to avoid typedefs conflicts. */ +#include "mozilla/ArrayUtils.h" +#include "mozilla/Base64.h" +#include "mozilla/ResultExtensions.h" + +#include "mozilla/dom/ContentChild.h" +#include "mozilla/dom/BrowserChild.h" +#include "mozilla/dom/CanonicalBrowsingContext.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/WindowGlobalParent.h" +#include "mozilla/RandomNum.h" +#include "mozilla/StaticPrefs_dom.h" +#include "mozilla/StaticPrefs_security.h" +#include "mozilla/StaticPtr.h" +#include "nsXULAppAPI.h" + +#include "ExternalHelperAppParent.h" +#include "nsExternalHelperAppService.h" +#include "nsCExternalHandlerService.h" +#include "nsIURI.h" +#include "nsIURL.h" +#include "nsIFile.h" +#include "nsIFileURL.h" +#include "nsIChannel.h" +#include "nsAppDirectoryServiceDefs.h" +#include "nsICategoryManager.h" +#include "nsDependentSubstring.h" +#include "nsSandboxFlags.h" +#include "nsString.h" +#include "nsUnicharUtils.h" +#include "nsIStringEnumerator.h" +#include "nsIStreamListener.h" +#include "nsIMIMEService.h" +#include "nsILoadGroup.h" +#include "nsIWebProgressListener.h" +#include "nsITransfer.h" +#include "nsReadableUtils.h" +#include "nsIRequest.h" +#include "nsDirectoryServiceDefs.h" +#include "nsIInterfaceRequestor.h" +#include "nsThreadUtils.h" +#include "nsIMutableArray.h" +#include "nsIRedirectHistoryEntry.h" +#include "nsOSHelperAppService.h" +#include "nsOSHelperAppServiceChild.h" +#include "nsContentSecurityUtils.h" +#include "nsUTF8Utils.h" +#include "nsUnicodeProperties.h" + +// used to access our datastore of user-configured helper applications +#include "nsIHandlerService.h" +#include "nsIMIMEInfo.h" +#include "nsIHelperAppLauncherDialog.h" +#include "nsIContentDispatchChooser.h" +#include "nsNetUtil.h" +#include "nsIPrivateBrowsingChannel.h" +#include "nsIIOService.h" +#include "nsNetCID.h" + +#include "nsIApplicationReputation.h" + +#include "nsDSURIContentListener.h" +#include "nsMimeTypes.h" +#include "nsMIMEInfoImpl.h" +// used for header disposition information. +#include "nsIHttpChannel.h" +#include "nsIHttpChannelInternal.h" +#include "nsIEncodedChannel.h" +#include "nsIMultiPartChannel.h" +#include "nsIFileChannel.h" +#include "nsIObserverService.h" // so we can be a profile change observer +#include "nsIPropertyBag2.h" // for the 64-bit content length + +#ifdef XP_MACOSX +# include "nsILocalFileMac.h" +#endif + +#include "nsEscape.h" + +#include "nsIStringBundle.h" // XXX needed to localize error msgs +#include "nsIPrompt.h" + +#include "nsITextToSubURI.h" // to unescape the filename + +#include "nsDocShellCID.h" + +#include "nsCRT.h" +#include "nsLocalHandlerApp.h" + +#include "nsIRandomGenerator.h" + +#include "ContentChild.h" +#include "nsXULAppAPI.h" +#include "nsPIDOMWindow.h" +#include "ExternalHelperAppChild.h" + +#include "mozilla/dom/nsHTTPSOnlyUtils.h" + +#ifdef XP_WIN +# include "nsWindowsHelpers.h" +# include "nsLocalFile.h" +#endif + +#include "mozilla/Components.h" +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Preferences.h" +#include "mozilla/ipc/URIUtils.h" + +using namespace mozilla; +using namespace mozilla::ipc; +using namespace mozilla::dom; + +#define kDefaultMaxFileNameLength 254 + +// Download Folder location constants +#define NS_PREF_DOWNLOAD_DIR "browser.download.dir" +#define NS_PREF_DOWNLOAD_FOLDERLIST "browser.download.folderList" +enum { + NS_FOLDER_VALUE_DESKTOP = 0, + NS_FOLDER_VALUE_DOWNLOADS = 1, + NS_FOLDER_VALUE_CUSTOM = 2 +}; + +LazyLogModule nsExternalHelperAppService::sLog("HelperAppService"); + +// Using level 3 here because the OSHelperAppServices use a log level +// of LogLevel::Debug (4), and we want less detailed output here +// Using 3 instead of LogLevel::Warning because we don't output warnings +#undef LOG +#define LOG(...) \ + MOZ_LOG(nsExternalHelperAppService::sLog, mozilla::LogLevel::Info, \ + (__VA_ARGS__)) +#define LOG_ENABLED() \ + MOZ_LOG_TEST(nsExternalHelperAppService::sLog, mozilla::LogLevel::Info) + +static const char NEVER_ASK_FOR_SAVE_TO_DISK_PREF[] = + "browser.helperApps.neverAsk.saveToDisk"; +static const char NEVER_ASK_FOR_OPEN_FILE_PREF[] = + "browser.helperApps.neverAsk.openFile"; + +StaticRefPtr<nsIFile> sFallbackDownloadDir; + +// Helper functions for Content-Disposition headers + +/** + * Given a URI fragment, unescape it + * @param aFragment The string to unescape + * @param aURI The URI from which this fragment is taken. Only its character set + * will be used. + * @param aResult [out] Unescaped string. + */ +static nsresult UnescapeFragment(const nsACString& aFragment, nsIURI* aURI, + nsAString& aResult) { + // We need the unescaper + nsresult rv; + nsCOMPtr<nsITextToSubURI> textToSubURI = + do_GetService(NS_ITEXTTOSUBURI_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + return textToSubURI->UnEscapeURIForUI(aFragment, /* aDontEscape = */ true, + aResult); +} + +/** + * UTF-8 version of UnescapeFragment. + * @param aFragment The string to unescape + * @param aURI The URI from which this fragment is taken. Only its character set + * will be used. + * @param aResult [out] Unescaped string, UTF-8 encoded. + * @note It is safe to pass the same string for aFragment and aResult. + * @note When this function fails, aResult will not be modified. + */ +static nsresult UnescapeFragment(const nsACString& aFragment, nsIURI* aURI, + nsACString& aResult) { + nsAutoString result; + nsresult rv = UnescapeFragment(aFragment, aURI, result); + if (NS_SUCCEEDED(rv)) CopyUTF16toUTF8(result, aResult); + return rv; +} + +/** + * Obtains the directory to use. This tends to vary per platform, and + * needs to be consistent throughout our codepaths. For platforms where + * helper apps use the downloads directory, this should be kept in + * sync with DownloadIntegration.sys.mjs. + * + * Optionally skip availability of the directory and storage. + */ +static nsresult GetDownloadDirectory(nsIFile** _directory, + bool aSkipChecks = false) { +#if defined(ANDROID) + return NS_ERROR_FAILURE; +#endif + + bool usePrefDir = !StaticPrefs::browser_download_start_downloads_in_tmp_dir(); + + nsCOMPtr<nsIFile> dir; + nsresult rv; + if (usePrefDir) { + // Try to get the users download location, if it's set. + switch (Preferences::GetInt(NS_PREF_DOWNLOAD_FOLDERLIST, -1)) { + case NS_FOLDER_VALUE_DESKTOP: + (void)NS_GetSpecialDirectory(NS_OS_DESKTOP_DIR, getter_AddRefs(dir)); + break; + case NS_FOLDER_VALUE_CUSTOM: { + Preferences::GetComplex(NS_PREF_DOWNLOAD_DIR, NS_GET_IID(nsIFile), + getter_AddRefs(dir)); + if (!dir) break; + + // If we're not checking for availability we're done. + if (aSkipChecks) { + dir.forget(_directory); + return NS_OK; + } + + // We have the directory, and now we need to make sure it exists + nsresult rv = dir->Create(nsIFile::DIRECTORY_TYPE, 0755); + // If we can't create this and it's not because the file already + // exists, clear out `dir` so we don't return it. + if (rv != NS_ERROR_FILE_ALREADY_EXISTS && NS_FAILED(rv)) { + dir = nullptr; + } + } break; + case NS_FOLDER_VALUE_DOWNLOADS: + // This is just the OS default location, so fall out + break; + } + if (!dir) { + rv = NS_GetSpecialDirectory(NS_OS_DEFAULT_DOWNLOAD_DIR, + getter_AddRefs(dir)); + if (NS_FAILED(rv)) { + // On some OSes, there is no guarantee this directory exists. + // Fall back to $HOME + Downloads. + if (sFallbackDownloadDir) { + sFallbackDownloadDir->Clone(getter_AddRefs(dir)); + } else { + rv = NS_GetSpecialDirectory(NS_OS_HOME_DIR, getter_AddRefs(dir)); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIStringBundleService> bundleService = + do_GetService(NS_STRINGBUNDLE_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + nsAutoString downloadLocalized; + nsCOMPtr<nsIStringBundle> downloadBundle; + rv = bundleService->CreateBundle( + "chrome://mozapps/locale/downloads/downloads.properties", + getter_AddRefs(downloadBundle)); + if (NS_SUCCEEDED(rv)) { + rv = downloadBundle->GetStringFromName("downloadsFolder", + downloadLocalized); + } + if (NS_FAILED(rv)) { + downloadLocalized.AssignLiteral("Downloads"); + } + rv = dir->Append(downloadLocalized); + NS_ENSURE_SUCCESS(rv, rv); + // Can't getter_AddRefs on StaticRefPtr, so do some copying. + nsCOMPtr<nsIFile> copy; + dir->Clone(getter_AddRefs(copy)); + sFallbackDownloadDir = copy.forget(); + ClearOnShutdown(&sFallbackDownloadDir); + } + if (aSkipChecks) { + dir.forget(_directory); + return NS_OK; + } + + // We have the directory, and now we need to make sure it exists + rv = dir->Create(nsIFile::DIRECTORY_TYPE, 0755); + if (rv == NS_ERROR_FILE_ALREADY_EXISTS || NS_SUCCEEDED(rv)) { + dir.forget(_directory); + rv = NS_OK; + } + return rv; + } + NS_ENSURE_SUCCESS(rv, rv); + } + } else { + rv = NS_GetSpecialDirectory(NS_OS_TEMP_DIR, getter_AddRefs(dir)); + NS_ENSURE_SUCCESS(rv, rv); + +#if !defined(XP_MACOSX) && defined(XP_UNIX) + // Ensuring that only the current user can read the file names we end up + // creating. Note that creating directories with a specified permission is + // only supported on Unix platform right now. That's why the above check + // exists. + + uint32_t permissions; + rv = dir->GetPermissions(&permissions); + NS_ENSURE_SUCCESS(rv, rv); + + if (permissions != PR_IRWXU) { + const char* userName = PR_GetEnv("USERNAME"); + if (!userName || !*userName) { + userName = PR_GetEnv("USER"); + } + if (!userName || !*userName) { + userName = PR_GetEnv("LOGNAME"); + } + if (!userName || !*userName) { + userName = "mozillaUser"; + } + + nsAutoString userDir; + userDir.AssignLiteral("mozilla_"); + userDir.AppendASCII(userName); + userDir.ReplaceChar(u"" FILE_PATH_SEPARATOR FILE_ILLEGAL_CHARACTERS, '_'); + + int counter = 0; + bool pathExists; + nsCOMPtr<nsIFile> finalPath; + + while (true) { + nsAutoString countedUserDir(userDir); + countedUserDir.AppendInt(counter, 10); + dir->Clone(getter_AddRefs(finalPath)); + finalPath->Append(countedUserDir); + + rv = finalPath->Exists(&pathExists); + NS_ENSURE_SUCCESS(rv, rv); + + if (pathExists) { + // If this path has the right permissions, use it. + rv = finalPath->GetPermissions(&permissions); + NS_ENSURE_SUCCESS(rv, rv); + + // Ensuring the path is writable by the current user. + bool isWritable; + rv = finalPath->IsWritable(&isWritable); + NS_ENSURE_SUCCESS(rv, rv); + + if (permissions == PR_IRWXU && isWritable) { + dir = finalPath; + break; + } + } + + rv = finalPath->Create(nsIFile::DIRECTORY_TYPE, PR_IRWXU); + if (NS_SUCCEEDED(rv)) { + dir = finalPath; + break; + } + if (rv != NS_ERROR_FILE_ALREADY_EXISTS) { + // Unexpected error. + return rv; + } + counter++; + } + } + +#endif + } + + NS_ASSERTION(dir, "Somehow we didn't get a download directory!"); + dir.forget(_directory); + return NS_OK; +} + +/** + * Helper for random bytes for the filename of downloaded part files. + */ +nsresult GenerateRandomName(nsACString& result) { + // We will request raw random bytes, and transform that to a base64 string, + // using url-based base64 encoding so that all characters from the base64 + // result will be acceptable for filenames. + // For each three bytes of random data, we will get four bytes of ASCII. + // Request a bit more, to be safe, then truncate in the end. + + nsresult rv; + const uint32_t wantedFileNameLength = 8; + const uint32_t requiredBytesLength = + static_cast<uint32_t>((wantedFileNameLength + 1) / 4 * 3); + + uint8_t buffer[requiredBytesLength]; + if (!mozilla::GenerateRandomBytesFromOS(buffer, requiredBytesLength)) { + return NS_ERROR_FAILURE; + } + + nsAutoCString tempLeafName; + // We're forced to specify a padding policy, though this is guaranteed + // not to need padding due to requiredBytesLength being a multiple of 3. + rv = Base64URLEncode(requiredBytesLength, buffer, + Base64URLEncodePaddingPolicy::Omit, tempLeafName); + NS_ENSURE_SUCCESS(rv, rv); + + tempLeafName.Truncate(wantedFileNameLength); + + result.Assign(tempLeafName); + return NS_OK; +} + +/** + * Structure for storing extension->type mappings. + * @see defaultMimeEntries + */ +struct nsDefaultMimeTypeEntry { + const char* mMimeType; + const char* mFileExtension; +}; + +/** + * Default extension->mimetype mappings. These are not overridable. + * If you add types here, make sure they are lowercase, or you'll regret it. + */ +static const nsDefaultMimeTypeEntry defaultMimeEntries[] = { + // The following are those extensions that we're asked about during startup, + // sorted by order used + {IMAGE_GIF, "gif"}, + {TEXT_XML, "xml"}, + {APPLICATION_RDF, "rdf"}, + {IMAGE_PNG, "png"}, + // -- end extensions used during startup + {TEXT_CSS, "css"}, + {IMAGE_JPEG, "jpeg"}, + {IMAGE_JPEG, "jpg"}, + {IMAGE_SVG_XML, "svg"}, + {TEXT_HTML, "html"}, + {TEXT_HTML, "htm"}, + {APPLICATION_XPINSTALL, "xpi"}, + {"application/xhtml+xml", "xhtml"}, + {"application/xhtml+xml", "xht"}, + {TEXT_PLAIN, "txt"}, + {APPLICATION_JSON, "json"}, + {APPLICATION_XJAVASCRIPT, "mjs"}, + {APPLICATION_XJAVASCRIPT, "js"}, + {APPLICATION_XJAVASCRIPT, "jsm"}, + {VIDEO_OGG, "ogv"}, + {VIDEO_OGG, "ogg"}, + {APPLICATION_OGG, "ogg"}, + {AUDIO_OGG, "oga"}, + {AUDIO_OGG, "opus"}, + {APPLICATION_PDF, "pdf"}, + {VIDEO_WEBM, "webm"}, + {AUDIO_WEBM, "webm"}, + {IMAGE_ICO, "ico"}, + {TEXT_PLAIN, "properties"}, + {TEXT_PLAIN, "locale"}, + {TEXT_PLAIN, "ftl"}, +#if defined(MOZ_WMF) + {VIDEO_MP4, "mp4"}, + {AUDIO_MP4, "m4a"}, + {AUDIO_MP3, "mp3"}, +#endif +#ifdef MOZ_RAW + {VIDEO_RAW, "yuv"} +#endif +}; + +/** + * This is a small private struct used to help us initialize some + * default mime types. + */ +struct nsExtraMimeTypeEntry { + const char* mMimeType; + const char* mFileExtensions; + const char* mDescription; +}; + +/** + * This table lists all of the 'extra' content types that we can deduce from + * particular file extensions. These entries also ensure that we provide a good + * descriptive name when we encounter files with these content types and/or + * extensions. These can be overridden by user helper app prefs. If you add + * types here, make sure they are lowercase, or you'll regret it. + */ +static const nsExtraMimeTypeEntry extraMimeEntries[] = { +#if defined(XP_MACOSX) // don't define .bin on the mac...use internet config to + // look that up... + {APPLICATION_OCTET_STREAM, "exe,com", "Binary File"}, +#else + {APPLICATION_OCTET_STREAM, "exe,com,bin", "Binary File"}, +#endif + {APPLICATION_GZIP2, "gz", "gzip"}, + {"application/x-arj", "arj", "ARJ file"}, + {"application/rtf", "rtf", "Rich Text Format File"}, + {APPLICATION_ZIP, "zip", "ZIP Archive"}, + {APPLICATION_XPINSTALL, "xpi", "XPInstall Install"}, + {APPLICATION_PDF, "pdf", "Portable Document Format"}, + {APPLICATION_POSTSCRIPT, "ps,eps,ai", "Postscript File"}, + {APPLICATION_XJAVASCRIPT, "js", "Javascript Source File"}, + {APPLICATION_XJAVASCRIPT, "jsm,mjs", "Javascript Module Source File"}, +#ifdef MOZ_WIDGET_ANDROID + {"application/vnd.android.package-archive", "apk", "Android Package"}, +#endif + + // OpenDocument formats + {"application/vnd.oasis.opendocument.text", "odt", "OpenDocument Text"}, + {"application/vnd.oasis.opendocument.presentation", "odp", + "OpenDocument Presentation"}, + {"application/vnd.oasis.opendocument.spreadsheet", "ods", + "OpenDocument Spreadsheet"}, + {"application/vnd.oasis.opendocument.graphics", "odg", + "OpenDocument Graphics"}, + + // Legacy Microsoft Office + {"application/msword", "doc", "Microsoft Word"}, + {"application/vnd.ms-powerpoint", "ppt", "Microsoft PowerPoint"}, + {"application/vnd.ms-excel", "xls", "Microsoft Excel"}, + + // Office Open XML + {"application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "docx", "Microsoft Word (Open XML)"}, + {"application/" + "vnd.openxmlformats-officedocument.presentationml.presentation", + "pptx", "Microsoft PowerPoint (Open XML)"}, + {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "xlsx", "Microsoft Excel (Open XML)"}, + + {IMAGE_ART, "art", "ART Image"}, + {IMAGE_BMP, "bmp", "BMP Image"}, + {IMAGE_GIF, "gif", "GIF Image"}, + {IMAGE_ICO, "ico,cur", "ICO Image"}, + {IMAGE_JPEG, "jpg,jpeg,jfif,pjpeg,pjp", "JPEG Image"}, + {IMAGE_PNG, "png", "PNG Image"}, + {IMAGE_APNG, "apng", "APNG Image"}, + {IMAGE_TIFF, "tiff,tif", "TIFF Image"}, + {IMAGE_XBM, "xbm", "XBM Image"}, + {IMAGE_SVG_XML, "svg", "Scalable Vector Graphics"}, + {IMAGE_WEBP, "webp", "WebP Image"}, + {IMAGE_AVIF, "avif", "AV1 Image File"}, + {IMAGE_JXL, "jxl", "JPEG XL Image File"}, + + {MESSAGE_RFC822, "eml", "RFC-822 data"}, + {TEXT_PLAIN, "txt,text", "Text File"}, + {APPLICATION_JSON, "json", "JavaScript Object Notation"}, + {TEXT_VTT, "vtt", "Web Video Text Tracks"}, + {TEXT_CACHE_MANIFEST, "appcache", "Application Cache Manifest"}, + {TEXT_HTML, "html,htm,shtml,ehtml", "HyperText Markup Language"}, + {"application/xhtml+xml", "xhtml,xht", + "Extensible HyperText Markup Language"}, + {APPLICATION_MATHML_XML, "mml", "Mathematical Markup Language"}, + {APPLICATION_RDF, "rdf", "Resource Description Framework"}, + {"text/csv", "csv", "CSV File"}, + {TEXT_XML, "xml,xsl,xbl", "Extensible Markup Language"}, + {TEXT_CSS, "css", "Style Sheet"}, + {TEXT_VCARD, "vcf,vcard", "Contact Information"}, + {TEXT_CALENDAR, "ics,ical,ifb,icalendar", "iCalendar"}, + {VIDEO_OGG, "ogv,ogg", "Ogg Video"}, + {APPLICATION_OGG, "ogg", "Ogg Video"}, + {AUDIO_OGG, "oga", "Ogg Audio"}, + {AUDIO_OGG, "opus", "Opus Audio"}, + {VIDEO_WEBM, "webm", "Web Media Video"}, + {AUDIO_WEBM, "webm", "Web Media Audio"}, + {AUDIO_MP3, "mp3,mpega,mp2", "MPEG Audio"}, + {VIDEO_MP4, "mp4,m4a,m4b", "MPEG-4 Video"}, + {AUDIO_MP4, "m4a,m4b", "MPEG-4 Audio"}, + {VIDEO_RAW, "yuv", "Raw YUV Video"}, + {AUDIO_WAV, "wav", "Waveform Audio"}, + {VIDEO_3GPP, "3gpp,3gp", "3GPP Video"}, + {VIDEO_3GPP2, "3g2", "3GPP2 Video"}, + {AUDIO_AAC, "aac", "AAC Audio"}, + {AUDIO_FLAC, "flac", "FLAC Audio"}, + {AUDIO_MIDI, "mid", "Standard MIDI Audio"}, + {APPLICATION_WASM, "wasm", "WebAssembly Module"}}; + +static const nsDefaultMimeTypeEntry sForbiddenPrimaryExtensions[] = { + {IMAGE_JPEG, "jfif"}}; + +/** + * File extensions for which decoding should be disabled. + * NOTE: These MUST be lower-case and ASCII. + */ +static const nsDefaultMimeTypeEntry nonDecodableExtensions[] = { + {APPLICATION_GZIP, "gz"}, + {APPLICATION_GZIP, "tgz"}, + {APPLICATION_ZIP, "zip"}, + {APPLICATION_COMPRESS, "z"}, + {APPLICATION_GZIP, "svgz"}}; + +/** + * Mimetypes for which we enforce using a known extension. + * + * In addition to this list, we do this for all audio/, video/ and + * image/ mimetypes. + */ +static const char* forcedExtensionMimetypes[] = { + APPLICATION_PDF, APPLICATION_OGG, APPLICATION_WASM, + TEXT_CALENDAR, TEXT_CSS, TEXT_VCARD}; + +/** + * Primary extensions of types whose descriptions should be overwritten. + * This extension is concatenated with "ExtHandlerDescription" to look up the + * description in unknownContentType.properties. + * NOTE: These MUST be lower-case and ASCII. + */ +static const char* descriptionOverwriteExtensions[] = { + "avif", "jxl", "pdf", "svg", "webp", "xml", +}; + +static StaticRefPtr<nsExternalHelperAppService> sExtHelperAppSvcSingleton; + +/** + * In child processes, return an nsOSHelperAppServiceChild for remoting + * OS calls to the parent process. In the parent process itself, use + * nsOSHelperAppService. + */ +/* static */ +already_AddRefed<nsExternalHelperAppService> +nsExternalHelperAppService::GetSingleton() { + if (!sExtHelperAppSvcSingleton) { + if (XRE_IsParentProcess()) { + sExtHelperAppSvcSingleton = new nsOSHelperAppService(); + } else { + sExtHelperAppSvcSingleton = new nsOSHelperAppServiceChild(); + } + ClearOnShutdown(&sExtHelperAppSvcSingleton); + } + + return do_AddRef(sExtHelperAppSvcSingleton); +} + +NS_IMPL_ISUPPORTS(nsExternalHelperAppService, nsIExternalHelperAppService, + nsPIExternalAppLauncher, nsIExternalProtocolService, + nsIMIMEService, nsIObserver, nsISupportsWeakReference) + +nsExternalHelperAppService::nsExternalHelperAppService() {} +nsresult nsExternalHelperAppService::Init() { + // Add an observer for profile change + nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService(); + if (!obs) return NS_ERROR_FAILURE; + + nsresult rv = obs->AddObserver(this, "profile-before-change", true); + NS_ENSURE_SUCCESS(rv, rv); + return obs->AddObserver(this, "last-pb-context-exited", true); +} + +nsExternalHelperAppService::~nsExternalHelperAppService() {} + +nsresult nsExternalHelperAppService::DoContentContentProcessHelper( + const nsACString& aMimeContentType, nsIChannel* aChannel, + BrowsingContext* aContentContext, bool aForceSave, + nsIInterfaceRequestor* aWindowContext, + nsIStreamListener** aStreamListener) { + NS_ENSURE_ARG_POINTER(aChannel); + + // We need to get a hold of a ContentChild so that we can begin forwarding + // this data to the parent. In the HTTP case, this is unfortunate, since + // we're actually passing data from parent->child->parent wastefully, but + // the Right Fix will eventually be to short-circuit those channels on the + // parent side based on some sort of subscription concept. + using mozilla::dom::ContentChild; + using mozilla::dom::ExternalHelperAppChild; + ContentChild* child = ContentChild::GetSingleton(); + if (!child) { + return NS_ERROR_FAILURE; + } + + nsCString disp; + nsCOMPtr<nsIURI> uri; + int64_t contentLength = -1; + bool wasFileChannel = false; + uint32_t contentDisposition = -1; + nsAutoString fileName; + nsCOMPtr<nsILoadInfo> loadInfo; + + aChannel->GetURI(getter_AddRefs(uri)); + aChannel->GetContentLength(&contentLength); + aChannel->GetContentDisposition(&contentDisposition); + aChannel->GetContentDispositionFilename(fileName); + aChannel->GetContentDispositionHeader(disp); + loadInfo = aChannel->LoadInfo(); + + nsCOMPtr<nsIFileChannel> fileChan(do_QueryInterface(aChannel)); + wasFileChannel = fileChan != nullptr; + + nsCOMPtr<nsIURI> referrer; + NS_GetReferrerFromChannel(aChannel, getter_AddRefs(referrer)); + + mozilla::net::LoadInfoArgs loadInfoArgs; + MOZ_ALWAYS_SUCCEEDS(LoadInfoToLoadInfoArgs(loadInfo, &loadInfoArgs)); + + nsCOMPtr<nsIPropertyBag2> props(do_QueryInterface(aChannel)); + // Determine whether a new window was opened specifically for this request + bool shouldCloseWindow = false; + if (props) { + props->GetPropertyAsBool(u"docshell.newWindowTarget"_ns, + &shouldCloseWindow); + } + + // Now we build a protocol for forwarding our data to the parent. The + // protocol will act as a listener on the child-side and create a "real" + // helperAppService listener on the parent-side, via another call to + // DoContent. + RefPtr<ExternalHelperAppChild> childListener = new ExternalHelperAppChild(); + MOZ_ALWAYS_TRUE(child->SendPExternalHelperAppConstructor( + childListener, uri, loadInfoArgs, nsCString(aMimeContentType), disp, + contentDisposition, fileName, aForceSave, contentLength, wasFileChannel, + referrer, aContentContext, shouldCloseWindow)); + + NS_ADDREF(*aStreamListener = childListener); + + uint32_t reason = nsIHelperAppLauncherDialog::REASON_CANTHANDLE; + + SanitizeFileName(fileName, 0); + + RefPtr<nsExternalAppHandler> handler = + new nsExternalAppHandler(nullptr, u""_ns, aContentContext, aWindowContext, + this, fileName, reason, aForceSave); + if (!handler) { + return NS_ERROR_OUT_OF_MEMORY; + } + + childListener->SetHandler(handler); + return NS_OK; +} + +NS_IMETHODIMP nsExternalHelperAppService::CreateListener( + const nsACString& aMimeContentType, nsIChannel* aChannel, + BrowsingContext* aContentContext, bool aForceSave, + nsIInterfaceRequestor* aWindowContext, + nsIStreamListener** aStreamListener) { + MOZ_ASSERT(!XRE_IsContentProcess()); + NS_ENSURE_ARG_POINTER(aChannel); + + nsAutoString fileName; + nsAutoCString fileExtension; + uint32_t reason = nsIHelperAppLauncherDialog::REASON_CANTHANDLE; + + uint32_t contentDisposition = -1; + aChannel->GetContentDisposition(&contentDisposition); + if (contentDisposition == nsIChannel::DISPOSITION_ATTACHMENT) { + reason = nsIHelperAppLauncherDialog::REASON_SERVERREQUEST; + } + + *aStreamListener = nullptr; + + // Get the file extension and name that we will need later + nsCOMPtr<nsIURI> uri; + bool allowURLExtension = + GetFileNameFromChannel(aChannel, fileName, getter_AddRefs(uri)); + + uint32_t flags = VALIDATE_ALLOW_EMPTY; + if (aMimeContentType.Equals(APPLICATION_GUESS_FROM_EXT, + nsCaseInsensitiveCStringComparator)) { + flags |= VALIDATE_GUESS_FROM_EXTENSION; + } + + nsCOMPtr<nsIMIMEInfo> mimeInfo = ValidateFileNameForSaving( + fileName, aMimeContentType, uri, nullptr, flags, allowURLExtension); + + LOG("Type/Ext lookup found 0x%p\n", mimeInfo.get()); + + // No mimeinfo -> we can't continue. probably OOM. + if (!mimeInfo) { + return NS_ERROR_OUT_OF_MEMORY; + } + + if (flags & VALIDATE_GUESS_FROM_EXTENSION) { + // Replace the content type with what was guessed. + nsAutoCString mimeType; + mimeInfo->GetMIMEType(mimeType); + aChannel->SetContentType(mimeType); + + if (reason == nsIHelperAppLauncherDialog::REASON_CANTHANDLE) { + reason = nsIHelperAppLauncherDialog::REASON_TYPESNIFFED; + } + } + + nsAutoString extension; + int32_t dotidx = fileName.RFind(u"."); + if (dotidx != -1) { + extension = Substring(fileName, dotidx + 1); + } + + // NB: ExternalHelperAppParent depends on this listener always being an + // nsExternalAppHandler. If this changes, make sure to update that code. + nsExternalAppHandler* handler = new nsExternalAppHandler( + mimeInfo, extension, aContentContext, aWindowContext, this, fileName, + reason, aForceSave); + if (!handler) { + return NS_ERROR_OUT_OF_MEMORY; + } + + NS_ADDREF(*aStreamListener = handler); + return NS_OK; +} + +NS_IMETHODIMP nsExternalHelperAppService::DoContent( + const nsACString& aMimeContentType, nsIChannel* aChannel, + nsIInterfaceRequestor* aContentContext, bool aForceSave, + nsIInterfaceRequestor* aWindowContext, + nsIStreamListener** aStreamListener) { + // Scripted interface requestors cannot return an instance of the + // (non-scriptable) nsPIDOMWindowOuter or nsPIDOMWindowInner interfaces, so + // get to the window via `nsIDOMWindow`. Unfortunately, at that point we + // don't know whether the thing we got is an inner or outer window, so have to + // work with either one. + RefPtr<BrowsingContext> bc; + nsCOMPtr<nsIDOMWindow> domWindow = do_GetInterface(aContentContext); + if (nsCOMPtr<nsPIDOMWindowOuter> outerWindow = do_QueryInterface(domWindow)) { + bc = outerWindow->GetBrowsingContext(); + } else if (nsCOMPtr<nsPIDOMWindowInner> innerWindow = + do_QueryInterface(domWindow)) { + bc = innerWindow->GetBrowsingContext(); + } + + if (XRE_IsContentProcess()) { + return DoContentContentProcessHelper(aMimeContentType, aChannel, bc, + aForceSave, aWindowContext, + aStreamListener); + } + + nsresult rv = CreateListener(aMimeContentType, aChannel, bc, aForceSave, + aWindowContext, aStreamListener); + return rv; +} + +NS_IMETHODIMP nsExternalHelperAppService::ApplyDecodingForExtension( + const nsACString& aExtension, const nsACString& aEncodingType, + bool* aApplyDecoding) { + *aApplyDecoding = true; + uint32_t i; + for (i = 0; i < ArrayLength(nonDecodableExtensions); ++i) { + if (aExtension.LowerCaseEqualsASCII( + nonDecodableExtensions[i].mFileExtension) && + aEncodingType.LowerCaseEqualsASCII( + nonDecodableExtensions[i].mMimeType)) { + *aApplyDecoding = false; + break; + } + } + return NS_OK; +} + +nsresult nsExternalHelperAppService::GetFileTokenForPath( + const char16_t* aPlatformAppPath, nsIFile** aFile) { + nsDependentString platformAppPath(aPlatformAppPath); + // First, check if we have an absolute path + nsIFile* localFile = nullptr; + nsresult rv = NS_NewLocalFile(platformAppPath, true, &localFile); + if (NS_SUCCEEDED(rv)) { + *aFile = localFile; + bool exists; + if (NS_FAILED((*aFile)->Exists(&exists)) || !exists) { + NS_RELEASE(*aFile); + return NS_ERROR_FILE_NOT_FOUND; + } + return NS_OK; + } + + // Second, check if file exists in mozilla program directory + rv = NS_GetSpecialDirectory(NS_XPCOM_CURRENT_PROCESS_DIR, aFile); + if (NS_SUCCEEDED(rv)) { + rv = (*aFile)->Append(platformAppPath); + if (NS_SUCCEEDED(rv)) { + bool exists = false; + rv = (*aFile)->Exists(&exists); + if (NS_SUCCEEDED(rv) && exists) return NS_OK; + } + NS_RELEASE(*aFile); + } + + return NS_ERROR_NOT_AVAILABLE; +} + +////////////////////////////////////////////////////////////////////////////////////////////////////// +// begin external protocol service default implementation... +////////////////////////////////////////////////////////////////////////////////////////////////////// +NS_IMETHODIMP nsExternalHelperAppService::ExternalProtocolHandlerExists( + const char* aProtocolScheme, bool* aHandlerExists) { + nsCOMPtr<nsIHandlerInfo> handlerInfo; + nsresult rv = GetProtocolHandlerInfo(nsDependentCString(aProtocolScheme), + getter_AddRefs(handlerInfo)); + if (NS_SUCCEEDED(rv)) { + // See if we have any known possible handler apps for this + nsCOMPtr<nsIMutableArray> possibleHandlers; + handlerInfo->GetPossibleApplicationHandlers( + getter_AddRefs(possibleHandlers)); + + uint32_t length; + possibleHandlers->GetLength(&length); + if (length) { + *aHandlerExists = true; + return NS_OK; + } + } + + // if not, fall back on an os-based handler + return OSProtocolHandlerExists(aProtocolScheme, aHandlerExists); +} + +NS_IMETHODIMP nsExternalHelperAppService::IsExposedProtocol( + const char* aProtocolScheme, bool* aResult) { + // check the per protocol setting first. it always takes precedence. + // if not set, then use the global setting. + + nsAutoCString prefName("network.protocol-handler.expose."); + prefName += aProtocolScheme; + bool val; + if (NS_SUCCEEDED(Preferences::GetBool(prefName.get(), &val))) { + *aResult = val; + return NS_OK; + } + + // by default, no protocol is exposed. i.e., by default all link clicks must + // go through the external protocol service. most applications override this + // default behavior. + *aResult = Preferences::GetBool("network.protocol-handler.expose-all", false); + + return NS_OK; +} + +static const char kExternalProtocolPrefPrefix[] = + "network.protocol-handler.external."; +static const char kExternalProtocolDefaultPref[] = + "network.protocol-handler.external-default"; + +// static +nsresult nsExternalHelperAppService::EscapeURI(nsIURI* aURI, nsIURI** aResult) { + MOZ_ASSERT(aURI); + MOZ_ASSERT(aResult); + + nsAutoCString spec; + aURI->GetSpec(spec); + + if (spec.Find("%00") != -1) return NS_ERROR_MALFORMED_URI; + + nsAutoCString escapedSpec; + nsresult rv = NS_EscapeURL(spec, esc_AlwaysCopy | esc_ExtHandler, escapedSpec, + fallible); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIIOService> ios(do_GetIOService()); + return ios->NewURI(escapedSpec, nullptr, nullptr, aResult); +} + +bool ExternalProtocolIsBlockedBySandbox( + BrowsingContext* aBrowsingContext, + const bool aHasValidUserGestureActivation) { + if (!StaticPrefs::dom_block_external_protocol_navigation_from_sandbox()) { + return false; + } + + if (!aBrowsingContext || aBrowsingContext->IsTop()) { + return false; + } + + uint32_t sandboxFlags = aBrowsingContext->GetSandboxFlags(); + + if (sandboxFlags == SANDBOXED_NONE) { + return false; + } + + if (!(sandboxFlags & SANDBOXED_AUXILIARY_NAVIGATION)) { + return false; + } + + if (!(sandboxFlags & SANDBOXED_TOPLEVEL_NAVIGATION)) { + return false; + } + + if (!(sandboxFlags & SANDBOXED_TOPLEVEL_NAVIGATION_CUSTOM_PROTOCOLS)) { + return false; + } + + if (!(sandboxFlags & SANDBOXED_TOPLEVEL_NAVIGATION_USER_ACTIVATION) && + aHasValidUserGestureActivation) { + return false; + } + + return true; +} + +NS_IMETHODIMP +nsExternalHelperAppService::LoadURI(nsIURI* aURI, + nsIPrincipal* aTriggeringPrincipal, + nsIPrincipal* aRedirectPrincipal, + BrowsingContext* aBrowsingContext, + bool aTriggeredExternally, + bool aHasValidUserGestureActivation) { + NS_ENSURE_ARG_POINTER(aURI); + + if (XRE_IsContentProcess()) { + mozilla::dom::ContentChild::GetSingleton()->SendLoadURIExternal( + aURI, aTriggeringPrincipal, aRedirectPrincipal, aBrowsingContext, + aTriggeredExternally, aHasValidUserGestureActivation); + return NS_OK; + } + + // Prevent sandboxed BrowsingContexts from navigating to external protocols. + // This only uses the sandbox flags of the target BrowsingContext of the + // load. The navigating document's CSP sandbox flags do not apply. + if (aBrowsingContext && + ExternalProtocolIsBlockedBySandbox(aBrowsingContext, + aHasValidUserGestureActivation)) { + // Log an error to the web console of the sandboxed BrowsingContext. + nsAutoString localizedMsg; + nsAutoCString spec; + aURI->GetSpec(spec); + + AutoTArray<nsString, 1> params = {NS_ConvertUTF8toUTF16(spec)}; + nsresult rv = nsContentUtils::FormatLocalizedString( + nsContentUtils::eSECURITY_PROPERTIES, "SandboxBlockedCustomProtocols", + params, localizedMsg); + NS_ENSURE_SUCCESS(rv, rv); + + // Log to the the parent window of the iframe. If there is no parent, fall + // back to the iframe window itself. + WindowContext* windowContext = aBrowsingContext->GetParentWindowContext(); + if (!windowContext) { + windowContext = aBrowsingContext->GetCurrentWindowContext(); + } + + // Skip logging if we still don't have a WindowContext. + NS_ENSURE_TRUE(windowContext, NS_ERROR_FAILURE); + + nsContentUtils::ReportToConsoleByWindowID( + localizedMsg, nsIScriptError::errorFlag, "Security"_ns, + windowContext->InnerWindowId(), + windowContext->Canonical()->GetDocumentURI()); + + return NS_OK; + } + + nsCOMPtr<nsIURI> escapedURI; + nsresult rv = EscapeURI(aURI, getter_AddRefs(escapedURI)); + NS_ENSURE_SUCCESS(rv, rv); + + nsAutoCString scheme; + escapedURI->GetScheme(scheme); + if (scheme.IsEmpty()) return NS_OK; // must have a scheme + + // Deny load if the prefs say to do so + nsAutoCString externalPref(kExternalProtocolPrefPrefix); + externalPref += scheme; + bool allowLoad = false; + if (NS_FAILED(Preferences::GetBool(externalPref.get(), &allowLoad))) { + // no scheme-specific value, check the default + if (NS_FAILED( + Preferences::GetBool(kExternalProtocolDefaultPref, &allowLoad))) { + return NS_OK; // missing default pref + } + } + + if (!allowLoad) { + return NS_OK; // explicitly denied + } + + // Now check if the principal is allowed to access the navigated context. + // We allow navigating subframes, even if not same-origin - non-external + // links can always navigate everywhere, so this is a minor additional + // restriction, only aiming to prevent some types of spoofing attacks + // from otherwise disjoint browsingcontext trees. + if (aBrowsingContext && aTriggeringPrincipal && + !StaticPrefs::security_allow_disjointed_external_uri_loads() && + // Add-on principals are always allowed: + !BasePrincipal::Cast(aTriggeringPrincipal)->AddonPolicy() && + // As is chrome code: + !aTriggeringPrincipal->IsSystemPrincipal()) { + RefPtr<BrowsingContext> bc = aBrowsingContext; + WindowGlobalParent* wgp = bc->Canonical()->GetCurrentWindowGlobal(); + bool foundAccessibleFrame = false; + + // Also allow this load if the target is a toplevel BC and contains a + // non-web-controlled about:blank document + if (bc->IsTop() && !bc->HadOriginalOpener() && wgp) { + RefPtr<nsIURI> uri = wgp->GetDocumentURI(); + foundAccessibleFrame = + uri && uri->GetSpecOrDefault().EqualsLiteral("about:blank"); + } + + while (!foundAccessibleFrame) { + if (wgp) { + foundAccessibleFrame = + aTriggeringPrincipal->Subsumes(wgp->DocumentPrincipal()); + } + // We have to get the parent via the bc, because there may not + // be a window global for the innermost bc; see bug 1650162. + BrowsingContext* parent = bc->GetParent(); + if (!parent) { + break; + } + bc = parent; + wgp = parent->Canonical()->GetCurrentWindowGlobal(); + } + + if (!foundAccessibleFrame) { + // See if this navigation could have come from a subframe. + nsTArray<RefPtr<BrowsingContext>> contexts; + aBrowsingContext->GetAllBrowsingContextsInSubtree(contexts); + for (const auto& kid : contexts) { + wgp = kid->Canonical()->GetCurrentWindowGlobal(); + if (wgp && aTriggeringPrincipal->Subsumes(wgp->DocumentPrincipal())) { + foundAccessibleFrame = true; + break; + } + } + } + + if (!foundAccessibleFrame) { + return NS_OK; // deny the load. + } + } + + nsCOMPtr<nsIHandlerInfo> handler; + rv = GetProtocolHandlerInfo(scheme, getter_AddRefs(handler)); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIContentDispatchChooser> chooser = + do_CreateInstance("@mozilla.org/content-dispatch-chooser;1", &rv); + NS_ENSURE_SUCCESS(rv, rv); + + return chooser->HandleURI( + handler, escapedURI, + aRedirectPrincipal ? aRedirectPrincipal : aTriggeringPrincipal, + aBrowsingContext, aTriggeredExternally); +} + +////////////////////////////////////////////////////////////////////////////////////////////////////// +// Methods related to deleting temporary files on exit +////////////////////////////////////////////////////////////////////////////////////////////////////// + +/* static */ +nsresult nsExternalHelperAppService::DeleteTemporaryFileHelper( + nsIFile* aTemporaryFile, nsCOMArray<nsIFile>& aFileList) { + bool isFile = false; + + // as a safety measure, make sure the nsIFile is really a file and not a + // directory object. + aTemporaryFile->IsFile(&isFile); + if (!isFile) return NS_OK; + + aFileList.AppendObject(aTemporaryFile); + + return NS_OK; +} + +NS_IMETHODIMP +nsExternalHelperAppService::DeleteTemporaryFileOnExit(nsIFile* aTemporaryFile) { + return DeleteTemporaryFileHelper(aTemporaryFile, mTemporaryFilesList); +} + +NS_IMETHODIMP +nsExternalHelperAppService::DeleteTemporaryPrivateFileWhenPossible( + nsIFile* aTemporaryFile) { + return DeleteTemporaryFileHelper(aTemporaryFile, mTemporaryPrivateFilesList); +} + +void nsExternalHelperAppService::ExpungeTemporaryFilesHelper( + nsCOMArray<nsIFile>& fileList) { + int32_t numEntries = fileList.Count(); + nsIFile* localFile; + for (int32_t index = 0; index < numEntries; index++) { + localFile = fileList[index]; + if (localFile) { + // First make the file writable, since the temp file is probably readonly. + localFile->SetPermissions(0600); + localFile->Remove(false); + } + } + + fileList.Clear(); +} + +void nsExternalHelperAppService::ExpungeTemporaryFiles() { + ExpungeTemporaryFilesHelper(mTemporaryFilesList); +} + +void nsExternalHelperAppService::ExpungeTemporaryPrivateFiles() { + ExpungeTemporaryFilesHelper(mTemporaryPrivateFilesList); +} + +static const char kExternalWarningPrefPrefix[] = + "network.protocol-handler.warn-external."; +static const char kExternalWarningDefaultPref[] = + "network.protocol-handler.warn-external-default"; + +NS_IMETHODIMP +nsExternalHelperAppService::GetProtocolHandlerInfo( + const nsACString& aScheme, nsIHandlerInfo** aHandlerInfo) { + // XXX enterprise customers should be able to turn this support off with a + // single master pref (maybe use one of the "exposed" prefs here?) + + bool exists; + nsresult rv = GetProtocolHandlerInfoFromOS(aScheme, &exists, aHandlerInfo); + if (NS_FAILED(rv)) { + // Either it knows nothing, or we ran out of memory + return NS_ERROR_FAILURE; + } + + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID); + if (handlerSvc) { + bool hasHandler = false; + (void)handlerSvc->Exists(*aHandlerInfo, &hasHandler); + if (hasHandler) { + rv = handlerSvc->FillHandlerInfo(*aHandlerInfo, ""_ns); + if (NS_SUCCEEDED(rv)) return NS_OK; + } + } + + return SetProtocolHandlerDefaults(*aHandlerInfo, exists); +} + +NS_IMETHODIMP +nsExternalHelperAppService::SetProtocolHandlerDefaults( + nsIHandlerInfo* aHandlerInfo, bool aOSHandlerExists) { + // this type isn't in our database, so we've only got an OS default handler, + // if one exists + + if (aOSHandlerExists) { + // we've got a default, so use it + aHandlerInfo->SetPreferredAction(nsIHandlerInfo::useSystemDefault); + + // whether or not to ask the user depends on the warning preference + nsAutoCString scheme; + aHandlerInfo->GetType(scheme); + + nsAutoCString warningPref(kExternalWarningPrefPrefix); + warningPref += scheme; + bool warn; + if (NS_FAILED(Preferences::GetBool(warningPref.get(), &warn))) { + // no scheme-specific value, check the default + warn = Preferences::GetBool(kExternalWarningDefaultPref, true); + } + aHandlerInfo->SetAlwaysAskBeforeHandling(warn); + } else { + // If no OS default existed, we set the preferred action to alwaysAsk. + // This really means not initialized (i.e. there's no available handler) + // to all the code... + aHandlerInfo->SetPreferredAction(nsIHandlerInfo::alwaysAsk); + } + + return NS_OK; +} + +// XPCOM profile change observer +NS_IMETHODIMP +nsExternalHelperAppService::Observe(nsISupports* aSubject, const char* aTopic, + const char16_t* someData) { + if (!strcmp(aTopic, "profile-before-change")) { + ExpungeTemporaryFiles(); + } else if (!strcmp(aTopic, "last-pb-context-exited")) { + ExpungeTemporaryPrivateFiles(); + } + return NS_OK; +} + +////////////////////////////////////////////////////////////////////////////////////////////////////// +// begin external app handler implementation +////////////////////////////////////////////////////////////////////////////////////////////////////// + +NS_IMPL_ADDREF(nsExternalAppHandler) +NS_IMPL_RELEASE(nsExternalAppHandler) + +NS_INTERFACE_MAP_BEGIN(nsExternalAppHandler) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIStreamListener) + NS_INTERFACE_MAP_ENTRY(nsIStreamListener) + NS_INTERFACE_MAP_ENTRY(nsIRequestObserver) + NS_INTERFACE_MAP_ENTRY(nsIHelperAppLauncher) + NS_INTERFACE_MAP_ENTRY(nsICancelable) + NS_INTERFACE_MAP_ENTRY(nsIBackgroundFileSaverObserver) + NS_INTERFACE_MAP_ENTRY(nsINamed) + NS_INTERFACE_MAP_ENTRY_CONCRETE(nsExternalAppHandler) +NS_INTERFACE_MAP_END + +nsExternalAppHandler::nsExternalAppHandler( + nsIMIMEInfo* aMIMEInfo, const nsAString& aFileExtension, + BrowsingContext* aBrowsingContext, nsIInterfaceRequestor* aWindowContext, + nsExternalHelperAppService* aExtProtSvc, + const nsAString& aSuggestedFileName, uint32_t aReason, bool aForceSave) + : mMimeInfo(aMIMEInfo), + mBrowsingContext(aBrowsingContext), + mWindowContext(aWindowContext), + mSuggestedFileName(aSuggestedFileName), + mForceSave(aForceSave), + mForceSaveInternallyHandled(false), + mCanceled(false), + mStopRequestIssued(false), + mIsFileChannel(false), + mShouldCloseWindow(false), + mHandleInternally(false), + mDialogShowing(false), + mReason(aReason), + mTempFileIsExecutable(false), + mTimeDownloadStarted(0), + mContentLength(-1), + mProgress(0), + mSaver(nullptr), + mDialogProgressListener(nullptr), + mTransfer(nullptr), + mRequest(nullptr), + mExtProtSvc(aExtProtSvc) { + // make sure the extention includes the '.' + if (!aFileExtension.IsEmpty() && aFileExtension.First() != '.') { + mFileExtension = char16_t('.'); + } + mFileExtension.Append(aFileExtension); + + mBufferSize = Preferences::GetUint("network.buffer.cache.size", 4096); +} + +nsExternalAppHandler::~nsExternalAppHandler() { + MOZ_ASSERT(!mSaver, "Saver should hold a reference to us until deleted"); +} + +void nsExternalAppHandler::DidDivertRequest(nsIRequest* request) { + MOZ_ASSERT(XRE_IsContentProcess(), "in child process"); + // Remove our request from the child loadGroup + RetargetLoadNotifications(request); +} + +NS_IMETHODIMP nsExternalAppHandler::SetWebProgressListener( + nsIWebProgressListener2* aWebProgressListener) { + // This is always called by nsHelperDlg.js. Go ahead and register the + // progress listener. At this point, we don't have mTransfer. + mDialogProgressListener = aWebProgressListener; + return NS_OK; +} + +NS_IMETHODIMP nsExternalAppHandler::GetTargetFile(nsIFile** aTarget) { + if (mFinalFileDestination) + *aTarget = mFinalFileDestination; + else + *aTarget = mTempFile; + + NS_IF_ADDREF(*aTarget); + return NS_OK; +} + +NS_IMETHODIMP nsExternalAppHandler::GetTargetFileIsExecutable(bool* aExec) { + // Use the real target if it's been set + if (mFinalFileDestination) return mFinalFileDestination->IsExecutable(aExec); + + // Otherwise, use the stored executable-ness of the temporary + *aExec = mTempFileIsExecutable; + return NS_OK; +} + +NS_IMETHODIMP nsExternalAppHandler::GetTimeDownloadStarted(PRTime* aTime) { + *aTime = mTimeDownloadStarted; + return NS_OK; +} + +NS_IMETHODIMP nsExternalAppHandler::GetContentLength(int64_t* aContentLength) { + *aContentLength = mContentLength; + return NS_OK; +} + +NS_IMETHODIMP nsExternalAppHandler::GetBrowsingContextId( + uint64_t* aBrowsingContextId) { + *aBrowsingContextId = mBrowsingContext ? mBrowsingContext->Id() : 0; + return NS_OK; +} + +void nsExternalAppHandler::RetargetLoadNotifications(nsIRequest* request) { + // we are going to run the downloading of the helper app in our own little + // docloader / load group context. so go ahead and force the creation of a + // load group and doc loader for us to use... + nsCOMPtr<nsIChannel> aChannel = do_QueryInterface(request); + if (!aChannel) return; + + bool isPrivate = NS_UsePrivateBrowsing(aChannel); + + nsCOMPtr<nsILoadGroup> oldLoadGroup; + aChannel->GetLoadGroup(getter_AddRefs(oldLoadGroup)); + + if (oldLoadGroup) { + oldLoadGroup->RemoveRequest(request, nullptr, NS_BINDING_RETARGETED); + } + + aChannel->SetLoadGroup(nullptr); + aChannel->SetNotificationCallbacks(nullptr); + + nsCOMPtr<nsIPrivateBrowsingChannel> pbChannel = do_QueryInterface(aChannel); + if (pbChannel) { + pbChannel->SetPrivate(isPrivate); + } +} + +nsresult nsExternalAppHandler::SetUpTempFile(nsIChannel* aChannel) { + // First we need to try to get the destination directory for the temporary + // file. + nsresult rv = GetDownloadDirectory(getter_AddRefs(mTempFile)); + NS_ENSURE_SUCCESS(rv, rv); + + // At this point, we do not have a filename for the temp file. For security + // purposes, this cannot be predictable, so we must use a cryptographic + // quality PRNG to generate one. + nsAutoCString tempLeafName; + rv = GenerateRandomName(tempLeafName); + NS_ENSURE_SUCCESS(rv, rv); + + // now append our extension. + nsAutoCString ext; + mMimeInfo->GetPrimaryExtension(ext); + if (!ext.IsEmpty()) { + ext.ReplaceChar(KNOWN_PATH_SEPARATORS FILE_ILLEGAL_CHARACTERS, '_'); + if (ext.First() != '.') tempLeafName.Append('.'); + tempLeafName.Append(ext); + } + + // We need to temporarily create a dummy file with the correct + // file extension to determine the executable-ness, so do this before adding + // the extra .part extension. + nsCOMPtr<nsIFile> dummyFile; + rv = NS_GetSpecialDirectory(NS_OS_TEMP_DIR, getter_AddRefs(dummyFile)); + NS_ENSURE_SUCCESS(rv, rv); + + // Set the file name without .part + rv = dummyFile->Append(NS_ConvertUTF8toUTF16(tempLeafName)); + NS_ENSURE_SUCCESS(rv, rv); + rv = dummyFile->CreateUnique(nsIFile::NORMAL_FILE_TYPE, 0600); + NS_ENSURE_SUCCESS(rv, rv); + + // Store executable-ness then delete + dummyFile->IsExecutable(&mTempFileIsExecutable); + dummyFile->Remove(false); + + // Add an additional .part to prevent the OS from running this file in the + // default application. + tempLeafName.AppendLiteral(".part"); + + rv = mTempFile->Append(NS_ConvertUTF8toUTF16(tempLeafName)); + // make this file unique!!! + NS_ENSURE_SUCCESS(rv, rv); + rv = mTempFile->CreateUnique(nsIFile::NORMAL_FILE_TYPE, 0600); + NS_ENSURE_SUCCESS(rv, rv); + + // Now save the temp leaf name, minus the ".part" bit, so we can use it later. + // This is a bit broken in the case when createUnique actually had to append + // some numbers, because then we now have a filename like foo.bar-1.part and + // we'll end up with foo.bar-1.bar as our final filename if we end up using + // this. But the other options are all bad too.... Ideally we'd have a way + // to tell createUnique to put its unique marker before the extension that + // comes before ".part" or something. + rv = mTempFile->GetLeafName(mTempLeafName); + NS_ENSURE_SUCCESS(rv, rv); + + NS_ENSURE_TRUE(StringEndsWith(mTempLeafName, u".part"_ns), + NS_ERROR_UNEXPECTED); + + // Strip off the ".part" from mTempLeafName + mTempLeafName.Truncate(mTempLeafName.Length() - ArrayLength(".part") + 1); + + MOZ_ASSERT(!mSaver, "Output file initialization called more than once!"); + mSaver = + do_CreateInstance(NS_BACKGROUNDFILESAVERSTREAMLISTENER_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + rv = mSaver->SetObserver(this); + if (NS_FAILED(rv)) { + mSaver = nullptr; + return rv; + } + + rv = mSaver->EnableSha256(); + NS_ENSURE_SUCCESS(rv, rv); + + rv = mSaver->EnableSignatureInfo(); + NS_ENSURE_SUCCESS(rv, rv); + LOG("Enabled hashing and signature verification"); + + rv = mSaver->SetTarget(mTempFile, false); + NS_ENSURE_SUCCESS(rv, rv); + + return rv; +} + +void nsExternalAppHandler::MaybeApplyDecodingForExtension( + nsIRequest* aRequest) { + MOZ_ASSERT(aRequest); + + nsCOMPtr<nsIEncodedChannel> encChannel = do_QueryInterface(aRequest); + if (!encChannel) { + return; + } + + // Turn off content encoding conversions if needed + bool applyConversion = true; + + // First, check to see if conversion is already disabled. If so, we + // have nothing to do here. + encChannel->GetApplyConversion(&applyConversion); + if (!applyConversion) { + return; + } + + nsCOMPtr<nsIURL> sourceURL(do_QueryInterface(mSourceUrl)); + if (sourceURL) { + nsAutoCString extension; + sourceURL->GetFileExtension(extension); + if (!extension.IsEmpty()) { + nsCOMPtr<nsIUTF8StringEnumerator> encEnum; + encChannel->GetContentEncodings(getter_AddRefs(encEnum)); + if (encEnum) { + bool hasMore; + nsresult rv = encEnum->HasMore(&hasMore); + if (NS_SUCCEEDED(rv) && hasMore) { + nsAutoCString encType; + rv = encEnum->GetNext(encType); + if (NS_SUCCEEDED(rv) && !encType.IsEmpty()) { + MOZ_ASSERT(mExtProtSvc); + mExtProtSvc->ApplyDecodingForExtension(extension, encType, + &applyConversion); + } + } + } + } + } + + encChannel->SetApplyConversion(applyConversion); +} + +already_AddRefed<nsIInterfaceRequestor> +nsExternalAppHandler::GetDialogParent() { + nsCOMPtr<nsIInterfaceRequestor> dialogParent = mWindowContext; + + if (!dialogParent && mBrowsingContext) { + dialogParent = do_QueryInterface(mBrowsingContext->GetDOMWindow()); + } + if (!dialogParent && mBrowsingContext && XRE_IsParentProcess()) { + RefPtr<Element> element = mBrowsingContext->Top()->GetEmbedderElement(); + if (element) { + dialogParent = do_QueryInterface(element->OwnerDoc()->GetWindow()); + } + } + return dialogParent.forget(); +} + +NS_IMETHODIMP nsExternalAppHandler::OnStartRequest(nsIRequest* request) { + MOZ_ASSERT(request, "OnStartRequest without request?"); + + // Set mTimeDownloadStarted here as the download has already started and + // we want to record the start time before showing the filepicker. + mTimeDownloadStarted = PR_Now(); + + mRequest = request; + + nsCOMPtr<nsIChannel> aChannel = do_QueryInterface(request); + + nsresult rv; + nsAutoCString MIMEType; + if (mMimeInfo) { + mMimeInfo->GetMIMEType(MIMEType); + } + // Now get the URI + if (aChannel) { + aChannel->GetURI(getter_AddRefs(mSourceUrl)); + // HTTPS-Only/HTTPS-FirstMode tries to upgrade connections to https. Once + // the download is in progress we set that flag so that timeout counter + // measures do not kick in. + nsCOMPtr<nsILoadInfo> loadInfo = aChannel->LoadInfo(); + bool isPrivateWin = loadInfo->GetOriginAttributes().mPrivateBrowsingId > 0; + if (nsHTTPSOnlyUtils::IsHttpsOnlyModeEnabled(isPrivateWin) || + nsHTTPSOnlyUtils::IsHttpsFirstModeEnabled(isPrivateWin)) { + uint32_t httpsOnlyStatus = loadInfo->GetHttpsOnlyStatus(); + httpsOnlyStatus |= nsILoadInfo::HTTPS_ONLY_DOWNLOAD_IN_PROGRESS; + loadInfo->SetHttpsOnlyStatus(httpsOnlyStatus); + } + } + + if (!mForceSave && StaticPrefs::browser_download_enable_spam_prevention() && + IsDownloadSpam(aChannel)) { + return NS_OK; + } + + mDownloadClassification = + nsContentSecurityUtils::ClassifyDownload(aChannel, MIMEType); + + if (mDownloadClassification == nsITransfer::DOWNLOAD_FORBIDDEN) { + // If the download is rated as forbidden, + // cancel the request so no ui knows about this. + mCanceled = true; + request->Cancel(NS_ERROR_ABORT); + return NS_OK; + } + + nsCOMPtr<nsIFileChannel> fileChan(do_QueryInterface(request)); + mIsFileChannel = fileChan != nullptr; + if (!mIsFileChannel) { + // It's possible that this request came from the child process and the + // file channel actually lives there. If this returns true, then our + // mSourceUrl will be an nsIFileURL anyway. + nsCOMPtr<dom::nsIExternalHelperAppParent> parent( + do_QueryInterface(request)); + mIsFileChannel = parent && parent->WasFileChannel(); + } + + // Get content length + if (aChannel) { + aChannel->GetContentLength(&mContentLength); + } + + if (mBrowsingContext) { + mMaybeCloseWindowHelper = new MaybeCloseWindowHelper(mBrowsingContext); + mMaybeCloseWindowHelper->SetShouldCloseWindow(mShouldCloseWindow); + nsCOMPtr<nsIPropertyBag2> props(do_QueryInterface(request, &rv)); + // Determine whether a new window was opened specifically for this request + if (props) { + bool tmp = false; + if (NS_SUCCEEDED( + props->GetPropertyAsBool(u"docshell.newWindowTarget"_ns, &tmp))) { + mMaybeCloseWindowHelper->SetShouldCloseWindow(tmp); + } + } + } + + // retarget all load notifications to our docloader instead of the original + // window's docloader... + RetargetLoadNotifications(request); + + // Close the underlying DOMWindow if it was opened specifically for the + // download. We don't run this in the content process, since we have + // an instance running in the parent as well, which will handle this + // if needed. + if (!XRE_IsContentProcess() && mMaybeCloseWindowHelper) { + mBrowsingContext = mMaybeCloseWindowHelper->MaybeCloseWindow(); + } + + // In an IPC setting, we're allowing the child process, here, to make + // decisions about decoding the channel (e.g. decompression). It will + // still forward the decoded (uncompressed) data back to the parent. + // Con: Uncompressed data means more IPC overhead. + // Pros: ExternalHelperAppParent doesn't need to implement nsIEncodedChannel. + // Parent process doesn't need to expect CPU time on decompression. + MaybeApplyDecodingForExtension(aChannel); + + // At this point, the child process has done everything it can usefully do + // for OnStartRequest. + if (XRE_IsContentProcess()) { + return NS_OK; + } + + rv = SetUpTempFile(aChannel); + if (NS_FAILED(rv)) { + nsresult transferError = rv; + + rv = CreateFailedTransfer(); + if (NS_FAILED(rv)) { + LOG("Failed to create transfer to report failure." + "Will fallback to prompter!"); + } + + mCanceled = true; + request->Cancel(transferError); + + nsAutoString path; + if (mTempFile) mTempFile->GetPath(path); + + SendStatusChange(kWriteError, transferError, request, path); + + return NS_OK; + } + + // Inform channel it is open on behalf of a download to throttle it during + // page loads and prevent its caching. + nsCOMPtr<nsIHttpChannelInternal> httpInternal = do_QueryInterface(aChannel); + if (httpInternal) { + rv = httpInternal->SetChannelIsForDownload(true); + MOZ_ASSERT(NS_SUCCEEDED(rv)); + } + + if (mSourceUrl->SchemeIs("data")) { + // In case we're downloading a data:// uri + // we don't want to apply AllowTopLevelNavigationToDataURI. + nsCOMPtr<nsILoadInfo> loadInfo = aChannel->LoadInfo(); + loadInfo->SetForceAllowDataURI(true); + } + + // now that the temp file is set up, find out if we need to invoke a dialog + // asking the user what they want us to do with this content... + + // We can get here for three reasons: "can't handle", "sniffed type", or + // "server sent content-disposition:attachment". In the first case we want + // to honor the user's "always ask" pref; in the other two cases we want to + // honor it only if the default action is "save". Opening attachments in + // helper apps by default breaks some websites (especially if the attachment + // is one part of a multipart document). Opening sniffed content in helper + // apps by default introduces security holes that we'd rather not have. + + // So let's find out whether the user wants to be prompted. If he does not, + // check mReason and the preferred action to see what we should do. + + bool alwaysAsk = true; + mMimeInfo->GetAlwaysAskBeforeHandling(&alwaysAsk); + if (alwaysAsk) { + // But we *don't* ask if this mimeInfo didn't come from + // our user configuration datastore and the user has said + // at some point in the distant past that they don't + // want to be asked. The latter fact would have been + // stored in pref strings back in the old days. + + bool mimeTypeIsInDatastore = false; + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID); + if (handlerSvc) { + handlerSvc->Exists(mMimeInfo, &mimeTypeIsInDatastore); + } + if (!handlerSvc || !mimeTypeIsInDatastore) { + if (!GetNeverAskFlagFromPref(NEVER_ASK_FOR_SAVE_TO_DISK_PREF, + MIMEType.get())) { + // Don't need to ask after all. + alwaysAsk = false; + // Make sure action matches pref (save to disk). + mMimeInfo->SetPreferredAction(nsIMIMEInfo::saveToDisk); + } else if (!GetNeverAskFlagFromPref(NEVER_ASK_FOR_OPEN_FILE_PREF, + MIMEType.get())) { + // Don't need to ask after all. + alwaysAsk = false; + } + } + } else if (MIMEType.EqualsLiteral("text/plain")) { + nsAutoCString ext; + mMimeInfo->GetPrimaryExtension(ext); + // If people are sending us ApplicationReputation-eligible files with + // text/plain mimetypes, enforce asking the user what to do. + if (!ext.IsEmpty()) { + nsAutoCString dummyFileName("f"); + if (ext.First() != '.') { + dummyFileName.Append("."); + } + ext.ReplaceChar(KNOWN_PATH_SEPARATORS FILE_ILLEGAL_CHARACTERS, '_'); + nsCOMPtr<nsIApplicationReputationService> appRep = + components::ApplicationReputation::Service(); + appRep->IsBinary(dummyFileName + ext, &alwaysAsk); + } + } + + int32_t action = nsIMIMEInfo::saveToDisk; + mMimeInfo->GetPreferredAction(&action); + + bool forcePrompt = mReason == nsIHelperAppLauncherDialog::REASON_TYPESNIFFED; + + // OK, now check why we're here + if (!alwaysAsk && forcePrompt) { + // Force asking if we're not saving. See comment back when we fetched the + // alwaysAsk boolean for details. + alwaysAsk = (action != nsIMIMEInfo::saveToDisk); + } + + bool shouldAutomaticallyHandleInternally = + action == nsIMIMEInfo::handleInternally; + + if (aChannel) { + uint32_t disposition = -1; + aChannel->GetContentDisposition(&disposition); + mForceSaveInternallyHandled = + shouldAutomaticallyHandleInternally && + disposition == nsIChannel::DISPOSITION_ATTACHMENT && + mozilla::StaticPrefs:: + browser_download_force_save_internally_handled_attachments(); + } + + // If we're not asking, check we actually know what to do: + if (!alwaysAsk) { + alwaysAsk = action != nsIMIMEInfo::saveToDisk && + action != nsIMIMEInfo::useHelperApp && + action != nsIMIMEInfo::useSystemDefault && + !shouldAutomaticallyHandleInternally; + } + + // If we're handling with the OS default and we are that default, force + // asking, so we don't end up in an infinite loop: + if (!alwaysAsk && action == nsIMIMEInfo::useSystemDefault) { + bool areOSDefault = false; + alwaysAsk = NS_SUCCEEDED(mMimeInfo->IsCurrentAppOSDefault(&areOSDefault)) && + areOSDefault; + } else if (!alwaysAsk && action == nsIMIMEInfo::useHelperApp) { + nsCOMPtr<nsIHandlerApp> preferredApp; + mMimeInfo->GetPreferredApplicationHandler(getter_AddRefs(preferredApp)); + nsCOMPtr<nsILocalHandlerApp> handlerApp = do_QueryInterface(preferredApp); + if (handlerApp) { + nsCOMPtr<nsIFile> executable; + handlerApp->GetExecutable(getter_AddRefs(executable)); + nsCOMPtr<nsIFile> ourselves; + if (executable && + // Despite the name, this really just fetches an nsIFile... + NS_SUCCEEDED(NS_GetSpecialDirectory(XRE_EXECUTABLE_FILE, + getter_AddRefs(ourselves)))) { + ourselves = nsMIMEInfoBase::GetCanonicalExecutable(ourselves); + executable = nsMIMEInfoBase::GetCanonicalExecutable(executable); + bool isSameApp = false; + alwaysAsk = + NS_FAILED(executable->Equals(ourselves, &isSameApp)) || isSameApp; + } + } + } + + // if we were told that we _must_ save to disk without asking, all the stuff + // before this is irrelevant; override it + if (mForceSave || mForceSaveInternallyHandled) { + alwaysAsk = false; + action = nsIMIMEInfo::saveToDisk; + shouldAutomaticallyHandleInternally = false; + } + // Additionally, if we are asked by the OS to open a local file, + // automatically downloading it to create a second copy of that file doesn't + // really make sense. We should ask the user what they want to do. + if (mSourceUrl->SchemeIs("file") && !alwaysAsk && + action == nsIMIMEInfo::saveToDisk) { + alwaysAsk = true; + } + + // If adding new checks, make sure this is the last check before telemetry + // and going ahead with opening the file! +#ifdef XP_WIN + /* We need to see whether the file we've got here could be + * executable. If it could, we had better not try to open it! + * We can skip this check, though, if we have a setting to open in a + * helper app. + */ + if (!alwaysAsk && action != nsIMIMEInfo::saveToDisk && + !shouldAutomaticallyHandleInternally) { + nsCOMPtr<nsIHandlerApp> prefApp; + mMimeInfo->GetPreferredApplicationHandler(getter_AddRefs(prefApp)); + if (action != nsIMIMEInfo::useHelperApp || !prefApp) { + nsCOMPtr<nsIFile> fileToTest; + GetTargetFile(getter_AddRefs(fileToTest)); + if (fileToTest) { + bool isExecutable; + rv = fileToTest->IsExecutable(&isExecutable); + if (NS_FAILED(rv) || mTempFileIsExecutable || + isExecutable) { // checking NS_FAILED, because paranoia is good + alwaysAsk = true; + } + } else { // Paranoia is good here too, though this really should not + // happen + NS_WARNING( + "GetDownloadInfo returned a null file after the temp file has been " + "set up! "); + alwaysAsk = true; + } + } + } +#endif + + if (alwaysAsk) { + // Display the dialog + mDialog = do_CreateInstance(NS_HELPERAPPLAUNCHERDLG_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + // this will create a reference cycle (the dialog holds a reference to us as + // nsIHelperAppLauncher), which will be broken in Cancel or CreateTransfer. + nsCOMPtr<nsIInterfaceRequestor> dialogParent = GetDialogParent(); + // Don't pop up the downloads panel since we're already going to pop up the + // UCT dialog for basically the same effect. + mDialogShowing = true; + rv = mDialog->Show(this, dialogParent, mReason); + + // what do we do if the dialog failed? I guess we should call Cancel and + // abort the load.... + } else { + // We need to do the save/open immediately, then. + if (action == nsIMIMEInfo::useHelperApp || + action == nsIMIMEInfo::useSystemDefault || + shouldAutomaticallyHandleInternally) { + // Check if the file is local, in which case just launch it from where it + // is. Otherwise, set the file to launch once it's finished downloading. + rv = mIsFileChannel ? LaunchLocalFile() + : SetDownloadToLaunch( + shouldAutomaticallyHandleInternally, nullptr); + } else { + rv = PromptForSaveDestination(); + } + } + return NS_OK; +} + +bool nsExternalAppHandler::IsDownloadSpam(nsIChannel* aChannel) { + nsCOMPtr<nsILoadInfo> loadInfo = aChannel->LoadInfo(); + nsCOMPtr<nsIPermissionManager> permissionManager = + mozilla::services::GetPermissionManager(); + nsCOMPtr<nsIPrincipal> principal = loadInfo->TriggeringPrincipal(); + bool exactHostMatch = false; + constexpr auto type = "automatic-download"_ns; + nsCOMPtr<nsIPermission> permission; + + permissionManager->GetPermissionObject(principal, type, exactHostMatch, + getter_AddRefs(permission)); + + if (permission) { + uint32_t capability; + permission->GetCapability(&capability); + if (capability == nsIPermissionManager::DENY_ACTION) { + mCanceled = true; + aChannel->Cancel(NS_ERROR_ABORT); + return true; + } + if (capability == nsIPermissionManager::ALLOW_ACTION) { + return false; + } + // If no action is set (i.e: null), we set PROMPT_ACTION by default, + // which will notify the Downloads UI to open the panel on the next request. + if (capability == nsIPermissionManager::PROMPT_ACTION) { + nsCOMPtr<nsIObserverService> observerService = + mozilla::services::GetObserverService(); + RefPtr<BrowsingContext> browsingContext; + loadInfo->GetBrowsingContext(getter_AddRefs(browsingContext)); + + nsAutoCString cStringURI; + loadInfo->TriggeringPrincipal()->GetPrePath(cStringURI); + observerService->NotifyObservers( + browsingContext, "blocked-automatic-download", + NS_ConvertASCIItoUTF16(cStringURI.get()).get()); + // FIXME: In order to escape memory leaks, currently we cancel blocked + // downloads. This is temporary solution, because download data should be + // kept in order to restart the blocked download. + mCanceled = true; + aChannel->Cancel(NS_ERROR_ABORT); + // End cancel + return true; + } + } + if (!loadInfo->GetHasValidUserGestureActivation()) { + permissionManager->AddFromPrincipal( + principal, type, nsIPermissionManager::PROMPT_ACTION, + nsIPermissionManager::EXPIRE_NEVER, 0 /* expire time */); + } + + return false; +} + +// Convert error info into proper message text and send OnStatusChange +// notification to the dialog progress listener or nsITransfer implementation. +void nsExternalAppHandler::SendStatusChange(ErrorType type, nsresult rv, + nsIRequest* aRequest, + const nsString& path) { + const char* msgId = nullptr; + switch (rv) { + case NS_ERROR_OUT_OF_MEMORY: + // No memory + msgId = "noMemory"; + break; + + case NS_ERROR_FILE_NO_DEVICE_SPACE: + // Out of space on target volume. + msgId = "diskFull"; + break; + + case NS_ERROR_FILE_READ_ONLY: + // Attempt to write to read/only file. + msgId = "readOnly"; + break; + + case NS_ERROR_FILE_ACCESS_DENIED: + if (type == kWriteError) { + // Attempt to write without sufficient permissions. +#if defined(ANDROID) + // On Android this means the SD card is present but + // unavailable (read-only). + msgId = "SDAccessErrorCardReadOnly"; +#else + msgId = "accessError"; +#endif + } else { + msgId = "launchError"; + } + break; + + case NS_ERROR_FILE_NOT_FOUND: + case NS_ERROR_FILE_UNRECOGNIZED_PATH: + // Helper app not found, let's verify this happened on launch + if (type == kLaunchError) { + msgId = "helperAppNotFound"; + break; + } +#if defined(ANDROID) + else if (type == kWriteError) { + // On Android this means the SD card is missing (not in + // SD slot). + msgId = "SDAccessErrorCardMissing"; + break; + } +#endif + [[fallthrough]]; + + default: + // Generic read/write/launch error message. + switch (type) { + case kReadError: + msgId = "readError"; + break; + case kWriteError: + msgId = "writeError"; + break; + case kLaunchError: + msgId = "launchError"; + break; + } + break; + } + + MOZ_LOG( + nsExternalHelperAppService::sLog, LogLevel::Error, + ("Error: %s, type=%i, listener=0x%p, transfer=0x%p, rv=0x%08" PRIX32 "\n", + msgId, type, mDialogProgressListener.get(), mTransfer.get(), + static_cast<uint32_t>(rv))); + + MOZ_LOG(nsExternalHelperAppService::sLog, LogLevel::Error, + (" path='%s'\n", NS_ConvertUTF16toUTF8(path).get())); + + // Get properties file bundle and extract status string. + nsCOMPtr<nsIStringBundleService> stringService = + mozilla::components::StringBundle::Service(); + if (stringService) { + nsCOMPtr<nsIStringBundle> bundle; + if (NS_SUCCEEDED(stringService->CreateBundle( + "chrome://global/locale/nsWebBrowserPersist.properties", + getter_AddRefs(bundle)))) { + nsAutoString msgText; + AutoTArray<nsString, 1> strings = {path}; + if (NS_SUCCEEDED(bundle->FormatStringFromName(msgId, strings, msgText))) { + if (mDialogProgressListener) { + // We have a listener, let it handle the error. + mDialogProgressListener->OnStatusChange( + nullptr, (type == kReadError) ? aRequest : nullptr, rv, + msgText.get()); + } else if (mTransfer) { + mTransfer->OnStatusChange(nullptr, + (type == kReadError) ? aRequest : nullptr, + rv, msgText.get()); + } else if (XRE_IsParentProcess()) { + // We don't have a listener. Simply show the alert ourselves. + nsCOMPtr<nsIInterfaceRequestor> dialogParent = GetDialogParent(); + nsresult qiRv; + nsCOMPtr<nsIPrompt> prompter(do_GetInterface(dialogParent, &qiRv)); + nsAutoString title; + bundle->FormatStringFromName("title", strings, title); + + MOZ_LOG( + nsExternalHelperAppService::sLog, LogLevel::Debug, + ("mBrowsingContext=0x%p, prompter=0x%p, qi rv=0x%08" PRIX32 + ", title='%s', msg='%s'", + mBrowsingContext.get(), prompter.get(), + static_cast<uint32_t>(qiRv), NS_ConvertUTF16toUTF8(title).get(), + NS_ConvertUTF16toUTF8(msgText).get())); + + // If we didn't have a prompter we will try and get a window + // instead, get it's docshell and use it to alert the user. + if (!prompter) { + nsCOMPtr<nsPIDOMWindowOuter> window(do_GetInterface(dialogParent)); + if (!window || !window->GetDocShell()) { + return; + } + + prompter = do_GetInterface(window->GetDocShell(), &qiRv); + + MOZ_LOG(nsExternalHelperAppService::sLog, LogLevel::Debug, + ("No prompter from mBrowsingContext, using DocShell, " + "window=0x%p, docShell=0x%p, " + "prompter=0x%p, qi rv=0x%08" PRIX32, + window.get(), window->GetDocShell(), prompter.get(), + static_cast<uint32_t>(qiRv))); + + // If we still don't have a prompter, there's nothing else we + // can do so just return. + if (!prompter) { + MOZ_LOG(nsExternalHelperAppService::sLog, LogLevel::Error, + ("No prompter from DocShell, no way to alert user")); + return; + } + } + + // We should always have a prompter at this point. + prompter->Alert(title.get(), msgText.get()); + } + } + } + } +} + +NS_IMETHODIMP +nsExternalAppHandler::OnDataAvailable(nsIRequest* request, + nsIInputStream* inStr, + uint64_t sourceOffset, uint32_t count) { + nsresult rv = NS_OK; + // first, check to see if we've been canceled.... + if (mCanceled || !mSaver) { + // then go cancel our underlying channel too + return request->Cancel(NS_BINDING_ABORTED); + } + + // read the data out of the stream and write it to the temp file. + if (count > 0) { + mProgress += count; + + nsCOMPtr<nsIStreamListener> saver = do_QueryInterface(mSaver); + rv = saver->OnDataAvailable(request, inStr, sourceOffset, count); + if (NS_SUCCEEDED(rv)) { + // Send progress notification. + if (mTransfer) { + mTransfer->OnProgressChange64(nullptr, request, mProgress, + mContentLength, mProgress, + mContentLength); + } + } else { + // An error occurred, notify listener. + nsAutoString tempFilePath; + if (mTempFile) { + mTempFile->GetPath(tempFilePath); + } + SendStatusChange(kReadError, rv, request, tempFilePath); + + // Cancel the download. + Cancel(rv); + } + } + return rv; +} + +NS_IMETHODIMP nsExternalAppHandler::OnStopRequest(nsIRequest* request, + nsresult aStatus) { + LOG("nsExternalAppHandler::OnStopRequest\n" + " mCanceled=%d, mTransfer=0x%p, aStatus=0x%08" PRIX32 "\n", + mCanceled, mTransfer.get(), static_cast<uint32_t>(aStatus)); + + mStopRequestIssued = true; + + // Cancel if the request did not complete successfully. + if (!mCanceled && NS_FAILED(aStatus)) { + // Send error notification. + nsAutoString tempFilePath; + if (mTempFile) mTempFile->GetPath(tempFilePath); + SendStatusChange(kReadError, aStatus, request, tempFilePath); + + Cancel(aStatus); + } + + // first, check to see if we've been canceled.... + if (mCanceled || !mSaver) { + return NS_OK; + } + + return mSaver->Finish(NS_OK); +} + +NS_IMETHODIMP +nsExternalAppHandler::OnTargetChange(nsIBackgroundFileSaver* aSaver, + nsIFile* aTarget) { + return NS_OK; +} + +NS_IMETHODIMP +nsExternalAppHandler::OnSaveComplete(nsIBackgroundFileSaver* aSaver, + nsresult aStatus) { + LOG("nsExternalAppHandler::OnSaveComplete\n" + " aSaver=0x%p, aStatus=0x%08" PRIX32 ", mCanceled=%d, mTransfer=0x%p\n", + aSaver, static_cast<uint32_t>(aStatus), mCanceled, mTransfer.get()); + + if (!mCanceled) { + // Save the hash and signature information + (void)mSaver->GetSha256Hash(mHash); + (void)mSaver->GetSignatureInfo(mSignatureInfo); + + // Free the reference that the saver keeps on us, even if we couldn't get + // the hash. + mSaver = nullptr; + + // Save the redirect information. + nsCOMPtr<nsIChannel> channel = do_QueryInterface(mRequest); + if (channel) { + nsCOMPtr<nsILoadInfo> loadInfo = channel->LoadInfo(); + nsresult rv = NS_OK; + nsCOMPtr<nsIMutableArray> redirectChain = + do_CreateInstance(NS_ARRAY_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + LOG("nsExternalAppHandler: Got %zu redirects\n", + loadInfo->RedirectChain().Length()); + for (nsIRedirectHistoryEntry* entry : loadInfo->RedirectChain()) { + redirectChain->AppendElement(entry); + } + mRedirects = redirectChain; + } + + if (NS_FAILED(aStatus)) { + nsAutoString path; + mTempFile->GetPath(path); + + // It may happen when e10s is enabled that there will be no transfer + // object available to communicate status as expected by the system. + // Let's try and create a temporary transfer object to take care of this + // for us, we'll fall back to using the prompt service if we absolutely + // have to. + if (!mTransfer) { + // We don't care if this fails. + CreateFailedTransfer(); + } + + SendStatusChange(kWriteError, aStatus, nullptr, path); + if (!mCanceled) Cancel(aStatus); + return NS_OK; + } + } + + // Notify the transfer object that we are done if the user has chosen an + // action. If the user hasn't chosen an action, the progress listener + // (nsITransfer) will be notified in CreateTransfer. + if (mTransfer) { + NotifyTransfer(aStatus); + } + + return NS_OK; +} + +void nsExternalAppHandler::NotifyTransfer(nsresult aStatus) { + MOZ_ASSERT(NS_IsMainThread(), "Must notify on main thread"); + MOZ_ASSERT(mTransfer, "We must have an nsITransfer"); + + LOG("Notifying progress listener"); + + if (NS_SUCCEEDED(aStatus)) { + (void)mTransfer->SetSha256Hash(mHash); + (void)mTransfer->SetSignatureInfo(mSignatureInfo); + (void)mTransfer->SetRedirects(mRedirects); + (void)mTransfer->OnProgressChange64( + nullptr, nullptr, mProgress, mContentLength, mProgress, mContentLength); + } + + (void)mTransfer->OnStateChange(nullptr, nullptr, + nsIWebProgressListener::STATE_STOP | + nsIWebProgressListener::STATE_IS_REQUEST | + nsIWebProgressListener::STATE_IS_NETWORK, + aStatus); + + // This nsITransfer object holds a reference to us (we are its observer), so + // we need to release the reference to break a reference cycle (and therefore + // to prevent leaking). We do this even if the previous calls failed. + mTransfer = nullptr; +} + +NS_IMETHODIMP nsExternalAppHandler::GetMIMEInfo(nsIMIMEInfo** aMIMEInfo) { + *aMIMEInfo = mMimeInfo; + NS_ADDREF(*aMIMEInfo); + return NS_OK; +} + +NS_IMETHODIMP nsExternalAppHandler::GetSource(nsIURI** aSourceURI) { + NS_ENSURE_ARG(aSourceURI); + *aSourceURI = mSourceUrl; + NS_IF_ADDREF(*aSourceURI); + return NS_OK; +} + +NS_IMETHODIMP nsExternalAppHandler::GetSuggestedFileName( + nsAString& aSuggestedFileName) { + aSuggestedFileName = mSuggestedFileName; + return NS_OK; +} + +nsresult nsExternalAppHandler::CreateTransfer() { + LOG("nsExternalAppHandler::CreateTransfer"); + + MOZ_ASSERT(NS_IsMainThread(), "Must create transfer on main thread"); + // We are back from the helper app dialog (where the user chooses to save or + // open), but we aren't done processing the load. in this case, throw up a + // progress dialog so the user can see what's going on. + // Also, release our reference to mDialog. We don't need it anymore, and we + // need to break the reference cycle. + mDialog = nullptr; + if (!mDialogProgressListener) { + NS_WARNING("The dialog should nullify the dialog progress listener"); + } + // In case of a non acceptable download, we need to cancel the request and + // pass a FailedTransfer for the Download UI. + if (mDownloadClassification != nsITransfer::DOWNLOAD_ACCEPTABLE) { + mCanceled = true; + mRequest->Cancel(NS_ERROR_ABORT); + if (mSaver) { + mSaver->Finish(NS_ERROR_ABORT); + mSaver = nullptr; + } + return CreateFailedTransfer(); + } + nsresult rv; + + // We must be able to create an nsITransfer object. If not, it doesn't matter + // much that we can't launch the helper application or save to disk. Work on + // a local copy rather than mTransfer until we know we succeeded, to make it + // clearer that this function is re-entrant. + nsCOMPtr<nsITransfer> transfer = + do_CreateInstance(NS_TRANSFER_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + // Initialize the download + nsCOMPtr<nsIURI> target; + rv = NS_NewFileURI(getter_AddRefs(target), mFinalFileDestination); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIChannel> channel = do_QueryInterface(mRequest); + nsCOMPtr<nsIHttpChannel> httpChannel = do_QueryInterface(mRequest); + nsCOMPtr<nsIReferrerInfo> referrerInfo = nullptr; + if (httpChannel) { + referrerInfo = httpChannel->GetReferrerInfo(); + } + + if (mBrowsingContext) { + rv = transfer->InitWithBrowsingContext( + mSourceUrl, target, u""_ns, mMimeInfo, mTimeDownloadStarted, mTempFile, + this, channel && NS_UsePrivateBrowsing(channel), + mDownloadClassification, referrerInfo, !mDialogShowing, + mBrowsingContext, mHandleInternally, nullptr); + } else { + rv = transfer->Init(mSourceUrl, nullptr, target, u""_ns, mMimeInfo, + mTimeDownloadStarted, mTempFile, this, + channel && NS_UsePrivateBrowsing(channel), + mDownloadClassification, referrerInfo, !mDialogShowing); + } + mDialogShowing = false; + + NS_ENSURE_SUCCESS(rv, rv); + + // If we were cancelled since creating the transfer, just return. It is + // always ok to return NS_OK if we are cancelled. Callers of this function + // must call Cancel if CreateTransfer fails, but there's no need to cancel + // twice. + if (mCanceled) { + return NS_OK; + } + rv = transfer->OnStateChange(nullptr, mRequest, + nsIWebProgressListener::STATE_START | + nsIWebProgressListener::STATE_IS_REQUEST | + nsIWebProgressListener::STATE_IS_NETWORK, + NS_OK); + NS_ENSURE_SUCCESS(rv, rv); + + if (mCanceled) { + return NS_OK; + } + + mRequest = nullptr; + // Finally, save the transfer to mTransfer. + mTransfer = transfer; + transfer = nullptr; + + // While we were bringing up the progress dialog, we actually finished + // processing the url. If that's the case then mStopRequestIssued will be + // true and OnSaveComplete has been called. + if (mStopRequestIssued && !mSaver && mTransfer) { + NotifyTransfer(NS_OK); + } + + return rv; +} + +nsresult nsExternalAppHandler::CreateFailedTransfer() { + nsresult rv; + nsCOMPtr<nsITransfer> transfer = + do_CreateInstance(NS_TRANSFER_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + // We won't pass the temp file to the transfer, so if we have one it needs to + // get deleted now. + if (mTempFile) { + if (mSaver) { + mSaver->Finish(NS_BINDING_ABORTED); + mSaver = nullptr; + } + mTempFile->Remove(false); + } + + nsCOMPtr<nsIURI> pseudoTarget; + if (!mFinalFileDestination) { + // If we don't have a download directory we're kinda screwed but it's OK + // we'll still report the error via the prompter. + nsCOMPtr<nsIFile> pseudoFile; + rv = GetDownloadDirectory(getter_AddRefs(pseudoFile), true); + NS_ENSURE_SUCCESS(rv, rv); + + // Append the default suggested filename. If the user restarts the transfer + // we will re-trigger a filename check anyway to ensure that it is unique. + rv = pseudoFile->Append(mSuggestedFileName); + NS_ENSURE_SUCCESS(rv, rv); + + rv = NS_NewFileURI(getter_AddRefs(pseudoTarget), pseudoFile); + NS_ENSURE_SUCCESS(rv, rv); + } else { + // Initialize the target, if present + rv = NS_NewFileURI(getter_AddRefs(pseudoTarget), mFinalFileDestination); + NS_ENSURE_SUCCESS(rv, rv); + } + + nsCOMPtr<nsIChannel> channel = do_QueryInterface(mRequest); + nsCOMPtr<nsIHttpChannel> httpChannel = do_QueryInterface(mRequest); + nsCOMPtr<nsIReferrerInfo> referrerInfo = nullptr; + if (httpChannel) { + referrerInfo = httpChannel->GetReferrerInfo(); + } + + if (mBrowsingContext) { + rv = transfer->InitWithBrowsingContext( + mSourceUrl, pseudoTarget, u""_ns, mMimeInfo, mTimeDownloadStarted, + mTempFile, this, channel && NS_UsePrivateBrowsing(channel), + mDownloadClassification, referrerInfo, true, mBrowsingContext, + mHandleInternally, httpChannel); + } else { + rv = transfer->Init(mSourceUrl, nullptr, pseudoTarget, u""_ns, mMimeInfo, + mTimeDownloadStarted, mTempFile, this, + channel && NS_UsePrivateBrowsing(channel), + mDownloadClassification, referrerInfo, true); + } + NS_ENSURE_SUCCESS(rv, rv); + + // Our failed transfer is ready. + mTransfer = std::move(transfer); + + return NS_OK; +} + +nsresult nsExternalAppHandler::SaveDestinationAvailable(nsIFile* aFile, + bool aDialogWasShown) { + if (aFile) { + if (aDialogWasShown) { + mDialogShowing = true; + } + ContinueSave(aFile); + } else { + Cancel(NS_BINDING_ABORTED); + } + + return NS_OK; +} + +void nsExternalAppHandler::RequestSaveDestination( + const nsString& aDefaultFile, const nsString& aFileExtension) { + // Display the dialog + // XXX Convert to use file picker? No, then embeddors could not do any sort of + // "AutoDownload" w/o showing a prompt + nsresult rv = NS_OK; + if (!mDialog) { + // Get helper app launcher dialog. + mDialog = do_CreateInstance(NS_HELPERAPPLAUNCHERDLG_CONTRACTID, &rv); + if (rv != NS_OK) { + Cancel(NS_BINDING_ABORTED); + return; + } + } + + // we want to explicitly unescape aDefaultFile b4 passing into the dialog. we + // can't unescape it because the dialog is implemented by a JS component which + // doesn't have a window so no unescape routine is defined... + + // Now, be sure to keep |this| alive, and the dialog + // If we don't do this, users that close the helper app dialog while the file + // picker is up would cause Cancel() to be called, and the dialog would be + // released, which would release this object too, which would crash. + // See Bug 249143 + RefPtr<nsExternalAppHandler> kungFuDeathGrip(this); + nsCOMPtr<nsIHelperAppLauncherDialog> dlg(mDialog); + nsCOMPtr<nsIInterfaceRequestor> dialogParent = GetDialogParent(); + + rv = dlg->PromptForSaveToFileAsync(this, dialogParent, aDefaultFile.get(), + aFileExtension.get(), mForceSave); + if (NS_FAILED(rv)) { + Cancel(NS_BINDING_ABORTED); + } +} + +// PromptForSaveDestination should only be called by the helper app dialog which +// allows the user to say launch with application or save to disk. +NS_IMETHODIMP nsExternalAppHandler::PromptForSaveDestination() { + if (mCanceled) return NS_OK; + + if (mForceSave || mForceSaveInternallyHandled) { + mMimeInfo->SetPreferredAction(nsIMIMEInfo::saveToDisk); + } + + if (mSuggestedFileName.IsEmpty()) { + RequestSaveDestination(mTempLeafName, mFileExtension); + } else { + nsAutoString fileExt; + int32_t pos = mSuggestedFileName.RFindChar('.'); + if (pos >= 0) { + mSuggestedFileName.Right(fileExt, mSuggestedFileName.Length() - pos); + } + if (fileExt.IsEmpty()) { + fileExt = mFileExtension; + } + + RequestSaveDestination(mSuggestedFileName, fileExt); + } + + return NS_OK; +} +nsresult nsExternalAppHandler::ContinueSave(nsIFile* aNewFileLocation) { + if (mCanceled) return NS_OK; + + MOZ_ASSERT(aNewFileLocation, "Must be called with a non-null file"); + + int32_t action = nsIMIMEInfo::saveToDisk; + mMimeInfo->GetPreferredAction(&action); + mHandleInternally = action == nsIMIMEInfo::handleInternally; + + nsresult rv = NS_OK; + nsCOMPtr<nsIFile> fileToUse = aNewFileLocation; + mFinalFileDestination = fileToUse; + + // Move what we have in the final directory, but append .part + // to it, to indicate that it's unfinished. Do not call SetTarget on the + // saver if we are done (Finish has been called) but OnSaverComplete has + // not been called. + if (mFinalFileDestination && mSaver && !mStopRequestIssued) { + nsCOMPtr<nsIFile> movedFile; + mFinalFileDestination->Clone(getter_AddRefs(movedFile)); + if (movedFile) { + nsAutoCString randomChars; + rv = GenerateRandomName(randomChars); + if (NS_SUCCEEDED(rv)) { + // Get the leaf name, strip any extensions, then + // add random bytes, followed by the extensions and '.part'. + nsAutoString leafName; + mFinalFileDestination->GetLeafName(leafName); + auto nameWithoutExtensionLength = leafName.FindChar('.'); + nsAutoString extensions(u""); + if (nameWithoutExtensionLength == kNotFound) { + nameWithoutExtensionLength = leafName.Length(); + } else { + extensions = Substring(leafName, nameWithoutExtensionLength); + } + leafName.Truncate(nameWithoutExtensionLength); + + nsAutoString suffix = u"."_ns + NS_ConvertASCIItoUTF16(randomChars) + + extensions + u".part"_ns; +#ifdef XP_WIN + // Deal with MAX_PATH on Windows. Worth noting that the original + // path for mFinalFileDestination must be valid for us to get + // here: either SetDownloadToLaunch or the caller of + // SaveDestinationAvailable has called CreateUnique or similar + // to ensure both a unique name and one that isn't too long. + // The only issue is we're making it longer to get the part + // file path... + nsAutoString path; + mFinalFileDestination->GetPath(path); + CheckedInt<uint16_t> fullPathLength = + CheckedInt<uint16_t>(path.Length()) + 1 + randomChars.Length() + + ArrayLength(".part"); + if (!fullPathLength.isValid()) { + leafName.Truncate(); + } else if (fullPathLength.value() > MAX_PATH) { + int32_t leafNameRemaining = + (int32_t)leafName.Length() - (fullPathLength.value() - MAX_PATH); + leafName.Truncate(std::max(leafNameRemaining, 0)); + } +#endif + leafName.Append(suffix); + movedFile->SetLeafName(leafName); + + rv = mSaver->SetTarget(movedFile, true); + if (NS_FAILED(rv)) { + nsAutoString path; + mTempFile->GetPath(path); + SendStatusChange(kWriteError, rv, nullptr, path); + Cancel(rv); + return NS_OK; + } + + mTempFile = movedFile; + } + } + } + + // The helper app dialog has told us what to do and we have a final file + // destination. + rv = CreateTransfer(); + // If we fail to create the transfer, Cancel. + if (NS_FAILED(rv)) { + Cancel(rv); + return rv; + } + + return NS_OK; +} + +// SetDownloadToLaunch should only be called by the helper app dialog which +// allows the user to say launch with application or save to disk. +NS_IMETHODIMP nsExternalAppHandler::SetDownloadToLaunch( + bool aHandleInternally, nsIFile* aNewFileLocation) { + if (mCanceled) return NS_OK; + + mHandleInternally = aHandleInternally; + + // Now that the user has elected to launch the downloaded file with a helper + // app, we're justified in removing the 'salted' name. We'll rename to what + // was specified in mSuggestedFileName after the download is done prior to + // launching the helper app. So that any existing file of that name won't be + // overwritten we call CreateUnique(). Also note that we use the same + // directory as originally downloaded so the download can be renamed in place + // later. + nsCOMPtr<nsIFile> fileToUse; + if (aNewFileLocation) { + fileToUse = aNewFileLocation; + } else { + (void)GetDownloadDirectory(getter_AddRefs(fileToUse)); + + if (mSuggestedFileName.IsEmpty()) { + // Keep using the leafname of the temp file, since we're just starting a + // helper + mSuggestedFileName = mTempLeafName; + } + +#ifdef XP_WIN + // Ensure we don't double-append the file extension if it matches: + if (StringEndsWith(mSuggestedFileName, mFileExtension, + nsCaseInsensitiveStringComparator)) { + fileToUse->Append(mSuggestedFileName); + } else { + fileToUse->Append(mSuggestedFileName + mFileExtension); + } +#else + fileToUse->Append(mSuggestedFileName); +#endif + } + + nsresult rv = fileToUse->CreateUnique(nsIFile::NORMAL_FILE_TYPE, 0600); + if (NS_SUCCEEDED(rv)) { + mFinalFileDestination = fileToUse; + // launch the progress window now that the user has picked the desired + // action. + rv = CreateTransfer(); + if (NS_FAILED(rv)) { + Cancel(rv); + } + } else { + // Cancel the download and report an error. We do not want to end up in + // a state where it appears that we have a normal download that is + // pointing to a file that we did not actually create. + nsAutoString path; + mTempFile->GetPath(path); + SendStatusChange(kWriteError, rv, nullptr, path); + Cancel(rv); + } + return rv; +} + +nsresult nsExternalAppHandler::LaunchLocalFile() { + nsCOMPtr<nsIFileURL> fileUrl(do_QueryInterface(mSourceUrl)); + if (!fileUrl) { + return NS_OK; + } + Cancel(NS_BINDING_ABORTED); + nsCOMPtr<nsIFile> file; + nsresult rv = fileUrl->GetFile(getter_AddRefs(file)); + + if (NS_SUCCEEDED(rv)) { + rv = mMimeInfo->LaunchWithFile(file); + if (NS_SUCCEEDED(rv)) return NS_OK; + } + nsAutoString path; + if (file) file->GetPath(path); + // If we get here, an error happened + SendStatusChange(kLaunchError, rv, nullptr, path); + return rv; +} + +NS_IMETHODIMP nsExternalAppHandler::Cancel(nsresult aReason) { + NS_ENSURE_ARG(NS_FAILED(aReason)); + + if (mCanceled) { + return NS_OK; + } + mCanceled = true; + + if (mSaver) { + // We are still writing to the target file. Give the saver a chance to + // close the target file, then notify the transfer object if necessary in + // the OnSaveComplete callback. + mSaver->Finish(aReason); + mSaver = nullptr; + } else { + if (mStopRequestIssued && mTempFile) { + // This branch can only happen when the user cancels the helper app dialog + // when the request has completed. The temp file has to be removed here, + // because mSaver has been released at that time with the temp file left. + (void)mTempFile->Remove(false); + } + + // Notify the transfer object that the download has been canceled, if the + // user has already chosen an action and we didn't notify already. + if (mTransfer) { + NotifyTransfer(aReason); + } + } + + // Break our reference cycle with the helper app dialog (set up in + // OnStartRequest) + mDialog = nullptr; + mDialogShowing = false; + + mRequest = nullptr; + + // Release the listener, to break the reference cycle with it (we are the + // observer of the listener). + mDialogProgressListener = nullptr; + + return NS_OK; +} + +bool nsExternalAppHandler::GetNeverAskFlagFromPref(const char* prefName, + const char* aContentType) { + // Search the obsolete pref strings. + nsAutoCString prefCString; + Preferences::GetCString(prefName, prefCString); + if (prefCString.IsEmpty()) { + // Default is true, if not found in the pref string. + return true; + } + + NS_UnescapeURL(prefCString); + nsACString::const_iterator start, end; + prefCString.BeginReading(start); + prefCString.EndReading(end); + return !CaseInsensitiveFindInReadable(nsDependentCString(aContentType), start, + end); +} + +NS_IMETHODIMP +nsExternalAppHandler::GetName(nsACString& aName) { + aName.AssignLiteral("nsExternalAppHandler"); + return NS_OK; +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// The following section contains our nsIMIMEService implementation and related +// methods. +// +////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// nsIMIMEService methods +NS_IMETHODIMP nsExternalHelperAppService::GetFromTypeAndExtension( + const nsACString& aMIMEType, const nsACString& aFileExt, + nsIMIMEInfo** _retval) { + MOZ_ASSERT(!aMIMEType.IsEmpty() || !aFileExt.IsEmpty(), + "Give me something to work with"); + MOZ_DIAGNOSTIC_ASSERT(aFileExt.FindChar('\0') == kNotFound, + "The extension should never contain null characters"); + LOG("Getting mimeinfo from type '%s' ext '%s'\n", + PromiseFlatCString(aMIMEType).get(), PromiseFlatCString(aFileExt).get()); + + *_retval = nullptr; + + // OK... we need a type. Get one. + nsAutoCString typeToUse(aMIMEType); + if (typeToUse.IsEmpty()) { + nsresult rv = GetTypeFromExtension(aFileExt, typeToUse); + if (NS_FAILED(rv)) return NS_ERROR_NOT_AVAILABLE; + } + + // We promise to only send lower case mime types to the OS + ToLowerCase(typeToUse); + + // First, ask the OS for a mime info + bool found; + nsresult rv = GetMIMEInfoFromOS(typeToUse, aFileExt, &found, _retval); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + LOG("OS gave back 0x%p - found: %i\n", *_retval, found); + // If we got no mimeinfo, something went wrong. Probably lack of memory. + if (!*_retval) return NS_ERROR_OUT_OF_MEMORY; + + // The handler service can make up for bad mime types by checking the file + // extension. If the mime type is known (in extras or in the handler + // service), we stop it doing so by flipping this bool to true. + bool trustMIMEType = false; + + // Check extras - not everything we support will be known by the OS store, + // unfortunately, and it may even miss some extensions that we know should + // be accepted. We only do this for non-octet-stream mimetypes, because + // our information for octet-stream would lead to us trying to open all such + // files as Binary file with exe, com or bin extension regardless of the + // real extension. + if (!typeToUse.Equals(APPLICATION_OCTET_STREAM, + nsCaseInsensitiveCStringComparator)) { + rv = FillMIMEInfoForMimeTypeFromExtras(typeToUse, !found, *_retval); + LOG("Searched extras (by type), rv 0x%08" PRIX32 "\n", + static_cast<uint32_t>(rv)); + trustMIMEType = NS_SUCCEEDED(rv); + found = found || NS_SUCCEEDED(rv); + } + + // Now, let's see if we can find something in our datastore. + // This will not overwrite the OS information that interests us + // (i.e. default application, default app. description) + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID); + if (handlerSvc) { + bool hasHandler = false; + (void)handlerSvc->Exists(*_retval, &hasHandler); + if (hasHandler) { + rv = handlerSvc->FillHandlerInfo(*_retval, ""_ns); + LOG("Data source: Via type: retval 0x%08" PRIx32 "\n", + static_cast<uint32_t>(rv)); + trustMIMEType = trustMIMEType || NS_SUCCEEDED(rv); + } else { + rv = NS_ERROR_NOT_AVAILABLE; + } + + found = found || NS_SUCCEEDED(rv); + } + + // If we still haven't found anything, try finding a match for + // an extension in extras first: + if (!found && !aFileExt.IsEmpty()) { + rv = FillMIMEInfoForExtensionFromExtras(aFileExt, *_retval); + LOG("Searched extras (by ext), rv 0x%08" PRIX32 "\n", + static_cast<uint32_t>(rv)); + } + + // Then check the handler service - but only do so if we really do not know + // the mimetype. This avoids overwriting good mimetype info with bad file + // extension info. + if ((!found || !trustMIMEType) && handlerSvc && !aFileExt.IsEmpty()) { + nsAutoCString overrideType; + rv = handlerSvc->GetTypeFromExtension(aFileExt, overrideType); + if (NS_SUCCEEDED(rv) && !overrideType.IsEmpty()) { + // We can't check handlerSvc->Exists() here, because we have a + // overideType. That's ok, it just results in some console noise. + // (If there's no handler for the override type, it throws) + rv = handlerSvc->FillHandlerInfo(*_retval, overrideType); + LOG("Data source: Via ext: retval 0x%08" PRIx32 "\n", + static_cast<uint32_t>(rv)); + found = found || NS_SUCCEEDED(rv); + } + } + + // If we still don't have a match, at least set the file description + // to `${aFileExt} File` if it's empty: + if (!found && !aFileExt.IsEmpty()) { + // XXXzpao This should probably be localized + nsAutoCString desc(aFileExt); + desc.AppendLiteral(" File"); + (*_retval)->SetDescription(NS_ConvertUTF8toUTF16(desc)); + LOG("Falling back to 'File' file description\n"); + } + + // Sometimes, OSes give us bad data. We have a set of forbidden extensions + // for some MIME types. If the primary extension is forbidden, + // overwrite it with a known-good one. See bug 1571247 for context. + nsAutoCString primaryExtension; + (*_retval)->GetPrimaryExtension(primaryExtension); + if (!primaryExtension.EqualsIgnoreCase(PromiseFlatCString(aFileExt).get())) { + if (MaybeReplacePrimaryExtension(primaryExtension, *_retval)) { + (*_retval)->GetPrimaryExtension(primaryExtension); + } + } + + // Finally, check if we got a file extension and if yes, if it is an + // extension on the mimeinfo, in which case we want it to be the primary one + if (!aFileExt.IsEmpty()) { + bool matches = false; + (*_retval)->ExtensionExists(aFileExt, &matches); + LOG("Extension '%s' matches mime info: %i\n", + PromiseFlatCString(aFileExt).get(), matches); + if (matches) { + nsAutoCString fileExt; + ToLowerCase(aFileExt, fileExt); + (*_retval)->SetPrimaryExtension(fileExt); + primaryExtension = fileExt; + } + } + + // Overwrite with a generic description if the primary extension for the + // type is in our list; these are file formats supported by Firefox and + // we don't want other brands positioning themselves as the sole viewer + // for a system. + if (!primaryExtension.IsEmpty()) { + for (const char* ext : descriptionOverwriteExtensions) { + if (primaryExtension.Equals(ext)) { + nsCOMPtr<nsIStringBundleService> bundleService = + do_GetService(NS_STRINGBUNDLE_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + nsCOMPtr<nsIStringBundle> unknownContentTypeBundle; + rv = bundleService->CreateBundle( + "chrome://mozapps/locale/downloads/unknownContentType.properties", + getter_AddRefs(unknownContentTypeBundle)); + if (NS_SUCCEEDED(rv)) { + nsAutoCString stringName(ext); + stringName.AppendLiteral("ExtHandlerDescription"); + nsAutoString handlerDescription; + rv = unknownContentTypeBundle->GetStringFromName(stringName.get(), + handlerDescription); + if (NS_SUCCEEDED(rv)) { + (*_retval)->SetDescription(handlerDescription); + } + } + break; + } + } + } + + if (LOG_ENABLED()) { + nsAutoCString type; + (*_retval)->GetMIMEType(type); + + LOG("MIME Info Summary: Type '%s', Primary Ext '%s'\n", type.get(), + primaryExtension.get()); + } + + return NS_OK; +} + +bool nsExternalHelperAppService::GetMIMETypeFromDefaultForExtension( + const nsACString& aExtension, nsACString& aMIMEType) { + // First of all, check our default entries + for (auto& entry : defaultMimeEntries) { + if (aExtension.LowerCaseEqualsASCII(entry.mFileExtension)) { + aMIMEType = entry.mMimeType; + return true; + } + } + return false; +} + +NS_IMETHODIMP +nsExternalHelperAppService::GetTypeFromExtension(const nsACString& aFileExt, + nsACString& aContentType) { + // OK. We want to try the following sources of mimetype information, in this + // order: + // 1. defaultMimeEntries array + // 2. OS-provided information + // 3. our "extras" array + // 4. Information from plugins + // 5. The "ext-to-type-mapping" category + // Note that, we are intentionally not looking at the handler service, because + // that can be affected by websites, which leads to undesired behavior. + + // Early return if called with an empty extension parameter + if (aFileExt.IsEmpty()) { + return NS_ERROR_NOT_AVAILABLE; + } + + // First of all, check our default entries + if (GetMIMETypeFromDefaultForExtension(aFileExt, aContentType)) { + return NS_OK; + } + + // Ask OS. + if (GetMIMETypeFromOSForExtension(aFileExt, aContentType)) { + return NS_OK; + } + + // Check extras array. + bool found = GetTypeFromExtras(aFileExt, aContentType); + if (found) { + return NS_OK; + } + + // Let's see if an extension added something + nsCOMPtr<nsICategoryManager> catMan( + do_GetService("@mozilla.org/categorymanager;1")); + if (catMan) { + // The extension in the category entry is always stored as lowercase + nsAutoCString lowercaseFileExt(aFileExt); + ToLowerCase(lowercaseFileExt); + // Read the MIME type from the category entry, if available + nsCString type; + nsresult rv = + catMan->GetCategoryEntry("ext-to-type-mapping", lowercaseFileExt, type); + if (NS_SUCCEEDED(rv)) { + aContentType = type; + return NS_OK; + } + } + + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP nsExternalHelperAppService::GetPrimaryExtension( + const nsACString& aMIMEType, const nsACString& aFileExt, + nsACString& _retval) { + NS_ENSURE_ARG(!aMIMEType.IsEmpty()); + + nsCOMPtr<nsIMIMEInfo> mi; + nsresult rv = + GetFromTypeAndExtension(aMIMEType, aFileExt, getter_AddRefs(mi)); + if (NS_FAILED(rv)) return rv; + + return mi->GetPrimaryExtension(_retval); +} + +NS_IMETHODIMP nsExternalHelperAppService::GetDefaultTypeFromURI( + nsIURI* aURI, nsACString& aContentType) { + NS_ENSURE_ARG_POINTER(aURI); + nsresult rv = NS_ERROR_NOT_AVAILABLE; + aContentType.Truncate(); + + // Now try to get an nsIURL so we don't have to do our own parsing + nsCOMPtr<nsIURL> url = do_QueryInterface(aURI); + if (!url) { + return NS_ERROR_NOT_AVAILABLE; + } + + nsAutoCString ext; + rv = url->GetFileExtension(ext); + if (NS_FAILED(rv)) { + return rv; + } + + if (!ext.IsEmpty()) { + UnescapeFragment(ext, url, ext); + + if (GetMIMETypeFromDefaultForExtension(ext, aContentType)) { + return NS_OK; + } + } + + return NS_ERROR_NOT_AVAILABLE; +}; + +NS_IMETHODIMP nsExternalHelperAppService::GetTypeFromURI( + nsIURI* aURI, nsACString& aContentType) { + NS_ENSURE_ARG_POINTER(aURI); + nsresult rv = NS_ERROR_NOT_AVAILABLE; + aContentType.Truncate(); + + // First look for a file to use. If we have one, we just use that. + nsCOMPtr<nsIFileURL> fileUrl = do_QueryInterface(aURI); + if (fileUrl) { + nsCOMPtr<nsIFile> file; + rv = fileUrl->GetFile(getter_AddRefs(file)); + if (NS_SUCCEEDED(rv)) { + rv = GetTypeFromFile(file, aContentType); + if (NS_SUCCEEDED(rv)) { + // we got something! + return rv; + } + } + } + + // Now try to get an nsIURL so we don't have to do our own parsing + nsCOMPtr<nsIURL> url = do_QueryInterface(aURI); + if (url) { + nsAutoCString ext; + rv = url->GetFileExtension(ext); + if (NS_FAILED(rv)) return rv; + if (ext.IsEmpty()) return NS_ERROR_NOT_AVAILABLE; + + UnescapeFragment(ext, url, ext); + + return GetTypeFromExtension(ext, aContentType); + } + + // no url, let's give the raw spec a shot + nsAutoCString specStr; + rv = aURI->GetSpec(specStr); + if (NS_FAILED(rv)) return rv; + UnescapeFragment(specStr, aURI, specStr); + + // find the file extension (if any) + int32_t extLoc = specStr.RFindChar('.'); + int32_t specLength = specStr.Length(); + if (-1 != extLoc && extLoc != specLength - 1 && + // nothing over 20 chars long can be sanely considered an + // extension.... Dat dere would be just data. + specLength - extLoc < 20) { + return GetTypeFromExtension(Substring(specStr, extLoc + 1), aContentType); + } + + // We found no information; say so. + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP nsExternalHelperAppService::GetTypeFromFile( + nsIFile* aFile, nsACString& aContentType) { + NS_ENSURE_ARG_POINTER(aFile); + nsresult rv; + + // Get the Extension + nsAutoString fileName; + rv = aFile->GetLeafName(fileName); + if (NS_FAILED(rv)) return rv; + + nsAutoCString fileExt; + if (!fileName.IsEmpty()) { + int32_t len = fileName.Length(); + for (int32_t i = len; i >= 0; i--) { + if (fileName[i] == char16_t('.')) { + CopyUTF16toUTF8(Substring(fileName, i + 1), fileExt); + break; + } + } + } + + if (fileExt.IsEmpty()) return NS_ERROR_FAILURE; + + return GetTypeFromExtension(fileExt, aContentType); +} + +nsresult nsExternalHelperAppService::FillMIMEInfoForMimeTypeFromExtras( + const nsACString& aContentType, bool aOverwriteDescription, + nsIMIMEInfo* aMIMEInfo) { + NS_ENSURE_ARG(aMIMEInfo); + + NS_ENSURE_ARG(!aContentType.IsEmpty()); + + // Look for default entry with matching mime type. + nsAutoCString MIMEType(aContentType); + ToLowerCase(MIMEType); + for (auto entry : extraMimeEntries) { + if (MIMEType.Equals(entry.mMimeType)) { + // This is the one. Set attributes appropriately. + nsDependentCString extensions(entry.mFileExtensions); + nsACString::const_iterator start, end; + extensions.BeginReading(start); + extensions.EndReading(end); + while (start != end) { + nsACString::const_iterator cursor = start; + mozilla::Unused << FindCharInReadable(',', cursor, end); + aMIMEInfo->AppendExtension(Substring(start, cursor)); + // If a comma was found, skip it for the next search. + start = cursor != end ? ++cursor : cursor; + } + + nsAutoString desc; + aMIMEInfo->GetDescription(desc); + if (aOverwriteDescription || desc.IsEmpty()) { + aMIMEInfo->SetDescription(NS_ConvertASCIItoUTF16(entry.mDescription)); + } + return NS_OK; + } + } + + return NS_ERROR_NOT_AVAILABLE; +} + +nsresult nsExternalHelperAppService::FillMIMEInfoForExtensionFromExtras( + const nsACString& aExtension, nsIMIMEInfo* aMIMEInfo) { + nsAutoCString type; + bool found = GetTypeFromExtras(aExtension, type); + if (!found) return NS_ERROR_NOT_AVAILABLE; + return FillMIMEInfoForMimeTypeFromExtras(type, true, aMIMEInfo); +} + +bool nsExternalHelperAppService::MaybeReplacePrimaryExtension( + const nsACString& aPrimaryExtension, nsIMIMEInfo* aMIMEInfo) { + for (const auto& entry : sForbiddenPrimaryExtensions) { + if (aPrimaryExtension.LowerCaseEqualsASCII(entry.mFileExtension)) { + nsDependentCString mime(entry.mMimeType); + for (const auto& extraEntry : extraMimeEntries) { + if (mime.LowerCaseEqualsASCII(extraEntry.mMimeType)) { + nsDependentCString goodExts(extraEntry.mFileExtensions); + int32_t commaPos = goodExts.FindChar(','); + commaPos = commaPos == kNotFound ? goodExts.Length() : commaPos; + auto goodExt = Substring(goodExts, 0, commaPos); + aMIMEInfo->SetPrimaryExtension(goodExt); + return true; + } + } + } + } + return false; +} + +bool nsExternalHelperAppService::GetTypeFromExtras(const nsACString& aExtension, + nsACString& aMIMEType) { + NS_ASSERTION(!aExtension.IsEmpty(), "Empty aExtension parameter!"); + + // Look for default entry with matching extension. + nsDependentCString::const_iterator start, end, iter; + int32_t numEntries = ArrayLength(extraMimeEntries); + for (int32_t index = 0; index < numEntries; index++) { + nsDependentCString extList(extraMimeEntries[index].mFileExtensions); + extList.BeginReading(start); + extList.EndReading(end); + iter = start; + while (start != end) { + FindCharInReadable(',', iter, end); + if (Substring(start, iter) + .Equals(aExtension, nsCaseInsensitiveCStringComparator)) { + aMIMEType = extraMimeEntries[index].mMimeType; + return true; + } + if (iter != end) { + ++iter; + } + start = iter; + } + } + + return false; +} + +bool nsExternalHelperAppService::GetMIMETypeFromOSForExtension( + const nsACString& aExtension, nsACString& aMIMEType) { + bool found = false; + nsCOMPtr<nsIMIMEInfo> mimeInfo; + nsresult rv = + GetMIMEInfoFromOS(""_ns, aExtension, &found, getter_AddRefs(mimeInfo)); + return NS_SUCCEEDED(rv) && found && mimeInfo && + NS_SUCCEEDED(mimeInfo->GetMIMEType(aMIMEType)); +} + +nsresult nsExternalHelperAppService::GetMIMEInfoFromOS( + const nsACString& aMIMEType, const nsACString& aFileExt, bool* aFound, + nsIMIMEInfo** aMIMEInfo) { + *aMIMEInfo = nullptr; + *aFound = false; + return NS_ERROR_NOT_IMPLEMENTED; +} + +nsresult nsExternalHelperAppService::UpdateDefaultAppInfo( + nsIMIMEInfo* aMIMEInfo) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +bool nsExternalHelperAppService::GetFileNameFromChannel(nsIChannel* aChannel, + nsAString& aFileName, + nsIURI** aURI) { + if (!aChannel) { + return false; + } + + aChannel->GetURI(aURI); + nsCOMPtr<nsIURL> url = do_QueryInterface(*aURI); + + // Check if we have a POST request, in which case we don't want to use + // the url's extension + bool allowURLExt = !net::ChannelIsPost(aChannel); + + // Check if we had a query string - we don't want to check the URL + // extension if a query is present in the URI + // If we already know we don't want to check the URL extension, don't + // bother checking the query + if (url && allowURLExt) { + nsAutoCString query; + + // We only care about the query for HTTP and HTTPS URLs + if (url->SchemeIs("http") || url->SchemeIs("https")) { + url->GetQuery(query); + } + + // Only get the extension if the query is empty; if it isn't, then the + // extension likely belongs to a cgi script and isn't helpful + allowURLExt = query.IsEmpty(); + } + + aChannel->GetContentDispositionFilename(aFileName); + + return allowURLExt; +} + +NS_IMETHODIMP +nsExternalHelperAppService::GetValidFileName(nsIChannel* aChannel, + const nsACString& aType, + nsIURI* aOriginalURI, + uint32_t aFlags, + nsAString& aOutFileName) { + nsCOMPtr<nsIURI> uri; + bool allowURLExtension = + GetFileNameFromChannel(aChannel, aOutFileName, getter_AddRefs(uri)); + + nsCOMPtr<nsIMIMEInfo> mimeInfo = ValidateFileNameForSaving( + aOutFileName, aType, uri, aOriginalURI, aFlags, allowURLExtension); + return NS_OK; +} + +NS_IMETHODIMP +nsExternalHelperAppService::ValidateFileNameForSaving( + const nsAString& aFileName, const nsACString& aType, uint32_t aFlags, + nsAString& aOutFileName) { + nsAutoString fileName(aFileName); + + // Just sanitize the filename only. + if (aFlags & VALIDATE_SANITIZE_ONLY) { + SanitizeFileName(fileName, aFlags); + } else { + nsCOMPtr<nsIMIMEInfo> mimeInfo = ValidateFileNameForSaving( + fileName, aType, nullptr, nullptr, aFlags, true); + } + + aOutFileName = fileName; + return NS_OK; +} + +already_AddRefed<nsIMIMEInfo> +nsExternalHelperAppService::ValidateFileNameForSaving( + nsAString& aFileName, const nsACString& aMimeType, nsIURI* aURI, + nsIURI* aOriginalURI, uint32_t aFlags, bool aAllowURLExtension) { + nsAutoString fileName(aFileName); + nsAutoCString extension; + nsCOMPtr<nsIMIMEInfo> mimeInfo; + + bool isBinaryType = aMimeType.EqualsLiteral(APPLICATION_OCTET_STREAM) || + aMimeType.EqualsLiteral(BINARY_OCTET_STREAM) || + aMimeType.EqualsLiteral("application/x-msdownload"); + + // We don't want to save hidden files starting with a dot, so remove any + // leading periods. This is done first, so that the remainder will be + // treated as the filename, and not an extension. + // Also, Windows ignores terminating dots. So we have to as well, so + // that our security checks do "the right thing" + fileName.Trim("."); + + bool urlIsFile = !!aURI && aURI->SchemeIs("file"); + + // We get the mime service here even though we're the default implementation + // of it, so it's possible to override only the mime service and not need to + // reimplement the whole external helper app service itself. + nsCOMPtr<nsIMIMEService> mimeService = do_GetService("@mozilla.org/mime;1"); + if (mimeService) { + if (fileName.IsEmpty()) { + nsCOMPtr<nsIURL> url = do_QueryInterface(aURI); + // Try to extract the file name from the url and use that as a first + // pass as the leaf name of our temp file... + if (url) { + nsAutoCString leafName; + url->GetFileName(leafName); + if (!leafName.IsEmpty()) { + if (NS_FAILED(UnescapeFragment(leafName, url, fileName))) { + CopyUTF8toUTF16(leafName, fileName); // use escaped name instead + fileName.Trim("."); + } + } + + // Only get the extension from the URL if allowed, or if this + // is a binary type in which case the type might not be valid + // anyway. + if (aAllowURLExtension || isBinaryType || urlIsFile) { + url->GetFileExtension(extension); + } + } + } else { + // Determine the current extension for the filename. + int32_t dotidx = fileName.RFind(u"."); + if (dotidx != -1) { + CopyUTF16toUTF8(Substring(fileName, dotidx + 1), extension); + } + } + + if (aFlags & VALIDATE_GUESS_FROM_EXTENSION) { + nsAutoCString mimeType; + if (!extension.IsEmpty()) { + mimeService->GetFromTypeAndExtension(EmptyCString(), extension, + getter_AddRefs(mimeInfo)); + if (mimeInfo) { + mimeInfo->GetMIMEType(mimeType); + } + } + + if (mimeType.IsEmpty()) { + // Extension lookup gave us no useful match, so use octet-stream + // instead. + mimeService->GetFromTypeAndExtension( + nsLiteralCString(APPLICATION_OCTET_STREAM), extension, + getter_AddRefs(mimeInfo)); + } + } else if (!aMimeType.IsEmpty()) { + // If this is a binary type, include the extension as a hint to get + // the mime info. For other types, the mime type itself should be + // sufficient. + // Unfortunately, on Windows, the mimetype is usually insufficient. + // Compensate at least on `file` URLs by trusting the extension - + // that's likely what we used to get the mimetype in the first place. + // The special case for application/ogg is because that type could + // actually be used for a video which can better be determined by the + // extension. This is tested by browser_save_video.js. + bool useExtension = + isBinaryType || urlIsFile || aMimeType.EqualsLiteral(APPLICATION_OGG); + mimeService->GetFromTypeAndExtension( + aMimeType, useExtension ? extension : EmptyCString(), + getter_AddRefs(mimeInfo)); + if (mimeInfo) { + // But if no primary extension was returned, this mime type is probably + // an unknown type. Look it up again but this time supply the extension. + nsAutoCString primaryExtension; + mimeInfo->GetPrimaryExtension(primaryExtension); + if (primaryExtension.IsEmpty()) { + mimeService->GetFromTypeAndExtension(aMimeType, extension, + getter_AddRefs(mimeInfo)); + } + } + } + } + + // If an empty filename is allowed, then return early. It will be saved + // using the filename of the temporary file that was created for the download. + if (aFlags & VALIDATE_ALLOW_EMPTY && fileName.IsEmpty()) { + aFileName.Truncate(); + return mimeInfo.forget(); + } + + // This section modifies the extension on the filename if it isn't valid for + // the given content type. + if (mimeInfo) { + bool isValidExtension; + if (extension.IsEmpty() || + NS_FAILED(mimeInfo->ExtensionExists(extension, &isValidExtension)) || + !isValidExtension) { + // Skip these checks for text and binary, so we don't append the unneeded + // .txt or other extension. + if (aMimeType.EqualsLiteral(TEXT_PLAIN) || isBinaryType) { + extension.Truncate(); + } else { + nsAutoCString originalExtension(extension); + // If an original url was supplied, see if it has a valid extension. + bool useOldExtension = false; + if (aOriginalURI) { + nsCOMPtr<nsIURL> originalURL(do_QueryInterface(aOriginalURI)); + if (originalURL) { + nsAutoCString uriExtension; + originalURL->GetFileExtension(uriExtension); + if (!uriExtension.IsEmpty()) { + mimeInfo->ExtensionExists(uriExtension, &useOldExtension); + if (useOldExtension) { + extension = uriExtension; + } + } + } + } + + if (!useOldExtension) { + // If the filename doesn't have a valid extension, or we don't know + // the extension, try to use the primary extension for the type. If we + // don't know the primary extension for the type, just continue with + // the existing extension, or leave the filename with no extension. + nsAutoCString primaryExtension; + mimeInfo->GetPrimaryExtension(primaryExtension); + if (!primaryExtension.IsEmpty()) { + extension = primaryExtension; + } + } + + // If an suitable extension was found, we will append to or replace the + // existing extension. + if (!extension.IsEmpty()) { + ModifyExtensionType modify = ShouldModifyExtension( + mimeInfo, aFlags & VALIDATE_FORCE_APPEND_EXTENSION, + originalExtension); + if (modify == ModifyExtension_Replace) { + int32_t dotidx = fileName.RFind(u"."); + if (dotidx != -1) { + // Remove the existing extension and replace it. + fileName.Truncate(dotidx); + } + } + + // Otherwise, just append the proper extension to the end of the + // filename, adding to the invalid extension that might already be + // there. + if (modify != ModifyExtension_Ignore) { + fileName.AppendLiteral("."); + fileName.Append(NS_ConvertUTF8toUTF16(extension)); + } + } + } + } + } + + CheckDefaultFileName(fileName, aFlags); + + // Make the filename safe for the filesystem. + SanitizeFileName(fileName, aFlags); + + aFileName = fileName; + return mimeInfo.forget(); +} + +void nsExternalHelperAppService::CheckDefaultFileName(nsAString& aFileName, + uint32_t aFlags) { + // If no filename is present, use a default filename. + if (!(aFlags & VALIDATE_NO_DEFAULT_FILENAME) && + (aFileName.Length() == 0 || aFileName.RFind(u".") == 0)) { + nsCOMPtr<nsIStringBundleService> stringService = + mozilla::components::StringBundle::Service(); + if (stringService) { + nsCOMPtr<nsIStringBundle> bundle; + if (NS_SUCCEEDED(stringService->CreateBundle( + "chrome://global/locale/contentAreaCommands.properties", + getter_AddRefs(bundle)))) { + nsAutoString defaultFileName; + bundle->GetStringFromName("UntitledSaveFileName", defaultFileName); + // Append any existing extension to the default filename. + aFileName = defaultFileName + aFileName; + } + } + + // Use 'Untitled' as a last resort. + if (!aFileName.Length()) { + aFileName.AssignLiteral("Untitled"); + } + } +} + +void nsExternalHelperAppService::SanitizeFileName(nsAString& aFileName, + uint32_t aFlags) { + nsAutoString fileName(aFileName); + + // Replace known invalid characters. + fileName.ReplaceChar(u"" KNOWN_PATH_SEPARATORS, u'_'); + fileName.ReplaceChar(u"" FILE_ILLEGAL_CHARACTERS, u' '); + fileName.StripChar(char16_t(0)); + + const char16_t *startStr, *endStr; + fileName.BeginReading(startStr); + fileName.EndReading(endStr); + + // True if multiple consecutive whitespace characters should + // be replaced by single space ' '. + bool collapseWhitespace = !(aFlags & VALIDATE_DONT_COLLAPSE_WHITESPACE); + + // The maximum filename length differs based on the platform: + // Windows (FAT/NTFS) stores filenames as a maximum of 255 UTF-16 code units. + // Mac (APFS) stores filenames with a maximum 255 of UTF-8 code units. + // Linux (ext3/ext4...) stores filenames with a maximum 255 bytes. + // So here we just use the maximum of 255 bytes. + // 0 means don't truncate at a maximum size. + const uint32_t maxBytes = + (aFlags & VALIDATE_DONT_TRUNCATE) ? 0 : kDefaultMaxFileNameLength; + + // True if the last character added was whitespace. + bool lastWasWhitespace = false; + + // Length of the filename that fits into the maximum size excluding the + // extension and period. + int32_t longFileNameEnd = -1; + + // Index of the last character added that was not a character that can be + // trimmed off of the end of the string. Trimmable characters are whitespace, + // periods and the vowel separator u'\u180e'. If all the characters after this + // point are trimmable characters, truncate the string to this point after + // iterating over the filename. + int32_t lastNonTrimmable = -1; + + // The number of bytes that the string would occupy if encoded in UTF-8. + uint32_t bytesLength = 0; + + // The length of the extension in bytes. + uint32_t extensionBytesLength = 0; + + // This algorithm iterates over each character in the string and appends it + // or a replacement character if needed to outFileName. + nsAutoString outFileName; + while (startStr < endStr) { + bool err = false; + char32_t nextChar = UTF16CharEnumerator::NextChar(&startStr, endStr, &err); + if (err) { + break; + } + + // nulls are already stripped out above. + MOZ_ASSERT(nextChar != char16_t(0)); + + auto unicodeCategory = unicode::GetGeneralCategory(nextChar); + if (unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_CONTROL || + unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_LINE_SEPARATOR || + unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_PARAGRAPH_SEPARATOR) { + // Skip over any control characters and separators. + continue; + } + + if (unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_SPACE_SEPARATOR || + nextChar == u'\ufeff') { + // Trim out any whitespace characters at the beginning of the filename, + // and only add whitespace in the middle of the filename if the last + // character was not whitespace or if we are not collapsing whitespace. + if (!outFileName.IsEmpty() && + (!lastWasWhitespace || !collapseWhitespace)) { + // Allow the ideographic space if it is present, otherwise replace with + // ' '. + if (nextChar != u'\u3000') { + nextChar = ' '; + } + lastWasWhitespace = true; + } else { + lastWasWhitespace = true; + continue; + } + } else { + lastWasWhitespace = false; + if (nextChar == '.' || nextChar == u'\u180e') { + // Don't add any periods or vowel separators at the beginning of the + // string. Note also that lastNonTrimmable is not adjusted in this + // case, because periods and vowel separators are included in the + // set of characters to trim at the end of the filename. + if (outFileName.IsEmpty()) { + continue; + } + } else { + if (unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_FORMAT) { + // Replace formatting characters with an underscore. + nextChar = '_'; + } + + // Don't truncate surrogate pairs in the middle. + lastNonTrimmable = + int32_t(outFileName.Length()) + + (NS_IS_HIGH_SURROGATE(H_SURROGATE(nextChar)) ? 2 : 1); + } + } + + if (maxBytes) { + // UTF16CharEnumerator already converts surrogate pairs, so we can use + // a simple computation of byte length here. + uint32_t charBytesLength = nextChar < 0x80 ? 1 + : nextChar < 0x800 ? 2 + : nextChar < 0x10000 ? 3 + : 4; + bytesLength += charBytesLength; + if (bytesLength > maxBytes) { + if (longFileNameEnd == -1) { + longFileNameEnd = int32_t(outFileName.Length()); + } + } + + // If we encounter a period, it could be the start of an extension, so + // start counting the number of bytes in the extension. If another period + // is found, start again since we want to use the last extension found. + if (nextChar == u'.') { + extensionBytesLength = 1; // 1 byte for the period. + } else if (extensionBytesLength) { + extensionBytesLength += charBytesLength; + } + } + + AppendUCS4ToUTF16(nextChar, outFileName); + } + + // If the filename is longer than the maximum allowed filename size, + // truncate it, but preserve the desired extension that is currently + // on the filename. + if (bytesLength > maxBytes && !outFileName.IsEmpty()) { + // Get the sanitized extension from the filename without the dot. + nsAutoString extension; + int32_t dotidx = outFileName.RFind(u"."); + if (dotidx != -1) { + extension = Substring(outFileName, dotidx + 1); + } + + // There are two ways in which the filename should be truncated: + // - If the filename was too long, truncate the name at the length + // of the filename. + // This position is indicated by longFileNameEnd. + // - lastNonTrimmable will indicate the last character that was not + // whitespace, a period, or a vowel separator at the end of the + // the string, so the string should be truncated there as well. + // If both apply, use the earliest position. + if (lastNonTrimmable >= 0) { + // Subtract off the amount for the extension and the period. + // Note that the extension length is in bytes but longFileNameEnd is in + // characters, but if they don't match, it just means we crop off + // more than is necessary. This is OK since it is better than cropping + // off too little. + longFileNameEnd -= extensionBytesLength; + if (longFileNameEnd <= 0) { + // This is extremely unlikely, but if the extension is larger than the + // maximum size, just get rid of it. In this case, the extension + // wouldn't have been an ordinary one we would want to preserve (such + // as .html or .png) so just truncate off the file wherever the first + // period appears. + int32_t dotidx = outFileName.Find(u"."); + outFileName.Truncate(dotidx > 0 ? dotidx : 1); + } else { + outFileName.Truncate(std::min(longFileNameEnd, lastNonTrimmable)); + + // Now that the filename has been truncated, re-append the extension + // again. + if (!extension.IsEmpty()) { + if (outFileName.Last() != '.') { + outFileName.AppendLiteral("."); + } + + outFileName.Append(extension); + } + } + } + } else if (lastNonTrimmable >= 0) { + // Otherwise, the filename wasn't too long, so just trim off the + // extra whitespace and periods at the end. + outFileName.Truncate(lastNonTrimmable); + } + +#ifdef XP_WIN + if (nsLocalFile::CheckForReservedFileName(outFileName)) { + outFileName.Truncate(); + CheckDefaultFileName(outFileName, aFlags); + } + +#endif + + if (!(aFlags & VALIDATE_ALLOW_INVALID_FILENAMES)) { + // If the extension is one these types, replace it with .download, as these + // types of files can have significance on Windows or Linux. + // This happens for any file, not just those with the shortcut mime type. + if (StringEndsWith(outFileName, u".lnk"_ns, + nsCaseInsensitiveStringComparator) || + StringEndsWith(outFileName, u".local"_ns, + nsCaseInsensitiveStringComparator) || + StringEndsWith(outFileName, u".url"_ns, + nsCaseInsensitiveStringComparator) || + StringEndsWith(outFileName, u".scf"_ns, + nsCaseInsensitiveStringComparator) || + StringEndsWith(outFileName, u".desktop"_ns, + nsCaseInsensitiveStringComparator)) { + outFileName.AppendLiteral(".download"); + } + } + + aFileName = outFileName; +} + +nsExternalHelperAppService::ModifyExtensionType +nsExternalHelperAppService::ShouldModifyExtension(nsIMIMEInfo* aMimeInfo, + bool aForceAppend, + const nsCString& aFileExt) { + nsAutoCString MIMEType; + if (!aMimeInfo || NS_FAILED(aMimeInfo->GetMIMEType(MIMEType))) { + return ModifyExtension_Append; + } + + // Determine whether the extensions should be appended or replaced depending + // on the content type. + bool canForce = StringBeginsWith(MIMEType, "image/"_ns) || + StringBeginsWith(MIMEType, "audio/"_ns) || + StringBeginsWith(MIMEType, "video/"_ns) || aFileExt.IsEmpty(); + + if (!canForce) { + for (const char* mime : forcedExtensionMimetypes) { + if (MIMEType.Equals(mime)) { + if (!StaticPrefs::browser_download_sanitize_non_media_extensions()) { + return ModifyExtension_Ignore; + } + canForce = true; + break; + } + } + + if (!canForce) { + return aForceAppend ? ModifyExtension_Append : ModifyExtension_Ignore; + } + } + + // If we get here, we know for sure the mimetype allows us to modify the + // existing extension, if it's wrong. Return whether we should replace it + // or append it. + bool knownExtension = false; + // Note that aFileExt is either empty or consists of an extension + // excluding the dot. + if (aFileExt.IsEmpty() || + (NS_SUCCEEDED(aMimeInfo->ExtensionExists(aFileExt, &knownExtension)) && + !knownExtension)) { + return ModifyExtension_Replace; + } + + return ModifyExtension_Append; +} diff --git a/uriloader/exthandler/nsExternalHelperAppService.h b/uriloader/exthandler/nsExternalHelperAppService.h new file mode 100644 index 0000000000..205f73cfa1 --- /dev/null +++ b/uriloader/exthandler/nsExternalHelperAppService.h @@ -0,0 +1,571 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * 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/. */ + +#ifndef nsExternalHelperAppService_h__ +#define nsExternalHelperAppService_h__ + +#include "mozilla/Logging.h" +#include "prtime.h" + +#include "nsIExternalHelperAppService.h" +#include "nsIExternalProtocolService.h" +#include "nsIWebProgressListener2.h" +#include "nsIHelperAppLauncherDialog.h" + +#include "nsILoadInfo.h" +#include "nsIMIMEInfo.h" +#include "nsIMIMEService.h" +#include "nsINamed.h" +#include "nsIStreamListener.h" +#include "nsIFile.h" +#include "nsIPermission.h" +#include "nsString.h" +#include "nsIInterfaceRequestor.h" +#include "nsIInterfaceRequestorUtils.h" +#include "nsIChannel.h" +#include "nsIBackgroundFileSaver.h" + +#include "nsCOMPtr.h" +#include "nsIObserver.h" +#include "nsCOMArray.h" +#include "nsWeakReference.h" +#include "mozilla/Attributes.h" + +class nsExternalAppHandler; +class nsIMIMEInfo; +class nsITransfer; +class nsIPrincipal; +class MaybeCloseWindowHelper; + +#define EXTERNAL_APP_HANDLER_IID \ + { \ + 0x50eb7479, 0x71ff, 0x4ef8, { \ + 0xb3, 0x1e, 0x3b, 0x59, 0xc8, 0xab, 0xb9, 0x24 \ + } \ + } + +/** + * The helper app service. Responsible for handling content that Mozilla + * itself can not handle + * Note that this is an abstract class - we depend on appropriate subclassing + * on a per-OS basis to implement some methods. + */ +class nsExternalHelperAppService : public nsIExternalHelperAppService, + public nsPIExternalAppLauncher, + public nsIExternalProtocolService, + public nsIMIMEService, + public nsIObserver, + public nsSupportsWeakReference { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIEXTERNALHELPERAPPSERVICE + NS_DECL_NSPIEXTERNALAPPLAUNCHER + NS_DECL_NSIMIMESERVICE + NS_DECL_NSIOBSERVER + + nsExternalHelperAppService(); + + /** + * Initializes internal state. Will be called automatically when + * this service is first instantiated. + */ + [[nodiscard]] nsresult Init(); + + /** + * nsIExternalProtocolService methods that we provide in this class. Other + * methods should be implemented by per-OS subclasses. + */ + NS_IMETHOD ExternalProtocolHandlerExists(const char* aProtocolScheme, + bool* aHandlerExists) override; + NS_IMETHOD IsExposedProtocol(const char* aProtocolScheme, + bool* aResult) override; + NS_IMETHOD GetProtocolHandlerInfo(const nsACString& aScheme, + nsIHandlerInfo** aHandlerInfo) override; + + NS_IMETHOD LoadURI(nsIURI* aURI, nsIPrincipal* aTriggeringPrincipal, + nsIPrincipal* aRedirectPrincipal, + mozilla::dom::BrowsingContext* aBrowsingContext, + bool aWasTriggeredExternally, + bool aHasValidUserGestureActivation) override; + NS_IMETHOD SetProtocolHandlerDefaults(nsIHandlerInfo* aHandlerInfo, + bool aOSHandlerExists) override; + + /** + * Given a string identifying an application, create an nsIFile representing + * it. This function should look in $PATH for the application. + * The base class implementation will first try to interpret platformAppPath + * as an absolute path, and if that fails it will look for a file next to the + * mozilla executable. Subclasses can override this method if they want a + * different behaviour. + * @param platformAppPath A platform specific path to an application that we + * got out of the rdf data source. This can be a mac + * file spec, a unix path or a windows path depending + * on the platform + * @param aFile [out] An nsIFile representation of that platform + * application path. + */ + virtual nsresult GetFileTokenForPath(const char16_t* platformAppPath, + nsIFile** aFile); + + NS_IMETHOD OSProtocolHandlerExists(const char* aScheme, bool* aExists) = 0; + + /** + * Given an extension, get a MIME type string from the builtin list of + * mime types. + * @return true if we successfully found a mimetype. + */ + virtual bool GetMIMETypeFromDefaultForExtension(const nsACString& aExtension, + nsACString& aMIMEType); + + /** + * Given an extension, get a MIME type string. If not overridden by + * the OS-specific nsOSHelperAppService, will call into GetMIMEInfoFromOS + * with an empty mimetype. + * @return true if we successfully found a mimetype. + */ + virtual bool GetMIMETypeFromOSForExtension(const nsACString& aExtension, + nsACString& aMIMEType); + + static already_AddRefed<nsExternalHelperAppService> GetSingleton(); + + // Internal method. Only called directly from tests. + static nsresult EscapeURI(nsIURI* aURI, nsIURI** aResult); + + /** + * Logging Module. Usage: set MOZ_LOG=HelperAppService:level, where level + * should be 2 for errors, 3 for debug messages from the cross- platform + * nsExternalHelperAppService, and 4 for os-specific debug messages. + */ + static mozilla::LazyLogModule sLog; + + protected: + virtual ~nsExternalHelperAppService(); + + /** + * Searches the "extra" array of MIMEInfo objects for an object + * with a specific type. If found, it will modify the passed-in + * MIMEInfo. Otherwise, it will return an error and the MIMEInfo + * will be untouched. + * @param aContentType The type to search for. + * @param aOverwriteDescription Whether to overwrite the description + * @param aMIMEInfo [inout] The mime info, if found + */ + nsresult FillMIMEInfoForMimeTypeFromExtras(const nsACString& aContentType, + bool aOverwriteDescription, + nsIMIMEInfo* aMIMEInfo); + /** + * Searches the "extra" array of MIMEInfo objects for an object + * with a specific extension. + * + * Does not change the MIME Type of the MIME Info. + * + * @see FillMIMEInfoForMimeTypeFromExtras + */ + nsresult FillMIMEInfoForExtensionFromExtras(const nsACString& aExtension, + nsIMIMEInfo* aMIMEInfo); + + /** + * Replace the primary extension of the mimeinfo object if it's in our + * list of forbidden extensions. This fixes up broken information + * provided to us by the OS. + */ + bool MaybeReplacePrimaryExtension(const nsACString& aPrimaryExtension, + nsIMIMEInfo* aMIMEInfo); + + /** + * Searches the "extra" array for a MIME type, and gets its extension. + * @param aExtension The extension to search for + * @param aMIMEType [out] The found MIME type. + * @return true if the extension was found, false otherwise. + */ + bool GetTypeFromExtras(const nsACString& aExtension, nsACString& aMIMEType); + + // friend, so that it can access the nspr log module. + friend class nsExternalAppHandler; + + /** + * Helper function for ExpungeTemporaryFiles and ExpungeTemporaryPrivateFiles + */ + static void ExpungeTemporaryFilesHelper(nsCOMArray<nsIFile>& fileList); + /** + * Helper function for DeleteTemporaryFileOnExit and + * DeleteTemporaryPrivateFileWhenPossible + */ + static nsresult DeleteTemporaryFileHelper(nsIFile* aTemporaryFile, + nsCOMArray<nsIFile>& aFileList); + /** + * Functions related to the tempory file cleanup service provided by + * nsExternalHelperAppService + */ + void ExpungeTemporaryFiles(); + /** + * Functions related to the tempory file cleanup service provided by + * nsExternalHelperAppService (for the temporary files added during + * the private browsing mode) + */ + void ExpungeTemporaryPrivateFiles(); + + bool GetFileNameFromChannel(nsIChannel* aChannel, nsAString& aFileName, + nsIURI** aURI); + + // Internal version of the method from nsIMIMEService. + already_AddRefed<nsIMIMEInfo> ValidateFileNameForSaving( + nsAString& aFileName, const nsACString& aMimeType, nsIURI* aURI, + nsIURI* aOriginalURI, uint32_t aFlags, bool aAllowURLExtension); + + // If aFileName is blank or just an extension, set aFileName to the + // default filename. + void CheckDefaultFileName(nsAString& aFileName, uint32_t aFlags); + + // Ensure that the filename is safe for the file system. This will remove or + // replace any invalid characters and trim extra whitespace as needed. If the + // filename is too long, it will be truncated but the existing period and + // extension, if any, will be preserved. + void SanitizeFileName(nsAString& aFileName, uint32_t aFlags); + + /** + * Helper routine that checks how we should modify an extension + * for this file. + */ + enum ModifyExtensionType { + // Replace an invalid extension with the preferred one. + ModifyExtension_Replace = 0, + // Append the preferred extension after any existing one. + ModifyExtension_Append = 1, + // Don't modify the extension. + ModifyExtension_Ignore = 2 + }; + ModifyExtensionType ShouldModifyExtension(nsIMIMEInfo* aMimeInfo, + bool aForceAppend, + const nsCString& aFileExt); + + /** + * Array for the files that should be deleted + */ + nsCOMArray<nsIFile> mTemporaryFilesList; + /** + * Array for the files that should be deleted (for the temporary files + * added during the private browsing mode) + */ + nsCOMArray<nsIFile> mTemporaryPrivateFilesList; + + private: + nsresult DoContentContentProcessHelper( + const nsACString& aMimeContentType, nsIChannel* aChannel, + mozilla::dom::BrowsingContext* aContentContext, bool aForceSave, + nsIInterfaceRequestor* aWindowContext, + nsIStreamListener** aStreamListener); +}; + +/** + * An external app handler is just a small little class that presents itself as + * a nsIStreamListener. It saves the incoming data into a temp file. The handler + * is bound to an application when it is created. When it receives an + * OnStopRequest it launches the application using the temp file it has + * stored the data into. We create a handler every time we have to process + * data using a helper app. + */ +class nsExternalAppHandler final : public nsIStreamListener, + public nsIHelperAppLauncher, + public nsIBackgroundFileSaverObserver, + public nsINamed { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSISTREAMLISTENER + NS_DECL_NSIREQUESTOBSERVER + NS_DECL_NSIHELPERAPPLAUNCHER + NS_DECL_NSICANCELABLE + NS_DECL_NSIBACKGROUNDFILESAVEROBSERVER + NS_DECL_NSINAMED + + NS_DECLARE_STATIC_IID_ACCESSOR(EXTERNAL_APP_HANDLER_IID) + + /** + * @param aMIMEInfo MIMEInfo object, representing the type of the + * content that should be handled + * @param aFileExtension The extension we need to append to our temp file, + * INCLUDING the ".". e.g. .mp3 + * @param aContentContext dom Window context, as passed to DoContent. + * @param aWindowContext Top level window context used in dialog parenting, + * as passed to DoContent. This parameter may be null, + * in which case dialogs will be parented to + * aContentContext. + * @param mExtProtSvc nsExternalHelperAppService on creation + * @param aSuggestedFileName The filename to use + * @param aReason A constant from nsIHelperAppLauncherDialog + * indicating why the request is handled by a helper app. + */ + nsExternalAppHandler(nsIMIMEInfo* aMIMEInfo, const nsAString& aFileExtension, + mozilla::dom::BrowsingContext* aBrowsingContext, + nsIInterfaceRequestor* aWindowContext, + nsExternalHelperAppService* aExtProtSvc, + const nsAString& aSuggestedFileName, uint32_t aReason, + bool aForceSave); + + /** + * Clean up after the request was diverted to the parent process. + */ + void DidDivertRequest(nsIRequest* request); + + /** + * Apply content conversions if needed. + */ + void MaybeApplyDecodingForExtension(nsIRequest* request); + + void SetShouldCloseWindow() { mShouldCloseWindow = true; } + + protected: + bool IsDownloadSpam(nsIChannel* aChannel); + + ~nsExternalAppHandler(); + + nsCOMPtr<nsIFile> mTempFile; + nsCOMPtr<nsIURI> mSourceUrl; + nsString mFileExtension; + nsString mTempLeafName; + + /** + * The MIME Info for this load. Will never be null. + */ + nsCOMPtr<nsIMIMEInfo> mMimeInfo; + + /** + * The BrowsingContext associated with this request to handle content. + */ + RefPtr<mozilla::dom::BrowsingContext> mBrowsingContext; + + /** + * If set, the parent window helper app dialogs and file pickers + * should use in parenting. If null, we use mContentContext. + */ + nsCOMPtr<nsIInterfaceRequestor> mWindowContext; + + /** + * Used to close the window on a timer, to avoid any exceptions that are + * thrown if we try to close the window before it's fully loaded. + */ + RefPtr<MaybeCloseWindowHelper> mMaybeCloseWindowHelper; + + /** + * The following field is set if we were processing an http channel that had + * a content disposition header which specified the SUGGESTED file name we + * should present to the user in the save to disk dialog. + */ + nsString mSuggestedFileName; + + /** + * If set, this handler should forcibly save the file to disk regardless of + * MIME info settings or anything else, without ever popping up the + * unknown content type handling dialog. + */ + bool mForceSave; + + /** + * If set, any internally handled type that has a disposition of + nsIChannel::DISPOSITION_ATTACHMENT will be saved to disk. + */ + bool mForceSaveInternallyHandled; + + /** + * The canceled flag is set if the user canceled the launching of this + * application before we finished saving the data to a temp file. + */ + bool mCanceled; + + /** + * True if a stop request has been issued. + */ + bool mStopRequestIssued; + + bool mIsFileChannel; + + /** + * True if the ExternalHelperAppChild told us that we should close the window + * if we handle the content as a download. + */ + bool mShouldCloseWindow; + + /** + * True if the file should be handled internally. + */ + bool mHandleInternally; + + /** + * True if any dialog (e.g. unknown content type or file picker) is shown — + * can stop downloads panel from opening, to avoid redundant interruptions. + */ + bool mDialogShowing; + + /** + * One of the REASON_ constants from nsIHelperAppLauncherDialog. Indicates the + * reason the dialog was shown (unknown content type, server requested it, + * etc). + */ + uint32_t mReason; + + /** + * Indicates if the nsContentSecurityUtils rate this download as + * acceptable, potentialy unwanted or illigal request. + * + */ + int32_t mDownloadClassification; + + /** + * Track the executable-ness of the temporary file. + */ + bool mTempFileIsExecutable; + + PRTime mTimeDownloadStarted; + int64_t mContentLength; + int64_t mProgress; /**< Number of bytes received (for sending progress + notifications). */ + + /** + * When we are told to save the temp file to disk (in a more permament + * location) before we are done writing the content to a temp file, then + * we need to remember the final destination until we are ready to use it. + */ + nsCOMPtr<nsIFile> mFinalFileDestination; + + uint32_t mBufferSize; + + /** + * This object handles saving the data received from the network to a + * temporary location first, and then move the file to its final location, + * doing all the input/output on a background thread. + */ + nsCOMPtr<nsIBackgroundFileSaver> mSaver; + + /** + * Stores the SHA-256 hash associated with the file that we downloaded. + */ + nsAutoCString mHash; + /** + * Stores the signature information of the downloaded file in an nsTArray of + * nsTArray of Array of bytes. If the file is unsigned this will be + * empty. + */ + nsTArray<nsTArray<nsTArray<uint8_t>>> mSignatureInfo; + /** + * Stores the redirect information associated with the channel. + */ + nsCOMPtr<nsIArray> mRedirects; + /** + * Get the dialog parent: the parent window that we can attach + * a dialog to when prompting the user for a download. + */ + already_AddRefed<nsIInterfaceRequestor> GetDialogParent(); + /** + * Creates the temporary file for the download and an output stream for it. + * Upon successful return, both mTempFile and mSaver will be valid. + */ + nsresult SetUpTempFile(nsIChannel* aChannel); + /** + * When we download a helper app, we are going to retarget all load + * notifications into our own docloader and load group instead of + * using the window which initiated the load....RetargetLoadNotifications + * contains that information... + */ + void RetargetLoadNotifications(nsIRequest* request); + /** + * Once the user tells us how they want to dispose of the content + * create an nsITransfer so they know what's going on. If this fails, the + * caller MUST call Cancel. + */ + nsresult CreateTransfer(); + + /** + * If we fail to create the necessary temporary file to initiate a transfer + * we will report the failure by creating a failed nsITransfer. + */ + nsresult CreateFailedTransfer(); + + /* + * The following two functions are part of the split of SaveToDisk + * to make it async, and works as following: + * + * SaveToDisk -------> RequestSaveDestination + * . + * . + * v + * ContinueSave <------- SaveDestinationAvailable + */ + + /** + * This is called by SaveToDisk to decide what's the final + * file destination chosen by the user or by auto-download settings. + */ + void RequestSaveDestination(const nsString& aDefaultFile, + const nsString& aDefaultFileExt); + + /** + * When SaveToDisk is called, it possibly delegates to RequestSaveDestination + * to decide the file destination. ContinueSave must then be called when + * the final destination is finally known. + * @param aFile The file that was chosen as the final destination. + * Must not be null. + */ + nsresult ContinueSave(nsIFile* aFile); + + /** + * Notify our nsITransfer object that we are done with the download. This is + * always called after the target file has been closed. + * + * @param aStatus + * NS_OK for success, or a failure code if the download failed. + * A partially downloaded file may still be available in this case. + */ + void NotifyTransfer(nsresult aStatus); + + /** + * Helper routine that searches a pref string for a given mime type + */ + bool GetNeverAskFlagFromPref(const char* prefName, const char* aContentType); + + /** + * Helper routine to ensure that mSuggestedFileName ends in the correct + * extension, in case the original extension contains invalid characters + * or if this download is for a mimetype where we enforce using a specific + * extension (image/, video/, and audio/ based mimetypes, and a few specific + * document types). + * + * It also ensure that mFileExtension only contains an extension + * when it is different from mSuggestedFileName's extension. + */ + void EnsureCorrectExtension(const nsString& aFileExt); + + typedef enum { kReadError, kWriteError, kLaunchError } ErrorType; + /** + * Utility function to send proper error notification to web progress listener + */ + void SendStatusChange(ErrorType type, nsresult aStatus, nsIRequest* aRequest, + const nsString& path); + + /** + * Set in HelperAppDlg.jsm. This is always null after the user has chosen an + * action. + */ + nsCOMPtr<nsIWebProgressListener2> mDialogProgressListener; + /** + * Set once the user has chosen an action. This is null after the download + * has been canceled or completes. + */ + nsCOMPtr<nsITransfer> mTransfer; + + nsCOMPtr<nsIHelperAppLauncherDialog> mDialog; + + /** + + * The request that's being loaded. Initialized in OnStartRequest. + * Nulled out in OnStopRequest or once we know what we're doing + * with the data, whichever happens later. + */ + nsCOMPtr<nsIRequest> mRequest; + + RefPtr<nsExternalHelperAppService> mExtProtSvc; +}; +NS_DEFINE_STATIC_IID_ACCESSOR(nsExternalAppHandler, EXTERNAL_APP_HANDLER_IID) + +#endif // nsExternalHelperAppService_h__ diff --git a/uriloader/exthandler/nsExternalProtocolHandler.cpp b/uriloader/exthandler/nsExternalProtocolHandler.cpp new file mode 100644 index 0000000000..4e93977b8f --- /dev/null +++ b/uriloader/exthandler/nsExternalProtocolHandler.cpp @@ -0,0 +1,550 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:set ts=2 sts=2 sw=2 et cin: + * + * 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/. */ + +#include "mozilla/dom/ContentChild.h" +#include "mozilla/BasePrincipal.h" +#include "nsIURI.h" +#include "nsExternalProtocolHandler.h" +#include "nsString.h" +#include "nsReadableUtils.h" +#include "nsCOMPtr.h" +#include "nsContentUtils.h" +#include "nsServiceManagerUtils.h" +#include "nsIInterfaceRequestor.h" +#include "nsIInterfaceRequestorUtils.h" +#include "nsIRedirectHistoryEntry.h" +#include "nsNetUtil.h" +#include "nsContentSecurityManager.h" +#include "nsExternalHelperAppService.h" + +// used to dispatch urls to default protocol handlers +#include "nsCExternalHandlerService.h" +#include "nsIExternalProtocolService.h" +#include "nsIChildChannel.h" +#include "nsIParentChannel.h" + +class nsILoadInfo; + +//////////////////////////////////////////////////////////////////////// +// a stub channel implemenation which will map calls to AsyncRead and +// OpenInputStream to calls in the OS for loading the url. +//////////////////////////////////////////////////////////////////////// + +class nsExtProtocolChannel : public nsIChannel, + public nsIChildChannel, + public nsIParentChannel { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSICHANNEL + NS_DECL_NSIREQUESTOBSERVER + NS_DECL_NSISTREAMLISTENER + NS_DECL_NSIREQUEST + NS_DECL_NSICHILDCHANNEL + NS_DECL_NSIPARENTCHANNEL + + nsExtProtocolChannel(nsIURI* aURI, nsILoadInfo* aLoadInfo); + + private: + virtual ~nsExtProtocolChannel(); + + nsresult OpenURL(); + void Finish(nsresult aResult); + + nsCOMPtr<nsIURI> mUrl; + nsCOMPtr<nsIURI> mOriginalURI; + nsresult mStatus; + nsLoadFlags mLoadFlags; + bool mWasOpened; + bool mCanceled; + // Set true (as a result of ConnectParent invoked from child process) + // when this channel is on the parent process and is being used as + // a redirect target channel. It turns AsyncOpen into a no-op since + // we do it on the child. + bool mConnectedParent; + + nsCOMPtr<nsIInterfaceRequestor> mCallbacks; + nsCOMPtr<nsILoadGroup> mLoadGroup; + nsCOMPtr<nsILoadInfo> mLoadInfo; + nsCOMPtr<nsIStreamListener> mListener; +}; + +NS_IMPL_ADDREF(nsExtProtocolChannel) +NS_IMPL_RELEASE(nsExtProtocolChannel) + +NS_INTERFACE_MAP_BEGIN(nsExtProtocolChannel) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIChannel) + NS_INTERFACE_MAP_ENTRY(nsIChannel) + NS_INTERFACE_MAP_ENTRY(nsIRequest) + NS_INTERFACE_MAP_ENTRY(nsIChildChannel) + NS_INTERFACE_MAP_ENTRY(nsIParentChannel) + NS_INTERFACE_MAP_ENTRY(nsIStreamListener) + NS_INTERFACE_MAP_ENTRY(nsIRequestObserver) +NS_INTERFACE_MAP_END + +nsExtProtocolChannel::nsExtProtocolChannel(nsIURI* aURI, nsILoadInfo* aLoadInfo) + : mUrl(aURI), + mOriginalURI(aURI), + mStatus(NS_OK), + mLoadFlags(nsIRequest::LOAD_NORMAL), + mWasOpened(false), + mCanceled(false), + mConnectedParent(false), + mLoadInfo(aLoadInfo) {} + +nsExtProtocolChannel::~nsExtProtocolChannel() {} + +NS_IMETHODIMP nsExtProtocolChannel::GetLoadGroup(nsILoadGroup** aLoadGroup) { + NS_IF_ADDREF(*aLoadGroup = mLoadGroup); + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetLoadGroup(nsILoadGroup* aLoadGroup) { + mLoadGroup = aLoadGroup; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetNotificationCallbacks( + nsIInterfaceRequestor** aCallbacks) { + NS_IF_ADDREF(*aCallbacks = mCallbacks); + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetNotificationCallbacks( + nsIInterfaceRequestor* aCallbacks) { + mCallbacks = aCallbacks; + return NS_OK; +} + +NS_IMETHODIMP +nsExtProtocolChannel::GetSecurityInfo( + nsITransportSecurityInfo** aSecurityInfo) { + *aSecurityInfo = nullptr; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetOriginalURI(nsIURI** aURI) { + NS_ADDREF(*aURI = mOriginalURI); + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetOriginalURI(nsIURI* aURI) { + NS_ENSURE_ARG_POINTER(aURI); + mOriginalURI = aURI; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetURI(nsIURI** aURI) { + *aURI = mUrl; + NS_IF_ADDREF(*aURI); + return NS_OK; +} + +nsresult nsExtProtocolChannel::OpenURL() { + nsresult rv = NS_ERROR_FAILURE; + nsCOMPtr<nsIExternalProtocolService> extProtService( + do_GetService(NS_EXTERNALPROTOCOLSERVICE_CONTRACTID)); + + if (extProtService) { +#ifdef DEBUG + nsAutoCString urlScheme; + mUrl->GetScheme(urlScheme); + bool haveHandler = false; + extProtService->ExternalProtocolHandlerExists(urlScheme.get(), + &haveHandler); + NS_ASSERTION(haveHandler, + "Why do we have a channel for this url if we don't support " + "the protocol?"); +#endif + + RefPtr<mozilla::dom::BrowsingContext> ctx; + rv = mLoadInfo->GetTargetBrowsingContext(getter_AddRefs(ctx)); + if (NS_FAILED(rv)) { + goto finish; + } + + RefPtr<nsIPrincipal> triggeringPrincipal = mLoadInfo->TriggeringPrincipal(); + RefPtr<nsIPrincipal> redirectPrincipal; + if (!mLoadInfo->RedirectChain().IsEmpty()) { + mLoadInfo->RedirectChain().LastElement()->GetPrincipal( + getter_AddRefs(redirectPrincipal)); + } + rv = extProtService->LoadURI(mUrl, triggeringPrincipal, redirectPrincipal, + ctx, mLoadInfo->GetLoadTriggeredFromExternal(), + mLoadInfo->GetHasValidUserGestureActivation()); + + if (NS_SUCCEEDED(rv) && mListener) { + mStatus = NS_ERROR_NO_CONTENT; + + RefPtr<nsExtProtocolChannel> self = this; + nsCOMPtr<nsIStreamListener> listener = mListener; + MessageLoop::current()->PostTask(NS_NewRunnableFunction( + "nsExtProtocolChannel::OpenURL", [self, listener]() { + listener->OnStartRequest(self); + listener->OnStopRequest(self, self->mStatus); + })); + } + } + +finish: + mCallbacks = nullptr; + mListener = nullptr; + return rv; +} + +NS_IMETHODIMP nsExtProtocolChannel::Open(nsIInputStream** aStream) { + nsCOMPtr<nsIStreamListener> listener; + nsresult rv = + nsContentSecurityManager::doContentSecurityCheck(this, listener); + NS_ENSURE_SUCCESS(rv, rv); + + return OpenURL(); +} + +NS_IMETHODIMP nsExtProtocolChannel::AsyncOpen(nsIStreamListener* aListener) { + nsCOMPtr<nsIStreamListener> listener = aListener; + nsresult rv = + nsContentSecurityManager::doContentSecurityCheck(this, listener); + if (NS_FAILED(rv)) { + mCallbacks = nullptr; + return rv; + } + + if (mConnectedParent) { + return NS_OK; + } + + MOZ_ASSERT( + mLoadInfo->GetSecurityMode() == 0 || + mLoadInfo->GetInitialSecurityCheckDone() || + (mLoadInfo->GetSecurityMode() == + nsILoadInfo::SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL && + mLoadInfo->GetLoadingPrincipal() && + mLoadInfo->GetLoadingPrincipal()->IsSystemPrincipal()), + "security flags in loadInfo but doContentSecurityCheck() not called"); + + NS_ENSURE_ARG_POINTER(listener); + NS_ENSURE_TRUE(!mWasOpened, NS_ERROR_ALREADY_OPENED); + + mWasOpened = true; + mListener = listener; + + return OpenURL(); +} + +NS_IMETHODIMP nsExtProtocolChannel::GetLoadFlags(nsLoadFlags* aLoadFlags) { + *aLoadFlags = mLoadFlags; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetLoadFlags(nsLoadFlags aLoadFlags) { + mLoadFlags = aLoadFlags; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetTRRMode(nsIRequest::TRRMode* aTRRMode) { + return GetTRRModeImpl(aTRRMode); +} + +NS_IMETHODIMP nsExtProtocolChannel::SetTRRMode(nsIRequest::TRRMode aTRRMode) { + return SetTRRModeImpl(aTRRMode); +} + +NS_IMETHODIMP nsExtProtocolChannel::GetIsDocument(bool* aIsDocument) { + return NS_GetIsDocumentChannel(this, aIsDocument); +} + +NS_IMETHODIMP nsExtProtocolChannel::GetContentType(nsACString& aContentType) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetContentType( + const nsACString& aContentType) { + return NS_ERROR_FAILURE; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetContentCharset( + nsACString& aContentCharset) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetContentCharset( + const nsACString& aContentCharset) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetContentDisposition( + uint32_t* aContentDisposition) { + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetContentDisposition( + uint32_t aContentDisposition) { + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetContentDispositionFilename( + nsAString& aContentDispositionFilename) { + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetContentDispositionFilename( + const nsAString& aContentDispositionFilename) { + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetContentDispositionHeader( + nsACString& aContentDispositionHeader) { + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetContentLength(int64_t* aContentLength) { + *aContentLength = -1; + return NS_OK; +} + +NS_IMETHODIMP +nsExtProtocolChannel::SetContentLength(int64_t aContentLength) { + MOZ_ASSERT_UNREACHABLE("SetContentLength"); + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetOwner(nsISupports** aPrincipal) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetOwner(nsISupports* aPrincipal) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetLoadInfo(nsILoadInfo** aLoadInfo) { + NS_IF_ADDREF(*aLoadInfo = mLoadInfo); + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetLoadInfo(nsILoadInfo* aLoadInfo) { + MOZ_RELEASE_ASSERT(aLoadInfo, "loadinfo can't be null"); + mLoadInfo = aLoadInfo; + return NS_OK; +} + +//////////////////////////////////////////////////////////////////////////////// +// From nsIRequest +//////////////////////////////////////////////////////////////////////////////// + +NS_IMETHODIMP nsExtProtocolChannel::GetName(nsACString& result) { + return mUrl->GetSpec(result); +} + +NS_IMETHODIMP nsExtProtocolChannel::IsPending(bool* result) { + *result = false; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetStatus(nsresult* status) { + *status = mStatus; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetCanceledReason( + const nsACString& aReason) { + return SetCanceledReasonImpl(aReason); +} + +NS_IMETHODIMP nsExtProtocolChannel::GetCanceledReason(nsACString& aReason) { + return GetCanceledReasonImpl(aReason); +} + +NS_IMETHODIMP nsExtProtocolChannel::CancelWithReason( + nsresult aStatus, const nsACString& aReason) { + return CancelWithReasonImpl(aStatus, aReason); +} + +NS_IMETHODIMP nsExtProtocolChannel::Cancel(nsresult status) { + if (NS_SUCCEEDED(mStatus)) { + mStatus = status; + } + mCanceled = true; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetCanceled(bool* aCanceled) { + *aCanceled = mCanceled; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::Suspend() { + MOZ_ASSERT_UNREACHABLE("Suspend"); + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::Resume() { + MOZ_ASSERT_UNREACHABLE("Resume"); + return NS_ERROR_NOT_IMPLEMENTED; +} + +/////////////////////////////////////////////////////////////////////// +// From nsIChildChannel +////////////////////////////////////////////////////////////////////// + +NS_IMETHODIMP nsExtProtocolChannel::ConnectParent(uint32_t registrarId) { + mozilla::dom::ContentChild::GetSingleton() + ->SendExtProtocolChannelConnectParent(registrarId); + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::CompleteRedirectSetup( + nsIStreamListener* listener) { + // For redirects to external protocols we AsyncOpen on the child + // (not the parent) because child channel has the right docshell + // (which is needed for the select dialog). + return AsyncOpen(listener); +} + +/////////////////////////////////////////////////////////////////////// +// From nsIParentChannel (derives from nsIStreamListener) +////////////////////////////////////////////////////////////////////// + +NS_IMETHODIMP nsExtProtocolChannel::SetParentListener( + mozilla::net::ParentChannelListener* aListener) { + // This is called as part of the connect parent operation from + // ContentParent::RecvExtProtocolChannelConnectParent. Setting + // this flag tells this channel to not proceed and makes AsyncOpen + // just no-op. Actual operation will happen from the child process + // via CompleteRedirectSetup call on the child channel. + mConnectedParent = true; + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetClassifierMatchedInfo( + const nsACString& aList, const nsACString& aProvider, + const nsACString& aFullHash) { + // nothing to do + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::SetClassifierMatchedTrackingInfo( + const nsACString& aLists, const nsACString& aFullHashes) { + // nothing to do + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::NotifyClassificationFlags( + uint32_t aClassificationFlags, bool aIsThirdParty) { + // nothing to do + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::Delete() { + // nothing to do + return NS_OK; +} + +NS_IMETHODIMP nsExtProtocolChannel::GetRemoteType(nsACString& aRemoteType) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::OnStartRequest(nsIRequest* aRequest) { + return NS_ERROR_UNEXPECTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::OnStopRequest(nsIRequest* aRequest, + nsresult aStatusCode) { + MOZ_ASSERT(NS_FAILED(aStatusCode)); + return NS_ERROR_UNEXPECTED; +} + +NS_IMETHODIMP nsExtProtocolChannel::OnDataAvailable( + nsIRequest* aRequest, nsIInputStream* aInputStream, uint64_t aOffset, + uint32_t aCount) { + // no data is expected + MOZ_CRASH("No data expected from external protocol channel"); + return NS_ERROR_UNEXPECTED; +} + +/////////////////////////////////////////////////////////////////////// +// the default protocol handler implementation +////////////////////////////////////////////////////////////////////// + +nsExternalProtocolHandler::nsExternalProtocolHandler() { + m_schemeName = "default"; +} + +nsExternalProtocolHandler::~nsExternalProtocolHandler() {} + +NS_IMPL_ADDREF(nsExternalProtocolHandler) +NS_IMPL_RELEASE(nsExternalProtocolHandler) + +NS_INTERFACE_MAP_BEGIN(nsExternalProtocolHandler) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIProtocolHandler) + NS_INTERFACE_MAP_ENTRY(nsIProtocolHandler) + NS_INTERFACE_MAP_ENTRY(nsIExternalProtocolHandler) + NS_INTERFACE_MAP_ENTRY(nsISupportsWeakReference) +NS_INTERFACE_MAP_END + +NS_IMETHODIMP nsExternalProtocolHandler::GetScheme(nsACString& aScheme) { + aScheme = m_schemeName; + return NS_OK; +} + +NS_IMETHODIMP +nsExternalProtocolHandler::AllowPort(int32_t port, const char* scheme, + bool* _retval) { + // don't override anything. + *_retval = false; + return NS_OK; +} +// returns TRUE if the OS can handle this protocol scheme and false otherwise. +bool nsExternalProtocolHandler::HaveExternalProtocolHandler(nsIURI* aURI) { + MOZ_ASSERT(aURI); + nsAutoCString scheme; + aURI->GetScheme(scheme); + + nsCOMPtr<nsIExternalProtocolService> extProtSvc( + do_GetService(NS_EXTERNALPROTOCOLSERVICE_CONTRACTID)); + if (!extProtSvc) { + return false; + } + + bool haveHandler = false; + extProtSvc->ExternalProtocolHandlerExists(scheme.get(), &haveHandler); + return haveHandler; +} + +NS_IMETHODIMP +nsExternalProtocolHandler::NewChannel(nsIURI* aURI, nsILoadInfo* aLoadInfo, + nsIChannel** aRetval) { + NS_ENSURE_TRUE(aURI, NS_ERROR_UNKNOWN_PROTOCOL); + NS_ENSURE_TRUE(aRetval, NS_ERROR_UNKNOWN_PROTOCOL); + + // Only try to return a channel if we have a protocol handler for the url. + // nsOSHelperAppService::LoadUriInternal relies on this to check trustedness + // for some platforms at least. (win uses ::ShellExecute and unix uses + // gnome_url_show.) + if (!HaveExternalProtocolHandler(aURI)) { + return NS_ERROR_UNKNOWN_PROTOCOL; + } + + nsCOMPtr<nsIChannel> channel = new nsExtProtocolChannel(aURI, aLoadInfo); + channel.forget(aRetval); + return NS_OK; +} + +/////////////////////////////////////////////////////////////////////// +// External protocol handler interface implementation +////////////////////////////////////////////////////////////////////// +NS_IMETHODIMP nsExternalProtocolHandler::ExternalAppExistsForScheme( + const nsACString& aScheme, bool* _retval) { + nsCOMPtr<nsIExternalProtocolService> extProtSvc( + do_GetService(NS_EXTERNALPROTOCOLSERVICE_CONTRACTID)); + if (extProtSvc) + return extProtSvc->ExternalProtocolHandlerExists( + PromiseFlatCString(aScheme).get(), _retval); + + // In case we don't have external protocol service. + *_retval = false; + return NS_OK; +} diff --git a/uriloader/exthandler/nsExternalProtocolHandler.h b/uriloader/exthandler/nsExternalProtocolHandler.h new file mode 100644 index 0000000000..373223bc39 --- /dev/null +++ b/uriloader/exthandler/nsExternalProtocolHandler.h @@ -0,0 +1,37 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#ifndef nsExternalProtocolHandler_h___ +#define nsExternalProtocolHandler_h___ + +#include "nsIExternalProtocolHandler.h" +#include "nsCOMPtr.h" +#include "nsString.h" +#include "nsWeakReference.h" +#include "mozilla/Attributes.h" + +class nsIURI; + +// protocol handlers need to support weak references if we want the netlib +// nsIOService to cache them. +class nsExternalProtocolHandler final : public nsIExternalProtocolHandler, + public nsSupportsWeakReference { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIPROTOCOLHANDLER + NS_DECL_NSIEXTERNALPROTOCOLHANDLER + + nsExternalProtocolHandler(); + + protected: + ~nsExternalProtocolHandler(); + + // helper function + bool HaveExternalProtocolHandler(nsIURI* aURI); + nsCString m_schemeName; +}; + +#endif // nsExternalProtocolHandler_h___ diff --git a/uriloader/exthandler/nsIContentDispatchChooser.idl b/uriloader/exthandler/nsIContentDispatchChooser.idl new file mode 100644 index 0000000000..00d61e575b --- /dev/null +++ b/uriloader/exthandler/nsIContentDispatchChooser.idl @@ -0,0 +1,40 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIHandlerInfo; +interface nsIPrincipal; +interface nsIURI; +webidl BrowsingContext; + +/** + * This is used to ask a user what they would like to do with a given piece of + * content. + */ +[scriptable, uuid(456ca3b2-02be-4f97-89a2-08c08d3ad88f)] +interface nsIContentDispatchChooser : nsISupports { + /** + * Opens the handler associated with the given resource. + * If the caller does not have permission or no handler is set, we ask the + * user to grant permission and pick a handler. + * + * @param aHander + * The interface describing the details of how this content should or + * can be handled. + * @param aURI + * The URI of the resource that we are asking about. + * @param aTriggeringPrincipal + * The principal making the request. + * @param aBrowsingContext + * The browsing context where the load should happen. + * @param aWasTriggeredExternally + * True if the load was tripped by an external app. + */ + void handleURI(in nsIHandlerInfo aHandler, + in nsIURI aURI, + in nsIPrincipal aTriggeringPrincipal, + in BrowsingContext aBrowsingContext, + [optional] in bool aWasTriggeredExternally); +}; diff --git a/uriloader/exthandler/nsIExternalHelperAppService.idl b/uriloader/exthandler/nsIExternalHelperAppService.idl new file mode 100644 index 0000000000..4a399acb72 --- /dev/null +++ b/uriloader/exthandler/nsIExternalHelperAppService.idl @@ -0,0 +1,191 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#include "nsICancelable.idl" + +interface nsIURI; +interface nsIChannel; +interface nsIStreamListener; +interface nsIFile; +interface nsIMIMEInfo; +interface nsIWebProgressListener2; +interface nsIInterfaceRequestor; +webidl BrowsingContext; + +/** + * The external helper app service is used for finding and launching + * platform specific external applications for a given mime content type. + */ +[scriptable, uuid(1E4F3AE1-B737-431F-A95D-31FA8DA70199)] +interface nsIExternalHelperAppService : nsISupports +{ + /** + * Binds an external helper application to a stream listener. The caller + * should pump data into the returned stream listener. When the OnStopRequest + * is issued, the stream listener implementation will launch the helper app + * with this data. + * @param aMimeContentType The content type of the incoming data + * @param aChannel The channel corresponding to the incoming data + * @param aContentContext Used in processing content document refresh + * headers after target content is downloaded. + * @param aForceSave True to always save this content to disk, regardless of + * nsIMIMEInfo and other such influences. + * @param aWindowContext Used in parenting helper app dialogs, usually + * points to the parent browser window. This parameter may be null, + * in which case dialogs will be parented to aContentContext. + * @return A nsIStreamListener which the caller should pump the data into. + */ + nsIStreamListener doContent (in ACString aMimeContentType, + in nsIChannel aChannel, + in nsIInterfaceRequestor aContentContext, + in boolean aForceSave, + [optional] in nsIInterfaceRequestor aWindowContext); + + /** + * Binds an external helper application to a stream listener. The caller + * should pump data into the returned stream listener. When the OnStopRequest + * is issued, the stream listener implementation will launch the helper app + * with this data. + * Replaces doContent for native code, and uses BrowsingContext. + * + * @param aMimeContentType The content type of the incoming data + * @param aChannel The channel corresponding to the incoming data + * @param aContentContext The BrowsingContext that the channel was initiated + * by. Used for closing the window if we opened one specifically for this download. + * @param aForceSave True to always save this content to disk, regardless of + * nsIMIMEInfo and other such influences. + * @param aWindowContext Used in parenting helper app dialogs, usually + * points to the parent browser window. This parameter may be null, + * in which case dialogs will be parented to aContentContext. + * @return A nsIStreamListener which the caller should pump the data into. + */ + nsIStreamListener createListener (in ACString aMimeContentType, + in nsIChannel aChannel, + in BrowsingContext aContentContext, + in boolean aForceSave, + [optional] in nsIInterfaceRequestor aWindowContext); + + /** + * Returns true if data from a URL with this extension combination + * is to be decoded from aEncodingType prior to saving or passing + * off to helper apps, false otherwise. + */ + boolean applyDecodingForExtension(in AUTF8String aExtension, + in ACString aEncodingType); + +}; + +/** + * This is a private interface shared between external app handlers and the platform specific + * external helper app service + */ +[scriptable, uuid(6613e2e7-feab-4e3a-bb1f-b03200d544ec)] +interface nsPIExternalAppLauncher : nsISupports +{ + /** + * mscott --> eventually I should move this into a new service so other + * consumers can add temporary files they want deleted on exit. + * @param aTemporaryFile A temporary file we should delete on exit. + */ + void deleteTemporaryFileOnExit(in nsIFile aTemporaryFile); + /** + * Delete a temporary file created inside private browsing mode when + * the private browsing mode has ended. + */ + void deleteTemporaryPrivateFileWhenPossible(in nsIFile aTemporaryFile); +}; + +/** + * A helper app launcher is a small object created to handle the launching + * of an external application. + * + * Note that cancelling the load via the nsICancelable interface will release + * the reference to the launcher dialog. + */ +[scriptable, uuid(acf2a516-7d7f-4771-8b22-6c4a559c088e)] +interface nsIHelperAppLauncher : nsICancelable +{ + /** + * The mime info object associated with the content type this helper app + * launcher is currently attempting to load + */ + readonly attribute nsIMIMEInfo MIMEInfo; + + /** + * The source uri + */ + readonly attribute nsIURI source; + + /** + * The suggested name for this file + */ + readonly attribute AString suggestedFileName; + + /** + * Saves the final destination of the file. + * NOTE: This will release the reference to the nsIHelperAppLauncherDialog. + */ + void promptForSaveDestination(); + + /** + * Tell the launcher that we will want to open the file. + * NOTE: This will release the reference to the nsIHelperAppLauncherDialog. + * @param aHandleInternally TRUE if we should handle opening this internally. + * @param aNewFileLocation a preferred location choosen through the File Picker. + * Null if going through the fast save without File Picker. + */ + void setDownloadToLaunch(in boolean aHandleInternally, in nsIFile aFile); + + + /** + * Use the MIMEInfo associated with us to open a file that is already local. + * Will no-op if `source` is not a local file. + */ + void launchLocalFile(); + + /** + * Callback invoked by nsIHelperAppLauncherDialog::promptForSaveToFileAsync + * after the user has chosen a file through the File Picker (or dismissed it). + * @param aFile The file that was chosen by the user (or null if dialog was dismissed). + * @param aDialogWasShown Optional boolean - false by default. Pass true if a + * dialog was opened in the process of reaching this file result. If true, we + * suppress the opening of the downloads panel to avoid redundancy. + */ + void saveDestinationAvailable(in nsIFile aFile, [optional] in boolean aDialogWasShown); + + /** + * The following methods are used by the progress dialog to get or set + * information on the current helper app launcher download. + * This reference will be released when the download is finished (after the + * listener receives the STATE_STOP notification). + */ + void setWebProgressListener(in nsIWebProgressListener2 aWebProgressListener); + + /** + * The file we are saving to + */ + readonly attribute nsIFile targetFile; + + /** + * The executable-ness of the target file + */ + readonly attribute boolean targetFileIsExecutable; + + /** + * Time when the download started + */ + readonly attribute PRTime timeDownloadStarted; + + /** + * The download content length, or -1 if the length is not available. + */ + readonly attribute int64_t contentLength; + + /** + * The browsingContext ID of the launcher's source + */ + readonly attribute uint64_t browsingContextId; +}; diff --git a/uriloader/exthandler/nsIExternalProtocolService.idl b/uriloader/exthandler/nsIExternalProtocolService.idl new file mode 100644 index 0000000000..7d714035e6 --- /dev/null +++ b/uriloader/exthandler/nsIExternalProtocolService.idl @@ -0,0 +1,155 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#include "nsISupports.idl" + +interface nsIURI; +interface nsIFile; +interface nsIPrincipal; +interface nsIInterfaceRequestor; +interface nsIHandlerInfo; + +webidl BrowsingContext; + +/** + * The external protocol service is used for finding and launching + * web handlers (a la registerProtocolHandler in the HTML5 draft) or + * platform-specific applications for handling particular protocols. + * + * You can ask the external protocol service if it has an external + * handler for a given protocol scheme. And you can ask it to load + * the url using the default handler. + */ +[scriptable, uuid(70f93b7a-3ec6-4bcb-b093-92d9984c9f83)] +interface nsIExternalProtocolService : nsISupports +{ + /** + * Check whether a handler for a specific protocol exists. Specifically, + * this looks to see whether there are any known possible application handlers + * in either the nsIHandlerService datastore or registered with the OS. + * + * @param aProtocolScheme The scheme from a url: http, ftp, mailto, etc. + * + * @return true if we have a handler and false otherwise. + * + * XXX shouldn't aProtocolScheme be an ACString like nsIURI::scheme? + */ + boolean externalProtocolHandlerExists(in string aProtocolScheme); + + /** + * Check whether a handler for a specific protocol is "exposed" as a visible + * feature of the current application. + * + * An exposed protocol handler is one that can be used in all contexts. A + * non-exposed protocol handler is one that can only be used internally by the + * application. For example, a non-exposed protocol would not be loaded by the + * application in response to a link click or a X-remote openURL command. + * Instead, it would be deferred to the system's external protocol handler. + * XXX shouldn't aProtocolScheme be an ACString like nsIURI::scheme? + */ + boolean isExposedProtocol(in string aProtocolScheme); + + /** + * Retrieve the handler for the given protocol. If neither the application + * nor the OS knows about a handler for the protocol, the object this method + * returns will represent a default handler for unknown content. + * + * @param aProtocolScheme the scheme from a URL: http, ftp, mailto, etc. + * + * Note: aProtocolScheme should not include a trailing colon, which is part + * of the URI syntax, not part of the scheme itself (i.e. pass "mailto" not + * "mailto:"). + * + * @return the handler, if any; otherwise a default handler + */ + nsIHandlerInfo getProtocolHandlerInfo(in ACString aProtocolScheme); + + /** + * Given a scheme, looks up the protocol info from the OS. This should be + * overridden by each OS's implementation. + * + * @param aScheme The protocol scheme we are looking for. + * @param aFound Was an OS default handler for this scheme found? + * @return An nsIHanderInfo for the protocol. + */ + nsIHandlerInfo getProtocolHandlerInfoFromOS(in ACString aProtocolScheme, + out boolean aFound); + + /** + * Set some sane defaults for a protocol handler object. + * + * @param aHandlerInfo nsIHandlerInfo object, as returned by + * getProtocolHandlerInfoFromOS + * @param aOSHandlerExists was the object above created for an extant + * OS default handler? This is generally the + * value of the aFound out param from + * getProtocolHandlerInfoFromOS. + */ + void setProtocolHandlerDefaults(in nsIHandlerInfo aHandlerInfo, + in boolean aOSHandlerExists); + + /** + * Used to load a URI via an external application. Might prompt the user for + * permission to load the external application. + * + * @param aURI + * The URI to load + * + * @param aTriggeringPrincipal + * The principal triggering this load. + * + * @param aRedirectPrincipal + * The last post-redirect principal triggering this load. + * Used for display and permission purposes. If null, we'll + * use the triggering principal. + * + * @param aBrowsingContext + * The context to parent the dialog against, and, if a web handler + * is chosen, it is loaded in this window as well. This parameter + * may be ultimately passed nsIURILoader.openURI in the case of a + * web handler, and aWindowContext is null or not present, web + * handlers will fail. We need to do better than that; bug 394483 + * filed in order to track. + * + * @param aWasTriggeredExternally + * If true, indicates the load was initiated by an external app. + * + * @param aHasValidUserGestureActivation + * Whether the document that triggered the load had user activation. + * Used for sandbox checks. + * + * @note Embedders that do not expose the http protocol should not currently + * use web-based protocol handlers, as handoff won't work correctly + * (bug 394479). + */ + void loadURI(in nsIURI aURI, + [optional] in nsIPrincipal aTriggeringPrincipal, + [optional] in nsIPrincipal aRedirectPrincipal, + [optional] in BrowsingContext aBrowsingContext, + [optional] in bool aWasTriggeredExternally, + [optional] in bool aHasValidUserGestureActivation); + + /** + * Gets a human-readable description for the application responsible for + * handling a specific protocol. + * + * @param aScheme The scheme to look up. For example, "mms". + * + * @throw NS_ERROR_NOT_IMPLEMENTED + * If getting descriptions for protocol helpers is not supported + * @throw NS_ERROR_NOT_AVAILABLE + * If no protocol helper exists for this scheme, or if it is not + * possible to get a description for it. + */ + AString getApplicationDescription(in AUTF8String aScheme); + + /** + * Check if this app is registered as the OS default for a given scheme. + * + * @param aScheme The scheme to look up. For example, "mms". + */ + bool isCurrentAppOSDefaultForProtocol(in AUTF8String aScheme); +}; diff --git a/uriloader/exthandler/nsIHandlerService.idl b/uriloader/exthandler/nsIHandlerService.idl new file mode 100644 index 0000000000..890c8ab59d --- /dev/null +++ b/uriloader/exthandler/nsIHandlerService.idl @@ -0,0 +1,179 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIHandlerInfo; +interface nsISimpleEnumerator; +interface nsIMIMEInfo; + +[scriptable, uuid(53f0ad17-ec62-46a1-adbc-efccc06babcd)] +interface nsIHandlerService : nsISupports +{ + /** + * Asynchronously performs any IO that the nsIHandlerService needs to do + * before it can be of use. + */ + void asyncInit(); + + /** + * Retrieve a list of all handlers in the datastore. This list is not + * guaranteed to be in any particular order, and callers should not assume + * it will remain in the same order in the future. + * + * @returns a list of all handlers in the datastore + */ + nsISimpleEnumerator enumerate(); + + /** + * Fill a handler info object with information from the datastore. + * + * Note: because of the way the external helper app service currently mixes + * OS and user handler info in the same handler info object, this method + * takes an existing handler info object (probably retrieved from the OS) + * and "fills it in" with information from the datastore, overriding any + * existing properties on the object with properties from the datastore. + * + * Ultimately, however, we're going to separate OS and user handler info + * into separate objects, at which point this method should be renamed to + * something like "get" or "retrieve", take a class and type (or perhaps + * a type whose class can be determined by querying the type, for example + * an nsIContentType which is also an nsIMIMEType or an nsIProtocolScheme), + * and return a handler info object representing only the user info. + * + * Note: if you specify an override type, then the service will fill in + * the handler info object with information about that type instead of + * the type specified by the object's nsIHandlerInfo::type attribute. + * + * This is useful when you are trying to retrieve information about a MIME + * type that doesn't exist in the datastore, but you have a file extension + * for that type, and nsIHandlerService::getTypeFromExtension returns another + * MIME type that does exist in the datastore and can handle that extension. + * + * For example, the user clicks on a link, and the content has a MIME type + * that isn't in the datastore, but the link has a file extension, and that + * extension is associated with another MIME type in the datastore (perhaps + * an unofficial MIME type preceded an official one, like with image/x-png + * and image/png). + * + * In that situation, you can call this method to fill in the handler info + * object with information about that other type by passing the other type + * as the aOverrideType parameter. + * + * @param aHandlerInfo the handler info object + * @param aOverrideType a type to use instead of aHandlerInfo::type + * + * Note: if there is no information in the datastore about this type, + * this method throws NS_ERROR_NOT_AVAILABLE. Callers are encouraged to + * check exists() before calling fillHandlerInfo(), to prevent spamming the + * console with XPCOM exception errors. + */ + void fillHandlerInfo(in nsIHandlerInfo aHandlerInfo, + in ACString aOverrideType); + + /** + * Save the preferred action, preferred handler, possible handlers, and + * always ask properties of the given handler info object to the datastore. + * Updates an existing record or creates a new one if necessary. + * + * Note: if preferred action is undefined or invalid, then we assume + * the default value nsIHandlerInfo::useHelperApp. + * + * @param aHandlerInfo the handler info object + */ + void store(in nsIHandlerInfo aHandlerInfo); + + /** + * Whether or not a record for the given handler info object exists + * in the datastore. If the datastore is corrupt (or some other error + * is caught in the implementation), false will be returned. + * + * @param aHandlerInfo a handler info object + * + * @returns whether or not a record exists + */ + boolean exists(in nsIHandlerInfo aHandlerInfo); + + /** + * Remove the given handler info object from the datastore. Deletes all + * records associated with the object, including the preferred handler, info, + * and type records plus the entry in the list of types, if they exist. + * Otherwise, it does nothing and does not return an error. + * + * @param aHandlerInfo the handler info object + */ + void remove(in nsIHandlerInfo aHandlerInfo); + + /** + * Get the MIME type mapped to the given file extension in the datastore. + * + * XXX If we ever support extension -> protocol scheme mappings, then this + * method should work for those as well. + * + * Note: in general, you should use nsIMIMEService::getTypeFromExtension + * to get a MIME type from a file extension, as that method checks a variety + * of other sources besides just the datastore. Use this only when you want + * to specifically get only the mapping available in the datastore. + * + * @param aFileExtension the file extension + * + * @returns the MIME type, if any; otherwise returns an empty string (""). + */ + ACString getTypeFromExtension(in ACString aFileExtension); + + /** + * Whether or not there is a handler known to the OS for the + * specified protocol type. + * + * @param aProtocolScheme scheme to check for support + * + * @returns whether or not a handler exists + */ + boolean existsForProtocolOS(in ACString aProtocolScheme); + + /** + * Whether or not there is a handler in the datastore or OS for + * the specified protocol type. If there is no handler in the datastore, + * falls back to a check for an OS handler. + * + * @param aProtocolScheme scheme to check for support + * + * @returns whether or not a handler exists + */ + boolean existsForProtocol(in ACString aProtocolScheme); + + /* + * This method only exists for nsOSHelperAppServiceChild using + * the ContentHandlerService implementation. + * + * Returns an nsIMIMEInfo for the provided MIME type and extension + * obtained from an OS lookup. If no handler is found for the type and + * extension, returns a generic nsIMIMEInfo object. The MIME type and + * extension can be the empty string. When the type and extension don't + * map to the same handler, the semantics/resolution are platform + * specific. See the platform implementations for details. + * + * @param aType The MIME type to get handler information for. + * @param aFileExtension The filename extension to use either alone + * or with the MIME type to get handler information + * for. UTF-8 encoded. + * @param [out] aFound Out param indicating whether a MIMEInfo could + * be found for the provided type and/or extension. + * Set to false when neither extension nor the MIME + * type are mapped to a handler. + * @return A nsIMIMEInfo object. This function must return + * a MIMEInfo object if it can allocate one. The + * only justifiable reason for not returning one is + * an out-of-memory error. + */ + [noscript] nsIMIMEInfo getMIMEInfoFromOS(in ACString aType, + in ACString aFileExtension, + out boolean aFound); + + /* + * Get a description for the application responsible for handling + * the provided protocol. + */ + AString getApplicationDescription(in ACString aProtocolScheme); +}; diff --git a/uriloader/exthandler/nsIHelperAppLauncherDialog.idl b/uriloader/exthandler/nsIHelperAppLauncherDialog.idl new file mode 100644 index 0000000000..9a7f76b7da --- /dev/null +++ b/uriloader/exthandler/nsIHelperAppLauncherDialog.idl @@ -0,0 +1,90 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIHelperAppLauncher; +interface nsIFile; +interface nsIInterfaceRequestor; + +/** + * This interface is used to display a confirmation dialog before + * launching a "helper app" to handle content not handled by + * Mozilla. + * + * Usage: Clients (of which there is one: the nsIExternalHelperAppService + * implementation in mozilla/uriloader/exthandler) create an instance of + * this interface (using the contract ID) and then call the show() method. + * + * The dialog is shown non-modally. The implementation of the dialog + * will access methods of the nsIHelperAppLauncher passed in to show() + * in order to cause a "save to disk" or "open using" action. + */ +[scriptable, uuid(bfc739f3-8d75-4034-a6f8-1039a5996bad)] +interface nsIHelperAppLauncherDialog : nsISupports { + /** + * This request is passed to the helper app dialog because Gecko can not + * handle content of this type. + */ + const unsigned long REASON_CANTHANDLE = 0; + + /** + * The server requested external handling. + */ + const unsigned long REASON_SERVERREQUEST = 1; + + /** + * Gecko detected that the type sent by the server (e.g. text/plain) does + * not match the actual type. + */ + const unsigned long REASON_TYPESNIFFED = 2; + + /** + * Show confirmation dialog for launching application (or "save to + * disk") for content specified by aLauncher. + * + * @param aLauncher + * A nsIHelperAppLauncher to be invoked when a file is selected. + * @param aWindowContext + * Window associated with action. + * @param aReason + * One of the constants from above. It indicates why the dialog is + * shown. Implementors should treat unknown reasons like + * REASON_CANTHANDLE. + */ + void show(in nsIHelperAppLauncher aLauncher, + in nsIInterfaceRequestor aWindowContext, + in unsigned long aReason); + + /** + * Async invoke a save-to-file dialog instead of the full fledged helper app + * dialog. When the file is chosen (or the dialog is closed), the callback + * in aLauncher (aLauncher.saveDestinationAvailable) is called with the + * selected file. + * + * @param aLauncher + * A nsIHelperAppLauncher to be invoked when a file is selected. + * @param aWindowContext + * Window associated with action. + * @param aDefaultFileName + * Default file name to provide (can be null) + * @param aSuggestedFileExtension + * Sugested file extension + * @param aForcePrompt + * Set to true to force prompting the user for thet file + * name/location, otherwise perferences may control if the user is + * prompted. + */ + void promptForSaveToFileAsync(in nsIHelperAppLauncher aLauncher, + in nsIInterfaceRequestor aWindowContext, + in wstring aDefaultFileName, + in wstring aSuggestedFileExtension, + in boolean aForcePrompt); +}; + + +%{C++ +#define NS_HELPERAPPLAUNCHERDLG_CONTRACTID "@mozilla.org/helperapplauncherdialog;1" +%} diff --git a/uriloader/exthandler/nsISharingHandlerApp.idl b/uriloader/exthandler/nsISharingHandlerApp.idl new file mode 100644 index 0000000000..5f497b6531 --- /dev/null +++ b/uriloader/exthandler/nsISharingHandlerApp.idl @@ -0,0 +1,11 @@ +/* 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/. */ + +#include "nsIMIMEInfo.idl" + +[scriptable, uuid(7111f769-53ec-41fd-b314-613661d5b6ba)] +interface nsISharingHandlerApp : nsIHandlerApp +{ + void share(in AString data, [optional] in AString title); +}; diff --git a/uriloader/exthandler/nsLocalHandlerApp.cpp b/uriloader/exthandler/nsLocalHandlerApp.cpp new file mode 100644 index 0000000000..6212d2aeb4 --- /dev/null +++ b/uriloader/exthandler/nsLocalHandlerApp.cpp @@ -0,0 +1,158 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#include "nsLocalHandlerApp.h" +#include "nsIURI.h" +#include "nsIProcess.h" +#include "nsComponentManagerUtils.h" + +// XXX why does nsMIMEInfoImpl have a threadsafe nsISupports? do we need one +// here too? +NS_IMPL_ISUPPORTS(nsLocalHandlerApp, nsILocalHandlerApp, nsIHandlerApp) + +//////////////////////////////////////////////////////////////////////////////// +//// nsIHandlerApp + +NS_IMETHODIMP nsLocalHandlerApp::GetName(nsAString& aName) { + if (mName.IsEmpty() && mExecutable) { + // Don't want to cache this, just in case someone resets the app + // without changing the description.... + mExecutable->GetLeafName(aName); + } else { + aName.Assign(mName); + } + + return NS_OK; +} + +NS_IMETHODIMP nsLocalHandlerApp::SetName(const nsAString& aName) { + mName.Assign(aName); + + return NS_OK; +} + +NS_IMETHODIMP +nsLocalHandlerApp::SetDetailedDescription(const nsAString& aDescription) { + mDetailedDescription.Assign(aDescription); + + return NS_OK; +} + +NS_IMETHODIMP +nsLocalHandlerApp::GetDetailedDescription(nsAString& aDescription) { + aDescription.Assign(mDetailedDescription); + + return NS_OK; +} + +NS_IMETHODIMP +nsLocalHandlerApp::Equals(nsIHandlerApp* aHandlerApp, bool* _retval) { + NS_ENSURE_ARG_POINTER(aHandlerApp); + + *_retval = false; + + // If the handler app isn't a local handler app, then it's not the same app. + nsCOMPtr<nsILocalHandlerApp> localHandlerApp = do_QueryInterface(aHandlerApp); + if (!localHandlerApp) return NS_OK; + + // If either handler app doesn't have an executable, then they aren't + // the same app. + nsCOMPtr<nsIFile> executable; + nsresult rv = localHandlerApp->GetExecutable(getter_AddRefs(executable)); + if (NS_FAILED(rv)) return rv; + + // Equality for two empty nsIHandlerApp + if (!executable && !mExecutable) { + *_retval = true; + return NS_OK; + } + + // At least one is set so they are not equal + if (!mExecutable || !executable) return NS_OK; + + // Check the command line parameter list lengths + uint32_t len; + localHandlerApp->GetParameterCount(&len); + if (mParameters.Length() != len) return NS_OK; + + // Check the command line params lists + for (uint32_t idx = 0; idx < mParameters.Length(); idx++) { + nsAutoString param; + if (NS_FAILED(localHandlerApp->GetParameter(idx, param)) || + !param.Equals(mParameters[idx])) + return NS_OK; + } + + return executable->Equals(mExecutable, _retval); +} + +NS_IMETHODIMP +nsLocalHandlerApp::LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext* aBrowsingContext) { + // pass the entire URI to the handler. + nsAutoCString spec; + aURI->GetAsciiSpec(spec); + return LaunchWithIProcess(spec); +} + +nsresult nsLocalHandlerApp::LaunchWithIProcess(const nsCString& aArg) { + nsresult rv; + nsCOMPtr<nsIProcess> process = do_CreateInstance(NS_PROCESS_CONTRACTID, &rv); + if (NS_FAILED(rv)) return rv; + + if (NS_FAILED(rv = process->Init(mExecutable))) return rv; + + const char* string = aArg.get(); + + return process->Run(false, &string, 1); +} + +//////////////////////////////////////////////////////////////////////////////// +//// nsILocalHandlerApp + +NS_IMETHODIMP +nsLocalHandlerApp::GetExecutable(nsIFile** aExecutable) { + NS_IF_ADDREF(*aExecutable = mExecutable); + return NS_OK; +} + +NS_IMETHODIMP +nsLocalHandlerApp::SetExecutable(nsIFile* aExecutable) { + mExecutable = aExecutable; + return NS_OK; +} + +NS_IMETHODIMP +nsLocalHandlerApp::GetParameterCount(uint32_t* aParameterCount) { + *aParameterCount = mParameters.Length(); + return NS_OK; +} + +NS_IMETHODIMP +nsLocalHandlerApp::ClearParameters() { + mParameters.Clear(); + return NS_OK; +} + +NS_IMETHODIMP +nsLocalHandlerApp::AppendParameter(const nsAString& aParam) { + mParameters.AppendElement(aParam); + return NS_OK; +} + +NS_IMETHODIMP +nsLocalHandlerApp::GetParameter(uint32_t parameterIndex, nsAString& _retval) { + if (mParameters.Length() <= parameterIndex) return NS_ERROR_INVALID_ARG; + + _retval.Assign(mParameters[parameterIndex]); + return NS_OK; +} + +NS_IMETHODIMP +nsLocalHandlerApp::ParameterExists(const nsAString& aParam, bool* _retval) { + *_retval = mParameters.Contains(aParam); + return NS_OK; +} diff --git a/uriloader/exthandler/nsLocalHandlerApp.h b/uriloader/exthandler/nsLocalHandlerApp.h new file mode 100644 index 0000000000..3ea8e3e4fc --- /dev/null +++ b/uriloader/exthandler/nsLocalHandlerApp.h @@ -0,0 +1,59 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#ifndef __nsLocalHandlerAppImpl_h__ +#define __nsLocalHandlerAppImpl_h__ + +#include "nsString.h" +#include "nsIMIMEInfo.h" +#include "nsIFile.h" +#include "nsTArray.h" + +class nsLocalHandlerApp : public nsILocalHandlerApp { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIHANDLERAPP + NS_DECL_NSILOCALHANDLERAPP + + nsLocalHandlerApp() {} + + nsLocalHandlerApp(const char16_t* aName, nsIFile* aExecutable) + : mName(aName), mExecutable(aExecutable) {} + + nsLocalHandlerApp(const nsAString& aName, nsIFile* aExecutable) + : mName(aName), mExecutable(aExecutable) {} + + protected: + virtual ~nsLocalHandlerApp() {} + + nsString mName; + nsString mDetailedDescription; + nsTArray<nsString> mParameters; + nsCOMPtr<nsIFile> mExecutable; + + /** + * Launches this application with a single argument (typically either + * a file path or a URI spec). This is meant as a helper method for + * implementations of (e.g.) LaunchWithURI. + * + * @param aApp The application to launch (may not be null) + * @param aArg The argument to pass on the command line + */ + nsresult LaunchWithIProcess(const nsCString& aArg); +}; + +// any platforms that need a platform-specific class instead of just +// using nsLocalHandlerApp need to add an include and a typedef here. +#ifdef XP_MACOSX +# ifndef NSLOCALHANDLERAPPMAC_H_ +# include "mac/nsLocalHandlerAppMac.h" +typedef nsLocalHandlerAppMac PlatformLocalHandlerApp_t; +# endif +#else +typedef nsLocalHandlerApp PlatformLocalHandlerApp_t; +#endif + +#endif // __nsLocalHandlerAppImpl_h__ diff --git a/uriloader/exthandler/nsMIMEInfoChild.h b/uriloader/exthandler/nsMIMEInfoChild.h new file mode 100644 index 0000000000..707f19aa67 --- /dev/null +++ b/uriloader/exthandler/nsMIMEInfoChild.h @@ -0,0 +1,54 @@ +/* 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/. */ + +#ifndef nsMIMEInfoChild_h +#define nsMIMEInfoChild_h + +#include "nsMIMEInfoImpl.h" + +/* + * A platform-generic nsMIMEInfo implementation to be used in child process + * generic code that needs a MIMEInfo with limited functionality. + */ +class nsChildProcessMIMEInfo : public nsMIMEInfoImpl { + public: + explicit nsChildProcessMIMEInfo(const char* aMIMEType = "") + : nsMIMEInfoImpl(aMIMEType) {} + + explicit nsChildProcessMIMEInfo(const nsACString& aMIMEType) + : nsMIMEInfoImpl(aMIMEType) {} + + nsChildProcessMIMEInfo(const nsACString& aType, HandlerClass aClass) + : nsMIMEInfoImpl(aType, aClass) {} + + NS_IMETHOD LaunchWithFile(nsIFile* aFile) override { + return NS_ERROR_NOT_IMPLEMENTED; + }; + + NS_IMETHOD IsCurrentAppOSDefault(bool* _retval) override { + return NS_ERROR_NOT_IMPLEMENTED; + }; + + protected: + [[nodiscard]] virtual nsresult LoadUriInternal(nsIURI* aURI) override { + return NS_ERROR_NOT_IMPLEMENTED; + }; + +#ifdef DEBUG + [[nodiscard]] virtual nsresult LaunchDefaultWithFile( + nsIFile* aFile) override { + return NS_ERROR_UNEXPECTED; + } +#endif + [[nodiscard]] static nsresult OpenApplicationWithURI(nsIFile* aApplication, + const nsCString& aURI) { + return NS_ERROR_NOT_IMPLEMENTED; + } + + NS_IMETHOD GetDefaultDescription(nsAString& aDefaultDescription) override { + return NS_ERROR_NOT_IMPLEMENTED; + }; +}; + +#endif diff --git a/uriloader/exthandler/nsMIMEInfoImpl.cpp b/uriloader/exthandler/nsMIMEInfoImpl.cpp new file mode 100644 index 0000000000..3616ab7fcf --- /dev/null +++ b/uriloader/exthandler/nsMIMEInfoImpl.cpp @@ -0,0 +1,526 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +#include "nsMIMEInfoImpl.h" +#include "nsString.h" +#include "nsReadableUtils.h" +#include "nsStringEnumerator.h" +#include "nsIFile.h" +#include "nsIFileURL.h" +#include "nsEscape.h" +#include "nsComponentManagerUtils.h" +#include "nsCURILoader.h" +#include "nsCExternalHandlerService.h" +#include "nsIExternalProtocolService.h" +#include "nsIObserverService.h" +#include "nsISupportsPrimitives.h" +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Services.h" +#include "mozilla/StaticPtr.h" +#include "mozilla/StaticPrefs_browser.h" +#include "xpcpublic.h" + +static bool sInitializedOurData = false; +mozilla::StaticRefPtr<nsIFile> sOurAppFile; + +/* static */ +already_AddRefed<nsIFile> nsMIMEInfoBase::GetCanonicalExecutable( + nsIFile* aFile) { + nsCOMPtr<nsIFile> binary = aFile; +#ifdef XP_MACOSX + nsAutoString path; + if (binary) { + binary->GetPath(path); + } + if (!StringEndsWith(path, u".app"_ns) && path.RFind(u".app/"_ns) == -1) { + // This shouldn't ever happen with Firefox's own binary, tracked in + // sOurAppFile, but might happen when called with other files. + return binary.forget(); + } + nsAutoString leafName; + if (binary) { + binary->GetLeafName(leafName); + } + while (binary && !StringEndsWith(leafName, u".app"_ns)) { + nsCOMPtr<nsIFile> parent; + binary->GetParent(getter_AddRefs(parent)); + binary = std::move(parent); + if (binary) { + binary->GetLeafName(leafName); + } + } +#endif + return binary.forget(); +} + +static void EnsureAppDetailsAvailable() { + if (sInitializedOurData) { + return; + } + sInitializedOurData = true; + nsCOMPtr<nsIFile> binary; + XRE_GetBinaryPath(getter_AddRefs(binary)); + sOurAppFile = nsMIMEInfoBase::GetCanonicalExecutable(binary); + ClearOnShutdown(&sOurAppFile); +} + +// nsISupports methods +NS_IMPL_ADDREF(nsMIMEInfoBase) +NS_IMPL_RELEASE(nsMIMEInfoBase) + +NS_INTERFACE_MAP_BEGIN(nsMIMEInfoBase) + NS_INTERFACE_MAP_ENTRY(nsIHandlerInfo) + // This is only an nsIMIMEInfo if it's a MIME handler. + NS_INTERFACE_MAP_ENTRY_CONDITIONAL(nsIMIMEInfo, mClass == eMIMEInfo) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIHandlerInfo) +NS_INTERFACE_MAP_END + +// nsMIMEInfoImpl methods + +// Constructors for a MIME handler. +nsMIMEInfoBase::nsMIMEInfoBase(const char* aMIMEType) + : mSchemeOrType(aMIMEType), + mClass(eMIMEInfo), + mAlwaysAskBeforeHandling( + mozilla::StaticPrefs:: + browser_download_always_ask_before_handling_new_types()) {} + +nsMIMEInfoBase::nsMIMEInfoBase(const nsACString& aMIMEType) + : mSchemeOrType(aMIMEType), + mClass(eMIMEInfo), + mAlwaysAskBeforeHandling( + mozilla::StaticPrefs:: + browser_download_always_ask_before_handling_new_types()) {} + +// Constructor for a handler that lets the caller specify whether this is a +// MIME handler or a protocol handler. In the long run, these will be distinct +// classes (f.e. nsMIMEInfo and nsProtocolInfo), but for now we reuse this class +// for both and distinguish between the two kinds of handlers via the aClass +// argument to this method, which can be either eMIMEInfo or eProtocolInfo. +nsMIMEInfoBase::nsMIMEInfoBase(const nsACString& aType, HandlerClass aClass) + : mSchemeOrType(aType), + mClass(aClass), + mAlwaysAskBeforeHandling( + mozilla::StaticPrefs:: + browser_download_always_ask_before_handling_new_types() || + aClass != eMIMEInfo) {} + +nsMIMEInfoBase::~nsMIMEInfoBase() {} + +NS_IMETHODIMP +nsMIMEInfoBase::GetFileExtensions(nsIUTF8StringEnumerator** aResult) { + return NS_NewUTF8StringEnumerator(aResult, &mExtensions, this); +} + +NS_IMETHODIMP +nsMIMEInfoBase::ExtensionExists(const nsACString& aExtension, bool* _retval) { + MOZ_ASSERT(!aExtension.IsEmpty(), "no extension"); + *_retval = mExtensions.Contains(aExtension, + nsCaseInsensitiveCStringArrayComparator()); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetPrimaryExtension(nsACString& _retval) { + if (!mExtensions.Length()) { + _retval.Truncate(); + return NS_ERROR_NOT_INITIALIZED; + } + _retval = mExtensions[0]; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::SetPrimaryExtension(const nsACString& aExtension) { + if (MOZ_UNLIKELY(aExtension.IsEmpty())) { + MOZ_ASSERT(false, "No extension"); + return NS_ERROR_INVALID_ARG; + } + int32_t i = mExtensions.IndexOf(aExtension, 0, + nsCaseInsensitiveCStringArrayComparator()); + if (i != -1) { + mExtensions.RemoveElementAt(i); + } + mExtensions.InsertElementAt(0, aExtension); + mIsDefaultAppInfoFresh = false; + return NS_OK; +} + +void nsMIMEInfoBase::AddUniqueExtension(const nsACString& aExtension) { + if (mExtensions.IsEmpty()) { + mIsDefaultAppInfoFresh = false; + } + if (!aExtension.IsEmpty() && + !mExtensions.Contains(aExtension, + nsCaseInsensitiveCStringArrayComparator())) { + mExtensions.AppendElement(aExtension); + } +} + +NS_IMETHODIMP +nsMIMEInfoBase::AppendExtension(const nsACString& aExtension) { + MOZ_ASSERT(!aExtension.IsEmpty(), "No extension"); + AddUniqueExtension(aExtension); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetType(nsACString& aType) { + if (mSchemeOrType.IsEmpty()) return NS_ERROR_NOT_INITIALIZED; + + aType = mSchemeOrType; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetMIMEType(nsACString& aMIMEType) { + if (mSchemeOrType.IsEmpty()) return NS_ERROR_NOT_INITIALIZED; + + aMIMEType = mSchemeOrType; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetDescription(nsAString& aDescription) { + aDescription = mDescription; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::SetDescription(const nsAString& aDescription) { + mDescription = aDescription; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::Equals(nsIMIMEInfo* aMIMEInfo, bool* _retval) { + if (!aMIMEInfo) return NS_ERROR_NULL_POINTER; + + nsAutoCString type; + nsresult rv = aMIMEInfo->GetMIMEType(type); + if (NS_FAILED(rv)) return rv; + + *_retval = mSchemeOrType.Equals(type); + + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::SetFileExtensions(const nsACString& aExtensions) { + mExtensions.Clear(); + nsACString::const_iterator start, end; + aExtensions.BeginReading(start); + aExtensions.EndReading(end); + while (start != end) { + nsACString::const_iterator cursor = start; + mozilla::Unused << FindCharInReadable(',', cursor, end); + AddUniqueExtension(Substring(start, cursor)); + // If a comma was found, skip it for the next search. + start = cursor != end ? ++cursor : cursor; + } + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetDefaultDescription(nsAString& aDefaultDescription) { + aDefaultDescription = mDefaultAppDescription; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetPreferredApplicationHandler( + nsIHandlerApp** aPreferredAppHandler) { + *aPreferredAppHandler = mPreferredApplication; + NS_IF_ADDREF(*aPreferredAppHandler); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::SetPreferredApplicationHandler( + nsIHandlerApp* aPreferredAppHandler) { + mPreferredApplication = aPreferredAppHandler; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetPossibleApplicationHandlers( + nsIMutableArray** aPossibleAppHandlers) { + if (!mPossibleApplications) + mPossibleApplications = do_CreateInstance(NS_ARRAY_CONTRACTID); + + if (!mPossibleApplications) return NS_ERROR_OUT_OF_MEMORY; + + *aPossibleAppHandlers = mPossibleApplications; + NS_IF_ADDREF(*aPossibleAppHandlers); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetPreferredAction(nsHandlerInfoAction* aPreferredAction) { + *aPreferredAction = mPreferredAction; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::SetPreferredAction(nsHandlerInfoAction aPreferredAction) { + mPreferredAction = aPreferredAction; + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetAlwaysAskBeforeHandling(bool* aAlwaysAsk) { + *aAlwaysAsk = mAlwaysAskBeforeHandling; + + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::SetAlwaysAskBeforeHandling(bool aAlwaysAsk) { + mAlwaysAskBeforeHandling = aAlwaysAsk; + return NS_OK; +} + +/* static */ +nsresult nsMIMEInfoBase::GetLocalFileFromURI(nsIURI* aURI, nsIFile** aFile) { + nsresult rv; + + nsCOMPtr<nsIFileURL> fileUrl = do_QueryInterface(aURI, &rv); + if (NS_FAILED(rv)) { + return rv; + } + + nsCOMPtr<nsIFile> file; + rv = fileUrl->GetFile(getter_AddRefs(file)); + if (NS_FAILED(rv)) { + return rv; + } + + file.forget(aFile); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoBase::LaunchWithFile(nsIFile* aFile) { + nsresult rv; + + // it doesn't make any sense to call this on protocol handlers + NS_ASSERTION(mClass == eMIMEInfo, + "nsMIMEInfoBase should have mClass == eMIMEInfo"); + + if (AutomationOnlyCheckIfLaunchStubbed(aFile)) { + return NS_OK; + } + + if (mPreferredAction == useSystemDefault) { + return LaunchDefaultWithFile(aFile); + } + + if (mPreferredAction == useHelperApp) { + if (!mPreferredApplication) return NS_ERROR_FILE_NOT_FOUND; + + // at the moment, we only know how to hand files off to local handlers + nsCOMPtr<nsILocalHandlerApp> localHandler = + do_QueryInterface(mPreferredApplication, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIFile> executable; + rv = localHandler->GetExecutable(getter_AddRefs(executable)); + NS_ENSURE_SUCCESS(rv, rv); + + return LaunchWithIProcess(executable, aFile->NativePath()); + } + + return NS_ERROR_INVALID_ARG; +} + +bool nsMIMEInfoBase::AutomationOnlyCheckIfLaunchStubbed(nsIFile* aFile) { + // This is pretty gross and hacky, but otherwise we can't automatically + // test this, and we keep breaking edgecases around this, so... + if (!xpc::IsInAutomation()) { + return false; + } + nsAutoString path; + aFile->GetPath(path); + nsCOMPtr<nsISupportsPRBool> canOpen = + do_CreateInstance("@mozilla.org/supports-PRBool;1"); + canOpen->SetData(true); + nsCOMPtr<nsIObserverService> observerService = + mozilla::services::GetObserverService(); + observerService->NotifyObservers(canOpen, "test-only-opening-downloaded-file", + path.get()); + bool data = true; + canOpen->GetData(&data); + return !data; +} + +NS_IMETHODIMP +nsMIMEInfoBase::LaunchWithURI(nsIURI* aURI, + mozilla::dom::BrowsingContext* aBrowsingContext) { + // This is only being called with protocol handlers + NS_ASSERTION(mClass == eProtocolInfo, + "nsMIMEInfoBase should be a protocol handler"); + + if (mPreferredAction == useSystemDefault) { + // First, ensure we're not accidentally going to call ourselves. + // That'd lead to an infinite loop (see bug 215554). + nsCOMPtr<nsIExternalProtocolService> extProtService = + do_GetService(NS_EXTERNALPROTOCOLSERVICE_CONTRACTID); + if (!extProtService) { + return NS_ERROR_FAILURE; + } + nsAutoCString scheme; + aURI->GetScheme(scheme); + bool isDefault = false; + nsresult rv = + extProtService->IsCurrentAppOSDefaultForProtocol(scheme, &isDefault); + if (NS_SUCCEEDED(rv) && isDefault) { + // Lie. This will trip the handler service into showing a dialog asking + // what the user wants. + return NS_ERROR_FILE_NOT_FOUND; + } + return LoadUriInternal(aURI); + } + + if (mPreferredAction == useHelperApp) { + if (!mPreferredApplication) return NS_ERROR_FILE_NOT_FOUND; + + EnsureAppDetailsAvailable(); + nsCOMPtr<nsILocalHandlerApp> localPreferredHandler = + do_QueryInterface(mPreferredApplication); + if (localPreferredHandler) { + nsCOMPtr<nsIFile> executable; + localPreferredHandler->GetExecutable(getter_AddRefs(executable)); + executable = GetCanonicalExecutable(executable); + bool isOurExecutable = false; + if (!executable || + NS_FAILED(executable->Equals(sOurAppFile, &isOurExecutable)) || + isOurExecutable) { + // Lie. This will trip the handler service into showing a dialog asking + // what the user wants. + return NS_ERROR_FILE_NOT_FOUND; + } + } + return mPreferredApplication->LaunchWithURI(aURI, aBrowsingContext); + } + + return NS_ERROR_INVALID_ARG; +} + +void nsMIMEInfoBase::CopyBasicDataTo(nsMIMEInfoBase* aOther) { + aOther->mSchemeOrType = mSchemeOrType; + aOther->mDefaultAppDescription = mDefaultAppDescription; + aOther->mExtensions = mExtensions.Clone(); +} + +/* static */ +already_AddRefed<nsIProcess> nsMIMEInfoBase::InitProcess(nsIFile* aApp, + nsresult* aResult) { + NS_ASSERTION(aApp, "Unexpected null pointer, fix caller"); + + nsCOMPtr<nsIProcess> process = + do_CreateInstance(NS_PROCESS_CONTRACTID, aResult); + if (NS_FAILED(*aResult)) return nullptr; + + *aResult = process->Init(aApp); + if (NS_FAILED(*aResult)) return nullptr; + + return process.forget(); +} + +/* static */ +nsresult nsMIMEInfoBase::LaunchWithIProcess(nsIFile* aApp, + const nsCString& aArg) { + nsresult rv; + nsCOMPtr<nsIProcess> process = InitProcess(aApp, &rv); + if (NS_FAILED(rv)) return rv; + + const char* string = aArg.get(); + + return process->Run(false, &string, 1); +} + +/* static */ +nsresult nsMIMEInfoBase::LaunchWithIProcess(nsIFile* aApp, + const nsString& aArg) { + nsresult rv; + nsCOMPtr<nsIProcess> process = InitProcess(aApp, &rv); + if (NS_FAILED(rv)) return rv; + + const char16_t* string = aArg.get(); + + return process->Runw(false, &string, 1); +} + +/* static */ +nsresult nsMIMEInfoBase::LaunchWithIProcess(nsIFile* aApp, const int aArgc, + const char16_t** aArgv) { + nsresult rv; + nsCOMPtr<nsIProcess> process = InitProcess(aApp, &rv); + if (NS_FAILED(rv)) { + return rv; + } + + return process->Runw(false, aArgv, aArgc); +} + +// nsMIMEInfoImpl implementation +NS_IMETHODIMP +nsMIMEInfoImpl::GetDefaultDescription(nsAString& aDefaultDescription) { + if (mDefaultAppDescription.IsEmpty()) { + nsCOMPtr<nsIFile> defaultApp = GetDefaultApplication(); + if (defaultApp) { + // Don't want to cache this, just in case someone resets the app + // without changing the description.... + defaultApp->GetLeafName(aDefaultDescription); + return NS_OK; + } + } + aDefaultDescription = mDefaultAppDescription; + + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoImpl::GetHasDefaultHandler(bool* _retval) { + *_retval = !mDefaultAppDescription.IsEmpty(); + nsCOMPtr<nsIFile> defaultApp = GetDefaultApplication(); + if (defaultApp) { + bool exists; + *_retval = NS_SUCCEEDED(defaultApp->Exists(&exists)) && exists; + } + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoImpl::IsCurrentAppOSDefault(bool* _retval) { + *_retval = false; + nsCOMPtr<nsIFile> defaultApp = GetDefaultApplication(); + if (defaultApp) { + // Determine if the default executable is our executable. + EnsureAppDetailsAvailable(); + bool isSame = false; + nsresult rv = defaultApp->Equals(sOurAppFile, &isSame); + if (NS_FAILED(rv)) { + return rv; + } + *_retval = isSame; + } + return NS_OK; +} + +nsresult nsMIMEInfoImpl::LaunchDefaultWithFile(nsIFile* aFile) { + nsCOMPtr<nsIFile> defaultApp = GetDefaultApplication(); + if (!defaultApp) { + return NS_ERROR_FILE_NOT_FOUND; + } + + return LaunchWithIProcess(defaultApp, aFile->NativePath()); +} + +NS_IMETHODIMP +nsMIMEInfoBase::GetPossibleLocalHandlers(nsIArray** _retval) { + return NS_ERROR_NOT_IMPLEMENTED; +} diff --git a/uriloader/exthandler/nsMIMEInfoImpl.h b/uriloader/exthandler/nsMIMEInfoImpl.h new file mode 100644 index 0000000000..9b080ac545 --- /dev/null +++ b/uriloader/exthandler/nsMIMEInfoImpl.h @@ -0,0 +1,224 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=4 sw=2 sts=2 et: */ +/* 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/. */ +#ifndef __nsmimeinfoimpl_h___ +#define __nsmimeinfoimpl_h___ + +#include "nsIMIMEInfo.h" +#include "nsAtom.h" +#include "nsString.h" +#include "nsTArray.h" +#include "nsIMutableArray.h" +#include "nsIFile.h" +#include "nsCOMPtr.h" +#include "nsIURI.h" +#include "nsIProcess.h" +#include "mozilla/dom/BrowsingContext.h" + +/** + * UTF8 moz-icon URI string for the default handler application's icon, if + * available. + */ +#define PROPERTY_DEFAULT_APP_ICON_URL "defaultApplicationIconURL" +/** + * UTF8 moz-icon URI string for the user's preferred handler application's + * icon, if available. + */ +#define PROPERTY_CUSTOM_APP_ICON_URL "customApplicationIconURL" + +/** + * Basic implementation of nsIMIMEInfo. Incomplete - it is meant to be + * subclassed, and GetHasDefaultHandler as well as LaunchDefaultWithFile need to + * be implemented. + */ +class nsMIMEInfoBase : public nsIMIMEInfo { + public: + NS_DECL_THREADSAFE_ISUPPORTS + + // I'd use NS_DECL_NSIMIMEINFO, but I don't want GetHasDefaultHandler + NS_IMETHOD GetFileExtensions(nsIUTF8StringEnumerator** _retval) override; + NS_IMETHOD SetFileExtensions(const nsACString& aExtensions) override; + NS_IMETHOD ExtensionExists(const nsACString& aExtension, + bool* _retval) override; + NS_IMETHOD AppendExtension(const nsACString& aExtension) override; + NS_IMETHOD GetPrimaryExtension(nsACString& aPrimaryExtension) override; + NS_IMETHOD SetPrimaryExtension(const nsACString& aPrimaryExtension) override; + NS_IMETHOD GetType(nsACString& aType) override; + NS_IMETHOD GetMIMEType(nsACString& aMIMEType) override; + NS_IMETHOD GetDescription(nsAString& aDescription) override; + NS_IMETHOD SetDescription(const nsAString& aDescription) override; + NS_IMETHOD Equals(nsIMIMEInfo* aMIMEInfo, bool* _retval) override; + NS_IMETHOD GetPreferredApplicationHandler( + nsIHandlerApp** aPreferredAppHandler) override; + NS_IMETHOD SetPreferredApplicationHandler( + nsIHandlerApp* aPreferredAppHandler) override; + NS_IMETHOD GetPossibleApplicationHandlers( + nsIMutableArray** aPossibleAppHandlers) override; + NS_IMETHOD GetDefaultDescription(nsAString& aDefaultDescription) override; + NS_IMETHOD LaunchWithFile(nsIFile* aFile) override; + NS_IMETHOD LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext* aBrowsingContext) override; + NS_IMETHOD GetPreferredAction(nsHandlerInfoAction* aPreferredAction) override; + NS_IMETHOD SetPreferredAction(nsHandlerInfoAction aPreferredAction) override; + NS_IMETHOD GetAlwaysAskBeforeHandling( + bool* aAlwaysAskBeforeHandling) override; + NS_IMETHOD SetAlwaysAskBeforeHandling(bool aAlwaysAskBeforeHandling) override; + NS_IMETHOD GetPossibleLocalHandlers(nsIArray** _retval) override; + + enum HandlerClass { eMIMEInfo, eProtocolInfo }; + + // nsMIMEInfoBase methods + explicit nsMIMEInfoBase(const char* aMIMEType = ""); + explicit nsMIMEInfoBase(const nsACString& aMIMEType); + nsMIMEInfoBase(const nsACString& aType, HandlerClass aClass); + + void SetMIMEType(const nsACString& aMIMEType) { mSchemeOrType = aMIMEType; } + + void SetDefaultDescription(const nsString& aDesc) { + mDefaultAppDescription = aDesc; + } + + /** + * Copies basic data of this MIME Info Implementation to the given other + * MIME Info. The data consists of the MIME Type, the (default) description, + * the MacOS type and creator, and the extension list (this object's + * extension list will replace aOther's list, not append to it). This + * function also ensures that aOther's primary extension will be the same as + * the one of this object. + */ + void CopyBasicDataTo(nsMIMEInfoBase* aOther); + + /** + * Return whether this MIMEInfo has any extensions + */ + bool HasExtensions() const { return mExtensions.Length() != 0; } + + static already_AddRefed<nsIFile> GetCanonicalExecutable(nsIFile* aFile); + + protected: + virtual ~nsMIMEInfoBase(); // must be virtual, as the the base class's + // Release should call the subclass's destructor + + /** + * Launch the default application for the given file. + * For even more control over the launching, override launchWithFile. + * Also see the comment about nsIMIMEInfo in general, above. + * + * @param aFile The file that should be opened + */ + virtual nsresult LaunchDefaultWithFile(nsIFile* aFile) = 0; + + /** + * Loads the URI with the OS default app. + * + * @param aURI The URI to pass off to the OS. + */ + virtual nsresult LoadUriInternal(nsIURI* aURI) = 0; + + bool AutomationOnlyCheckIfLaunchStubbed(nsIFile* aFile); + + static already_AddRefed<nsIProcess> InitProcess(nsIFile* aApp, + nsresult* aResult); + + /** + * This method can be used to launch the file or URI with a single + * argument (typically either a file path or a URI spec). This is + * meant as a helper method for implementations of + * LaunchWithURI/LaunchDefaultWithFile. + * + * @param aApp The application to launch (may not be null) + * @param aArg The argument to pass on the command line + */ + static nsresult LaunchWithIProcess(nsIFile* aApp, const nsCString& aArg); + static nsresult LaunchWithIProcess(nsIFile* aApp, const nsString& aArg); + static nsresult LaunchWithIProcess(nsIFile* aApp, const int aArgc, + const char16_t** aArgv); + + /** + * Given a file: nsIURI, return the associated nsIFile + * + * @param aURI the file: URI in question + * @param aFile the associated nsIFile (out param) + */ + static nsresult GetLocalFileFromURI(nsIURI* aURI, nsIFile** aFile); + + /** + * Internal helper to avoid adding duplicates. + */ + void AddUniqueExtension(const nsACString& aExtension); + + // member variables + nsTArray<nsCString> + mExtensions; ///< array of file extensions associated w/ this MIME obj + nsString mDescription; ///< human readable description + nsCString mSchemeOrType; + HandlerClass mClass; + nsCOMPtr<nsIHandlerApp> mPreferredApplication; + nsCOMPtr<nsIMutableArray> mPossibleApplications; + nsHandlerInfoAction mPreferredAction = + nsIMIMEInfo::saveToDisk; ///< preferred action to associate with this + ///< type + nsString mPreferredAppDescription; + nsString mDefaultAppDescription; + bool mAlwaysAskBeforeHandling; + bool mIsDefaultAppInfoFresh = false; +}; + +/** + * This is a complete implementation of nsIMIMEInfo, and contains all necessary + * methods. However, depending on your platform you may want to use a different + * way of launching applications. This class stores the default application in a + * member variable and provides a function for setting it. For local + * applications, launching is done using nsIProcess, native path of the file to + * open as first argument. + */ +class nsMIMEInfoImpl : public nsMIMEInfoBase { + public: + explicit nsMIMEInfoImpl(const char* aMIMEType = "") + : nsMIMEInfoBase(aMIMEType) {} + explicit nsMIMEInfoImpl(const nsACString& aMIMEType) + : nsMIMEInfoBase(aMIMEType) {} + nsMIMEInfoImpl(const nsACString& aType, HandlerClass aClass) + : nsMIMEInfoBase(aType, aClass) {} + virtual ~nsMIMEInfoImpl() {} + + // nsIMIMEInfo methods + NS_IMETHOD GetHasDefaultHandler(bool* _retval) override; + NS_IMETHOD GetDefaultDescription(nsAString& aDefaultDescription) override; + NS_IMETHOD IsCurrentAppOSDefault(bool* _retval) override; + + // additional methods + /** + * Sets the default application. Supposed to be only called by the OS Helper + * App Services. + */ + void SetDefaultApplication(nsIFile* aApp) { + mDefaultApplication = aApp; + mIsDefaultAppInfoFresh = true; + } + + protected: + // nsMIMEInfoBase methods + /** + * The base class implementation is to use LaunchWithIProcess in combination + * with mDefaultApplication. Subclasses can override that behaviour. + */ + virtual nsresult LaunchDefaultWithFile(nsIFile* aFile) override; + + /** + * Loads the URI with the OS default app. This should be overridden by each + * OS's implementation. + */ + virtual nsresult LoadUriInternal(nsIURI* aURI) override = 0; + + // Accessor for default application for subclasses. + nsIFile* GetDefaultApplication() { return mDefaultApplication; } + + private: + nsCOMPtr<nsIFile> + mDefaultApplication; ///< default application associated with this type. +}; + +#endif //__nsmimeinfoimpl_h___ diff --git a/uriloader/exthandler/nsOSHelperAppServiceChild.cpp b/uriloader/exthandler/nsOSHelperAppServiceChild.cpp new file mode 100644 index 0000000000..8ba3a2edc9 --- /dev/null +++ b/uriloader/exthandler/nsOSHelperAppServiceChild.cpp @@ -0,0 +1,119 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#include <sys/types.h> +#include <sys/stat.h> +#include "mozilla/Logging.h" +#include "mozilla/net/NeckoCommon.h" +#include "nsOSHelperAppServiceChild.h" +#include "nsISupports.h" +#include "nsString.h" +#include "nsTArray.h" +#include "nsIFile.h" +#include "nsIHandlerService.h" +#include "nsMimeTypes.h" +#include "nsMIMEInfoImpl.h" +#include "nsCExternalHandlerService.h" +#include "nsCRT.h" +#include "nsEmbedCID.h" + +#undef LOG +#define LOG(...) \ + MOZ_LOG(nsExternalHelperAppService::sLog, mozilla::LogLevel::Info, \ + (__VA_ARGS__)) +#undef LOG_ERR +#define LOG_ERR(...) \ + MOZ_LOG(nsExternalHelperAppService::sLog, mozilla::LogLevel::Error, \ + (__VA_ARGS__)) +#undef LOG_ENABLED +#define LOG_ENABLED() \ + MOZ_LOG_TEST(nsExternalHelperAppService::sLog, mozilla::LogLevel::Info) + +nsresult nsOSHelperAppServiceChild::ExternalProtocolHandlerExists( + const char* aProtocolScheme, bool* aHandlerExists) { + nsresult rv; + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID, &rv); + if (NS_WARN_IF(NS_FAILED(rv))) { + LOG_ERR("nsOSHelperAppServiceChild error: no handler service"); + return rv; + } + + nsAutoCString scheme(aProtocolScheme); + *aHandlerExists = false; + rv = handlerSvc->ExistsForProtocol(scheme, aHandlerExists); + LOG("nsOSHelperAppServiceChild::ExternalProtocolHandlerExists(): " + "protocol: %s, result: %" PRId32, + aProtocolScheme, static_cast<uint32_t>(rv)); + mozilla::Unused << NS_WARN_IF(NS_FAILED(rv)); + return rv; +} + +nsresult nsOSHelperAppServiceChild::OSProtocolHandlerExists(const char* aScheme, + bool* aExists) { + // Use ExternalProtocolHandlerExists() which includes the + // OS-level check and remotes the call to the parent process. + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +nsOSHelperAppServiceChild::GetApplicationDescription(const nsACString& aScheme, + nsAString& aRetVal) { + nsresult rv; + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID, &rv); + if (NS_WARN_IF(NS_FAILED(rv))) { + LOG_ERR("nsOSHelperAppServiceChild error: no handler service"); + return rv; + } + + rv = handlerSvc->GetApplicationDescription(aScheme, aRetVal); + LOG("nsOSHelperAppServiceChild::GetApplicationDescription(): " + "scheme: %s, result: %" PRId32 ", description: %s", + PromiseFlatCString(aScheme).get(), static_cast<uint32_t>(rv), + NS_ConvertUTF16toUTF8(aRetVal).get()); + mozilla::Unused << NS_WARN_IF(NS_FAILED(rv)); + return rv; +} + +NS_IMETHODIMP +nsOSHelperAppServiceChild::GetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aFileExt, + bool* aFound, + nsIMIMEInfo** aMIMEInfo) { + nsresult rv; + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID, &rv); + if (NS_WARN_IF(NS_FAILED(rv))) { + LOG_ERR("nsOSHelperAppServiceChild error: no handler service"); + return rv; + } + + rv = handlerSvc->GetMIMEInfoFromOS(aMIMEType, aFileExt, aFound, aMIMEInfo); + LOG("nsOSHelperAppServiceChild::GetMIMEInfoFromOS(): " + "MIME type: %s, extension: %s, result: %" PRId32, + PromiseFlatCString(aMIMEType).get(), PromiseFlatCString(aFileExt).get(), + static_cast<uint32_t>(rv)); + mozilla::Unused << NS_WARN_IF(NS_FAILED(rv)); + return rv; +} + +NS_IMETHODIMP +nsOSHelperAppServiceChild::GetProtocolHandlerInfoFromOS( + const nsACString& aScheme, bool* aFound, nsIHandlerInfo** aRetVal) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +nsOSHelperAppServiceChild::IsCurrentAppOSDefaultForProtocol( + const nsACString& aScheme, bool* aRetVal) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +nsresult nsOSHelperAppServiceChild::GetFileTokenForPath( + const char16_t* platformAppPath, nsIFile** aFile) { + return NS_ERROR_NOT_IMPLEMENTED; +} diff --git a/uriloader/exthandler/nsOSHelperAppServiceChild.h b/uriloader/exthandler/nsOSHelperAppServiceChild.h new file mode 100644 index 0000000000..05c2195d10 --- /dev/null +++ b/uriloader/exthandler/nsOSHelperAppServiceChild.h @@ -0,0 +1,48 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#ifndef nsOSHelperAppServiceChild_h__ +#define nsOSHelperAppServiceChild_h__ + +#include "nsExternalHelperAppService.h" + +class nsIMIMEInfo; + +/* + * Provides a generic implementation of the nsExternalHelperAppService + * platform-specific methods by remoting calls to the parent process. + * Only provides implementations for the methods needed in unprivileged + * child processes. + */ +class nsOSHelperAppServiceChild : public nsExternalHelperAppService { + public: + nsOSHelperAppServiceChild(){}; + virtual ~nsOSHelperAppServiceChild() = default; + + NS_IMETHOD GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval) override; + + nsresult GetFileTokenForPath(const char16_t* platformAppPath, + nsIFile** aFile) override; + + NS_IMETHOD ExternalProtocolHandlerExists(const char* aProtocolScheme, + bool* aHandlerExists) override; + + NS_IMETHOD OSProtocolHandlerExists(const char* aScheme, + bool* aExists) override; + + NS_IMETHOD GetApplicationDescription(const nsACString& aScheme, + nsAString& aRetVal) override; + NS_IMETHOD IsCurrentAppOSDefaultForProtocol(const nsACString& aScheme, + bool* _retval) override; + + NS_IMETHOD GetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aFileExt, bool* aFound, + nsIMIMEInfo** aMIMEInfo) override; +}; + +#endif // nsOSHelperAppServiceChild_h__ diff --git a/uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs b/uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs new file mode 100644 index 0000000000..0e52cc2568 --- /dev/null +++ b/uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs @@ -0,0 +1,253 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Shared functions for tests related to invoking external handler applications. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Assert } from "resource://testing-common/Assert.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gExternalProtocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gHandlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +export var HandlerServiceTestUtils = { + /** + * Retrieves the names of all the MIME types and protocols configured in the + * handler service instance currently under testing. + * + * @return Array of strings like "example/type" or "example-scheme", sorted + * alphabetically regardless of category. + */ + getAllHandlerInfoTypes() { + return Array.from( + lazy.gHandlerService.enumerate(), + info => info.type + ).sort(); + }, + + /** + * Retrieves all the configured handlers for MIME types and protocols. + * + * @note The nsIHandlerInfo instances returned by the "enumerate" method + * cannot be used for testing because they incorporate information from + * the operating system and also from the default nsIHandlerService + * instance, independently from what instance is under testing. + * + * @return Array of nsIHandlerInfo instances sorted by their "type" property. + */ + getAllHandlerInfos() { + return this.getAllHandlerInfoTypes().map(type => this.getHandlerInfo(type)); + }, + + /** + * Retrieves an nsIHandlerInfo for the given MIME type or protocol, which + * incorporates information from the operating system and also from the + * handler service instance currently under testing. + * + * @note If the handler service instance currently under testing is not the + * default one and the requested type is a MIME type, the returned + * nsIHandlerInfo will include information from the default + * nsIHandlerService instance. This cannot be avoided easily because the + * getMIMEInfoFromOS method is not exposed to JavaScript. + * + * @param type + * MIME type or scheme name of the nsIHandlerInfo to retrieve. + * + * @return The populated nsIHandlerInfo instance. + */ + getHandlerInfo(type) { + if (type.includes("/")) { + // We have to use the getFromTypeAndExtension method because we don't have + // access to getMIMEInfoFromOS. This means that we have to reset the data + // that may have been imported from the default nsIHandlerService instance + // and is not overwritten by fillHandlerInfo later. + let handlerInfo = lazy.gMIMEService.getFromTypeAndExtension(type, null); + if (AppConstants.platform == "android") { + // On Android, the first handler application is always the internal one. + while (handlerInfo.possibleApplicationHandlers.length > 1) { + handlerInfo.possibleApplicationHandlers.removeElementAt(1); + } + } else { + handlerInfo.possibleApplicationHandlers.clear(); + } + handlerInfo.setFileExtensions(""); + // Populate the object from the handler service instance under testing. + if (lazy.gHandlerService.exists(handlerInfo)) { + lazy.gHandlerService.fillHandlerInfo(handlerInfo, ""); + } + return handlerInfo; + } + + // Populate the protocol information from the handler service instance under + // testing, like the nsIExternalProtocolService::GetProtocolHandlerInfo + // method does on the default nsIHandlerService instance. + let osDefaultHandlerFound = {}; + let handlerInfo = + lazy.gExternalProtocolService.getProtocolHandlerInfoFromOS( + type, + osDefaultHandlerFound + ); + if (lazy.gHandlerService.exists(handlerInfo)) { + lazy.gHandlerService.fillHandlerInfo(handlerInfo, ""); + } else { + lazy.gExternalProtocolService.setProtocolHandlerDefaults( + handlerInfo, + osDefaultHandlerFound.value + ); + } + return handlerInfo; + }, + + /** + * Creates an nsIHandlerInfo for the given MIME type or protocol, initialized + * to the default values for the current platform. + * + * @note For this method to work, the specified MIME type or protocol must not + * be configured in the default handler service instance or the one + * under testing, and must not be registered in the operating system. + * + * @param type + * MIME type or scheme name of the nsIHandlerInfo to create. + * + * @return The blank nsIHandlerInfo instance. + */ + getBlankHandlerInfo(type) { + let handlerInfo = this.getHandlerInfo(type); + + let preferredAction, preferredApplicationHandler; + let alwaysAskBeforeHandling = true; + + if (AppConstants.platform == "android") { + // On Android, the default preferredAction for MIME types is useHelperApp. + // For protocols, we always behave as if an operating system provided + // handler existed, and as such we initialize them to useSystemDefault. + // This is because the AndroidBridge is not available in xpcshell tests. + preferredAction = type.includes("/") + ? Ci.nsIHandlerInfo.useHelperApp + : Ci.nsIHandlerInfo.useSystemDefault; + // On Android, the default handler application is always the internal one. + preferredApplicationHandler = { + name: "Android chooser", + }; + } else { + // On Desktop, the default preferredAction for MIME types is saveToDisk, + // while for protocols it is alwaysAsk. Since Bug 1735843, for new MIME + // types we default to not asking before handling unless a pref is set. + alwaysAskBeforeHandling = Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types", + false + ); + + if (type.includes("/")) { + preferredAction = Ci.nsIHandlerInfo.saveToDisk; + } else { + preferredAction = Ci.nsIHandlerInfo.alwaysAsk; + // we'll always ask before handling protocols + alwaysAskBeforeHandling = true; + } + preferredApplicationHandler = null; + } + + this.assertHandlerInfoMatches(handlerInfo, { + type, + preferredAction, + alwaysAskBeforeHandling, + preferredApplicationHandler, + }); + return handlerInfo; + }, + + /** + * Checks whether an nsIHandlerInfo instance matches the provided object. + */ + assertHandlerInfoMatches(handlerInfo, expected) { + let expectedInterface = expected.type.includes("/") + ? Ci.nsIMIMEInfo + : Ci.nsIHandlerInfo; + Assert.ok(handlerInfo instanceof expectedInterface); + Assert.equal(handlerInfo.type, expected.type); + + if (!expected.preferredActionOSDependent) { + Assert.equal(handlerInfo.preferredAction, expected.preferredAction); + Assert.equal( + handlerInfo.alwaysAskBeforeHandling, + expected.alwaysAskBeforeHandling + ); + } + + if (expectedInterface == Ci.nsIMIMEInfo) { + let fileExtensionsEnumerator = handlerInfo.getFileExtensions(); + for (let expectedFileExtension of expected.fileExtensions || []) { + Assert.equal(fileExtensionsEnumerator.getNext(), expectedFileExtension); + } + Assert.ok(!fileExtensionsEnumerator.hasMore()); + } + + if (expected.preferredApplicationHandler) { + this.assertHandlerAppMatches( + handlerInfo.preferredApplicationHandler, + expected.preferredApplicationHandler + ); + } else { + Assert.equal(handlerInfo.preferredApplicationHandler, null); + } + + let handlerAppsArrayEnumerator = + handlerInfo.possibleApplicationHandlers.enumerate(); + if (AppConstants.platform == "android") { + // On Android, the first handler application is always the internal one. + this.assertHandlerAppMatches(handlerAppsArrayEnumerator.getNext(), { + name: "Android chooser", + }); + } + for (let expectedHandlerApp of expected.possibleApplicationHandlers || []) { + this.assertHandlerAppMatches( + handlerAppsArrayEnumerator.getNext(), + expectedHandlerApp + ); + } + Assert.ok(!handlerAppsArrayEnumerator.hasMoreElements()); + }, + + /** + * Checks whether an nsIHandlerApp instance matches the provided object. + */ + assertHandlerAppMatches(handlerApp, expected) { + Assert.ok(handlerApp instanceof Ci.nsIHandlerApp); + Assert.equal(handlerApp.name, expected.name); + if (expected.executable) { + Assert.ok(handlerApp instanceof Ci.nsILocalHandlerApp); + Assert.ok(expected.executable.equals(handlerApp.executable)); + } else if (expected.uriTemplate) { + Assert.ok(handlerApp instanceof Ci.nsIWebHandlerApp); + Assert.equal(handlerApp.uriTemplate, expected.uriTemplate); + } else if (expected.service) { + Assert.ok(handlerApp instanceof Ci.nsIDBusHandlerApp); + Assert.equal(handlerApp.service, expected.service); + Assert.equal(handlerApp.method, expected.method); + Assert.equal(handlerApp.dBusInterface, expected.dBusInterface); + Assert.equal(handlerApp.objectPath, expected.objectPath); + } + }, +}; diff --git a/uriloader/exthandler/tests/WriteArgument.cpp b/uriloader/exthandler/tests/WriteArgument.cpp new file mode 100644 index 0000000000..603965150e --- /dev/null +++ b/uriloader/exthandler/tests/WriteArgument.cpp @@ -0,0 +1,20 @@ +#include <stdio.h> +#include "prenv.h" + +int main(int argc, char* argv[]) { + if (argc != 2) return 1; + + const char* value = PR_GetEnv("WRITE_ARGUMENT_FILE"); + + if (!value) return 2; + + FILE* outfile = fopen(value, "w"); + if (!outfile) return 3; + + // We only need to write out the first argument (no newline). + fputs(argv[argc - 1], outfile); + + fclose(outfile); + + return 0; +} diff --git a/uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp b/uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp new file mode 100644 index 0000000000..e63922182c --- /dev/null +++ b/uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp @@ -0,0 +1,46 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#include "nsExternalHelperAppService.h" +#include "gtest/gtest.h" +#include "nsNetUtil.h" +#include "mozilla/StaticPrefs_network.h" + +using namespace mozilla; + +TEST(ExternalHelperAppService, EscapeURI) +{ + if (StaticPrefs::network_url_useDefaultURI()) { + return; + } + + nsCString input("myproto://hello world"); + nsCString expected("myproto://hello%20world"); + + nsCOMPtr<nsIIOService> ios(do_GetIOService()); + nsCOMPtr<nsIURI> uri; + nsresult rv = ios->NewURI(input, nullptr, nullptr, getter_AddRefs(uri)); + EXPECT_EQ(rv, NS_OK); + + nsAutoCString spec; + rv = uri->GetSpec(spec); + EXPECT_EQ(rv, NS_OK); + + // Constructing the URI does not escape the space character. + ASSERT_TRUE(spec.Equals(input)); + + // Created an escaped version of the URI. + nsCOMPtr<nsIURI> escapedURI; + rv = nsExternalHelperAppService::EscapeURI(uri, getter_AddRefs(escapedURI)); + EXPECT_EQ(rv, NS_OK); + + nsAutoCString escapedSpec; + rv = escapedURI->GetSpec(escapedSpec); + EXPECT_EQ(rv, NS_OK); + + // Escaped URI should have an escaped spec. + ASSERT_TRUE(escapedSpec.Equals(expected)); +} diff --git a/uriloader/exthandler/tests/gtest/moz.build b/uriloader/exthandler/tests/gtest/moz.build new file mode 100644 index 0000000000..163afe927d --- /dev/null +++ b/uriloader/exthandler/tests/gtest/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +UNIFIED_SOURCES += [ + "ExternalHelperAppServiceTest.cpp", +] + +FINAL_LIBRARY = "xul-gtest" diff --git a/uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html b/uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html new file mode 100644 index 0000000000..d0e98abace --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + <head> + <title>Protocol handler</title> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> + </head> + <body> + <script type="text/javascript"> + navigator.registerProtocolHandler("ftp", + "https://example.com/browser/uriloader/exthandler/tests/mochitest/blank.html?uri=%s", + "Test Protocol"); + </script> + <a id="link" href="ftp://user:password@domain.com/path">ftp link</a> + </body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js b/uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js new file mode 100644 index 0000000000..4f66c99dc6 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js @@ -0,0 +1,104 @@ +/* eslint-env mozilla/chrome-script */ + +const { ComponentUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ComponentUtils.sys.mjs" +); + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +let gMIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +const HELPERAPP_DIALOG_CONTRACT = "@mozilla.org/helperapplauncherdialog;1"; +const HELPERAPP_DIALOG_CID = Components.ID( + Cc[HELPERAPP_DIALOG_CONTRACT].number +); + +let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile); +tmpDir.append("testsavedir" + Math.floor(Math.random() * 2 ** 32)); +// Create this dir if it doesn't exist (ignores existing dirs) +try { + tmpDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o777, true); +} catch (ex) { + if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) { + throw ex; + } +} +Services.prefs.setIntPref("browser.download.folderList", 2); +Services.prefs.setCharPref("browser.download.dir", tmpDir.path); + +const FAKE_CID = Services.uuid.generateUUID(); +function HelperAppLauncherDialog() {} +HelperAppLauncherDialog.prototype = { + show(aLauncher, aWindowContext, aReason) { + if ( + Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + ) { + let f = tmpDir.clone(); + f.append(aLauncher.suggestedFileName); + aLauncher.saveDestinationAvailable(f); + sendAsyncMessage("suggestedFileName", aLauncher.suggestedFileName); + } else { + sendAsyncMessage("wrongAPICall", "show"); + } + aLauncher.cancel(Cr.NS_BINDING_ABORTED); + }, + promptForSaveToFileAsync( + appLauncher, + parent, + filename, + extension, + forceSave + ) { + if ( + !Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + ) { + let f = tmpDir.clone(); + f.append(filename); + appLauncher.saveDestinationAvailable(f); + sendAsyncMessage("suggestedFileName", filename); + } else { + sendAsyncMessage("wrongAPICall", "promptForSaveToFileAsync"); + } + appLauncher.cancel(Cr.NS_BINDING_ABORTED); + }, + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]), +}; + +var registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +registrar.registerFactory( + FAKE_CID, + "", + HELPERAPP_DIALOG_CONTRACT, + ComponentUtils.generateSingletonFactory(HelperAppLauncherDialog) +); + +addMessageListener("unregister", async function () { + registrar.registerFactory( + HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT, + null + ); + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + for (let dl of downloads) { + await dl.refresh(); + if (dl.target.exists || dl.target.partFileExists) { + dump("Finalizing download.\n"); + await dl.finalize(true).catch(console.error); + } + } + await list.removeFinished(); + dump("Clearing " + tmpDir.path + "\n"); + tmpDir.remove(true); + sendAsyncMessage("unregistered"); +}); diff --git a/uriloader/exthandler/tests/mochitest/blank.html b/uriloader/exthandler/tests/mochitest/blank.html new file mode 100644 index 0000000000..5417098d72 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/blank.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title></title> +</head> +<body> + +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/browser.toml b/uriloader/exthandler/tests/mochitest/browser.toml new file mode 100644 index 0000000000..2eaf6632ac --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser.toml @@ -0,0 +1,164 @@ +[DEFAULT] +head = "head.js" +support-files = ["protocolHandler.html"] + + +["browser_auto_close_window.js"] +support-files = [ + "download_page.html", + "download.bin", + "download.sjs", +] + +["browser_auto_close_window_nodialog.js"] +support-files = [ + "download_page.html", + "download.bin", + "download.sjs", +] + +["browser_bad_download_dir.js"] +run-if = ["os == 'linux'"] +support-files = ["download.bin"] + +["browser_download_always_ask_preferred_app.js"] + +["browser_download_idn_blocklist.js"] +support-files = ["download.bin"] + +["browser_download_open_with_internal_handler.js"] +support-files = [ + "file_image_svgxml.svg", + "file_image_svgxml.svg^headers^", + "file_pdf_application_pdf.pdf", + "file_pdf_application_pdf.pdf^headers^", + "file_pdf_application_unknown.pdf", + "file_pdf_application_unknown.pdf^headers^", + "file_pdf_application_octet_stream.pdf", + "file_pdf_application_octet_stream.pdf^headers^", + "file_pdf_binary_octet_stream.pdf", + "file_pdf_binary_octet_stream.pdf^headers^", + "file_txt_attachment_test.txt", + "file_txt_attachment_test.txt^headers^", + "file_xml_attachment_binary_octet_stream.xml", + "file_xml_attachment_binary_octet_stream.xml^headers^", + "file_xml_attachment_test.xml", + "file_xml_attachment_test.xml^headers^", + "file_green.webp", + "file_green.webp^headers^", +] + +["browser_download_preferred_action.js"] +support-files = ["mime_type_download.sjs"] + +["browser_download_privatebrowsing.js"] + +["browser_download_skips_dialog.js"] +support-files = [ + "file_green.webp", + "file_green.webp^headers^", +] + +["browser_download_spam_permissions.js"] +support-files = ["test_spammy_page.html"] + +["browser_download_urlescape.js"] +support-files = [ + "file_with@@funny_name.png", + "file_with@@funny_name.png^headers^", + "file_with[funny_name.webm", + "file_with[funny_name.webm^headers^", +] + +["browser_extension_correction.js"] +support-files = [ + "file_as.exe", + "file_as.exe^headers^", +] + +["browser_filehandling_loop.js"] + +["browser_first_prompt_not_blocked_without_user_interaction.js"] +support-files = ["file_external_protocol_iframe.html"] + +["browser_ftp_protocol_handlers.js"] +support-files = [ + "FTPprotocolHandler.html", + "blank.html", +] + +["browser_launched_app_save_directory.js"] +support-files = [ + "file_pdf_application_pdf.pdf", + "file_pdf_application_pdf.pdf^headers^", + "file_green.webp", + "file_green.webp^headers^", +] + +["browser_local_files_no_save_without_asking.js"] +support-files = ["file_pdf_binary_octet_stream.pdf"] + +["browser_local_files_open_doesnt_duplicate.js"] +support-files = ["file_pdf_binary_octet_stream.pdf"] + +["browser_open_internal_choice_persistence.js"] +skip-if = ["apple_silicon"] # bug 1752482 +support-files = [ + "file_pdf_application_pdf.pdf", + "file_pdf_application_pdf.pdf^headers^", +] + +["browser_pdf_save_as.js"] + +["browser_protocol_ask_dialog.js"] +support-files = ["file_nested_protocol_request.html"] + +["browser_protocol_ask_dialog_external.js"] +support-files = ["redirect_helper.sjs"] + +["browser_protocol_ask_dialog_permission.js"] +support-files = [ + "redirect_helper.sjs", + "script_redirect.html", +] + +["browser_protocol_custom_sandbox.js"] +support-files = ["protocol_custom_sandbox_helper.sjs"] + +["browser_protocol_custom_sandbox_csp.js"] +support-files = ["protocol_custom_sandbox_helper.sjs"] + +["browser_protocolhandler_loop.js"] + +["browser_remember_download_option.js"] + +["browser_save_filenames.js"] +support-files = ["save_filenames.html"] + +["browser_shows_where_to_save_dialog.js"] +support-files = [ + "file_green.webp", + "file_green.webp^headers^", + "file_pdf_application_pdf.pdf", + "file_pdf_application_pdf.pdf^headers^", + "file_txt_attachment_test.txt", + "file_txt_attachment_test.txt^headers^", +] + +["browser_standalone_application_chooser_window_fallback.js"] +support-files = [ + "FTPprotocolHandler.html", + "blank.html", +] + +["browser_txt_download_save_as.js"] +support-files = [ + "file_txt_attachment_test.txt", + "file_txt_attachment_test.txt^headers^", + "!/toolkit/content/tests/browser/common/mockTransfer.js", +] + +["browser_web_handler_app_pinned_tab.js"] +support-files = ["mailto.html"] + +["browser_web_protocol_handlers.js"] diff --git a/uriloader/exthandler/tests/mochitest/browser_auto_close_window.js b/uriloader/exthandler/tests/mochitest/browser_auto_close_window.js new file mode 100644 index 0000000000..af60b59c4b --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_auto_close_window.js @@ -0,0 +1,342 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ComponentUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ComponentUtils.sys.mjs" +); + +const ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); +const PAGE_URL = ROOT + "download_page.html"; +const SJS_URL = ROOT + "download.sjs"; + +const HELPERAPP_DIALOG_CONTRACT_ID = "@mozilla.org/helperapplauncherdialog;1"; +const HELPERAPP_DIALOG_CID = Components.ID( + Cc[HELPERAPP_DIALOG_CONTRACT_ID].number +); +const MOCK_HELPERAPP_DIALOG_CID = Components.ID( + "{2f372b6f-56c9-46d5-af0d-9f09bb69860c}" +); + +let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +let curDialogResolve = null; + +function HelperAppLauncherDialog() {} + +HelperAppLauncherDialog.prototype = { + show(aLauncher, aWindowContext, aReason) { + ok(true, "Showing the helper app dialog"); + curDialogResolve(aWindowContext); + executeSoon(() => { + aLauncher.cancel(Cr.NS_ERROR_ABORT); + }); + }, + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]), +}; + +function promiseHelperAppDialog() { + return new Promise(resolve => { + curDialogResolve = resolve; + }); +} + +let mockHelperAppService; + +add_setup(async function () { + // Replace the real helper app dialog with our own. + mockHelperAppService = ComponentUtils.generateSingletonFactory( + HelperAppLauncherDialog + ); + registrar.registerFactory( + MOCK_HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT_ID, + mockHelperAppService + ); + + // Ensure we always prompt for these downloads. + const HandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" + ].getService(Ci.nsIHandlerService); + + const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + const mimeInfo = MIMEService.getFromTypeAndExtension( + "application/octet-stream", + "bin" + ); + mimeInfo.alwaysAskBeforeHandling = true; + HandlerService.store(mimeInfo); + + // On Mac, .bin is application/macbinary + let mimeInfoMac; + if (AppConstants.platform == "macosx") { + mimeInfoMac = MIMEService.getFromTypeAndExtension( + "application/macbinary", + "bin" + ); + mimeInfoMac.alwaysAskBeforeHandling = true; + HandlerService.store(mimeInfoMac); + } + + registerCleanupFunction(() => { + HandlerService.remove(mimeInfo); + if (mimeInfoMac) { + HandlerService.remove(mimeInfoMac); + } + }); +}); + +add_task(async function simple_navigation() { + // Tests that simple navigation gives us the right windowContext (that is, + // the window that we're using). + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#regular_load", + {}, + browser + ); + let windowContext = await dialogAppeared; + + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + } + ); +}); + +add_task(async function accel_navigation() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#regular_load", + { accelKey: true }, + browser + ); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +// Given a browser pointing to download_page.html, clicks on the link that +// opens with target="_blank" (i.e. a new tab) and ensures that we +// automatically open and close that tab. +async function testNewTab(browser) { + let dialogAppeared = promiseHelperAppDialog(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter("#target_blank", {}, browser); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); +} + +add_task(async function target_blank() { + // Tests that a link with target=_blank opens a new tab and closes it, + // returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + await testNewTab(browser); + } + ); +}); + +add_task(async function target_blank_no_opener() { + // Tests that a link with target=_blank and no opener opens a new tab + // and closes it, returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#target_blank_no_opener", + {}, + browser + ); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +add_task(async function open_in_new_tab_no_opener() { + // Tests that a link with target=_blank and no opener opens a new tab + // and closes it, returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#open_in_new_tab_no_opener", + {}, + browser + ); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +add_task(async function new_window() { + // Tests that a link that forces us to open a new window (by specifying a + // width and a height in window.open) opens a new window for the load, + // realizes that we need to close that window and returns the *original* + // window as the window context. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let windowOpened = BrowserTestUtils.waitForNewWindow(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#new_window", + {}, + browser + ); + let win = await windowOpened; + // Now allow request to complete: + fetch(SJS_URL + "?finish"); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + + // The window should close on its own. If not, this test will time out. + await BrowserTestUtils.domWindowClosed(win); + ok(win.closed, "window was opened and closed"); + + is( + await fetch(SJS_URL + "?reset").then(r => r.text()), + "OK", + "Test reseted" + ); + } + ); +}); + +add_task(async function new_window_no_opener() { + // Tests that a link that forces us to open a new window (by specifying a + // width and a height in window.open) opens a new window for the load, + // realizes that we need to close that window and returns the *original* + // window as the window context. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let windowOpened = BrowserTestUtils.waitForNewWindow(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#new_window_no_opener", + {}, + browser + ); + let win = await windowOpened; + // Now allow request to complete: + fetch(SJS_URL + "?finish"); + + await dialogAppeared; + + // The window should close on its own. If not, this test will time out. + await BrowserTestUtils.domWindowClosed(win); + ok(win.closed, "window was opened and closed"); + + is( + await fetch(SJS_URL + "?reset").then(r => r.text()), + "OK", + "Test reseted" + ); + } + ); +}); + +add_task(async function nested_window_opens() { + // Tests that the window auto-closing feature works if the download is + // initiated by a window that, itself, has an opener (see bug 1373109). + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (outerBrowser) { + let secondTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + `${PAGE_URL}?newwin`, + true + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "#open_in_new_tab", + {}, + outerBrowser + ); + let secondTab = await secondTabPromise; + let nestedBrowser = secondTab.linkedBrowser; + + await SpecialPowers.spawn(nestedBrowser, [], function () { + ok(content.opener, "this window has an opener"); + }); + + await testNewTab(nestedBrowser); + + isnot( + secondTab.linkedBrowser, + null, + "the page that triggered the download is still open" + ); + BrowserTestUtils.removeTab(secondTab); + } + ); +}); + +add_task(async function cleanup() { + // Unregister our factory from XPCOM and restore the original CID. + registrar.unregisterFactory(MOCK_HELPERAPP_DIALOG_CID, mockHelperAppService); + registrar.registerFactory( + HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT_ID, + null + ); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js b/uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js new file mode 100644 index 0000000000..4f1e8ab18e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js @@ -0,0 +1,308 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ComponentUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ComponentUtils.sys.mjs" +); + +const ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); +const PAGE_URL = ROOT + "download_page.html"; +const SJS_URL = ROOT + "download.sjs"; + +const HELPERAPP_DIALOG_CONTRACT_ID = "@mozilla.org/helperapplauncherdialog;1"; +const HELPERAPP_DIALOG_CID = Components.ID( + Cc[HELPERAPP_DIALOG_CONTRACT_ID].number +); +const MOCK_HELPERAPP_DIALOG_CID = Components.ID( + "{2f372b6f-56c9-46d5-af0d-9f09bb69860c}" +); + +let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +let curSaveResolve = null; + +function HelperAppLauncherDialog() {} + +HelperAppLauncherDialog.prototype = { + show(aLauncher, aWindowContext, aReason) { + ok(false, "Shouldn't be showing the helper app dialog"); + executeSoon(() => { + aLauncher.cancel(Cr.NS_ERROR_ABORT); + }); + }, + promptForSaveToFileAsync(aLauncher, aWindowContext, aReason) { + ok(true, "Shouldn't be showing the helper app dialog"); + curSaveResolve(aWindowContext); + executeSoon(() => { + aLauncher.cancel(Cr.NS_ERROR_ABORT); + }); + }, + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]), +}; + +function promiseSave() { + return new Promise(resolve => { + curSaveResolve = resolve; + }); +} + +let mockHelperAppService; + +add_setup(async function () { + // Replace the real helper app dialog with our own. + mockHelperAppService = ComponentUtils.generateSingletonFactory( + HelperAppLauncherDialog + ); + registrar.registerFactory( + MOCK_HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT_ID, + mockHelperAppService + ); + + // Ensure we always prompt for these downloads. + const HandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" + ].getService(Ci.nsIHandlerService); + + const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + const mimeInfo = MIMEService.getFromTypeAndExtension( + "application/octet-stream", + "bin" + ); + mimeInfo.alwaysAskBeforeHandling = false; + mimeInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk; + HandlerService.store(mimeInfo); + + registerCleanupFunction(() => { + HandlerService.remove(mimeInfo); + }); +}); + +add_task(async function simple_navigation() { + // Tests that simple navigation gives us the right windowContext (that is, + // the window that we're using). + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#regular_load", + {}, + browser + ); + let windowContext = await saveHappened; + + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + } + ); +}); + +// Given a browser pointing to download_page.html, clicks on the link that +// opens with target="_blank" (i.e. a new tab) and ensures that we +// automatically open and close that tab. +async function testNewTab(browser) { + let saveHappened = promiseSave(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter("#target_blank", {}, browser); + + let windowContext = await saveHappened; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); +} + +add_task(async function target_blank() { + // Tests that a link with target=_blank opens a new tab and closes it, + // returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + await testNewTab(browser); + } + ); +}); + +add_task(async function target_blank_no_opener() { + // Tests that a link with target=_blank and no opener opens a new tab + // and closes it, returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#target_blank_no_opener", + {}, + browser + ); + + let windowContext = await saveHappened; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +add_task(async function open_in_new_tab_no_opener() { + // Tests that a link with target=_blank and no opener opens a new tab + // and closes it, returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#open_in_new_tab_no_opener", + {}, + browser + ); + + let windowContext = await saveHappened; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +add_task(async function new_window() { + // Tests that a link that forces us to open a new window (by specifying a + // width and a height in window.open) opens a new window for the load, + // realizes that we need to close that window and returns the *original* + // window as the window context. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + let windowOpened = BrowserTestUtils.waitForNewWindow(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#new_window", + {}, + browser + ); + let win = await windowOpened; + // Now allow request to complete: + fetch(SJS_URL + "?finish"); + + let windowContext = await saveHappened; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + + // The window should close on its own. If not, this test will time out. + await BrowserTestUtils.domWindowClosed(win); + ok(win.closed, "window was opened and closed"); + + is( + await fetch(SJS_URL + "?reset").then(r => r.text()), + "OK", + "Test reseted" + ); + } + ); +}); + +add_task(async function new_window_no_opener() { + // Tests that a link that forces us to open a new window (by specifying a + // width and a height in window.open) opens a new window for the load, + // realizes that we need to close that window and returns the *original* + // window as the window context. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + let windowOpened = BrowserTestUtils.waitForNewWindow(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#new_window_no_opener", + {}, + browser + ); + let win = await windowOpened; + // Now allow request to complete: + fetch(SJS_URL + "?finish"); + + await saveHappened; + + // The window should close on its own. If not, this test will time out. + await BrowserTestUtils.domWindowClosed(win); + ok(win.closed, "window was opened and closed"); + + is( + await fetch(SJS_URL + "?reset").then(r => r.text()), + "OK", + "Test reseted" + ); + } + ); +}); + +add_task(async function nested_window_opens() { + // Tests that the window auto-closing feature works if the download is + // initiated by a window that, itself, has an opener (see bug 1373109). + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (outerBrowser) { + let secondTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + `${PAGE_URL}?newwin`, + true + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "#open_in_new_tab", + {}, + outerBrowser + ); + let secondTab = await secondTabPromise; + let nestedBrowser = secondTab.linkedBrowser; + + await SpecialPowers.spawn(nestedBrowser, [], function () { + ok(content.opener, "this window has an opener"); + }); + + await testNewTab(nestedBrowser); + + isnot( + secondTab.linkedBrowser, + null, + "the page that triggered the download is still open" + ); + BrowserTestUtils.removeTab(secondTab); + } + ); +}); + +add_task(async function cleanup() { + // Unregister our factory from XPCOM and restore the original CID. + registrar.unregisterFactory(MOCK_HELPERAPP_DIALOG_CID, mockHelperAppService); + registrar.registerFactory( + HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT_ID, + null + ); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js b/uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js new file mode 100644 index 0000000000..7fe22e0661 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); + +const ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); +const TEST_FILE = ROOT + "download.bin"; + +add_task(async function test_check_download_dir() { + // Force XDG dir to somewhere that has no config files, causing lookups of the + // system download dir to fail: + let newXDGRoot = FileTestUtils.getTempFile("xdgstuff"); + newXDGRoot.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + let oldXDG = Services.env.exists("XDG_CONFIG_HOME") + ? Services.env.get("XDG_CONFIG_HOME") + : ""; + registerCleanupFunction(() => Services.env.set("XDG_CONFIG_HOME", oldXDG)); + Services.env.set("XDG_CONFIG_HOME", newXDGRoot.path + "/"); + + let propBundle = Services.strings.createBundle( + "chrome://mozapps/locale/downloads/downloads.properties" + ); + let dlRoot = PathUtils.join( + Services.dirsvc.get("Home", Ci.nsIFile).path, + propBundle.GetStringFromName("downloadsFolder") + ); + + // Check lookups fail: + Assert.throws( + () => Services.dirsvc.get("DfltDwnld", Ci.nsIFile), + /NS_ERROR_FAILURE/, + "Should throw when asking for downloads dir." + ); + + await SpecialPowers.pushPrefEnv({ + set: [ + // Avoid opening dialogs + ["browser.download.always_ask_before_handling_new_types", false], + // Switch back to default OS downloads dir (changed in head.js): + ["browser.download.folderList", 1], + ], + }); + + let publicList = await Downloads.getList(Downloads.PUBLIC); + let downloadFinished = promiseDownloadFinished(publicList); + let tab = BrowserTestUtils.addTab(gBrowser, TEST_FILE); + let dl = await downloadFinished; + ok(dl.succeeded, "Download should succeed."); + Assert.stringContains( + dl.target.path, + dlRoot, + "Should store download under DL folder root." + ); + let dlKids = await IOUtils.getChildren(dlRoot); + ok( + dlKids.includes(dl.target.path), + "Download should be a direct child of the DL folder." + ); + await IOUtils.remove(dl.target.path); + + BrowserTestUtils.removeTab(tab); + + // Download a second file to make sure we're not continuously adding filenames + // onto the download folder path. + downloadFinished = promiseDownloadFinished(publicList); + tab = BrowserTestUtils.addTab(gBrowser, TEST_FILE); + dl = await downloadFinished; + Assert.stringContains( + dl.target.path, + dlRoot, + "Second download should store download under new DL folder root." + ); + dlKids = await IOUtils.getChildren(dlRoot); + ok( + dlKids.includes(dl.target.path), + "Second download should be a direct child of the new DL folder." + ); + BrowserTestUtils.removeTab(tab); + await IOUtils.remove(dl.target.path); + + await publicList.removeFinished(); + await IOUtils.remove(newXDGRoot.path); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js b/uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js new file mode 100644 index 0000000000..b4bfe5e51a --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js @@ -0,0 +1,25 @@ +add_task(async function () { + // Create mocked objects for test + let launcher = createMockedObjects(false); + // Open helper app dialog with mocked launcher + let dlg = await openHelperAppDialog(launcher); + let doc = dlg.document; + let location = doc.getElementById("source"); + let expectedValue = launcher.source.prePath; + if (location.value != expectedValue) { + info("Waiting for dialog to be populated."); + await BrowserTestUtils.waitForAttribute("value", location, expectedValue); + } + is( + doc.getElementById("mode").selectedItem.id, + "open", + "Should be opening the file." + ); + ok( + !dlg.document.getElementById("openHandler").selectedItem.hidden, + "Should not have selected a hidden item." + ); + let helperAppDialogHiddenPromise = BrowserTestUtils.windowClosed(dlg); + doc.getElementById("unknownContentType").cancelDialog(); + await helperAppDialogHiddenPromise; +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js b/uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js new file mode 100644 index 0000000000..0d49a898a0 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_HOST = "example.org"; +const TEST_FILE = "\u3002.bin"; +const TEST_URL = `http://${TEST_HOST}/${TEST_FILE}`; + +const { XPCShellContentUtils } = ChromeUtils.importESModule( + "resource://testing-common/XPCShellContentUtils.sys.mjs" +); +XPCShellContentUtils.initMochitest(this); +const server = XPCShellContentUtils.createHttpServer({ + hosts: [TEST_HOST], +}); +let file = getChromeDir(getResolvedURI(gTestPath)); +file.append("download.bin"); +server.registerFile(`/${encodeURIComponent(TEST_FILE)}`, file); + +/** + * Check that IDN blocklisted characters are not escaped in + * download file names. + */ +add_task(async function test_idn_blocklisted_char_not_escaped() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", false]], + }); + + info("Testing with " + TEST_URL); + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let downloadFinished = promiseDownloadFinished(publicList); + var tab = BrowserTestUtils.addTab(gBrowser, TEST_URL); + let dl = await downloadFinished; + ok(dl.succeeded, "Download should succeed."); + Assert.equal( + PathUtils.filename(dl.target.path), + TEST_FILE, + "Should not escape a download file name." + ); + await IOUtils.remove(dl.target.path); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js b/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js new file mode 100644 index 0000000000..c73538fe19 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js @@ -0,0 +1,814 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const MimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +const HandlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +let MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +function waitForAcceptButtonToGetEnabled(doc) { + let dialog = doc.querySelector("#unknownContentType"); + let button = dialog.getButton("accept"); + return TestUtils.waitForCondition( + () => !button.disabled, + "Wait for Accept button to get enabled" + ); +} + +async function waitForPdfJS(browser, url) { + // Runs tests after all "load" event handlers have fired off + let loadPromise = BrowserTestUtils.waitForContentEvent( + browser, + "documentloaded", + false, + null, + true + ); + BrowserTestUtils.startLoadingURIString(browser, url); + return loadPromise; +} + +/** + * This test covers which choices are presented for downloaded files and how + * those choices are handled. Unless a pref is enabled + * (browser.download.always_ask_before_handling_new_types) the unknown content + * dialog will be skipped altogether by default when downloading. + * To retain coverage for the non-default scenario, each task sets `alwaysAskBeforeHandling` + * to true for the relevant mime-type and extensions. + */ +function alwaysAskForHandlingTypes(typeExtensions, ask = true) { + let mimeInfos = []; + for (let [type, ext] of Object.entries(typeExtensions)) { + const mimeInfo = MimeSvc.getFromTypeAndExtension(type, ext); + mimeInfo.alwaysAskBeforeHandling = ask; + if (!ask) { + mimeInfo.preferredAction = mimeInfo.handleInternally; + } + HandlerSvc.store(mimeInfo); + mimeInfos.push(mimeInfo); + } + return mimeInfos; +} + +add_setup(async function () { + // Remove the security delay for the dialog during the test. + await SpecialPowers.pushPrefEnv({ + set: [ + ["security.dialog_enable_delay", 0], + ["browser.helperApps.showOpenOptionForPdfJS", true], + ["browser.helperApps.showOpenOptionForViewableInternally", true], + ], + }); + + // Restore handlers after the whole test has run + const registerRestoreHandler = function (type, ext) { + const mimeInfo = MimeSvc.getFromTypeAndExtension(type, ext); + const existed = HandlerSvc.exists(mimeInfo); + + registerCleanupFunction(() => { + if (existed) { + HandlerSvc.store(mimeInfo); + } else { + HandlerSvc.remove(mimeInfo); + } + }); + }; + registerRestoreHandler("application/pdf", "pdf"); + registerRestoreHandler("binary/octet-stream", "pdf"); + registerRestoreHandler("application/unknown", "pdf"); + registerRestoreHandler("image/webp", "webp"); +}); + +/** + * Check that loading a PDF file with content-disposition: attachment + * shows an option to open with the internal handler, and that the + * internal option handler is not present when the download button + * is clicked from pdf.js. + */ +add_task(async function test_check_open_with_internal_handler() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "application/pdf": "pdf", + "binary/octet-stream": "pdf", + }); + + for (let file of [ + "file_pdf_application_pdf.pdf", + "file_pdf_binary_octet_stream.pdf", + ]) { + info("Testing with " + file); + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + // Add an extra tab after the loading tab so we can test that + // pdf.js is opened in the adjacent tab and not at the end of + // the tab strip. + let extraTab = await BrowserTestUtils.addTab(gBrowser, "about:blank"); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + let internalHandlerRadio = doc.querySelector("#handleInternally"); + + await waitForAcceptButtonToGetEnabled(doc); + + ok(!internalHandlerRadio.hidden, "The option should be visible for PDF"); + ok(internalHandlerRadio.selected, "The option should be selected"); + + let downloadFinishedPromise = promiseDownloadFinished(publicList); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + let dialog = doc.querySelector("#unknownContentType"); + let button = dialog.getButton("accept"); + button.disabled = false; + dialog.acceptDialog(); + info("waiting for new tab to open"); + let newTab = await newTabPromise; + + is( + newTab._tPos - 1, + loadingTab._tPos, + "pdf.js should be opened in an adjacent tab" + ); + + await ContentTask.spawn(newTab.linkedBrowser, null, async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.readyState == "complete" + ); + }); + + let publicDownloads = await publicList.getAll(); + is( + publicDownloads.length, + 1, + "download should appear in publicDownloads list" + ); + + let download = await downloadFinishedPromise; + + let subdialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + // Current tab has file: URI and TEST_PATH is http uri, so uri will be different + BrowserTestUtils.startLoadingURIString( + newTab.linkedBrowser, + TEST_PATH + file + ); + let subDialogWindow = await subdialogPromise; + let subDoc = subDialogWindow.document; + + // Prevent racing with initialization of the dialog and make sure that + // the final state of the dialog has the correct visibility of the internal-handler option. + await waitForAcceptButtonToGetEnabled(subDoc); + let subInternalHandlerRadio = subDoc.querySelector("#handleInternally"); + ok( + !subInternalHandlerRadio.hidden, + "This option should be shown when the dialog is shown for another PDF" + ); + // Cancel dialog + subDoc.querySelector("#unknownContentType").cancelDialog(); + + let filepickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(() => { + resolve(fp.defaultString); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + }); + + subdialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + await SpecialPowers.spawn(newTab.linkedBrowser, [], async () => { + let downloadButton; + await ContentTaskUtils.waitForCondition(() => { + downloadButton = content.document.querySelector("#download"); + return !!downloadButton; + }); + ok(downloadButton, "Download button should be present in pdf.js"); + downloadButton.click(); + }); + info( + "Waiting for unknown content type dialog to appear from pdf.js download button click" + ); + let filename = await filepickerPromise; + is(filename, file, "filename was set in filepicker"); + + // Remove the first file (can't do this sooner or the second load fails): + if (download?.target.exists) { + try { + info("removing " + download.target.path); + await IOUtils.remove(download.target.path); + } catch (ex) { + /* ignore */ + } + } + + BrowserTestUtils.removeTab(loadingTab); + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(extraTab); + + await publicList.removeFinished(); + } + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +/** + * Test that choosing to open in an external application doesn't + * open the PDF into pdf.js + */ +add_task(async function test_check_open_with_external_application() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "application/pdf": "pdf", + "binary/octet-stream": "pdf", + }); + + for (let file of [ + "file_pdf_application_pdf.pdf", + "file_pdf_binary_octet_stream.pdf", + ]) { + info("Testing with " + file); + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + let oldLaunchFile = DownloadIntegration.launchFile; + let waitForLaunchFileCalled = new Promise(resolve => { + DownloadIntegration.launchFile = () => { + ok(true, "The file should be launched with an external application"); + resolve(); + }; + }); + + let doc = dialogWindow.document; + await waitForAcceptButtonToGetEnabled(doc); + let dialog = doc.querySelector("#unknownContentType"); + doc.querySelector("#open").click(); + let button = dialog.getButton("accept"); + button.disabled = false; + info("Accepting the dialog"); + dialog.acceptDialog(); + info("Waiting until DownloadIntegration.launchFile is called"); + await waitForLaunchFileCalled; + DownloadIntegration.launchFile = oldLaunchFile; + + let publicDownloads = await publicList.getAll(); + is( + publicDownloads.length, + 1, + "download should appear in publicDownloads list" + ); + let download = publicDownloads[0]; + ok( + !download.launchWhenSucceeded, + "launchWhenSucceeded should be false after launchFile is called" + ); + + BrowserTestUtils.removeTab(loadingTab); + if (download?.target.exists) { + try { + info("removing " + download.target.path); + await IOUtils.remove(download.target.path); + } catch (ex) { + /* ignore */ + } + } + await publicList.removeFinished(); + } + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +/** + * Test that choosing to open a PDF with an external application works and + * then downloading the same file again and choosing Open with Firefox opens + * the download in Firefox. + */ +add_task(async function test_check_open_with_external_then_internal() { + // This test only runs on Windows because appPicker.xhtml is only used on Windows. + if (AppConstants.platform != "win") { + return; + } + + // This test covers a bug that only occurs when the mimeInfo is set to Always Ask + const mimeInfo = MimeSvc.getFromTypeAndExtension("application/pdf", "pdf"); + console.log( + "mimeInfo.preferredAction is currently:", + mimeInfo.preferredAction + ); + mimeInfo.preferredAction = mimeInfo.alwaysAsk; + mimeInfo.alwaysAskBeforeHandling = true; + HandlerSvc.store(mimeInfo); + + for (let [file, mimeType] of [ + ["file_pdf_application_pdf.pdf", "application/pdf"], + ["file_pdf_binary_octet_stream.pdf", "binary/octet-stream"], + ["file_pdf_application_unknown.pdf", "application/unknown"], + ]) { + info("Testing with " + file); + let originalMimeInfo = MimeSvc.getFromTypeAndExtension(mimeType, "pdf"); + + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + // Open a new tab to the PDF file which will trigger the Unknown Content Type dialog + // and choose to open the PDF with an external application. + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + let oldLaunchFile = DownloadIntegration.launchFile; + let waitForLaunchFileCalled = new Promise(resolve => { + DownloadIntegration.launchFile = () => { + ok(true, "The file should be launched with an external application"); + resolve(); + }; + }); + + let doc = dialogWindow.document; + await waitForAcceptButtonToGetEnabled(doc); + let dialog = doc.querySelector("#unknownContentType"); + let openHandlerMenulist = doc.querySelector("#openHandler"); + let originalDefaultHandler = openHandlerMenulist.label; + doc.querySelector("#open").click(); + doc.querySelector("#openHandlerPopup").click(); + let oldOpenDialog = dialogWindow.openDialog; + dialogWindow.openDialog = (location, unused2, unused3, params) => { + is(location, "chrome://global/content/appPicker.xhtml", "app picker"); + let handlerApp = params.mimeInfo.possibleLocalHandlers.queryElementAt( + 0, + Ci.nsILocalHandlerApp + ); + ok(handlerApp.executable, "handlerApp should be executable"); + ok(handlerApp.executable.isFile(), "handlerApp should be a file"); + params.handlerApp = handlerApp; + }; + doc.querySelector("#choose").click(); + dialogWindow.openDialog = oldOpenDialog; + await TestUtils.waitForCondition( + () => originalDefaultHandler != openHandlerMenulist.label, + "waiting for openHandler to get updated" + ); + let newDefaultHandler = openHandlerMenulist.label; + info(`was ${originalDefaultHandler}, now ${newDefaultHandler}`); + let button = dialog.getButton("accept"); + button.disabled = false; + info("Accepting the dialog"); + dialog.acceptDialog(); + info("Waiting until DownloadIntegration.launchFile is called"); + await waitForLaunchFileCalled; + BrowserTestUtils.removeTab(loadingTab); + + // Now, open a new tab to the PDF file which will trigger the Unknown Content Type dialog + // and choose to open the PDF internally. The previously used external application should be shown as + // the external option. + dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + DownloadIntegration.launchFile = () => { + ok(false, "The file should not be launched with an external application"); + }; + + doc = dialogWindow.document; + await waitForAcceptButtonToGetEnabled(doc); + openHandlerMenulist = doc.querySelector("#openHandler"); + is(openHandlerMenulist.label, newDefaultHandler, "'new' handler"); + dialog = doc.querySelector("#unknownContentType"); + doc.querySelector("#handleInternally").click(); + info("Accepting the dialog"); + button = dialog.getButton("accept"); + button.disabled = false; + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + dialog.acceptDialog(); + + info("waiting for new tab to open"); + let newTab = await newTabPromise; + + await ContentTask.spawn(newTab.linkedBrowser, null, async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.readyState == "complete" + ); + }); + + is( + newTab.linkedBrowser.contentPrincipal.origin, + "resource://pdf.js", + "PDF should be opened with pdf.js" + ); + + BrowserTestUtils.removeTab(loadingTab); + BrowserTestUtils.removeTab(newTab); + + // Now trigger the dialog again and select the system + // default option to reset the state for the next iteration of the test. + // Reset the state for the next iteration of the test. + HandlerSvc.store(originalMimeInfo); + DownloadIntegration.launchFile = oldLaunchFile; + let [download] = await publicList.getAll(); + if (download?.target.exists) { + try { + info("removing " + download.target.path); + await IOUtils.remove(download.target.path); + } catch (ex) { + /* ignore */ + } + } + await publicList.removeFinished(); + } +}); + +/** + * Check that the "Open with internal handler" option is presented + * for other viewable internally types. + */ +add_task( + async function test_internal_handler_hidden_with_viewable_internally_type() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "binary/octet-stream": "xml", + "image/webp": "webp", + }); + + for (let [file, checkDefault] of [ + // The default for binary/octet-stream is changed by the PDF tests above, + // this may change given bug 1659008, so I'm just ignoring the default for now. + ["file_xml_attachment_binary_octet_stream.xml", false], + ["file_green.webp", true], + ]) { + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + let internalHandlerRadio = doc.querySelector("#handleInternally"); + + // Prevent racing with initialization of the dialog and make sure that + // the final state of the dialog has the correct visibility of the internal-handler option. + await waitForAcceptButtonToGetEnabled(doc); + + let fileDesc = file.substring(file.lastIndexOf(".") + 1); + + ok( + !internalHandlerRadio.hidden, + `The option should be visible for ${fileDesc}` + ); + if (checkDefault) { + ok( + internalHandlerRadio.selected, + `The option should be selected for ${fileDesc}` + ); + } + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + } + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } + } +); + +/** + * Check that the "Open with internal handler" option is not presented + * for non-PDF, non-viewable-internally types. + */ +add_task(async function test_internal_handler_hidden_with_other_type() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "text/plain": "txt", + }); + + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_txt_attachment_test.txt", + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + + // Prevent racing with initialization of the dialog and make sure that + // the final state of the dialog has the correct visibility of the internal-handler option. + await waitForAcceptButtonToGetEnabled(doc); + + let internalHandlerRadio = doc.querySelector("#handleInternally"); + ok( + internalHandlerRadio.hidden, + "The option should be hidden for unknown file type" + ); + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +/** + * Check that the "Open with internal handler" option is not presented + * when the feature is disabled for PDFs. + */ +add_task(async function test_internal_handler_hidden_with_pdf_pref_disabled() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "application/pdf": "pdf", + "binary/octet-stream": "pdf", + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.helperApps.showOpenOptionForPdfJS", false]], + }); + for (let file of [ + "file_pdf_application_pdf.pdf", + "file_pdf_binary_octet_stream.pdf", + ]) { + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + + await waitForAcceptButtonToGetEnabled(doc); + + let internalHandlerRadio = doc.querySelector("#handleInternally"); + ok( + internalHandlerRadio.hidden, + "The option should be hidden for PDF when the pref is false" + ); + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + } + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +/** + * Check that the "Open with internal handler" option is not presented + * for other viewable internally types when disabled. + */ +add_task( + async function test_internal_handler_hidden_with_viewable_internally_pref_disabled() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "text/xml": "xml", + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.helperApps.showOpenOptionForViewableInternally", false]], + }); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_xml_attachment_test.xml", + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + + await waitForAcceptButtonToGetEnabled(doc); + + let internalHandlerRadio = doc.querySelector("#handleInternally"); + ok( + internalHandlerRadio.hidden, + "The option should be hidden for XML when the pref is false" + ); + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } + } +); + +/* + * This test sets the action to internal. The files should open directly without asking. + */ +add_task(async function test_check_open_with_internal_handler_noask() { + const mimeInfosToRestore = alwaysAskForHandlingTypes( + { + "application/pdf": "pdf", + "binary/octet-stream": "pdf", + "application/octet-stream": "pdf", + }, + false + ); + + // Build the matrix of tests to perform. + let matrix = { + alwaysOpenPDFInline: [false, true], + file: [ + "file_pdf_application_pdf.pdf", + "file_pdf_binary_octet_stream.pdf", + "file_pdf_application_octet_stream.pdf", + ], + where: ["top", "popup", "frame"], + }; + let tests = [{}]; + for (let [key, values] of Object.entries(matrix)) { + tests = tests.flatMap(test => + values.map(value => ({ [key]: value, ...test })) + ); + } + + for (let test of tests) { + info(`test case: ${JSON.stringify(test)}`); + let { alwaysOpenPDFInline, file, where } = test; + + // These are the cases that can be opened inline. binary/octet-stream + // isn't handled by pdfjs. + let canHandleInline = + file == "file_pdf_application_pdf.pdf" || + (file == "file_pdf_application_octet_stream.pdf" && where != "frame"); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.helperApps.showOpenOptionForPdfJS", true], + ["browser.helperApps.showOpenOptionForViewableInternally", true], + ["browser.download.open_pdf_attachments_inline", alwaysOpenPDFInline], + ], + }); + + async function doNavigate(browser) { + await SpecialPowers.spawn( + browser, + [TEST_PATH + file, where], + async (contentUrl, where_) => { + switch (where_) { + case "top": + content.location = contentUrl; + break; + case "popup": + content.open(contentUrl); + break; + case "frame": + let frame = content.document.createElement("iframe"); + frame.setAttribute("src", contentUrl); + content.document.body.appendChild(frame); + break; + default: + ok(false, "Unknown where value"); + break; + } + } + ); + } + + // If this is true, the pdf is opened directly without downloading it. + // Otherwise, it must first be downloaded and optionally displayed in + // a tab with a file url. + let openPDFDirectly = alwaysOpenPDFInline && canHandleInline; + + await BrowserTestUtils.withNewTab( + { gBrowser, url: TEST_PATH + "blank.html" }, + async browser => { + let readyPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + null, + false, + !openPDFDirectly + ); + + await doNavigate(browser); + + await readyPromise; + + is( + gBrowser.selectedBrowser.currentURI.scheme, + openPDFDirectly ? "https" : "file", + "Loaded PDF uri has the correct scheme" + ); + + // intentionally don't bother checking session history without ship to + // keep complexity down. + if (Services.appinfo.sessionHistoryInParent) { + let shistory = browser.browsingContext.sessionHistory; + is(shistory.count, 1, "should a single shentry"); + is(shistory.index, 0, "should be on the first entry"); + let shentry = shistory.getEntryAtIndex(shistory.index); + is(shentry.URI.spec, TEST_PATH + "blank.html"); + } + + await SpecialPowers.spawn( + browser, + [TEST_PATH + "blank.html"], + async blankUrl => { + ok( + !docShell.isAttemptingToNavigate, + "should not still be attempting to navigate" + ); + is( + content.location.href, + blankUrl, + "original browser hasn't navigated" + ); + } + ); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + ); + } + + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +add_task(async () => { + MockFilePicker.cleanup(); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js b/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js new file mode 100644 index 0000000000..b66266ec9e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js @@ -0,0 +1,299 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); +const { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +const gHandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" +].getService(Ci.nsIHandlerService); +const gMIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +const localHandlerAppFactory = Cc["@mozilla.org/uriloader/local-handler-app;1"]; + +const ROOT_URL = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const FILE_TYPES_MIME_SETTINGS = {}; + +// File types to test +const FILE_TYPES_TO_TEST = [ + // application/ms-word files cannot render in the browser so + // handleInternally does not work for it + { + extension: "doc", + mimeType: "application/ms-word", + blockHandleInternally: true, + }, + { + extension: "pdf", + mimeType: "application/pdf", + }, + { + extension: "pdf", + mimeType: "application/unknown", + }, + { + extension: "pdf", + mimeType: "binary/octet-stream", + }, + // text/plain files automatically render in the browser unless + // the CD header explicitly tells the browser to download it + { + extension: "txt", + mimeType: "text/plain", + requireContentDispositionHeader: true, + }, + { + extension: "xml", + mimeType: "binary/octet-stream", + }, +].map(file => { + return { + ...file, + url: `${ROOT_URL}mime_type_download.sjs?contentType=${file.mimeType}&extension=${file.extension}`, + }; +}); + +// Preferred action types to apply to each downloaded file +const PREFERRED_ACTIONS = [ + "saveToDisk", + "alwaysAsk", + "useHelperApp", + "handleInternally", + "useSystemDefault", +].map(property => { + let label = property.replace(/([A-Z])/g, " $1"); + label = label.charAt(0).toUpperCase() + label.slice(1); + return { + id: Ci.nsIHandlerInfo[property], + label, + }; +}); + +async function createDownloadTest( + downloadList, + localHandlerApp, + file, + action, + useContentDispositionHeader +) { + // Skip handleInternally case for files that cannot be handled internally + if ( + action.id === Ci.nsIHandlerInfo.handleInternally && + file.blockHandleInternally + ) { + return; + } + let skipDownload = + action.id === Ci.nsIHandlerInfo.handleInternally && + file.mimeType === "application/pdf"; + // Types that require the CD header only display as handleInternally + // when the CD header is missing + if (file.requireContentDispositionHeader && !useContentDispositionHeader) { + if (action.id === Ci.nsIHandlerInfo.handleInternally) { + skipDownload = true; + } else { + return; + } + } + info( + `Testing download with mime-type ${file.mimeType} and extension ${ + file.extension + }, preferred action "${action.label}," and ${ + useContentDispositionHeader + ? "Content-Disposition: attachment" + : "no Content-Disposition" + } header.` + ); + info("Preparing for download..."); + // apply preferredAction settings + let mimeSettings = gMIMEService.getFromTypeAndExtension( + file.mimeType, + file.extension + ); + mimeSettings.preferredAction = action.id; + mimeSettings.alwaysAskBeforeHandling = + action.id === Ci.nsIHandlerInfo.alwaysAsk; + if (action.id === Ci.nsIHandlerInfo.useHelperApp) { + mimeSettings.preferredApplicationHandler = localHandlerApp; + } + gHandlerService.store(mimeSettings); + // delayed check for files opened in a new tab, except for skipped downloads + let expectViewInBrowserTab = + action.id === Ci.nsIHandlerInfo.handleInternally && !skipDownload; + let viewInBrowserTabOpened = null; + if (expectViewInBrowserTab) { + viewInBrowserTabOpened = BrowserTestUtils.waitForNewTab( + gBrowser, + uri => uri.includes("file://"), + true + ); + } + // delayed check for launched files + let expectLaunch = + action.id === Ci.nsIHandlerInfo.useSystemDefault || + action.id === Ci.nsIHandlerInfo.useHelperApp; + let oldLaunchFile = DownloadIntegration.launchFile; + let fileLaunched = null; + if (expectLaunch) { + fileLaunched = Promise.withResolvers(); + DownloadIntegration.launchFile = () => { + ok( + expectLaunch, + `The file ${file.mimeType} should be launched with an external application.` + ); + fileLaunched.resolve(); + }; + } + info(`Start download of ${file.url}`); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let downloadFinishedPromise = skipDownload + ? null + : promiseDownloadFinished(downloadList); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, file.url); + if (action.id === Ci.nsIHandlerInfo.alwaysAsk) { + info("Check Always Ask dialog."); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should show unknownContentType dialog for Always Ask preferred actions." + ); + let doc = dialogWindow.document; + let dialog = doc.querySelector("#unknownContentType"); + let acceptButton = dialog.getButton("accept"); + acceptButton.disabled = false; + let saveItem = doc.querySelector("#save"); + saveItem.disabled = false; + saveItem.click(); + dialog.acceptDialog(); + } + let download = null; + let downloadPath = null; + if (!skipDownload) { + info("Wait for download to finish..."); + download = await downloadFinishedPromise; + downloadPath = download.target.path; + } + // check delayed assertions + if (expectLaunch) { + info("Wait for file to be launched in external application..."); + await fileLaunched.promise; + } + if (expectViewInBrowserTab) { + info("Wait for file to be opened in new tab..."); + let viewInBrowserTab = await viewInBrowserTabOpened; + ok( + viewInBrowserTab, + `The file ${file.mimeType} should be opened in a new tab.` + ); + BrowserTestUtils.removeTab(viewInBrowserTab); + } + info("Checking for saved file..."); + let saveFound = downloadPath && (await IOUtils.exists(downloadPath)); + info("Cleaning up..."); + if (saveFound) { + try { + info(`Deleting file ${downloadPath}...`); + await IOUtils.remove(downloadPath); + } catch (ex) { + info(`Error: ${ex}`); + } + } + info("Removing download from list..."); + await downloadList.removeFinished(); + info("Clearing settings..."); + DownloadIntegration.launchFile = oldLaunchFile; + info("Asserting results..."); + if (download) { + ok(download.succeeded, "Download should complete successfully"); + ok( + !download._launchedFromPanel, + "Download should never be launched from panel" + ); + } + if (skipDownload) { + ok(!saveFound, "Download should not be saved to disk"); + } else { + ok(saveFound, "Download should be saved to disk"); + } +} + +add_task(async function test_download_preferred_action() { + // Prepare tests + for (const index in FILE_TYPES_TO_TEST) { + let file = FILE_TYPES_TO_TEST[index]; + let originalMimeSettings = gMIMEService.getFromTypeAndExtension( + file.mimeType, + file.extension + ); + if (gHandlerService.exists(originalMimeSettings)) { + FILE_TYPES_MIME_SETTINGS[index] = originalMimeSettings; + } + } + let downloadList = await Downloads.getList(Downloads.PUBLIC); + let oldLaunchFile = DownloadIntegration.launchFile; + registerCleanupFunction(async function () { + await removeAllDownloads(); + DownloadIntegration.launchFile = oldLaunchFile; + Services.prefs.clearUserPref( + "browser.download.always_ask_before_handling_new_types" + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:home" + ); + for (const index in FILE_TYPES_TO_TEST) { + let file = FILE_TYPES_TO_TEST[index]; + let mimeSettings = gMIMEService.getFromTypeAndExtension( + file.mimeType, + file.extension + ); + if (FILE_TYPES_MIME_SETTINGS[index]) { + gHandlerService.store(FILE_TYPES_MIME_SETTINGS[index]); + } else { + gHandlerService.remove(mimeSettings); + } + } + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", false]], + }); + let launcherPath = FileTestUtils.getTempFile("app-launcher").path; + let localHandlerApp = localHandlerAppFactory.createInstance( + Ci.nsILocalHandlerApp + ); + localHandlerApp.executable = new FileUtils.File(launcherPath); + localHandlerApp.executable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755); + // run tests + for (const file of FILE_TYPES_TO_TEST) { + // The CD header specifies the download file extension on download + let fileNoHeader = file; + let fileWithHeader = structuredClone(file); + fileWithHeader.url += "&withHeader"; + for (const action of PREFERRED_ACTIONS) { + // Clone file objects to prevent side-effects between iterations + await createDownloadTest( + downloadList, + localHandlerApp, + structuredClone(fileWithHeader), + action, + true + ); + await createDownloadTest( + downloadList, + localHandlerApp, + structuredClone(fileNoHeader), + action, + false + ); + } + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js b/uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js new file mode 100644 index 0000000000..8ded5e6401 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that downloads started from a private window by clicking on a link end + * up in the global list of private downloads (see bug 1367581). + */ + +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); +const { DownloadPaths } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadPaths.sys.mjs" +); +const { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +add_task(async function test_setup() { + // Save downloads to disk without showing the dialog. + let cid = MockRegistrar.register("@mozilla.org/helperapplauncherdialog;1", { + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]), + show(launcher) { + launcher.promptForSaveDestination(); + }, + promptForSaveToFileAsync(launcher) { + // The dialog should create the empty placeholder file. + let file = FileTestUtils.getTempFile(); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + launcher.saveDestinationAvailable(file); + }, + }); + registerCleanupFunction(() => { + MockRegistrar.unregister(cid); + }); +}); + +add_task(async function test_download_privatebrowsing() { + let privateList = await Downloads.getList(Downloads.PRIVATE); + let publicList = await Downloads.getList(Downloads.PUBLIC); + + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + try { + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + `data:text/html,<a download href="data:text/plain,">download</a>` + ); + + let promiseNextPrivateDownload = new Promise(resolve => { + privateList.addView({ + onDownloadAdded(download) { + privateList.removeView(this); + resolve(download); + }, + }); + }); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.document.querySelector("a").click(); + }); + + // Wait for the download to finish so the file can be safely deleted later. + let download = await promiseNextPrivateDownload; + await download.whenSucceeded(); + + // Clean up after checking that there are no new public downloads either. + let publicDownloads = await publicList.getAll(); + Assert.equal(publicDownloads.length, 0); + await privateList.removeFinished(); + } finally { + await BrowserTestUtils.closeWindow(win); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js b/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js new file mode 100644 index 0000000000..0b8c816d2e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js @@ -0,0 +1,59 @@ +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +// New file is being downloaded and no dialogs are shown in the way. +add_task(async function skipDialogAndDownloadFile() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.useDownloadDir", true], + ], + }); + + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let downloadFinishedPromise = promiseDownloadFinished(publicList); + + let initialTabsCount = gBrowser.tabs.length; + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_green.webp", + waitForLoad: false, + waitForStateStop: true, + }); + + // We just open the file to be downloaded... and wait for it to be downloaded! + // We see no dialogs to be accepted in the process. + let download = await downloadFinishedPromise; + await BrowserTestUtils.waitForCondition( + () => gBrowser.tabs.length == initialTabsCount + 2 + ); + + gBrowser.removeCurrentTab(); + BrowserTestUtils.removeTab(loadingTab); + + Assert.ok( + await IOUtils.exists(download.target.path), + "The file should have been downloaded." + ); + + try { + info("removing " + download.target.path); + if (Services.appinfo.OS === "WINNT") { + // We need to make the file writable to delete it on Windows. + await IOUtils.setPermissions(download.target.path, 0o600); + } + await IOUtils.remove(download.target.path); + } catch (ex) { + info("The file " + download.target.path + " is not removed, " + ex); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js b/uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js new file mode 100644 index 0000000000..0f80af5ac5 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js @@ -0,0 +1,158 @@ +/* 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/. */ + +"use strict"; + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +const TEST_URI = "https://example.com"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + TEST_URI +); + +const AUTOMATIC_DOWNLOAD_TOPIC = "blocked-automatic-download"; + +var MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); +registerCleanupFunction(() => MockFilePicker.cleanup()); + +let gTempDownloadDir; + +add_setup(async function () { + // Create temp directory + let time = new Date().getTime(); + let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append(time); + gTempDownloadDir = tempDir; + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir); + + PermissionTestUtils.add( + TEST_URI, + "automatic-download", + Services.perms.UNKNOWN_ACTION + ); + await SpecialPowers.pushPrefEnv({ + set: [ + // We disable browser.download.always_ask_before_handling_new_types here + // since the test expects the download to be saved directly to disk and + // not prompted by the UnknownContentType window. + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.enable_spam_prevention", true], + ], + }); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + await IOUtils.remove(tempDir.path, { recursive: true }); + }); +}); + +add_task(async function check_download_spam_permissions() { + const INITIAL_TABS_COUNT = gBrowser.tabs.length; + let publicList = await Downloads.getList(Downloads.PUBLIC); + let downloadFinishedPromise = promiseDownloadFinished( + publicList, + true /* stop the download from openning */ + ); + let blockedDownloadsCount = 0; + let blockedDownloadsURI = ""; + let automaticDownloadObserver = { + observe: function automatic_download_observe(aSubject, aTopic, aData) { + if (aTopic === AUTOMATIC_DOWNLOAD_TOPIC) { + blockedDownloadsCount++; + blockedDownloadsURI = aData; + } + }, + }; + Services.obs.addObserver(automaticDownloadObserver, AUTOMATIC_DOWNLOAD_TOPIC); + + let newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PATH + "test_spammy_page.html" + ); + registerCleanupFunction(async () => { + DownloadIntegration.downloadSpamProtection.removeDownloadSpamForWindow( + TEST_URI, + window + ); + DownloadsPanel.hidePanel(); + await publicList.removeFinished(); + BrowserTestUtils.removeTab(newTab); + Services.obs.removeObserver( + automaticDownloadObserver, + AUTOMATIC_DOWNLOAD_TOPIC + ); + }); + + let download = await downloadFinishedPromise; + await TestUtils.waitForCondition( + () => gBrowser.tabs.length == INITIAL_TABS_COUNT + 1 + ); + is( + PermissionTestUtils.testPermission(TEST_URI, "automatic-download"), + Services.perms.PROMPT_ACTION, + "The permission to prompt the user should be stored." + ); + + ok( + await IOUtils.exists(download.target.path), + "One file should be downloaded" + ); + + let aCopyFilePath = download.target.path.replace(".pdf", "(1).pdf"); + is( + await IOUtils.exists(aCopyFilePath), + false, + "An other file should be blocked" + ); + + info("Will wait for blockedDownloadsCount to be >= 99"); + await TestUtils.waitForCondition(() => blockedDownloadsCount >= 99); + is(blockedDownloadsCount, 99, "Browser should block 99 downloads"); + is( + blockedDownloadsURI, + TEST_URI, + "The test URI should have blocked automatic downloads" + ); + + await savelink(); +}); + +// Check to ensure that a link saved manually is not blocked. +async function savelink() { + let publicList = await Downloads.getList(Downloads.PUBLIC); + let menu = document.getElementById("contentAreaContextMenu"); + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + "#image", + 5, + 5, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShown; + + await new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + resolve(); + let file = gTempDownloadDir.clone(); + file.append("file_with__funny_name.png"); + MockFilePicker.setFiles([file]); + return Ci.nsIFilePicker.returnOK; + }; + let menuitem = menu.querySelector("#context-savelink"); + menu.activateItem(menuitem); + }); + + await promiseDownloadFinished( + publicList, + true // stop the download from openning + ); +} diff --git a/uriloader/exthandler/tests/mochitest/browser_download_urlescape.js b/uriloader/exthandler/tests/mochitest/browser_download_urlescape.js new file mode 100644 index 0000000000..ababcc095c --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_urlescape.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +var MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); +registerCleanupFunction(() => MockFilePicker.cleanup()); + +/** + * Check downloading files URL-escapes content-disposition + * information when necessary. + */ +add_task(async function test_check_filename_urlescape() { + let pendingPromise; + let pendingTest = ""; + let expectedFileName = ""; + MockFilePicker.showCallback = function (fp) { + info(`${pendingTest} - Filepicker shown, checking filename`); + is( + fp.defaultString, + expectedFileName, + `${pendingTest} - Should have escaped filename` + ); + ok( + pendingPromise, + `${pendingTest} - Should have expected this picker open.` + ); + if (pendingPromise) { + pendingPromise.resolve(); + } + return Ci.nsIFilePicker.returnCancel; + }; + function runTestFor(fileName, selector) { + return BrowserTestUtils.withNewTab(TEST_PATH + fileName, async browser => { + expectedFileName = fileName; + let tabLabel = gBrowser.getTabForBrowser(browser).getAttribute("label"); + ok( + tabLabel.startsWith(fileName), + `"${tabLabel}" should have been escaped.` + ); + + pendingTest = "save browser"; + pendingPromise = Promise.withResolvers(); + // First try to save the browser + saveBrowser(browser); + await pendingPromise.promise; + + // Next, try the context menu: + pendingTest = "save from context menu"; + pendingPromise = Promise.withResolvers(); + let menu = document.getElementById("contentAreaContextMenu"); + let menuShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + selector, + 5, + 5, + { type: "contextmenu", button: 2 }, + browser + ); + await menuShown; + gContextMenu.saveMedia(); + menu.hidePopup(); + await pendingPromise.promise; + pendingPromise = null; + }); + } + await runTestFor("file_with@@funny_name.png", "img"); + await runTestFor("file_with[funny_name.webm", "video"); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_extension_correction.js b/uriloader/exthandler/tests/mochitest/browser_extension_correction.js new file mode 100644 index 0000000000..9d67bf7213 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_extension_correction.js @@ -0,0 +1,222 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +let gPathsToRemove = []; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.useDownloadDir", true]], + }); + registerCleanupFunction(async () => { + for (let path of gPathsToRemove) { + // IOUtils.remove ignores non-existing files out of the box. + await IOUtils.remove(path); + } + let publicList = await Downloads.getList(Downloads.PUBLIC); + await publicList.removeFinished(); + }); +}); + +async function testLinkWithoutExtension(type, shouldHaveExtension) { + info("Checking " + type); + + let task = function () { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [type], mimetype => { + let link = content.document.createElement("a"); + link.textContent = "Click me"; + link.href = "data:" + mimetype + ",hello"; + link.download = "somefile"; + content.document.body.appendChild(link); + link.click(); + }); + }; + + await checkDownloadWithExtensionState(task, { + type, + shouldHaveExtension, + alwaysViewPDFInline: false, + }); + + if (type == "application/pdf") { + // For PDF, try again with the always open inline preference set + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.open_pdf_attachments_inline", true]], + }); + + await checkDownloadWithExtensionState(task, { + type, + shouldHaveExtension, + alwaysViewPDFInline: true, + }); + + await SpecialPowers.popPrefEnv(); + } +} + +async function checkDownloadWithExtensionState( + task, + { type, shouldHaveExtension, expectedName = null, alwaysViewPDFInline } +) { + const shouldExpectDialog = Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types", + false + ); + + let winPromise; + if (shouldExpectDialog) { + winPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + } + + let publicList = await Downloads.getList(Downloads.PUBLIC); + let shouldCheckFilename = shouldHaveExtension || !shouldExpectDialog; + + let downloadFinishedPromise = shouldCheckFilename + ? promiseDownloadFinished(publicList) + : null; + + // PDF should load using the internal viewer without downloading it. + let waitForLoad; + if ( + (!shouldExpectDialog || alwaysViewPDFInline) && + type == "application/pdf" + ) { + waitForLoad = BrowserTestUtils.waitForNewTab(gBrowser); + } + + await task(); + await waitForLoad; + + let win; + if (shouldExpectDialog) { + info("Waiting for dialog."); + win = await winPromise; + } + + expectedName ??= shouldHaveExtension + ? "somefile." + getMIMEInfoForType(type).primaryExtension + : "somefile"; + + let closedPromise = true; + if (shouldExpectDialog) { + let actualName = win.document.getElementById("location").value; + closedPromise = BrowserTestUtils.windowClosed(win); + + if (shouldHaveExtension) { + is(actualName, expectedName, `${type} should get an extension`); + } else { + is(actualName, expectedName, `${type} should not get an extension`); + } + } + + if (shouldExpectDialog && shouldHaveExtension) { + // Then pick "save" in the dialog, if we have a dialog. + let dialog = win.document.getElementById("unknownContentType"); + win.document.getElementById("save").click(); + let button = dialog.getButton("accept"); + button.disabled = false; + dialog.acceptDialog(); + } + + if (!shouldExpectDialog && type == "application/pdf") { + if (alwaysViewPDFInline) { + is( + gURLBar.inputField.value, + "data:application/pdf,hello", + "url is correct for " + type + ); + } else { + ok( + gURLBar.inputField.value.startsWith("file://") && + gURLBar.inputField.value.endsWith("somefile.pdf"), + "url is correct for " + type + ); + } + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + + if (shouldExpectDialog || !alwaysViewPDFInline || type != "application/pdf") { + // Wait for the download if it exists (may produce null). + let download = await downloadFinishedPromise; + if (download) { + // Check the download's extension is correct. + is( + PathUtils.filename(download.target.path), + expectedName, + `Downloaded file should match ${expectedName}` + ); + gPathsToRemove.push(download.target.path); + let pathToRemove = download.target.path; + // Avoid one file interfering with subsequent files. + await publicList.removeFinished(); + await IOUtils.remove(pathToRemove); + } else if (win) { + // We just cancel out for files that would end up without a path, as we'd + // prompt for a filename. + win.close(); + } + } + + return closedPromise; +} + +/** + * Check that for document types, images, videos and audio files, + * we enforce a useful extension. + */ +add_task(async function test_enforce_useful_extension() { + await BrowserTestUtils.withNewTab("data:text/html,", async browser => { + await testLinkWithoutExtension("image/png", true); + await testLinkWithoutExtension("audio/ogg", true); + await testLinkWithoutExtension("video/webm", true); + await testLinkWithoutExtension("application/pdf", true); + + await testLinkWithoutExtension("application/x-nonsense", false); + await testLinkWithoutExtension("application/octet-stream", false); + await testLinkWithoutExtension("binary/octet-stream", false); + await testLinkWithoutExtension("application/x-msdownload", false); + }); +}); + +/** + * Check that we still use URL extension info when we don't have anything else, + * despite bogus local info. + */ +add_task(async function test_broken_saved_handlerinfo_and_useless_mimetypes() { + let bogusType = getMIMEInfoForType("binary/octet-stream"); + registerCleanupFunction(() => { + handlerSvc.remove(bogusType); + }); + bogusType.setFileExtensions(["jpg"]); + let handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + handlerSvc.store(bogusType); + let tabToClean = null; + let task = function () { + return BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_as.exe?foo=bar", + waitForLoad: false, + waitForStateStop: true, + }).then(tab => { + return (tabToClean = tab); + }); + }; + await checkDownloadWithExtensionState(task, { + type: "binary/octet-stream", + shouldHaveExtension: true, + expectedName: "file_as.exe", + }); + // Downloads should really close their tabs... + if (tabToClean?.isConnected) { + BrowserTestUtils.removeTab(tabToClean); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js b/uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js new file mode 100644 index 0000000000..0aabb222d9 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * If the user has set Firefox itself as a helper app, + * we should force prompting what to do, rather than ending up + * in an infinite loop. + * In an ideal world, we'd also test the case where we are the OS + * default handler app, but that would require test infrastructure + * to make ourselves the OS default (or at least fool ourselves into + * believing we are) which we don't have... + */ +add_task(async function test_helperapp() { + // Set up the test infrastructure: + const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + let handlerInfo = mimeSvc.getFromTypeAndExtension("application/x-foo", "foo"); + registerCleanupFunction(() => { + handlerSvc.remove(handlerInfo); + }); + // Say we want to use a specific app: + handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + handlerInfo.alwaysAskBeforeHandling = false; + + // Say it's us: + let selfFile = Services.dirsvc.get("XREExeF", Ci.nsIFile); + // Make sure it's the .app + if (AppConstants.platform == "macosx") { + while ( + !selfFile.leafName.endsWith(".app") && + !selfFile.leafName.endsWith(".app/") + ) { + selfFile = selfFile.parent; + } + } + let selfHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + selfHandlerApp.executable = selfFile; + handlerInfo.possibleApplicationHandlers.appendElement(selfHandlerApp); + handlerInfo.preferredApplicationHandler = selfHandlerApp; + handlerSvc.store(handlerInfo); + + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Now, do some safety stubbing. If we do end up recursing we spawn + // infinite tabs. We definitely don't want that. Avoid it by stubbing + // our external URL handling bits: + let oldAddTab = gBrowser.addTab; + registerCleanupFunction(() => (gBrowser.addTab = oldAddTab)); + let wrongThingHappenedPromise = new Promise(resolve => { + gBrowser.addTab = function (aURI) { + ok(false, "Tried to open unexpected URL in a tab: " + aURI); + resolve(null); + // Pass a dummy object to avoid upsetting BrowserContentHandler - + // if it thinks opening the tab failed, it tries to open a window instead, + // which we can't prevent as easily, and at which point we still end up + // with runaway tabs. + return {}; + }; + }); + + let askedUserPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + + info("Clicking a link that should open the unknown content type dialog"); + await SpecialPowers.spawn(browser, [], () => { + let link = content.document.createElement("a"); + link.download = "foo.foo"; + link.textContent = "Foo file"; + link.href = "data:application/x-foo,hello"; + content.document.body.append(link); + link.click(); + }); + let dialog = await Promise.race([ + wrongThingHappenedPromise, + askedUserPromise, + ]); + ok(dialog, "Should have gotten a dialog"); + Assert.stringContains( + dialog.document.location.href, + "unknownContentType", + "Should have opened correct dialog." + ); + + let closePromise = BrowserTestUtils.windowClosed(dialog); + dialog.close(); + await closePromise; + askedUserPromise = null; + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js b/uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js new file mode 100644 index 0000000000..df446f4c52 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +add_task(setupMailHandler); + +add_task(async function test_open_without_user_interaction() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.disable_open_during_load", true], + ["dom.block_external_protocol_in_iframes", true], + ["dom.delay.block_external_protocol_in_iframes.enabled", false], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + TEST_PATH + "file_external_protocol_iframe.html" + ); + + let dialog = await dialogWindowPromise; + ok(dialog, "Should show the dialog even without user interaction"); + + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + + // Adding another iframe without user interaction should be blocked. + let blockedWarning = new Promise(resolve => { + Services.console.registerListener(function onMessage(msg) { + let { message, logLevel } = msg; + if (logLevel != Ci.nsIConsoleMessage.warn) { + return; + } + if (!message.includes("Iframe with external protocol was blocked")) { + return; + } + Services.console.unregisterListener(onMessage); + resolve(); + }); + }); + + info("Adding another frame without user interaction"); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let frame = content.document.createElement("iframe"); + frame.src = "mailto:foo@baz.com"; + content.document.body.appendChild(frame); + }); + + await blockedWarning; + + info("Removing tab to close the dialog."); + gBrowser.removeTab(tab); + await dialogClosedPromise; +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js b/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js new file mode 100644 index 0000000000..6b7c70cc01 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +let testURL = + "https://example.com/browser/" + + "uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html"; + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.external_protocol_requires_permission", false]], + }); + + // Load a page registering a protocol handler. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, testURL); + await BrowserTestUtils.browserLoaded(browser, false, testURL); + + // Register the protocol handler by clicking the notificationbar button. + let notificationValue = "Protocol Registration: ftp"; + let getNotification = () => + gBrowser.getNotificationBox().getNotificationWithValue(notificationValue); + await BrowserTestUtils.waitForCondition(getNotification); + let notification = getNotification(); + let button = notification.buttonContainer.querySelector("button"); + ok(button, "got registration button"); + button.click(); + + // Set the new handler as default. + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("ftp"); + ok(!protoInfo.preferredApplicationHandler, "no preferred handler is set"); + let handlers = protoInfo.possibleApplicationHandlers; + is(1, handlers.length, "only one handler registered for ftp"); + let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp); + ok(handler instanceof Ci.nsIWebHandlerApp, "the handler is a web handler"); + is( + handler.uriTemplate, + "https://example.com/browser/uriloader/exthandler/tests/mochitest/blank.html?uri=%s", + "correct url template" + ); + protoInfo.preferredAction = protoInfo.useHelperApp; + protoInfo.preferredApplicationHandler = handler; + protoInfo.alwaysAskBeforeHandling = false; + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + handlerSvc.store(protoInfo); + + const expectedURL = + "https://example.com/browser/uriloader/exthandler/tests/mochitest/blank.html?uri=ftp%3A%2F%2Fdomain.com%2Fpath"; + + // Middle-click a testprotocol link and check the new tab is correct + let link = "#link"; + + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL); + await BrowserTestUtils.synthesizeMouseAtCenter(link, { button: 1 }, browser); + let tab = await promiseTabOpened; + gBrowser.selectedTab = tab; + is( + gURLBar.value, + UrlbarTestUtils.trimURL(expectedURL), + "the expected URL is displayed in the location bar" + ); + BrowserTestUtils.removeTab(tab); + + // Shift-click the testprotocol link and check the new window. + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: expectedURL, + }); + await BrowserTestUtils.synthesizeMouseAtCenter( + link, + { shiftKey: true }, + browser + ); + let win = await newWindowPromise; + await BrowserTestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == expectedURL + ); + is( + win.gURLBar.value, + UrlbarTestUtils.trimURL(expectedURL), + "the expected URL is displayed in the location bar" + ); + await BrowserTestUtils.closeWindow(win); + + // Click the testprotocol link and check the url in the current tab. + let loadPromise = BrowserTestUtils.browserLoaded(browser); + await BrowserTestUtils.synthesizeMouseAtCenter(link, {}, browser); + await loadPromise; + await BrowserTestUtils.waitForCondition( + () => gURLBar.value != UrlbarTestUtils.trimURL(testURL) + ); + is( + gURLBar.value, + UrlbarTestUtils.trimURL(expectedURL), + "the expected URL is displayed in the location bar" + ); + + // Cleanup. + protoInfo.preferredApplicationHandler = null; + handlers.removeElementAt(0); + handlerSvc.store(protoInfo); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js b/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js new file mode 100644 index 0000000000..d978410263 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js @@ -0,0 +1,111 @@ +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", false]], + }); + const allowDirectoriesVal = DownloadIntegration.allowDirectories; + DownloadIntegration.allowDirectories = true; + registerCleanupFunction(() => { + DownloadIntegration.allowDirectories = allowDirectoriesVal; + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("browser.download.folderList"); + }); +}); + +async function aDownloadLaunchedWithAppIsSavedInFolder(downloadDir) { + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + + let downloadFinishedPromise = promiseDownloadFinished(publicList); + let initialTabsCount = gBrowser.tabs.length; + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_green.webp", + waitForLoad: false, + waitForStateStop: true, + }); + + let download = await downloadFinishedPromise; + await BrowserTestUtils.waitForCondition( + () => gBrowser.tabs.length == initialTabsCount + 2 + ); + + gBrowser.removeCurrentTab(); + BrowserTestUtils.removeTab(loadingTab); + + ok( + download.target.path.startsWith(downloadDir), + "Download should be placed in default download directory: " + + downloadDir + + ", and it's located in " + + download.target.path + ); + + Assert.ok( + await IOUtils.exists(download.target.path), + "The file should not have been deleted." + ); + + try { + info("removing " + download.target.path); + if (Services.appinfo.OS === "WINNT") { + // We need to make the file writable to delete it on Windows. + await IOUtils.setPermissions(download.target.path, 0o600); + } + await IOUtils.remove(download.target.path); + } catch (ex) { + info("The file " + download.target.path + " is not removed, " + ex); + } +} + +add_task(async function aDownloadLaunchedWithAppIsSavedInCustomDir() { + //Test the temp dir. + let time = new Date().getTime(); + let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append(time); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir); + let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, tempDir.path); + Assert.ok(await IOUtils.exists(downloadDir)); + registerCleanupFunction(async () => { + await IOUtils.remove(tempDir.path, { recursive: true }); + }); + await aDownloadLaunchedWithAppIsSavedInFolder(downloadDir); +}); + +add_task(async function aDownloadLaunchedWithAppIsSavedInDownloadsDir() { + // Test the system downloads directory. + Services.prefs.setIntPref("browser.download.folderList", 1); + let systemDir = await DownloadIntegration.getSystemDownloadsDirectory(); + let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, systemDir); + + await aDownloadLaunchedWithAppIsSavedInFolder(downloadDir); +}); + +add_task(async function aDownloadLaunchedWithAppIsSavedInDesktopDir() { + // Test the desktop directory. + Services.prefs.setIntPref("browser.download.folderList", 0); + let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, Services.dirsvc.get("Desk", Ci.nsIFile).path); + + await aDownloadLaunchedWithAppIsSavedInFolder(downloadDir); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js b/uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js new file mode 100644 index 0000000000..cfa4788a43 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that loading a local PDF file + * prompts the user when pdfjs.disabled is set to true, + * and alwaysAsk is false; + */ +add_task( + async function test_check_browser_local_files_no_save_without_asking() { + // Get a ref to the pdf we want to open. + let file = getChromeDir(getResolvedURI(gTestPath)); + file.append("file_pdf_binary_octet_stream.pdf"); + + await SpecialPowers.pushPrefEnv({ set: [["pdfjs.disabled", true]] }); + + const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + const handlerSvc = Cc[ + "@mozilla.org/uriloader/handler-service;1" + ].getService(Ci.nsIHandlerService); + const mimeInfo = mimeSvc.getFromTypeAndExtension("application/pdf", "pdf"); + // This test covers a bug that only occurs when the mimeInfo is set to Always Ask = false + // Here we check if we ask the user what to do for local files, if the file is set to save to disk automatically; + // that is, we check that we prompt the user despite the user's preference. + mimeInfo.preferredAction = mimeInfo.saveToDisk; + mimeInfo.alwaysAskBeforeHandling = false; + handlerSvc.store(mimeInfo); + + info("Testing with " + file.path); + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + + let publicDownloads = await publicList.getAll(); + is( + publicDownloads.length, + 0, + "download should not appear in publicDownloads list" + ); + + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + var loadingTab = BrowserTestUtils.addTab(gBrowser, file.path); + + let dialogWindow = await dialogWindowPromise; + + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + let doc = dialogWindow.document; + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + } +); diff --git a/uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js b/uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js new file mode 100644 index 0000000000..d8e7c87c10 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); +let mimeInfo = mimeSvc.getFromTypeAndExtension("application/pdf", "pdf"); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ set: [["pdfjs.disabled", true]] }); + + let oldAsk = mimeInfo.alwaysAskBeforeHandling; + let oldPreferredAction = mimeInfo.preferredAction; + let oldPreferredApp = mimeInfo.preferredApplicationHandler; + registerCleanupFunction(() => { + mimeInfo.preferredApplicationHandler = oldPreferredApp; + mimeInfo.preferredAction = oldPreferredAction; + mimeInfo.alwaysAskBeforeHandling = oldAsk; + handlerSvc.store(mimeInfo); + }); + + if (!mimeInfo.preferredApplicationHandler) { + let handlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + handlerApp.executable = Services.dirsvc.get("TmpD", Ci.nsIFile); + handlerApp.executable.append("foopydoo.exe"); + mimeInfo.possibleApplicationHandlers.appendElement(handlerApp); + mimeInfo.preferredApplicationHandler = handlerApp; + } +}); + +add_task(async function open_from_dialog() { + // Force PDFs to prompt: + mimeInfo.preferredAction = mimeInfo.useHelperApp; + mimeInfo.alwaysAskBeforeHandling = true; + handlerSvc.store(mimeInfo); + + let openingPromise = TestUtils.topicObserved( + "test-only-opening-downloaded-file", + (subject, data) => { + subject.QueryInterface(Ci.nsISupportsPRBool); + // Block opening the file: + subject.data = false; + return true; + } + ); + + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let openedFile = getChromeDir(getResolvedURI(gTestPath)); + openedFile.append("file_pdf_binary_octet_stream.pdf"); + let expectedPath = openedFile.isSymlink() + ? openedFile.target + : openedFile.path; + let loadingTab = BrowserTestUtils.addTab(gBrowser, expectedPath); + + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + let doc = dialogWindow.document; + + // Select the 'open' entry. + doc.querySelector("#open").click(); + let dialog = doc.querySelector("#unknownContentType"); + dialog.getButton("accept").removeAttribute("disabled"); + dialog.acceptDialog(); + let [, openedPath] = await openingPromise; + is( + openedPath, + expectedPath, + "Should have opened file directly (not created a copy)." + ); + if (openedPath != expectedPath) { + await IOUtils.setPermissions(openedPath, 0o666); + await IOUtils.remove(openedPath); + } + BrowserTestUtils.removeTab(loadingTab); +}); + +add_task(async function open_directly() { + // Force PDFs to open immediately: + mimeInfo.preferredAction = mimeInfo.useHelperApp; + mimeInfo.alwaysAskBeforeHandling = false; + handlerSvc.store(mimeInfo); + + let openingPromise = TestUtils.topicObserved( + "test-only-opening-downloaded-file", + (subject, data) => { + subject.QueryInterface(Ci.nsISupportsPRBool); + // Block opening the file: + subject.data = false; + return true; + } + ); + + let openedFile = getChromeDir(getResolvedURI(gTestPath)); + openedFile.append("file_pdf_binary_octet_stream.pdf"); + let expectedPath = openedFile.isSymlink() + ? openedFile.target + : openedFile.path; + let loadingTab = BrowserTestUtils.addTab(gBrowser, expectedPath); + + let [, openedPath] = await openingPromise; + is( + openedPath, + expectedPath, + "Should have opened file directly (not created a copy)." + ); + if (openedPath != expectedPath) { + await IOUtils.setPermissions(openedPath, 0o666); + await IOUtils.remove(openedPath); + } + BrowserTestUtils.removeTab(loadingTab); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js b/uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js new file mode 100644 index 0000000000..424fd424f9 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js @@ -0,0 +1,310 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const { + handleInternally, + saveToDisk, + useSystemDefault, + alwaysAsk, + useHelperApp, +} = Ci.nsIHandlerInfo; + +function waitForAcceptButtonToGetEnabled(doc) { + let dialog = doc.querySelector("#unknownContentType"); + let button = dialog.getButton("accept"); + return TestUtils.waitForCondition( + () => !button.disabled, + "Wait for Accept button to get enabled" + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Remove the security delay for the dialog during the test. + ["security.dialog_enable_delay", 0], + ["browser.helperApps.showOpenOptionForViewableInternally", true], + // Make sure we don't open a file picker dialog somehow. + ["browser.download.useDownloadDir", true], + ], + }); + + // Restore handlers after the whole test has run + const registerRestoreHandler = function (type, ext) { + const mimeInfo = gMimeSvc.getFromTypeAndExtension(type, ext); + const existed = gHandlerSvc.exists(mimeInfo); + registerCleanupFunction(() => { + if (existed) { + gHandlerSvc.store(mimeInfo); + } else { + gHandlerSvc.remove(mimeInfo); + } + }); + }; + registerRestoreHandler("image/svg+xml", "svg"); +}); + +function ensureMIMEState({ preferredAction, alwaysAskBeforeHandling }) { + const mimeInfo = gMimeSvc.getFromTypeAndExtension("image/svg+xml", "svg"); + mimeInfo.preferredAction = preferredAction; + mimeInfo.alwaysAskBeforeHandling = alwaysAskBeforeHandling; + gHandlerSvc.store(mimeInfo); +} + +function waitDelay(delay) { + return new Promise((resolve, reject) => { + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + window.setTimeout(resolve, delay); + }); +} + +function promisePanelOpened() { + if (DownloadsPanel.panel && DownloadsPanel.panel.state == "open") { + return Promise.resolve(); + } + return BrowserTestUtils.waitForEvent(DownloadsPanel.panel, "popupshown"); +} + +const kTestCasesPrefEnabled = [ + { + description: + "Pref enabled - internal handling as default should not change prefs", + preDialogState: { + preferredAction: handleInternally, + alwaysAskBeforeHandling: false, + }, + expectTab: true, + expectLaunch: false, + expectedPreferredAction: handleInternally, + expectedAlwaysAskBeforeHandling: false, + expectUCT: false, + }, + { + description: + "Pref enabled - external handling as default should not change prefs", + preDialogState: { + preferredAction: useSystemDefault, + alwaysAskBeforeHandling: false, + }, + expectTab: false, + expectLaunch: true, + expectedPreferredAction: useSystemDefault, + expectedAlwaysAskBeforeHandling: false, + expectUCT: false, + }, + { + description: "Pref enabled - saveToDisk as default should not change prefs", + preDialogState: { + preferredAction: saveToDisk, + alwaysAskBeforeHandling: false, + }, + expectTab: false, + expectLaunch: false, + expectedPreferredAction: saveToDisk, + expectedAlwaysAskBeforeHandling: false, + expectUCT: false, + }, + { + description: + "Pref enabled - choose internal + alwaysAsk default + checkbox should update persisted default", + preDialogState: { + preferredAction: alwaysAsk, + alwaysAskBeforeHandling: false, + }, + dialogActions(doc) { + let handleItem = doc.querySelector("#handleInternally"); + handleItem.click(); + ok(handleItem.selected, "The 'open' option should now be selected"); + let checkbox = doc.querySelector("#rememberChoice"); + checkbox.checked = true; + checkbox.doCommand(); + }, + // new tab will not launch in test environment when alwaysAsk is preferredAction + expectTab: false, + expectLaunch: false, + expectedPreferredAction: handleInternally, + expectedAlwaysAskBeforeHandling: false, + expectUCT: true, + }, + { + description: + "Pref enabled - saveToDisk with alwaysAsk default should update persisted default", + preDialogState: { + preferredAction: alwaysAsk, + alwaysAskBeforeHandling: false, + }, + dialogActions(doc) { + let saveItem = doc.querySelector("#save"); + saveItem.click(); + ok(saveItem.selected, "The 'save' option should now be selected"); + }, + expectTab: false, + expectLaunch: false, + expectedPreferredAction: saveToDisk, + expectedAlwaysAskBeforeHandling: false, + expectUCT: true, + }, +]; + +add_task( + async function test_check_saving_handler_choices_with_always_ask_before_handling_new_types_pref_enabled() { + SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", false]], + }); + + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let file = "file_image_svgxml.svg"; + + for (let testCase of kTestCasesPrefEnabled) { + info("Testing with " + file + "; " + testCase.description); + ensureMIMEState(testCase.preDialogState); + const { expectTab, expectLaunch, description, expectUCT } = testCase; + + let oldLaunchFile = DownloadIntegration.launchFile; + let fileLaunched = Promise.withResolvers(); + DownloadIntegration.launchFile = () => { + ok( + expectLaunch, + `The file should ${ + expectLaunch ? "" : "not " + }be launched with an external application - ${description}` + ); + fileLaunched.resolve(); + }; + + info("Load window and tabs"); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + + // See if UCT window appears in loaded tab. + let dialogWindow = await Promise.race([ + waitDelay(1000), + dialogWindowPromise, + ]); + + is( + !!dialogWindow, + expectUCT, + `UCT window should${expectUCT ? "" : " not"} have appeared` + ); + + let download; + + if (dialogWindow) { + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Unknown content dialogWindow should be loaded correctly." + ); + let doc = dialogWindow.document; + let internalHandlerRadio = doc.querySelector("#handleInternally"); + + info("Waiting for accept button to get enabled"); + await waitForAcceptButtonToGetEnabled(doc); + + ok( + !internalHandlerRadio.hidden, + "The option should be visible for SVG" + ); + + info("Running UCT dialog options before downloading file"); + await testCase.dialogActions(doc); + + let dialog = doc.querySelector("#unknownContentType"); + dialog.acceptDialog(); + + info("Waiting for downloads to finish"); + let downloadFinishedPromise = promiseDownloadFinished(publicList); + download = await downloadFinishedPromise; + } else { + let downloadPanelPromise = promisePanelOpened(); + await downloadPanelPromise; + is( + DownloadsPanel.isPanelShowing, + true, + "DownloadsPanel should be open" + ); + + info("Skipping UCT dialog options"); + info("Waiting for downloads to finish"); + // Unlike when the UCT window opens, the download immediately starts. + let downloadList = await publicList; + [download] = downloadList._downloads; + } + + if (expectLaunch) { + info("Waiting for launch to finish"); + await fileLaunched.promise; + } + DownloadIntegration.launchFile = oldLaunchFile; + + is( + download.contentType, + "image/svg+xml", + "File contentType should be correct" + ); + is( + download.source.url, + `${TEST_PATH + file}`, + "File name should be correct." + ); + is( + (await publicList.getAll()).length, + 1, + "download should appear in public list" + ); + + // Check mime info: + const mimeInfo = gMimeSvc.getFromTypeAndExtension("image/svg+xml", "svg"); + gHandlerSvc.fillHandlerInfo(mimeInfo, ""); + is( + mimeInfo.preferredAction, + testCase.expectedPreferredAction, + "preferredAction - " + description + ); + is( + mimeInfo.alwaysAskBeforeHandling, + testCase.expectedAlwaysAskBeforeHandling, + "alwaysAskBeforeHandling - " + description + ); + + info("Cleaning up"); + BrowserTestUtils.removeTab(loadingTab); + // By default, if internal is default with pref enabled, we view the svg file in + // in a new tab. Close this tab in order for the test case to pass. + if (expectTab && testCase.preferredAction !== alwaysAsk) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + await publicList.removeFinished(); + if (download?.target.exists) { + try { + await IOUtils.remove(download.target.path); + } catch (ex) { + /* ignore */ + } + } + } + } +); diff --git a/uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js b/uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js new file mode 100644 index 0000000000..a08fe342cc --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const HandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" +].getService(Ci.nsIHandlerService); +const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const { saveToDisk, alwaysAsk, handleInternally, useSystemDefault } = + Ci.nsIHandlerInfo; +const MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +async function testPdfFilePicker(mimeInfo) { + await BrowserTestUtils.withNewTab( + `data:text/html,<a id="test-link" href="${TEST_PATH}/file_pdf_application_pdf.pdf">Test PDF Link</a>`, + async browser => { + let menu = document.getElementById("contentAreaContextMenu"); + ok(menu, "Context menu exists on the page"); + + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouseAtCenter( + "a#test-link", + { type: "contextmenu", button: 2 }, + browser + ); + await popupShown; + info("Context menu popup was successfully displayed"); + + let filePickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = fp => { + ok(true, "filepicker should be visible"); + ok( + fp.defaultExtension === "pdf", + "Default extension in filepicker should be pdf" + ); + ok( + fp.defaultString === "file_pdf_application_pdf.pdf", + "Default string name in filepicker should have the correct pdf file name" + ); + setTimeout(resolve, 0); + return Ci.nsIFilePicker.returnCancel; + }; + }); + + let menuitem = menu.querySelector("#context-savelink"); + menu.activateItem(menuitem); + await filePickerPromise; + } + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.useDownloadDir", false], + ], + }); + + registerCleanupFunction(async () => { + let publicList = await Downloads.getList(Downloads.PUBLIC); + await publicList.removeFinished(); + + if (DownloadsPanel.isVisible) { + info("Closing downloads panel"); + let hiddenPromise = BrowserTestUtils.waitForEvent( + DownloadsPanel.panel, + "popuphidden" + ); + DownloadsPanel.hidePanel(); + await hiddenPromise; + } + + let mimeInfo = MIMEService.getFromTypeAndExtension( + "application/pdf", + "pdf" + ); + let existed = HandlerService.exists(mimeInfo); + if (existed) { + HandlerService.store(mimeInfo); + } else { + HandlerService.remove(mimeInfo); + } + + // We only want to run MockFilerPicker.cleanup after the entire test is run. + // Otherwise, we cannot use MockFilePicker for each preferredAction. + MockFilePicker.cleanup(); + }); +}); + +/** + * Tests that selecting the context menu item `Save Link As…` on a PDF link + * opens the file picker when always_ask_before_handling_new_types is disabled, + * regardless of preferredAction. + */ +add_task(async function test_pdf_save_as_link() { + let mimeInfo; + + for (let preferredAction of [ + saveToDisk, + alwaysAsk, + handleInternally, + useSystemDefault, + ]) { + mimeInfo = MIMEService.getFromTypeAndExtension("application/pdf", "pdf"); + mimeInfo.alwaysAskBeforeHandling = preferredAction === alwaysAsk; + mimeInfo.preferredAction = preferredAction; + HandlerService.store(mimeInfo); + + info(`Testing filepicker for preferredAction ${preferredAction}`); + await testPdfFilePicker(mimeInfo); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js new file mode 100644 index 0000000000..7244aec503 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js @@ -0,0 +1,467 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const CONTENT_HANDLING_URL = + "chrome://mozapps/content/handling/appChooser.xhtml"; + +add_task(setupMailHandler); + +/** + * Check that if we open the protocol handler dialog from a subframe, we close + * it when closing the tab. + */ +add_task(async function test_closed_by_tab_closure() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PATH + "file_nested_protocol_request.html" + ); + + // Wait for the window and then click the link. + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog URL is as expected" + ); + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + + info("Removing tab to close the dialog."); + gBrowser.removeTab(tab); + await dialogClosedPromise; + ok(!dialog._frame.contentWindow, "The dialog should have been closed."); +}); + +/** + * Check that if we open the protocol handler dialog from a subframe, we close + * it when navigating the tab to a non-same-origin URL. + */ +add_task(async function test_closed_by_tab_navigation() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PATH + "file_nested_protocol_request.html" + ); + + // Wait for the window and then click the link. + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog URL is as expected" + ); + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + info( + "Set up unload handler to ensure we don't break when the window global gets cleared" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.addEventListener("unload", function () {}); + }); + + info("Navigating tab to a different but same origin page."); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, TEST_PATH); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, TEST_PATH); + ok(dialog._frame.contentWindow, "Dialog should stay open."); + + // The use of weak references in various parts of the code means that we're + // susceptible to dropping crucial bits of our implementation on the floor, + // if they get GC'd, and then the test hangs. + // Do a bunch of GC/CC runs so that if we ever break, it's deterministic. + let numCycles = 3; + for (let i = 0; i < numCycles; i++) { + Cu.forceGC(); + Cu.forceCC(); + } + + info("Now navigate to a cross-origin page."); + const CROSS_ORIGIN_TEST_PATH = TEST_PATH.replace(".com", ".org"); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + CROSS_ORIGIN_TEST_PATH + ); + let loadPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + CROSS_ORIGIN_TEST_PATH + ); + await dialogClosedPromise; + ok(!dialog._frame.contentWindow, "The dialog should have been closed."); + + // Avoid errors from aborted loads by waiting for it to finish. + await loadPromise; + gBrowser.removeTab(tab); +}); + +/** + * Check that we cannot open more than one of these dialogs. + */ +add_task(async function test_multiple_dialogs() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PATH + "file_nested_protocol_request.html" + ); + + // Wait for the window and then click the link. + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog URL is as expected" + ); + + // Navigate the parent frame: + await ContentTask.spawn(tab.linkedBrowser, [], () => + content.eval("location.href = 'mailto:help@example.com'") + ); + + // Wait for a few ticks: + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 100)); + // Check we only have one dialog + + let tabDialogBox = gBrowser.getTabDialogBox(tab.linkedBrowser); + let dialogs = tabDialogBox + .getTabDialogManager() + ._dialogs.filter(d => d._openedURL == CONTENT_HANDLING_URL); + + is(dialogs.length, 1, "Should only have 1 dialog open"); + + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + dialog.close(); + dialog = await dialogClosedPromise; + + ok(!dialog._openedURL, "The dialog should have been closed."); + + // Then reopen the dialog again, to make sure we don't keep blocking: + dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Second dialog URL is as expected" + ); + + dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + info("Removing tab to close the dialog."); + gBrowser.removeTab(tab); + await dialogClosedPromise; + ok(!dialog._frame.contentWindow, "The dialog should have been closed again."); +}); + +/** + * Check that navigating invisible frames to external-proto URLs + * is handled correctly. + */ +add_task(async function invisible_iframes() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + // Ensure we notice the dialog opening: + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let frame = content.document.createElement("iframe"); + frame.style.display = "none"; + frame.src = "mailto:help@example.com"; + content.document.body.append(frame); + }); + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog opens as expected for invisible iframe" + ); + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + dialog.close(); + await dialogClosedPromise; + gBrowser.removeTab(tab); +}); + +/** + * Check that nested iframes are handled correctly. + */ +add_task(async function nested_iframes() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + // Ensure we notice the dialog opening: + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + let innerLoaded = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + "https://example.org/" + ); + info("Constructing top frame"); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let frame = content.document.createElement("iframe"); + frame.src = "https://example.org/"; // cross-origin frame. + content.document.body.prepend(frame); + + content.eval( + `window.addEventListener("message", e => e.source.location = "mailto:help@example.com");` + ); + }); + + await innerLoaded; + let parentBC = tab.linkedBrowser.browsingContext; + + info("Creating innermost frame"); + await SpecialPowers.spawn(parentBC.children[0], [], async function () { + let innerFrame = content.document.createElement("iframe"); + let frameLoaded = ContentTaskUtils.waitForEvent(innerFrame, "load", true); + content.document.body.prepend(innerFrame); + await frameLoaded; + }); + + info("Posting event from innermost frame"); + await SpecialPowers.spawn( + parentBC.children[0].children[0], + [], + async function () { + // Top browsing context needs reference to the innermost, which is cross origin. + content.eval("top.postMessage('hello', '*')"); + } + ); + + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog opens as expected for deeply nested cross-origin iframe" + ); + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + dialog.close(); + await dialogClosedPromise; + gBrowser.removeTab(tab); +}); + +add_task(async function test_oop_iframe() { + const URI = `data:text/html,<div id="root"><iframe src="http://example.com/document-builder.sjs?html=<a href='mailto:help@example.com'>Mail it</a>"></iframe></div>`; + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URI); + + // Wait for the window and then click the link. + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog URL is as expected" + ); + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + + info("Removing tab to close the dialog."); + gBrowser.removeTab(tab); + await dialogClosedPromise; + ok(!dialog._frame.contentWindow, "The dialog should have been closed."); +}); + +/** + * Check that a cross-origin iframe can navigate the top frame + * to an external protocol. + */ +add_task(async function xorigin_iframe_can_navigate_top() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + // Ensure we notice the dialog opening: + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + let innerLoaded = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + "https://example.org/" + ); + info("Constructing frame"); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let frame = content.document.createElement("iframe"); + frame.src = "https://example.org/"; // cross-origin frame. + content.document.body.prepend(frame); + }); + await innerLoaded; + + info("Navigating top bc from frame"); + let parentBC = tab.linkedBrowser.browsingContext; + await SpecialPowers.spawn(parentBC.children[0], [], async function () { + content.eval("window.top.location.href = 'mailto:example@example.com';"); + }); + + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog opens as expected for navigating the top frame from an x-origin frame." + ); + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + dialog.close(); + await dialogClosedPromise; + gBrowser.removeTab(tab); +}); + +/** + * Check that when navigating to an external protocol from an iframe in a + * background tab, we show the dialog in the correct tab. + */ +add_task(async function iframe_background_tab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + let innerLoaded = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + "https://example.org/" + ); + info("Constructing frame"); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let frame = content.document.createElement("iframe"); + frame.src = "https://example.org/"; + content.document.body.prepend(frame); + }); + await innerLoaded; + + info("Switching to new tab"); + let newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.net/" + ); + + // Wait for the chooser dialog to open in the background tab. It should not + // open in the foreground tab which is unrelated to the external protocol + // navigation. + let dialogWindowPromise = waitForProtocolAppChooserDialog(gBrowser, true); + + info("Navigating to external proto from frame in background tab"); + let parentBC = tab.linkedBrowser.browsingContext; + await SpecialPowers.spawn(parentBC.children[0], [], async function () { + content.eval("location.href = 'mailto:example@example.com';"); + }); + + // Wait for dialog to open in one of the tabs. + let dialog = await dialogWindowPromise; + + is( + gBrowser.getTabDialogBox(tab.linkedBrowser)._tabDialogManager._topDialog, + dialog, + "Dialog opened in the background tab" + ); + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Opened dialog is appChooser dialog." + ); + + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog(gBrowser, false); + dialog.close(); + await dialogClosedPromise; + + gBrowser.removeTab(tab); + gBrowser.removeTab(newTab); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js new file mode 100644 index 0000000000..591f1afbc5 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +/** + * Creates dummy protocol handler + */ +function initTestHandlers() { + let handlerInfoThatAsks = + HandlerServiceTestUtils.getBlankHandlerInfo("local-app-test"); + + let appHandler = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + // This is a dir and not executable, but that's enough for here. + appHandler.executable = Services.dirsvc.get("XCurProcD", Ci.nsIFile); + handlerInfoThatAsks.possibleApplicationHandlers.appendElement(appHandler); + handlerInfoThatAsks.preferredApplicationHandler = appHandler; + handlerInfoThatAsks.preferredAction = handlerInfoThatAsks.useHelperApp; + handlerInfoThatAsks.alwaysAskBeforeHandling = false; + gHandlerService.store(handlerInfoThatAsks); + + let webHandlerInfo = + HandlerServiceTestUtils.getBlankHandlerInfo("web+somesite"); + let webHandler = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + webHandler.name = "Somesite"; + webHandler.uriTemplate = "https://example.com/handle_url?u=%s"; + webHandlerInfo.possibleApplicationHandlers.appendElement(webHandler); + webHandlerInfo.preferredApplicationHandler = webHandler; + webHandlerInfo.preferredAction = webHandlerInfo.useHelperApp; + webHandlerInfo.alwaysAskBeforeHandling = false; + gHandlerService.store(webHandlerInfo); + + registerCleanupFunction(() => { + gHandlerService.remove(webHandlerInfo); + gHandlerService.remove(handlerInfoThatAsks); + }); +} + +function makeCmdLineHelper(url) { + return Cu.createCommandLine( + ["-url", url], + null, + Ci.nsICommandLine.STATE_REMOTE_EXPLICIT + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["network.protocol-handler.prompt-from-external", true]], + }); + initTestHandlers(); +}); + +/** + * Check that if we get a direct request from another app / the OS to open a + * link, we always prompt, even if we think we know what the correct answer + * is. This is to avoid infinite loops in such situations where the OS and + * Firefox have conflicting ideas about the default handler, or where our + * checks with the OS don't work (Linux and/or Snap, at time of this comment). + */ +add_task(async function test_external_asks_anyway() { + let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService( + Ci.nsICommandLineHandler + ); + let chooserDialogOpenPromise = waitForProtocolAppChooserDialog( + gBrowser, + true + ); + let fakeCmdLine = makeCmdLineHelper("local-app-test:dummy"); + cmdLineHandler.handle(fakeCmdLine); + let dialog = await chooserDialogOpenPromise; + ok(dialog, "Should have prompted."); + + let dialogClosedPromise = waitForProtocolAppChooserDialog( + gBrowser.selectedBrowser, + false + ); + let dialogEl = dialog._frame.contentDocument.querySelector("dialog"); + dialogEl.cancelDialog(); + await dialogClosedPromise; + // We will have opened a tab; close it. + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +/** + * Like the previous test, but avoid asking for web and extension handlers, + * as we can open those ourselves without looping. + */ +add_task(async function test_web_app_doesnt_ask() { + // Listen for a dialog open and fail the test if it does: + let dialogOpenListener = () => ok(false, "Shouldn't have opened a dialog!"); + document.documentElement.addEventListener("dialogopen", dialogOpenListener); + registerCleanupFunction(() => + document.documentElement.removeEventListener( + "dialogopen", + dialogOpenListener + ) + ); + + // Set up a promise for a tab to open with the right URL: + const kURL = "web+somesite:dummy"; + const kLoadedURL = + "https://example.com/handle_url?u=" + encodeURIComponent(kURL); + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, kLoadedURL); + + // Load the URL: + let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService( + Ci.nsICommandLineHandler + ); + let fakeCmdLine = makeCmdLineHelper(kURL); + cmdLineHandler.handle(fakeCmdLine); + + // Check that the tab loaded. If instead the dialog opened, the dialogopen handler + // will fail the test. + let tab = await tabPromise; + is( + tab.linkedBrowser.currentURI.spec, + kLoadedURL, + "Should have opened the right URL." + ); + BrowserTestUtils.removeTab(tab); + + // We do this both here and in cleanup so it's easy to add tasks to this test, + // and so we clean up correctly if the test aborts before we get here. + document.documentElement.removeEventListener( + "dialogopen", + dialogOpenListener + ); +}); + +add_task(async function external_https_redirect_doesnt_ask() { + Services.perms.addFromPrincipal( + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + "https://example.com" + ), + "open-protocol-handler^local-app-test", + Services.perms.ALLOW_ACTION + ); + // Listen for a dialog open and fail the test if it does: + let dialogOpenListener = () => ok(false, "Shouldn't have opened a dialog!"); + document.documentElement.addEventListener("dialogopen", dialogOpenListener); + registerCleanupFunction(() => { + document.documentElement.removeEventListener( + "dialogopen", + dialogOpenListener + ); + Services.perms.removeAll(); + }); + + let initialTab = gBrowser.selectedTab; + + gHandlerService.wrappedJSObject.mockProtocolHandler("local-app-test"); + registerCleanupFunction(() => + gHandlerService.wrappedJSObject.mockProtocolHandler() + ); + + // Set up a promise for an app to have launched with the right URI: + let loadPromise = TestUtils.topicObserved("mocked-protocol-handler"); + + // Load the URL: + const kURL = "local-app-test:redirect"; + let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService( + Ci.nsICommandLineHandler + ); + let fakeCmdLine = makeCmdLineHelper( + TEST_PATH + "redirect_helper.sjs?uri=" + encodeURIComponent(kURL) + ); + cmdLineHandler.handle(fakeCmdLine); + + // Check that the mock app was launched. If the dialog showed instead, + // the test will fail. + let [uri] = await loadPromise; + is(uri.spec, "local-app-test:redirect", "Should have seen correct URI."); + // We might have opened a blank tab, see bug 1718104 and friends. + if (gBrowser.selectedTab != initialTab) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + + // We do this both here and in cleanup so it's easy to add tasks to this test, + // and so we clean up correctly if the test aborts before we get here. + document.documentElement.removeEventListener( + "dialogopen", + dialogOpenListener + ); + gHandlerService.wrappedJSObject.mockProtocolHandler(); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js new file mode 100644 index 0000000000..c567722b6d --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js @@ -0,0 +1,1383 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +const ROOT_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content/", + "" +); + +// Testing multiple protocol / origin combinations takes long on debug. +requestLongerTimeout(7); + +const DIALOG_URL_APP_CHOOSER = + "chrome://mozapps/content/handling/appChooser.xhtml"; +const DIALOG_URL_PERMISSION = + "chrome://mozapps/content/handling/permissionDialog.xhtml"; + +const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler"; +const PERMISSION_KEY_DELIMITER = "^"; + +const TEST_PROTOS = ["foo", "bar"]; + +let testDir = getChromeDir(getResolvedURI(gTestPath)); + +const ORIGIN1 = "https://example.com"; +const ORIGIN2 = "https://example.org"; +const ORIGIN3 = Services.io.newFileURI(testDir).spec; +const PRINCIPAL1 = + Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN1); +const PRINCIPAL2 = + Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN2); +const PRINCIPAL3 = + Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN3); + +const NULL_PRINCIPAL_SCHEME = Services.scriptSecurityManager + .createNullPrincipal({}) + .scheme.toLowerCase(); + +/** + * Get the open protocol handler permission key for a given protocol scheme. + * @param {string} aProtocolScheme - Scheme of protocol to construct permission + * key with. + */ +function getSkipProtoDialogPermissionKey(aProtocolScheme) { + return ( + PROTOCOL_HANDLER_OPEN_PERM_KEY + PERMISSION_KEY_DELIMITER + aProtocolScheme + ); +} + +function getSystemProtocol() { + // TODO add a scheme for Windows 10 or greater once support is added (see bug 1764599). + if (AppConstants.platform == "macosx") { + return "itunes"; + } + + info( + "Skipping this test since there isn't a suitable default protocol on this platform" + ); + return null; +} + +/** + * Creates dummy web protocol handlers used for testing. + */ +function initTestHandlers() { + TEST_PROTOS.forEach(scheme => { + let webHandler = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + webHandler.name = scheme + "Handler"; + webHandler.uriTemplate = ORIGIN1 + "/?url=%s"; + + let handlerInfo = HandlerServiceTestUtils.getBlankHandlerInfo(scheme); + handlerInfo.possibleApplicationHandlers.appendElement(webHandler); + handlerInfo.preferredApplicationHandler = webHandler; + gHandlerService.store(handlerInfo); + }); +} + +/** + * Update whether the protocol handler dialog is shown for our test protocol + + * handler. + * @param {string} scheme - Scheme of the protocol to change the ask state for. + * @param {boolean} ask - true => show dialog, false => skip dialog. + */ +function updateAlwaysAsk(scheme, ask) { + let handlerInfo = HandlerServiceTestUtils.getHandlerInfo(scheme); + handlerInfo.alwaysAskBeforeHandling = ask; + gHandlerService.store(handlerInfo); +} + +/** + * Test whether the protocol handler dialog is set to show for our + * test protocol + handler. + * @param {string} scheme - Scheme of the protocol to test the ask state for. + * @param {boolean} ask - true => show dialog, false => skip dialog. + */ +function testAlwaysAsk(scheme, ask) { + is( + HandlerServiceTestUtils.getHandlerInfo(scheme).alwaysAskBeforeHandling, + ask, + "Should have correct alwaysAsk state." + ); +} + +/** + * Triggers the load via a server redirect. + * @param {string} serverRedirect - The redirect type. + */ +function useServerRedirect(serverRedirect) { + return async (browser, scheme) => { + let uri = `${scheme}://test`; + + let innerParams = new URLSearchParams(); + innerParams.set("uri", uri); + innerParams.set("redirectType", serverRedirect); + let params = new URLSearchParams(); + params.set( + "uri", + "https://example.com/" + + ROOT_PATH + + "redirect_helper.sjs?" + + innerParams.toString() + ); + uri = + "https://example.org/" + + ROOT_PATH + + "redirect_helper.sjs?" + + params.toString(); + BrowserTestUtils.startLoadingURIString(browser, uri); + }; +} + +/** + * Triggers the load with a specific principal or the browser's current + * principal. + * @param {nsIPrincipal} [principal] - Principal to use to trigger the load. + */ +function useTriggeringPrincipal(principal = undefined) { + return async (browser, scheme) => { + let uri = `${scheme}://test`; + let triggeringPrincipal = principal ?? browser.contentPrincipal; + + info("Loading uri: " + uri); + browser.loadURI(Services.io.newURI(uri), { triggeringPrincipal }); + }; +} + +/** + * Navigates to a test URL with the given protocol scheme and waits for the + * result. + * @param {MozBrowser} browser - Browser to navigate. + * @param {string} scheme - Scheme of the test url. e.g. irc + * @param {Object} [options] - Test options. + * @param {Object} [options.permDialogOptions] - Test options for the permission + * dialog. If defined, we expect this dialog to be shown. + * @param {Object} [options.chooserDialogOptions] - Test options for the chooser + * dialog. If defined, we expect this dialog to be shown. + * @param {Function} [options.triggerLoad] - An async callback function to + * trigger the load. Will be passed the browser and scheme to use. + * @param {nsIPrincipal} [options.triggeringPrincipal] - Principal to trigger + * the load with. Defaults to the browsers content principal. + * @returns {Promise} - A promise which resolves once the test is complete. + */ +async function testOpenProto( + browser, + scheme, + { + permDialogOptions, + chooserDialogOptions, + triggerLoad = useTriggeringPrincipal(), + } = {} +) { + let permDialogOpenPromise; + let chooserDialogOpenPromise; + + if (permDialogOptions) { + info("Should see permission dialog"); + permDialogOpenPromise = waitForProtocolPermissionDialog(browser, true); + } + + if (chooserDialogOptions) { + info("Should see chooser dialog"); + chooserDialogOpenPromise = waitForProtocolAppChooserDialog(browser, true); + } + await triggerLoad(browser, scheme); + let webHandlerLoadedPromise; + + let webHandlerShouldOpen = + (!permDialogOptions && !chooserDialogOptions) || + ((permDialogOptions?.actionConfirm || permDialogOptions?.actionChangeApp) && + chooserDialogOptions?.actionConfirm); + + // Register web handler load listener if we expect to trigger it. + if (webHandlerShouldOpen) { + webHandlerLoadedPromise = waitForHandlerURL(browser, scheme); + } + + if (permDialogOpenPromise) { + let dialog = await permDialogOpenPromise; + let dialogEl = getDialogElementFromSubDialog(dialog); + let dialogType = getDialogType(dialog); + + let { + hasCheckbox, + checkboxOrigin, + hasChangeApp, + chooserIsNext, + actionCheckbox, + actionConfirm, + actionChangeApp, + checkContents, + } = permDialogOptions; + + if (actionChangeApp) { + actionConfirm = false; + } + + let descriptionEl = dialogEl.querySelector("#description"); + ok( + descriptionEl && BrowserTestUtils.isVisible(descriptionEl), + "Has a visible description element." + ); + + ok( + !descriptionEl.innerHTML.toLowerCase().includes(NULL_PRINCIPAL_SCHEME), + "Description does not include NullPrincipal scheme." + ); + + await testCheckbox(dialogEl, dialogType, { + hasCheckbox, + actionCheckbox, + checkboxOrigin, + }); + + // Check the button label depending on whether we would show the chooser + // dialog next or directly open the handler. + let acceptBtnLabel = dialogEl.getButton("accept")?.label; + + if (chooserIsNext) { + is( + acceptBtnLabel, + "Choose Application", + "Accept button has choose app label" + ); + } else { + is(acceptBtnLabel, "Open Link", "Accept button has open link label"); + } + + let changeAppLink = dialogEl.ownerDocument.getElementById("change-app"); + if (typeof hasChangeApp == "boolean") { + ok(changeAppLink, "Permission dialog should have changeApp link label"); + is( + !changeAppLink.hidden, + hasChangeApp, + "Permission dialog change app link label" + ); + } + + if (checkContents) { + checkContents(dialogEl); + } + + if (actionChangeApp) { + let dialogClosedPromise = waitForProtocolPermissionDialog(browser, false); + changeAppLink.click(); + await dialogClosedPromise; + } else { + await closeDialog(browser, dialog, actionConfirm, scheme); + } + } + + if (chooserDialogOpenPromise) { + let dialog = await chooserDialogOpenPromise; + let dialogEl = getDialogElementFromSubDialog(dialog); + let dialogType = getDialogType(dialog); + + let { hasCheckbox, actionCheckbox, actionConfirm } = chooserDialogOptions; + + await testCheckbox(dialogEl, dialogType, { + hasCheckbox, + actionCheckbox, + }); + + await closeDialog(browser, dialog, actionConfirm, scheme); + } + + if (webHandlerShouldOpen) { + info("Waiting for web handler to open"); + await webHandlerLoadedPromise; + } else { + info("Web handler open canceled"); + } +} + +/** + * Inspects the checkbox state and interacts with it. + * @param {dialog} dialogEl + * @param {string} dialogType - String identifier of dialog type. + * Either "permission" or "chooser". + * @param {Object} options - Test Options. + * @param {boolean} [options.hasCheckbox] - Whether the dialog is expected to + * have a visible checkbox. + * @param {boolean} [options.hasCheckboxState] - The check state of the checkbox + * to test for. true = checked, false = unchecked. + * @param {boolean} [options.actionCheckbox] - The state to set on the checkbox. + * true = checked, false = unchecked. + */ +async function testCheckbox( + dialogEl, + dialogType, + { hasCheckbox, hasCheckboxState = false, actionCheckbox, checkboxOrigin } +) { + let checkbox = dialogEl.ownerDocument.getElementById("remember"); + if (typeof hasCheckbox == "boolean") { + is( + checkbox && BrowserTestUtils.isVisible(checkbox), + hasCheckbox, + "Dialog checkbox has correct visibility." + ); + + let checkboxLabel = dialogEl.ownerDocument.getElementById("remember-label"); + is( + checkbox && BrowserTestUtils.isVisible(checkboxLabel), + hasCheckbox, + "Dialog checkbox label has correct visibility." + ); + if (hasCheckbox) { + ok( + !checkboxLabel.innerHTML.toLowerCase().includes(NULL_PRINCIPAL_SCHEME), + "Dialog checkbox label does not include NullPrincipal scheme." + ); + } + } + + if (typeof hasCheckboxState == "boolean") { + is(checkbox.checked, hasCheckboxState, "Dialog checkbox has correct state"); + } + + if (checkboxOrigin) { + let doc = dialogEl.ownerDocument; + let hostFromLabel = doc.l10n.getAttributes( + doc.getElementById("remember-label") + ).args.host; + is(hostFromLabel, checkboxOrigin, "Checkbox should be for correct domain."); + } + + if (typeof actionCheckbox == "boolean") { + checkbox.click(); + } +} + +/** + * Get the dialog element which is a child of the SubDialogs browser frame. + * @param {SubDialog} subDialog - Dialog to get the dialog element for. + */ +function getDialogElementFromSubDialog(subDialog) { + let dialogEl = subDialog._frame.contentDocument.querySelector("dialog"); + ok(dialogEl, "SubDialog should have dialog element"); + return dialogEl; +} + +/** + * Wait for the test handler to be opened. + * @param {MozBrowser} browser - The browser the load should occur in. + * @param {string} scheme - Scheme which triggered the handler to open. + */ +function waitForHandlerURL(browser, scheme) { + return BrowserTestUtils.browserLoaded( + browser, + false, + url => url == `${ORIGIN1}/?url=${scheme}%3A%2F%2Ftest` + ); +} + +/** + * Test for open-protocol-handler permission. + * @param {nsIPrincipal} principal - The principal to test the permission on. + * @param {string} scheme - Scheme to generate permission key. + * @param {boolean} hasPerm - Whether we expect the princial to set the + * permission (true), or not (false). + */ +function testPermission(principal, scheme, hasPerm) { + let permKey = getSkipProtoDialogPermissionKey(scheme); + let result = Services.perms.testPermissionFromPrincipal(principal, permKey); + let message = `${permKey} ${hasPerm ? "is" : "is not"} set for ${ + principal.origin + }.`; + is(result == Services.perms.ALLOW_ACTION, hasPerm, message); +} + +/** + * Get the checkbox element of the dialog used to remember the handler choice or + * store the permission. + * @param {SubDialog} dialog - Protocol handler dialog embedded in a SubDialog. + * @param {string} dialogType - Type of the dialog which holds the checkbox. + * @returns {HTMLInputElement} - Checkbox of the dialog. + */ +function getDialogCheckbox(dialog, dialogType) { + let id; + if (dialogType == "permission") { + id = "remember-permission"; + } else { + id = "remember"; + } + return dialog._frame.contentDocument.getElementById(id); +} + +function getDialogType(dialog) { + let url = dialog._frame.currentURI.spec; + + if (url === DIALOG_URL_PERMISSION) { + return "permission"; + } + if (url === DIALOG_URL_APP_CHOOSER) { + return "chooser"; + } + throw new Error("Dialog with unexpected url"); +} + +/** + * Exit a protocol handler SubDialog and wait for it to be fully closed. + * @param {MozBrowser} browser - Browser element of the tab where the dialog is + * shown. + * @param {SubDialog} dialog - SubDialog object which holds the protocol handler + * @param {boolean} confirm - Whether to confirm (true) or cancel (false) the + * dialog. + * @param {string} scheme - The scheme of the protocol the dialog is opened for. + * dialog. + */ +async function closeDialog(browser, dialog, confirm, scheme) { + let dialogClosedPromise = waitForSubDialog(browser, dialog._openedURL, false); + let dialogEl = getDialogElementFromSubDialog(dialog); + + if (confirm) { + if (getDialogType(dialog) == "chooser") { + // Select our test protocol handler + let listItem = dialogEl.ownerDocument.querySelector( + `richlistitem[name="${scheme}Handler"]` + ); + listItem.click(); + } + + dialogEl.setAttribute("buttondisabledaccept", false); + dialogEl.acceptDialog(); + } else { + dialogEl.cancelDialog(); + } + + return dialogClosedPromise; +} + +registerCleanupFunction(function () { + // Clean up test handlers + TEST_PROTOS.forEach(scheme => { + let handlerInfo = HandlerServiceTestUtils.getHandlerInfo(scheme); + gHandlerService.remove(handlerInfo); + }); + + // Clear permissions + Services.perms.removeAll(); +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.external_protocol_requires_permission", true]], + }); + initTestHandlers(); +}); + +/** + * Tests that when "remember" is unchecked, we only allow the protocol to be + * opened once and don't store any permission. + */ +add_task(async function test_permission_allow_once() { + for (let scheme of TEST_PROTOS) { + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: false, + chooserIsNext: true, + actionConfirm: true, + }, + chooserDialogOptions: { hasCheckbox: true, actionConfirm: true }, + }); + }); + + // No permission should be set + testPermission(PRINCIPAL1, scheme, false); + testPermission(PRINCIPAL2, scheme, false); + + // No preferred app should be set + testAlwaysAsk(scheme, true); + + // If we open again we should see the permission dialog + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: false, + chooserIsNext: true, + actionConfirm: false, + }, + }); + }); + } +}); + +/** + * Tests that when checking the "remember" checkbox, the protocol permission + * is set correctly and allows the caller to skip the permission dialog in + * subsequent calls. + */ +add_task(async function test_permission_allow_persist() { + for (let [origin, principal] of [ + [ORIGIN1, PRINCIPAL1], + [ORIGIN3, PRINCIPAL3], + ]) { + for (let scheme of TEST_PROTOS) { + info("Testing with origin " + origin); + info("testing with principal of origin " + principal.origin); + info("testing with protocol " + scheme); + + // Set a permission for an unrelated protocol. + // We should still see the permission dialog. + Services.perms.addFromPrincipal( + principal, + getSkipProtoDialogPermissionKey("foobar"), + Services.perms.ALLOW_ACTION + ); + + await BrowserTestUtils.withNewTab(origin, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: false, + chooserIsNext: true, + actionCheckbox: true, + actionConfirm: true, + }, + chooserDialogOptions: { hasCheckbox: true, actionConfirm: true }, + }); + }); + + // Permission should be set + testPermission(principal, scheme, true); + testPermission(PRINCIPAL2, scheme, false); + + // No preferred app should be set + testAlwaysAsk(scheme, true); + + // If we open again with the origin where we granted permission, we should + // directly get the chooser dialog. + await BrowserTestUtils.withNewTab(origin, async browser => { + await testOpenProto(browser, scheme, { + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, + }, + }); + }); + + // If we open with the other origin, we should see the permission dialog + await BrowserTestUtils.withNewTab(ORIGIN2, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: false, + chooserIsNext: true, + actionConfirm: false, + }, + }); + }); + + // Cleanup permissions + Services.perms.removeAll(); + } + } +}); + +/** + * Tests that if a preferred protocol handler is set, the permission dialog + * shows the application name and a link which leads to the app chooser. + */ +add_task(async function test_permission_application_set() { + let scheme = TEST_PROTOS[0]; + updateAlwaysAsk(scheme, false); + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: true, + chooserIsNext: false, + actionChangeApp: true, + }, + chooserDialogOptions: { hasCheckbox: true, actionConfirm: true }, + }); + }); + + // Cleanup + updateAlwaysAsk(scheme, true); +}); + +/** + * Tests that we correctly handle system principals. They should always + * show the permission dialog, but give the option to choose another + * app if there isn't a default handler. + */ +add_task(async function test_permission_system_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: false, + hasChangeApp: false, + chooserIsNext: true, + actionChangeApp: false, + }, + triggerLoad: useTriggeringPrincipal( + Services.scriptSecurityManager.getSystemPrincipal() + ), + }); + }); +}); + +/** + * Tests that we correctly handle system principals and show + * a simplified permission dialog if there is a default handler. + */ +add_task(async function test_permission_system_principal() { + let scheme = getSystemProtocol(); + if (!scheme) { + return; + } + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: false, + hasChangeApp: false, + chooserIsNext: false, + actionChangeApp: false, + }, + triggerLoad: useTriggeringPrincipal( + Services.scriptSecurityManager.getSystemPrincipal() + ), + }); + }); +}); + +/** + * Tests that we don't show the permission dialog if the permission is disabled + * by pref. + */ +add_task(async function test_permission_disabled() { + let scheme = TEST_PROTOS[0]; + + await SpecialPowers.pushPrefEnv({ + set: [["security.external_protocol_requires_permission", false]], + }); + + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + chooserDialogOptions: { hasCheckbox: true, actionConfirm: true }, + }); + }); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests that we directly open the handler if permission and handler are set. + */ +add_task(async function test_app_and_permission_set() { + let scheme = TEST_PROTOS[1]; + + updateAlwaysAsk(scheme, false); + Services.perms.addFromPrincipal( + PRINCIPAL2, + getSkipProtoDialogPermissionKey(scheme), + Services.perms.ALLOW_ACTION + ); + + await BrowserTestUtils.withNewTab(ORIGIN2, async browser => { + await testOpenProto(browser, scheme); + }); + + // Cleanup + Services.perms.removeAll(); + updateAlwaysAsk(scheme, true); +}); + +/** + * Tests that the alwaysAsk state is not updated if the user cancels the dialog + */ +add_task(async function test_change_app_checkbox_cancel() { + let scheme = TEST_PROTOS[0]; + + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + chooserIsNext: true, + hasChangeApp: false, + actionConfirm: true, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionCheckbox: true, // Activate checkbox + actionConfirm: false, // Cancel dialog + }, + }); + }); + + // Should not have applied value from checkbox + testAlwaysAsk(scheme, true); +}); + +/** + * Tests that the external protocol dialogs behave correctly when a null + * principal is passed. + */ +add_task(async function test_null_principal() { + let scheme = TEST_PROTOS[0]; + + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: () => { + let uri = `${scheme}://test`; + ContentTask.spawn(browser, { uri }, args => { + let frame = content.document.createElement("iframe"); + frame.src = `data:text/html,<script>location.href="${args.uri}"</script>`; + content.document.body.appendChild(frame); + }); + }, + permDialogOptions: { + hasCheckbox: false, + chooserIsNext: true, + hasChangeApp: false, + actionConfirm: true, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Tests that the external protocol dialogs behave correctly when no principal + * is passed. + */ +add_task(async function test_no_principal() { + let scheme = TEST_PROTOS[1]; + + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: () => { + let uri = `${scheme}://test`; + + let contentDispatchChooser = Cc[ + "@mozilla.org/content-dispatch-chooser;1" + ].createInstance(Ci.nsIContentDispatchChooser); + + let handler = HandlerServiceTestUtils.getHandlerInfo(scheme); + + contentDispatchChooser.handleURI( + handler, + Services.io.newURI(uri), + null, + browser.browsingContext + ); + }, + permDialogOptions: { + hasCheckbox: false, + chooserIsNext: true, + hasChangeApp: false, + actionConfirm: true, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Tests that if a URI scheme has a non-standard protocol, an OS default exists, + * and the user hasn't selected an alternative only the permission dialog is shown. + */ +add_task(async function test_non_standard_protocol() { + let scheme = getSystemProtocol(); + if (!scheme) { + return; + } + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: true, + chooserIsNext: false, + actionChangeApp: false, + }, + }); + }); +}); + +/** + * Tests that we show the permission dialog for extension content scripts. + */ +add_task(async function test_extension_content_script_permission() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + let testExtension; + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + let uri = `${scheme}://test`; + + const EXTENSION_DATA = { + manifest: { + content_scripts: [ + { + matches: [browser.currentURI.spec], + js: ["navigate.js"], + }, + ], + browser_specific_settings: { + gecko: { id: "allowed@mochi.test" }, + }, + }, + files: { + "navigate.js": `window.location.href = "${uri}";`, + }, + useAddonManager: "permanent", + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + }, + permDialogOptions: { + hasCheckbox: true, + chooserIsNext: true, + hasChangeApp: false, + actionCheckbox: true, + actionConfirm: true, + checkContents: dialogEl => { + let description = dialogEl.querySelector("#description"); + let { id, args } = + description.ownerDocument.l10n.getAttributes(description); + is( + id, + "permission-dialog-description-extension", + "Should be using the correct string." + ); + is( + args.extension, + "Generated extension", + "Should have the correct extension name." + ); + }, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + let extensionPrivatePrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + { privateBrowsingId: 1 } + ); + + let key = getSkipProtoDialogPermissionKey(scheme); + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.ALLOW_ACTION, + "Should have permanently allowed the extension" + ); + is( + Services.perms.testPermissionFromPrincipal( + extensionPrivatePrincipal, + key + ), + Services.perms.UNKNOWN_ACTION, + "Should not have changed the private principal permission" + ); + is( + Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key), + Services.perms.UNKNOWN_ACTION, + "Should not have allowed the page" + ); + + await testExtension.unload(); + + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.UNKNOWN_ACTION, + "Should have cleared the extension's normal principal permission" + ); + is( + Services.perms.testPermissionFromPrincipal( + extensionPrivatePrincipal, + key + ), + Services.perms.UNKNOWN_ACTION, + "Should have cleared the private browsing principal" + ); + }); +}); + +/** + * Tests that we show the permission dialog for extension content scripts. + */ +add_task(async function test_extension_private_content_script_permission() { + let scheme = TEST_PROTOS[0]; + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: ORIGIN1 }, + async browser => { + let testExtension; + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + let uri = `${scheme}://test`; + + const EXTENSION_DATA = { + manifest: { + content_scripts: [ + { + matches: [browser.currentURI.spec], + js: ["navigate.js"], + }, + ], + browser_specific_settings: { + gecko: { id: "allowed@mochi.test" }, + }, + }, + files: { + "navigate.js": `window.location.href = "${uri}";`, + }, + useAddonManager: "permanent", + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + let perms = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }; + await ExtensionPermissions.add("allowed@mochi.test", perms); + let addon = await AddonManager.getAddonByID("allowed@mochi.test"); + await addon.reload(); + }, + permDialogOptions: { + hasCheckbox: true, + chooserIsNext: true, + hasChangeApp: false, + actionCheckbox: true, + actionConfirm: true, + checkContents: dialogEl => { + let description = dialogEl.querySelector("#description"); + let { id, args } = + description.ownerDocument.l10n.getAttributes(description); + is( + id, + "permission-dialog-description-extension", + "Should be using the correct string." + ); + is( + args.extension, + "Generated extension", + "Should have the correct extension name." + ); + }, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + let extensionPrivatePrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + { privateBrowsingId: 1 } + ); + + let key = getSkipProtoDialogPermissionKey(scheme); + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.UNKNOWN_ACTION, + "Should not have changed the extension's normal principal permission" + ); + is( + Services.perms.testPermissionFromPrincipal( + extensionPrivatePrincipal, + key + ), + Services.perms.ALLOW_ACTION, + "Should have allowed the private browsing principal" + ); + is( + Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key), + Services.perms.UNKNOWN_ACTION, + "Should not have allowed the page" + ); + + await testExtension.unload(); + + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.UNKNOWN_ACTION, + "Should have cleared the extension's normal principal permission" + ); + is( + Services.perms.testPermissionFromPrincipal( + extensionPrivatePrincipal, + key + ), + Services.perms.UNKNOWN_ACTION, + "Should have cleared the private browsing principal" + ); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests that we do not show the permission dialog for extension content scripts + * when the page already has permission. + */ +add_task(async function test_extension_allowed_content() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + let testExtension; + + let key = getSkipProtoDialogPermissionKey(scheme); + Services.perms.addFromPrincipal( + PRINCIPAL1, + key, + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_NEVER + ); + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + let uri = `${scheme}://test`; + + const EXTENSION_DATA = { + manifest: { + content_scripts: [ + { + matches: [browser.currentURI.spec], + js: ["navigate.js"], + }, + ], + }, + files: { + "navigate.js": `window.location.href = "${uri}";`, + }, + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.UNKNOWN_ACTION, + "Should not have permanently allowed the extension" + ); + + await testExtension.unload(); + Services.perms.removeFromPrincipal(PRINCIPAL1, key); + }); +}); + +/** + * Tests that we do not show the permission dialog for extension content scripts + * when the extension already has permission. + */ +add_task(async function test_extension_allowed_extension() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + let testExtension; + + let key = getSkipProtoDialogPermissionKey(scheme); + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + const EXTENSION_DATA = { + manifest: { + permissions: [`${ORIGIN1}/*`], + }, + background() { + browser.test.onMessage.addListener(async (msg, uri) => { + switch (msg) { + case "engage": + browser.tabs.executeScript({ + code: `window.location.href = "${uri}";`, + }); + break; + default: + browser.test.fail(`Unexpected message received: ${msg}`); + } + }); + }, + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + Services.perms.addFromPrincipal( + extensionPrincipal, + key, + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_NEVER + ); + + testExtension.sendMessage("engage", `${scheme}://test`); + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + await testExtension.unload(); + Services.perms.removeFromPrincipal(PRINCIPAL1, key); + }); +}); + +/** + * Tests that we show the permission dialog for extensions directly opening a + * protocol. + */ +add_task(async function test_extension_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + let testExtension; + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + const EXTENSION_DATA = { + background() { + browser.test.onMessage.addListener(async (msg, url) => { + switch (msg) { + case "engage": + browser.tabs.update({ + url, + }); + break; + default: + browser.test.fail(`Unexpected message received: ${msg}`); + } + }); + }, + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + testExtension.sendMessage("engage", `${scheme}://test`); + }, + permDialogOptions: { + hasCheckbox: true, + chooserIsNext: true, + hasChangeApp: false, + actionCheckbox: true, + actionConfirm: true, + checkContents: dialogEl => { + let description = dialogEl.querySelector("#description"); + let { id, args } = + description.ownerDocument.l10n.getAttributes(description); + is( + id, + "permission-dialog-description-extension", + "Should be using the correct string." + ); + is( + args.extension, + "Generated extension", + "Should have the correct extension name." + ); + }, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + + let key = getSkipProtoDialogPermissionKey(scheme); + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.ALLOW_ACTION, + "Should have permanently allowed the extension" + ); + is( + Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key), + Services.perms.UNKNOWN_ACTION, + "Should not have allowed the page" + ); + + await testExtension.unload(); + }); +}); + +/** + * Test that we use the redirect principal for the dialog when applicable. + */ +add_task(async function test_redirect_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: useServerRedirect("location"), + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Test that we use the redirect principal for the dialog for refresh headers. + */ +add_task(async function test_redirect_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: useServerRedirect("refresh"), + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Test that we use the redirect principal for the dialog for meta refreshes. + */ +add_task(async function test_redirect_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: useServerRedirect("meta-refresh"), + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Test that we use the redirect principal for the dialog for JS redirects. + */ +add_task(async function test_redirect_principal_js() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: () => { + let uri = `${scheme}://test`; + + let innerParams = new URLSearchParams(); + innerParams.set("uri", uri); + let params = new URLSearchParams(); + params.set( + "uri", + "https://example.com/" + + ROOT_PATH + + "script_redirect.html?" + + innerParams.toString() + ); + uri = + "https://example.org/" + + ROOT_PATH + + "script_redirect.html?" + + params.toString(); + BrowserTestUtils.startLoadingURIString(browser, uri); + }, + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Test that we use the redirect principal for the dialog for link clicks. + */ +add_task(async function test_redirect_principal_links() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + let uri = `${scheme}://test`; + + let params = new URLSearchParams(); + params.set("uri", uri); + uri = + "https://example.com/" + + ROOT_PATH + + "redirect_helper.sjs?" + + params.toString(); + await ContentTask.spawn(browser, { uri }, args => { + let textLink = content.document.createElement("a"); + textLink.href = args.uri; + textLink.textContent = "click me"; + content.document.body.appendChild(textLink); + textLink.click(); + }); + }, + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js new file mode 100644 index 0000000000..bc4b13730a --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests navigation to external protocol from sandboxed iframes. + */ + +"use strict"; + +requestLongerTimeout(2); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.block_external_protocol_navigation_from_sandbox", true]], + }); + + await setupMailHandler(); +}); + +add_task(async function test_sandbox_disabled() { + await runExtProtocolSandboxTest({ blocked: false, sandbox: null }); +}); + +add_task(async function test_sandbox_allowed() { + let flags = [ + "allow-popups", + "allow-top-navigation", + "allow-top-navigation-by-user-activation", + "allow-top-navigation-to-custom-protocols", + ]; + + for (let flag of flags) { + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: `allow-scripts ${flag}`, + }); + } +}); + +add_task(async function test_sandbox_blocked() { + let flags = [ + "", + "allow-same-origin", + "allow-forms", + "allow-scripts", + "allow-pointer-lock", + "allow-orientation-lock", + "allow-modals", + "allow-popups-to-escape-sandbox", + "allow-presentation", + "allow-storage-access-by-user-activation", + "allow-downloads", + ]; + + for (let flag of flags) { + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: `allow-scripts ${flag}`, + }); + } +}); + +add_task(async function test_sandbox_blocked_triggers() { + info( + "For sandboxed frames external protocol navigation is blocked, no matter how it is triggered." + ); + for (let triggerMethod of [ + "trustedClick", + "untrustedClick", + "trustedLocationAPI", + "untrustedLocationAPI", + "frameSrc", + ]) { + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts", + triggerMethod, + }); + } + + info( + "When allow-top-navigation-by-user-activation navigation to external protocols with transient user activations is allowed." + ); + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: "allow-scripts allow-top-navigation-by-user-activation", + triggerMethod: "trustedClick", + }); + + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts allow-top-navigation-by-user-activation", + triggerMethod: "untrustedClick", + }); + + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts allow-top-navigation-by-user-activation", + triggerMethod: "untrustedLocationAPI", + }); + + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts allow-top-navigation-by-user-activation", + triggerMethod: "frameSrc", + }); +}); + +add_task(async function test_sandbox_combination() { + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: + "allow-scripts allow-downloads allow-top-navigation-to-custom-protocols", + }); + + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: + "allow-scripts allow-top-navigation allow-top-navigation-to-custom-protocols", + }); + + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts allow-modals", + }); +}); + +add_task(async function test_sandbox_iframe_redirect() { + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts", + triggerMethod: "frameSrcRedirect", + }); + + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: "allow-scripts allow-top-navigation-to-custom-protocols", + triggerMethod: "frameSrcRedirect", + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js new file mode 100644 index 0000000000..03d2fe8cf5 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests navigation to external protocol from csp-sandboxed iframes. + */ + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.block_external_protocol_navigation_from_sandbox", true]], + }); + + await setupMailHandler(); +}); + +add_task(async function test_sandbox_csp() { + for (let triggerMethod of [ + "trustedClick", + "untrustedClick", + "trustedLocationAPI", + "untrustedLocationAPI", + ]) { + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: "allow-scripts", + useCSPSandbox: true, + triggerMethod, + }); + } + + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: "allow-scripts allow-top-navigation-to-custom-protocols", + useCSPSandbox: true, + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js b/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js new file mode 100644 index 0000000000..d906d9c55b --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_helperapp() { + // Set up the test infrastructure: + const kProt = "foopydoopydoo"; + const extProtocolSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + let handlerInfo = extProtocolSvc.getProtocolHandlerInfo(kProt); + if (handlerSvc.exists(handlerInfo)) { + handlerSvc.fillHandlerInfo(handlerInfo, ""); + } + // Say we want to use a specific app: + handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + handlerInfo.alwaysAskBeforeHandling = false; + + // Say it's us: + let selfFile = Services.dirsvc.get("XREExeF", Ci.nsIFile); + // Make sure it's the .app + if (AppConstants.platform == "macosx") { + while ( + !selfFile.leafName.endsWith(".app") && + !selfFile.leafName.endsWith(".app/") + ) { + selfFile = selfFile.parent; + } + } + let selfHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + selfHandlerApp.executable = selfFile; + handlerInfo.possibleApplicationHandlers.appendElement(selfHandlerApp); + handlerInfo.preferredApplicationHandler = selfHandlerApp; + handlerSvc.store(handlerInfo); + + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Now, do some safety stubbing. If we do end up recursing we spawn + // infinite tabs. We definitely don't want that. Avoid it by stubbing + // our external URL handling bits: + let oldAddTab = gBrowser.addTab; + registerCleanupFunction(() => (gBrowser.addTab = oldAddTab)); + let wrongThingHappenedPromise = new Promise(resolve => { + gBrowser.addTab = function (aURI) { + ok(false, "Tried to open unexpected URL in a tab: " + aURI); + resolve(null); + // Pass a dummy object to avoid upsetting BrowserContentHandler - + // if it thinks opening the tab failed, it tries to open a window instead, + // which we can't prevent as easily, and at which point we still end up + // with runaway tabs. + return {}; + }; + }); + + let askedUserPromise = waitForProtocolAppChooserDialog(browser, true); + + gBrowser.fixupAndLoadURIString(kProt + ":test", { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL, + }); + let dialog = await Promise.race([ + wrongThingHappenedPromise, + askedUserPromise, + ]); + ok(dialog, "Should have gotten a dialog"); + + let closePromise = waitForProtocolAppChooserDialog(browser, false); + dialog.close(); + await closePromise; + askedUserPromise = null; + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_remember_download_option.js b/uriloader/exthandler/tests/mochitest/browser_remember_download_option.js new file mode 100644 index 0000000000..f3fedab69c --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_remember_download_option.js @@ -0,0 +1,61 @@ +add_task(async function () { + // create mocked objects + let launcher = createMockedObjects(true); + + // open helper app dialog with mocked launcher + let dlg = await openHelperAppDialog(launcher); + + let doc = dlg.document; + let dialogElement = doc.getElementById("unknownContentType"); + + // Set remember choice + ok( + !doc.getElementById("rememberChoice").checked, + "Remember choice checkbox should be not checked." + ); + doc.getElementById("rememberChoice").checked = true; + + // Make sure the mock handler information is not in nsIHandlerService + ok( + !gHandlerSvc.exists(launcher.MIMEInfo), + "Should not be in nsIHandlerService." + ); + + // close the dialog by pushing the ok button. + let dialogClosedPromise = BrowserTestUtils.windowClosed(dlg); + // Make sure the ok button is enabled, since the ok button might be disabled by + // EnableDelayHelper mechanism. Please refer the detailed + // https://searchfox.org/mozilla-central/source/toolkit/components/prompts/src/PromptUtils.sys.mjs#51 + dialogElement.getButton("accept").disabled = false; + dialogElement.acceptDialog(); + await dialogClosedPromise; + + // check the mocked handler information is saved in nsIHandlerService + ok(gHandlerSvc.exists(launcher.MIMEInfo), "Should be in nsIHandlerService."); + // check the extension. + var mimeType = gHandlerSvc.getTypeFromExtension("abc"); + is(mimeType, launcher.MIMEInfo.type, "Got correct mime type."); + for (let handlerInfo of gHandlerSvc.enumerate()) { + if (handlerInfo.type == launcher.MIMEInfo.type) { + // check the alwaysAskBeforeHandling + ok( + !handlerInfo.alwaysAskBeforeHandling, + "Should turn off the always ask." + ); + // check the preferredApplicationHandler + ok( + handlerInfo.preferredApplicationHandler.equals( + launcher.MIMEInfo.preferredApplicationHandler + ), + "Should be equal to the mockedHandlerApp." + ); + // check the perferredAction + is( + handlerInfo.preferredAction, + launcher.MIMEInfo.preferredAction, + "Should be equal to Ci.nsIHandlerInfo.useHelperApp." + ); + break; + } + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_save_filenames.js b/uriloader/exthandler/tests/mochitest/browser_save_filenames.js new file mode 100644 index 0000000000..46750d5c87 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_save_filenames.js @@ -0,0 +1,860 @@ +// There are at least seven different ways in a which a file can be saved or downloaded. This +// test ensures that the filename is determined correctly when saving in these ways. The seven +// ways are: +// - save the file individually from the File menu +// - save as complete web page (this uses a different codepath than the previous one) +// - dragging an image to the local file system +// - copy an image and paste it as a file to the local file system (windows only) +// - open a link with content-disposition set to attachment +// - open a link with the download attribute +// - save a link or image from the context menu + +requestLongerTimeout(8); + +let types = { + text: "text/plain", + html: "text/html", + png: "image/png", + jpeg: "image/jpeg", + webp: "image/webp", + otherimage: "image/unknown", + // Other js types (application/javascript and text/javascript) are handled by the system + // inconsistently, but application/x-javascript is handled by the external helper app service, + // so it is used here for this test. + js: "application/x-javascript", + binary: "application/octet-stream", + nonsense: "application/x-nonsense", + zip: "application/zip", + json: "application/json", + tar: "application/x-tar", +}; + +const PNG_DATA = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" +); + +const JPEG_DATA = atob( + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4z" + + "NDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEB" + + "AxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS" + + "0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKz" + + "tLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgEC" + + "BAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpj" + + "ZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6" + + "/9oADAMBAAIRAxEAPwD3+iiigD//2Q==" +); + +const WEBP_DATA = atob( + "UklGRiIAAABXRUJQVlA4TBUAAAAvY8AYAAfQ/4j+B4CE8H+/ENH/VCIA" +); + +const DEFAULT_FILENAME = + AppConstants.platform == "win" ? "Untitled.htm" : "Untitled.html"; + +const PROMISE_FILENAME_TYPE = "application/x-moz-file-promise-dest-filename"; + +let MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +let expectedItems; +let sendAsAttachment = false; +let httpServer = null; + +function handleRequest(aRequest, aResponse) { + const queryString = new URLSearchParams(aRequest.queryString); + let type = queryString.get("type"); + let filename = queryString.get("filename"); + let dispname = queryString.get("dispname"); + + aResponse.setStatusLine(aRequest.httpVersion, 200); + if (type) { + aResponse.setHeader("Content-Type", types[type]); + } + + if (dispname) { + let dispositionType = sendAsAttachment ? "attachment" : "inline"; + aResponse.setHeader( + "Content-Disposition", + dispositionType + ';name="' + dispname + '"' + ); + } else if (filename) { + let dispositionType = sendAsAttachment ? "attachment" : "inline"; + aResponse.setHeader( + "Content-Disposition", + dispositionType + ';filename="' + filename + '"' + ); + } else if (sendAsAttachment) { + aResponse.setHeader("Content-Disposition", "attachment"); + } + + if (type == "png") { + aResponse.write(PNG_DATA); + } else if (type == "jpeg") { + aResponse.write(JPEG_DATA); + } else if (type == "webp") { + aResponse.write(WEBP_DATA); + } else if (type == "html") { + aResponse.write( + "<html><head><title>file.inv</title></head><body>File</body></html>" + ); + } else { + aResponse.write("// Some Text"); + } +} + +function handleBasicImageRequest(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "image/png"); + aResponse.write(PNG_DATA); +} + +function handleRedirect(aRequest, aResponse) { + const queryString = new URLSearchParams(aRequest.queryString); + let filename = queryString.get("filename"); + + aResponse.setStatusLine(aRequest.httpVersion, 302); + aResponse.setHeader("Location", "/bell" + filename[0] + "?" + queryString); +} + +// nsIFile::CreateUnique crops long filenames if the path is too long, but +// we don't know exactly how long depending on the full path length, so +// for those save methods that use CreateUnique, instead just verify that +// the filename starts with the right string and has the correct extension. +function checkShortenedFilename(actual, expected) { + if (actual.length < expected.length) { + let actualDot = actual.lastIndexOf("."); + let actualExtension = actual.substring(actualDot); + let expectedExtension = expected.substring(expected.lastIndexOf(".")); + if ( + actualExtension == expectedExtension && + expected.startsWith(actual.substring(0, actualDot)) + ) { + return true; + } + } + + return false; +} + +add_setup(async function () { + const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" + ); + httpServer = new HttpServer(); + httpServer.start(8000); + + // Need to load the page from localhost:8000 as the download attribute + // only applies to links from the same domain. + let saveFilenamesPage = await IOUtils.getFile( + Services.dirsvc.get("CurWorkD", Ci.nsIFile).path, + ..."browser/uriloader/exthandler/tests/mochitest/save_filenames.html".split( + "/" + ) + ); + httpServer.registerFile("/save_filenames.html", saveFilenamesPage); + + // A variety of different scripts are set up to better ensure uniqueness. + httpServer.registerPathHandler("/save_filename.sjs", handleRequest); + httpServer.registerPathHandler("/save_thename.sjs", handleRequest); + httpServer.registerPathHandler("/getdata.png", handleRequest); + httpServer.registerPathHandler("/base", handleRequest); + httpServer.registerPathHandler("/basedata", handleRequest); + httpServer.registerPathHandler("/basetext", handleRequest); + httpServer.registerPathHandler("/text2.txt", handleRequest); + httpServer.registerPathHandler("/text3.gonk", handleRequest); + httpServer.registerPathHandler("/basic.png", handleBasicImageRequest); + httpServer.registerPathHandler("/aquamarine.jpeg", handleBasicImageRequest); + httpServer.registerPathHandler("/lazuli.exe", handleBasicImageRequest); + httpServer.registerPathHandler("/redir", handleRedirect); + httpServer.registerPathHandler("/bellr", handleRequest); + httpServer.registerPathHandler("/bellg", handleRequest); + httpServer.registerPathHandler("/bellb", handleRequest); + httpServer.registerPathHandler("/executable.exe", handleRequest); + + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://localhost:8000/save_filenames.html" + ); + + expectedItems = await getItems("items"); +}); + +function getItems(parentid) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [parentid, AppConstants.platform], + (id, platform) => { + let elements = []; + let elem = content.document.getElementById(id).firstElementChild; + while (elem) { + let filename = + elem.dataset["filenamePlatform" + platform] || elem.dataset.filename; + let url = elem.getAttribute("src") || elem.getAttribute("href"); + let draggable = + elem.localName == "img" && elem.dataset.nodrag != "true"; + let unknown = elem.dataset.unknown; + let noattach = elem.dataset.noattach; + let savepagename = elem.dataset.savepagename; + let pickedfilename = elem.dataset.pickedfilename; + elements.push({ + draggable, + unknown, + filename, + url, + noattach, + savepagename, + pickedfilename, + }); + elem = elem.nextElementSibling; + } + return elements; + } + ); +} + +function getDirectoryEntries(dir) { + let files = []; + let entries = dir.directoryEntries; + while (true) { + let file = entries.nextFile; + if (!file) { + break; + } + files.push(file.leafName); + } + entries.close(); + return files; +} + +// This test saves the document as a complete web page and verifies +// that the resources are saved with the correct filename. +add_task(async function save_document() { + let browser = gBrowser.selectedBrowser; + + let tmp = SpecialPowers.Services.dirsvc.get("TmpD", Ci.nsIFile); + const baseFilename = "test_save_filenames_" + Date.now(); + + let tmpFile = tmp.clone(); + tmpFile.append(baseFilename + "_document.html"); + let tmpDir = tmp.clone(); + tmpDir.append(baseFilename + "_document_files"); + + MockFilePicker.displayDirectory = tmpDir; + MockFilePicker.showCallback = function (fp) { + MockFilePicker.setFiles([tmpFile]); + MockFilePicker.filterIndex = 0; // kSaveAsType_Complete + }; + + let downloadsList = await Downloads.getList(Downloads.PUBLIC); + let savePromise = promiseDownloadFinished(downloadsList); + saveBrowser(browser); + await savePromise; + + let filesSaved = getDirectoryEntries(tmpDir); + + for (let idx = 0; idx < expectedItems.length; idx++) { + let filename = expectedItems[idx].filename; + if (idx == 66 && AppConstants.platform == "win") { + // This is special-cased on Windows. The default filename will be used, since + // the filename is invalid, but since the previous test file has the same issue, + // this second file will be saved with a number suffix added to it. + filename = "Untitled_002"; + } + + let file = tmpDir.clone(); + file.append(filename); + + let fileIdx = -1; + // Use checkShortenedFilename to check long filenames. + if (filename.length > 240) { + for (let t = 0; t < filesSaved.length; t++) { + if ( + filesSaved[t].length > 60 && + checkShortenedFilename(filesSaved[t], filename) + ) { + fileIdx = t; + break; + } + } + } else { + fileIdx = filesSaved.indexOf(filename); + } + + ok( + fileIdx >= 0, + "file i" + + idx + + " " + + filename + + " was saved with the correct name using saveDocument" + ); + if (fileIdx >= 0) { + // If found, remove it from the list. At end of the test, the + // list should be empty. + filesSaved.splice(fileIdx, 1); + } + } + + is(filesSaved.length, 0, "all files accounted for"); + tmpDir.remove(true); + tmpFile.remove(false); + downloadsList.removeFinished(); +}); + +// This test simulates dragging the images in the document and ensuring that +// the correct filename is used for each one. +// On Mac, the data is added in the parent process instead, so we cannot +// test dragging directly. +if (AppConstants.platform != "macosx") { + add_task(async function drag_files() { + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn(browser, [PROMISE_FILENAME_TYPE], type => { + content.addEventListener("dragstart", event => { + content.draggedFile = event.dataTransfer.getData(type); + event.preventDefault(); + }); + }); + + for (let idx = 0; idx < expectedItems.length; idx++) { + if (!expectedItems[idx].draggable) { + // You can't drag non-images and invalid images. + continue; + } + + await BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 1, + 1, + { type: "mousedown" }, + browser + ); + await BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 11, + 11, + { type: "mousemove" }, + browser + ); + await BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 20, + 20, + { type: "mousemove" }, + browser + ); + await BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 20, + 20, + { type: "mouseup" }, + browser + ); + + let draggedFile = await SpecialPowers.spawn(browser, [], () => { + let file = content.draggedFile; + content.draggedFile = null; + return file; + }); + + is( + draggedFile, + expectedItems[idx].filename, + "i" + + idx + + " " + + expectedItems[idx].filename + + " was saved with the correct name when dragging" + ); + } + }); +} + +// This test checks that copying an image provides the right filename +// for pasting to the local file system. This is only implemented on Windows. +const imageAsFileEnabled = SpecialPowers.getBoolPref( + "clipboard.imageAsFile.enabled", + false +); +if (AppConstants.platform == "win" && imageAsFileEnabled) { + add_task(async function copy_image() { + for (let idx = 0; idx < expectedItems.length; idx++) { + if (!expectedItems[idx].draggable) { + // You can't context-click on non-images. + continue; + } + + let data = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [idx, PROMISE_FILENAME_TYPE], + (imagenum, type) => { + // No need to wait for the data to be really on the clipboard, we only + // need the promise data added when the command is performed. + SpecialPowers.setCommandNode( + content, + content.document.getElementById("i" + imagenum) + ); + SpecialPowers.doCommand(content, "cmd_copyImageContents"); + + return SpecialPowers.getClipboardData(type); + } + ); + + is( + data, + expectedItems[idx].filename, + "i" + + idx + + " " + + expectedItems[idx].filename + + " was saved with the correct name when copying" + ); + } + }); +} + +// This test checks the default filename selected when selecting to save +// a file from either the context menu or what would happen when save page +// as was selected from the file menu. Note that this tests a filename assigned +// when using content-disposition: inline. +add_task(async function saveas_files() { + // Iterate over each item and try saving them from the context menu, + // and the Save Page As command on the File menu. + for (let testname of ["context menu", "save page as"]) { + for (let idx = 0; idx < expectedItems.length; idx++) { + let menu; + if (testname == "context menu") { + if (!expectedItems[idx].draggable) { + // You can't context-click on non-images. + continue; + } + + menu = document.getElementById("contentAreaContextMenu"); + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 5, + 5, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShown; + } else { + if (expectedItems[idx].unknown == "typeonly") { + // Items marked with unknown="typeonly" have unknown content types and + // will be downloaded instead of opened in a tab. + let list = await Downloads.getList(Downloads.PUBLIC); + let downloadFinishedPromise = promiseDownloadFinished(list); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: expectedItems[idx].url, + waitForLoad: false, + waitForStateStop: true, + }); + + let download = await downloadFinishedPromise; + + let filename = PathUtils.filename(download.target.path); + + let expectedFilename = expectedItems[idx].filename; + if (expectedFilename.length > 240) { + ok( + checkShortenedFilename(filename, expectedFilename), + "open link" + + idx + + " " + + expectedFilename + + " was downloaded with the correct name when opened as a url (with long name)" + ); + } else { + is( + filename, + expectedFilename, + "open link" + + idx + + " " + + expectedFilename + + " was downloaded with the correct name when opened as a url" + ); + } + + try { + await IOUtils.remove(download.target.path); + } catch (ex) {} + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + continue; + } + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: expectedItems[idx].url, + waitForLoad: false, + waitForStateStop: true, + }); + } + + let filename = await new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(() => { + resolve(fp.defaultString); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + + if (testname == "context menu") { + let menuitem = document.getElementById("context-saveimage"); + menu.activateItem(menuitem); + } else if (testname == "save page as") { + document.getElementById("Browser:SavePage").doCommand(); + } + }); + + // Trying to open an unknown or binary type will just open a blank + // page, so trying to save will just save the blank page with the + // filename Untitled.html. + let expectedFilename = expectedItems[idx].unknown + ? DEFAULT_FILENAME + : expectedItems[idx].savepagename || expectedItems[idx].filename; + + // When saving via contentAreaUtils.js, the content disposition name + // field is used as an alternate. + if (expectedFilename == "save_thename.png") { + expectedFilename = "withname.png"; + } + + is( + filename, + expectedFilename, + "i" + + idx + + " " + + expectedFilename + + " was saved with the correct name " + + testname + ); + + if (testname == "save page as") { + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + } + } +}); + +// This test checks that the filename is saved correctly when it +// has been modified within the file picker. +add_task(async function saveas_files_modified_in_filepicker() { + let items = await getItems("modifieditems"); + for (let idx = 0; idx < items.length; idx++) { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: items[idx].url, + waitForLoad: false, + waitForStateStop: true, + }); + + let savedFile = SpecialPowers.Services.dirsvc.get("TmpD", Ci.nsIFile); + + let downloadsList = await Downloads.getList(Downloads.PUBLIC); + let savePromise = promiseDownloadFinished(downloadsList); + + await new Promise(resolve => { + MockFilePicker.displayDirectory = savedFile; + + MockFilePicker.showCallback = function (fp) { + MockFilePicker.filterIndex = 0; // kSaveAsType_Complete + savedFile.append(items[idx].pickedfilename); + MockFilePicker.setFiles([savedFile]); + setTimeout(() => { + resolve(items[idx].pickedfilename); + }, 0); + + return Ci.nsIFilePicker.returnOK; + }; + + document.getElementById("Browser:SavePage").doCommand(); + }); + + await savePromise; + + savedFile.leafName = items[idx].filename; + ok( + savedFile.exists(), + "i" + + idx + + " '" + + savedFile.leafName + + "' was saved when modified with the correct name " + ); + if (savedFile.exists()) { + savedFile.remove(false); + } + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); + +// This test checks that links that result in files with +// content-disposition: attachment are saved with the right filenames. +add_task(async function save_links() { + sendAsAttachment = true; + + // Create some links based on each image and insert them into the document. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let doc = content.document; + let insertPos = doc.getElementById("attachment-links"); + + let idx = 0; + let elem = doc.getElementById("items").firstElementChild; + while (elem) { + let attachmentlink = doc.createElement("a"); + attachmentlink.id = "attachmentlink" + idx; + attachmentlink.href = elem.localName == "object" ? elem.data : elem.src; + attachmentlink.textContent = elem.dataset.filename; + insertPos.appendChild(attachmentlink); + insertPos.appendChild(doc.createTextNode(" ")); + + elem = elem.nextElementSibling; + idx++; + } + }); + + let list = await Downloads.getList(Downloads.PUBLIC); + + for (let idx = 0; idx < expectedItems.length; idx++) { + // Skip the items that won't have a content-disposition. + if (expectedItems[idx].noattach) { + continue; + } + + let downloadFinishedPromise = promiseDownloadFinished(list); + + BrowserTestUtils.synthesizeMouse( + "#attachmentlink" + idx, + 5, + 5, + {}, + gBrowser.selectedBrowser + ); + + let download = await downloadFinishedPromise; + + let filename = PathUtils.filename(download.target.path); + + let expectedFilename = expectedItems[idx].filename; + // Use checkShortenedFilename to check long filenames. + if (expectedItems[idx].filename.length > 240) { + ok( + checkShortenedFilename(filename, expectedFilename), + "attachmentlink" + + idx + + " " + + expectedFilename + + " was saved with the correct name when opened as attachment (with long name)" + ); + } else { + is( + filename, + expectedFilename, + "attachmentlink" + + idx + + " " + + expectedFilename + + " was saved with the correct name when opened as attachment" + ); + } + + try { + await IOUtils.remove(download.target.path); + } catch (ex) {} + } + + sendAsAttachment = false; +}); + +// This test checks some cases where links to images are saved using Save Link As, +// and when opening them in a new tab and then using Save Page As. +add_task(async function saveas_image_links() { + let links = await getItems("links"); + + // Iterate over each link and try saving the links from the context menu, + // and then after opening a new tab for that link and then selecting + // the Save Page As command on the File menu. + for (let testname of ["save link as", "save link then save page as"]) { + for (let idx = 0; idx < links.length; idx++) { + let menu = document.getElementById("contentAreaContextMenu"); + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + "#link" + idx, + 5, + 5, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShown; + + let promptPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(() => { + resolve(fp.defaultString); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + }); + + if (testname == "save link as") { + let menuitem = document.getElementById("context-savelink"); + menu.activateItem(menuitem); + } else { + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + + let menuitem = document.getElementById("context-openlinkintab"); + menu.activateItem(menuitem); + + let tab = await newTabPromise; + await BrowserTestUtils.switchTab(gBrowser, tab); + + document.getElementById("Browser:SavePage").doCommand(); + } + + let filename = await promptPromise; + + let expectedFilename = links[idx].filename; + // Only codepaths that go through contentAreaUtils.js use the + // name from the content disposition. + if (testname == "save link as" && expectedFilename == "four.png") { + expectedFilename = "save_filename.png"; + } + + is( + filename, + expectedFilename, + "i" + + idx + + " " + + expectedFilename + + " link was saved with the correct name " + + testname + ); + + if (testname == "save link then save page as") { + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + } + } +}); + +// This test checks that links that with a download attribute +// are saved with the right filenames. +add_task(async function save_download_links() { + let downloads = await getItems("downloads"); + + let list = await Downloads.getList(Downloads.PUBLIC); + for (let idx = 0; idx < downloads.length; idx++) { + let downloadFinishedPromise = promiseDownloadFinished(list); + + BrowserTestUtils.synthesizeMouse( + "#download" + idx, + 2, + 2, + {}, + gBrowser.selectedBrowser + ); + + let download = await downloadFinishedPromise; + + let filename = PathUtils.filename(download.target.path); + + if (downloads[idx].filename.length > 240) { + ok( + checkShortenedFilename(filename, downloads[idx].filename), + "download" + + idx + + " " + + downloads[idx].filename + + " was saved with the correct name when link has download attribute" + ); + } else { + if (idx == 66 && filename == "Untitled(1)") { + // Sometimes, the previous test's file still exists or wasn't created in time + // and a non-duplicated name is created. Allow this rather than figuring out + // how to avoid it since it doesn't affect what is being tested here. + filename = "Untitled"; + } + + is( + filename, + downloads[idx].filename, + "download" + + idx + + " " + + downloads[idx].filename + + " was saved with the correct name when link has download attribute" + ); + } + + try { + await IOUtils.remove(download.target.path); + } catch (ex) {} + } +}); + +// This test verifies that invalid extensions are not removed when they +// have been entered in the file picker. +add_task(async function save_page_with_invalid_after_filepicker() { + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://localhost:8000/save_filename.sjs?type=html&filename=invfile.lnk" + ); + + let filename = await new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + let expectedFilename = + AppConstants.platform == "win" ? "invfile.lnk.htm" : "invfile.lnk.html"; + is(fp.defaultString, expectedFilename, "supplied filename is correct"); + setTimeout(() => { + resolve("otherfile.local"); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + + document.getElementById("Browser:SavePage").doCommand(); + }); + + is(filename, "otherfile.local", "lnk extension has been preserved"); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function save_page_with_invalid_extension() { + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://localhost:8000/save_filename.sjs?type=html" + ); + + let filename = await new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(() => { + resolve(fp.defaultString); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + + document.getElementById("Browser:SavePage").doCommand(); + }); + + is( + filename, + AppConstants.platform == "win" ? "file.inv.htm" : "file.inv.html", + "html extension has been added" + ); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async () => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + MockFilePicker.cleanup(); + await new Promise(resolve => httpServer.stop(resolve)); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js b/uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js new file mode 100644 index 0000000000..6bf375dfff --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js @@ -0,0 +1,303 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const { handleInternally, useHelperApp, useSystemDefault, saveToDisk } = + Ci.nsIHandlerInfo; + +let MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.useDownloadDir", false], + ], + }); + + registerCleanupFunction(async () => { + let hiddenPromise = BrowserTestUtils.waitForEvent( + DownloadsPanel.panel, + "popuphidden" + ); + DownloadsPanel.hidePanel(); + await hiddenPromise; + MockFilePicker.cleanup(); + }); +}); + +// This test ensures that a "Save as..." filepicker dialog is shown for a file +// if useDownloadDir ("Always ask where to save files") is set to false and +// the filetype is set to save to disk. +add_task(async function aDownloadSavedToDiskPromptsForFolder() { + let publicList = await Downloads.getList(Downloads.PUBLIC); + ensureMIMEState( + { preferredAction: saveToDisk }, + { type: "text/plain", ext: "txt" } + ); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(resolve, 0); + return Ci.nsIFilePicker.returnCancel; + }; + }); + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_txt_attachment_test.txt", + waitForLoad: false, + waitForStateStop: true, + }); + + info("Waiting on filepicker."); + await filePickerShownPromise; + ok(true, "filepicker should have been shown"); + + BrowserTestUtils.removeTab(loadingTab); +}); + +// This test ensures that downloads configured to open internally create only +// one file destination when saved via the filepicker, and don't prompt. +add_task(async function testFilesHandledInternally() { + let dir = await setupFilePickerDirectory(); + + ensureMIMEState( + { preferredAction: handleInternally }, + { type: "image/webp", ext: "webp" } + ); + + let filePickerShown = false; + MockFilePicker.showCallback = function (fp) { + filePickerShown = true; + return Ci.nsIFilePicker.returnCancel; + }; + + let thirdTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => { + info("Got load for " + url); + return url.endsWith("file_green.webp") && url.startsWith("file:"); + }, + true, + true + ); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_green.webp", + waitForLoad: false, + waitForStateStop: true, + }); + + let openedTab = await thirdTabPromise; + ok(!filePickerShown, "file picker should not have shown up."); + + assertCorrectFile(dir, "file_green.webp"); + + // Cleanup + BrowserTestUtils.removeTab(loadingTab); + BrowserTestUtils.removeTab(openedTab); +}); + +// This test ensures that downloads configured to open with a system default +// app create only one file destination and don't open the filepicker. +add_task(async function testFilesHandledBySystemDefaultApp() { + let dir = await setupFilePickerDirectory(); + + ensureMIMEState({ preferredAction: useSystemDefault }); + + let filePickerShown = false; + MockFilePicker.showCallback = function (fp) { + filePickerShown = true; + return Ci.nsIFilePicker.returnCancel; + }; + + let oldLaunchFile = DownloadIntegration.launchFile; + let launchFileCalled = new Promise(resolve => { + DownloadIntegration.launchFile = async (file, mimeInfo) => { + is( + useSystemDefault, + mimeInfo.preferredAction, + "The file should be launched with a system app handler." + ); + resolve(); + }; + }); + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_pdf_application_pdf.pdf", + waitForLoad: false, + waitForStateStop: true, + }); + + await launchFileCalled; + ok(!filePickerShown, "file picker should not have shown up."); + + assertCorrectFile(dir, "file_pdf_application_pdf.pdf"); + + // Cleanup + BrowserTestUtils.removeTab(loadingTab); + DownloadIntegration.launchFile = oldLaunchFile; +}); + +// This test ensures that downloads configured to open with a helper app create +// only one file destination when saved via the filepicker. +add_task(async function testFilesHandledByHelperApp() { + let dir = await setupFilePickerDirectory(); + + // Create a custom helper app so we can check that a launcherPath is + // configured for the serialized download. + let appHandler = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + appHandler.name = "Dummy Test Handler"; + appHandler.executable = Services.dirsvc.get("ProfD", Ci.nsIFile); + appHandler.executable.append("helper_handler_test.exe"); + + if (!appHandler.executable.exists()) { + appHandler.executable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o777); + } + + ensureMIMEState({ + preferredAction: useHelperApp, + preferredHandlerApp: appHandler, + }); + + let filePickerShown = false; + MockFilePicker.showCallback = function (fp) { + filePickerShown = true; + return Ci.nsIFilePicker.returnCancel; + }; + + let publicDownloads = await Downloads.getList(Downloads.PUBLIC); + let downloadFinishedPromise = new Promise(resolve => { + publicDownloads.addView({ + onDownloadChanged(download) { + if (download.succeeded || download.error) { + ok( + download.launcherPath.includes("helper_handler_test.exe"), + "Launcher path is available." + ); + resolve(); + } + }, + }); + }); + + let oldLaunchFile = DownloadIntegration.launchFile; + let launchFileCalled = new Promise(resolve => { + DownloadIntegration.launchFile = async (file, mimeInfo) => { + is( + useHelperApp, + mimeInfo.preferredAction, + "The file should be launched with a helper app handler." + ); + resolve(); + }; + }); + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_pdf_application_pdf.pdf", + waitForLoad: false, + waitForStateStop: true, + }); + + await downloadFinishedPromise; + await launchFileCalled; + ok(!filePickerShown, "file picker should not have shown up."); + assertCorrectFile(dir, "file_pdf_application_pdf.pdf"); + + // Cleanup + BrowserTestUtils.removeTab(loadingTab); + DownloadIntegration.launchFile = oldLaunchFile; +}); + +async function setupFilePickerDirectory() { + let saveDir = createSaveDir(); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, saveDir); + Services.prefs.setIntPref("browser.download.folderList", 2); + + MockFilePicker.displayDirectory = saveDir; + MockFilePicker.returnValue = MockFilePicker.returnOK; + MockFilePicker.showCallback = function (fp) { + let file = saveDir.clone(); + file.append(fp.defaultString); + MockFilePicker.setFiles([file]); + }; + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("browser.download.folderList"); + let publicList = await Downloads.getList(Downloads.PUBLIC); + let unfinishedDownloads = new Set( + (await publicList.getAll()).filter(dl => !dl.succeeded && !dl.error) + ); + if (unfinishedDownloads.size) { + info(`Have ${unfinishedDownloads.size} unfinished downloads, waiting.`); + await new Promise(resolve => { + let view = { + onChanged(dl) { + if (unfinishedDownloads.has(dl) && (dl.succeeded || dl.error)) { + unfinishedDownloads.delete(dl); + info(`Removed another download.`); + if (!unfinishedDownloads.size) { + publicList.removeView(view); + resolve(); + } + } + }, + }; + publicList.addView(view); + }); + } + try { + await IOUtils.remove(saveDir.path, { recursive: true }); + } catch (e) { + console.error(e); + } + }); + + return saveDir; +} + +function assertCorrectFile(saveDir, filename) { + info("Make sure additional files haven't been created."); + let iter = saveDir.directoryEntries; + let file = iter.nextFile; + ok(file.path.includes(filename), "Download has correct filename"); + ok(!iter.nextFile, "Only one file was created."); +} + +function createSaveDir() { + info("Creating save directory."); + let time = new Date().getTime(); + let saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + saveDir.append(time); + return saveDir; +} + +function ensureMIMEState( + { preferredAction, preferredHandlerApp = null }, + { type = "application/pdf", ext = "pdf" } = {} +) { + const mimeInfo = gMimeSvc.getFromTypeAndExtension(type, ext); + mimeInfo.preferredAction = preferredAction; + mimeInfo.preferredApplicationHandler = preferredHandlerApp; + mimeInfo.alwaysAskBeforeHandling = false; + gHandlerSvc.store(mimeInfo); +} diff --git a/uriloader/exthandler/tests/mochitest/browser_standalone_application_chooser_window_fallback.js b/uriloader/exthandler/tests/mochitest/browser_standalone_application_chooser_window_fallback.js new file mode 100644 index 0000000000..40496fb3b2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_standalone_application_chooser_window_fallback.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the fallback fixed in bug 1875460, if the modern tab dialog box is not +// supported. + +const TEST_URL = + "https://example.com/browser/" + + "uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html"; + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.external_protocol_requires_permission", false]], + }); + + // Load a page with an FTP link. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, TEST_URL); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + + // Make sure no handler is set, forcing the dialog to show. + let protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("ftp"); + ok(!protoInfo.preferredApplicationHandler, "no preferred handler is set"); + let handlers = protoInfo.possibleApplicationHandlers; + is(0, handlers.length, "no handler registered for ftp"); + protoInfo.alwaysAskBeforeHandling = true; + let handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + handlerSvc.store(protoInfo); + + // Delete getTabDialogBox from gBrowser, to test the fallback to the standalone + // application chooser window. + let _getTabDialogBox = gBrowser.getTabDialogBox; + delete gBrowser.getTabDialogBox; + + let appChooserDialogOpenPromise = BrowserTestUtils.domWindowOpened( + null, + async win => { + await BrowserTestUtils.waitForEvent(win, "load"); + Assert.ok( + win.document.documentURI == + "chrome://mozapps/content/handling/appChooser.xhtml", + "application chooser dialog opened" + ); + return true; + } + ); + let link = "#link"; + await BrowserTestUtils.synthesizeMouseAtCenter(link, {}, browser); + let appChooserDialog = await appChooserDialogOpenPromise; + + let appChooserDialogClosePromise = + BrowserTestUtils.domWindowClosed(appChooserDialog); + let dialog = appChooserDialog.document.getElementsByTagName("dialog")[0]; + let cancelButton = dialog.getButton("cancel"); + cancelButton.click(); + await appChooserDialogClosePromise; + + // Restore the original getTabDialogBox(), to not affect other tests. + gBrowser.getTabDialogBox = _getTabDialogBox; +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js b/uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js new file mode 100644 index 0000000000..7155c35fd9 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); +const HandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" +].getService(Ci.nsIHandlerService); +const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const { + saveToDisk, + alwaysAsk, + handleInternally, + useHelperApp, + useSystemDefault, +} = Ci.nsIHandlerInfo; +const testDir = createTemporarySaveDirectory(); +const MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js", + this +); + +async function testSaveAsDownload() { + await BrowserTestUtils.withNewTab( + `data:text/html,<a id="test-link" href="${TEST_PATH}/file_txt_attachment_test.txt">Test TXT Link</a>`, + async browser => { + let menu = document.getElementById("contentAreaContextMenu"); + ok(menu, "Context menu exists on the page"); + + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouseAtCenter( + "a#test-link", + { type: "contextmenu", button: 2 }, + browser + ); + await popupShown; + info("Context menu popup was successfully displayed"); + + let filePickerPromise = setupFilePicker(); + + info("Clicking Save As... context menu"); + let menuitem = menu.querySelector("#context-savelink"); + menu.activateItem(menuitem); + await filePickerPromise; + } + ); +} + +async function setupFilePicker() { + return new Promise(resolve => { + MockFilePicker.returnValue = MockFilePicker.returnOK; + MockFilePicker.displayDirectory = testDir; + MockFilePicker.showCallback = fp => { + ok(true, "filepicker should be visible"); + ok( + fp.defaultExtension === "txt", + "Default extension in filepicker should be txt" + ); + ok( + fp.defaultString === "file_txt_attachment_test.txt", + "Default string name in filepicker should have the correct file name" + ); + const destFile = testDir.clone(); + destFile.append(fp.defaultString); + MockFilePicker.setFiles([destFile]); + + mockTransferCallback = success => { + ok(success, "File should have been downloaded successfully"); + ok(destFile.exists(), "File should exist in test directory"); + resolve(destFile); + }; + }; + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.useDownloadDir", false], + ], + }); + mockTransferRegisterer.register(); + + let oldLaunchFile = DownloadIntegration.launchFile; + DownloadIntegration.launchFile = () => { + ok(false, "Download should not have launched"); + }; + + registerCleanupFunction(async () => { + DownloadIntegration.launchFile = oldLaunchFile; + mockTransferRegisterer.unregister(); + + // We only want to run MockFilerPicker.cleanup after the entire test is run. + // Otherwise, we cannot use MockFilePicker for each preferredAction. + MockFilePicker.cleanup(); + + testDir.remove(true); + ok(!testDir.exists(), "Test directory should be removed"); + }); +}); + +/** + * Tests that selecting the context menu item `Save Link As…` on a txt file link + * opens the file picker and only downloads the file without any launches when + * browser.download.always_ask_before_handling_new_types is disabled. + */ +add_task(async function test_txt_save_as_link() { + let mimeInfo; + + for (let preferredAction of [ + saveToDisk, + alwaysAsk, + handleInternally, + useHelperApp, + useSystemDefault, + ]) { + mimeInfo = MIMEService.getFromTypeAndExtension("text/plain", "txt"); + mimeInfo.alwaysAskBeforeHandling = preferredAction === alwaysAsk; + mimeInfo.preferredAction = preferredAction; + HandlerService.store(mimeInfo); + + info( + `Setting up filepicker with preferredAction ${preferredAction} and ask = ${mimeInfo.alwaysAskBeforeHandling}` + ); + await testSaveAsDownload(mimeInfo); + } +}); + +/** + * Tests that selecting the context menu item `Save Link As…` on a txt file link + * opens the file picker and only downloads the file without any launches when + * browser.download.always_ask_before_handling_new_types is disabled. For this + * particular test, set alwaysAskBeforeHandling to true. + */ +add_task(async function test_txt_save_as_link_alwaysAskBeforeHandling() { + let mimeInfo; + + for (let preferredAction of [ + saveToDisk, + alwaysAsk, + handleInternally, + useHelperApp, + useSystemDefault, + ]) { + mimeInfo = MIMEService.getFromTypeAndExtension("text/plain", "txt"); + mimeInfo.alwaysAskBeforeHandling = true; + mimeInfo.preferredAction = preferredAction; + HandlerService.store(mimeInfo); + + info( + `Setting up filepicker with preferredAction ${preferredAction} and ask = ${mimeInfo.alwaysAskBeforeHandling}` + ); + await testSaveAsDownload(mimeInfo); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js b/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js new file mode 100644 index 0000000000..a9d4803fca --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +let testURL = + "http://mochi.test:8888/browser/" + + "uriloader/exthandler/tests/mochitest/mailto.html"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gExternalProtocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "gHandlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +let prevAlwaysAskBeforeHandling; +let prevPreferredAction; +let prevPreferredApplicationHandler; + +add_setup(async function () { + let handler = gExternalProtocolService.getProtocolHandlerInfo("mailto", {}); + + // Create a fake mail handler + const APP_NAME = "ExMail"; + const HANDLER_URL = "https://example.com/?extsrc=mailto&url=%s"; + let app = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance( + Ci.nsIWebHandlerApp + ); + app.uriTemplate = HANDLER_URL; + app.name = APP_NAME; + + // Store defaults + prevAlwaysAskBeforeHandling = handler.alwaysAskBeforeHandling; + prevPreferredAction = handler.preferredAction; + prevPreferredApplicationHandler = handler.preferredApplicationHandler; + + // Set the fake app as default + handler.alwaysAskBeforeHandling = false; + handler.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + handler.preferredApplicationHandler = app; + gHandlerService.store(handler); +}); + +registerCleanupFunction(async function () { + let handler = gExternalProtocolService.getProtocolHandlerInfo("mailto", {}); + handler.alwaysAskBeforeHandling = prevAlwaysAskBeforeHandling; + handler.preferredAction = prevPreferredAction; + handler.preferredApplicationHandler = prevPreferredApplicationHandler; + gHandlerService.store(handler); +}); + +add_task(async function () { + const expectedURL = + "https://example.com/?extsrc=mailto&url=mailto%3Amail%40example.com"; + + // Load a page with mailto handler. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, testURL); + await BrowserTestUtils.browserLoaded(browser, false, testURL); + + // Pin as an app tab + gBrowser.pinTab(gBrowser.selectedTab); + + // Click the link and check the new tab is correct + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL); + await BrowserTestUtils.synthesizeMouseAtCenter("#link", {}, browser); + let tab = await promiseTabOpened; + is( + gURLBar.value, + UrlbarTestUtils.trimURL(expectedURL), + "the mailto web handler is opened in a new tab" + ); + BrowserTestUtils.removeTab(tab); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js b/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js new file mode 100644 index 0000000000..828d02d24b --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js @@ -0,0 +1,130 @@ +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +let testURL = + "https://example.com/browser/" + + "uriloader/exthandler/tests/mochitest/protocolHandler.html"; + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.external_protocol_requires_permission", false]], + }); + + // Load a page registering a protocol handler. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, testURL); + await BrowserTestUtils.browserLoaded(browser, false, testURL); + + // Register the protocol handler by clicking the notificationbar button. + let notificationValue = "Protocol Registration: web+testprotocol"; + let getNotification = () => + gBrowser.getNotificationBox().getNotificationWithValue(notificationValue); + await BrowserTestUtils.waitForCondition(getNotification); + let notification = getNotification(); + let button = notification.buttonContainer.querySelector("button"); + ok(button, "got registration button"); + button.click(); + + // Set the new handler as default. + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("web+testprotocol"); + is( + protoInfo.preferredAction, + protoInfo.useHelperApp, + "using a helper application is the preferred action" + ); + ok(!protoInfo.preferredApplicationHandler, "no preferred handler is set"); + let handlers = protoInfo.possibleApplicationHandlers; + is(1, handlers.length, "only one handler registered for web+testprotocol"); + let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp); + ok(handler instanceof Ci.nsIWebHandlerApp, "the handler is a web handler"); + is( + handler.uriTemplate, + "https://example.com/foobar?uri=%s", + "correct url template" + ); + protoInfo.preferredApplicationHandler = handler; + protoInfo.alwaysAskBeforeHandling = false; + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + handlerSvc.store(protoInfo); + + const expectedURL = + "https://example.com/foobar?uri=web%2Btestprotocol%3Atest"; + + // Create a framed link: + await SpecialPowers.spawn(browser, [], async function () { + let iframe = content.document.createElement("iframe"); + iframe.src = `data:text/html,<a href="web+testprotocol:test">Click me</a>`; + content.document.body.append(iframe); + // Can't return this promise because it resolves to the event object. + await ContentTaskUtils.waitForEvent(iframe, "load"); + iframe.contentDocument.querySelector("a").click(); + }); + let kidContext = browser.browsingContext.children[0]; + await TestUtils.waitForCondition(() => { + let spec = kidContext.currentWindowGlobal?.documentURI?.spec || ""; + return spec == expectedURL; + }); + is( + kidContext.currentWindowGlobal.documentURI.spec, + expectedURL, + "Should load in frame." + ); + + // Middle-click a testprotocol link and check the new tab is correct + let link = "#link"; + + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL); + await BrowserTestUtils.synthesizeMouseAtCenter(link, { button: 1 }, browser); + let tab = await promiseTabOpened; + gBrowser.selectedTab = tab; + is( + gURLBar.value, + UrlbarTestUtils.trimURL(expectedURL), + "the expected URL is displayed in the location bar" + ); + BrowserTestUtils.removeTab(tab); + + // Shift-click the testprotocol link and check the new window. + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: expectedURL, + }); + await BrowserTestUtils.synthesizeMouseAtCenter( + link, + { shiftKey: true }, + browser + ); + let win = await newWindowPromise; + await BrowserTestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == expectedURL + ); + is( + win.gURLBar.value, + UrlbarTestUtils.trimURL(expectedURL), + "the expected URL is displayed in the location bar" + ); + await BrowserTestUtils.closeWindow(win); + + // Click the testprotocol link and check the url in the current tab. + let loadPromise = BrowserTestUtils.browserLoaded(browser); + await BrowserTestUtils.synthesizeMouseAtCenter(link, {}, browser); + await loadPromise; + await BrowserTestUtils.waitForCondition( + () => gURLBar.value != UrlbarTestUtils.trimURL(testURL) + ); + is( + gURLBar.value, + UrlbarTestUtils.trimURL(expectedURL), + "the expected URL is displayed in the location bar" + ); + + // Cleanup. + protoInfo.preferredApplicationHandler = null; + handlers.removeElementAt(0); + handlerSvc.store(protoInfo); +}); diff --git a/uriloader/exthandler/tests/mochitest/download.bin b/uriloader/exthandler/tests/mochitest/download.bin new file mode 100644 index 0000000000..0e4b0c7bae --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/download.bin @@ -0,0 +1 @@ +abc123 diff --git a/uriloader/exthandler/tests/mochitest/download.sjs b/uriloader/exthandler/tests/mochitest/download.sjs new file mode 100644 index 0000000000..d02d2b7355 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/download.sjs @@ -0,0 +1,42 @@ +"use strict"; + +function actuallyHandleRequest(req, res) { + res.setHeader("Content-Type", "application/octet-stream", false); + res.write("abc123"); + res.finish(); +} + +function handleRequest(req, res) { + if (req.queryString.includes("finish")) { + res.write("OK"); + let downloadReq = null; + getObjectState("downloadReq", o => { + downloadReq = o; + }); + // Two possibilities: either the download request has already reached us, or not. + if (downloadReq) { + downloadReq.wrappedJSObject.callback(); + } else { + // Set a variable to allow the request to complete immediately: + setState("finishReq", "true"); + } + } else if (req.queryString.includes("reset")) { + res.write("OK"); + setObjectState("downloadReq", null); + setState("finishReq", "false"); + } else { + res.processAsync(); + if (getState("finishReq") === "true") { + actuallyHandleRequest(req, res); + } else { + let o = { + callback() { + actuallyHandleRequest(req, res); + }, + }; + o.wrappedJSObject = o; + o.QueryInterface = () => o; + setObjectState("downloadReq", o); + } + } +} diff --git a/uriloader/exthandler/tests/mochitest/download_page.html b/uriloader/exthandler/tests/mochitest/download_page.html new file mode 100644 index 0000000000..5a264888fa --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/download_page.html @@ -0,0 +1,22 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> +<html> +<head> + <meta charset=UTF-8> + <title>Test page for link clicking</title> + <script type="text/javascript"> + function launch_download(extra) { + window.open("download.sjs", "_blank", "height=100,width=100" + extra); + } + </script> +</head> +<body> + <a href="download.bin" id="regular_load">regular load</a> + <a href="download.bin" id="target_blank" target="_blank" rel="opener">target blank</a> + <a href="#" onclick="launch_download(''); return false" id="new_window">new window</a> + <a href="#" onclick="window.open('download_page.html?newwin'); return false" id="open_in_new_tab">click to reopen</a> + <a href="download.bin" id="target_blank_no_opener" rel="noopener" target="_blank">target blank (noopener)</a> + <a href="#" onclick="window.open('download.bin', '_blank', 'noopener'); return false" id="open_in_new_tab_no_opener">click to reopen (noopener)</a> + <a href="#" onclick="launch_download(',noopener'); return false" id="new_window_no_opener">new window (noopener)</a> +</body> diff --git a/uriloader/exthandler/tests/mochitest/file_as.exe b/uriloader/exthandler/tests/mochitest/file_as.exe new file mode 100644 index 0000000000..f2f5ab47f3 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_as.exe @@ -0,0 +1 @@ +Not actually an executable... but let's pretend! diff --git a/uriloader/exthandler/tests/mochitest/file_as.exe^headers^ b/uriloader/exthandler/tests/mochitest/file_as.exe^headers^ new file mode 100644 index 0000000000..89f22e30be --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_as.exe^headers^ @@ -0,0 +1,2 @@ +Content-Type: binary/octet-stream +Content-Disposition: attachment diff --git a/uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html b/uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html new file mode 100644 index 0000000000..eb2fb74441 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html @@ -0,0 +1 @@ +<iframe src="mailto:foo@bar.com"></iframe> diff --git a/uriloader/exthandler/tests/mochitest/file_green.webp b/uriloader/exthandler/tests/mochitest/file_green.webp Binary files differnew file mode 100644 index 0000000000..04b7f003b4 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_green.webp diff --git a/uriloader/exthandler/tests/mochitest/file_green.webp^headers^ b/uriloader/exthandler/tests/mochitest/file_green.webp^headers^ new file mode 100644 index 0000000000..3f6afd6625 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_green.webp^headers^ @@ -0,0 +1,3 @@ +Content-Disposition: attachment; filename=file_green.webp +Content-Type: image/webp + diff --git a/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg new file mode 100644 index 0000000000..b730c4c492 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg @@ -0,0 +1,3 @@ +<svg version="1.1" xmlns="http://www.w3.org/2000/svg"> + <rect x="5" y="5" width="10" height="10" fill="white"/> +</svg> diff --git a/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^ b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^ new file mode 100644 index 0000000000..5279ae8636 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^ @@ -0,0 +1,2 @@ +content-disposition: attachment; filename=file_image_svgxml_svg; filename*=UTF-8''file_image_svgxml.svg +content-type: image/svg+xml diff --git a/uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html b/uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html new file mode 100644 index 0000000000..b1bb863f89 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html @@ -0,0 +1 @@ +<iframe srcdoc="<a href='mailto:help@example.com'>Mail someone</a>"></iframe> diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^ new file mode 100644 index 0000000000..9e8cb41cba --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment; filename="file_pdf_application_octet_stream.pdf"; filename*=UTF-8''file_pdf_application_octet_stream.pdf +Content-Type: application/octet-stream diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^ new file mode 100644 index 0000000000..d1d59b9754 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^ @@ -0,0 +1,2 @@ +content-disposition: attachment; filename=file_pdf_application_pdf.pdf; filename*=UTF-8''file_pdf_application_pdf.pdf +content-type: application/pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^ new file mode 100644 index 0000000000..157c0e0943 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^ @@ -0,0 +1,2 @@ +content-disposition: attachment; filename=file_pdf_application_unknown.pdf; filename*=UTF-8''file_pdf_application_unknown.pdf +content-type: application/unknown diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^ new file mode 100644 index 0000000000..6358f54f48 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment; filename="file_pdf_binary_octet_stream.pdf"; filename*=UTF-8''file_pdf_binary_octet_stream.pdf +Content-Type: binary/octet-stream diff --git a/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt diff --git a/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^ b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^ new file mode 100644 index 0000000000..dcfed6af23 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment; filename=file_txt_attachment_test.txt +Content-Type: text/plain diff --git a/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png Binary files differnew file mode 100644 index 0000000000..743292dc6f --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png diff --git a/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^ b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^ new file mode 100644 index 0000000000..06e0cd957f --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: inline; filename=file_with%40%40funny_name.png +Content-Type: image/png diff --git a/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm Binary files differnew file mode 100644 index 0000000000..7bc738b8b4 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm diff --git a/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^ b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^ new file mode 100644 index 0000000000..b77e9d3687 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: inline; filename=file_with%5Bfunny_name.webm +Content-Type: video/webm diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml new file mode 100644 index 0000000000..3a5792586a --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml @@ -0,0 +1,4 @@ +<?xml version = "1.0" encoding = "utf-8"?> + +<something> +</something> diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^ new file mode 100644 index 0000000000..5bdc4448e8 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment +Content-Type: binary/octet-stream diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml new file mode 100644 index 0000000000..3a5792586a --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml @@ -0,0 +1,4 @@ +<?xml version = "1.0" encoding = "utf-8"?> + +<something> +</something> diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^ new file mode 100644 index 0000000000..ac0355d976 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment; filename=file_xml_attachment_test.xml +Content-Type: text/xml diff --git a/uriloader/exthandler/tests/mochitest/head.js b/uriloader/exthandler/tests/mochitest/head.js new file mode 100644 index 0000000000..4b973da06d --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/head.js @@ -0,0 +1,537 @@ +var { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +var { HandlerServiceTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/HandlerServiceTestUtils.sys.mjs" +); + +var gMimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +var gHandlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +function createMockedHandlerApp() { + // Mock the executable + let mockedExecutable = new FileUtils.File( + PathUtils.join(PathUtils.tempDir, "mockedExecutable") + ); + if (!mockedExecutable.exists()) { + mockedExecutable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755); + } + + // Mock the handler app + let mockedHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + mockedHandlerApp.executable = mockedExecutable; + mockedHandlerApp.detailedDescription = "Mocked handler app"; + + registerCleanupFunction(function () { + // remove the mocked executable from disk. + if (mockedExecutable.exists()) { + mockedExecutable.remove(true); + } + }); + + return mockedHandlerApp; +} + +function createMockedObjects(createHandlerApp) { + // Mock the mime info + let internalMockedMIME = gMimeSvc.getFromTypeAndExtension( + "text/x-test-handler", + null + ); + internalMockedMIME.alwaysAskBeforeHandling = true; + internalMockedMIME.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + internalMockedMIME.appendExtension("abc"); + if (createHandlerApp) { + let mockedHandlerApp = createMockedHandlerApp(); + internalMockedMIME.description = mockedHandlerApp.detailedDescription; + internalMockedMIME.possibleApplicationHandlers.appendElement( + mockedHandlerApp + ); + internalMockedMIME.preferredApplicationHandler = mockedHandlerApp; + } + + // Proxy for the mocked MIME info for faking the read-only attributes + let mockedMIME = new Proxy(internalMockedMIME, { + get(target, property) { + switch (property) { + case "hasDefaultHandler": + return true; + case "defaultDescription": + return "Default description"; + default: + return target[property]; + } + }, + }); + + // Mock the launcher: + let mockedLauncher = { + MIMEInfo: mockedMIME, + source: Services.io.newURI("http://www.mozilla.org/"), + suggestedFileName: "test_download_dialog.abc", + targetFileIsExecutable: false, + saveToDisk() {}, + cancel() {}, + setDownloadToLaunch() {}, + setWebProgressListener() {}, + saveDestinationAvailable() {}, + contentLength: 42, + targetFile: null, // never read + // PRTime is microseconds since epoch, Date.now() returns milliseconds: + timeDownloadStarted: Date.now() * 1000, + QueryInterface: ChromeUtils.generateQI([ + "nsICancelable", + "nsIHelperAppLauncher", + ]), + }; + + registerCleanupFunction(function () { + // remove the mocked mime info from database. + let mockHandlerInfo = gMimeSvc.getFromTypeAndExtension( + "text/x-test-handler", + null + ); + if (gHandlerSvc.exists(mockHandlerInfo)) { + gHandlerSvc.remove(mockHandlerInfo); + } + }); + + return mockedLauncher; +} + +function createTemporarySaveDirectory() { + var saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + saveDir.append("testsavedir"); + if (!saveDir.exists()) { + saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + return saveDir; +} + +async function openHelperAppDialog(launcher) { + let helperAppDialog = Cc[ + "@mozilla.org/helperapplauncherdialog;1" + ].createInstance(Ci.nsIHelperAppLauncherDialog); + + let helperAppDialogShownPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + try { + helperAppDialog.show(launcher, window, "foopy"); + } catch (ex) { + ok( + false, + "Trying to show unknownContentType.xhtml failed with exception: " + ex + ); + console.error(ex); + } + let dlg = await helperAppDialogShownPromise; + + is( + dlg.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Got correct dialog" + ); + + return dlg; +} + +/** + * Wait for a subdialog event indicating a dialog either opened + * or was closed. + * + * First argument is the browser in which to listen. If a tabbrowser, + * we listen to subdialogs for any tab of that browser. + */ +async function waitForSubDialog(browser, url, state) { + let eventStr = state ? "dialogopen" : "dialogclose"; + + let eventTarget; + + // Tabbrowser? + if (browser.tabContainer) { + eventTarget = browser.tabContainer.ownerDocument.documentElement; + } else { + // Individual browser. Get its box: + let tabDialogBox = browser.ownerGlobal.gBrowser.getTabDialogBox(browser); + eventTarget = tabDialogBox.getTabDialogManager()._dialogStack; + } + + let checkFn; + + if (state) { + checkFn = dialogEvent => dialogEvent.detail.dialog?._openedURL == url; + } + + let event = await BrowserTestUtils.waitForEvent( + eventTarget, + eventStr, + true, + checkFn + ); + + let { dialog } = event.detail; + + // If the dialog is closing wait for it to be fully closed before resolving + if (!state) { + await dialog._closingPromise; + } + + return event.detail.dialog; +} + +/** + * Wait for protocol permission dialog open/close. + * @param {MozBrowser} browser - Browser element the dialog belongs to. + * @param {boolean} state - true: dialog open, false: dialog close + * @returns {Promise<SubDialog>} - Returns a promise which resolves with the + * SubDialog object of the dialog which closed or opened. + */ +async function waitForProtocolPermissionDialog(browser, state) { + return waitForSubDialog( + browser, + "chrome://mozapps/content/handling/permissionDialog.xhtml", + state + ); +} + +/** + * Wait for protocol app chooser dialog open/close. + * @param {MozBrowser} browser - Browser element the dialog belongs to. + * @param {boolean} state - true: dialog open, false: dialog close + * @returns {Promise<SubDialog>} - Returns a promise which resolves with the + * SubDialog object of the dialog which closed or opened. + */ +async function waitForProtocolAppChooserDialog(browser, state) { + return waitForSubDialog( + browser, + "chrome://mozapps/content/handling/appChooser.xhtml", + state + ); +} + +async function promiseDownloadFinished(list, stopFromOpening) { + return new Promise(resolve => { + list.addView({ + onDownloadChanged(download) { + if (stopFromOpening) { + download.launchWhenSucceeded = false; + } + info("Download changed!"); + if (download.succeeded || download.error) { + info("Download succeeded or errored"); + list.removeView(this); + resolve(download); + } + }, + }); + }); +} + +function setupMailHandler() { + let mailHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("mailto"); + let gOldMailHandlers = []; + + // Remove extant web handlers because they have icons that + // we fetch from the web, which isn't allowed in tests. + let handlers = mailHandlerInfo.possibleApplicationHandlers; + for (let i = handlers.Count() - 1; i >= 0; i--) { + try { + let handler = handlers.queryElementAt(i, Ci.nsIWebHandlerApp); + gOldMailHandlers.push(handler); + // If we get here, this is a web handler app. Remove it: + handlers.removeElementAt(i); + } catch (ex) {} + } + + let previousHandling = mailHandlerInfo.alwaysAskBeforeHandling; + mailHandlerInfo.alwaysAskBeforeHandling = true; + + // Create a dummy web mail handler so we always know the mailto: protocol. + // Without this, the test fails on VMs without a default mailto: handler, + // because no dialog is ever shown, as we ignore subframe navigations to + // protocols that cannot be handled. + let dummy = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance( + Ci.nsIWebHandlerApp + ); + dummy.name = "Handler 1"; + dummy.uriTemplate = "https://example.com/first/%s"; + mailHandlerInfo.possibleApplicationHandlers.appendElement(dummy); + + gHandlerSvc.store(mailHandlerInfo); + registerCleanupFunction(() => { + // Re-add the original protocol handlers: + let mailHandlers = mailHandlerInfo.possibleApplicationHandlers; + for (let i = handlers.Count() - 1; i >= 0; i--) { + try { + // See if this is a web handler. If it is, it'll throw, otherwise, + // we will remove it. + mailHandlers.queryElementAt(i, Ci.nsIWebHandlerApp); + mailHandlers.removeElementAt(i); + } catch (ex) {} + } + for (let h of gOldMailHandlers) { + mailHandlers.appendElement(h); + } + mailHandlerInfo.alwaysAskBeforeHandling = previousHandling; + gHandlerSvc.store(mailHandlerInfo); + }); +} + +let gDownloadDir; + +async function setDownloadDir() { + let tmpDir = PathUtils.join( + PathUtils.tempDir, + "testsavedir" + Math.floor(Math.random() * 2 ** 32) + ); + // Create this dir if it doesn't exist (ignores existing dirs) + await IOUtils.makeDirectory(tmpDir); + registerCleanupFunction(async function () { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + try { + await IOUtils.remove(tmpDir, { recursive: true }); + } catch (e) { + console.error(e); + } + }); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setCharPref("browser.download.dir", tmpDir); + return tmpDir; +} + +add_setup(async function test_common_initialize() { + gDownloadDir = await setDownloadDir(); + Services.prefs.setCharPref("browser.download.loglevel", "Debug"); + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.download.loglevel"); + }); +}); + +async function removeAllDownloads() { + let publicList = await Downloads.getList(Downloads.PUBLIC); + let downloads = await publicList.getAll(); + for (let download of downloads) { + await publicList.remove(download); + if (await IOUtils.exists(download.target.path)) { + await download.finalize(true); + } + } +} + +// Helpers for external protocol sandbox tests. +const EXT_PROTO_URI_MAILTO = "mailto:test@example.com"; + +/** + * Creates and iframe and navigate to an external protocol from the iframe. + * @param {MozBrowser} browser - Browser to spawn iframe in. + * @param {string} sandboxAttr - Sandbox attribute value for the iframe. + * @param {'trustedClick'|'untrustedClick'|'trustedLocationAPI'|'untrustedLocationAPI'|'frameSrc'|'frameSrcRedirect'} triggerMethod + * - How to trigger the navigation to the external protocol. + */ +async function navigateExternalProtoFromIframe( + browser, + sandboxAttr, + useCSPSandbox = false, + triggerMethod = "trustedClick" +) { + if ( + ![ + "trustedClick", + "untrustedClick", + "trustedLocationAPI", + "untrustedLocationAPI", + "frameSrc", + "frameSrcRedirect", + ].includes(triggerMethod) + ) { + throw new Error("Invalid trigger method " + triggerMethod); + } + + // Construct the url to use as iframe src. + let testPath = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ); + let frameSrc = testPath + "/protocol_custom_sandbox_helper.sjs"; + + // Load the external protocol directly via the frame src field. + if (triggerMethod == "frameSrc") { + frameSrc = EXT_PROTO_URI_MAILTO; + } else if (triggerMethod == "frameSrcRedirect") { + let url = new URL(frameSrc); + url.searchParams.set("redirectCustomProtocol", "true"); + frameSrc = url.href; + } + + // If enabled set the sandbox attributes via CSP header instead. To do + // this we need to pass the sandbox flags to the test server via query + // params. + if (useCSPSandbox) { + let url = new URL(frameSrc); + url.searchParams.set("cspSandbox", sandboxAttr); + frameSrc = url.href; + + // If we use CSP sandbox attributes we shouldn't set any via iframe attribute. + sandboxAttr = null; + } + + // Create a sandboxed iframe and navigate to the external protocol. + await SpecialPowers.spawn( + browser, + [sandboxAttr, frameSrc, EXT_PROTO_URI_MAILTO, triggerMethod], + async (sandbox, src, extProtoURI, trigger) => { + let frame = content.document.createElement("iframe"); + + if (sandbox != null) { + frame.sandbox = sandbox; + } + + frame.src = src; + + let useFrameSrc = trigger == "frameSrc" || trigger == "frameSrcRedirect"; + + // Create frame load promise. + let frameLoadPromise; + // We won't get a load event if we directly put the external protocol in + // the frame src. + if (!useFrameSrc) { + frameLoadPromise = ContentTaskUtils.waitForEvent(frame, "load", false); + } + + content.document.body.appendChild(frame); + await frameLoadPromise; + + if (!useFrameSrc) { + // Trigger the external protocol navigation in the iframe. We test + // navigation by clicking links and navigation via the history API. + await SpecialPowers.spawn( + frame, + [extProtoURI, trigger], + async (uri, trigger2) => { + let link = content.document.createElement("a"); + link.innerText = "CLICK ME"; + link.id = "extProtoLink"; + content.document.body.appendChild(link); + + if (trigger2 == "trustedClick" || trigger2 == "untrustedClick") { + link.href = uri; + } else if ( + trigger2 == "trustedLocationAPI" || + trigger2 == "untrustedLocationAPI" + ) { + link.setAttribute("onclick", `location.href = '${uri}'`); + } + + if ( + trigger2 == "untrustedClick" || + trigger2 == "untrustedLocationAPI" + ) { + link.click(); + } else if ( + trigger2 == "trustedClick" || + trigger2 == "trustedLocationAPI" + ) { + await ContentTaskUtils.waitForCondition( + () => link, + "wait for link to be present" + ); + await EventUtils.synthesizeMouseAtCenter(link, {}, content); + } + } + ); + } + } + ); +} + +/** + * Wait for the sandbox error message which is shown in the web console when an + * external protocol navigation from a sandboxed context is blocked. + * @returns {Promise} - Promise which resolves once message has been logged. + */ +function waitForExtProtocolSandboxError() { + return new Promise(resolve => { + Services.console.registerListener(function onMessage(msg) { + let { message, logLevel } = msg; + if (logLevel != Ci.nsIConsoleMessage.error) { + return; + } + if ( + !message.includes( + `Blocked navigation to custom protocol “${EXT_PROTO_URI_MAILTO}” from a sandboxed context.` + ) + ) { + return; + } + Services.console.unregisterListener(onMessage); + resolve(); + }); + }); +} + +/** + * Run the external protocol sandbox test using iframes. + * @param {Object} options + * @param {boolean} options.blocked - Whether the navigation should be blocked. + * @param {string} options.sandbox - See {@link navigateExternalProtoFromIframe}. + * @param {string} options.useCSPSandbox - See {@link navigateExternalProtoFromIframe}. + * @param {string} options.triggerMethod - See {@link navigateExternalProtoFromIframe}. + * @returns {Promise} - Promise which resolves once the test has finished. + */ +function runExtProtocolSandboxTest(options) { + let { blocked, sandbox, useCSPSandbox = false, triggerMethod } = options; + + let testPath = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ); + + info("runSandboxTest options: " + JSON.stringify(options)); + return BrowserTestUtils.withNewTab( + testPath + "/protocol_custom_sandbox_helper.sjs", + async browser => { + if (blocked) { + let errorPromise = waitForExtProtocolSandboxError(); + await navigateExternalProtoFromIframe( + browser, + sandbox, + useCSPSandbox, + triggerMethod + ); + await errorPromise; + + ok( + errorPromise, + "Should not show the dialog for iframe with sandbox " + sandbox + ); + } else { + let dialogWindowOpenPromise = waitForProtocolAppChooserDialog( + browser, + true + ); + await navigateExternalProtoFromIframe( + browser, + sandbox, + useCSPSandbox, + triggerMethod + ); + let dialog = await dialogWindowOpenPromise; + + ok(dialog, "Should show the dialog for sandbox " + sandbox); + + // Close dialog before closing the tab to avoid intermittent failures. + let dialogWindowClosePromise = waitForProtocolAppChooserDialog( + browser, + false + ); + + dialog.close(); + await dialogWindowClosePromise; + } + } + ); +} diff --git a/uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs b/uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs new file mode 100644 index 0000000000..8afa04cfe0 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs @@ -0,0 +1,18 @@ +/* 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/. */ + +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + + if (!request.queryString.match(/^name=/)) { + return; + } + var name = decodeURIComponent(request.queryString.substring(5)); + + response.setHeader("Content-Type", 'image/png; name="' + name + '"'); + response.setHeader( + "Content-Disposition", + 'attachment; filename="' + name + '"' + ); +} diff --git a/uriloader/exthandler/tests/mochitest/mailto.html b/uriloader/exthandler/tests/mochitest/mailto.html new file mode 100644 index 0000000000..d507697443 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/mailto.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <title>Mailto handler</title> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> + </head> + <body> + <a id="link" href="mailto:mail@example.com">mailto link</a> + </body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/mime_type_download.sjs b/uriloader/exthandler/tests/mochitest/mime_type_download.sjs new file mode 100644 index 0000000000..a12bfe9735 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/mime_type_download.sjs @@ -0,0 +1,21 @@ +function handleRequest(request, response) { + "use strict"; + + let content = ""; + let params = new URLSearchParams(request.queryString); + let extension = params.get("extension"); + let contentType = params.get("contentType"); + if (params.has("withHeader")) { + response.setHeader( + "Content-Disposition", + `attachment; filename="mime_type_download${ + extension ? "." + extension : "" + }";`, + false + ); + } + response.setHeader("Content-Type", contentType, false); + response.setHeader("Content-Length", "" + content.length, false); + response.setStatusLine(request.httpVersion, 200); + response.write(content); +} diff --git a/uriloader/exthandler/tests/mochitest/mochitest.toml b/uriloader/exthandler/tests/mochitest/mochitest.toml new file mode 100644 index 0000000000..1e0afbcdde --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/mochitest.toml @@ -0,0 +1,25 @@ +[DEFAULT] + +["test_invalidCharFileExtension.xhtml"] +skip-if = ["os == 'android'"] # Bug 1525959 +support-files = [ + "HelperAppLauncherDialog_chromeScript.js", + "invalidCharFileExtension.sjs", +] + +["test_nullCharFile.xhtml"] +skip-if = ["os == 'android'"] # Bug 1525959 +support-files = ["HelperAppLauncherDialog_chromeScript.js"] + +["test_unknown_ext_protocol_handlers.html"] + +["test_unsafeBidiChars.xhtml"] +skip-if = [ + "os == 'android'", # Bug 1525959 + "http3", + "http2", +] +support-files = [ + "HelperAppLauncherDialog_chromeScript.js", + "unsafeBidiFileName.sjs", +] diff --git a/uriloader/exthandler/tests/mochitest/protocolHandler.html b/uriloader/exthandler/tests/mochitest/protocolHandler.html new file mode 100644 index 0000000000..eff8a53aab --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/protocolHandler.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + <head> + <title>Protocol handler</title> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> + </head> + <body> + <script type="text/javascript"> + navigator.registerProtocolHandler("web+testprotocol", + "https://example.com/foobar?uri=%s", + "Test Protocol"); + </script> + <a id="link" href="web+testprotocol:test">testprotocol link</a> + </body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs b/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs new file mode 100644 index 0000000000..d12a953b57 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + let query = new URLSearchParams(request.queryString); + + // Set CSP sandbox attributes if caller requests any. + let cspSandbox = query.get("cspSandbox"); + if (cspSandbox) { + response.setHeader( + "Content-Security-Policy", + "sandbox " + cspSandbox, + false + ); + } + + // Redirect to custom protocol via HTTP 302. + if (query.get("redirectCustomProtocol")) { + response.setStatusLine(request.httpVersion, 302, "Found"); + + response.setHeader("Location", "mailto:test@example.com", false); + response.write("Redirect!"); + return; + } + + response.setStatusLine(request.httpVersion, 200); + response.write("OK"); +} diff --git a/uriloader/exthandler/tests/mochitest/redirect_helper.sjs b/uriloader/exthandler/tests/mochitest/redirect_helper.sjs new file mode 100644 index 0000000000..cdf6415c54 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/redirect_helper.sjs @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function handleRequest(request, response) { + let params = new URLSearchParams(request.queryString); + let uri = params.get("uri"); + let redirectType = params.get("redirectType") || "location"; + switch (redirectType) { + case "refresh": + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Refresh", "0; url=" + uri); + break; + + case "meta-refresh": + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + response.write(`<meta http-equiv="refresh" content="0; url=${uri}">`); + break; + + case "location": + // fall through + default: + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", uri); + } +} diff --git a/uriloader/exthandler/tests/mochitest/save_filenames.html b/uriloader/exthandler/tests/mochitest/save_filenames.html new file mode 100644 index 0000000000..8755186206 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/save_filenames.html @@ -0,0 +1,377 @@ +<html> +<head> +<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> +</head> +<body> +<style> + img { padding: 10px; border: 1px solid red; } + a { padding-left: 10px; } +</style> + +<span id="items"> + +<!-- simple filename --> +<img id="i0" src="http://localhost:8000/basic.png" + data-noattach="true" data-filename="basic.png"> + +<!-- simple filename with content disposition --> +<img id="i1" src="http://localhost:8000/save_filename.sjs?type=png&filename=simple.png" data-filename="simple.png"> + +<!-- invalid characters in the filename --> +<img id="i2" src="http://localhost:8000/save_filename.sjs?type=png&filename=invalidfilename/a:b*c%63d.png" data-filename="invalidfilename_a b ccd.png"> + +<!-- invalid extension for a png image --> +<img id="i3" src="http://localhost:8000/save_filename.sjs?type=png&filename=invalidextension.pang" data-filename="invalidextension.png"> + +<!-- jpeg extension for a png image --> +<img id="i4" src="http://localhost:8000/save_filename.sjs?type=png&filename=reallyapng.jpeg" data-filename="reallyapng.png"> + +<!-- txt extension for a png image --> +<img id="i5" src="http://localhost:8000/save_filename.sjs?type=png&filename=nottext.txt" data-filename="nottext.png"> + +<!-- no extension for a png image --> +<img id="i6" src="http://localhost:8000/save_filename.sjs?type=png&filename=noext" data-filename="noext.png"> + +<!-- empty extension for a png image --> +<img id="i7" src="http://localhost:8000/save_filename.sjs?type=png&filename=noextdot." data-filename="noextdot.png"> + +<!-- long filename --> +<img id="i8" src="http://localhost:8000/save_filename.sjs?type=png&filename=averylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilename.png" + data-filename="averylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongf.png"> + +<!-- long filename with invalid extension --> +<img id="i9" src="http://localhost:8000/save_filename.sjs?type=png&filename=bverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilename.exe" + data-filename="bverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongf.png"> + +<!-- long filename with invalid extension --> +<img id="i10" src="http://localhost:8000/save_filename.sjs?type=png&filename=cverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilename.exe.jpg" + data-filename="cverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongf.png"> + +<!-- jpeg with jpg extension --> +<img id="i11" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=thejpg.jpg" data-filename="thejpg.jpg"> + +<!-- jpeg with jpeg extension --> +<img id="i12" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=thejpg.jpeg" data-filename="thejpg.jpeg"> + +<!-- jpeg with invalid extension --> +<img id="i13" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=morejpg.exe" data-filename="morejpg.jpg" data-filename-platformlinux="morejpg.jpeg"> + +<!-- jpeg with multiple extensions --> +<img id="i14" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=anotherjpg.jpg.exe" data-filename="anotherjpg.jpg.jpg" data-filename-platformlinux="anotherjpg.jpg.jpeg"> + +<!-- jpeg with no filename portion --> +<img id="i15" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=.jpg" + data-filename="jpg.jpg" data-filename-platformlinux="jpg.jpeg"> + +<!-- png with no filename portion and invalid extension --> +<img id="i16" src="http://localhost:8000/save_filename.sjs?type=png&filename=.exe" data-filename="exe.png"> + +<!-- png with escaped characters --> +<img id="i17" src="http://localhost:8000/save_filename.sjs?type=png&filename=first%20file.png" data-filename="first file.png"> + +<!-- png with more escaped characters --> +<img id="i18" src="http://localhost:8000/save_filename.sjs?type=png&filename=second%32file%2Eexe" data-filename="second2file.png"> + +<!-- unknown type with png extension --> +<img id="i19" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=nonsense1.png" + data-nodrag="true" data-unknown="typeonly" data-filename="nonsense1.png"> + +<!-- unknown type with exe extension --> +<img id="i20" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=nonsense2.exe" + data-nodrag="true" data-unknown="typeonly" data-filename="nonsense2.exe"> + +<!-- unknown type with no extension --> +<img id="i21" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=nonsense3" + data-nodrag="true" data-unknown="typeonly" data-filename="nonsense3"> + +<!-- simple script --> +<script id="i22" src="http://localhost:8000/save_filename.sjs?type=js&filename=script1.js" data-filename="script1.js"></script> + +<!-- script with invalid extension. --> +<script id="i23" src="http://localhost:8000/save_filename.sjs?type=js&filename=script2.exe" + data-filename="script2.exe" data-savepagename="script2.exe.js"></script> + +<!-- script with escaped characters --> +<script id="i24" src="http://localhost:8000/save_filename.sjs?type=js&filename=script%20%33.exe" + data-filename="script 3.exe" data-savepagename="script 3.exe.js"></script> + +<!-- script with long filename --> +<script id="i25" src="http://localhost:8000/save_filename.sjs?type=js&filename=script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789.js" + data-filename="script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script12345.js"></script> + +<!-- binary with exe extension --> +<object id="i26" data="http://localhost:8000/save_filename.sjs?type=binary&filename=download1.exe" + data-unknown="true" data-filename="download1.exe"></object> + +<!-- binary with invalid extension --> +<object id="i27" data="http://localhost:8000/save_filename.sjs?type=binary&filename=download2.png" + data-unknown="true" data-filename="download2.png"></object> + +<!-- binary with no extension --> +<object id="i28" data="http://localhost:8000/save_filename.sjs?type=binary&filename=downloadnoext" + data-unknown="true" data-filename="downloadnoext"></object> + +<!-- binary with no other invalid characters --> +<object id="i29" data="http://localhost:8000/save_filename.sjs?type=binary&filename=binary^%31%20exe.exe" + data-unknown="true" data-filename="binary^1 exe.exe"></object> + +<!-- unknown image type with no extension, but ending in png --> +<img id="i30" src="http://localhost:8000/save_filename.sjs?type=otherimage&filename=specialpng" + data-unknown="typeonly" data-nodrag="true" data-filename="specialpng"> + +<!-- unknown image type with no extension, but ending in many dots --> +<img id="i31" src="http://localhost:8000/save_filename.sjs?type=otherimage&filename=extrapng..." + data-unknown="typeonly" data-nodrag="true" data-filename="extrapng"> + +<!-- image type with no content-disposition filename specified --> +<img id="i32" src="http://localhost:8000/save_filename.sjs?type=png" data-filename="save_filename.png"> + +<!-- binary with no content-disposition filename specified --> +<object id="i33" data="http://localhost:8000/save_filename.sjs?type=binary" + data-unknown="true" data-filename="save_filename.sjs"></object> + +<!-- image where url has png extension --> +<img id="i34" src="http://localhost:8000/getdata.png?type=png&filename=override.png" data-filename="override.png"> + +<!-- image where url has png extension but content disposition has incorrect extension --> +<img id="i35" src="http://localhost:8000/getdata.png?type=png&filename=flower.jpeg" data-filename="flower.png"> + +<!-- image where url has png extension but content disposition does not --> +<img id="i36" src="http://localhost:8000/getdata.png?type=png&filename=ruby" data-filename="ruby.png"> + +<!-- image where url has png extension but content disposition has invalid characters --> +<img id="i37" src="http://localhost:8000/getdata.png?type=png&filename=sapphire/data" data-filename="sapphire_data.png"> + +<!-- image where neither content disposition or url have an extension --> +<img id="i38" src="http://localhost:8000/base?type=png&filename=emerald" data-filename="emerald.png"> + +<!-- image where filename is not specified --> +<img id="i39" src="http://localhost:8000/base?type=png" data-filename="base.png"> + +<!-- simple script where url filename has no extension --> +<script id="i40" src="http://localhost:8000/base?type=js&filename=script4.js" data-filename="script4.js"></script> + +<!-- script where url filename has no extension and invalid extension in content disposition filename --> +<script id="i41" src="http://localhost:8000/base?type=js&filename=script5.exe" + data-filename="script5.exe" data-savepagename="script5.exe.js"></script> + +<!-- script where url filename has no extension and escaped characters in content disposition filename--> +<script id="i42" src="http://localhost:8000/base?type=js&filename=script%20%36.exe" + data-filename="script 6.exe" data-savepagename="script 6.exe.js"></script> + +<!-- text where filename is present --> +<img id="i43" src="http://localhost:8000/getdata.png?type=text&filename=readme.txt" + data-nodrag="true" data-filename="readme.txt"> + +<!-- text where filename is present with a different extension --> +<img id="i44" src="http://localhost:8000/getdata.png?type=text&filename=main.cpp" + data-nodrag="true" data-filename="main.cpp"> + +<!-- text where extension is not present --> +<img id="i45" src="http://localhost:8000/getdata.png?type=text&filename=readme" + data-nodrag="true" data-filename="readme"> + +<!-- text where extension is not present and url does not have extension --> +<img id="i46" src="http://localhost:8000/base?type=text&filename=info" + data-nodrag="true" data-filename="info"> + +<!-- text where filename is not present --> +<img id="i47" src="http://localhost:8000/basetext?type=text" + data-nodrag="true" data-filename="basetext"> + +<!-- text where url has extension --> +<img id="i48" src="http://localhost:8000/text2.txt?type=text" + data-nodrag="true" data-filename="text2.txt"> + +<!-- text where url has extension --> +<img id="i49" src="http://localhost:8000/text3.gonk?type=text" + data-nodrag="true" data-filename="text3.gonk"> + +<!-- text with long filename --> +<img id="i50" src="http://localhost:8000/text3.gonk?type=text&filename=text0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789text0123456789zztext0123456789zztext0123456789zztext01234567.exe.txt" data-nodrag="true" data-filename="text0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext012345.txt"> + +<!-- webp image --> +<img id="i51" src="http://localhost:8000/save_filename.sjs?type=webp&filename=webpimage.webp" + data-filename="webpimage.webp"> + +<!-- webp image with jpg extension --> +<img id="i52" src="http://localhost:8000/save_filename.sjs?type=webp&filename=realwebpimage.jpg" + data-filename="realwebpimage.webp"> + +<!-- no content type specified --> +<img id="i53" src="http://localhost:8000/save_filename.sjs?&filename=notype.png" + data-nodrag="true" data-filename="notype.png"> + +<!-- no content type specified. --> +<img id="i54" src="http://localhost:8000/save_filename.sjs?&filename=notypebin.exe" + data-nodrag="true" data-filename="notypebin.exe"> + +<!-- extension contains invalid characters --> +<img id="i55" src="http://localhost:8000/save_filename.sjs?type=png&filename=extinvalid.a?*" + data-filename="extinvalid.png"> + +<!-- filename with redirect and content disposition --> +<img id="i56" src="http://localhost:8000/redir?type=png&filename=red.png" data-filename="red.png"> + +<!-- filename with redirect and different type --> +<img id="i57" src="http://localhost:8000/redir?type=jpeg&filename=green.png" + data-filename="green.jpg" data-filename-platformlinux="green.jpeg"> + +<!-- filename with redirect and binary type --> +<object id="i58" data="http://localhost:8000/redir?type=binary&filename=blue.png" + data-unknown="true" data-filename="blue.png"></object> + +<!-- filename in url with incorrect extension --> +<img id="i59" src="http://localhost:8000/aquamarine.jpeg" + data-noattach="true" data-filename="aquamarine.png"> + +<!-- filename in url with exe extension, but returns a png image --> +<img id="i60" src="http://localhost:8000/lazuli.exe" + data-noattach="true" data-filename="lazuli.png"> + +<!-- filename with leading, trailing and duplicate spaces --> +<img id="i61" src="http://localhost:8000/save_filename.sjs?type=png&filename= with spaces.png " + data-filename="with spaces.png"> + +<!-- filename with leading and trailing periods --> +<img id="i62" src="http://localhost:8000/save_filename.sjs?type=png&filename=..with..dots..png.." + data-filename="with..dots..png"> + +<!-- filename with non-ascii character --> +<img id="i63" src="http://localhost:8000/base?type=png&filename=s%C3%B6meescapes.%C3%B7ng" data-filename="sömeescapes.png"> + +<!-- filename with content disposition name assigned. The name is only used + when selecting to manually save, otherwise it is ignored. --> +<img id="i64" src="http://localhost:8000/save_thename.sjs?type=png&dispname=withname" + data-filename="save_thename.png"> + +<!-- reserved filename on Windows --> +<img id="i65" src="http://localhost:8000/save_filename.sjs?type=text&filename=com1" + data-nodrag="true" data-filename="com1" data-filename-platformwin="Untitled"> + +<!-- reserved filename with extension on Windows --> +<img id="i66" src="http://localhost:8000/save_filename.sjs?type=text&filename=com2.any" + data-nodrag="true" data-filename="com2.any" data-filename-platformwin="Untitled"> + +<!-- simple zip file --> +<object id="i67" data="http://localhost:8000/save_filename.sjs?type=zip&filename=simple.zip" data-filename="simple.zip" + data-unknown="true"></object> + +<!-- simple zip file with differing extension --> +<object id="i68" data="http://localhost:8000/save_filename.sjs?type=zip&filename=simple.jar" data-filename="simple.jar" + data-unknown="true"></object> + +<!-- simple zip file with no extension --> +<object id="i69" data="http://localhost:8000/save_filename.sjs?type=zip&filename=simplepack" data-filename="simplepack.zip" + data-unknown="true"></object> + +<!-- simple json file --> +<object id="i70" data="http://localhost:8000/save_filename.sjs?type=json&filename=simple.json" data-filename="simple.json" + data-unknown="true"></object> + +<!-- simple json file with differing extension --> +<object id="i71" data="http://localhost:8000/save_filename.sjs?type=json&filename=simple.dat" data-filename="simple.dat" + data-unknown="true"></object> + +<!-- compressed file with .gz extension --> +<img id="i72" src="http://localhost:8000/save_filename.sjs?type=png&filename=compressed.png.gz" data-filename="compressed.png.png"> + +<!-- compressed file with .tar.gz extension --> +<object id="i73" data="http://localhost:8000/save_filename.sjs?type=tar&filename=compressed2.tar.gz" data-filename="compressed2.tar.gz" + data-unknown="true"></object> + +<!-- compressed file with bittar.gz extension. There is no tar mime info on Windows so the filename is not changed. --> +<object id="i74" data="http://localhost:8000/save_filename.sjs?type=tar&filename=compressed3.bittar.gz" + data-filename="compressed3.bittar.gz" + data-unknown="true"></object> + +<!-- compressed file with .tar.bz2 extension --> +<object id="i75" data="http://localhost:8000/save_filename.sjs?type=tar&filename=buzz.tar.bz2" data-filename="buzz.tar.bz2" + data-unknown="true"></object> + +<!-- executable with no filename specified and an extension specified within the url --> +<img id="i76" src="http://localhost:8000/executable.exe?type=nonsense" + data-nodrag="true" data-unknown="typeonly" data-filename="executable.exe"> + +<!-- embedded child html --> +<object id="i77" data="http://localhost:8000/save_filename.sjs?type=html&filename=child.par" + data-filename="child.par" data-unknown="true"></object> + +<!-- file starting with a dot with and unknown extension --> +<img id="i78" src="http://localhost:8000/save_filename.sjs?type=png&filename=.extension" data-filename="extension.png"> + +<!-- html file starting with a dot --> +<object id="i79" data="http://localhost:8000/save_filename.sjs?type=html&filename=.alternate" + data-filename="alternate.html" data-filename-platformwin="alternate.htm" data-unknown="true"></object> + +<!-- unrecognized file type starting with a dot --> +<object id="i80" data="http://localhost:8000/save_filename.sjs?type=otherimage&filename=.alternate" data-filename="alternate" + data-unknown="true"></object> + +<!-- filename with lnk extension --> +<img id="i81" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=shortcut.lnk" + data-nodrag="true" data-unknown="typeonly" + data-filename="shortcut.lnk.download"> + +<!-- long filename with lnk extension --> +<img id="i82" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=longshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshort1234567890.lnk" + data-nodrag="true" data-unknown="typeonly" + data-filename="longshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshort1234567890.lnk.download"> + +</span> + +<!-- This set is used to test filenames that are changed within the file picker. --> +<span id="modifieditems"> + +<!-- filename which is changed within the file picker --> +<a id="mod0" href="http://localhost:8000/save_filename.sjs?type=png&filename=teal.png" + data-pickedfilename='cyan.png' data-filename='cyan.png'> + +<!-- filename which is changed to an invalid filename within the file picker --> +<a id="mod1" href="http://localhost:8000/save_filename.sjs?type=png&filename=orange.png" + data-pickedfilename='"peach".png' data-filename='peach .png'> + +<!-- an invalid filename which is changed to another invalid filename within the file picker --> +<a id="mod2" href="http://localhost:8000/save_filename.sjs?type=png&filename=%maroon%34.png" + data-pickedfilename='"violet".png' data-filename="violet .png"> + +</span> + +<!-- This set is used to test the filename specified by the download attribute is validated correctly. --> +<span id="downloads"> + <a id="download0" href="http://localhost:8000/base" download="pearl.png" data-filename="pearl.png">Link</a> + <a id="download1" href="http://localhost:8000/save_filename.sjs?type=png" download="opal.jpeg" data-filename="opal.png">Link</a> + <a id="download2" href="http://localhost:8000/save_filename.sjs?type=jpeg" + download="amethyst.png" data-filename="amethyst.jpg" + data-filename-platformlinux="amethyst.jpeg">Link</a> + <a id="download3" href="http://localhost:8000/save_filename.sjs?type=text" + download="onyx.png" data-filename="onyx.png">Link</a> + <!-- The content-disposition overrides the download attribute. --> + <a id="download4" href="http://localhost:8000/save_filename.sjs?type=png&filename=fakename.jpeg" download="topaz.jpeg" data-filename="fakename.png">Link</a> + <a id="download5" href="http://localhost:8000/save_filename.sjs?type=png" + download="amber?.png" data-filename="amber .png">Link</a> + <a id="download6" href="http://localhost:8000/save_filename.sjs?type=jpeg" + download="jade.:*jpeg" data-filename="jade.jpg" + data-filename-platformlinux="jade.jpeg">Link</a>> + <a id="download7" href="http://localhost:8000/save_filename.sjs?type=png" + download="thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename.png" + data-filename="thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisavery.png">Link</a> + <a id="download8" href="http://localhost:8000/base" + download="	
 ᠎᠎ spa ced.png 	
 ᠎᠎ " + data-filename="spa ced.png">Link</a> +</span> + +<span id="links"> + <a id="link0" href="http://localhost:8000/save_filename.sjs?type=png&filename=one.png" data-filename="one.png">One</a> + <a id="link1" href="http://localhost:8000/save_filename.sjs?type=png&filename=two.jpeg" data-filename="two.png">Two</a> + <a id="link2" href="http://localhost:8000/save_filename.sjs?type=png&filename=three.con" data-filename="three.png">Three</a> + <a id="link3" href="http://localhost:8000/save_filename.sjs?type=png&dispname=four" data-filename="four.png">Four</a> + <a id="link4" href="http://localhost:8000/save_filename.sjs?type=png&filename=five.local" data-filename="five.png">Five</a> +</span> + +<!-- The content-disposition attachment generates links from the images/objects/scripts above + and inserts them here. --> +<p id="attachment-links"> +</p> + +</body></html> diff --git a/uriloader/exthandler/tests/mochitest/script_redirect.html b/uriloader/exthandler/tests/mochitest/script_redirect.html new file mode 100644 index 0000000000..31e0dc6a7e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/script_redirect.html @@ -0,0 +1,5 @@ +<script> + let params = new URL(document.location).searchParams; + let uri = params.get("uri"); + document.location = uri; +</script> diff --git a/uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml b/uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml new file mode 100644 index 0000000000..177af3757f --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml @@ -0,0 +1,65 @@ +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title>Test for Handling of unsafe bidi chars</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<iframe id="test"></iframe> +<script type="text/javascript"> +var tests = [ + ["test.png:large", "test.png"], + ["test.png/large", "test.png"], + [":test.png::large:", "test.png"], +]; + +add_task(async function() { + function promiseMessage(messageName) { + return new Promise(resolve => { + chromeScript.addMessageListener(messageName, function listener(data) { + chromeScript.removeMessageListener(messageName, listener); + resolve(data); + }); + }); + } + + let url = SimpleTest.getTestFileURL("HelperAppLauncherDialog_chromeScript.js"); + let chromeScript = SpecialPowers.loadChromeScript(url); + + function wrongAPICallListener(msg) { + ok( + false, + `Called ${msg} when always ask pref was set to ${ + Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + }, which shouldn't happen.` + ); + } + chromeScript.addMessageListener("wrongAPICall", wrongAPICallListener); + + for (let prefVal of [false, true]) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", prefVal]] + }); + // Run all the tests. + for (let [name, expected] of tests) { + let promiseName = promiseMessage("suggestedFileName"); + document.getElementById("test").src = + "invalidCharFileExtension.sjs?name=" + encodeURIComponent(name); + is((await promiseName), expected, "got the expected sanitized name"); + } + } + + // Clean up. + let promise = promiseMessage("unregistered"); + chromeScript.sendAsyncMessage("unregister"); + await promise; + + chromeScript.removeMessageListener("wrongAPICall", wrongAPICallListener); + chromeScript.destroy(); +}); +</script> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml b/uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml new file mode 100644 index 0000000000..b153395e81 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml @@ -0,0 +1,67 @@ +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title>Test for Handling of null char</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<iframe id="test"></iframe> +<script type="text/javascript"> +var tests = [ + ["test.html\u0000.png", "test.html_.png"], + ["test.html.\u0000png", "test.html._png"], +]; + +add_task(async function() { + function promiseMessage(messageName) { + return new Promise(resolve => { + chromeScript.addMessageListener(messageName, function listener(data) { + chromeScript.removeMessageListener(messageName, listener); + resolve(data); + }); + }); + } + + let url = SimpleTest.getTestFileURL("HelperAppLauncherDialog_chromeScript.js"); + let chromeScript = SpecialPowers.loadChromeScript(url); + + function wrongAPICallListener(msg) { + ok( + false, + `Called ${msg} when always ask pref was set to ${ + Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + }, which shouldn't happen.` + ); + } + chromeScript.addMessageListener("wrongAPICall", wrongAPICallListener); + + for (let prefVal of [false, true]) { + info("Pushing pref"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", prefVal]] + }); + for (let [name, expected] of tests) { + let promiseName = promiseMessage("suggestedFileName"); + const a = document.createElement('a'); + // Pass an unknown mimetype so we don't "correct" the extension: + a.href = "data:application/baconizer;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + a.download = name; + a.dispatchEvent(new MouseEvent('click')); + is((await promiseName), expected, "got the expected sanitized name"); + } + } + + // Clean up. + let promise = promiseMessage("unregistered"); + chromeScript.sendAsyncMessage("unregister"); + await promise; + + chromeScript.removeMessageListener("wrongAPICall", wrongAPICallListener); + chromeScript.destroy(); +}); +</script> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/test_spammy_page.html b/uriloader/exthandler/tests/mochitest/test_spammy_page.html new file mode 100644 index 0000000000..b1e60a1e8e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_spammy_page.html @@ -0,0 +1,27 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8" /> + <title>...</title> +</head> +<body> + <p> Hello, it's the spammy page! </p> +<script type="text/javascript"> + let count = 0; +window.onload = window.onclick = function() { + if (count < 100) { + count++; + let l = document.createElement('a'); + l.href = 'data:text/plain,some text'; + l.download = 'sometext.pdf'; + + document.body.appendChild(l); + l.click(); + } +} +</script> +<a id="image" href="${TEST_ROOT}/file_with@@funny_name.png">Image</a> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html b/uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html new file mode 100644 index 0000000000..f8727db605 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for no error reporting for unknown external protocols</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<iframe id="testFrame"></iframe> +<script type="text/javascript"> + +SimpleTest.waitForExplicitFinish(); + +window.onload = () => { + let testFrame = document.getElementById("testFrame"); + + try { + testFrame.contentWindow.location.href = "unknownextproto:"; + ok(true, "There is no error reporting for unknown external protocol navigation."); + } catch (e) { + ok(false, "There should be no error reporting for unknown external protocol navigation."); + } + + SimpleTest.finish(); +}; +</script> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml b/uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml new file mode 100644 index 0000000000..34c6c956fd --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml @@ -0,0 +1,89 @@ +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title>Test for Handling of unsafe bidi chars</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<iframe id="test"></iframe> +<script type="text/javascript"> +var unsafeBidiChars = [ + "\xe2\x80\xaa", // LRE + "\xe2\x80\xab", // RLE + "\xe2\x80\xac", // PDF + "\xe2\x80\xad", // LRO + "\xe2\x80\xae", // RLO +]; + +var tests = [ + "{1}.test", + "{1}File.test", + "Fi{1}le.test", + "File{1}.test", + "File.{1}test", + "File.te{1}st", + "File.test{1}", + "File.{1}", +]; + +function replace(name, x) { + return name.replace(/\{1\}/, x); +} + +function sanitize(name) { + return replace(name, "_"); +} + +add_task(async function() { + function promiseMessage(messageName) { + return new Promise(resolve => { + chromeScript.addMessageListener(messageName, function listener(data) { + chromeScript.removeMessageListener(messageName, listener); + resolve(data); + }); + }); + } + + let url = SimpleTest.getTestFileURL("HelperAppLauncherDialog_chromeScript.js"); + let chromeScript = SpecialPowers.loadChromeScript(url); + + function wrongAPICallListener(msg) { + ok( + false, + `Called ${msg} when always ask pref was set to ${ + Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + }, which shouldn't happen.` + ); + } + chromeScript.addMessageListener("wrongAPICall", wrongAPICallListener); + + for (let prefVal of [false, true]) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", prefVal]] + }); + for (let test of tests) { + for (let char of unsafeBidiChars) { + let promiseName = promiseMessage("suggestedFileName"); + let name = replace(test, char); + let expected = sanitize(test); + document.getElementById("test").src = + "unsafeBidiFileName.sjs?name=" + encodeURIComponent(name); + is((await promiseName), expected, "got the expected sanitized name"); + } + } + } + + // Clean up. + let promise = promiseMessage("unregistered"); + chromeScript.sendAsyncMessage("unregister"); + await promise; + + chromeScript.removeMessageListener("wrongAPICall", wrongAPICallListener); + chromeScript.destroy(); +}); +</script> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs b/uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs new file mode 100644 index 0000000000..4f88ff6de2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs @@ -0,0 +1,18 @@ +/* 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/. */ + +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + + if (!request.queryString.match(/^name=/)) { + return; + } + var name = decodeURIComponent(request.queryString.substring(5)); + + response.setHeader( + "Content-Type", + 'application/octet-stream; name="' + name + '"' + ); + response.setHeader("Content-Disposition", 'inline; filename="' + name + '"'); +} diff --git a/uriloader/exthandler/tests/moz.build b/uriloader/exthandler/tests/moz.build new file mode 100644 index 0000000000..95d7f5fe59 --- /dev/null +++ b/uriloader/exthandler/tests/moz.build @@ -0,0 +1,35 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +MOCHITEST_MANIFESTS += ["mochitest/mochitest.toml"] + +XPCSHELL_TESTS_MANIFESTS += ["unit/xpcshell.toml"] + +BROWSER_CHROME_MANIFESTS += ["mochitest/browser.toml"] + +TEST_DIRS += [ + "gtest", +] + +TESTING_JS_MODULES += [ + "HandlerServiceTestUtils.sys.mjs", +] + +GeckoSimplePrograms( + [ + "WriteArgument", + ], + linkage=None, +) + +if CONFIG["COMPILE_ENVIRONMENT"]: + TEST_HARNESS_FILES.xpcshell.uriloader.exthandler.tests.unit += [ + "!WriteArgument%s" % CONFIG["BIN_SUFFIX"] + ] + +USE_LIBS += [ + "nspr", +] diff --git a/uriloader/exthandler/tests/unit/handlers.json b/uriloader/exthandler/tests/unit/handlers.json new file mode 100644 index 0000000000..51ce581d83 --- /dev/null +++ b/uriloader/exthandler/tests/unit/handlers.json @@ -0,0 +1,83 @@ +{ + "defaultHandlersVersion": { + "en-US": 999 + }, + "mimeTypes": { + "example/type.handleinternally": { + "unknownProperty": "preserved", + "action": 3, + "extensions": ["example_one"] + }, + "example/type.savetodisk": { + "action": 0, + "ask": true, + "handlers": [ + { + "name": "Example Default Handler", + "uriTemplate": "https://www.example.com/?url=%s" + } + ], + "extensions": ["example_two", "example_three"] + }, + "example/type.usehelperapp": { + "action": 2, + "ask": true, + "handlers": [ + { + "name": "Example Default Handler", + "uriTemplate": "https://www.example.com/?url=%s" + }, + { + "name": "Example Possible Handler One", + "uriTemplate": "http://www.example.com/?id=1&url=%s" + }, + { + "name": "Example Possible Handler Two", + "uriTemplate": "http://www.example.com/?id=2&url=%s" + } + ], + "extensions": ["example_two", "example_three"] + }, + "example/type.usesystemdefault": { + "action": 4, + "handlers": [ + null, + { + "name": "Example Possible Handler", + "uriTemplate": "http://www.example.com/?url=%s" + } + ] + } + }, + "schemes": { + "examplescheme.usehelperapp": { + "action": 2, + "ask": true, + "handlers": [ + { + "name": "Example Default Handler", + "uriTemplate": "https://www.example.com/?url=%s" + }, + { + "name": "Example Possible Handler One", + "uriTemplate": "http://www.example.com/?id=1&url=%s" + }, + { + "name": "Example Possible Handler Two", + "uriTemplate": "http://www.example.com/?id=2&url=%s" + } + ] + }, + "examplescheme.usesystemdefault": { + "action": 4, + "handlers": [ + null, + { + "name": "Example Possible Handler", + "uriTemplate": "http://www.example.com/?url=%s" + } + ] + } + }, + "isDownloadsImprovementsAlreadyMigrated": true +} diff --git a/uriloader/exthandler/tests/unit/head.js b/uriloader/exthandler/tests/unit/head.js new file mode 100644 index 0000000000..72d1ac87c5 --- /dev/null +++ b/uriloader/exthandler/tests/unit/head.js @@ -0,0 +1,84 @@ +/* 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/. */ + +/* + * Initialization for tests related to invoking external handler applications. + */ + +"use strict"; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +var { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const { HandlerServiceTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/HandlerServiceTestUtils.sys.mjs" +); +var { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gHandlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +do_get_profile(); + +let jsonPath = PathUtils.join(PathUtils.profileDir, "handlers.json"); + +/** + * Unloads the nsIHandlerService data store, so the back-end file can be + * accessed or modified, and the new data will be loaded at the next access. + */ +let unloadHandlerStore = async function () { + // If this function is called before the nsIHandlerService instance has been + // initialized for the first time, the observer below will not be registered. + // We have to force initialization to prevent the function from stalling. + gHandlerService; + + let promise = TestUtils.topicObserved("handlersvc-json-replace-complete"); + Services.obs.notifyObservers(null, "handlersvc-json-replace"); + await promise; +}; + +/** + * Unloads the data store and deletes it. + */ +let deleteHandlerStore = async function () { + await unloadHandlerStore(); + + await IOUtils.remove(jsonPath, { ignoreAbsent: true }); + + Services.prefs.clearUserPref("gecko.handlerService.defaultHandlersVersion"); +}; + +/** + * Unloads the data store and replaces it with the test data file. + */ +let copyTestDataToHandlerStore = async function () { + await unloadHandlerStore(); + + await IOUtils.copy(do_get_file("handlers.json").path, jsonPath); + + Services.prefs.setIntPref("gecko.handlerService.defaultHandlersVersion", 100); +}; + +/** + * Ensures the files are removed and the services unloaded when the tests end. + */ +registerCleanupFunction(async function test_terminate() { + await deleteHandlerStore(); +}); diff --git a/uriloader/exthandler/tests/unit/mailcap b/uriloader/exthandler/tests/unit/mailcap new file mode 100644 index 0000000000..dc93ef8042 --- /dev/null +++ b/uriloader/exthandler/tests/unit/mailcap @@ -0,0 +1,2 @@ +text/plain; cat '%s'; needsterminal +text/plain; sed '%s' diff --git a/uriloader/exthandler/tests/unit/test_badMIMEType.js b/uriloader/exthandler/tests/unit/test_badMIMEType.js new file mode 100644 index 0000000000..49c5e8d848 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_badMIMEType.js @@ -0,0 +1,29 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +function run_test() { + // "text/plain" has an 0xFF character appended to it. This means it's an + // invalid string, which is tricky to enter using a text editor (I used + // emacs' hexl-mode). It also means an ordinary text editor might drop it + // or convert it to something that *is* valid (in UTF8). So we measure + // its length to make sure this hasn't happened. + var badMimeType = "text/plainÿ"; + Assert.equal(badMimeType.length, 11); + try { + Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getFromTypeAndExtension(badMimeType, "txt"); + } catch (e) { + if ( + !(e instanceof Ci.nsIException) || + e.result != Cr.NS_ERROR_NOT_AVAILABLE + ) { + throw e; + } + // This is an expected exception, thrown if the type can't be determined + } + // Not crashing is good enough + Assert.equal(true, true); +} diff --git a/uriloader/exthandler/tests/unit/test_defaults_handlerService.js b/uriloader/exthandler/tests/unit/test_defaults_handlerService.js new file mode 100644 index 0000000000..72d3779fd8 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_defaults_handlerService.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gExternalProtocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); +ChromeUtils.defineESModuleGetters(this, { + kHandlerList: "resource://gre/modules/handlers/HandlerList.sys.mjs", +}); + +add_task(async function test_check_defaults_get_added() { + let protocols = Object.keys(kHandlerList.default.schemes); + for (let protocol of protocols) { + let protocolHandlerCount = + kHandlerList.default.schemes[protocol].handlers.length; + Assert.ok( + gHandlerService.wrappedJSObject._store.data.schemes[protocol].stubEntry, + `Expect stub for ${protocol}` + ); + let info = gExternalProtocolService.getProtocolHandlerInfo(protocol, {}); + Assert.ok( + info, + `Should be able to get protocol handler info for ${protocol}` + ); + let handlers = Array.from( + info.possibleApplicationHandlers.enumerate(Ci.nsIHandlerApp) + ); + handlers = handlers.filter(h => h instanceof Ci.nsIWebHandlerApp); + Assert.equal( + handlers.length, + protocolHandlerCount, + `Default web handlers for ${protocol} should match` + ); + let { alwaysAskBeforeHandling, preferredAction } = info; + // Actually store something, pretending there was a change: + let infoToWrite = gExternalProtocolService.getProtocolHandlerInfo( + protocol, + {} + ); + gHandlerService.store(infoToWrite); + ok( + !gHandlerService.wrappedJSObject._store.data.schemes[protocol].stubEntry, + "Expect stub entry info to go away" + ); + + let newInfo = gExternalProtocolService.getProtocolHandlerInfo(protocol, {}); + Assert.equal( + alwaysAskBeforeHandling, + newInfo.alwaysAskBeforeHandling, + protocol + " - always ask shouldn't change" + ); + Assert.equal( + preferredAction, + newInfo.preferredAction, + protocol + " - preferred action shouldn't change" + ); + await deleteHandlerStore(); + } +}); + +add_task(async function test_check_default_modification() { + Assert.ok( + true, + JSON.stringify(gHandlerService.wrappedJSObject._store.data.schemes.mailto) + ); + Assert.ok( + gHandlerService.wrappedJSObject._store.data.schemes.mailto.stubEntry, + "Expect stub for mailto" + ); + let mailInfo = gExternalProtocolService.getProtocolHandlerInfo("mailto", {}); + mailInfo.alwaysAskBeforeHandling = false; + mailInfo.preferredAction = Ci.nsIHandlerInfo.useSystemDefault; + gHandlerService.store(mailInfo); + Assert.ok( + !gHandlerService.wrappedJSObject._store.data.schemes.mailto.stubEntry, + "Stub entry should be removed immediately." + ); + let newMail = gExternalProtocolService.getProtocolHandlerInfo("mailto", {}); + Assert.equal(newMail.preferredAction, Ci.nsIHandlerInfo.useSystemDefault); + Assert.equal(newMail.alwaysAskBeforeHandling, false); + await deleteHandlerStore(); +}); + +add_task(async function test_migrations() { + const kTestData = [ + ["A", "http://compose.mail.yahoo.co.jp/ym/Compose?To=%s"], + ["B", "http://www.inbox.lv/rfc2368/?value=%s"], + ["C", "http://poczta.interia.pl/mh/?mailto=%s"], + ["D", "http://win.mail.ru/cgi-bin/sentmsg?mailto=%s"], + ]; + // Set up the test handlers. This doesn't use prefs like the previous test, + // because we now refuse to import insecure handler prefs. They can only + // exist if they were added into the handler store before this restriction + // was created (bug 1526890). + gHandlerService.wrappedJSObject._injectDefaultProtocolHandlers(); + let handler = gExternalProtocolService.getProtocolHandlerInfo("mailto"); + while (handler.possibleApplicationHandlers.length) { + handler.possibleApplicationHandlers.removeElementAt(0); + } + for (let [name, uriTemplate] of kTestData) { + let app = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance( + Ci.nsIWebHandlerApp + ); + app.uriTemplate = uriTemplate; + app.name = name; + handler.possibleApplicationHandlers.appendElement(app); + } + gHandlerService.store(handler); + + // Now migrate them: + Services.prefs.setCharPref("browser.handlers.migrations", "blah,secure-mail"); + gHandlerService.wrappedJSObject._migrateProtocolHandlersIfNeeded(); + + // Now check the result: + handler = gExternalProtocolService.getProtocolHandlerInfo("mailto"); + + let expectedURIs = new Set([ + "https://mail.yahoo.co.jp/compose/?To=%s", + "https://mail.inbox.lv/compose?to=%s", + "https://poczta.interia.pl/mh/?mailto=%s", + "https://e.mail.ru/cgi-bin/sentmsg?mailto=%s", + ]); + + let possibleApplicationHandlers = Array.from( + handler.possibleApplicationHandlers.enumerate(Ci.nsIWebHandlerApp) + ); + // Set iterators are stable to deletion, so this works: + for (let expected of expectedURIs) { + for (let app of possibleApplicationHandlers) { + if (app instanceof Ci.nsIWebHandlerApp && app.uriTemplate == expected) { + Assert.ok(true, "Found handler with URI " + expected); + // ... even when we remove items. + expectedURIs.delete(expected); + break; + } + } + } + Assert.equal(expectedURIs.size, 0, "Should have seen all the expected URIs."); + + for (let app of possibleApplicationHandlers) { + if (app instanceof Ci.nsIWebHandlerApp) { + Assert.ok( + !kTestData.some(n => n[1] == app.uriTemplate), + "Should not be any of the original handlers" + ); + } + } + + Assert.ok( + !handler.preferredApplicationHandler, + "Shouldn't have preferred handler initially." + ); + await deleteHandlerStore(); +}); + +/** + * Check that non-https templates or ones without a '%s' are ignored. + */ +add_task(async function invalid_handlers_are_rejected() { + let schemes = kHandlerList.default.schemes; + schemes.myfancyinvalidstuff = { + handlers: [ + { + name: "No template at all", + }, + { + name: "Not secure", + uriTemplate: "http://example.com/%s", + }, + { + name: "No replacement percent-s bit", + uriTemplate: "https://example.com/", + }, + { + name: "Actually valid", + uriTemplate: "https://example.com/%s", + }, + ], + }; + gHandlerService.wrappedJSObject._injectDefaultProtocolHandlers(); + // Now check the result: + let handler = gExternalProtocolService.getProtocolHandlerInfo( + "myfancyinvalidstuff" + ); + + let expectedURIs = ["https://example.com/%s"]; + + Assert.deepEqual( + Array.from( + handler.possibleApplicationHandlers.enumerate(Ci.nsIWebHandlerApp), + e => e.uriTemplate + ), + expectedURIs, + "Should have seen only 1 handler added." + ); + await deleteHandlerStore(); +}); diff --git a/uriloader/exthandler/tests/unit/test_filename_sanitize.js b/uriloader/exthandler/tests/unit/test_filename_sanitize.js new file mode 100644 index 0000000000..d8ab12c266 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_filename_sanitize.js @@ -0,0 +1,398 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test verifies that +// nsIMIMEService.validateFileNameForSaving sanitizes filenames +// properly with different flags. + +"use strict"; + +add_task(async function validate_filename_method() { + let mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + + function checkFilename(filename, flags, mime = "image/png") { + return mimeService.validateFileNameForSaving(filename, mime, flags); + } + + Assert.equal(checkFilename("basicfile.png", 0), "basicfile.png"); + Assert.equal(checkFilename(" whitespace.png ", 0), "whitespace.png"); + Assert.equal( + checkFilename(" .whitespaceanddots.png...", 0), + "whitespaceanddots.png" + ); + Assert.equal( + checkFilename(" \u00a0 \u00a0 extrawhitespace.png \u00a0 \u00a0 ", 0), + "extrawhitespace.png" + ); + Assert.equal( + checkFilename(" filename with whitespace.png ", 0), + "filename with whitespace.png" + ); + Assert.equal(checkFilename("\\path.png", 0), "_path.png"); + Assert.equal( + checkFilename("\\path*and/$?~file.png", 0), + "_path and_$ ~file.png" + ); + Assert.equal( + checkFilename(" \u180e whit\u180ee.png \u180e", 0), + "whit\u180ee.png" + ); + Assert.equal(checkFilename("簡単簡単簡単", 0), "簡単簡単簡単.png"); + Assert.equal(checkFilename(" happy\u061c\u2069.png", 0), "happy__.png"); + Assert.equal( + checkFilename("12345678".repeat(31) + "abcdefgh.png", 0), + "12345678".repeat(31) + "ab.png" + ); + Assert.equal( + checkFilename("簡単".repeat(41) + ".png", 0), + "簡単".repeat(41) + ".png" + ); + Assert.equal( + checkFilename("a" + "簡単".repeat(42) + ".png", 0), + "a" + "簡単".repeat(40) + "簡.png" + ); + Assert.equal( + checkFilename("a" + "簡単".repeat(56) + ".png", 0), + "a" + "簡単".repeat(40) + ".png" + ); + Assert.equal(checkFilename("café.png", 0), "café.png"); + Assert.equal( + checkFilename("café".repeat(50) + ".png", 0), + "café".repeat(50) + ".png" + ); + Assert.equal( + checkFilename("café".repeat(51) + ".png", 0), + "café".repeat(49) + "caf.png" + ); + + Assert.equal( + checkFilename("\u{100001}\u{100002}.png", 0), + "\u{100001}\u{100002}.png" + ); + Assert.equal( + checkFilename("\u{100001}\u{100002}".repeat(31) + ".png", 0), + "\u{100001}\u{100002}".repeat(31) + ".png" + ); + Assert.equal( + checkFilename("\u{100001}\u{100002}".repeat(32) + ".png", 0), + "\u{100001}\u{100002}".repeat(30) + "\u{100001}.png" + ); + + Assert.equal( + checkFilename("noextensionfile".repeat(16), 0), + "noextensionfile".repeat(16) + ".png" + ); + Assert.equal( + checkFilename("noextensionfile".repeat(17), 0), + "noextensionfile".repeat(16) + "noextensio.png" + ); + Assert.equal( + checkFilename("noextensionfile".repeat(16) + "noextensionfil.", 0), + "noextensionfile".repeat(16) + "noextensio.png" + ); + + Assert.equal(checkFilename(" first .png ", 0), "first .png"); + Assert.equal( + checkFilename( + " second .png ", + mimeService.VALIDATE_DONT_COLLAPSE_WHITESPACE + ), + "second .png" + ); + + // For whatever reason, the Android mime handler accepts the .jpeg + // extension for image/png, so skip this test there. + if (AppConstants.platform != "android") { + Assert.equal(checkFilename("thi/*rd.jpeg", 0), "thi_ rd.png"); + } + + Assert.equal( + checkFilename("f*\\ourth file.jpg", mimeService.VALIDATE_SANITIZE_ONLY), + "f _ourth file.jpg" + ); + Assert.equal( + checkFilename( + "f*\\ift h.jpe*\\g", + mimeService.VALIDATE_SANITIZE_ONLY | + mimeService.VALIDATE_DONT_COLLAPSE_WHITESPACE + ), + "f _ift h.jpe _g" + ); + Assert.equal(checkFilename("sixth.j pe/*g", 0), "sixth.png"); + + let repeatStr = "12345678".repeat(31); + Assert.equal( + checkFilename( + repeatStr + "seventh.png", + mimeService.VALIDATE_DONT_TRUNCATE + ), + repeatStr + "seventh.png" + ); + Assert.equal( + checkFilename(repeatStr + "seventh.png", 0), + repeatStr + "se.png" + ); + + // no filename, so index is used by default. + Assert.equal(checkFilename(".png", 0), "png.png"); + + // sanitization only, so Untitled is not added, but initial period is stripped. + Assert.equal( + checkFilename(".png", mimeService.VALIDATE_SANITIZE_ONLY), + "png" + ); + + // correct .png extension is applied. + Assert.equal(checkFilename(".butterpecan.icecream", 0), "butterpecan.png"); + + // sanitization only, so extension is not modified, but initial period is stripped. + Assert.equal( + checkFilename(".butterpecan.icecream", mimeService.VALIDATE_SANITIZE_ONLY), + "butterpecan.icecream" + ); + + let ext = ".fairlyLongExtension"; + Assert.equal( + checkFilename(repeatStr + ext, mimeService.VALIDATE_SANITIZE_ONLY), + repeatStr.substring(0, 254 - ext.length) + ext + ); + + ext = "lo%?n/ginvalid? ch\\ars"; + Assert.equal( + checkFilename(repeatStr + ext, mimeService.VALIDATE_SANITIZE_ONLY), + repeatStr + "lo% n_" + ); + + ext = ".long/invalid%? ch\\ars"; + Assert.equal( + checkFilename(repeatStr + ext, mimeService.VALIDATE_SANITIZE_ONLY), + repeatStr.substring(0, 233) + ".long_invalid% ch_ars" + ); + + Assert.equal( + checkFilename("test_テスト_T\x83E\\S\x83T.png", 0), + "test_テスト_T E_S T.png" + ); + Assert.equal( + checkFilename("test_テスト_T\x83E\\S\x83T.pテ\x83ng", 0), + "test_テスト_T E_S T.png" + ); + + // Check we don't invalidate surrogate pairs when trimming. + Assert.equal(checkFilename("test😀", 0, ""), "test😀"); + Assert.equal(checkFilename("test😀😀", 0, ""), "test😀😀"); + + // Now check some media types + Assert.equal( + mimeService.validateFileNameForSaving("video.ogg", "video/ogg", 0), + "video.ogg", + "video.ogg" + ); + Assert.equal( + mimeService.validateFileNameForSaving("video.ogv", "video/ogg", 0), + "video.ogv", + "video.ogv" + ); + Assert.equal( + mimeService.validateFileNameForSaving("video.ogt", "video/ogg", 0), + "video.ogv", + "video.ogt" + ); + + Assert.equal( + mimeService.validateFileNameForSaving("audio.mp3", "audio/mpeg", 0), + "audio.mp3", + "audio.mp3" + ); + Assert.equal( + mimeService.validateFileNameForSaving("audio.mpega", "audio/mpeg", 0), + "audio.mpega", + "audio.mpega" + ); + Assert.equal( + mimeService.validateFileNameForSaving("audio.mp2", "audio/mpeg", 0), + "audio.mp2", + "audio.mp2" + ); + + let expected = "audio.mp3"; + if (AppConstants.platform == "linux") { + expected = "audio.mpga"; + } else if (AppConstants.platform == "android") { + expected = "audio.mp4"; + } + + Assert.equal( + mimeService.validateFileNameForSaving("audio.mp4", "audio/mpeg", 0), + expected, + "audio.mp4" + ); + + Assert.equal( + mimeService.validateFileNameForSaving("sound.m4a", "audio/mp4", 0), + "sound.m4a", + "sound.m4a" + ); + Assert.equal( + mimeService.validateFileNameForSaving("sound.m4b", "audio/mp4", 0), + AppConstants.platform == "android" ? "sound.m4a" : "sound.m4b", + "sound.m4b" + ); + Assert.equal( + mimeService.validateFileNameForSaving("sound.m4c", "audio/mp4", 0), + AppConstants.platform == "macosx" ? "sound.mp4" : "sound.m4a", + "sound.mpc" + ); + + // This has a long filename with a 13 character extension. The end of the filename should be + // cropped to fit into 255 bytes. + Assert.equal( + mimeService.validateFileNameForSaving( + "라이브9.9만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데이 앵콜 🎁 1.등 유산균 컬처렐 특가!", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "라이브9.9만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 .등 유산균 컬처렐 특가!", + "very long filename with extension" + ); + + // This filename has a very long extension, almost the entire filename. + Assert.equal( + mimeService.validateFileNameForSaving( + "라이브9.9만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데이 앵콜 🎁 1등 유산균 컬처렐 특가!", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "라이브9", + "another very long filename with long extension" + ); + + // This filename is cropped at 254 bytes. + Assert.equal( + mimeService.validateFileNameForSaving( + ".라이브99만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데이 앵콜 🎁 1등 유산균 컬처렐 특가!", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "라이브99만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데", + "very filename with extension only" + ); + + Assert.equal( + mimeService.validateFileNameForSaving("filename.LNK", "text/unknown", 0), + "filename.LNK.download", + "filename.LNK" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.local", "text/unknown", 0), + "filename.local.download", + "filename.local" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.url", "text/unknown", 0), + "filename.url.download", + "filename.url" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.URl", "text/unknown", 0), + "filename.URl.download", + "filename.URl" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.scf", "text/unknown", 0), + "filename.scf.download", + "filename.scf" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.sCF", "text/unknown", 0), + "filename.sCF.download", + "filename.sCF" + ); + + Assert.equal( + mimeService.validateFileNameForSaving("filename.lnk\n", "text/unknown", 0), + "filename.lnk.download", + "filename.lnk with newline" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.lnk\n ", + "text/unknown", + 0 + ), + "filename.lnk.download", + "filename.lnk with newline" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.\n\t lnk", + "text/unknown", + 0 + ), + "filename. lnk", + "filename.lnk with space and newline" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.local\u180e\u180e\u180e", + "text/unknown", + 0 + ), + "filename.local.download", + "filename.lnk with vowel separators" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.LNK", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "filename.LNK.download", + "filename.LNK sanitize only" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.LNK\n", + "text/unknown", + mimeService.VALIDATE_ALLOW_INVALID_FILENAMES + ), + "filename.LNK", + "filename.LNK allow invalid" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.URL\n", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY | + mimeService.VALIDATE_ALLOW_INVALID_FILENAMES + ), + "filename.URL", + "filename.URL allow invalid, sanitize only" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.desktop", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "filename.desktop.download", + "filename.desktop sanitize only" + ); + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.DESKTOP\n", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY | + mimeService.VALIDATE_ALLOW_INVALID_FILENAMES + ), + "filename.DESKTOP", + "filename.DESKTOP allow invalid, sanitize only" + ); +}); diff --git a/uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js b/uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js new file mode 100644 index 0000000000..6f4fe52a49 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_utf8_extension() { + const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + let someMIME = mimeService.getFromTypeAndExtension( + "application/x-nonsense", + ".тест" + ); + Assert.stringContains(someMIME.description, "тест"); + // primary extension isn't set on macOS or android, see bug 1721181 + if (AppConstants.platform != "macosx" && AppConstants.platform != "android") { + Assert.equal(someMIME.primaryExtension, ".тест"); + } +}); diff --git a/uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js b/uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js new file mode 100644 index 0000000000..03b0cea25e --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); + +// PDF files should always have a generic description instead +// of relying on what is registered with the Operating System. +add_task(async function test_check_unknown_mime_type() { + const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + let pdfType = mimeService.getTypeFromExtension("pdf"); + Assert.equal(pdfType, "application/pdf"); + let extension = mimeService.getPrimaryExtension("application/pdf", ""); + Assert.equal(extension, "pdf", "Expect pdf extension when given mime"); + let mimeInfo = gMIMEService.getFromTypeAndExtension("", "pdf"); + let stringBundle = Services.strings.createBundle( + "chrome://mozapps/locale/downloads/unknownContentType.properties" + ); + Assert.equal( + mimeInfo.description, + stringBundle.GetStringFromName("pdfExtHandlerDescription"), + "PDF has generic description" + ); +}); diff --git a/uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js b/uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js new file mode 100644 index 0000000000..9beef9d9c5 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Zip files can be opened by Windows explorer, so we should always be able to +// determine a description and default handler for them. However, things can +// get messy if they are sent to us with a mime type other than what Windows +// considers the "right" mimetype (application/x-zip-compressed), like +// application/zip, which is what most places (IANA, macOS, probably all linux +// distros, Apache, etc.) think is the "right" mimetype. +add_task(async function test_check_unknown_mime_type() { + const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + let zipType = mimeService.getTypeFromExtension("zip"); + Assert.equal(zipType, "application/x-zip-compressed"); + try { + let extension = mimeService.getPrimaryExtension("application/zip", ""); + Assert.equal( + extension, + "zip", + "Expect our own info to provide an extension for zip files." + ); + } catch (ex) { + Assert.ok(false, "We shouldn't throw when getting zip info."); + } + let found = {}; + let mimeInfo = mimeService.getMIMEInfoFromOS("application/zip", "zip", found); + Assert.ok( + mimeInfo.hasDefaultHandler, + "Should have a default app for zip files" + ); +}); diff --git a/uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js new file mode 100644 index 0000000000..7202db58de --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js @@ -0,0 +1,65 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +/** + * Test for bug 508030 <https://bugzilla.mozilla.org/show_bug.cgi?id=508030>: + * nsIMIMEService.getTypeFromExtension fails to find a match in the + * "ext-to-type-mapping" category if the provided extension is not lowercase. + */ +function run_test() { + // --- Common services --- + + const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + + const categoryManager = Services.catMan; + + // --- Test procedure --- + + const kTestExtension = "testextension"; + const kTestExtensionMixedCase = "testExtensIon"; + const kTestMimeType = "application/x-testextension"; + + // Ensure that the test extension is not initially recognized by the operating + // system or the "ext-to-type-mapping" category. + try { + // Try and get the MIME type associated with the extension. + mimeService.getTypeFromExtension(kTestExtension); + // The line above should have thrown an exception. + do_throw("nsIMIMEService.getTypeFromExtension succeeded unexpectedly"); + } catch (e) { + if ( + !(e instanceof Ci.nsIException) || + e.result != Cr.NS_ERROR_NOT_AVAILABLE + ) { + throw e; + } + // This is an expected exception, thrown if the type can't be determined. + // Any other exception would cause the test to fail. + } + + // Add a temporary category entry mapping the extension to the MIME type. + categoryManager.addCategoryEntry( + "ext-to-type-mapping", + kTestExtension, + kTestMimeType, + false, + true + ); + + // Check that the mapping is recognized in the simple case. + var type = mimeService.getTypeFromExtension(kTestExtension); + Assert.equal(type, kTestMimeType); + + // Check that the mapping is recognized even if the extension has mixed case. + type = mimeService.getTypeFromExtension(kTestExtensionMixedCase); + Assert.equal(type, kTestMimeType); + + // Clean up after ourselves. + categoryManager.deleteCategoryEntry( + "ext-to-type-mapping", + kTestExtension, + false + ); +} diff --git a/uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js new file mode 100644 index 0000000000..5076a57738 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js @@ -0,0 +1,218 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +/** + * Test for bug 484579 <https://bugzilla.mozilla.org/show_bug.cgi?id=484579>: + * nsIMIMEService.getTypeFromExtension may fail unexpectedly on Windows when + * "Content Type" is empty in the registry. + */ + +// We must use a file extension that isn't listed in nsExternalHelperAppService's +// defaultMimeEntries, otherwise the code takes a shortcut skipping the registry. +const FILE_EXTENSION = ".nfo"; +// This is used to ensure the test properly used the mock, so that if we change +// the underlying code, it won't be skipped. +let gTestUsedOurMock = false; + +function run_test() { + // Activate the override of the file association data in the registry. + registerMockWindowsRegKeyFactory(); + + // Check the mock has been properly activated. + let regKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + regKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT, + FILE_EXTENSION, + Ci.nsIWindowsRegKey.ACCESS_QUERY_VALUE + ); + Assert.equal( + regKey.readStringValue("content type"), + "", + "Check the mock replied as expected." + ); + Assert.ok(gTestUsedOurMock, "The test properly used the mock registry"); + // Reset gTestUsedOurMock, because we just used it. + gTestUsedOurMock = false; + // Try and get the MIME type associated with the extension. If this + // operation does not throw an unexpected exception, the test succeeds. + Assert.throws( + () => { + Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromExtension(FILE_EXTENSION); + }, + /NS_ERROR_NOT_AVAILABLE/, + "Should throw a NOT_AVAILABLE exception" + ); + + Assert.ok(gTestUsedOurMock, "The test properly used the mock registry"); +} + +/** + * Constructs a new mock registry key by wrapping the provided object. + * + * This mock implementation is tailored for this test, and forces consumers + * of the readStringValue method to believe that the "Content Type" value of + * the FILE_EXTENSION key under HKEY_CLASSES_ROOT is an empty string. + * + * The same value read from "HKEY_LOCAL_MACHINE\SOFTWARE\Classes" is not + * affected. + * + * @param aWrappedObject An actual nsIWindowsRegKey implementation. + */ +function MockWindowsRegKey(aWrappedObject) { + this._wrappedObject = aWrappedObject; + + // This function creates a forwarding function for wrappedObject + function makeForwardingFunction(functionName) { + return function () { + return aWrappedObject[functionName].apply(aWrappedObject, arguments); + }; + } + + // Forward all the functions that are not explicitly overridden + for (var propertyName in aWrappedObject) { + if (!(propertyName in this)) { + if (typeof aWrappedObject[propertyName] == "function") { + this[propertyName] = makeForwardingFunction(propertyName); + } else { + this[propertyName] = aWrappedObject[propertyName]; + } + } + } +} + +MockWindowsRegKey.prototype = { + // --- Overridden nsISupports interface functions --- + + QueryInterface: ChromeUtils.generateQI(["nsIWindowsRegKey"]), + + // --- Overridden nsIWindowsRegKey interface functions --- + + open(aRootKey, aRelPath, aMode) { + // Remember the provided root key and path + this._rootKey = aRootKey; + this._relPath = aRelPath; + + // Create the actual registry key + return this._wrappedObject.open(aRootKey, aRelPath, aMode); + }, + + openChild(aRelPath, aMode) { + // Open the child key and wrap it + var innerKey = this._wrappedObject.openChild(aRelPath, aMode); + var key = new MockWindowsRegKey(innerKey); + + // Set the properties of the child key and return it + key._rootKey = this._rootKey; + key._relPath = this._relPath + aRelPath; + return key; + }, + + createChild(aRelPath, aMode) { + // Create the child key and wrap it + var innerKey = this._wrappedObject.createChild(aRelPath, aMode); + var key = new MockWindowsRegKey(innerKey); + + // Set the properties of the child key and return it + key._rootKey = this._rootKey; + key._relPath = this._relPath + aRelPath; + return key; + }, + + get childCount() { + return this._wrappedObject.childCount; + }, + + get valueCount() { + return this._wrappedObject.valueCount; + }, + + readStringValue(aName) { + // If this is the key under test, return a fake value + if ( + this._rootKey == Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT && + this._relPath.toLowerCase() == FILE_EXTENSION && + aName.toLowerCase() == "content type" + ) { + gTestUsedOurMock = true; + return ""; + } + // Return the real value from the registry + return this._wrappedObject.readStringValue(aName); + }, +}; + +function registerMockWindowsRegKeyFactory() { + const kMockCID = Components.ID("{9b23dfe9-296b-4740-ba1c-d39c9a16e55e}"); + const kWindowsRegKeyContractID = "@mozilla.org/windows-registry-key;1"; + // Preserve the original CID. + let originalWindowsRegKeyCID = Cc[kWindowsRegKeyContractID].number; + + // See bug 1694345 - nsNotifyAddrListener::CheckAdaptersAddresses might + // attempt to use the registry off the main thread, so we disable that + // feature while the mock registry is active. + let oldSuffixListPref = Services.prefs.getBoolPref( + "network.notify.dnsSuffixList" + ); + Services.prefs.setBoolPref("network.notify.dnsSuffixList", false); + + let oldCheckForProxiesPref = Services.prefs.getBoolPref( + "network.notify.checkForProxies" + ); + Services.prefs.setBoolPref("network.notify.checkForProxies", false); + + let oldCheckForNRPTPref = Services.prefs.getBoolPref( + "network.notify.checkForNRPT" + ); + Services.prefs.setBoolPref("network.notify.checkForNRPT", false); + + info("Create a mock RegKey factory"); + let originalRegKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + let mockWindowsRegKeyFactory = { + createInstance(iid) { + info("Create a mock wrapper around RegKey"); + var key = new MockWindowsRegKey(originalRegKey); + return key.QueryInterface(iid); + }, + }; + info("Register the mock RegKey factory"); + let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory( + kMockCID, + "Mock Windows Registry Key Implementation", + kWindowsRegKeyContractID, + mockWindowsRegKeyFactory + ); + + registerCleanupFunction(() => { + // Free references to the mock factory + registrar.unregisterFactory(kMockCID, mockWindowsRegKeyFactory); + // Restore the original factory + registrar.registerFactory( + Components.ID(originalWindowsRegKeyCID), + "", + kWindowsRegKeyContractID, + null + ); + + Services.prefs.setBoolPref( + "network.notify.dnsSuffixList", + oldSuffixListPref + ); + Services.prefs.setBoolPref( + "network.notify.checkForProxies", + oldCheckForProxiesPref + ); + Services.prefs.setBoolPref( + "network.notify.checkForNRPT", + oldCheckForNRPTPref + ); + }); +} diff --git a/uriloader/exthandler/tests/unit/test_handlerService.js b/uriloader/exthandler/tests/unit/test_handlerService.js new file mode 100644 index 0000000000..46a4d9fdd9 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_handlerService.js @@ -0,0 +1,467 @@ +/* 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/. */ + +function run_test() { + //* *************************************************************************// + // Constants + + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + + const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + + const prefSvc = Services.prefs; + + let noMailto = false; + if (mozinfo.os == "win") { + // Check mailto handler from registry. + // If registry entry is nothing, no mailto handler + let regSvc = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + try { + regSvc.open(regSvc.ROOT_KEY_CLASSES_ROOT, "mailto", regSvc.ACCESS_READ); + noMailto = false; + } catch (ex) { + noMailto = true; + } + regSvc.close(); + } + + if (mozinfo.os == "linux") { + // Check mailto handler from GIO + // If there isn't one, then we have no mailto handler + let gIOSvc = Cc["@mozilla.org/gio-service;1"].createInstance( + Ci.nsIGIOService + ); + try { + gIOSvc.getAppForURIScheme("mailto"); + noMailto = false; + } catch (ex) { + noMailto = true; + } + } + + //* *************************************************************************// + // Sample Data + + // It doesn't matter whether or not this nsIFile is actually executable, + // only that it has a path and exists. Since we don't know any executable + // that exists on all platforms (except possibly the application being + // tested, but there doesn't seem to be a way to get a reference to that + // from the directory service), we use the temporary directory itself. + var executable = Services.dirsvc.get("TmpD", Ci.nsIFile); + // XXX We could, of course, create an actual executable in the directory: + // executable.append("localhandler"); + // if (!executable.exists()) + // executable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755); + + var localHandler = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + localHandler.name = "Local Handler"; + localHandler.executable = executable; + + var webHandler = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + webHandler.name = "Web Handler"; + webHandler.uriTemplate = "http://www.example.com/?%s"; + + // FIXME: these tests create and manipulate enough variables that it would + // make sense to move each test into its own scope so we don't run the risk + // of one test stomping on another's data. + + //* *************************************************************************// + // Test Default Properties + + // Get a handler info for a MIME type that neither the application nor + // the OS knows about and make sure its properties are set to the proper + // default values. + + var handlerInfo = mimeSvc.getFromTypeAndExtension("nonexistent/type", null); + + // Make sure it's also an nsIHandlerInfo. + Assert.ok(handlerInfo instanceof Ci.nsIHandlerInfo); + + Assert.equal(handlerInfo.type, "nonexistent/type"); + + // Deprecated property, but we should still make sure it's set correctly. + Assert.equal(handlerInfo.MIMEType, "nonexistent/type"); + + // These properties are the ones the handler service knows how to store. + Assert.equal(handlerInfo.preferredAction, Ci.nsIHandlerInfo.saveToDisk); + Assert.equal(handlerInfo.preferredApplicationHandler, null); + Assert.equal(handlerInfo.possibleApplicationHandlers.length, 0); + Assert.equal( + handlerInfo.alwaysAskBeforeHandling, + prefSvc.getBoolPref( + "browser.download.always_ask_before_handling_new_types", + false + ) + ); + + // These properties are initialized to default values by the service, + // so we might as well make sure they're initialized to the right defaults. + Assert.equal(handlerInfo.description, ""); + Assert.equal(handlerInfo.hasDefaultHandler, false); + Assert.equal(handlerInfo.defaultDescription, ""); + + const kExternalWarningDefault = + "network.protocol-handler.warn-external-default"; + prefSvc.setBoolPref(kExternalWarningDefault, true); + + // XXX add more thorough protocol info property checking + + // no OS default handler exists + var protoInfo = protoSvc.getProtocolHandlerInfo("x-moz-rheet"); + Assert.equal(protoInfo.preferredAction, protoInfo.alwaysAsk); + Assert.ok(protoInfo.alwaysAskBeforeHandling); + + // OS default exists, injected default does not exist, + // explicit warning pref: false + const kExternalWarningPrefPrefix = "network.protocol-handler.warn-external."; + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "http", false); + protoInfo = protoSvc.getProtocolHandlerInfo("http"); + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + // NOTE: this assertion will fail if the system executing the test does not + // have a handler registered for the http protocol. This is very unlikely to + // actually happen except on certain configurations of Linux, but one of + // those configurations is the default WSL Ubuntu install. So, if you are + // running this test locally and seeing a failure here, it might not be + // anything to really worry about. + Assert.ok(!protoInfo.alwaysAskBeforeHandling); + + // OS default exists, injected default does not exist, + // explicit warning pref: true + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "http", true); + protoInfo = protoSvc.getProtocolHandlerInfo("http"); + // OS handler isn't included in possibleApplicationHandlers, so length is 0 + // Once they become instances of nsILocalHandlerApp, this number will need + // to change. + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + Assert.ok(protoInfo.alwaysAskBeforeHandling); + + // OS default exists, injected default exists, explicit warning pref: false + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "mailto", false); + protoInfo = protoSvc.getProtocolHandlerInfo("mailto"); + if (AppConstants.MOZ_APP_NAME == "thunderbird") { + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + } else { + Assert.equal(1, protoInfo.possibleApplicationHandlers.length); + } + + // Win7+ or Linux's GIO might not have a default mailto: handler + if (noMailto) { + Assert.ok(protoInfo.alwaysAskBeforeHandling); + } else { + Assert.ok(!protoInfo.alwaysAskBeforeHandling); + } + + // OS default exists, injected default exists, explicit warning pref: true + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "mailto", true); + protoInfo = protoSvc.getProtocolHandlerInfo("mailto"); + if (AppConstants.MOZ_APP_NAME == "thunderbird") { + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + } else { + Assert.equal(1, protoInfo.possibleApplicationHandlers.length); + // Win7+ or Linux's GIO may have no default mailto: handler, so we'd ask + // anyway. Otherwise, the default handlers will not have stored preferred + // actions etc., so re-requesting them after the warning pref has changed + // will use the updated pref value. So both when we have and do not have + // a default mailto: handler, we'll ask: + Assert.ok(protoInfo.alwaysAskBeforeHandling); + // As soon as anyone actually stores updated defaults into the profile + // database, that default will stop tracking the warning pref. + } + // Now set the value stored in RDF to true, and the pref to false, to make + // sure we still get the right value. (Basically, same thing as above but + // with the values reversed.) + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "mailto", false); + protoInfo.alwaysAskBeforeHandling = true; + handlerSvc.store(protoInfo); + protoInfo = protoSvc.getProtocolHandlerInfo("mailto"); + if (AppConstants.MOZ_APP_NAME == "thunderbird") { + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + } else { + Assert.equal(1, protoInfo.possibleApplicationHandlers.length); + Assert.ok(protoInfo.alwaysAskBeforeHandling); + } + + //* *************************************************************************// + // Test Round-Trip Data Integrity + + // Test round-trip data integrity by setting the properties of the handler + // info object to different values, telling the handler service to store the + // object, and then retrieving a new info object for the same type and making + // sure its properties are identical. + + handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + handlerInfo.preferredApplicationHandler = localHandler; + handlerInfo.alwaysAskBeforeHandling = false; + + handlerSvc.store(handlerInfo); + + handlerInfo = mimeSvc.getFromTypeAndExtension("nonexistent/type", null); + + Assert.equal(handlerInfo.preferredAction, Ci.nsIHandlerInfo.useHelperApp); + + Assert.notEqual(handlerInfo.preferredApplicationHandler, null); + var preferredHandler = handlerInfo.preferredApplicationHandler; + Assert.equal(typeof preferredHandler, "object"); + Assert.equal(preferredHandler.name, "Local Handler"); + Assert.ok(preferredHandler instanceof Ci.nsILocalHandlerApp); + preferredHandler.QueryInterface(Ci.nsILocalHandlerApp); + Assert.equal(preferredHandler.executable.path, localHandler.executable.path); + + Assert.ok(!handlerInfo.alwaysAskBeforeHandling); + + // Make sure the handler service's enumerate method lists all known handlers. + var handlerInfo2 = mimeSvc.getFromTypeAndExtension("nonexistent/type2", null); + handlerSvc.store(handlerInfo2); + var handlerTypes = ["nonexistent/type", "nonexistent/type2"]; + handlerTypes.push("mailto"); + for (let handler of handlerSvc.enumerate()) { + Assert.notEqual(handlerTypes.indexOf(handler.type), -1); + handlerTypes.splice(handlerTypes.indexOf(handler.type), 1); + } + Assert.equal(handlerTypes.length, 0); + // Make sure the handler service's remove method removes a handler record. + handlerSvc.remove(handlerInfo2); + let handlers = handlerSvc.enumerate(); + while (handlers.hasMoreElements()) { + Assert.notEqual( + handlers.getNext().QueryInterface(Ci.nsIHandlerInfo).type, + handlerInfo2.type + ); + } + + // Make sure we can store and retrieve a handler info object with no preferred + // handler. + var noPreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/no-preferred-handler", + null + ); + handlerSvc.store(noPreferredHandlerInfo); + noPreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/no-preferred-handler", + null + ); + Assert.equal(noPreferredHandlerInfo.preferredApplicationHandler, null); + + // Make sure that the handler service removes an existing handler record + // if we store a handler info object with no preferred handler. + var removePreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/rem-preferred-handler", + null + ); + removePreferredHandlerInfo.preferredApplicationHandler = localHandler; + handlerSvc.store(removePreferredHandlerInfo); + removePreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/rem-preferred-handler", + null + ); + removePreferredHandlerInfo.preferredApplicationHandler = null; + handlerSvc.store(removePreferredHandlerInfo); + removePreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/rem-preferred-handler", + null + ); + Assert.equal(removePreferredHandlerInfo.preferredApplicationHandler, null); + + // Make sure we can store and retrieve a handler info object with possible + // handlers. We test both adding and removing handlers. + + // Get a handler info and make sure it has no possible handlers. + var possibleHandlersInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/possible-handlers", + null + ); + Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 0); + + // Store and re-retrieve the handler and make sure it still has no possible + // handlers. + handlerSvc.store(possibleHandlersInfo); + possibleHandlersInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/possible-handlers", + null + ); + Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 0); + + // Add two handlers, store the object, re-retrieve it, and make sure it has + // two handlers. + possibleHandlersInfo.possibleApplicationHandlers.appendElement(localHandler); + possibleHandlersInfo.possibleApplicationHandlers.appendElement(webHandler); + handlerSvc.store(possibleHandlersInfo); + possibleHandlersInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/possible-handlers", + null + ); + Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 2); + + // Figure out which is the local and which is the web handler and the index + // in the array of the local handler, which is the one we're going to remove + // to test removal of a handler. + var handler1 = + possibleHandlersInfo.possibleApplicationHandlers.queryElementAt( + 0, + Ci.nsIHandlerApp + ); + var handler2 = + possibleHandlersInfo.possibleApplicationHandlers.queryElementAt( + 1, + Ci.nsIHandlerApp + ); + var localPossibleHandler, webPossibleHandler, localIndex; + if (handler1 instanceof Ci.nsILocalHandlerApp) { + [localPossibleHandler, webPossibleHandler, localIndex] = [ + handler1, + handler2, + 0, + ]; + } else { + [localPossibleHandler, webPossibleHandler, localIndex] = [ + handler2, + handler1, + 1, + ]; + } + localPossibleHandler.QueryInterface(Ci.nsILocalHandlerApp); + webPossibleHandler.QueryInterface(Ci.nsIWebHandlerApp); + + // Make sure the two handlers are the ones we stored. + Assert.equal(localPossibleHandler.name, localHandler.name); + Assert.ok(localPossibleHandler.equals(localHandler)); + Assert.equal(webPossibleHandler.name, webHandler.name); + Assert.ok(webPossibleHandler.equals(webHandler)); + + // Remove a handler, store the object, re-retrieve it, and make sure + // it only has one handler. + possibleHandlersInfo.possibleApplicationHandlers.removeElementAt(localIndex); + handlerSvc.store(possibleHandlersInfo); + possibleHandlersInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/possible-handlers", + null + ); + Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 1); + + // Make sure the handler is the one we didn't remove. + webPossibleHandler = + possibleHandlersInfo.possibleApplicationHandlers.queryElementAt( + 0, + Ci.nsIWebHandlerApp + ); + Assert.equal(webPossibleHandler.name, webHandler.name); + Assert.ok(webPossibleHandler.equals(webHandler)); + + // //////////////////////////////////////////////////// + // handler info command line parameters and equality + var localApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + var handlerApp = localApp.QueryInterface(Ci.nsIHandlerApp); + + Assert.ok(handlerApp.equals(localApp)); + + localApp.executable = executable; + + Assert.equal(0, localApp.parameterCount); + localApp.appendParameter("-test1"); + Assert.equal(1, localApp.parameterCount); + localApp.appendParameter("-test2"); + Assert.equal(2, localApp.parameterCount); + Assert.ok(localApp.parameterExists("-test1")); + Assert.ok(localApp.parameterExists("-test2")); + Assert.ok(!localApp.parameterExists("-false")); + localApp.clearParameters(); + Assert.equal(0, localApp.parameterCount); + + var localApp2 = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + + localApp2.executable = executable; + + localApp.clearParameters(); + Assert.ok(localApp.equals(localApp2)); + + // equal: + // cut -d 1 -f 2 + // cut -d 1 -f 2 + + localApp.appendParameter("-test1"); + localApp.appendParameter("-test2"); + localApp.appendParameter("-test3"); + localApp2.appendParameter("-test1"); + localApp2.appendParameter("-test2"); + localApp2.appendParameter("-test3"); + Assert.ok(localApp.equals(localApp2)); + + // not equal: + // cut -d 1 -f 2 + // cut -f 1 -d 2 + + localApp.clearParameters(); + localApp2.clearParameters(); + + localApp.appendParameter("-test1"); + localApp.appendParameter("-test2"); + localApp.appendParameter("-test3"); + localApp2.appendParameter("-test2"); + localApp2.appendParameter("-test1"); + localApp2.appendParameter("-test3"); + Assert.ok(!localApp2.equals(localApp)); + + var str; + str = localApp.getParameter(0); + Assert.equal(str, "-test1"); + str = localApp.getParameter(1); + Assert.equal(str, "-test2"); + str = localApp.getParameter(2); + Assert.equal(str, "-test3"); + + // FIXME: test round trip integrity for a protocol. + // FIXME: test round trip integrity for a handler info with a web handler. + + //* *************************************************************************// + // getTypeFromExtension tests + + // test nonexistent extension + var lolType = handlerSvc.getTypeFromExtension("lolcat"); + Assert.equal(lolType, ""); + + // add a handler for the extension + var lolHandler = mimeSvc.getFromTypeAndExtension("application/lolcat", null); + + Assert.ok(!lolHandler.extensionExists("lolcat")); + lolHandler.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + lolHandler.preferredApplicationHandler = localHandler; + lolHandler.alwaysAskBeforeHandling = false; + lolHandler.appendExtension("lolcat"); + + // store the handler + Assert.ok(!handlerSvc.exists(lolHandler)); + handlerSvc.store(lolHandler); + Assert.ok(handlerSvc.exists(lolHandler)); + + // test now-existent extension + lolType = handlerSvc.getTypeFromExtension("lolcat"); + Assert.equal(lolType, "application/lolcat"); + + // test mailcap entries with needsterminal are ignored on non-Windows non-Mac. + if (mozinfo.os != "win" && mozinfo.os != "mac") { + prefSvc.setStringPref( + "helpers.private_mailcap_file", + do_get_file("mailcap").path + ); + handlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", null); + Assert.equal(handlerInfo.preferredAction, Ci.nsIHandlerInfo.saveToDisk); + Assert.equal(handlerInfo.defaultDescription, "sed"); + } +} diff --git a/uriloader/exthandler/tests/unit/test_handlerService_store.js b/uriloader/exthandler/tests/unit/test_handlerService_store.js new file mode 100644 index 0000000000..27c8dc6c9e --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_handlerService_store.js @@ -0,0 +1,757 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests the nsIHandlerService interface. + */ + +// Set up an nsIWebHandlerApp instance that can be used in multiple tests. +let webHandlerApp = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" +].createInstance(Ci.nsIWebHandlerApp); +webHandlerApp.name = "Web Handler"; +webHandlerApp.uriTemplate = "https://www.example.com/?url=%s"; +let expectedWebHandlerApp = { + name: webHandlerApp.name, + uriTemplate: webHandlerApp.uriTemplate, +}; + +// Set up an nsILocalHandlerApp instance that can be used in multiple tests. The +// executable should exist, but it doesn't need to point to an actual file, so +// we simply initialize it to the path of an existing directory. +let localHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" +].createInstance(Ci.nsILocalHandlerApp); +localHandlerApp.name = "Local Handler"; +localHandlerApp.executable = new FileUtils.File(PathUtils.tempDir); +let expectedLocalHandlerApp = { + name: localHandlerApp.name, + executable: localHandlerApp.executable, +}; + +/** + * Returns a new nsIHandlerInfo instance initialized to known values that don't + * depend on the platform and are easier to verify later. + * + * @param type + * Because the "preferredAction" is initialized to saveToDisk, this + * should represent a MIME type rather than a protocol. + */ +function getKnownHandlerInfo(type) { + let handlerInfo = HandlerServiceTestUtils.getBlankHandlerInfo(type); + handlerInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk; + handlerInfo.alwaysAskBeforeHandling = false; + return handlerInfo; +} + +/** + * Checks that the information stored in the handler service instance under + * testing matches the test data files. + */ +function assertAllHandlerInfosMatchTestData() { + let handlerInfos = HandlerServiceTestUtils.getAllHandlerInfos(); + + // It's important that the MIME types we check here do not exist at the + // operating system level, otherwise the list of handlers and file extensions + // will be merged. The current implementation avoids duplicate entries. + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "example/type.handleinternally", + preferredAction: Ci.nsIHandlerInfo.handleInternally, + alwaysAskBeforeHandling: false, + fileExtensions: ["example_one"], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "example/type.savetodisk", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: true, + preferredApplicationHandler: { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + possibleApplicationHandlers: [ + { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + ], + fileExtensions: ["example_two", "example_three"], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "example/type.usehelperapp", + preferredAction: Ci.nsIHandlerInfo.useHelperApp, + alwaysAskBeforeHandling: true, + preferredApplicationHandler: { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + possibleApplicationHandlers: [ + { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + { + name: "Example Possible Handler One", + uriTemplate: "http://www.example.com/?id=1&url=%s", + }, + { + name: "Example Possible Handler Two", + uriTemplate: "http://www.example.com/?id=2&url=%s", + }, + ], + fileExtensions: ["example_two", "example_three"], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "example/type.usesystemdefault", + preferredAction: Ci.nsIHandlerInfo.useSystemDefault, + alwaysAskBeforeHandling: false, + possibleApplicationHandlers: [ + { + name: "Example Possible Handler", + uriTemplate: "http://www.example.com/?url=%s", + }, + ], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "examplescheme.usehelperapp", + preferredAction: Ci.nsIHandlerInfo.useHelperApp, + alwaysAskBeforeHandling: true, + preferredApplicationHandler: { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + possibleApplicationHandlers: [ + { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + { + name: "Example Possible Handler One", + uriTemplate: "http://www.example.com/?id=1&url=%s", + }, + { + name: "Example Possible Handler Two", + uriTemplate: "http://www.example.com/?id=2&url=%s", + }, + ], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "examplescheme.usesystemdefault", + preferredAction: Ci.nsIHandlerInfo.useSystemDefault, + alwaysAskBeforeHandling: false, + possibleApplicationHandlers: [ + { + name: "Example Possible Handler", + uriTemplate: "http://www.example.com/?url=%s", + }, + ], + }); + + Assert.equal(handlerInfos.length, 0); +} + +/** + * Loads data from a file in a predefined format, verifying that the format is + * recognized and all the known properties are loaded and saved. + */ +add_task(async function test_store_fillHandlerInfo_predefined() { + // Test that the file format used in previous versions can be loaded. + await copyTestDataToHandlerStore(); + await assertAllHandlerInfosMatchTestData(); + + // Keep a copy of the nsIHandlerInfo instances, then delete the handler store + // and populate it with the known data. Since the handler store is empty, the + // default handlers for the current locale are also injected, so we have to + // delete them manually before adding the other nsIHandlerInfo instances. + let testHandlerInfos = HandlerServiceTestUtils.getAllHandlerInfos(); + await deleteHandlerStore(); + for (let handlerInfo of HandlerServiceTestUtils.getAllHandlerInfos()) { + gHandlerService.remove(handlerInfo); + } + for (let handlerInfo of testHandlerInfos) { + gHandlerService.store(handlerInfo); + } + + // Test that the known data still matches after saving it and reloading. + await unloadHandlerStore(); + await assertAllHandlerInfosMatchTestData(); +}); + +/** + * Check that "store" is able to add new instances, that "remove" and "exists" + * work, and that "fillHandlerInfo" throws when the instance does not exist. + */ +add_task(async function test_store_remove_exists() { + // Test both MIME types and protocols. + for (let type of [ + "example/type.usehelperapp", + "examplescheme.usehelperapp", + ]) { + // Create new nsIHandlerInfo instances before loading the test data. + await deleteHandlerStore(); + let handlerInfoPresent = HandlerServiceTestUtils.getHandlerInfo(type); + let handlerInfoAbsent = HandlerServiceTestUtils.getHandlerInfo(type + "2"); + + // Set up known properties that we can verify later. + handlerInfoAbsent.preferredAction = Ci.nsIHandlerInfo.saveToDisk; + handlerInfoAbsent.alwaysAskBeforeHandling = false; + + await copyTestDataToHandlerStore(); + + Assert.ok(gHandlerService.exists(handlerInfoPresent)); + Assert.ok(!gHandlerService.exists(handlerInfoAbsent)); + + gHandlerService.store(handlerInfoAbsent); + gHandlerService.remove(handlerInfoPresent); + + await unloadHandlerStore(); + + Assert.ok(!gHandlerService.exists(handlerInfoPresent)); + Assert.ok(gHandlerService.exists(handlerInfoAbsent)); + + Assert.throws( + () => gHandlerService.fillHandlerInfo(handlerInfoPresent, ""), + ex => ex.result == Cr.NS_ERROR_NOT_AVAILABLE + ); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo(type + "2"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: type + "2", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + }); + } +}); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance with a + * "preferredAction" that is either a valid or an unknown value, and the + * action always takes on an appropriate value when reloading. + */ +add_task(async function test_store_preferredAction() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + // Valid action values should all remain unchanged across a refresh, except + // for alwaysAsk which may be overridden with useHelperApp depending on prefs. + // Invalid action values should always convert to useHelperApp. + const actions = [ + { + preferred: Ci.nsIHandlerInfo.alwaysAsk, + expected: Ci.nsIHandlerInfo.alwaysAsk, + }, + { + preferred: Ci.nsIHandlerInfo.handleInternally, + expected: Ci.nsIHandlerInfo.handleInternally, + }, + { preferred: 999, expected: Ci.nsIHandlerInfo.useHelperApp }, + ]; + + for (let action of actions) { + handlerInfo.preferredAction = action.preferred; + gHandlerService.store(handlerInfo); + gHandlerService.fillHandlerInfo(handlerInfo, ""); + Assert.equal(handlerInfo.preferredAction, action.expected); + } +}); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance containing an + * nsILocalHandlerApp instance pointing to an executable that doesn't exist, but + * this entry is ignored when reloading. + */ +add_task(async function test_store_localHandlerApp_missing() { + if (!("@mozilla.org/uriloader/dbus-handler-app;1" in Cc)) { + info("Skipping test because it does not apply to this platform."); + return; + } + + let missingHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + missingHandlerApp.name = "Non-existing Handler"; + missingHandlerApp.executable = new FileUtils.File( + PathUtils.join(PathUtils.tempDir, "nonexisting") + ); + + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = missingHandlerApp; + handlerInfo.possibleApplicationHandlers.appendElement(missingHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + possibleApplicationHandlers: [expectedWebHandlerApp], + }); +}); + +/** + * Test saving and reloading an instance of nsIDBusHandlerApp. + */ +add_task(async function test_store_dBusHandlerApp() { + if (!("@mozilla.org/uriloader/dbus-handler-app;1" in Cc)) { + info("Skipping test because it does not apply to this platform."); + return; + } + + // Set up an nsIDBusHandlerApp instance for testing. + let dBusHandlerApp = Cc[ + "@mozilla.org/uriloader/dbus-handler-app;1" + ].createInstance(Ci.nsIDBusHandlerApp); + dBusHandlerApp.name = "DBus Handler"; + dBusHandlerApp.service = "test.method.server"; + dBusHandlerApp.method = "Method"; + dBusHandlerApp.dBusInterface = "test.method.Type"; + dBusHandlerApp.objectPath = "/test/method/Object"; + let expectedDBusHandlerApp = { + name: dBusHandlerApp.name, + service: dBusHandlerApp.service, + method: dBusHandlerApp.method, + dBusInterface: dBusHandlerApp.dBusInterface, + objectPath: dBusHandlerApp.objectPath, + }; + + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = dBusHandlerApp; + handlerInfo.possibleApplicationHandlers.appendElement(dBusHandlerApp); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedDBusHandlerApp, + possibleApplicationHandlers: [expectedDBusHandlerApp], + }); +}); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance with a + * "preferredApplicationHandler" and no "possibleApplicationHandlers", but the + * former is always included in the latter list when reloading. + */ +add_task( + async function test_store_possibleApplicationHandlers_includes_preferred() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = localHandlerApp; + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = + HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedLocalHandlerApp, + possibleApplicationHandlers: [expectedLocalHandlerApp], + }); + } +); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance with a + * "preferredApplicationHandler" that is not the first element in + * "possibleApplicationHandlers", but the former is always included as the first + * element of the latter list when reloading. + */ +add_task( + async function test_store_possibleApplicationHandlers_preferred_first() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = webHandlerApp; + // The preferred handler is appended after the other one. + handlerInfo.possibleApplicationHandlers.appendElement(localHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = + HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedWebHandlerApp, + possibleApplicationHandlers: [ + expectedWebHandlerApp, + expectedLocalHandlerApp, + ], + }); + } +); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance with an + * uppercase file extension, but it is converted to lowercase when reloading. + */ +add_task(async function test_store_fileExtensions_lowercase() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.appendExtension("extension_test1"); + handlerInfo.appendExtension("EXTENSION_test2"); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + fileExtensions: ["extension_test1", "extension_test2"], + }); +}); + +/** + * Tests that appendExtension doesn't add duplicates, and that anyway duplicates + * from possibleApplicationHandlers are removed when saving and reloading. + */ +add_task(async function test_store_no_duplicates() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = webHandlerApp; + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(localHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(localHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + handlerInfo.appendExtension("extension_test1"); + handlerInfo.appendExtension("extension_test2"); + handlerInfo.appendExtension("extension_test1"); + handlerInfo.appendExtension("EXTENSION_test1"); + Assert.deepEqual(Array.from(handlerInfo.getFileExtensions()), [ + "extension_test1", + "extension_test2", + ]); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedWebHandlerApp, + possibleApplicationHandlers: [ + expectedWebHandlerApp, + expectedLocalHandlerApp, + ], + fileExtensions: ["extension_test1", "extension_test2"], + }); +}); + +/** + * Tests that setFileExtensions doesn't add duplicates. + */ +add_task(async function test_setFileExtensions_no_duplicates() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.setFileExtensions("a,b,A,b,c,a"); + let expected = ["a", "b", "c"]; + Assert.deepEqual(Array.from(handlerInfo.getFileExtensions()), expected); + // Test empty extensions, also at begin and end. + handlerInfo.setFileExtensions(",a,,b,A,c,"); + Assert.deepEqual(Array.from(handlerInfo.getFileExtensions()), expected); +}); + +/** + * Tests that "store" deletes properties that have their default values from + * the data store. + * + * File extensions are never deleted once they have been associated. + */ +add_task(async function test_store_deletes_properties_except_extensions() { + await deleteHandlerStore(); + + // Prepare an nsIHandlerInfo instance with all the properties set to values + // that will result in deletions. The preferredAction is also set to a defined + // value so we can more easily verify it later. + let handlerInfo = HandlerServiceTestUtils.getBlankHandlerInfo( + "example/type.savetodisk" + ); + handlerInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk; + handlerInfo.alwaysAskBeforeHandling = false; + + // All the properties for "example/type.savetodisk" are present in the test + // data, so we load the data before overwriting their values. + await copyTestDataToHandlerStore(); + gHandlerService.store(handlerInfo); + + // Now we can reload the data and verify that no extra values have been kept. + await unloadHandlerStore(); + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo( + "example/type.savetodisk" + ); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/type.savetodisk", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + fileExtensions: ["example_two", "example_three"], + }); +}); + +/** + * Tests the "overrideType" argument of "fillHandlerInfo". + */ +add_task(async function test_fillHandlerInfo_overrideType() { + // Test both MIME types and protocols. + for (let type of [ + "example/type.usesystemdefault", + "examplescheme.usesystemdefault", + ]) { + await deleteHandlerStore(); + + // Create new nsIHandlerInfo instances before loading the test data. + let handlerInfoAbsent = HandlerServiceTestUtils.getHandlerInfo(type + "2"); + + // Fill the nsIHandlerInfo instance using the type that actually exists. + await copyTestDataToHandlerStore(); + gHandlerService.fillHandlerInfo(handlerInfoAbsent, type); + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfoAbsent, { + // While the data is populated from another type, the type is unchanged. + type: type + "2", + preferredAction: Ci.nsIHandlerInfo.useSystemDefault, + alwaysAskBeforeHandling: false, + possibleApplicationHandlers: [ + { + name: "Example Possible Handler", + uriTemplate: "http://www.example.com/?url=%s", + }, + ], + }); + } +}); + +/** + * Tests "getTypeFromExtension" including unknown extensions. + */ +add_task(async function test_getTypeFromExtension() { + await copyTestDataToHandlerStore(); + + Assert.equal(gHandlerService.getTypeFromExtension(""), ""); + Assert.equal(gHandlerService.getTypeFromExtension("example_unknown"), ""); + Assert.equal( + gHandlerService.getTypeFromExtension("example_one"), + "example/type.handleinternally" + ); + Assert.equal( + gHandlerService.getTypeFromExtension("EXAMPLE_one"), + "example/type.handleinternally" + ); +}); + +/** + * Checks that the information stored in the handler service instance under + * testing matches the default handlers for the English locale. + */ +function assertAllHandlerInfosMatchDefaultHandlers() { + let handlerInfos = HandlerServiceTestUtils.getAllHandlerInfos(); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "mailto", + preferredActionOSDependent: true, + possibleApplicationHandlers: [ + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + ], + }); + + Assert.equal(handlerInfos.length, 0); +} + +/** + * Tests the default protocol handlers imported from the locale-specific data. + */ +add_task( + { skip_if: () => AppConstants.MOZ_APP_NAME == "thunderbird" }, + async function test_default_protocol_handlers() { + if ( + !Services.prefs.getPrefType("gecko.handlerService.defaultHandlersVersion") + ) { + info("This platform or locale does not have default handlers."); + return; + } + + // This will inject the default protocol handlers for the current locale. + await deleteHandlerStore(); + + await assertAllHandlerInfosMatchDefaultHandlers(); + } +); + +/** + * Tests that the default protocol handlers are not imported again from the + * locale-specific data if they already exist. + */ +add_task( + { skip_if: () => AppConstants.MOZ_APP_NAME == "thunderbird" }, + async function test_default_protocol_handlers_no_duplicates() { + if ( + !Services.prefs.getPrefType("gecko.handlerService.defaultHandlersVersion") + ) { + info("This platform or locale does not have default handlers."); + return; + } + + // This will inject the default protocol handlers for the current locale. + await deleteHandlerStore(); + + // Clear the preference to force injecting again. + Services.prefs.clearUserPref("gecko.handlerService.defaultHandlersVersion"); + + await unloadHandlerStore(); + + // There should be no duplicate handlers in the protocols. + assertAllHandlerInfosMatchDefaultHandlers(); + } +); + +/** + * Ensures forward compatibility by checking that the "store" method preserves + * unknown properties in the test data. + */ +add_task(async function test_store_keeps_unknown_properties() { + // Create a new nsIHandlerInfo instance before loading the test data. + await deleteHandlerStore(); + let handlerInfo = HandlerServiceTestUtils.getHandlerInfo( + "example/type.handleinternally" + ); + + await copyTestDataToHandlerStore(); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + let data = await IOUtils.readJSON(jsonPath); + Assert.equal( + data.mimeTypes["example/type.handleinternally"].unknownProperty, + "preserved" + ); +}); + +/** + * Runs the asyncInit method, ensuring that it successfully inits the store + * and calls the handlersvc-store-initialized topic. + */ +add_task(async function test_async_init() { + await deleteHandlerStore(); + await copyTestDataToHandlerStore(); + gHandlerService.asyncInit(); + await TestUtils.topicObserved("handlersvc-store-initialized"); + await assertAllHandlerInfosMatchTestData(); + + await unloadHandlerStore(); +}); + +/** + * Races the asyncInit method against the sync init (implicit in enumerate), + * to ensure that the store will be synchronously initialized without any + * ill effects. + */ +add_task(async function test_race_async_init() { + await deleteHandlerStore(); + await copyTestDataToHandlerStore(); + let storeInitialized = false; + // Pass a callback to synchronously observe the topic, as a promise would + // resolve asynchronously + TestUtils.topicObserved("handlersvc-store-initialized", () => { + storeInitialized = true; + return true; + }); + gHandlerService.asyncInit(); + Assert.ok(!storeInitialized); + gHandlerService.enumerate(); + Assert.ok(storeInitialized); + await assertAllHandlerInfosMatchTestData(); + + await unloadHandlerStore(); +}); + +/** + * Test saving and reloading an instance of nsIGIOMimeApp. + */ +add_task(async function test_store_gioHandlerApp() { + if (!("@mozilla.org/gio-service;1" in Cc)) { + info("Skipping test because it does not apply to this platform."); + return; + } + + // Create dummy exec file that following won't fail because file not found error + let dummyHandlerFile = await IOUtils.getFile( + PathUtils.tempDir, + "dummyHandler" + ); + dummyHandlerFile.createUnique( + Ci.nsIFile.NORMAL_FILE_TYPE, + parseInt("777", 8) + ); + + // Set up an nsIGIOMimeApp instance for testing. + let handlerApp = Cc["@mozilla.org/gio-service;1"] + .getService(Ci.nsIGIOService) + .createAppFromCommand(dummyHandlerFile.path, "Dummy GIO handler"); + let expectedGIOMimeHandlerApp = { + name: handlerApp.name, + command: handlerApp.command, + }; + + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = handlerApp; + handlerInfo.possibleApplicationHandlers.appendElement(handlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedGIOMimeHandlerApp, + possibleApplicationHandlers: [expectedGIOMimeHandlerApp, webHandlerApp], + }); + + await IOUtils.remove(dummyHandlerFile.path); + + // After removing dummyHandlerFile, the handler should disappear from the + // list of possibleApplicationHandlers and preferredAppHandler should be null. + actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: null, + possibleApplicationHandlers: [webHandlerApp], + }); +}); diff --git a/uriloader/exthandler/tests/unit/test_punycodeURIs.js b/uriloader/exthandler/tests/unit/test_punycodeURIs.js new file mode 100644 index 0000000000..949c9914ec --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_punycodeURIs.js @@ -0,0 +1,126 @@ +/* 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/. */ + +// Encoded test URI to work on all platforms/independent of file encoding +const kTestURI = "http://\u65e5\u672c\u8a93.jp/"; +const kExpectedURI = "http://xn--wgv71a309e.jp/"; +const kOutputFile = "result.txt"; + +// Try several times in case the box we're running on is slow. +const kMaxCheckExistAttempts = 30; // seconds +var gCheckExistsAttempts = 0; + +const tempDir = do_get_tempdir(); + +function checkFile() { + // This is where we expect the output + var tempFile = tempDir.clone(); + tempFile.append(kOutputFile); + + if (!tempFile.exists()) { + if (gCheckExistsAttempts >= kMaxCheckExistAttempts) { + do_throw( + "Expected File " + + tempFile.path + + " does not exist after " + + kMaxCheckExistAttempts + + " seconds" + ); + } else { + ++gCheckExistsAttempts; + // Wait a bit longer then try again + do_timeout(1000, checkFile); + return; + } + } + + // Now read it + var fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + var sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + fstream.init(tempFile, -1, 0, 0); + sstream.init(fstream); + + // Read the first line only as that's the one we expect WriteArguments + // to be writing the argument to. + var data = sstream.read(4096); + + sstream.close(); + fstream.close(); + + // Now remove the old file + tempFile.remove(false); + + // This currently fails on Mac with an argument like -psn_0_nnnnnn + // This seems to be to do with how the executable is called, but I couldn't + // find a way around it. + // Additionally the lack of OS detection in xpcshell tests sucks, so we'll + // have to check for the argument mac gives us. + if (data.substring(0, 7) != "-psn_0_") { + Assert.equal(data, kExpectedURI); + } + + do_test_finished(); +} + +function run_test() { + if (mozinfo.os == "mac") { + dump("INFO | test_punycodeURIs.js | Skipping test on mac, bug 599475"); + return; + } + + // set up the uri to test with + var ioService = Services.io; + + // set up the local handler object + var localHandler = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + localHandler.name = "Test Local Handler App"; + + // WriteArgument will just dump its arguments to a file for us. + var processDir = do_get_cwd(); + var exe = processDir.clone(); + exe.append("WriteArgument"); + + if (!exe.exists()) { + // Maybe we are on windows + exe.leafName = "WriteArgument.exe"; + if (!exe.exists()) { + do_throw("Could not locate the WriteArgument tests executable\n"); + } + } + + var outFile = tempDir.clone(); + outFile.append(kOutputFile); + + // Set an environment variable for WriteArgument to pick up + // The Write Argument file needs to know where its libraries are, so + // just force the path variable + // For mac + var greDir = Services.dirsvc.get("GreD", Ci.nsIFile); + + Services.env.set("DYLD_LIBRARY_PATH", greDir.path); + // For Linux + Services.env.set("LD_LIBRARY_PATH", greDir.path); + // XXX: handle windows + + // Now tell it where we want the file. + Services.env.set("WRITE_ARGUMENT_FILE", outFile.path); + + var uri = ioService.newURI(kTestURI); + + // Just check we've got these matching, if we haven't there's a problem + // with ascii spec or our test case. + Assert.equal(uri.asciiSpec, kExpectedURI); + + localHandler.executable = exe; + localHandler.launchWithURI(uri); + + do_test_pending(); + do_timeout(1000, checkFile); +} diff --git a/uriloader/exthandler/tests/unit/xpcshell.toml b/uriloader/exthandler/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..216ad49555 --- /dev/null +++ b/uriloader/exthandler/tests/unit/xpcshell.toml @@ -0,0 +1,43 @@ +[DEFAULT] +head = "head.js" +run-sequentially = "Bug 912235 - Intermittent failures" +firefox-appdir = "browser" + +["test_badMIMEType.js"] +run-if = ["buildapp == 'browser'"] + +["test_defaults_handlerService.js"] +# No default stored handlers on android given lack of support. +# No default stored handlers on Thunderbird. +skip-if = [ + "os == 'android'", + "appname == 'thunderbird'", +] + +["test_filename_sanitize.js"] +skip-if = ["os == 'mac'"] # Bug 1817727 + +["test_getFromTypeAndExtension.js"] + +["test_getMIMEInfo_pdf.js"] + +["test_getMIMEInfo_unknown_mime_type.js"] +run-if = ["os == 'win'"] # Windows only test + +["test_getTypeFromExtension_ext_to_type_mapping.js"] + +["test_getTypeFromExtension_with_empty_Content_Type.js"] +run-if = ["os == 'win'"] # Windows only test + +["test_handlerService.js"] +support-files = ["mailcap"] +# Bug 676997: test consistently fails on Android +fail-if = ["os == 'android'"] + +["test_handlerService_store.js"] +# Disabled for 1563343 -- the app should determine possible handlers in GV. +fail-if = ["os == 'android'"] +support-files = ["handlers.json"] + +["test_punycodeURIs.js"] +skip-if = ["os == 'win' && msix"] # https://bugzilla.mozilla.org/show_bug.cgi?id=1809485 diff --git a/uriloader/exthandler/uikit/nsLocalHandlerAppUIKit.h b/uriloader/exthandler/uikit/nsLocalHandlerAppUIKit.h new file mode 100644 index 0000000000..06b31a723a --- /dev/null +++ b/uriloader/exthandler/uikit/nsLocalHandlerAppUIKit.h @@ -0,0 +1,27 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#ifndef nslocalhandlerappuikit_h_ +#define nslocalhandlerappuikit_h_ + +#include "nsLocalHandlerApp.h" + +class nsLocalHandlerAppUIKit final : public nsLocalHandlerApp { + public: + nsLocalHandlerAppUIKit() {} + ~nsLocalHandlerAppUIKit() {} + + nsLocalHandlerAppUIKit(const char16_t* aName, nsIFile* aExecutable) + : nsLocalHandlerApp(aName, aExecutable) {} + + nsLocalHandlerAppUIKit(const nsAString& aName, nsIFile* aExecutable) + : nsLocalHandlerApp(aName, aExecutable) {} + + NS_IMETHOD LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext* aBrowsingContext) override; +}; + +#endif /* nslocalhandlerappuikit_h_ */ diff --git a/uriloader/exthandler/uikit/nsLocalHandlerAppUIKit.mm b/uriloader/exthandler/uikit/nsLocalHandlerAppUIKit.mm new file mode 100644 index 0000000000..6f70e035bb --- /dev/null +++ b/uriloader/exthandler/uikit/nsLocalHandlerAppUIKit.mm @@ -0,0 +1,16 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#import <CoreFoundation/CoreFoundation.h> + +#include "nsLocalHandlerAppUIKit.h" +#include "nsIURI.h" + +NS_IMETHODIMP +nsLocalHandlerAppUIKit::LaunchWithURI( + nsIURI* aURI, mozilla::dom::BrowsingContext* aBrowsingContext) { + return NS_ERROR_NOT_IMPLEMENTED; +} diff --git a/uriloader/exthandler/uikit/nsMIMEInfoUIKit.h b/uriloader/exthandler/uikit/nsMIMEInfoUIKit.h new file mode 100644 index 0000000000..109eb16e3f --- /dev/null +++ b/uriloader/exthandler/uikit/nsMIMEInfoUIKit.h @@ -0,0 +1,31 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#ifndef nsMIMEInfoUIKit_h_ +#define nsMIMEInfoUIKit_h_ + +#include "nsMIMEInfoImpl.h" + +class nsMIMEInfoUIKit final : public nsMIMEInfoImpl { + public: + explicit nsMIMEInfoUIKit(const nsACString& aMIMEType) + : nsMIMEInfoImpl(aMIMEType) {} + nsMIMEInfoUIKit(const nsACString& aType, HandlerClass aClass) + : nsMIMEInfoImpl(aType, aClass) {} + + NS_IMETHOD LaunchWithFile(nsIFile* aFile) override; + + protected: + virtual nsresult LoadUriInternal(nsIURI* aURI) override; +#ifdef DEBUG + virtual nsresult LaunchDefaultWithFile(nsIFile* aFile) override { + MOZ_ASSERT_UNREACHABLE("do not call this method, use LaunchWithFile"); + return NS_ERROR_UNEXPECTED; + } +#endif +}; + +#endif diff --git a/uriloader/exthandler/uikit/nsMIMEInfoUIKit.mm b/uriloader/exthandler/uikit/nsMIMEInfoUIKit.mm new file mode 100644 index 0000000000..1226da220e --- /dev/null +++ b/uriloader/exthandler/uikit/nsMIMEInfoUIKit.mm @@ -0,0 +1,16 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#include "nsMIMEInfoUIKit.h" + +NS_IMETHODIMP +nsMIMEInfoUIKit::LaunchWithFile(nsIFile* aFile) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +nsresult nsMIMEInfoUIKit::LoadUriInternal(nsIURI* aURI) { + return NS_ERROR_NOT_IMPLEMENTED; +} diff --git a/uriloader/exthandler/uikit/nsOSHelperAppService.h b/uriloader/exthandler/uikit/nsOSHelperAppService.h new file mode 100644 index 0000000000..426d8bb78a --- /dev/null +++ b/uriloader/exthandler/uikit/nsOSHelperAppService.h @@ -0,0 +1,54 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#ifndef nsOSHelperAppService_h__ +#define nsOSHelperAppService_h__ + +// The OS helper app service is a subclass of nsExternalHelperAppService and +// is implemented on each platform. It contains platform specific code for +// finding helper applications for a given mime type in addition to launching +// those applications. This is the UIKit version. + +#include "nsExternalHelperAppService.h" +#include "nsCExternalHandlerService.h" +#include "nsCOMPtr.h" + +class nsOSHelperAppService final : public nsExternalHelperAppService { + public: + nsOSHelperAppService(); + ~nsOSHelperAppService(); + + // override nsIExternalProtocolService methods + NS_IMETHOD GetApplicationDescription(const nsACString& aScheme, + nsAString& _retval) override; + NS_IMETHOD IsCurrentAppOSDefaultForProtocol(const nsACString& aScheme, + bool* _retval) override; + + // method overrides --> used to hook the mime service into internet config.... + NS_IMETHOD GetFromTypeAndExtension(const nsACString& aType, + const nsACString& aFileExt, + nsIMIMEInfo** aMIMEInfo) override; + NS_IMETHOD GetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aFileExt, bool* aFound, + nsIMIMEInfo** aMIMEInfo) override; + NS_IMETHOD GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval) override; + + // GetFileTokenForPath must be implemented by each platform. + // platformAppPath --> a platform specific path to an application that we got + // out of the rdf data source. This can be a mac file + // spec, a unix path or a windows path depending on the + // platform + // aFile --> an nsIFile representation of that platform application path. + virtual nsresult GetFileTokenForPath(const char16_t* platformAppPath, + nsIFile** aFile) override; + + nsresult OSProtocolHandlerExists(const char* aScheme, + bool* aHandlerExists) override; +}; + +#endif // nsOSHelperAppService_h__ diff --git a/uriloader/exthandler/uikit/nsOSHelperAppService.mm b/uriloader/exthandler/uikit/nsOSHelperAppService.mm new file mode 100644 index 0000000000..bfd83a583b --- /dev/null +++ b/uriloader/exthandler/uikit/nsOSHelperAppService.mm @@ -0,0 +1,58 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:expandtab:shiftwidth=2:tabstop=2:cin: + * 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/. */ + +#include "nsOSHelperAppService.h" + +nsOSHelperAppService::nsOSHelperAppService() : nsExternalHelperAppService() {} + +nsOSHelperAppService::~nsOSHelperAppService() {} + +nsresult nsOSHelperAppService::OSProtocolHandlerExists( + const char* aProtocolScheme, bool* aHandlerExists) { + *aHandlerExists = false; + return NS_OK; +} + +NS_IMETHODIMP +nsOSHelperAppService::GetApplicationDescription(const nsACString& aScheme, + nsAString& _retval) { + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP +nsOSHelperAppService::IsCurrentAppOSDefaultForProtocol( + const nsACString& aScheme, bool* _retval) { + return NS_ERROR_NOT_AVAILABLE; +} + +nsresult nsOSHelperAppService::GetFileTokenForPath( + const char16_t* aPlatformAppPath, nsIFile** aFile) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +nsOSHelperAppService::GetFromTypeAndExtension(const nsACString& aType, + const nsACString& aFileExt, + nsIMIMEInfo** aMIMEInfo) { + return nsExternalHelperAppService::GetFromTypeAndExtension(aType, aFileExt, + aMIMEInfo); +} + +NS_IMETHODIMP nsOSHelperAppService::GetMIMEInfoFromOS( + const nsACString& aMIMEType, const nsACString& aFileExt, bool* aFound, + nsIMIMEInfo** aMIMEInfo) { + *aMIMEInfo = nullptr; + *aFound = false; + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +nsOSHelperAppService::GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval) { + *found = false; + return NS_OK; +} diff --git a/uriloader/exthandler/unix/nsGNOMERegistry.cpp b/uriloader/exthandler/unix/nsGNOMERegistry.cpp new file mode 100644 index 0000000000..dad4adbfd7 --- /dev/null +++ b/uriloader/exthandler/unix/nsGNOMERegistry.cpp @@ -0,0 +1,101 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsGNOMERegistry.h" +#include "nsString.h" +#include "nsMIMEInfoUnix.h" +#include "nsIGIOService.h" + +/* static */ +bool nsGNOMERegistry::HandlerExists(const char* aProtocolScheme) { + nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID); + if (!giovfs) { + return false; + } + + nsCOMPtr<nsIHandlerApp> app; + return NS_SUCCEEDED(giovfs->GetAppForURIScheme( + nsDependentCString(aProtocolScheme), getter_AddRefs(app))); +} + +// XXX Check HandlerExists() before calling LoadURL. + +/* static */ +nsresult nsGNOMERegistry::LoadURL(nsIURI* aURL) { + nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID); + if (!giovfs) { + return NS_ERROR_FAILURE; + } + + return giovfs->ShowURI(aURL); +} + +/* static */ +void nsGNOMERegistry::GetAppDescForScheme(const nsACString& aScheme, + nsAString& aDesc) { + nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID); + if (!giovfs) return; + + nsCOMPtr<nsIHandlerApp> app; + if (NS_FAILED(giovfs->GetAppForURIScheme(aScheme, getter_AddRefs(app)))) + return; + + app->GetName(aDesc); +} + +/* static */ +already_AddRefed<nsMIMEInfoBase> nsGNOMERegistry::GetFromExtension( + const nsACString& aFileExt) { + nsAutoCString mimeType; + nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID); + if (!giovfs) { + return nullptr; + } + + // Get the MIME type from the extension, then call GetFromType to + // fill in the MIMEInfo. + if (NS_FAILED(giovfs->GetMimeTypeFromExtension(aFileExt, mimeType)) || + mimeType.EqualsLiteral("application/octet-stream")) { + return nullptr; + } + + RefPtr<nsMIMEInfoBase> mi = GetFromType(mimeType); + if (mi) { + mi->AppendExtension(aFileExt); + } + + return mi.forget(); +} + +/* static */ +already_AddRefed<nsMIMEInfoBase> nsGNOMERegistry::GetFromType( + const nsACString& aMIMEType) { + RefPtr<nsMIMEInfoUnix> mimeInfo = new nsMIMEInfoUnix(aMIMEType); + NS_ENSURE_TRUE(mimeInfo, nullptr); + + nsAutoString name; + nsAutoCString description; + + nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID); + if (!giovfs) { + return nullptr; + } + + nsCOMPtr<nsIHandlerApp> handlerApp; + if (NS_FAILED( + giovfs->GetAppForMimeType(aMIMEType, getter_AddRefs(handlerApp))) || + !handlerApp) { + return nullptr; + } + handlerApp->GetName(name); + giovfs->GetDescriptionForMimeType(aMIMEType, description); + + mimeInfo->SetDefaultDescription(name); + + mimeInfo->SetPreferredAction(nsIMIMEInfo::saveToDisk); + mimeInfo->SetDescription(NS_ConvertUTF8toUTF16(description)); + + return mimeInfo.forget(); +} diff --git a/uriloader/exthandler/unix/nsGNOMERegistry.h b/uriloader/exthandler/unix/nsGNOMERegistry.h new file mode 100644 index 0000000000..ea626c5b50 --- /dev/null +++ b/uriloader/exthandler/unix/nsGNOMERegistry.h @@ -0,0 +1,28 @@ +/* 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/. */ + +#ifndef nsGNOMERegistry_h +#define nsGNOMERegistry_h + +#include "nsIURI.h" +#include "nsCOMPtr.h" + +class nsMIMEInfoBase; + +class nsGNOMERegistry { + public: + static bool HandlerExists(const char* aProtocolScheme); + + static nsresult LoadURL(nsIURI* aURL); + + static void GetAppDescForScheme(const nsACString& aScheme, nsAString& aDesc); + + static already_AddRefed<nsMIMEInfoBase> GetFromExtension( + const nsACString& aFileExt); + + static already_AddRefed<nsMIMEInfoBase> GetFromType( + const nsACString& aMIMEType); +}; + +#endif // nsGNOMERegistry_h diff --git a/uriloader/exthandler/unix/nsMIMEInfoUnix.cpp b/uriloader/exthandler/unix/nsMIMEInfoUnix.cpp new file mode 100644 index 0000000000..330c441159 --- /dev/null +++ b/uriloader/exthandler/unix/nsMIMEInfoUnix.cpp @@ -0,0 +1,84 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#include "nsMIMEInfoUnix.h" +#include "nsGNOMERegistry.h" +#include "nsIGIOService.h" +#include "nsNetCID.h" +#include "nsIIOService.h" +#ifdef MOZ_ENABLE_DBUS +# include "nsDBusHandlerApp.h" +#endif + +nsresult nsMIMEInfoUnix::LoadUriInternal(nsIURI* aURI) { + return nsGNOMERegistry::LoadURL(aURI); +} + +NS_IMETHODIMP +nsMIMEInfoUnix::GetHasDefaultHandler(bool* _retval) { + // if a default app is set, it means the application has been set from + // either /etc/mailcap or ${HOME}/.mailcap, in which case we don't want to + // give the GNOME answer. + if (GetDefaultApplication()) { + return nsMIMEInfoImpl::GetHasDefaultHandler(_retval); + } + + *_retval = false; + + if (mClass == eProtocolInfo) { + *_retval = nsGNOMERegistry::HandlerExists(mSchemeOrType.get()); + } else { + RefPtr<nsMIMEInfoBase> mimeInfo = + nsGNOMERegistry::GetFromType(mSchemeOrType); + if (!mimeInfo) { + nsAutoCString ext; + nsresult rv = GetPrimaryExtension(ext); + if (NS_SUCCEEDED(rv)) { + mimeInfo = nsGNOMERegistry::GetFromExtension(ext); + } + } + if (mimeInfo) *_retval = true; + } + + if (*_retval) return NS_OK; + + return NS_OK; +} + +nsresult nsMIMEInfoUnix::LaunchDefaultWithFile(nsIFile* aFile) { + // if a default app is set, it means the application has been set from + // either /etc/mailcap or ${HOME}/.mailcap, in which case we don't want to + // give the GNOME answer. + if (GetDefaultApplication()) { + return nsMIMEInfoImpl::LaunchDefaultWithFile(aFile); + } + + nsAutoCString nativePath; + aFile->GetNativePath(nativePath); + + nsCOMPtr<nsIGIOService> giovfs = do_GetService(NS_GIOSERVICE_CONTRACTID); + if (!giovfs) { + return NS_ERROR_FAILURE; + } + + // nsGIOMimeApp->Launch wants a URI string instead of local file + nsresult rv; + nsCOMPtr<nsIIOService> ioservice = + do_GetService(NS_IOSERVICE_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + nsCOMPtr<nsIURI> uri; + rv = ioservice->NewFileURI(aFile, getter_AddRefs(uri)); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIHandlerApp> app; + if (NS_FAILED( + giovfs->GetAppForMimeType(mSchemeOrType, getter_AddRefs(app))) || + !app) { + return NS_ERROR_FILE_NOT_FOUND; + } + + return app->LaunchWithURI(uri, nullptr); +} diff --git a/uriloader/exthandler/unix/nsMIMEInfoUnix.h b/uriloader/exthandler/unix/nsMIMEInfoUnix.h new file mode 100644 index 0000000000..2e32be4915 --- /dev/null +++ b/uriloader/exthandler/unix/nsMIMEInfoUnix.h @@ -0,0 +1,30 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#ifndef nsMIMEInfoUnix_h_ +#define nsMIMEInfoUnix_h_ + +#include "nsMIMEInfoImpl.h" + +class nsMIMEInfoUnix : public nsMIMEInfoImpl { + public: + explicit nsMIMEInfoUnix(const char* aMIMEType = "") + : nsMIMEInfoImpl(aMIMEType) {} + explicit nsMIMEInfoUnix(const nsACString& aMIMEType) + : nsMIMEInfoImpl(aMIMEType) {} + nsMIMEInfoUnix(const nsACString& aType, HandlerClass aClass) + : nsMIMEInfoImpl(aType, aClass) {} + static bool HandlerExists(const char* aProtocolScheme); + + protected: + NS_IMETHOD GetHasDefaultHandler(bool* _retval) override; + + virtual nsresult LoadUriInternal(nsIURI* aURI) override; + + virtual nsresult LaunchDefaultWithFile(nsIFile* aFile) override; +}; + +#endif // nsMIMEInfoUnix_h_ diff --git a/uriloader/exthandler/unix/nsOSHelperAppService.cpp b/uriloader/exthandler/unix/nsOSHelperAppService.cpp new file mode 100644 index 0000000000..7f6eaa46f2 --- /dev/null +++ b/uriloader/exthandler/unix/nsOSHelperAppService.cpp @@ -0,0 +1,1498 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#include <sys/types.h> +#include <sys/stat.h> + +#include "nsOSHelperAppService.h" +#include "nsMIMEInfoUnix.h" +#ifdef MOZ_WIDGET_GTK +# include "nsGNOMERegistry.h" +# ifdef MOZ_BUILD_APP_IS_BROWSER +# include "nsIToolkitShellService.h" +# include "nsIGNOMEShellService.h" +# endif +#endif +#include "nsISupports.h" +#include "nsString.h" +#include "nsReadableUtils.h" +#include "nsUnicharUtils.h" +#include "nsIFileStreams.h" +#include "nsILineInputStream.h" +#include "nsIFile.h" +#include "nsIProcess.h" +#include "nsNetCID.h" +#include "nsXPCOM.h" +#include "nsComponentManagerUtils.h" +#include "nsCRT.h" +#include "nsDirectoryServiceDefs.h" +#include "nsDirectoryServiceUtils.h" +#include "nsXULAppAPI.h" +#include "ContentHandlerService.h" +#include "prenv.h" // for PR_GetEnv() +#include "mozilla/EnumeratedRange.h" +#include "mozilla/Preferences.h" +#include "mozilla/ClearOnShutdown.h" +#include "nsMimeTypes.h" +#include <mutex> + +using namespace mozilla; + +#define LOG(...) \ + MOZ_LOG(nsOSHelperAppService::sLog, mozilla::LogLevel::Debug, (__VA_ARGS__)) +#define LOG_ENABLED() \ + MOZ_LOG_TEST(nsOSHelperAppService::sLog, mozilla::LogLevel::Debug) + +static nsresult FindSemicolon(nsAString::const_iterator& aSemicolon_iter, + const nsAString::const_iterator& aEnd_iter); +static nsresult ParseMIMEType(const nsAString::const_iterator& aStart_iter, + nsAString::const_iterator& aMajorTypeStart, + nsAString::const_iterator& aMajorTypeEnd, + nsAString::const_iterator& aMinorTypeStart, + nsAString::const_iterator& aMinorTypeEnd, + const nsAString::const_iterator& aEnd_iter); + +inline bool IsNetscapeFormat(const nsACString& aBuffer); + +nsOSHelperAppService::~nsOSHelperAppService() {} + +/* + * Take a command with all the mailcap escapes in it and unescape it + * Ideally this needs the mime type, mime type options, and location of the + * temporary file, but this last can't be got from here + */ +// static +nsresult nsOSHelperAppService::UnescapeCommand(const nsAString& aEscapedCommand, + const nsAString& aMajorType, + const nsAString& aMinorType, + nsACString& aUnEscapedCommand) { + LOG("-- UnescapeCommand"); + LOG("Command to escape: '%s'\n", + NS_LossyConvertUTF16toASCII(aEscapedCommand).get()); + // XXX This function will need to get the mime type and various stuff like + // that being passed in to work properly + + LOG( + ("UnescapeCommand really needs some work -- it should actually do some " + "unescaping\n")); + + CopyUTF16toUTF8(aEscapedCommand, aUnEscapedCommand); + LOG("Escaped command: '%s'\n", PromiseFlatCString(aUnEscapedCommand).get()); + return NS_OK; +} + +/* Put aSemicolon_iter at the first non-escaped semicolon after + * aStart_iter but before aEnd_iter + */ + +static nsresult FindSemicolon(nsAString::const_iterator& aSemicolon_iter, + const nsAString::const_iterator& aEnd_iter) { + bool semicolonFound = false; + while (aSemicolon_iter != aEnd_iter && !semicolonFound) { + switch (*aSemicolon_iter) { + case '\\': + aSemicolon_iter.advance(2); + break; + case ';': + semicolonFound = true; + break; + default: + ++aSemicolon_iter; + break; + } + } + return NS_OK; +} + +static nsresult ParseMIMEType(const nsAString::const_iterator& aStart_iter, + nsAString::const_iterator& aMajorTypeStart, + nsAString::const_iterator& aMajorTypeEnd, + nsAString::const_iterator& aMinorTypeStart, + nsAString::const_iterator& aMinorTypeEnd, + const nsAString::const_iterator& aEnd_iter) { + nsAString::const_iterator iter(aStart_iter); + + // skip leading whitespace + while (iter != aEnd_iter && nsCRT::IsAsciiSpace(*iter)) { + ++iter; + } + + if (iter == aEnd_iter) { + return NS_ERROR_INVALID_ARG; + } + + aMajorTypeStart = iter; + + // find major/minor separator ('/') + while (iter != aEnd_iter && *iter != '/') { + ++iter; + } + + if (iter == aEnd_iter) { + return NS_ERROR_INVALID_ARG; + } + + aMajorTypeEnd = iter; + + // skip '/' + ++iter; + + if (iter == aEnd_iter) { + return NS_ERROR_INVALID_ARG; + } + + aMinorTypeStart = iter; + + // find end of minor type, delimited by whitespace or ';' + while (iter != aEnd_iter && !nsCRT::IsAsciiSpace(*iter) && *iter != ';') { + ++iter; + } + + aMinorTypeEnd = iter; + + return NS_OK; +} + +// TODO: We should consider not only caching the file location but maybe the +// file contents too? +enum class FileKind { + PrivateMimeTypes = 0, + GlobalMimeTypes, + PrivateMailCap, + GlobalMailCap, + + Count, +}; + +struct FileLocationCache { + struct Entry { + bool mIsCached = false; + nsresult mResult = NS_OK; + nsString mLocation; + + void Clear() { *this = {}; } + }; + + EnumeratedArray<FileKind, FileKind::Count, Entry> mEntries; + + static const char* PrefFor(FileKind aKind) { + switch (aKind) { + case FileKind::GlobalMimeTypes: + return "helpers.global_mime_types_file"; + case FileKind::PrivateMimeTypes: + return "helpers.private_mime_types_file"; + case FileKind::GlobalMailCap: + return "helpers.global_mailcap_file"; + case FileKind::PrivateMailCap: + return "helpers.private_mailcap_file"; + case FileKind::Count: + break; + } + MOZ_ASSERT_UNREACHABLE("Unknown file kind"); + return ""; + } + + void Clear() { + for (auto& entry : mEntries) { + entry.Clear(); + } + } + + static void PrefChangeCallback(const char*, void*) { Get().Clear(); } + + static FileLocationCache& Get() { + static FileLocationCache sCache; + static std::once_flag flag; + std::call_once(flag, [&] { + for (auto kind : + MakeEnumeratedRange(FileKind::PrivateMimeTypes, FileKind::Count)) { + Preferences::RegisterCallback(PrefChangeCallback, + nsDependentCString(PrefFor(kind))); + } + RunOnShutdown([] { + sCache.Clear(); + for (auto kind : + MakeEnumeratedRange(FileKind::PrivateMimeTypes, FileKind::Count)) { + Preferences::UnregisterCallback(PrefChangeCallback, + nsDependentCString(PrefFor(kind))); + } + }); + }); + return sCache; + } + + Entry& EntryFor(FileKind aKind) { return mEntries[aKind]; } +}; + +// The lookup order is: +// 1) user pref +// 2) env var (if any) +// 3) pref +static nsresult DoGetFileLocation(FileKind aKind, nsAString& aFileLocation) { + LOG("-- GetFileLocation(%d)\n", int(aKind)); + + aFileLocation.Truncate(); + + const char* envVar = [&]() -> const char* { + switch (aKind) { + case FileKind::PrivateMailCap: + return "PERSONAL_MAILCAP"; + case FileKind::GlobalMailCap: + return "MAILCAP"; + default: + return nullptr; + } + }(); + + const char* prefName = FileLocationCache::PrefFor(aKind); + + if (envVar) { + // If we have an env var we should check whether the pref is a user pref. If + // we do not, we don't care. + if (Preferences::HasUserValue(prefName) && + NS_SUCCEEDED(Preferences::GetString(prefName, aFileLocation))) { + return NS_OK; + } + + char* envValue = PR_GetEnv(envVar); + if (envValue && *envValue) { + // the pref is in the system charset and it's a filepath... The + // natural way to do the charset conversion is by just initing + // an nsIFile with the native path and asking it for the Unicode + // version. + nsresult rv; + nsCOMPtr<nsIFile> file(do_CreateInstance(NS_LOCAL_FILE_CONTRACTID, &rv)); + NS_ENSURE_SUCCESS(rv, rv); + + rv = file->InitWithNativePath(nsDependentCString(envValue)); + NS_ENSURE_SUCCESS(rv, rv); + + return file->GetPath(aFileLocation); + } + } + + return Preferences::GetString(prefName, aFileLocation); +} + +static nsresult GetFileLocation(FileKind aKind, nsAString& aFileLocation) { + MOZ_ASSERT(NS_IsMainThread()); + auto& entry = FileLocationCache::Get().EntryFor(aKind); + if (!entry.mIsCached) { + entry.mIsCached = true; + entry.mResult = DoGetFileLocation(aKind, entry.mLocation); + } + aFileLocation = entry.mLocation; + return entry.mResult; +} + +/* Get the mime.types file names from prefs and look up info in them + based on extension */ +// static +nsresult nsOSHelperAppService::LookUpTypeAndDescription( + const nsAString& aFileExtension, nsAString& aMajorType, + nsAString& aMinorType, nsAString& aDescription, bool aUserData) { + LOG("-- LookUpTypeAndDescription for extension '%s'\n", + NS_LossyConvertUTF16toASCII(aFileExtension).get()); + nsAutoString mimeFileName; + + auto kind = + aUserData ? FileKind::PrivateMimeTypes : FileKind::GlobalMimeTypes; + nsresult rv = GetFileLocation(kind, mimeFileName); + if (NS_SUCCEEDED(rv) && !mimeFileName.IsEmpty()) { + rv = GetTypeAndDescriptionFromMimetypesFile( + mimeFileName, aFileExtension, aMajorType, aMinorType, aDescription); + } else { + rv = NS_ERROR_NOT_AVAILABLE; + } + + return rv; +} + +inline bool IsNetscapeFormat(const nsACString& aBuffer) { + return StringBeginsWith( + aBuffer, + nsLiteralCString( + "#--Netscape Communications Corporation MIME Information")) || + StringBeginsWith(aBuffer, "#--MCOM MIME Information"_ns); +} + +/* + * Create a file stream and line input stream for the filename. + * Leaves the first line of the file in aBuffer and sets the format to + * true for netscape files and false for normail ones + */ +// static +nsresult nsOSHelperAppService::CreateInputStream( + const nsAString& aFilename, nsIFileInputStream** aFileInputStream, + nsILineInputStream** aLineInputStream, nsACString& aBuffer, + bool* aNetscapeFormat, bool* aMore) { + LOG("-- CreateInputStream"); + nsresult rv = NS_OK; + + nsCOMPtr<nsIFile> file(do_CreateInstance(NS_LOCAL_FILE_CONTRACTID, &rv)); + if (NS_FAILED(rv)) return rv; + rv = file->InitWithPath(aFilename); + if (NS_FAILED(rv)) return rv; + + nsCOMPtr<nsIFileInputStream> fileStream( + do_CreateInstance(NS_LOCALFILEINPUTSTREAM_CONTRACTID, &rv)); + if (NS_FAILED(rv)) return rv; + rv = fileStream->Init(file, -1, -1, false); + if (NS_FAILED(rv)) return rv; + + nsCOMPtr<nsILineInputStream> lineStream(do_QueryInterface(fileStream, &rv)); + + if (NS_FAILED(rv)) { + LOG("Interface trouble in stream land!"); + return rv; + } + + rv = lineStream->ReadLine(aBuffer, aMore); + if (NS_FAILED(rv)) { + fileStream->Close(); + return rv; + } + + *aNetscapeFormat = IsNetscapeFormat(aBuffer); + + *aFileInputStream = fileStream; + NS_ADDREF(*aFileInputStream); + *aLineInputStream = lineStream; + NS_ADDREF(*aLineInputStream); + + return NS_OK; +} + +/* Open the file, read the first line, decide what type of file it is, + then get info based on extension */ +// static +nsresult nsOSHelperAppService::GetTypeAndDescriptionFromMimetypesFile( + const nsAString& aFilename, const nsAString& aFileExtension, + nsAString& aMajorType, nsAString& aMinorType, nsAString& aDescription) { + LOG("-- GetTypeAndDescriptionFromMimetypesFile\n"); + LOG("Getting type and description from types file '%s'\n", + NS_LossyConvertUTF16toASCII(aFilename).get()); + LOG("Using extension '%s'\n", + NS_LossyConvertUTF16toASCII(aFileExtension).get()); + nsCOMPtr<nsIFileInputStream> mimeFile; + nsCOMPtr<nsILineInputStream> mimeTypes; + bool netscapeFormat; + nsAutoString buf; + nsAutoCString cBuf; + bool more = false; + nsresult rv = CreateInputStream(aFilename, getter_AddRefs(mimeFile), + getter_AddRefs(mimeTypes), cBuf, + &netscapeFormat, &more); + + if (NS_FAILED(rv)) { + return rv; + } + + nsAutoString extensions; + nsAutoStringN<101> entry; + nsAString::const_iterator majorTypeStart, majorTypeEnd, minorTypeStart, + minorTypeEnd, descriptionStart, descriptionEnd; + + do { + CopyASCIItoUTF16(cBuf, buf); + // read through, building up an entry. If we finish an entry, check for + // a match and return out of the loop if we match + + // skip comments and empty lines + if (!buf.IsEmpty() && buf.First() != '#') { + entry.Append(buf); + if (entry.Last() == '\\') { + entry.Truncate(entry.Length() - 1); + entry.Append(char16_t( + ' ')); // in case there is no trailing whitespace on this line + } else { // we have a full entry + LOG("Current entry: '%s'\n", NS_LossyConvertUTF16toASCII(entry).get()); + if (netscapeFormat) { + rv = ParseNetscapeMIMETypesEntry( + entry, majorTypeStart, majorTypeEnd, minorTypeStart, minorTypeEnd, + extensions, descriptionStart, descriptionEnd); + if (NS_FAILED(rv)) { + // We sometimes get things like RealPlayer appending + // "normal" entries to "Netscape" .mime.types files. Try + // to handle that. Bug 106381. + LOG("Bogus entry; trying 'normal' mode\n"); + rv = ParseNormalMIMETypesEntry( + entry, majorTypeStart, majorTypeEnd, minorTypeStart, + minorTypeEnd, extensions, descriptionStart, descriptionEnd); + } + } else { + rv = ParseNormalMIMETypesEntry( + entry, majorTypeStart, majorTypeEnd, minorTypeStart, minorTypeEnd, + extensions, descriptionStart, descriptionEnd); + if (NS_FAILED(rv)) { + // We sometimes get things like StarOffice prepending + // "normal" entries to "Netscape" .mime.types files. Try + // to handle that. Bug 136670. + LOG("Bogus entry; trying 'Netscape' mode\n"); + rv = ParseNetscapeMIMETypesEntry( + entry, majorTypeStart, majorTypeEnd, minorTypeStart, + minorTypeEnd, extensions, descriptionStart, descriptionEnd); + } + } + + if (NS_SUCCEEDED(rv)) { // entry parses + nsAString::const_iterator start, end; + extensions.BeginReading(start); + extensions.EndReading(end); + nsAString::const_iterator iter(start); + + while (start != end) { + FindCharInReadable(',', iter, end); + if (Substring(start, iter) + .Equals(aFileExtension, + nsCaseInsensitiveStringComparator)) { + // it's a match. Assign the type and description and run + aMajorType.Assign(Substring(majorTypeStart, majorTypeEnd)); + aMinorType.Assign(Substring(minorTypeStart, minorTypeEnd)); + aDescription.Assign(Substring(descriptionStart, descriptionEnd)); + mimeFile->Close(); + return NS_OK; + } + if (iter != end) { + ++iter; + } + start = iter; + } + } else { + LOG("Failed to parse entry: %s\n", + NS_LossyConvertUTF16toASCII(entry).get()); + } + // truncate the entry for the next iteration + entry.Truncate(); + } + } + if (!more) { + rv = NS_ERROR_NOT_AVAILABLE; + break; + } + // read the next line + rv = mimeTypes->ReadLine(cBuf, &more); + } while (NS_SUCCEEDED(rv)); + + mimeFile->Close(); + return rv; +} + +/* Get the mime.types file names from prefs and look up info in them + based on mimetype */ +// static +nsresult nsOSHelperAppService::LookUpExtensionsAndDescription( + const nsAString& aMajorType, const nsAString& aMinorType, + nsAString& aFileExtensions, nsAString& aDescription) { + LOG("-- LookUpExtensionsAndDescription for type '%s/%s'\n", + NS_LossyConvertUTF16toASCII(aMajorType).get(), + NS_LossyConvertUTF16toASCII(aMinorType).get()); + nsAutoString mimeFileName; + + nsresult rv = GetFileLocation(FileKind::PrivateMimeTypes, mimeFileName); + if (NS_SUCCEEDED(rv) && !mimeFileName.IsEmpty()) { + rv = GetExtensionsAndDescriptionFromMimetypesFile( + mimeFileName, aMajorType, aMinorType, aFileExtensions, aDescription); + } else { + rv = NS_ERROR_NOT_AVAILABLE; + } + if (NS_FAILED(rv) || aFileExtensions.IsEmpty()) { + rv = GetFileLocation(FileKind::GlobalMimeTypes, mimeFileName); + if (NS_SUCCEEDED(rv) && !mimeFileName.IsEmpty()) { + rv = GetExtensionsAndDescriptionFromMimetypesFile( + mimeFileName, aMajorType, aMinorType, aFileExtensions, aDescription); + } else { + rv = NS_ERROR_NOT_AVAILABLE; + } + } + return rv; +} + +/* Open the file, read the first line, decide what type of file it is, + then get info based on extension */ +// static +nsresult nsOSHelperAppService::GetExtensionsAndDescriptionFromMimetypesFile( + const nsAString& aFilename, const nsAString& aMajorType, + const nsAString& aMinorType, nsAString& aFileExtensions, + nsAString& aDescription) { + LOG("-- GetExtensionsAndDescriptionFromMimetypesFile\n"); + LOG("Getting extensions and description from types file '%s'\n", + NS_LossyConvertUTF16toASCII(aFilename).get()); + LOG("Using type '%s/%s'\n", NS_LossyConvertUTF16toASCII(aMajorType).get(), + NS_LossyConvertUTF16toASCII(aMinorType).get()); + nsCOMPtr<nsIFileInputStream> mimeFile; + nsCOMPtr<nsILineInputStream> mimeTypes; + bool netscapeFormat; + nsAutoCString cBuf; + nsAutoString buf; + bool more = false; + nsresult rv = CreateInputStream(aFilename, getter_AddRefs(mimeFile), + getter_AddRefs(mimeTypes), cBuf, + &netscapeFormat, &more); + if (NS_FAILED(rv)) { + return rv; + } + + nsAutoString extensions; + nsAutoStringN<101> entry; + nsAString::const_iterator majorTypeStart, majorTypeEnd, minorTypeStart, + minorTypeEnd, descriptionStart, descriptionEnd; + + do { + CopyASCIItoUTF16(cBuf, buf); + // read through, building up an entry. If we finish an entry, check for + // a match and return out of the loop if we match + + // skip comments and empty lines + if (!buf.IsEmpty() && buf.First() != '#') { + entry.Append(buf); + if (entry.Last() == '\\') { + entry.Truncate(entry.Length() - 1); + entry.Append(char16_t( + ' ')); // in case there is no trailing whitespace on this line + } else { // we have a full entry + LOG("Current entry: '%s'\n", NS_LossyConvertUTF16toASCII(entry).get()); + if (netscapeFormat) { + rv = ParseNetscapeMIMETypesEntry( + entry, majorTypeStart, majorTypeEnd, minorTypeStart, minorTypeEnd, + extensions, descriptionStart, descriptionEnd); + + if (NS_FAILED(rv)) { + // We sometimes get things like RealPlayer appending + // "normal" entries to "Netscape" .mime.types files. Try + // to handle that. Bug 106381. + LOG("Bogus entry; trying 'normal' mode\n"); + rv = ParseNormalMIMETypesEntry( + entry, majorTypeStart, majorTypeEnd, minorTypeStart, + minorTypeEnd, extensions, descriptionStart, descriptionEnd); + } + } else { + rv = ParseNormalMIMETypesEntry( + entry, majorTypeStart, majorTypeEnd, minorTypeStart, minorTypeEnd, + extensions, descriptionStart, descriptionEnd); + + if (NS_FAILED(rv)) { + // We sometimes get things like StarOffice prepending + // "normal" entries to "Netscape" .mime.types files. Try + // to handle that. Bug 136670. + LOG("Bogus entry; trying 'Netscape' mode\n"); + rv = ParseNetscapeMIMETypesEntry( + entry, majorTypeStart, majorTypeEnd, minorTypeStart, + minorTypeEnd, extensions, descriptionStart, descriptionEnd); + } + } + + if (NS_SUCCEEDED(rv) && + Substring(majorTypeStart, majorTypeEnd) + .Equals(aMajorType, nsCaseInsensitiveStringComparator) && + Substring(minorTypeStart, minorTypeEnd) + .Equals(aMinorType, nsCaseInsensitiveStringComparator)) { + // it's a match + aFileExtensions.Assign(extensions); + aDescription.Assign(Substring(descriptionStart, descriptionEnd)); + mimeFile->Close(); + return NS_OK; + } + if (NS_FAILED(rv)) { + LOG("Failed to parse entry: %s\n", + NS_LossyConvertUTF16toASCII(entry).get()); + } + + entry.Truncate(); + } + } + if (!more) { + rv = NS_ERROR_NOT_AVAILABLE; + break; + } + // read the next line + rv = mimeTypes->ReadLine(cBuf, &more); + } while (NS_SUCCEEDED(rv)); + + mimeFile->Close(); + return rv; +} + +/* + * This parses a Netscape format mime.types entry. There are two + * possible formats: + * + * type=foo/bar; options exts="baz" description="Some type" + * + * and + * + * type=foo/bar; options description="Some type" exts="baz" + */ +// static +nsresult nsOSHelperAppService::ParseNetscapeMIMETypesEntry( + const nsAString& aEntry, nsAString::const_iterator& aMajorTypeStart, + nsAString::const_iterator& aMajorTypeEnd, + nsAString::const_iterator& aMinorTypeStart, + nsAString::const_iterator& aMinorTypeEnd, nsAString& aExtensions, + nsAString::const_iterator& aDescriptionStart, + nsAString::const_iterator& aDescriptionEnd) { + LOG("-- ParseNetscapeMIMETypesEntry\n"); + NS_ASSERTION(!aEntry.IsEmpty(), + "Empty Netscape MIME types entry being parsed."); + + nsAString::const_iterator start_iter, end_iter, match_start, match_end; + + aEntry.BeginReading(start_iter); + aEntry.EndReading(end_iter); + + // skip trailing whitespace + do { + --end_iter; + } while (end_iter != start_iter && nsCRT::IsAsciiSpace(*end_iter)); + // if we're pointing to a quote, don't advance -- we don't want to + // include the quote.... + if (*end_iter != '"') ++end_iter; + match_start = start_iter; + match_end = end_iter; + + // Get the major and minor types + // First the major type + if (!FindInReadable(u"type="_ns, match_start, match_end)) { + return NS_ERROR_FAILURE; + } + + match_start = match_end; + + while (match_end != end_iter && *match_end != '/') { + ++match_end; + } + if (match_end == end_iter) { + return NS_ERROR_FAILURE; + } + + aMajorTypeStart = match_start; + aMajorTypeEnd = match_end; + + // now the minor type + if (++match_end == end_iter) { + return NS_ERROR_FAILURE; + } + + match_start = match_end; + + while (match_end != end_iter && !nsCRT::IsAsciiSpace(*match_end) && + *match_end != ';') { + ++match_end; + } + if (match_end == end_iter) { + return NS_ERROR_FAILURE; + } + + aMinorTypeStart = match_start; + aMinorTypeEnd = match_end; + + // ignore everything up to the end of the mime type from here on + start_iter = match_end; + + // get the extensions + match_start = match_end; + match_end = end_iter; + if (FindInReadable(u"exts="_ns, match_start, match_end)) { + nsAString::const_iterator extStart, extEnd; + + if (match_end == end_iter || + (*match_end == '"' && ++match_end == end_iter)) { + return NS_ERROR_FAILURE; + } + + extStart = match_end; + match_start = extStart; + match_end = end_iter; + if (FindInReadable(u"desc=\""_ns, match_start, match_end)) { + // exts= before desc=, so we have to find the actual end of the extensions + extEnd = match_start; + if (extEnd == extStart) { + return NS_ERROR_FAILURE; + } + + do { + --extEnd; + } while (extEnd != extStart && nsCRT::IsAsciiSpace(*extEnd)); + + if (extEnd != extStart && *extEnd == '"') { + --extEnd; + } + } else { + // desc= before exts=, so we can use end_iter as the end of the extensions + extEnd = end_iter; + } + aExtensions = Substring(extStart, extEnd); + } else { + // no extensions + aExtensions.Truncate(); + } + + // get the description + match_start = start_iter; + match_end = end_iter; + if (FindInReadable(u"desc=\""_ns, match_start, match_end)) { + aDescriptionStart = match_end; + match_start = aDescriptionStart; + match_end = end_iter; + if (FindInReadable(u"exts="_ns, match_start, match_end)) { + // exts= after desc=, so have to find actual end of description + aDescriptionEnd = match_start; + if (aDescriptionEnd == aDescriptionStart) { + return NS_ERROR_FAILURE; + } + + do { + --aDescriptionEnd; + } while (aDescriptionEnd != aDescriptionStart && + nsCRT::IsAsciiSpace(*aDescriptionEnd)); + } else { + // desc= after exts=, so use end_iter for the description end + aDescriptionEnd = end_iter; + } + } else { + // no description + aDescriptionStart = start_iter; + aDescriptionEnd = start_iter; + } + + return NS_OK; +} + +/* + * This parses a normal format mime.types entry. The format is: + * + * major/minor ext1 ext2 ext3 + */ +// static +nsresult nsOSHelperAppService::ParseNormalMIMETypesEntry( + const nsAString& aEntry, nsAString::const_iterator& aMajorTypeStart, + nsAString::const_iterator& aMajorTypeEnd, + nsAString::const_iterator& aMinorTypeStart, + nsAString::const_iterator& aMinorTypeEnd, nsAString& aExtensions, + nsAString::const_iterator& aDescriptionStart, + nsAString::const_iterator& aDescriptionEnd) { + LOG("-- ParseNormalMIMETypesEntry\n"); + NS_ASSERTION(!aEntry.IsEmpty(), + "Empty Normal MIME types entry being parsed."); + + nsAString::const_iterator start_iter, end_iter, iter; + + aEntry.BeginReading(start_iter); + aEntry.EndReading(end_iter); + + // no description + aDescriptionStart = start_iter; + aDescriptionEnd = start_iter; + + // skip leading whitespace + while (start_iter != end_iter && nsCRT::IsAsciiSpace(*start_iter)) { + ++start_iter; + } + if (start_iter == end_iter) { + return NS_ERROR_FAILURE; + } + // skip trailing whitespace + do { + --end_iter; + } while (end_iter != start_iter && nsCRT::IsAsciiSpace(*end_iter)); + + ++end_iter; // point to first whitespace char (or to end of string) + iter = start_iter; + + // get the major type + if (!FindCharInReadable('/', iter, end_iter)) return NS_ERROR_FAILURE; + + nsAString::const_iterator equals_sign_iter(start_iter); + if (FindCharInReadable('=', equals_sign_iter, iter)) + return NS_ERROR_FAILURE; // see bug 136670 + + aMajorTypeStart = start_iter; + aMajorTypeEnd = iter; + + // get the minor type + if (++iter == end_iter) { + return NS_ERROR_FAILURE; + } + start_iter = iter; + + while (iter != end_iter && !nsCRT::IsAsciiSpace(*iter)) { + ++iter; + } + aMinorTypeStart = start_iter; + aMinorTypeEnd = iter; + + // get the extensions + aExtensions.Truncate(); + while (iter != end_iter) { + while (iter != end_iter && nsCRT::IsAsciiSpace(*iter)) { + ++iter; + } + + start_iter = iter; + while (iter != end_iter && !nsCRT::IsAsciiSpace(*iter)) { + ++iter; + } + aExtensions.Append(Substring(start_iter, iter)); + if (iter != end_iter) { // not the last extension + aExtensions.Append(char16_t(',')); + } + } + + return NS_OK; +} + +// static +nsresult nsOSHelperAppService::LookUpHandlerAndDescription( + const nsAString& aMajorType, const nsAString& aMinorType, + nsAString& aHandler, nsAString& aDescription, nsAString& aMozillaFlags) { + // The mailcap lookup is two-pass to handle the case of mailcap files + // that have something like: + // + // text/*; emacs %s + // text/rtf; soffice %s + // + // in that order. We want to pick up "soffice" for text/rtf in such cases + nsresult rv = DoLookUpHandlerAndDescription( + aMajorType, aMinorType, aHandler, aDescription, aMozillaFlags, true); + if (NS_FAILED(rv)) { + rv = DoLookUpHandlerAndDescription(aMajorType, aMinorType, aHandler, + aDescription, aMozillaFlags, false); + } + + // maybe we have an entry for "aMajorType/*"? + if (NS_FAILED(rv)) { + rv = DoLookUpHandlerAndDescription(aMajorType, u"*"_ns, aHandler, + aDescription, aMozillaFlags, true); + } + + if (NS_FAILED(rv)) { + rv = DoLookUpHandlerAndDescription(aMajorType, u"*"_ns, aHandler, + aDescription, aMozillaFlags, false); + } + + return rv; +} + +// static +nsresult nsOSHelperAppService::DoLookUpHandlerAndDescription( + const nsAString& aMajorType, const nsAString& aMinorType, + nsAString& aHandler, nsAString& aDescription, nsAString& aMozillaFlags, + bool aUserData) { + LOG("-- LookUpHandlerAndDescription for type '%s/%s'\n", + NS_LossyConvertUTF16toASCII(aMajorType).get(), + NS_LossyConvertUTF16toASCII(aMinorType).get()); + nsAutoString mailcapFileName; + + const auto kind = + aUserData ? FileKind::PrivateMailCap : FileKind::GlobalMailCap; + nsresult rv = GetFileLocation(kind, mailcapFileName); + if (NS_FAILED(rv) || mailcapFileName.IsEmpty()) { + return NS_ERROR_NOT_AVAILABLE; + } + return GetHandlerAndDescriptionFromMailcapFile(mailcapFileName, aMajorType, + aMinorType, aHandler, + aDescription, aMozillaFlags); +} + +// static +nsresult nsOSHelperAppService::GetHandlerAndDescriptionFromMailcapFile( + const nsAString& aFilename, const nsAString& aMajorType, + const nsAString& aMinorType, nsAString& aHandler, nsAString& aDescription, + nsAString& aMozillaFlags) { + LOG("-- GetHandlerAndDescriptionFromMailcapFile\n"); + LOG("Getting handler and description from mailcap file '%s'\n", + NS_LossyConvertUTF16toASCII(aFilename).get()); + LOG("Using type '%s/%s'\n", NS_LossyConvertUTF16toASCII(aMajorType).get(), + NS_LossyConvertUTF16toASCII(aMinorType).get()); + + nsresult rv = NS_OK; + bool more = false; + + nsCOMPtr<nsIFile> file(do_CreateInstance(NS_LOCAL_FILE_CONTRACTID, &rv)); + if (NS_FAILED(rv)) return rv; + rv = file->InitWithPath(aFilename); + if (NS_FAILED(rv)) return rv; + + nsCOMPtr<nsIFileInputStream> mailcapFile( + do_CreateInstance(NS_LOCALFILEINPUTSTREAM_CONTRACTID, &rv)); + if (NS_FAILED(rv)) return rv; + rv = mailcapFile->Init(file, -1, -1, false); + if (NS_FAILED(rv)) return rv; + + nsCOMPtr<nsILineInputStream> mailcap(do_QueryInterface(mailcapFile, &rv)); + + if (NS_FAILED(rv)) { + LOG("Interface trouble in stream land!"); + return rv; + } + + nsAutoStringN<129> entry; + nsAutoStringN<81> buffer; + nsAutoCStringN<81> cBuffer; + rv = mailcap->ReadLine(cBuffer, &more); + if (NS_FAILED(rv)) { + mailcapFile->Close(); + return rv; + } + + do { // return on end-of-file in the loop + + CopyASCIItoUTF16(cBuffer, buffer); + if (!buffer.IsEmpty() && buffer.First() != '#') { + entry.Append(buffer); + if (entry.Last() == '\\') { // entry continues on next line + entry.Truncate(entry.Length() - 1); + entry.Append(char16_t( + ' ')); // in case there is no trailing whitespace on this line + } else { // we have a full entry in entry. Check it for the type + LOG("Current entry: '%s'\n", NS_LossyConvertUTF16toASCII(entry).get()); + + nsAString::const_iterator semicolon_iter, start_iter, end_iter, + majorTypeStart, majorTypeEnd, minorTypeStart, minorTypeEnd; + entry.BeginReading(start_iter); + entry.EndReading(end_iter); + semicolon_iter = start_iter; + FindSemicolon(semicolon_iter, end_iter); + if (semicolon_iter != + end_iter) { // we have something resembling a valid entry + rv = ParseMIMEType(start_iter, majorTypeStart, majorTypeEnd, + minorTypeStart, minorTypeEnd, semicolon_iter); + if (NS_SUCCEEDED(rv) && + Substring(majorTypeStart, majorTypeEnd) + .Equals(aMajorType, nsCaseInsensitiveStringComparator) && + Substring(minorTypeStart, minorTypeEnd) + .Equals(aMinorType, nsCaseInsensitiveStringComparator)) { + // we have a match + bool match = true; + ++semicolon_iter; // point at the first char past the semicolon + start_iter = semicolon_iter; // handler string starts here + FindSemicolon(semicolon_iter, end_iter); + while (start_iter != semicolon_iter && + nsCRT::IsAsciiSpace(*start_iter)) { + ++start_iter; + } + + LOG("The real handler is: '%s'\n", + NS_LossyConvertUTF16toASCII( + Substring(start_iter, semicolon_iter)) + .get()); + + // XXX ugly hack. Just grab the executable name + nsAString::const_iterator end_handler_iter = semicolon_iter; + nsAString::const_iterator end_executable_iter = start_iter; + while (end_executable_iter != end_handler_iter && + !nsCRT::IsAsciiSpace(*end_executable_iter)) { + ++end_executable_iter; + } + // XXX End ugly hack + + aHandler = Substring(start_iter, end_executable_iter); + + nsAString::const_iterator start_option_iter, end_optionname_iter, + equal_sign_iter; + bool equalSignFound; + while (match && semicolon_iter != end_iter && + ++semicolon_iter != + end_iter) { // there are options left and we still match + start_option_iter = semicolon_iter; + // skip over leading whitespace + while (start_option_iter != end_iter && + nsCRT::IsAsciiSpace(*start_option_iter)) { + ++start_option_iter; + } + if (start_option_iter == end_iter) { // nothing actually here + break; + } + semicolon_iter = start_option_iter; + FindSemicolon(semicolon_iter, end_iter); + equal_sign_iter = start_option_iter; + equalSignFound = false; + while (equal_sign_iter != semicolon_iter && !equalSignFound) { + switch (*equal_sign_iter) { + case '\\': + equal_sign_iter.advance(2); + break; + case '=': + equalSignFound = true; + break; + default: + ++equal_sign_iter; + break; + } + } + end_optionname_iter = start_option_iter; + // find end of option name + while (end_optionname_iter != equal_sign_iter && + !nsCRT::IsAsciiSpace(*end_optionname_iter)) { + ++end_optionname_iter; + } + nsDependentSubstring optionName(start_option_iter, + end_optionname_iter); + if (equalSignFound) { + // This is an option that has a name and value + if (optionName.EqualsLiteral("description")) { + aDescription = Substring(++equal_sign_iter, semicolon_iter); + } else if (optionName.EqualsLiteral("x-mozilla-flags")) { + aMozillaFlags = Substring(++equal_sign_iter, semicolon_iter); + } else if (optionName.EqualsLiteral("test")) { + nsAutoCString testCommand; + rv = UnescapeCommand( + Substring(++equal_sign_iter, semicolon_iter), aMajorType, + aMinorType, testCommand); + if (NS_FAILED(rv)) continue; + nsCOMPtr<nsIProcess> process = + do_CreateInstance(NS_PROCESS_CONTRACTID, &rv); + if (NS_FAILED(rv)) continue; + nsCOMPtr<nsIFile> file( + do_CreateInstance(NS_LOCAL_FILE_CONTRACTID, &rv)); + if (NS_FAILED(rv)) continue; + rv = file->InitWithNativePath("/bin/sh"_ns); + if (NS_FAILED(rv)) continue; + rv = process->Init(file); + if (NS_FAILED(rv)) continue; + const char* args[] = {"-c", testCommand.get()}; + LOG("Running Test: %s\n", testCommand.get()); + rv = process->Run(true, args, 2); + if (NS_FAILED(rv)) continue; + int32_t exitValue; + rv = process->GetExitValue(&exitValue); + if (NS_FAILED(rv)) continue; + LOG("Exit code: %d\n", exitValue); + if (exitValue) { + match = false; + } + } + } else { + // This is an option that just has a name but no value (eg + // "copiousoutput") + if (optionName.EqualsLiteral("needsterminal")) { + match = false; + } + } + } + + if (match) { // we did not fail any test clauses; all is good + // get out of here + mailcapFile->Close(); + return NS_OK; + } + // pretend that this match never happened + aDescription.Truncate(); + aMozillaFlags.Truncate(); + aHandler.Truncate(); + } + } + // zero out the entry for the next cycle + entry.Truncate(); + } + } + if (!more) { + rv = NS_ERROR_NOT_AVAILABLE; + break; + } + rv = mailcap->ReadLine(cBuffer, &more); + } while (NS_SUCCEEDED(rv)); + mailcapFile->Close(); + return rv; +} + +nsresult nsOSHelperAppService::OSProtocolHandlerExists( + const char* aProtocolScheme, bool* aHandlerExists) { + nsresult rv = NS_OK; + + if (!XRE_IsContentProcess()) { +#ifdef MOZ_WIDGET_GTK + // Check the GNOME registry for a protocol handler + *aHandlerExists = nsGNOMERegistry::HandlerExists(aProtocolScheme); +#else + *aHandlerExists = false; +#endif + } else { + *aHandlerExists = false; + nsCOMPtr<nsIHandlerService> handlerSvc = + do_GetService(NS_HANDLERSERVICE_CONTRACTID, &rv); + if (NS_SUCCEEDED(rv) && handlerSvc) { + rv = handlerSvc->ExistsForProtocolOS(nsCString(aProtocolScheme), + aHandlerExists); + } + } + + return rv; +} + +NS_IMETHODIMP nsOSHelperAppService::GetApplicationDescription( + const nsACString& aScheme, nsAString& _retval) { +#ifdef MOZ_WIDGET_GTK + nsGNOMERegistry::GetAppDescForScheme(aScheme, _retval); + return _retval.IsEmpty() ? NS_ERROR_NOT_AVAILABLE : NS_OK; +#else + return NS_ERROR_NOT_AVAILABLE; +#endif +} + +NS_IMETHODIMP nsOSHelperAppService::IsCurrentAppOSDefaultForProtocol( + const nsACString& aScheme, bool* _retval) { + *_retval = false; +#if defined(MOZ_BUILD_APP_IS_BROWSER) && defined(MOZ_WIDGET_GTK) + if (nsCOMPtr<nsIGNOMEShellService> shell = + do_GetService(NS_TOOLKITSHELLSERVICE_CONTRACTID)) { + return shell->IsDefaultForScheme(aScheme, _retval); + } +#endif + return NS_OK; +} + +nsresult nsOSHelperAppService::GetFileTokenForPath( + const char16_t* platformAppPath, nsIFile** aFile) { + LOG("-- nsOSHelperAppService::GetFileTokenForPath: '%s'\n", + NS_LossyConvertUTF16toASCII(platformAppPath).get()); + if (!*platformAppPath) { // empty filename--return error + NS_WARNING("Empty filename passed in."); + return NS_ERROR_INVALID_ARG; + } + + // first check if the base class implementation finds anything + nsresult rv = + nsExternalHelperAppService::GetFileTokenForPath(platformAppPath, aFile); + if (NS_SUCCEEDED(rv)) return rv; + // If the reason for failure was that the file doesn't exist, return too + // (because it means the path was absolute, and so that we shouldn't search in + // the path) + if (rv == NS_ERROR_FILE_NOT_FOUND) return rv; + + // If we get here, we really should have a relative path. + NS_ASSERTION(*platformAppPath != char16_t('/'), "Unexpected absolute path"); + + nsCOMPtr<nsIFile> localFile(do_CreateInstance(NS_LOCAL_FILE_CONTRACTID)); + + if (!localFile) return NS_ERROR_NOT_INITIALIZED; + + bool exists = false; + // ugly hack. Walk the PATH variable... + char* unixpath = PR_GetEnv("PATH"); + nsAutoCString path(unixpath); + + const char* start_iter = path.BeginReading(start_iter); + const char* colon_iter = start_iter; + const char* end_iter = path.EndReading(end_iter); + + while (start_iter != end_iter && !exists) { + while (colon_iter != end_iter && *colon_iter != ':') { + ++colon_iter; + } + localFile->InitWithNativePath(Substring(start_iter, colon_iter)); + rv = localFile->AppendRelativePath(nsDependentString(platformAppPath)); + // Failing AppendRelativePath is a bad thing - it should basically always + // succeed given a relative path. Show a warning if it does fail. + // To prevent infinite loops when it does fail, return at this point. + NS_ENSURE_SUCCESS(rv, rv); + localFile->Exists(&exists); + if (!exists) { + if (colon_iter == end_iter) { + break; + } + ++colon_iter; + start_iter = colon_iter; + } + } + + if (exists) { + rv = NS_OK; + } else { + rv = NS_ERROR_NOT_AVAILABLE; + } + + *aFile = localFile; + NS_IF_ADDREF(*aFile); + + return rv; +} + +already_AddRefed<nsMIMEInfoBase> nsOSHelperAppService::GetFromExtension( + const nsCString& aFileExt) { + // if the extension is empty, return immediately + if (aFileExt.IsEmpty()) { + return nullptr; + } + + LOG("Here we do an extension lookup for '%s'\n", aFileExt.get()); + + nsAutoString majorType, minorType, mime_types_description, + mailcap_description, handler, mozillaFlags; + + nsresult rv = + LookUpTypeAndDescription(NS_ConvertUTF8toUTF16(aFileExt), majorType, + minorType, mime_types_description, true); + + if (NS_FAILED(rv) || majorType.IsEmpty()) { +#ifdef MOZ_WIDGET_GTK + LOG("Looking in GNOME registry\n"); + RefPtr<nsMIMEInfoBase> gnomeInfo = + nsGNOMERegistry::GetFromExtension(aFileExt); + if (gnomeInfo) { + LOG("Got MIMEInfo from GNOME registry\n"); + return gnomeInfo.forget(); + } +#endif + + rv = LookUpTypeAndDescription(NS_ConvertUTF8toUTF16(aFileExt), majorType, + minorType, mime_types_description, false); + } + + if (NS_FAILED(rv)) { + return nullptr; + } + + NS_LossyConvertUTF16toASCII asciiMajorType(majorType); + NS_LossyConvertUTF16toASCII asciiMinorType(minorType); + + LOG("Type/Description results: majorType='%s', minorType='%s', " + "description='%s'\n", + asciiMajorType.get(), asciiMinorType.get(), + NS_LossyConvertUTF16toASCII(mime_types_description).get()); + + if (majorType.IsEmpty() && minorType.IsEmpty()) { + // we didn't get a type mapping, so we can't do anything useful + return nullptr; + } + + nsAutoCString mimeType(asciiMajorType + "/"_ns + asciiMinorType); + RefPtr<nsMIMEInfoUnix> mimeInfo = new nsMIMEInfoUnix(mimeType); + + mimeInfo->AppendExtension(aFileExt); + rv = LookUpHandlerAndDescription(majorType, minorType, handler, + mailcap_description, mozillaFlags); + LOG("Handler/Description results: handler='%s', description='%s', " + "mozillaFlags='%s'\n", + NS_LossyConvertUTF16toASCII(handler).get(), + NS_LossyConvertUTF16toASCII(mailcap_description).get(), + NS_LossyConvertUTF16toASCII(mozillaFlags).get()); + mailcap_description.Trim(" \t\""); + mozillaFlags.Trim(" \t"); + if (!mime_types_description.IsEmpty()) { + mimeInfo->SetDescription(mime_types_description); + } else { + mimeInfo->SetDescription(mailcap_description); + } + + if (NS_SUCCEEDED(rv) && handler.IsEmpty()) { + rv = NS_ERROR_NOT_AVAILABLE; + } + + if (NS_SUCCEEDED(rv)) { + nsCOMPtr<nsIFile> handlerFile; + rv = GetFileTokenForPath(handler.get(), getter_AddRefs(handlerFile)); + + if (NS_SUCCEEDED(rv)) { + mimeInfo->SetDefaultApplication(handlerFile); + mimeInfo->SetPreferredAction(nsIMIMEInfo::saveToDisk); + mimeInfo->SetDefaultDescription(handler); + } + } + + if (NS_FAILED(rv)) { + mimeInfo->SetPreferredAction(nsIMIMEInfo::saveToDisk); + } + + return mimeInfo.forget(); +} + +already_AddRefed<nsMIMEInfoBase> nsOSHelperAppService::GetFromType( + const nsCString& aMIMEType) { + // if the type is empty, return immediately + if (aMIMEType.IsEmpty()) { + return nullptr; + } + + LOG("Here we do a mimetype lookup for '%s'\n", aMIMEType.get()); + + // extract the major and minor types + NS_ConvertASCIItoUTF16 mimeType(aMIMEType); + nsAString::const_iterator start_iter, end_iter, majorTypeStart, majorTypeEnd, + minorTypeStart, minorTypeEnd; + + mimeType.BeginReading(start_iter); + mimeType.EndReading(end_iter); + + // XXX FIXME: add typeOptions parsing in here + nsresult rv = ParseMIMEType(start_iter, majorTypeStart, majorTypeEnd, + minorTypeStart, minorTypeEnd, end_iter); + + if (NS_FAILED(rv)) { + return nullptr; + } + + nsDependentSubstring majorType(majorTypeStart, majorTypeEnd); + nsDependentSubstring minorType(minorTypeStart, minorTypeEnd); + + // First check the user's private mailcap file + nsAutoString mailcap_description, handler, mozillaFlags; + DoLookUpHandlerAndDescription(majorType, minorType, handler, + mailcap_description, mozillaFlags, true); + + LOG("Private Handler/Description results: handler='%s', description='%s'\n", + NS_LossyConvertUTF16toASCII(handler).get(), + NS_LossyConvertUTF16toASCII(mailcap_description).get()); + + // Now look up our extensions + nsAutoString extensions, mime_types_description; + LookUpExtensionsAndDescription(majorType, minorType, extensions, + mime_types_description); + +#ifdef MOZ_WIDGET_GTK + if (handler.IsEmpty()) { + RefPtr<nsMIMEInfoBase> gnomeInfo = nsGNOMERegistry::GetFromType(aMIMEType); + if (gnomeInfo) { + LOG("Got MIMEInfo from GNOME registry without extensions; setting them " + "to %s\n", + NS_LossyConvertUTF16toASCII(extensions).get()); + + NS_ASSERTION(!gnomeInfo->HasExtensions(), "How'd that happen?"); + gnomeInfo->SetFileExtensions(NS_ConvertUTF16toUTF8(extensions)); + return gnomeInfo.forget(); + } + } +#endif + + if (handler.IsEmpty()) { + DoLookUpHandlerAndDescription(majorType, minorType, handler, + mailcap_description, mozillaFlags, false); + } + + if (handler.IsEmpty()) { + DoLookUpHandlerAndDescription(majorType, u"*"_ns, handler, + mailcap_description, mozillaFlags, true); + } + + if (handler.IsEmpty()) { + DoLookUpHandlerAndDescription(majorType, u"*"_ns, handler, + mailcap_description, mozillaFlags, false); + } + + LOG("Handler/Description results: handler='%s', description='%s', " + "mozillaFlags='%s'\n", + NS_LossyConvertUTF16toASCII(handler).get(), + NS_LossyConvertUTF16toASCII(mailcap_description).get(), + NS_LossyConvertUTF16toASCII(mozillaFlags).get()); + + mailcap_description.Trim(" \t\""); + mozillaFlags.Trim(" \t"); + + if (handler.IsEmpty() && extensions.IsEmpty() && + mailcap_description.IsEmpty() && mime_types_description.IsEmpty()) { + // No real useful info + return nullptr; + } + + RefPtr<nsMIMEInfoUnix> mimeInfo = new nsMIMEInfoUnix(aMIMEType); + + mimeInfo->SetFileExtensions(NS_ConvertUTF16toUTF8(extensions)); + if (!mime_types_description.IsEmpty()) { + mimeInfo->SetDescription(mime_types_description); + } else { + mimeInfo->SetDescription(mailcap_description); + } + + rv = NS_ERROR_NOT_AVAILABLE; + nsCOMPtr<nsIFile> handlerFile; + if (!handler.IsEmpty()) { + rv = GetFileTokenForPath(handler.get(), getter_AddRefs(handlerFile)); + } + + if (NS_SUCCEEDED(rv)) { + mimeInfo->SetDefaultApplication(handlerFile); + mimeInfo->SetPreferredAction(nsIMIMEInfo::saveToDisk); + mimeInfo->SetDefaultDescription(handler); + } else { + mimeInfo->SetPreferredAction(nsIMIMEInfo::saveToDisk); + } + + return mimeInfo.forget(); +} + +nsresult nsOSHelperAppService::GetMIMEInfoFromOS(const nsACString& aType, + const nsACString& aFileExt, + bool* aFound, + nsIMIMEInfo** aMIMEInfo) { + *aFound = true; + RefPtr<nsMIMEInfoBase> retval; + // Fallback to lookup by extension when generic 'application/octet-stream' + // content type is received. + if (!aType.EqualsLiteral(APPLICATION_OCTET_STREAM)) { + retval = GetFromType(PromiseFlatCString(aType)); + } + bool hasDefault = false; + if (retval) retval->GetHasDefaultHandler(&hasDefault); + if (!retval || !hasDefault) { + RefPtr<nsMIMEInfoBase> miByExt = + GetFromExtension(PromiseFlatCString(aFileExt)); + // If we had no extension match, but a type match, use that + if (!miByExt && retval) { + retval.forget(aMIMEInfo); + return NS_OK; + } + // If we had an extension match but no type match, set the mimetype and use + // it + if (!retval && miByExt) { + if (!aType.IsEmpty()) miByExt->SetMIMEType(aType); + miByExt.swap(retval); + + retval.forget(aMIMEInfo); + return NS_OK; + } + // If we got nothing, make a new mimeinfo + if (!retval) { + *aFound = false; + retval = new nsMIMEInfoUnix(aType); + if (retval) { + if (!aFileExt.IsEmpty()) retval->AppendExtension(aFileExt); + } + + retval.forget(aMIMEInfo); + return NS_OK; + } + + // Copy the attributes of retval (mimeinfo from type) onto miByExt, to + // return it + // but reset to just collected mDefaultAppDescription (from ext) + nsAutoString byExtDefault; + miByExt->GetDefaultDescription(byExtDefault); + retval->SetDefaultDescription(byExtDefault); + retval->CopyBasicDataTo(miByExt); + + miByExt.swap(retval); + } + retval.forget(aMIMEInfo); + return NS_OK; +} + +NS_IMETHODIMP +nsOSHelperAppService::GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval) { + NS_ASSERTION(!aScheme.IsEmpty(), "No scheme was specified!"); + + nsresult rv = + OSProtocolHandlerExists(nsPromiseFlatCString(aScheme).get(), found); + if (NS_FAILED(rv)) return rv; + + nsMIMEInfoUnix* handlerInfo = + new nsMIMEInfoUnix(aScheme, nsMIMEInfoBase::eProtocolInfo); + NS_ENSURE_TRUE(handlerInfo, NS_ERROR_OUT_OF_MEMORY); + NS_ADDREF(*_retval = handlerInfo); + + if (!*found) { + // Code that calls this requires an object regardless if the OS has + // something for us, so we return the empty object. + return NS_OK; + } + + nsAutoString desc; + GetApplicationDescription(aScheme, desc); + handlerInfo->SetDefaultDescription(desc); + + return NS_OK; +} diff --git a/uriloader/exthandler/unix/nsOSHelperAppService.h b/uriloader/exthandler/unix/nsOSHelperAppService.h new file mode 100644 index 0000000000..25feeac359 --- /dev/null +++ b/uriloader/exthandler/unix/nsOSHelperAppService.h @@ -0,0 +1,122 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#ifndef nsOSHelperAppService_h__ +#define nsOSHelperAppService_h__ + +// The OS helper app service is a subclass of nsExternalHelperAppService and is +// implemented on each platform. It contains platform specific code for finding +// helper applications for a given mime type in addition to launching those +// applications. + +#include "nsExternalHelperAppService.h" +#include "nsCExternalHandlerService.h" +#include "nsCOMPtr.h" + +class nsIFileInputStream; +class nsILineInputStream; +class nsMIMEInfoBase; + +class nsOSHelperAppService : public nsExternalHelperAppService { + public: + virtual ~nsOSHelperAppService(); + + // method overrides for mime.types and mime.info look up steps + NS_IMETHOD GetMIMEInfoFromOS(const nsACString& aMimeType, + const nsACString& aFileExt, bool* aFound, + nsIMIMEInfo** aMIMEInfo) override; + NS_IMETHOD GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval) override; + + // override nsIExternalProtocolService methods + nsresult OSProtocolHandlerExists(const char* aProtocolScheme, + bool* aHandlerExists) override; + NS_IMETHOD GetApplicationDescription(const nsACString& aScheme, + nsAString& _retval) override; + NS_IMETHOD IsCurrentAppOSDefaultForProtocol(const nsACString& aScheme, + bool* _retval) override; + + // GetFileTokenForPath must be implemented by each platform. + // platformAppPath --> a platform specific path to an application that we got + // out of the rdf data source. This can be a mac file + // spec, a unix path or a windows path depending on the + // platform + // aFile --> an nsIFile representation of that platform application path. + virtual nsresult GetFileTokenForPath(const char16_t* platformAppPath, + nsIFile** aFile) override; + + protected: + already_AddRefed<nsMIMEInfoBase> GetFromType(const nsCString& aMimeType); + already_AddRefed<nsMIMEInfoBase> GetFromExtension(const nsCString& aFileExt); + + private: + // Helper methods which have to access static members + static nsresult UnescapeCommand(const nsAString& aEscapedCommand, + const nsAString& aMajorType, + const nsAString& aMinorType, + nsACString& aUnEscapedCommand); + static nsresult LookUpTypeAndDescription(const nsAString& aFileExtension, + nsAString& aMajorType, + nsAString& aMinorType, + nsAString& aDescription, + bool aUserData); + static nsresult CreateInputStream(const nsAString& aFilename, + nsIFileInputStream** aFileInputStream, + nsILineInputStream** aLineInputStream, + nsACString& aBuffer, bool* aNetscapeFormat, + bool* aMore); + + static nsresult GetTypeAndDescriptionFromMimetypesFile( + const nsAString& aFilename, const nsAString& aFileExtension, + nsAString& aMajorType, nsAString& aMinorType, nsAString& aDescription); + + static nsresult LookUpExtensionsAndDescription(const nsAString& aMajorType, + const nsAString& aMinorType, + nsAString& aFileExtensions, + nsAString& aDescription); + + static nsresult GetExtensionsAndDescriptionFromMimetypesFile( + const nsAString& aFilename, const nsAString& aMajorType, + const nsAString& aMinorType, nsAString& aFileExtensions, + nsAString& aDescription); + + static nsresult ParseNetscapeMIMETypesEntry( + const nsAString& aEntry, nsAString::const_iterator& aMajorTypeStart, + nsAString::const_iterator& aMajorTypeEnd, + nsAString::const_iterator& aMinorTypeStart, + nsAString::const_iterator& aMinorTypeEnd, nsAString& aExtensions, + nsAString::const_iterator& aDescriptionStart, + nsAString::const_iterator& aDescriptionEnd); + + static nsresult ParseNormalMIMETypesEntry( + const nsAString& aEntry, nsAString::const_iterator& aMajorTypeStart, + nsAString::const_iterator& aMajorTypeEnd, + nsAString::const_iterator& aMinorTypeStart, + nsAString::const_iterator& aMinorTypeEnd, nsAString& aExtensions, + nsAString::const_iterator& aDescriptionStart, + nsAString::const_iterator& aDescriptionEnd); + + static nsresult LookUpHandlerAndDescription(const nsAString& aMajorType, + const nsAString& aMinorType, + nsAString& aHandler, + nsAString& aDescription, + nsAString& aMozillaFlags); + + static nsresult DoLookUpHandlerAndDescription(const nsAString& aMajorType, + const nsAString& aMinorType, + nsAString& aHandler, + nsAString& aDescription, + nsAString& aMozillaFlags, + bool aUserData); + + static nsresult GetHandlerAndDescriptionFromMailcapFile( + const nsAString& aFilename, const nsAString& aMajorType, + const nsAString& aMinorType, nsAString& aHandler, nsAString& aDescription, + nsAString& aMozillaFlags); +}; + +#endif // nsOSHelperAppService_h__ diff --git a/uriloader/exthandler/win/nsMIMEInfoWin.cpp b/uriloader/exthandler/win/nsMIMEInfoWin.cpp new file mode 100644 index 0000000000..758b2018a7 --- /dev/null +++ b/uriloader/exthandler/win/nsMIMEInfoWin.cpp @@ -0,0 +1,913 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * + * 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/. */ + +#include "nsArrayEnumerator.h" +#include "nsComponentManagerUtils.h" +#include "nsCOMArray.h" +#include "nsLocalFile.h" +#include "nsMIMEInfoWin.h" +#include "nsIMIMEService.h" +#include "nsNetUtil.h" +#include <windows.h> +#include <shellapi.h> +#include "nsIMutableArray.h" +#include "nsTArray.h" +#include <shlobj.h> +#include "nsIWindowsRegKey.h" +#include "nsUnicharUtils.h" +#include "nsTextToSubURI.h" +#include "nsVariant.h" +#include "mozilla/CmdLineAndEnvUtils.h" +#include "mozilla/ShellHeaderOnlyUtils.h" +#include "mozilla/StaticPrefs_browser.h" +#include "mozilla/UrlmonHeaderOnlyUtils.h" +#include "mozilla/UniquePtrExtensions.h" + +#define RUNDLL32_EXE L"\\rundll32.exe" + +NS_IMPL_ISUPPORTS_INHERITED(nsMIMEInfoWin, nsMIMEInfoBase, nsIPropertyBag) + +nsMIMEInfoWin::~nsMIMEInfoWin() {} + +nsresult nsMIMEInfoWin::LaunchDefaultWithFile(nsIFile* aFile) { + // Launch the file, unless it is an executable. + bool executable = true; + aFile->IsExecutable(&executable); + if (executable) return NS_ERROR_FAILURE; + + return aFile->Launch(); +} + +nsresult nsMIMEInfoWin::ShellExecuteWithIFile(nsIFile* aExecutable, int aArgc, + const wchar_t** aArgv) { + nsresult rv; + + NS_ASSERTION(aArgc >= 1, "aArgc must be at least 1"); + + nsAutoString execPath; + rv = aExecutable->GetTarget(execPath); + if (NS_FAILED(rv) || execPath.IsEmpty()) { + rv = aExecutable->GetPath(execPath); + } + if (NS_FAILED(rv)) { + return rv; + } + + auto assembledArgs = mozilla::MakeCommandLine(aArgc, aArgv); + if (!assembledArgs) { + return NS_ERROR_FILE_EXECUTION_FAILED; + } + + _bstr_t execPathBStr(execPath.get()); + // Pass VT_ERROR/DISP_E_PARAMNOTFOUND to omit an optional RPC parameter + // to execute a file with the default verb. + _variant_t verbDefault(DISP_E_PARAMNOTFOUND, VT_ERROR); + _variant_t workingDir; + _variant_t showCmd(SW_SHOWNORMAL); + + // Ask Explorer to ShellExecute on our behalf, as some applications such as + // Skype for Business do not start correctly when inheriting our process's + // migitation policies. + // It does not work in a special environment such as Citrix. In such a case + // we fall back to launching an application as a child process. We need to + // find a way to handle the combination of these interop issues. + mozilla::LauncherVoidResult shellExecuteOk = mozilla::ShellExecuteByExplorer( + execPathBStr, assembledArgs.get(), verbDefault, workingDir, showCmd); + if (shellExecuteOk.isErr()) { + // No need to pass assembledArgs to LaunchWithIProcess. aArgv will be + // processed in nsProcess::RunProcess. + return LaunchWithIProcess(aExecutable, aArgc, + reinterpret_cast<const char16_t**>(aArgv)); + } + + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoWin::LaunchWithFile(nsIFile* aFile) { + nsresult rv; + + // it doesn't make any sense to call this on protocol handlers + NS_ASSERTION(mClass == eMIMEInfo, + "nsMIMEInfoBase should have mClass == eMIMEInfo"); + + if (AutomationOnlyCheckIfLaunchStubbed(aFile)) { + return NS_OK; + } + + if (mPreferredAction == useSystemDefault) { + nsCOMPtr<nsIFile> defaultApp = GetDefaultApplication(); + if (defaultApp && + mozilla::StaticPrefs::browser_pdf_launchDefaultEdgeAsApp()) { + // Since Edgium is the default handler for PDF and other kinds of files, + // if we're using the OS default and it's Edgium prefer its app mode so it + // operates as a viewer (without browser toolbars). Bug 1632277. + nsAutoCString defaultAppExecutable; + rv = defaultApp->GetNativeLeafName(defaultAppExecutable); + if (NS_SUCCEEDED(rv) && + defaultAppExecutable.LowerCaseEqualsLiteral("msedge.exe")) { + nsAutoString path; + rv = aFile->GetPath(path); + if (NS_SUCCEEDED(rv)) { + // If the --app flag doesn't work we'll want to fallback to a + // regular path. Send two args so we call `msedge.exe --app={path} + // {path}`. + nsAutoString appArg; + appArg.AppendLiteral("--app="); + appArg.Append(path); + const wchar_t* argv[] = {appArg.get(), path.get()}; + + return ShellExecuteWithIFile(defaultApp, mozilla::ArrayLength(argv), + argv); + } + } + } + return LaunchDefaultWithFile(aFile); + } + + if (mPreferredAction == useHelperApp) { + if (!mPreferredApplication) return NS_ERROR_FILE_NOT_FOUND; + + // at the moment, we only know how to hand files off to local handlers + nsCOMPtr<nsILocalHandlerApp> localHandler = + do_QueryInterface(mPreferredApplication, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIFile> executable; + rv = localHandler->GetExecutable(getter_AddRefs(executable)); + NS_ENSURE_SUCCESS(rv, rv); + + // Deal with local dll based handlers + nsCString filename; + executable->GetNativeLeafName(filename); + if (filename.Length() > 4) { + nsCString extension(Substring(filename, filename.Length() - 4, 4)); + + if (extension.LowerCaseEqualsLiteral(".dll")) { + nsAutoString args; + + // executable is rundll32, everything else is a list of parameters, + // including the dll handler. + if (!GetDllLaunchInfo(executable, aFile, args, false)) + return NS_ERROR_INVALID_ARG; + + WCHAR rundll32Path[MAX_PATH + sizeof(RUNDLL32_EXE) / sizeof(WCHAR) + + 1] = {L'\0'}; + if (!GetSystemDirectoryW(rundll32Path, MAX_PATH)) { + return NS_ERROR_FILE_NOT_FOUND; + } + lstrcatW(rundll32Path, RUNDLL32_EXE); + + SHELLEXECUTEINFOW seinfo; + memset(&seinfo, 0, sizeof(seinfo)); + seinfo.cbSize = sizeof(SHELLEXECUTEINFOW); + seinfo.fMask = 0; + seinfo.hwnd = nullptr; + seinfo.lpVerb = nullptr; + seinfo.lpFile = rundll32Path; + seinfo.lpParameters = args.get(); + seinfo.lpDirectory = nullptr; + seinfo.nShow = SW_SHOWNORMAL; + if (ShellExecuteExW(&seinfo)) return NS_OK; + + switch ((LONG_PTR)seinfo.hInstApp) { + case 0: + case SE_ERR_OOM: + return NS_ERROR_OUT_OF_MEMORY; + case SE_ERR_ACCESSDENIED: + return NS_ERROR_FILE_ACCESS_DENIED; + case SE_ERR_ASSOCINCOMPLETE: + case SE_ERR_NOASSOC: + return NS_ERROR_UNEXPECTED; + case SE_ERR_DDEBUSY: + case SE_ERR_DDEFAIL: + case SE_ERR_DDETIMEOUT: + return NS_ERROR_NOT_AVAILABLE; + case SE_ERR_DLLNOTFOUND: + return NS_ERROR_FAILURE; + case SE_ERR_SHARE: + return NS_ERROR_FILE_IS_LOCKED; + default: + switch (GetLastError()) { + case ERROR_FILE_NOT_FOUND: + return NS_ERROR_FILE_NOT_FOUND; + case ERROR_PATH_NOT_FOUND: + return NS_ERROR_FILE_UNRECOGNIZED_PATH; + case ERROR_BAD_FORMAT: + return NS_ERROR_FILE_CORRUPTED; + } + } + return NS_ERROR_FILE_EXECUTION_FAILED; + } + } + nsAutoString path; + aFile->GetPath(path); + const wchar_t* argv[] = {path.get()}; + return ShellExecuteWithIFile(executable, mozilla::ArrayLength(argv), argv); + } + + return NS_ERROR_INVALID_ARG; +} + +NS_IMETHODIMP +nsMIMEInfoWin::GetHasDefaultHandler(bool* _retval) { + // We have a default application if we have a description + // We can ShellExecute anything; however, callers are probably interested if + // there is really an application associated with this type of file + *_retval = !mDefaultAppDescription.IsEmpty(); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoWin::GetEnumerator(nsISimpleEnumerator** _retval) { + nsCOMArray<nsIVariant> properties; + + nsCOMPtr<nsIVariant> variant; + GetProperty(u"defaultApplicationIconURL"_ns, getter_AddRefs(variant)); + if (variant) properties.AppendObject(variant); + + GetProperty(u"customApplicationIconURL"_ns, getter_AddRefs(variant)); + if (variant) properties.AppendObject(variant); + + return NS_NewArrayEnumerator(_retval, properties, NS_GET_IID(nsIVariant)); +} + +static nsresult GetIconURLVariant(nsIFile* aApplication, nsIVariant** _retval) { + nsAutoCString fileURLSpec; + NS_GetURLSpecFromFile(aApplication, fileURLSpec); + nsAutoCString iconURLSpec; + iconURLSpec.AssignLiteral("moz-icon://"); + iconURLSpec += fileURLSpec; + RefPtr<nsVariant> writable(new nsVariant()); + writable->SetAsAUTF8String(iconURLSpec); + writable.forget(_retval); + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoWin::GetProperty(const nsAString& aName, nsIVariant** _retval) { + nsresult rv; + nsCOMPtr<nsIFile> defaultApp = GetDefaultApplication(); + if (defaultApp && aName.EqualsLiteral(PROPERTY_DEFAULT_APP_ICON_URL)) { + rv = GetIconURLVariant(defaultApp, _retval); + NS_ENSURE_SUCCESS(rv, rv); + } else if (mPreferredApplication && + aName.EqualsLiteral(PROPERTY_CUSTOM_APP_ICON_URL)) { + nsCOMPtr<nsILocalHandlerApp> localHandler = + do_QueryInterface(mPreferredApplication, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIFile> executable; + rv = localHandler->GetExecutable(getter_AddRefs(executable)); + NS_ENSURE_SUCCESS(rv, rv); + + rv = GetIconURLVariant(executable, _retval); + NS_ENSURE_SUCCESS(rv, rv); + } + + return NS_OK; +} + +// this implementation was pretty much copied verbatime from +// Tony Robinson's code in nsExternalProtocolWin.cpp +nsresult nsMIMEInfoWin::LoadUriInternal(nsIURI* aURL) { + nsresult rv = NS_OK; + + // 1. Find the default app for this protocol + // 2. Set up the command line + // 3. Launch the app. + + // For now, we'll just cheat essentially, check for the command line + // then just call ShellExecute()! + + if (aURL) { + // extract the url spec from the url + nsAutoCString urlSpec; + aURL->GetAsciiSpec(urlSpec); + + // Unescape non-ASCII characters in the URL + nsAutoString utf16Spec; + if (NS_FAILED(nsTextToSubURI::UnEscapeNonAsciiURI("UTF-8"_ns, urlSpec, + utf16Spec))) { + CopyASCIItoUTF16(urlSpec, utf16Spec); + } + + // Ask the shell/urlmon to parse |utf16Spec| to avoid malformed URLs. + // Failure is indicative of a potential security issue so we should + // bail out if so. + mozilla::LauncherResult<_bstr_t> validatedUri = + mozilla::UrlmonValidateUri(utf16Spec.get()); + if (validatedUri.isErr()) { + return NS_ERROR_FAILURE; + } + + _variant_t args; + _variant_t verb(L"open"); + _variant_t workingDir; + _variant_t showCmd(SW_SHOWNORMAL); + + // To open a uri, we first try ShellExecuteByExplorer, which starts a new + // process as a child process of explorer.exe, because applications may not + // support the mitigation policies inherited from our process. If it fails, + // we fall back to ShellExecuteExW. + // + // For Thunderbird, however, there is a known issue that + // ShellExecuteByExplorer succeeds but explorer.exe shows an error popup + // if a uri to open includes credentials. This does not happen in Firefox + // because Firefox does not have to launch a process to open a uri. + // + // Since Thunderbird does not use mitigation policies which could cause + // compatibility issues, we get no benefit from using + // ShellExecuteByExplorer. Thus we skip it and go straight to + // ShellExecuteExW for Thunderbird. +#ifndef MOZ_THUNDERBIRD + mozilla::LauncherVoidResult shellExecuteOk = + mozilla::ShellExecuteByExplorer(validatedUri.inspect(), args, verb, + workingDir, showCmd); + if (shellExecuteOk.isOk()) { + return NS_OK; + } +#endif // MOZ_THUNDERBIRD + + SHELLEXECUTEINFOW sinfo = {sizeof(sinfo)}; + sinfo.fMask = SEE_MASK_NOASYNC; + sinfo.lpVerb = V_BSTR(&verb); + sinfo.nShow = showCmd; + sinfo.lpFile = validatedUri.inspect(); + + BOOL result = ShellExecuteExW(&sinfo); + if (!result || reinterpret_cast<LONG_PTR>(sinfo.hInstApp) < 32) { + rv = NS_ERROR_FAILURE; + } + } + + return rv; +} + +void nsMIMEInfoWin::UpdateDefaultInfoIfStale() { + if (!mIsDefaultAppInfoFresh) { + nsCOMPtr<nsIMIMEService> mime = do_GetService("@mozilla.org/mime;1"); + if (mime) { + mime->UpdateDefaultAppInfo(static_cast<nsIMIMEInfo*>(this)); + } + mIsDefaultAppInfoFresh = true; + } +} + +// Given a path to a local file, return its nsILocalHandlerApp instance. +bool nsMIMEInfoWin::GetLocalHandlerApp(const nsAString& aCommandHandler, + nsCOMPtr<nsILocalHandlerApp>& aApp) { + nsCOMPtr<nsIFile> locfile; + nsresult rv = NS_NewLocalFile(aCommandHandler, true, getter_AddRefs(locfile)); + if (NS_FAILED(rv)) return false; + + aApp = do_CreateInstance("@mozilla.org/uriloader/local-handler-app;1"); + if (!aApp) return false; + + aApp->SetExecutable(locfile); + return true; +} + +// Return the cleaned up file path associated with a command verb +// located in root/Applications. +bool nsMIMEInfoWin::GetAppsVerbCommandHandler(const nsAString& appExeName, + nsAString& applicationPath, + bool edit) { + nsCOMPtr<nsIWindowsRegKey> appKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!appKey) return false; + + // HKEY_CLASSES_ROOT\Applications\iexplore.exe + nsAutoString applicationsPath; + applicationsPath.AppendLiteral("Applications\\"); + applicationsPath.Append(appExeName); + + nsresult rv = + appKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, applicationsPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) return false; + + // Check for the NoOpenWith flag, if it exists + uint32_t value; + if (NS_SUCCEEDED(appKey->ReadIntValue(u"NoOpenWith"_ns, &value)) && + value == 1) + return false; + + nsAutoString dummy; + if (NS_SUCCEEDED(appKey->ReadStringValue(u"NoOpenWith"_ns, dummy))) + return false; + + appKey->Close(); + + // HKEY_CLASSES_ROOT\Applications\iexplore.exe\shell\open\command + applicationsPath.AssignLiteral("Applications\\"); + applicationsPath.Append(appExeName); + if (!edit) + applicationsPath.AppendLiteral("\\shell\\open\\command"); + else + applicationsPath.AppendLiteral("\\shell\\edit\\command"); + + rv = appKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, applicationsPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) return false; + + nsAutoString appFilesystemCommand; + if (NS_SUCCEEDED(appKey->ReadStringValue(u""_ns, appFilesystemCommand))) { + // Expand environment vars, clean up any misc. + if (!nsLocalFile::CleanupCmdHandlerPath(appFilesystemCommand)) return false; + + applicationPath = appFilesystemCommand; + return true; + } + return false; +} + +// Return a fully populated command string based on +// passing information. Used in launchWithFile to trace +// back to the full handler path based on the dll. +// (dll, targetfile, return args, open/edit) +bool nsMIMEInfoWin::GetDllLaunchInfo(nsIFile* aDll, nsIFile* aFile, + nsAString& args, bool edit) { + if (!aDll || !aFile) return false; + + nsString appExeName; + aDll->GetLeafName(appExeName); + + nsCOMPtr<nsIWindowsRegKey> appKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!appKey) return false; + + // HKEY_CLASSES_ROOT\Applications\iexplore.exe + nsAutoString applicationsPath; + applicationsPath.AppendLiteral("Applications\\"); + applicationsPath.Append(appExeName); + + nsresult rv = + appKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, applicationsPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) return false; + + // Check for the NoOpenWith flag, if it exists + uint32_t value; + rv = appKey->ReadIntValue(u"NoOpenWith"_ns, &value); + if (NS_SUCCEEDED(rv) && value == 1) return false; + + nsAutoString dummy; + if (NS_SUCCEEDED(appKey->ReadStringValue(u"NoOpenWith"_ns, dummy))) + return false; + + appKey->Close(); + + // HKEY_CLASSES_ROOT\Applications\iexplore.exe\shell\open\command + applicationsPath.AssignLiteral("Applications\\"); + applicationsPath.Append(appExeName); + if (!edit) + applicationsPath.AppendLiteral("\\shell\\open\\command"); + else + applicationsPath.AppendLiteral("\\shell\\edit\\command"); + + rv = appKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, applicationsPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) return false; + + nsAutoString appFilesystemCommand; + if (NS_SUCCEEDED(appKey->ReadStringValue(u""_ns, appFilesystemCommand))) { + // Replace embedded environment variables. + uint32_t bufLength = + ::ExpandEnvironmentStringsW(appFilesystemCommand.get(), nullptr, 0); + if (bufLength == 0) // Error + return false; + + auto destination = mozilla::MakeUniqueFallible<wchar_t[]>(bufLength); + if (!destination) return false; + if (!::ExpandEnvironmentStringsW(appFilesystemCommand.get(), + destination.get(), bufLength)) + return false; + + appFilesystemCommand.Assign(destination.get()); + + // C:\Windows\System32\rundll32.exe "C:\Program Files\Windows + // Photo Gallery\PhotoViewer.dll", ImageView_Fullscreen %1 + nsAutoString params; + constexpr auto rundllSegment = u"rundll32.exe "_ns; + int32_t index = appFilesystemCommand.Find(rundllSegment); + if (index > kNotFound) { + params.Append( + Substring(appFilesystemCommand, index + rundllSegment.Length())); + } else { + params.Append(appFilesystemCommand); + } + + // check to make sure we have a %1 and fill it + constexpr auto percentOneParam = u"%1"_ns; + index = params.Find(percentOneParam); + if (index == kNotFound) // no parameter + return false; + + nsString target; + aFile->GetTarget(target); + params.Replace(index, 2, target); + + args = params; + + return true; + } + return false; +} + +// Return the cleaned up file path associated with a progid command +// verb located in root. +bool nsMIMEInfoWin::GetProgIDVerbCommandHandler(const nsAString& appProgIDName, + nsAString& applicationPath, + bool edit) { + nsCOMPtr<nsIWindowsRegKey> appKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!appKey) return false; + + nsAutoString appProgId(appProgIDName); + + // HKEY_CLASSES_ROOT\Windows.XPSReachViewer\shell\open\command + if (!edit) + appProgId.AppendLiteral("\\shell\\open\\command"); + else + appProgId.AppendLiteral("\\shell\\edit\\command"); + + nsresult rv = appKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, appProgId, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) return false; + + nsAutoString appFilesystemCommand; + if (NS_SUCCEEDED(appKey->ReadStringValue(u""_ns, appFilesystemCommand))) { + // Expand environment vars, clean up any misc. + if (!nsLocalFile::CleanupCmdHandlerPath(appFilesystemCommand)) return false; + + applicationPath = appFilesystemCommand; + return true; + } + return false; +} + +// Helper routine used in tracking app lists. Converts path +// entries to lower case and stores them in the trackList array. +void nsMIMEInfoWin::ProcessPath(nsCOMPtr<nsIMutableArray>& appList, + nsTArray<nsString>& trackList, + const nsAString& appFilesystemCommand) { + nsAutoString lower(appFilesystemCommand); + ToLowerCase(lower); + + // Don't include firefox.exe in the list + WCHAR exe[MAX_PATH + 1]; + uint32_t len = GetModuleFileNameW(nullptr, exe, MAX_PATH); + if (len < MAX_PATH && len != 0) { + int32_t index = lower.Find(reinterpret_cast<const char16_t*>(exe)); + if (index != -1) return; + } + + nsCOMPtr<nsILocalHandlerApp> aApp; + if (!GetLocalHandlerApp(appFilesystemCommand, aApp)) return; + + // Save in our main tracking arrays + appList->AppendElement(aApp); + trackList.AppendElement(lower); +} + +// Helper routine that handles a compare between a path +// and an array of paths. +static bool IsPathInList(nsAString& appPath, nsTArray<nsString>& trackList) { + // trackList data is always lowercase, see ProcessPath + // above. + nsAutoString tmp(appPath); + ToLowerCase(tmp); + + for (uint32_t i = 0; i < trackList.Length(); i++) { + if (tmp.Equals(trackList[i])) return true; + } + return false; +} + +/** + * Returns a list of nsILocalHandlerApp objects containing local + * handlers associated with this mimeinfo. Implemented per + * platform using information in this object to generate the + * best list. Typically used for an "open with" style user + * option. + * + * @return nsIArray of nsILocalHandlerApp + */ +NS_IMETHODIMP +nsMIMEInfoWin::GetPossibleLocalHandlers(nsIArray** _retval) { + nsresult rv; + + *_retval = nullptr; + + nsCOMPtr<nsIMutableArray> appList = do_CreateInstance("@mozilla.org/array;1"); + + if (!appList) return NS_ERROR_FAILURE; + + nsTArray<nsString> trackList; + + nsAutoCString fileExt; + GetPrimaryExtension(fileExt); + + nsCOMPtr<nsIWindowsRegKey> regKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!regKey) return NS_ERROR_FAILURE; + nsCOMPtr<nsIWindowsRegKey> appKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!appKey) return NS_ERROR_FAILURE; + + nsAutoString workingRegistryPath; + + bool extKnown = false; + if (fileExt.IsEmpty()) { + extKnown = true; + // Mime type discovery is possible in some cases, through + // HKEY_CLASSES_ROOT\MIME\Database\Content Type, however, a number + // of file extensions related to mime type are simply not defined, + // (application/rss+xml & application/atom+xml are good examples) + // in which case we can only provide a generic list. + nsAutoCString mimeType; + GetMIMEType(mimeType); + if (!mimeType.IsEmpty()) { + workingRegistryPath.AppendLiteral("MIME\\Database\\Content Type\\"); + workingRegistryPath.Append(NS_ConvertASCIItoUTF16(mimeType)); + + rv = regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, + workingRegistryPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + nsAutoString mimeFileExt; + if (NS_SUCCEEDED(regKey->ReadStringValue(u""_ns, mimeFileExt))) { + CopyUTF16toUTF8(mimeFileExt, fileExt); + extKnown = false; + } + } + } + } + + nsAutoString fileExtToUse; + if (!fileExt.IsEmpty() && fileExt.First() != '.') { + fileExtToUse = char16_t('.'); + } + fileExtToUse.Append(NS_ConvertUTF8toUTF16(fileExt)); + + // Note, the order in which these occur has an effect on the + // validity of the resulting display list. + + if (!extKnown) { + // 1) Get the default handler if it exists + workingRegistryPath = fileExtToUse; + + rv = + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, + workingRegistryPath, nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + nsAutoString appProgId; + if (NS_SUCCEEDED(regKey->ReadStringValue(u""_ns, appProgId))) { + // Bug 358297 - ignore the embedded internet explorer handler + if (appProgId != u"XPSViewer.Document"_ns) { + nsAutoString appFilesystemCommand; + if (GetProgIDVerbCommandHandler(appProgId, appFilesystemCommand, + false) && + !IsPathInList(appFilesystemCommand, trackList)) { + ProcessPath(appList, trackList, appFilesystemCommand); + } + } + } + regKey->Close(); + } + + // 2) list HKEY_CLASSES_ROOT\.ext\OpenWithList + + workingRegistryPath = fileExtToUse; + workingRegistryPath.AppendLiteral("\\OpenWithList"); + + rv = + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, + workingRegistryPath, nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + uint32_t count = 0; + if (NS_SUCCEEDED(regKey->GetValueCount(&count)) && count > 0) { + for (uint32_t index = 0; index < count; index++) { + nsAutoString appName; + if (NS_FAILED(regKey->GetValueName(index, appName))) continue; + + // HKEY_CLASSES_ROOT\Applications\firefox.exe = "path params" + nsAutoString appFilesystemCommand; + if (!GetAppsVerbCommandHandler(appName, appFilesystemCommand, + false) || + IsPathInList(appFilesystemCommand, trackList)) + continue; + ProcessPath(appList, trackList, appFilesystemCommand); + } + } + regKey->Close(); + } + + // 3) List HKEY_CLASSES_ROOT\.ext\OpenWithProgids, with the + // different step of resolving the progids for the command handler. + + workingRegistryPath = fileExtToUse; + workingRegistryPath.AppendLiteral("\\OpenWithProgids"); + + rv = + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, + workingRegistryPath, nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + uint32_t count = 0; + if (NS_SUCCEEDED(regKey->GetValueCount(&count)) && count > 0) { + for (uint32_t index = 0; index < count; index++) { + // HKEY_CLASSES_ROOT\.ext\OpenWithProgids\Windows.XPSReachViewer + nsAutoString appProgId; + if (NS_FAILED(regKey->GetValueName(index, appProgId))) continue; + + nsAutoString appFilesystemCommand; + if (!GetProgIDVerbCommandHandler(appProgId, appFilesystemCommand, + false) || + IsPathInList(appFilesystemCommand, trackList)) + continue; + ProcessPath(appList, trackList, appFilesystemCommand); + } + } + regKey->Close(); + } + + // 4) Add any non configured applications located in the MRU list + + // HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion + // \Explorer\FileExts\.ext\OpenWithList + workingRegistryPath = nsLiteralString( + u"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\"); + workingRegistryPath += fileExtToUse; + workingRegistryPath.AppendLiteral("\\OpenWithList"); + + rv = + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CURRENT_USER, + workingRegistryPath, nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + uint32_t count = 0; + if (NS_SUCCEEDED(regKey->GetValueCount(&count)) && count > 0) { + for (uint32_t index = 0; index < count; index++) { + nsAutoString appName, appValue; + if (NS_FAILED(regKey->GetValueName(index, appName))) continue; + if (appName.EqualsLiteral("MRUList")) continue; + if (NS_FAILED(regKey->ReadStringValue(appName, appValue))) continue; + + // HKEY_CLASSES_ROOT\Applications\firefox.exe = "path params" + nsAutoString appFilesystemCommand; + if (!GetAppsVerbCommandHandler(appValue, appFilesystemCommand, + false) || + IsPathInList(appFilesystemCommand, trackList)) + continue; + ProcessPath(appList, trackList, appFilesystemCommand); + } + } + } + + // 5) Add any non configured progids in the MRU list, with the + // different step of resolving the progids for the command handler. + + // HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion + // \Explorer\FileExts\.ext\OpenWithProgids + workingRegistryPath = nsLiteralString( + u"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\"); + workingRegistryPath += fileExtToUse; + workingRegistryPath.AppendLiteral("\\OpenWithProgids"); + + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CURRENT_USER, workingRegistryPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + uint32_t count = 0; + if (NS_SUCCEEDED(regKey->GetValueCount(&count)) && count > 0) { + for (uint32_t index = 0; index < count; index++) { + nsAutoString appIndex, appProgId; + if (NS_FAILED(regKey->GetValueName(index, appProgId))) continue; + + nsAutoString appFilesystemCommand; + if (!GetProgIDVerbCommandHandler(appProgId, appFilesystemCommand, + false) || + IsPathInList(appFilesystemCommand, trackList)) + continue; + ProcessPath(appList, trackList, appFilesystemCommand); + } + } + regKey->Close(); + } + + // 6) Check the perceived type value, and use this to lookup the + // perceivedtype open with list. + // http://msdn2.microsoft.com/en-us/library/aa969373.aspx + + workingRegistryPath = fileExtToUse; + + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, workingRegistryPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + nsAutoString perceivedType; + rv = regKey->ReadStringValue(u"PerceivedType"_ns, perceivedType); + if (NS_SUCCEEDED(rv)) { + nsAutoString openWithListPath(u"SystemFileAssociations\\"_ns); + openWithListPath.Append(perceivedType); // no period + openWithListPath.AppendLiteral("\\OpenWithList"); + + nsresult rv = appKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, + openWithListPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + uint32_t count = 0; + if (NS_SUCCEEDED(regKey->GetValueCount(&count)) && count > 0) { + for (uint32_t index = 0; index < count; index++) { + nsAutoString appName; + if (NS_FAILED(regKey->GetValueName(index, appName))) continue; + + // HKEY_CLASSES_ROOT\Applications\firefox.exe = "path params" + nsAutoString appFilesystemCommand; + if (!GetAppsVerbCommandHandler(appName, appFilesystemCommand, + false) || + IsPathInList(appFilesystemCommand, trackList)) + continue; + ProcessPath(appList, trackList, appFilesystemCommand); + } + } + } + } + } + } // extKnown == false + + // 7) list global HKEY_CLASSES_ROOT\*\OpenWithList + // Listing general purpose handlers, not specific to a mime type or file + // extension + + workingRegistryPath = u"*\\OpenWithList"_ns; + + rv = regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, + workingRegistryPath, nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + uint32_t count = 0; + if (NS_SUCCEEDED(regKey->GetValueCount(&count)) && count > 0) { + for (uint32_t index = 0; index < count; index++) { + nsAutoString appName; + if (NS_FAILED(regKey->GetValueName(index, appName))) continue; + + // HKEY_CLASSES_ROOT\Applications\firefox.exe = "path params" + nsAutoString appFilesystemCommand; + if (!GetAppsVerbCommandHandler(appName, appFilesystemCommand, false) || + IsPathInList(appFilesystemCommand, trackList)) + continue; + ProcessPath(appList, trackList, appFilesystemCommand); + } + } + regKey->Close(); + } + + // 8) General application's list - not file extension specific on windows + workingRegistryPath = u"Applications"_ns; + + rv = + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, workingRegistryPath, + nsIWindowsRegKey::ACCESS_ENUMERATE_SUB_KEYS | + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + uint32_t count = 0; + if (NS_SUCCEEDED(regKey->GetChildCount(&count)) && count > 0) { + for (uint32_t index = 0; index < count; index++) { + nsAutoString appName; + if (NS_FAILED(regKey->GetChildName(index, appName))) continue; + + // HKEY_CLASSES_ROOT\Applications\firefox.exe = "path params" + nsAutoString appFilesystemCommand; + if (!GetAppsVerbCommandHandler(appName, appFilesystemCommand, false) || + IsPathInList(appFilesystemCommand, trackList)) + continue; + ProcessPath(appList, trackList, appFilesystemCommand); + } + } + } + + // Return to the caller + *_retval = appList; + NS_ADDREF(*_retval); + + return NS_OK; +} + +NS_IMETHODIMP +nsMIMEInfoWin::IsCurrentAppOSDefault(bool* _retval) { + *_retval = false; + nsCOMPtr<nsIFile> defaultApp = GetDefaultApplication(); + if (defaultApp) { + // Determine if the default executable is our executable. + nsCOMPtr<nsIFile> ourBinary; + XRE_GetBinaryPath(getter_AddRefs(ourBinary)); + bool isSame = false; + nsresult rv = defaultApp->Equals(ourBinary, &isSame); + if (NS_FAILED(rv)) { + return rv; + } + *_retval = isSame; + } + return NS_OK; +} diff --git a/uriloader/exthandler/win/nsMIMEInfoWin.h b/uriloader/exthandler/win/nsMIMEInfoWin.h new file mode 100644 index 0000000000..fa972b23a9 --- /dev/null +++ b/uriloader/exthandler/win/nsMIMEInfoWin.h @@ -0,0 +1,79 @@ +/* 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/. */ + +#ifndef nsMIMEInfoWin_h_ +#define nsMIMEInfoWin_h_ + +#include "nsMIMEInfoImpl.h" +#include "nsIPropertyBag.h" +#include "nsIMutableArray.h" +#include "nsTArray.h" + +class nsMIMEInfoWin : public nsMIMEInfoBase, public nsIPropertyBag { + virtual ~nsMIMEInfoWin(); + + public: + explicit nsMIMEInfoWin(const char* aType = "") : nsMIMEInfoBase(aType) {} + explicit nsMIMEInfoWin(const nsACString& aMIMEType) + : nsMIMEInfoBase(aMIMEType) {} + nsMIMEInfoWin(const nsACString& aType, HandlerClass aClass) + : nsMIMEInfoBase(aType, aClass) {} + + NS_IMETHOD LaunchWithFile(nsIFile* aFile) override; + NS_IMETHOD GetHasDefaultHandler(bool* _retval) override; + NS_IMETHOD GetPossibleLocalHandlers(nsIArray** _retval) override; + NS_IMETHOD IsCurrentAppOSDefault(bool* _retval) override; + + void UpdateDefaultInfoIfStale(); + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_NSIPROPERTYBAG + + void SetDefaultApplicationHandler(nsIFile* aDefaultApplication) { + mDefaultApplication = aDefaultApplication; + } + + protected: + nsIFile* GetDefaultApplication() { + UpdateDefaultInfoIfStale(); + return mDefaultApplication; + } + + virtual nsresult LoadUriInternal(nsIURI* aURI); + virtual nsresult LaunchDefaultWithFile(nsIFile* aFile); + + private: + nsCOMPtr<nsIFile> mDefaultApplication; + + // Given a path to a local handler, return its + // nsILocalHandlerApp instance. + bool GetLocalHandlerApp(const nsAString& aCommandHandler, + nsCOMPtr<nsILocalHandlerApp>& aApp); + + // Return the cleaned up file path associated + // with a command verb located in root/Applications. + bool GetAppsVerbCommandHandler(const nsAString& appExeName, + nsAString& applicationPath, bool bEdit); + + // Return the cleaned up file path associated + // with a progid command verb located in root. + bool GetProgIDVerbCommandHandler(const nsAString& appProgIDName, + nsAString& applicationPath, bool bEdit); + + // Lookup a rundll command handler and return + // a populated command template for use with rundll32.exe. + bool GetDllLaunchInfo(nsIFile* aDll, nsIFile* aFile, nsAString& args, + bool bEdit); + + // Helper routine used in tracking app lists + void ProcessPath(nsCOMPtr<nsIMutableArray>& appList, + nsTArray<nsString>& trackList, + const nsAString& appFilesystemCommand); + + // Helper routine to call mozilla::ShellExecuteByExplorer + nsresult ShellExecuteWithIFile(nsIFile* aExecutable, int aArgc, + const wchar_t** aArgv); +}; + +#endif diff --git a/uriloader/exthandler/win/nsOSHelperAppService.cpp b/uriloader/exthandler/win/nsOSHelperAppService.cpp new file mode 100644 index 0000000000..e3a2ae9873 --- /dev/null +++ b/uriloader/exthandler/win/nsOSHelperAppService.cpp @@ -0,0 +1,609 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * vim:set ts=2 sts=2 sw=2 et cin: + * + * 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/. */ + +#include "nsComponentManagerUtils.h" +#include "nsOSHelperAppService.h" +#include "nsISupports.h" +#include "nsString.h" +#include "nsIMIMEInfo.h" +#include "nsMIMEInfoWin.h" +#include "nsMimeTypes.h" +#include "nsNativeCharsetUtils.h" +#include "nsLocalFile.h" +#include "nsIWindowsRegKey.h" +#include "nsXULAppAPI.h" +#include "mozilla/UniquePtrExtensions.h" + +// shellapi.h is needed to build with WIN32_LEAN_AND_MEAN +#include <shellapi.h> +#include <shlwapi.h> + +#define LOG(...) MOZ_LOG(sLog, mozilla::LogLevel::Debug, (__VA_ARGS__)) + +// helper methods: forward declarations... +static nsresult GetExtensionFromWindowsMimeDatabase(const nsACString& aMimeType, + nsString& aFileExtension); + +nsOSHelperAppService::nsOSHelperAppService() + : nsExternalHelperAppService(), mAppAssoc(nullptr) { + CoInitialize(nullptr); + CoCreateInstance(CLSID_ApplicationAssociationRegistration, nullptr, + CLSCTX_INPROC, IID_IApplicationAssociationRegistration, + (void**)&mAppAssoc); +} + +nsOSHelperAppService::~nsOSHelperAppService() { + if (mAppAssoc) mAppAssoc->Release(); + mAppAssoc = nullptr; + CoUninitialize(); +} + +// The windows registry provides a mime database key which lists a set of mime +// types and corresponding "Extension" values. we can use this to look up our +// mime type to see if there is a preferred extension for the mime type. +static nsresult GetExtensionFromWindowsMimeDatabase(const nsACString& aMimeType, + nsString& aFileExtension) { + nsAutoString mimeDatabaseKey; + mimeDatabaseKey.AssignLiteral("MIME\\Database\\Content Type\\"); + + AppendASCIItoUTF16(aMimeType, mimeDatabaseKey); + + nsCOMPtr<nsIWindowsRegKey> regKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!regKey) return NS_ERROR_NOT_AVAILABLE; + + nsresult rv = + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, mimeDatabaseKey, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + + if (NS_SUCCEEDED(rv)) + regKey->ReadStringValue(u"Extension"_ns, aFileExtension); + + return NS_OK; +} + +nsresult nsOSHelperAppService::OSProtocolHandlerExists( + const char* aProtocolScheme, bool* aHandlerExists) { + // look up the protocol scheme in the windows registry....if we find a match + // then we have a handler for it... + *aHandlerExists = false; + if (aProtocolScheme && *aProtocolScheme) { + NS_ENSURE_TRUE(mAppAssoc, NS_ERROR_NOT_AVAILABLE); + wchar_t* pResult = nullptr; + NS_ConvertASCIItoUTF16 scheme(aProtocolScheme); + // We are responsible for freeing returned strings. + HRESULT hr = mAppAssoc->QueryCurrentDefault(scheme.get(), AT_URLPROTOCOL, + AL_EFFECTIVE, &pResult); + if (SUCCEEDED(hr)) { + CoTaskMemFree(pResult); + nsCOMPtr<nsIWindowsRegKey> regKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!regKey) { + return NS_ERROR_NOT_AVAILABLE; + } + + nsresult rv = regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, + nsDependentString(scheme.get()), + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) { + // Open will fail if the registry key path doesn't exist. + return NS_OK; + } + + bool hasValue; + rv = regKey->HasValue(u"URL Protocol"_ns, &hasValue); + if (NS_FAILED(rv)) { + return NS_ERROR_FAILURE; + } + if (!hasValue) { + return NS_OK; + } + + *aHandlerExists = true; + } + } + + return NS_OK; +} + +NS_IMETHODIMP nsOSHelperAppService::GetApplicationDescription( + const nsACString& aScheme, nsAString& _retval) { + nsCOMPtr<nsIWindowsRegKey> regKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!regKey) return NS_ERROR_NOT_AVAILABLE; + + NS_ConvertASCIItoUTF16 buf(aScheme); + + wchar_t result[1024]; + DWORD resultSize = 1024; + HRESULT hr = AssocQueryString(0x1000 /* ASSOCF_IS_PROTOCOL */, + ASSOCSTR_FRIENDLYAPPNAME, buf.get(), NULL, + result, &resultSize); + if (SUCCEEDED(hr)) { + _retval = result; + return NS_OK; + } + + NS_ENSURE_TRUE(mAppAssoc, NS_ERROR_NOT_AVAILABLE); + wchar_t* pResult = nullptr; + // We are responsible for freeing returned strings. + hr = mAppAssoc->QueryCurrentDefault(buf.get(), AT_URLPROTOCOL, AL_EFFECTIVE, + &pResult); + if (SUCCEEDED(hr)) { + nsCOMPtr<nsIFile> app; + nsAutoString appInfo(pResult); + CoTaskMemFree(pResult); + if (NS_SUCCEEDED(GetDefaultAppInfo(appInfo, _retval, getter_AddRefs(app)))) + return NS_OK; + } + return NS_ERROR_NOT_AVAILABLE; +} + +NS_IMETHODIMP nsOSHelperAppService::IsCurrentAppOSDefaultForProtocol( + const nsACString& aScheme, bool* _retval) { + *_retval = false; + + NS_ENSURE_TRUE(mAppAssoc, NS_ERROR_NOT_AVAILABLE); + + NS_ConvertASCIItoUTF16 buf(aScheme); + + // Find the progID + wchar_t* pResult = nullptr; + HRESULT hr = mAppAssoc->QueryCurrentDefault(buf.get(), AT_URLPROTOCOL, + AL_EFFECTIVE, &pResult); + if (FAILED(hr)) { + return NS_ERROR_FAILURE; + } + nsAutoString progID(pResult); + // We are responsible for freeing returned strings. + CoTaskMemFree(pResult); + + // Find the default executable. + nsAutoString description; + nsCOMPtr<nsIFile> appFile; + nsresult rv = GetDefaultAppInfo(progID, description, getter_AddRefs(appFile)); + if (NS_FAILED(rv)) { + return rv; + } + // Determine if the default executable is our executable. + nsCOMPtr<nsIFile> ourBinary; + XRE_GetBinaryPath(getter_AddRefs(ourBinary)); + bool isSame = false; + rv = appFile->Equals(ourBinary, &isSame); + if (NS_FAILED(rv)) { + return rv; + } + *_retval = isSame; + return NS_OK; +} + +// GetMIMEInfoFromRegistry: This function obtains the values of some of the +// nsIMIMEInfo attributes for the mimeType/extension associated with the input +// registry key. The default entry for that key is the name of a registry key +// under HKEY_CLASSES_ROOT. The default value for *that* key is the descriptive +// name of the type. The EditFlags value is a binary value; the low order bit +// of the third byte of which indicates that the user does not need to be +// prompted. +// +// This function sets only the Description attribute of the input nsIMIMEInfo. +/* static */ +nsresult nsOSHelperAppService::GetMIMEInfoFromRegistry(const nsString& fileType, + nsIMIMEInfo* pInfo) { + nsresult rv = NS_OK; + + NS_ENSURE_ARG(pInfo); + nsCOMPtr<nsIWindowsRegKey> regKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!regKey) return NS_ERROR_NOT_AVAILABLE; + + rv = regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, fileType, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) return NS_ERROR_FAILURE; + + // OK, the default value here is the description of the type. + nsAutoString description; + rv = regKey->ReadStringValue(u""_ns, description); + if (NS_SUCCEEDED(rv)) pInfo->SetDescription(description); + + return NS_OK; +} + +///////////////////////////////////////////////////////////////////////////////////////////////// +// method overrides used to gather information from the windows registry for +// various mime types. +//////////////////////////////////////////////////////////////////////////////////////////////// + +/// Looks up the type for the extension aExt and compares it to aType +/* static */ +bool nsOSHelperAppService::typeFromExtEquals(const char16_t* aExt, + const char* aType) { + if (!aType) return false; + nsAutoString fileExtToUse; + if (aExt[0] != char16_t('.')) fileExtToUse = char16_t('.'); + + fileExtToUse.Append(aExt); + + bool eq = false; + nsCOMPtr<nsIWindowsRegKey> regKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!regKey) return eq; + + nsresult rv = + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, fileExtToUse, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) return eq; + + nsAutoString type; + rv = regKey->ReadStringValue(u"Content Type"_ns, type); + if (NS_SUCCEEDED(rv)) eq = type.LowerCaseEqualsASCII(aType); + + return eq; +} + +// The "real" name of a given helper app (as specified by the path to the +// executable file held in various registry keys) is stored n the VERSIONINFO +// block in the file's resources. We need to find the path to the executable +// and then retrieve the "FileDescription" field value from the file. +nsresult nsOSHelperAppService::GetDefaultAppInfo( + const nsAString& aAppInfo, nsAString& aDefaultDescription, + nsIFile** aDefaultApplication) { + nsAutoString handlerCommand; + + // If all else fails, use the file type key name, which will be + // something like "pngfile" for .pngs, "WMVFile" for .wmvs, etc. + aDefaultDescription = aAppInfo; + *aDefaultApplication = nullptr; + + if (aAppInfo.IsEmpty()) return NS_ERROR_FAILURE; + + // aAppInfo may be a file, file path, program id, or + // Applications reference - + // c:\dir\app.exe + // Applications\appfile.exe/dll (shell\open...) + // ProgID.progid (shell\open...) + + nsAutoString handlerKeyName(aAppInfo); + + nsCOMPtr<nsIWindowsRegKey> chkKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!chkKey) return NS_ERROR_FAILURE; + + nsresult rv = + chkKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, handlerKeyName, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) { + // It's a file system path to a handler + handlerCommand.Assign(aAppInfo); + } else { + handlerKeyName.AppendLiteral("\\shell\\open\\command"); + nsCOMPtr<nsIWindowsRegKey> regKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!regKey) return NS_ERROR_FAILURE; + + nsresult rv = + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, handlerKeyName, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) return NS_ERROR_FAILURE; + + // OK, the default value here is the description of the type. + rv = regKey->ReadStringValue(u""_ns, handlerCommand); + if (NS_FAILED(rv)) { + // Check if there is a DelegateExecute string + nsAutoString delegateExecute; + rv = regKey->ReadStringValue(u"DelegateExecute"_ns, delegateExecute); + NS_ENSURE_SUCCESS(rv, rv); + + // Look for InProcServer32 + nsAutoString delegateExecuteRegPath; + delegateExecuteRegPath.AssignLiteral("CLSID\\"); + delegateExecuteRegPath.Append(delegateExecute); + delegateExecuteRegPath.AppendLiteral("\\InProcServer32"); + rv = chkKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, + delegateExecuteRegPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_SUCCEEDED(rv)) { + rv = chkKey->ReadStringValue(u""_ns, handlerCommand); + } + + if (NS_FAILED(rv)) { + // Look for LocalServer32 + delegateExecuteRegPath.AssignLiteral("CLSID\\"); + delegateExecuteRegPath.Append(delegateExecute); + delegateExecuteRegPath.AppendLiteral("\\LocalServer32"); + rv = chkKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, + delegateExecuteRegPath, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + NS_ENSURE_SUCCESS(rv, rv); + rv = chkKey->ReadStringValue(u""_ns, handlerCommand); + NS_ENSURE_SUCCESS(rv, rv); + } + } + } + + // XXX FIXME: If this fails, the UI will display the full command + // string. + // There are some rare cases this can happen - ["url.dll" -foo] + // for example won't resolve correctly to the system dir. The + // subsequent launch of the helper app will work though. + nsCOMPtr<nsILocalFileWin> lf = new nsLocalFile(); + rv = lf->InitWithCommandLine(handlerCommand); + NS_ENSURE_SUCCESS(rv, rv); + lf.forget(aDefaultApplication); + + wchar_t friendlyName[1024]; + DWORD friendlyNameSize = 1024; + HRESULT hr = AssocQueryString(ASSOCF_NONE, ASSOCSTR_FRIENDLYAPPNAME, + PromiseFlatString(aAppInfo).get(), NULL, + friendlyName, &friendlyNameSize); + if (SUCCEEDED(hr) && friendlyNameSize > 1) { + aDefaultDescription.Assign(friendlyName, friendlyNameSize - 1); + } + + return NS_OK; +} + +already_AddRefed<nsMIMEInfoWin> nsOSHelperAppService::GetByExtension( + const nsString& aFileExt, const char* aTypeHint) { + if (aFileExt.IsEmpty()) return nullptr; + + // Determine the mime type. + nsAutoCString typeToUse; + if (aTypeHint && *aTypeHint) { + typeToUse.Assign(aTypeHint); + } else if (!GetMIMETypeFromOSForExtension(NS_ConvertUTF16toUTF8(aFileExt), + typeToUse)) { + return nullptr; + } + + RefPtr<nsMIMEInfoWin> mimeInfo = new nsMIMEInfoWin(typeToUse); + + // Our extension APIs expect extensions without the '.', so normalize: + uint32_t dotlessIndex = aFileExt.First() != char16_t('.') ? 0 : 1; + nsAutoCString lowerFileExt = + NS_ConvertUTF16toUTF8(Substring(aFileExt, dotlessIndex)); + ToLowerCase(lowerFileExt); + mimeInfo->AppendExtension(lowerFileExt); + mimeInfo->SetPreferredAction(nsIMIMEInfo::saveToDisk); + + if (NS_FAILED(InternalSetDefaultsOnMIME(mimeInfo))) { + return nullptr; + } + + return mimeInfo.forget(); +} + +nsresult nsOSHelperAppService::InternalSetDefaultsOnMIME( + nsMIMEInfoWin* aMIMEInfo) { + NS_ENSURE_ARG(aMIMEInfo); + + nsAutoCString primaryExt; + aMIMEInfo->GetPrimaryExtension(primaryExt); + + if (primaryExt.IsEmpty()) { + return NS_ERROR_FAILURE; + } + + // windows registry assumes your file extension is going to include the '.', + // but our APIs don't have it, so add it: + nsAutoString assocType = NS_ConvertUTF8toUTF16(primaryExt); + if (assocType.First() != char16_t('.')) { + assocType.Insert(char16_t('.'), 0); + } + + nsAutoString appInfo; + bool found; + + // Retrieve the default application for this extension + NS_ENSURE_TRUE(mAppAssoc, NS_ERROR_NOT_AVAILABLE); + wchar_t* pResult = nullptr; + HRESULT hr = mAppAssoc->QueryCurrentDefault(assocType.get(), AT_FILEEXTENSION, + AL_EFFECTIVE, &pResult); + if (SUCCEEDED(hr)) { + found = true; + appInfo.Assign(pResult); + CoTaskMemFree(pResult); + } else { + found = false; + } + + // Bug 358297 - ignore the default handler, force the user to choose app + if (appInfo.EqualsLiteral("XPSViewer.Document")) found = false; + + if (!found) { + return NS_ERROR_NOT_AVAILABLE; + } + + // Get other nsIMIMEInfo fields from registry, if possible. + nsAutoString defaultDescription; + nsCOMPtr<nsIFile> defaultApplication; + + if (NS_FAILED(GetDefaultAppInfo(appInfo, defaultDescription, + getter_AddRefs(defaultApplication)))) { + return NS_ERROR_NOT_AVAILABLE; + } + + aMIMEInfo->SetDefaultDescription(defaultDescription); + aMIMEInfo->SetDefaultApplicationHandler(defaultApplication); + + // Grab the general description + GetMIMEInfoFromRegistry(appInfo, aMIMEInfo); + return NS_OK; +} + +NS_IMETHODIMP +nsOSHelperAppService::GetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aFileExt, + bool* aFound, nsIMIMEInfo** aMIMEInfo) { + *aFound = false; + + const nsCString& flatType = PromiseFlatCString(aMIMEType); + nsAutoString fileExtension; + CopyUTF8toUTF16(aFileExt, fileExtension); + + /* XXX The octet-stream check is a gross hack to wallpaper over the most + * common Win32 extension issues caused by the fix for bug 116938. See bug + * 120327, comment 271 for why this is needed. Not even sure we + * want to remove this once we have fixed all this stuff to work + * right; any info we get from the OS on this type is pretty much + * useless.... + */ + bool haveMeaningfulMimeType = + !aMIMEType.IsEmpty() && + !aMIMEType.LowerCaseEqualsLiteral(APPLICATION_OCTET_STREAM); + LOG("Extension lookup on '%S' with mimetype '%s'%s\n", + static_cast<const wchar_t*>(fileExtension.get()), flatType.get(), + haveMeaningfulMimeType ? " (treated as meaningful)" : ""); + + RefPtr<nsMIMEInfoWin> mi; + + // We should have *something* to go on here. + nsAutoString extensionFromMimeType; + if (haveMeaningfulMimeType) { + GetExtensionFromWindowsMimeDatabase(aMIMEType, extensionFromMimeType); + } + if (fileExtension.IsEmpty() && extensionFromMimeType.IsEmpty()) { + // Without an extension from the mimetype or the file, we can't + // do anything here. + mi = new nsMIMEInfoWin(flatType.get()); + mi.forget(aMIMEInfo); + return NS_OK; + } + + // Either fileExtension or extensionFromMimeType must now be non-empty. + + *aFound = true; + + // On Windows, we prefer the file extension for lookups over the mimetype, + // because that's how windows does things. + // If we have no file extension or it doesn't match the mimetype, use the + // mime type's default file extension instead. + bool usedMimeTypeExtensionForLookup = false; + if (fileExtension.IsEmpty() || + (!extensionFromMimeType.IsEmpty() && + !typeFromExtEquals(fileExtension.get(), flatType.get()))) { + usedMimeTypeExtensionForLookup = true; + fileExtension = extensionFromMimeType; + LOG("Now using '%s' mimetype's default file extension '%S' for lookup\n", + flatType.get(), static_cast<const wchar_t*>(fileExtension.get())); + } + + // If we have an extension, use it for lookup: + mi = GetByExtension(fileExtension, flatType.get()); + LOG("Extension lookup on '%S' found: 0x%p\n", + static_cast<const wchar_t*>(fileExtension.get()), mi.get()); + + if (mi) { + bool hasDefault = false; + mi->GetHasDefaultHandler(&hasDefault); + // If we don't find a default handler description, see if we can find one + // using the mimetype. + if (!hasDefault && !usedMimeTypeExtensionForLookup) { + RefPtr<nsMIMEInfoWin> miFromMimeType = + GetByExtension(extensionFromMimeType, flatType.get()); + LOG("Mime-based ext. lookup for '%S' found 0x%p\n", + static_cast<const wchar_t*>(extensionFromMimeType.get()), + miFromMimeType.get()); + if (miFromMimeType) { + nsAutoString desc; + miFromMimeType->GetDefaultDescription(desc); + mi->SetDefaultDescription(desc); + } + } + mi.forget(aMIMEInfo); + return NS_OK; + } + + // The extension didn't work. Try the extension from the mimetype if + // different: + if (!extensionFromMimeType.IsEmpty() && !usedMimeTypeExtensionForLookup) { + mi = GetByExtension(extensionFromMimeType, flatType.get()); + LOG("Mime-based ext. lookup for '%S' found 0x%p\n", + static_cast<const wchar_t*>(extensionFromMimeType.get()), mi.get()); + } + if (mi) { + mi.forget(aMIMEInfo); + return NS_OK; + } + // This didn't work either, so just return an empty dummy mimeinfo. + *aFound = false; + mi = new nsMIMEInfoWin(flatType.get()); + // If we didn't resort to the mime type's extension, we must have had a + // valid extension, so stick its lowercase version on the mime info. + if (!usedMimeTypeExtensionForLookup) { + nsAutoCString lowerFileExt; + ToLowerCase(aFileExt, lowerFileExt); + mi->AppendExtension(lowerFileExt); + } + mi.forget(aMIMEInfo); + return NS_OK; +} + +NS_IMETHODIMP +nsOSHelperAppService::UpdateDefaultAppInfo(nsIMIMEInfo* aMIMEInfo) { + InternalSetDefaultsOnMIME(static_cast<nsMIMEInfoWin*>(aMIMEInfo)); + return NS_OK; +} + +NS_IMETHODIMP +nsOSHelperAppService::GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval) { + NS_ASSERTION(!aScheme.IsEmpty(), "No scheme was specified!"); + + nsresult rv = + OSProtocolHandlerExists(nsPromiseFlatCString(aScheme).get(), found); + if (NS_FAILED(rv)) return rv; + + nsMIMEInfoWin* handlerInfo = + new nsMIMEInfoWin(aScheme, nsMIMEInfoBase::eProtocolInfo); + NS_ENSURE_TRUE(handlerInfo, NS_ERROR_OUT_OF_MEMORY); + NS_ADDREF(*_retval = handlerInfo); + + if (!*found) { + // Code that calls this requires an object regardless if the OS has + // something for us, so we return the empty object. + return NS_OK; + } + + nsAutoString desc; + GetApplicationDescription(aScheme, desc); + handlerInfo->SetDefaultDescription(desc); + + return NS_OK; +} + +bool nsOSHelperAppService::GetMIMETypeFromOSForExtension( + const nsACString& aExtension, nsACString& aMIMEType) { + if (aExtension.IsEmpty()) return false; + + // windows registry assumes your file extension is going to include the '.'. + // so make sure it's there... + nsAutoString fileExtToUse; + if (aExtension.First() != '.') fileExtToUse = char16_t('.'); + + AppendUTF8toUTF16(aExtension, fileExtToUse); + + // Try to get an entry from the windows registry. + nsCOMPtr<nsIWindowsRegKey> regKey = + do_CreateInstance("@mozilla.org/windows-registry-key;1"); + if (!regKey) return false; + + nsresult rv = + regKey->Open(nsIWindowsRegKey::ROOT_KEY_CLASSES_ROOT, fileExtToUse, + nsIWindowsRegKey::ACCESS_QUERY_VALUE); + if (NS_FAILED(rv)) return false; + + nsAutoString mimeType; + if (NS_FAILED(regKey->ReadStringValue(u"Content Type"_ns, mimeType)) || + mimeType.IsEmpty()) { + return false; + } + // Content-Type is always in ASCII + aMIMEType.Truncate(); + LossyAppendUTF16toASCII(mimeType, aMIMEType); + return true; +} diff --git a/uriloader/exthandler/win/nsOSHelperAppService.h b/uriloader/exthandler/win/nsOSHelperAppService.h new file mode 100644 index 0000000000..ff7c597217 --- /dev/null +++ b/uriloader/exthandler/win/nsOSHelperAppService.h @@ -0,0 +1,76 @@ +/* -*- Mode: C++; tab-width: 3; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * 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/. */ + +#ifndef nsOSHelperAppService_h__ +#define nsOSHelperAppService_h__ + +// The OS helper app service is a subclass of nsExternalHelperAppService and is +// implemented on each platform. It contains platform specific code for finding +// helper applications for a given mime type in addition to launching those +// applications. + +#include "nsExternalHelperAppService.h" +#include "nsCExternalHandlerService.h" +#include "nsCOMPtr.h" +#include <windows.h> +#include <shlobj.h> + +class nsMIMEInfoWin; +class nsIMIMEInfo; + +class nsOSHelperAppService : public nsExternalHelperAppService { + public: + nsOSHelperAppService(); + virtual ~nsOSHelperAppService(); + + // override nsIExternalProtocolService methods + NS_IMETHOD OSProtocolHandlerExists(const char* aProtocolScheme, + bool* aHandlerExists) override; + nsresult LoadUriInternal(nsIURI* aURL); + NS_IMETHOD GetApplicationDescription(const nsACString& aScheme, + nsAString& _retval) override; + + NS_IMETHOD IsCurrentAppOSDefaultForProtocol(const nsACString& aScheme, + bool* _retval) override; + + // method overrides for windows registry look up steps.... + NS_IMETHOD GetMIMEInfoFromOS(const nsACString& aMIMEType, + const nsACString& aFileExt, bool* aFound, + nsIMIMEInfo** aMIMEInfo) override; + NS_IMETHOD UpdateDefaultAppInfo(nsIMIMEInfo* aMIMEInfo) override; + NS_IMETHOD GetProtocolHandlerInfoFromOS(const nsACString& aScheme, + bool* found, + nsIHandlerInfo** _retval); + virtual bool GetMIMETypeFromOSForExtension(const nsACString& aExtension, + nsACString& aMIMEType) override; + + /** Get the string value of a registry value and store it in result. + * @return true on success, false on failure + */ + static bool GetValueString(HKEY hKey, const char16_t* pValueName, + nsAString& result); + + protected: + nsresult GetDefaultAppInfo(const nsAString& aTypeName, + nsAString& aDefaultDescription, + nsIFile** aDefaultApplication); + // Lookup a mime info by extension, using an optional type hint + already_AddRefed<nsMIMEInfoWin> GetByExtension( + const nsString& aFileExt, const char* aTypeHint = nullptr); + nsresult InternalSetDefaultsOnMIME(nsMIMEInfoWin* aMIMEInfo); + nsresult FindOSMimeInfoForType(const char* aMimeContentType, nsIURI* aURI, + char** aFileExtension, + nsIMIMEInfo** aMIMEInfo); + + static nsresult GetMIMEInfoFromRegistry(const nsString& fileType, + nsIMIMEInfo* pInfo); + /// Looks up the type for the extension aExt and compares it to aType + static bool typeFromExtEquals(const char16_t* aExt, const char* aType); + + private: + IApplicationAssociationRegistration* mAppAssoc; +}; + +#endif // nsOSHelperAppService_h__ |