diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index e1d227a02e0d285..db767b05f562af4 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -128,7 +128,8 @@ The configuration currently reads the following top-level fields: ```json { "main": "/path/to/bundled/script.js", - "output": "/path/to/write/the/generated/blob.blob" + "output": "/path/to/write/the/generated/blob.blob", + "disableExperimentalSEAWarning": true / false // Default: false } ``` diff --git a/lib/internal/main/embedding.js b/lib/internal/main/embedding.js index aa3f06cca10f995..313ecb2bec7bf49 100644 --- a/lib/internal/main/embedding.js +++ b/lib/internal/main/embedding.js @@ -3,7 +3,7 @@ const { prepareMainThreadExecution, markBootstrapComplete, } = require('internal/process/pre_execution'); -const { isSea } = internalBinding('sea'); +const { isExperimentalSeaWarningDisabled, isSea } = internalBinding('sea'); const { emitExperimentalWarning } = require('internal/util'); const { embedderRequire, embedderRunCjs } = require('internal/util/embedding'); const { getEmbedderEntryFunction } = internalBinding('mksnapshot'); @@ -11,7 +11,7 @@ const { getEmbedderEntryFunction } = internalBinding('mksnapshot'); prepareMainThreadExecution(false, true); markBootstrapComplete(); -if (isSea()) { +if (isSea() && !isExperimentalSeaWarningDisabled()) { emitExperimentalWarning('Single executable application'); } diff --git a/src/json_parser.cc b/src/json_parser.cc index 4778ea2960361a7..bf4df2ba65e9eaf 100644 --- a/src/json_parser.cc +++ b/src/json_parser.cc @@ -58,7 +58,7 @@ bool JSONParser::Parse(const std::string& content) { return true; } -std::optional JSONParser::GetTopLevelField( +std::optional JSONParser::GetTopLevelStringField( const std::string& field) { Isolate* isolate = isolate_.get(); Local context = context_.Get(isolate); @@ -77,4 +77,26 @@ std::optional JSONParser::GetTopLevelField( return utf8_value.ToString(); } +std::optional JSONParser::GetTopLevelBoolField(const std::string& field) { + Isolate* isolate = isolate_.get(); + Local context = context_.Get(isolate); + Local content_object = content_.Get(isolate); + Local value; + // It's not a real script, so don't print the source line. + errors::PrinterTryCatch bootstrapCatch( + isolate, errors::PrinterTryCatch::kDontPrintSourceLine); + if (!content_object + ->Has(context, OneByteString(isolate, field.c_str(), field.length())) + .FromMaybe(false)) { + return false; + } + if (!content_object + ->Get(context, OneByteString(isolate, field.c_str(), field.length())) + .ToLocal(&value) || + !value->IsBoolean()) { + return {}; + } + return value->BooleanValue(isolate); +} + } // namespace node diff --git a/src/json_parser.h b/src/json_parser.h index 41fe77929882c96..6f38c4846b80270 100644 --- a/src/json_parser.h +++ b/src/json_parser.h @@ -18,7 +18,8 @@ class JSONParser { JSONParser(); ~JSONParser() {} bool Parse(const std::string& content); - std::optional GetTopLevelField(const std::string& field); + std::optional GetTopLevelStringField(const std::string& field); + std::optional GetTopLevelBoolField(const std::string& field); private: // We might want a lighter-weight JSON parser for this use case. But for now diff --git a/src/node_sea.cc b/src/node_sea.cc index 5936dc7de9a0d27..4ebdeddc7871e3e 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -33,15 +33,21 @@ using v8::Value; namespace node { namespace sea { +namespace { // A special number that will appear at the beginning of the single executable // preparation blobs ready to be injected into the binary. We use this to check // that the data given to us are intended for building single executable // applications. -static const uint32_t kMagic = 0x143da20; +const uint32_t kMagic = 0x143da20; -std::string_view FindSingleExecutableCode() { +struct SeaResource { + std::string_view code; + bool disable_experimental_sea_warning; +}; + +SeaResource FindSingleExecutableResource() { CHECK(IsSingleExecutable()); - static const std::string_view sea_code = []() -> std::string_view { + static const SeaResource sea_resource = []() -> SeaResource { size_t size; #ifdef __APPLE__ postject_options options; @@ -55,10 +61,22 @@ std::string_view FindSingleExecutableCode() { #endif uint32_t first_word = reinterpret_cast(code)[0]; CHECK_EQ(first_word, kMagic); + bool disable_experimental_sea_warning = + reinterpret_cast(code + sizeof(first_word))[0]; // TODO(joyeecheung): do more checks here e.g. matching the versions. - return {code + sizeof(first_word), size - sizeof(first_word)}; + return { + {code + sizeof(first_word) + sizeof(disable_experimental_sea_warning), + size - sizeof(first_word) - sizeof(disable_experimental_sea_warning)}, + disable_experimental_sea_warning}; }(); - return sea_code; + return sea_resource; +} + +} // namespace + +std::string_view FindSingleExecutableCode() { + SeaResource sea_resource = FindSingleExecutableResource(); + return sea_resource.code; } bool IsSingleExecutable() { @@ -69,6 +87,11 @@ void IsSingleExecutable(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(IsSingleExecutable()); } +void IsExperimentalSeaWarningDisabled(const FunctionCallbackInfo& args) { + SeaResource sea_resource = FindSingleExecutableResource(); + args.GetReturnValue().Set(sea_resource.disable_experimental_sea_warning); +} + std::tuple FixupArgsForSEA(int argc, char** argv) { // Repeats argv[0] at position 1 on argv as a replacement for the missing // entry point file path. @@ -90,6 +113,7 @@ namespace { struct SeaConfig { std::string main_path; std::string output_path; + bool disable_experimental_sea_warning; }; std::optional ParseSingleExecutableConfig( @@ -112,7 +136,8 @@ std::optional ParseSingleExecutableConfig( return std::nullopt; } - result.main_path = parser.GetTopLevelField("main").value_or(std::string()); + result.main_path = + parser.GetTopLevelStringField("main").value_or(std::string()); if (result.main_path.empty()) { FPrintF(stderr, "\"main\" field of %s is not a non-empty string\n", @@ -121,7 +146,7 @@ std::optional ParseSingleExecutableConfig( } result.output_path = - parser.GetTopLevelField("output").value_or(std::string()); + parser.GetTopLevelStringField("output").value_or(std::string()); if (result.output_path.empty()) { FPrintF(stderr, "\"output\" field of %s is not a non-empty string\n", @@ -129,6 +154,17 @@ std::optional ParseSingleExecutableConfig( return std::nullopt; } + std::optional disable_experimental_sea_warning = + parser.GetTopLevelBoolField("disableExperimentalSEAWarning"); + if (!disable_experimental_sea_warning.has_value()) { + FPrintF(stderr, + "\"disableExperimentalSEAWarning\" field of %s is not a Boolean\n", + config_path); + return std::nullopt; + } + result.disable_experimental_sea_warning = + disable_experimental_sea_warning.value(); + return result; } @@ -144,9 +180,17 @@ bool GenerateSingleExecutableBlob(const SeaConfig& config) { std::vector sink; // TODO(joyeecheung): reuse the SnapshotSerializerDeserializer for this. - sink.reserve(sizeof(kMagic) + main_script.size()); + sink.reserve(sizeof(kMagic) + + sizeof(config.disable_experimental_sea_warning) + + main_script.size()); const char* pos = reinterpret_cast(&kMagic); sink.insert(sink.end(), pos, pos + sizeof(kMagic)); + const char* disable_experimental_sea_warning = + reinterpret_cast(&config.disable_experimental_sea_warning); + sink.insert(sink.end(), + disable_experimental_sea_warning, + disable_experimental_sea_warning + + sizeof(config.disable_experimental_sea_warning)); sink.insert( sink.end(), main_script.data(), main_script.data() + main_script.size()); @@ -182,10 +226,15 @@ void Initialize(Local target, Local context, void* priv) { SetMethod(context, target, "isSea", IsSingleExecutable); + SetMethod(context, + target, + "isExperimentalSeaWarningDisabled", + IsExperimentalSeaWarningDisabled); } void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(IsSingleExecutable); + registry->Register(IsExperimentalSeaWarningDisabled); } } // namespace sea diff --git a/test/fixtures/sea.js b/test/fixtures/sea.js index efdc32708b98980..068e4f1a8aa1c72 100644 --- a/test/fixtures/sea.js +++ b/test/fixtures/sea.js @@ -3,11 +3,15 @@ const createdRequire = createRequire(__filename); // Although, require('../common') works locally, that couldn't be used here // because we set NODE_TEST_DIR=/Users/iojs/node-tmp on Jenkins CI. -const { expectWarning } = createdRequire(process.env.COMMON_DIRECTORY); - -expectWarning('ExperimentalWarning', - 'Single executable application is an experimental feature and ' + - 'might change at any time'); +const { expectWarning, mustNotCall } = createdRequire(process.env.COMMON_DIRECTORY); + +if (createdRequire('./sea-config.json').disableExperimentalSEAWarning) { + process.on('warning', mustNotCall()); +} else { + expectWarning('ExperimentalWarning', + 'Single executable application is an experimental feature and ' + + 'might change at any time'); +} const { deepStrictEqual, strictEqual, throws } = require('assert'); const { dirname } = require('path'); diff --git a/test/parallel/test-single-executable-application-disable-experimental-sea-warning.js b/test/parallel/test-single-executable-application-disable-experimental-sea-warning.js new file mode 100644 index 000000000000000..6948983146f24ad --- /dev/null +++ b/test/parallel/test-single-executable-application-disable-experimental-sea-warning.js @@ -0,0 +1,125 @@ +'use strict'; +const common = require('../common'); + +// This tests the creation of a single executable application. + +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); +const { copyFileSync, readFileSync, writeFileSync, existsSync } = require('fs'); +const { execFileSync } = require('child_process'); +const { join } = require('path'); +const { strictEqual } = require('assert'); +const assert = require('assert'); + +if (!process.config.variables.single_executable_application) + common.skip('Single Executable Application support has been disabled.'); + +if (!['darwin', 'win32', 'linux'].includes(process.platform)) + common.skip(`Unsupported platform ${process.platform}.`); + +if (process.platform === 'linux' && process.config.variables.is_debug === 1) + common.skip('Running the resultant binary fails with `Couldn\'t read target executable"`.'); + +if (process.config.variables.node_shared) + common.skip('Running the resultant binary fails with ' + + '`/home/iojs/node-tmp/.tmp.2366/sea: error while loading shared libraries: ' + + 'libnode.so.112: cannot open shared object file: No such file or directory`.'); + +if (process.config.variables.icu_gyp_path === 'tools/icu/icu-system.gyp') + common.skip('Running the resultant binary fails with ' + + '`/home/iojs/node-tmp/.tmp.2379/sea: error while loading shared libraries: ' + + 'libicui18n.so.71: cannot open shared object file: No such file or directory`.'); + +if (!process.config.variables.node_use_openssl || process.config.variables.node_shared_openssl) + common.skip('Running the resultant binary fails with `Node.js is not compiled with OpenSSL crypto support`.'); + +if (process.config.variables.want_separate_host_toolset !== 0) + common.skip('Running the resultant binary fails with `Segmentation fault (core dumped)`.'); + +if (process.platform === 'linux') { + const osReleaseText = readFileSync('/etc/os-release', { encoding: 'utf-8' }); + const isAlpine = /^NAME="Alpine Linux"/m.test(osReleaseText); + if (isAlpine) common.skip('Alpine Linux is not supported.'); + + if (process.arch === 's390x') { + common.skip('On s390x, postject fails with `memory access out of bounds`.'); + } + + if (process.arch === 'ppc64') { + common.skip('On ppc64, this test times out.'); + } +} + +const inputFile = fixtures.path('sea.js'); +const requirableFile = join(tmpdir.path, 'requirable.js'); +const configFile = join(tmpdir.path, 'sea-config.json'); +const seaPrepBlob = join(tmpdir.path, 'sea-prep.blob'); +const outputFile = join(tmpdir.path, process.platform === 'win32' ? 'sea.exe' : 'sea'); + +tmpdir.refresh(); + +writeFileSync(requirableFile, ` +module.exports = { + hello: 'world', +}; +`); + +writeFileSync(configFile, ` +{ + "main": "sea.js", + "output": "sea-prep.blob", + "disableExperimentalSEAWarning": true +} +`); + +// Copy input to working directory +copyFileSync(inputFile, join(tmpdir.path, 'sea.js')); +execFileSync(process.execPath, ['--experimental-sea-config', 'sea-config.json'], { + cwd: tmpdir.path +}); + +assert(existsSync(seaPrepBlob)); + +copyFileSync(process.execPath, outputFile); +const postjectFile = fixtures.path('postject-copy', 'node_modules', 'postject', 'dist', 'cli.js'); +execFileSync(process.execPath, [ + postjectFile, + outputFile, + 'NODE_SEA_BLOB', + seaPrepBlob, + '--sentinel-fuse', 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', + ...process.platform === 'darwin' ? [ '--macho-segment-name', 'NODE_SEA' ] : [], +]); + +if (process.platform === 'darwin') { + execFileSync('codesign', [ '--sign', '-', outputFile ]); + execFileSync('codesign', [ '--verify', outputFile ]); +} else if (process.platform === 'win32') { + let signtoolFound = false; + try { + execFileSync('where', [ 'signtool' ]); + signtoolFound = true; + } catch (err) { + console.log(err.message); + } + if (signtoolFound) { + let certificatesFound = false; + try { + execFileSync('signtool', [ 'sign', '/fd', 'SHA256', outputFile ]); + certificatesFound = true; + } catch (err) { + if (!/SignTool Error: No certificates were found that met all the given criteria/.test(err)) { + throw err; + } + } + if (certificatesFound) { + execFileSync('signtool', 'verify', '/pa', 'SHA256', outputFile); + } + } +} + +const singleExecutableApplicationOutput = execFileSync( + outputFile, + [ '-a', '--b=c', 'd' ], + { env: { COMMON_DIRECTORY: join(__dirname, '..', 'common') } }); +strictEqual(singleExecutableApplicationOutput.toString(), 'Hello, world! 😊\n'); diff --git a/test/parallel/test-single-executable-application.js b/test/parallel/test-single-executable-application.js index 823f02bbf4cdc95..1cd313b3ac98087 100644 --- a/test/parallel/test-single-executable-application.js +++ b/test/parallel/test-single-executable-application.js @@ -67,7 +67,8 @@ module.exports = { writeFileSync(configFile, ` { "main": "sea.js", - "output": "sea-prep.blob" + "output": "sea-prep.blob", + "disableExperimentalSEAWarning": false } `); diff --git a/test/parallel/test-single-executable-blob-config-errors.js b/test/parallel/test-single-executable-blob-config-errors.js index 9c4013f7dc6f665..fd4a4133399ab6b 100644 --- a/test/parallel/test-single-executable-blob-config-errors.js +++ b/test/parallel/test-single-executable-blob-config-errors.js @@ -115,6 +115,30 @@ const { join } = require('path'); ); } +{ + tmpdir.refresh(); + const config = join(tmpdir.path, 'invalid-disableExperimentalSEAWarning.json'); + writeFileSync(config, ` +{ + "main": "bundle.js", + "output": "sea-prep.blob", + "disableExperimentalSEAWarning": "💥" +} + `, 'utf8'); + const child = spawnSync( + process.execPath, + ['--experimental-sea-config', config], { + cwd: tmpdir.path, + }); + const stderr = child.stderr.toString(); + assert.strictEqual(child.status, 1); + assert( + stderr.includes( + `"disableExperimentalSEAWarning" field of ${config} is not a Boolean` + ) + ); +} + { tmpdir.refresh(); const config = join(tmpdir.path, 'nonexistent-main-relative.json'); diff --git a/test/parallel/test-single-executable-blob-config.js b/test/parallel/test-single-executable-blob-config.js index 919026e97aeae79..c96bc735204ddba 100644 --- a/test/parallel/test-single-executable-blob-config.js +++ b/test/parallel/test-single-executable-blob-config.js @@ -48,3 +48,68 @@ const { join } = require('path'); assert.strictEqual(child.status, 0); assert(existsSync(output)); } + +{ + tmpdir.refresh(); + const config = join(tmpdir.path, 'no-disableExperimentalSEAWarning.json'); + const main = join(tmpdir.path, 'bundle.js'); + const output = join(tmpdir.path, 'output.blob'); + writeFileSync(main, 'console.log("hello")', 'utf-8'); + const configJson = JSON.stringify({ + main: 'bundle.js', + output: 'output.blob', + }); + writeFileSync(config, configJson, 'utf8'); + const child = spawnSync( + process.execPath, + ['--experimental-sea-config', config], { + cwd: tmpdir.path, + }); + + assert.strictEqual(child.status, 0); + assert(existsSync(output)); +} + +{ + tmpdir.refresh(); + const config = join(tmpdir.path, 'true-disableExperimentalSEAWarning.json'); + const main = join(tmpdir.path, 'bundle.js'); + const output = join(tmpdir.path, 'output.blob'); + writeFileSync(main, 'console.log("hello")', 'utf-8'); + const configJson = JSON.stringify({ + main: 'bundle.js', + output: 'output.blob', + disableExperimentalSEAWarning: true, + }); + writeFileSync(config, configJson, 'utf8'); + const child = spawnSync( + process.execPath, + ['--experimental-sea-config', config], { + cwd: tmpdir.path, + }); + + assert.strictEqual(child.status, 0); + assert(existsSync(output)); +} + +{ + tmpdir.refresh(); + const config = join(tmpdir.path, 'false-disableExperimentalSEAWarning.json'); + const main = join(tmpdir.path, 'bundle.js'); + const output = join(tmpdir.path, 'output.blob'); + writeFileSync(main, 'console.log("hello")', 'utf-8'); + const configJson = JSON.stringify({ + main: 'bundle.js', + output: 'output.blob', + disableExperimentalSEAWarning: false, + }); + writeFileSync(config, configJson, 'utf8'); + const child = spawnSync( + process.execPath, + ['--experimental-sea-config', config], { + cwd: tmpdir.path, + }); + + assert.strictEqual(child.status, 0); + assert(existsSync(output)); +}