Skip to content

Commit 7f4e853

Browse files
committed
feat: HMR robustness and additional tests
1 parent 5740bd7 commit 7f4e853

5 files changed

Lines changed: 131 additions & 36 deletions

File tree

NativeScript/runtime/DevFlags.mm

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,25 @@ bool IsHttpFetchUrlLogEnabled() {
8282
static bool s_allowRemoteModules = false;
8383
static std::vector<std::string> s_remoteModuleAllowlist;
8484

85-
// Helper to check if a URL starts with a given prefix
86-
static bool UrlStartsWith(const std::string& url, const std::string& prefix) {
87-
if (prefix.size() > url.size()) return false;
88-
return url.compare(0, prefix.size(), prefix) == 0;
85+
// Returns true when `url` is authorized by allowlist `entry`.
86+
//
87+
// This is intentionally stricter than a raw string-prefix test: after the
88+
// matched entry text, the next character in `url` must be a URL-component
89+
// boundary ('/', '?', or '#'), the URL must end exactly at the entry, or the
90+
// entry must itself end in '/'. That refuses lookalike-host and lookalike-port
91+
// bypasses — an entry of "https://cdn.example.com" must NOT authorize
92+
// "https://cdn.example.com.attacker.com/x.js" or
93+
// "https://cdn.example.com:9999/x.js". To allow a specific port, include it in
94+
// the allowlist entry (deny-by-default for anything not explicitly listed).
95+
static bool RemoteUrlMatchesAllowlistEntry(const std::string& url,
96+
const std::string& entry) {
97+
if (entry.empty()) return false;
98+
if (url.size() < entry.size()) return false;
99+
if (url.compare(0, entry.size(), entry) != 0) return false;
100+
if (url.size() == entry.size()) return true; // exact match
101+
if (entry.back() == '/') return true; // entry ended at a boundary
102+
const char next = url[entry.size()];
103+
return next == '/' || next == '?' || next == '#';
89104
}
90105

91106
void InitializeSecurityConfig() {
@@ -147,9 +162,9 @@ bool IsRemoteUrlAllowed(const std::string& url) {
147162
return true;
148163
}
149164

150-
// Check if URL matches any allowlist prefix
151-
for (const std::string& prefix : s_remoteModuleAllowlist) {
152-
if (UrlStartsWith(url, prefix)) {
165+
// Check if URL matches any allowlist entry on a URL-component boundary.
166+
for (const std::string& entry : s_remoteModuleAllowlist) {
167+
if (RemoteUrlMatchesAllowlistEntry(url, entry)) {
153168
return true;
154169
}
155170
}

NativeScript/runtime/HMRSupport.mm

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3209,6 +3209,31 @@ void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local<v8::Context> contex
32093209
InitializeHotDisposeRunner(isolate, context);
32103210
InitializeHotPruneRunner(isolate, context);
32113211
InitializeHotDeclinedHelper(isolate, context);
3212+
3213+
// Debug-only diagnostic: expose the HTTP canonical-key function to JS so
3214+
// the test harness can pin its identity behavior across cache-busters,
3215+
// boot/hmr tags, and versioned bridge endpoints. This is NOT part of the
3216+
// HMR client API surface and is never installed in release builds.
3217+
{
3218+
auto canonicalizeCb = [](const v8::FunctionCallbackInfo<v8::Value>& info) {
3219+
v8::Isolate* iso = info.GetIsolate();
3220+
if (info.Length() < 1 || !info[0]->IsString()) {
3221+
info.GetReturnValue().SetEmptyString();
3222+
return;
3223+
}
3224+
v8::String::Utf8Value u(iso, info[0]);
3225+
std::string key =
3226+
CanonicalizeHttpUrlKey(*u ? std::string(*u) : std::string());
3227+
info.GetReturnValue().Set(tns::ToV8String(iso, key.c_str()));
3228+
};
3229+
v8::Local<v8::Function> fn =
3230+
v8::Function::New(context, canonicalizeCb).ToLocalChecked();
3231+
context->Global()
3232+
->CreateDataProperty(
3233+
context, tns::ToV8String(isolate, "__nsCanonicalizeHttpUrlKey"),
3234+
fn)
3235+
.Check();
3236+
}
32123237
} catch (...) {
32133238
// Don't crash if HMR setup fails
32143239
}

NativeScript/runtime/ModuleInternalCallbacks.mm

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -409,33 +409,40 @@ static bool ShouldTraceRegistryKey(const std::string& rawKey, const std::string&
409409

410410
void SetImportMap(const std::string& json) {
411411
g_importMap.clear();
412-
// Minimal JSON parser for {"imports": {"key": "value", ...}} shape.
413-
// We avoid pulling in a full JSON library; the import map is simple flat key-value.
414-
size_t importsPos = json.find("\"imports\"");
415-
if (importsPos == std::string::npos) return;
416-
size_t braceOpen = json.find('{', importsPos + 9);
417-
if (braceOpen == std::string::npos) return;
418-
size_t braceClose = json.find('}', braceOpen + 1);
419-
if (braceClose == std::string::npos) return;
420-
421-
std::string inner = json.substr(braceOpen + 1, braceClose - braceOpen - 1);
422-
// Parse "key": "value" pairs
423-
size_t pos = 0;
424-
while (pos < inner.size()) {
425-
size_t keyStart = inner.find('"', pos);
426-
if (keyStart == std::string::npos) break;
427-
size_t keyEnd = inner.find('"', keyStart + 1);
428-
if (keyEnd == std::string::npos) break;
429-
std::string key = inner.substr(keyStart + 1, keyEnd - keyStart - 1);
430-
431-
size_t valStart = inner.find('"', keyEnd + 1);
432-
if (valStart == std::string::npos) break;
433-
size_t valEnd = inner.find('"', valStart + 1);
434-
if (valEnd == std::string::npos) break;
435-
std::string val = inner.substr(valStart + 1, valEnd - valStart - 1);
436-
437-
g_importMap[key] = val;
438-
pos = valEnd + 1;
412+
// The import map is a small, flat {"imports": {"specifier": "target", ...}}
413+
// object. Parse it with Foundation's JSON reader rather than a hand-rolled
414+
// scanner so escapes, nesting, and malformed input are handled correctly and
415+
// can't desync key/value pairing.
416+
@autoreleasepool {
417+
NSData* data = [NSData dataWithBytes:json.data() length:json.size()];
418+
if (data == nil || data.length == 0) {
419+
return;
420+
}
421+
NSError* err = nil;
422+
id parsed = [NSJSONSerialization JSONObjectWithData:data
423+
options:kNilOptions
424+
error:&err];
425+
if (parsed == nil || ![parsed isKindOfClass:[NSDictionary class]]) {
426+
if (IsScriptLoadingLogEnabled()) {
427+
NSString* detail = err.localizedDescription ?: @"not an object";
428+
Log(@"[import-map] parse failed: %s", [detail UTF8String] ?: "unknown");
429+
}
430+
return;
431+
}
432+
id imports = [(NSDictionary*)parsed objectForKey:@"imports"];
433+
if (![imports isKindOfClass:[NSDictionary class]]) {
434+
return; // no "imports" object → empty map, same as the prior parser
435+
}
436+
for (id key in (NSDictionary*)imports) {
437+
if (![key isKindOfClass:[NSString class]]) continue;
438+
id value = [(NSDictionary*)imports objectForKey:key];
439+
if (![value isKindOfClass:[NSString class]]) continue; // skip non-string targets
440+
const char* k = [(NSString*)key UTF8String];
441+
const char* v = [(NSString*)value UTF8String];
442+
if (k != nullptr && v != nullptr) {
443+
g_importMap[std::string(k)] = std::string(v);
444+
}
445+
}
439446
}
440447
if (IsScriptLoadingLogEnabled()) {
441448
Log(@"[import-map] loaded %lu entries", (unsigned long)g_importMap.size());

NativeScript/runtime/Runtime.mm

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@
3232
#include "URLPatternImpl.h"
3333
#include "URLSearchParamsImpl.h"
3434
#include <vector>
35-
#include "HMRSupport.h"
36-
#include "DevFlags.h"
3735

3836
#define STRINGIZE(x) #x
3937
#define STRINGIZE_VALUE_OF(x) STRINGIZE(x)

TestRunner/app/tests/HttpEsmLoaderTests.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,4 +502,54 @@ describe("HTTP ESM Loader", function() {
502502
});
503503
});
504504

505+
// Focused, deterministic coverage for the native HTTP canonical-key function.
506+
// These run only in debug builds, where HMRSupport.mm installs the
507+
// `__nsCanonicalizeHttpUrlKey` diagnostic global; in release they self-skip via
508+
// pending(). They require no HTTP host — they pin pure string identity behavior.
509+
describe("HTTP canonical key (native __nsCanonicalizeHttpUrlKey)", function () {
510+
function getCanon() {
511+
return (typeof global !== "undefined") ? global.__nsCanonicalizeHttpUrlKey : undefined;
512+
}
513+
514+
function checkKey(input, expected) {
515+
var canon = getCanon();
516+
if (typeof canon !== "function") {
517+
pending("__nsCanonicalizeHttpUrlKey not installed (release build)");
518+
return;
519+
}
520+
expect(canon(input)).toBe(expected);
521+
}
522+
523+
it("is installed as a function in debug builds", function () {
524+
var canon = getCanon();
525+
if (typeof canon !== "function") {
526+
pending("__nsCanonicalizeHttpUrlKey not installed (release build)");
527+
return;
528+
}
529+
expect(typeof canon).toBe("function");
530+
});
531+
532+
it("drops dev cache-busters (t/v/import) but keeps real query params", function () {
533+
checkKey("http://h/ns/core?p=x&t=123&v=9&import=1", "http://h/ns/core?p=x");
534+
});
535+
536+
it("leaves public (non-dev, non-volatile) URLs untouched", function () {
537+
checkKey("https://cdn.example.com/lib.js?token=abc", "https://cdn.example.com/lib.js?token=abc");
538+
});
539+
540+
it("strips boot and hmr tags to the canonical /ns/m path", function () {
541+
checkKey("http://h/ns/m/__ns_boot__/b1/foo.js", "http://h/ns/m/foo.js");
542+
checkKey("http://h/ns/m/__ns_hmr__/v7/foo.js", "http://h/ns/m/foo.js");
543+
});
544+
545+
it("collapses versioned bridge endpoints to their base path", function () {
546+
checkKey("http://h/ns/rt/42", "http://h/ns/rt");
547+
checkKey("http://h/ns/core/13", "http://h/ns/core");
548+
});
549+
550+
it("ignores URL fragments for dev endpoints", function () {
551+
checkKey("http://h/ns/m/foo.js#frag", "http://h/ns/m/foo.js");
552+
});
553+
});
554+
505555
console.log("HTTP ESM Loader tests loaded");

0 commit comments

Comments
 (0)