From dbf2931f0e7fd28acfa3c60940265303856ed0b0 Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Tue, 27 Oct 2020 10:49:25 -0700 Subject: [PATCH] feat: Electron Fuses, package time feature toggles (#24241) * feat: add new 'fuses' feature for package-time build-flag style feature control * feat: put ENABLE_RUN_AS_NODE behind a fuse as well * chore: address PR feedback * build: move FUSE_EXPORT to headers * build: use hex codes for kFuseWire char[] * docs: add fuse wire documentation * chore: update fuses.json info * Apply suggestions from code review Co-authored-by: Jeremy Rose * chore: add link to fuse schema * Update shell/app/electron_library_main.mm Co-authored-by: Jeremy Rose Co-authored-by: Jeremy Rose --- BUILD.gn | 16 +++++ build/fuses/build.py | 101 +++++++++++++++++++++++++++++ build/fuses/fuses.json | 6 ++ docs/tutorial/fuses.md | 54 +++++++++++++++ shell/app/electron_library_main.mm | 6 ++ shell/app/electron_main.cc | 10 +-- shell/common/api/features.cc | 3 +- shell/common/crash_keys.cc | 4 ++ 8 files changed, 195 insertions(+), 5 deletions(-) create mode 100755 build/fuses/build.py create mode 100644 build/fuses/fuses.json create mode 100644 docs/tutorial/fuses.md diff --git a/BUILD.gn b/BUILD.gn index 819a665e50..9714d0f0b9 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -303,6 +303,19 @@ templated_file("electron_version_header") { args_files = get_target_outputs(":electron_version_args") } +action("electron_fuses") { + script = "build/fuses/build.py" + + inputs = [ "build/fuses/fuses.json" ] + + outputs = [ + "$target_gen_dir/fuses.h", + "$target_gen_dir/fuses.cc", + ] + + args = rebase_path(outputs) +} + source_set("electron_lib") { configs += [ "//v8:external_startup_data" ] configs += [ "//third_party/electron_node:node_internals" ] @@ -313,6 +326,7 @@ source_set("electron_lib") { ] deps = [ + ":electron_fuses", ":electron_js2c", ":electron_version_header", ":manifests", @@ -657,6 +671,8 @@ source_set("electron_lib") { "shell/browser/electron_pdf_web_contents_helper_client.h", ] } + + sources += get_target_outputs(":electron_fuses") } electron_paks("packed_resources") { diff --git a/build/fuses/build.py b/build/fuses/build.py new file mode 100755 index 0000000000..f17c08fdb8 --- /dev/null +++ b/build/fuses/build.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +import json +import os +import sys + +dir_path = os.path.dirname(os.path.realpath(__file__)) + +SENTINEL = "dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX" + +TEMPLATE_H = """ +#ifndef ELECTRON_FUSES_H_ +#define ELECTRON_FUSES_H_ + +#if defined(WIN32) +#define FUSE_EXPORT __declspec(dllexport) +#else +#define FUSE_EXPORT __attribute__((visibility("default"))) +#endif + +namespace electron { + +namespace fuses { + +extern const volatile char kFuseWire[]; + +{getters} + +} // namespace fuses + +} // namespace electron + +#endif // ELECTRON_FUSES_H_ +""" + +TEMPLATE_CC = """ +#include "electron/fuses.h" + +namespace electron { + +namespace fuses { + +const volatile char kFuseWire[] = { /* sentinel */ {sentinel}, /* fuse_version */ {fuse_version}, /* fuse_wire_length */ {fuse_wire_length}, /* fuse_wire */ {initial_config}}; + +{getters} + +} + +} +""" + +with open(os.path.join(dir_path, "fuses.json"), 'r') as f: + fuse_defaults = json.load(f) + +fuse_version = fuse_defaults['_version'] +del fuse_defaults['_version'] +del fuse_defaults['_schema'] +del fuse_defaults['_comment'] + +if fuse_version >= pow(2, 8): + raise Exception("Fuse version can not exceed one byte in size") + +fuses = fuse_defaults.keys() + +initial_config = "" +getters_h = "" +getters_cc = "" +index = len(SENTINEL) + 1 +for fuse in fuses: + index += 1 + initial_config += fuse_defaults[fuse] + name = ''.join(word.title() for word in fuse.split('_')) + getters_h += "FUSE_EXPORT bool Is{name}Enabled();\n".replace("{name}", name) + getters_cc += """ +bool Is{name}Enabled() { + return kFuseWire[{index}] == '1'; +} +""".replace("{name}", name).replace("{index}", str(index)) + +def c_hex(n): + s = hex(n)[2:] + return "0x" + s.rjust(2, '0') + +def hex_arr(s): + arr = [] + for char in s: + arr.append(c_hex(ord(char))) + return ",".join(arr) + +header = TEMPLATE_H.replace("{getters}", getters_h.strip()) +impl = TEMPLATE_CC.replace("{sentinel}", hex_arr(SENTINEL)) +impl = impl.replace("{fuse_version}", c_hex(fuse_version)) +impl = impl.replace("{fuse_wire_length}", c_hex(len(fuses))) +impl = impl.replace("{initial_config}", hex_arr(initial_config)) +impl = impl.replace("{getters}", getters_cc.strip()) + +with open(sys.argv[1], 'w') as f: + f.write(header) + +with open(sys.argv[2], 'w') as f: + f.write(impl) diff --git a/build/fuses/fuses.json b/build/fuses/fuses.json new file mode 100644 index 0000000000..9ff211adb4 --- /dev/null +++ b/build/fuses/fuses.json @@ -0,0 +1,6 @@ +{ + "_comment": "Modifying the fuse schema in any breaking way should result in the _version prop being incremented. NEVER remove a fuse or change its meaning, instead mark it as removed with 'r'", + "_schema": "0 == off, 1 == on, r == removed fuse", + "_version": 1, + "run_as_node": "1" +} diff --git a/docs/tutorial/fuses.md b/docs/tutorial/fuses.md new file mode 100644 index 0000000000..76141ca314 --- /dev/null +++ b/docs/tutorial/fuses.md @@ -0,0 +1,54 @@ +# Electron Fuses + +> Package time feature toggles + +## What are fuses? + +For a subset of Electron functionality it makes sense to disable certain features for an entire application. For example, 99% of apps don't make use of `ELECTRON_RUN_AS_NODE`, these applications want to be able to ship a binary that is incapable of using that feature. We also don't want Electron consumers building Electron from source as that is both a massive technical challenge and has a high cost of both time and money. + +Fuses are the solution to this problem, at a high level they are "magic bits" in the Electron binary that can be flipped when packaging your Electron app to enable / disable certain features / restrictions. Because they are flipped at package time before you code sign your app the OS becomes responsible for ensuring those bits aren't flipped back via OS level code signing validation (Gatekeeper / App Locker). + +## How do I flip the fuses? + +### The easy way + +We've made a handy module `@electron/fuses` to make flipping these fuses easy. Check out the README of that module for more details on usage and potential error cases. + +```js +require('@electron/fuses').flipFuses( + // Path to electron + require('electron'), + // Fuses to flip + { + runAsNode: false + } +) +``` + +### The hard way + +#### Quick Glossary + +* **Fuse Wire**: A sequence of bytes in the Electron binary used to control the fuses +* **Sentinel**: A static known sequence of bytes you can use to locate the fuse wire +* **Fuse Schema**: The format / allowed values for the fuse wire + +Manually flipping fuses requires editing the Electron binary and modifying the fuse wire to be the sequence of bytes that represent the state of the fuses you want. + +Somewhere in the Electron binary there will be a sequence of bytes that look like this: + +```text +| ...binary | sentinel_bytes | fuse_version | fuse_wire_length | fuse_wire | ...binary | +``` + +* `sentinel_bytes` is always this exact string `dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX` +* `fuse_version` is a single byte whose unsigned integer value represents the version of the fuse schema +* `fuse_wire_length` is a single byte whose unsigned integer value represents the number of fuses in the following fuse wire +* `fuse_wire` is a sequence of N bytes, each byte represents a single fuse and its state. + * "0" (0x30) indicates the fuse is disabled + * "1" (0x31) indicates the fuse is enabled + * "r" (0x72) indicates the fuse has been removed and changing the byte to either 1 or 0 will have no effect. + +To flip a fuse you find its position in the fuse wire and change it to "0" or "1" depending on the state you'd like. + +You can view the current schema [here](https://github.com/electron/electron/blob/master/build/fuses/fuses.json). diff --git a/shell/app/electron_library_main.mm b/shell/app/electron_library_main.mm index 58baed01c7..4f4e9f0583 100644 --- a/shell/app/electron_library_main.mm +++ b/shell/app/electron_library_main.mm @@ -9,6 +9,7 @@ #include "base/mac/bundle_locations.h" #include "base/mac/scoped_nsautorelease_pool.h" #include "content/public/app/content_main.h" +#include "electron/fuses.h" #include "shell/app/electron_main_delegate.h" #include "shell/app/node_main.h" #include "shell/common/electron_command_line.h" @@ -25,6 +26,11 @@ int ElectronMain(int argc, char* argv[]) { #if BUILDFLAG(ENABLE_RUN_AS_NODE) int ElectronInitializeICUandStartNode(int argc, char* argv[]) { + if (!electron::fuses::IsRunAsNodeEnabled()) { + CHECK(false) << "run_as_node fuse is disabled"; + return 1; + } + base::AtExitManager atexit_manager; base::mac::ScopedNSAutoreleasePool pool; base::mac::SetOverrideFrameworkBundlePath( diff --git a/shell/app/electron_main.cc b/shell/app/electron_main.cc index 44d14adcb8..0d360903ea 100644 --- a/shell/app/electron_main.cc +++ b/shell/app/electron_main.cc @@ -47,6 +47,7 @@ #include "base/at_exit.h" #include "base/i18n/icu_util.h" #include "electron/buildflags/buildflags.h" +#include "electron/fuses.h" #include "shell/app/node_main.h" #include "shell/common/electron_command_line.h" #include "shell/common/electron_constants.h" @@ -128,7 +129,8 @@ int APIENTRY wWinMain(HINSTANCE instance, HINSTANCE, wchar_t* cmd, int) { #endif #if BUILDFLAG(ENABLE_RUN_AS_NODE) - bool run_as_node = IsEnvSet(electron::kRunAsNode); + bool run_as_node = + electron::fuses::IsRunAsNodeEnabled() && IsEnvSet(electron::kRunAsNode); #else bool run_as_node = false; #endif @@ -141,7 +143,7 @@ int APIENTRY wWinMain(HINSTANCE instance, HINSTANCE, wchar_t* cmd, int) { std::transform(arguments.argv, arguments.argv + arguments.argc, argv.begin(), [](auto& a) { return _strdup(base::WideToUTF8(a).c_str()); }); #if BUILDFLAG(ENABLE_RUN_AS_NODE) - if (run_as_node) { + if (electron::fuses::IsRunAsNodeEnabled() && run_as_node) { base::AtExitManager atexit_manager; base::i18n::InitializeICU(); auto ret = electron::NodeMain(argv.size(), argv.data()); @@ -216,7 +218,7 @@ int main(int argc, char* argv[]) { FixStdioStreams(); #if BUILDFLAG(ENABLE_RUN_AS_NODE) - if (IsEnvSet(electron::kRunAsNode)) { + if (electron::fuses::IsRunAsNodeEnabled() && IsEnvSet(electron::kRunAsNode)) { base::i18n::InitializeICU(); base::AtExitManager atexit_manager; return electron::NodeMain(argc, argv); @@ -237,7 +239,7 @@ int main(int argc, char* argv[]) { FixStdioStreams(); #if BUILDFLAG(ENABLE_RUN_AS_NODE) - if (IsEnvSet(electron::kRunAsNode)) { + if (electron::fuses::IsRunAsNodeEnabled() && IsEnvSet(electron::kRunAsNode)) { return ElectronInitializeICUandStartNode(argc, argv); } #endif diff --git a/shell/common/api/features.cc b/shell/common/api/features.cc index 28808a72fb..d040ef3a23 100644 --- a/shell/common/api/features.cc +++ b/shell/common/api/features.cc @@ -3,6 +3,7 @@ // found in the LICENSE file. #include "electron/buildflags/buildflags.h" +#include "electron/fuses.h" #include "printing/buildflags/buildflags.h" #include "shell/common/gin_helper/dictionary.h" #include "shell/common/node_includes.h" @@ -30,7 +31,7 @@ bool IsPDFViewerEnabled() { } bool IsRunAsNodeEnabled() { - return BUILDFLAG(ENABLE_RUN_AS_NODE); + return electron::fuses::IsRunAsNodeEnabled() && BUILDFLAG(ENABLE_RUN_AS_NODE); } bool IsFakeLocationProviderEnabled() { diff --git a/shell/common/crash_keys.cc b/shell/common/crash_keys.cc index f88dcddfca..2cfbd8f22d 100644 --- a/shell/common/crash_keys.cc +++ b/shell/common/crash_keys.cc @@ -14,6 +14,7 @@ #include "base/strings/string_split.h" #include "components/crash/core/common/crash_key.h" #include "content/public/common/content_switches.h" +#include "electron/fuses.h" #include "shell/common/electron_constants.h" #include "shell/common/options_switches.h" #include "third_party/crashpad/crashpad/client/annotation.h" @@ -100,6 +101,9 @@ void GetCrashKeys(std::map* keys) { namespace { bool IsRunningAsNode() { #if BUILDFLAG(ENABLE_RUN_AS_NODE) + if (!electron::fuses::IsRunAsNodeEnabled()) + return false; + return base::Environment::Create()->HasVar(electron::kRunAsNode); #else return false;