Skip to content

Commit aaab873

Browse files
committed
feat: bundle encryption
1 parent 42ce6b0 commit aaab873

37 files changed

Lines changed: 1608 additions & 63 deletions

NativeScriptWindowsDemo/NativeScriptWindowsDemo.csproj

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,12 @@
104104
<Compile Include="$(NSGenDir)\NSWinRTProxies\**\*.cs" Visible="false" Condition="Exists('$(NSGenDir)\NSWinRTProxies')" />
105105
<Compile Remove="$(NSGenDir)\NSWinRTProxies\obj\**\*.cs" Condition="Exists('$(NSGenDir)\NSWinRTProxies\obj')" />
106106

107-
<Content Include="App\**\*">
107+
<!-- Sealed source-protected bundle (see runtime/src/source_protect.rs), when present, takes
108+
priority over the plaintext App\ tree; produced by the nsbundle_pack packer tool. -->
109+
<Content Include="app.nsbundle" Condition="Exists('app.nsbundle')">
110+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
111+
</Content>
112+
<Content Include="App\**\*" Condition="!Exists('app.nsbundle')">
108113
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
109114
</Content>
110115
<Content Include="$(NSGenDir)\sbg-manifest.json" Link="sbg-manifest.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('$(NSGenDir)\sbg-manifest.json')" />

NativeScriptWindowsDemo/RuntimeHost.cs

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ internal sealed class RuntimeHost : IDisposable
3232
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_local_folder))]
3333
private static extern void runtime_set_local_folder([MarshalAs(UnmanagedType.LPUTF8Str)] string localFolder);
3434

35+
// Escape hatch for apps packed with `nsbundle_pack --key-hex <hex>` (custom-key
36+
// app.nsbundle containers). Must be called before runtime_init if used at all — apps
37+
// packed with the default pepper (no --key-hex) never need to call this. Not wired to a
38+
// default call site here; an app author supplies their own key material and calls this
39+
// from Initialize() before runtime_init(...).
40+
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_bundle_key))]
41+
private static extern int runtime_set_bundle_key([MarshalAs(UnmanagedType.LPUTF8Str)] string keyHex);
42+
43+
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_read_protected_file))]
44+
private static extern IntPtr runtime_read_protected_file([MarshalAs(UnmanagedType.LPUTF8Str)] string virtualPath);
45+
46+
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_free_protected_string))]
47+
private static extern void runtime_free_protected_string(IntPtr ptr);
48+
3549
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_get_last_js_error))]
3650
private static extern IntPtr runtime_get_last_js_error();
3751

@@ -191,6 +205,27 @@ private static bool ConsumeDebugBreakMarker()
191205
}
192206
#endif
193207

208+
/// Reads a file that may live inside a sealed app.nsbundle instead of on disk: tries the
209+
/// protected-VFS native call first (cheap no-op when no bundle is loaded), falls back to
210+
/// the real filesystem. `path` is whatever candidate the caller already built for
211+
/// `File.Exists`/`File.ReadAllText` — the native side strips it down to the bundle's
212+
/// virtual (app/App-relative) path itself, so no extra bookkeeping is needed here.
213+
private static string TryReadVirtual(string path)
214+
{
215+
IntPtr ptr = IntPtr.Zero;
216+
try
217+
{
218+
ptr = runtime_read_protected_file(path);
219+
return ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr);
220+
}
221+
catch { return null; }
222+
finally { if (ptr != IntPtr.Zero) runtime_free_protected_string(ptr); }
223+
}
224+
225+
private static bool VExists(string path) => TryReadVirtual(path) != null || File.Exists(path);
226+
227+
private static string VReadAllText(string path) => TryReadVirtual(path) ?? File.ReadAllText(path);
228+
194229
public void RunMainScript()
195230
{
196231
if (!_initialized)
@@ -208,7 +243,7 @@ public void RunMainScript()
208243
foreach (var chunkName in new[] { "runtime.js", "vendor.js" })
209244
{
210245
var chunkPath = Path.Combine(dir, chunkName);
211-
if (File.Exists(chunkPath) &&
246+
if (VExists(chunkPath) &&
212247
!string.Equals(chunkPath, Path.GetFullPath(entryPath), StringComparison.OrdinalIgnoreCase))
213248
{
214249
chunks.Add(chunkPath);
@@ -218,7 +253,7 @@ public void RunMainScript()
218253

219254
foreach (var scriptPath in chunks)
220255
{
221-
var script = File.ReadAllText(Path.GetFullPath(scriptPath));
256+
var script = VReadAllText(Path.GetFullPath(scriptPath));
222257
try
223258
{
224259
runtime_runscript(_runtime, script, Path.GetFileName(scriptPath));
@@ -260,7 +295,7 @@ private static string ResolveEntryScriptPath()
260295
foreach (var dir in appDirCandidates)
261296
{
262297
var candidate = Path.Combine(dir, "package.json");
263-
if (File.Exists(candidate))
298+
if (VExists(candidate))
264299
{
265300
packageJsonPath = candidate;
266301
resolvedBaseDir = dir;
@@ -269,7 +304,7 @@ private static string ResolveEntryScriptPath()
269304
}
270305

271306
// Also accept package.json at the project root (parent of bin/).
272-
if (packageJsonPath == null && File.Exists(Path.Combine(parentDir, "package.json")))
307+
if (packageJsonPath == null && VExists(Path.Combine(parentDir, "package.json")))
273308
{
274309
packageJsonPath = Path.Combine(parentDir, "package.json");
275310
resolvedBaseDir = parentDir;
@@ -278,7 +313,7 @@ private static string ResolveEntryScriptPath()
278313
string Fallback() =>
279314
appDirCandidates
280315
.SelectMany(d => new[] { Path.Combine(d, "bundle.js"), Path.Combine(d, "bundle.mjs") })
281-
.FirstOrDefault(File.Exists);
316+
.FirstOrDefault(VExists);
282317

283318
if (packageJsonPath == null)
284319
return Fallback();
@@ -304,7 +339,7 @@ string Fallback() =>
304339

305340
private static RuntimePackageConfig ParsePackageConfig(string packageJsonPath)
306341
{
307-
using var doc = JsonDocument.Parse(File.ReadAllText(packageJsonPath));
342+
using var doc = JsonDocument.Parse(VReadAllText(packageJsonPath));
308343
var config = new RuntimePackageConfig();
309344
if (doc.RootElement.TryGetProperty("main", out var main) && main.ValueKind == JsonValueKind.String)
310345
config.Main = main.GetString();
@@ -321,11 +356,11 @@ private static string ResolveScriptPath(string baseDir, string scriptPath)
321356
foreach (var candidate in new[] { normalized, normalized + ".js", normalized + ".mjs" })
322357
{
323358
var direct = Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDir, candidate);
324-
if (File.Exists(direct)) return direct;
359+
if (VExists(direct)) return direct;
325360
var appLower = Path.Combine(baseDir, "app", candidate);
326-
if (File.Exists(appLower)) return appLower;
361+
if (VExists(appLower)) return appLower;
327362
var appUpper = Path.Combine(baseDir, "App", candidate);
328-
if (File.Exists(appUpper)) return appUpper;
363+
if (VExists(appUpper)) return appUpper;
329364
}
330365
return null;
331366
}

TestApp/RuntimeHost.cs

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ internal sealed class RuntimeHost : IDisposable
3232
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_local_folder))]
3333
private static extern void runtime_set_local_folder([MarshalAs(UnmanagedType.LPUTF8Str)] string localFolder);
3434

35+
// Escape hatch for apps packed with `nsbundle_pack --key-hex <hex>` (custom-key
36+
// app.nsbundle containers). Must be called before runtime_init if used at all — apps
37+
// packed with the default pepper (no --key-hex) never need to call this. Not wired to a
38+
// default call site here; an app author supplies their own key material and calls this
39+
// from Initialize() before runtime_init(...).
40+
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_bundle_key))]
41+
private static extern int runtime_set_bundle_key([MarshalAs(UnmanagedType.LPUTF8Str)] string keyHex);
42+
43+
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_read_protected_file))]
44+
private static extern IntPtr runtime_read_protected_file([MarshalAs(UnmanagedType.LPUTF8Str)] string virtualPath);
45+
46+
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_free_protected_string))]
47+
private static extern void runtime_free_protected_string(IntPtr ptr);
48+
3549
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_get_last_js_error))]
3650
private static extern IntPtr runtime_get_last_js_error();
3751

@@ -191,6 +205,27 @@ private static bool ConsumeDebugBreakMarker()
191205
}
192206
#endif
193207

208+
/// Reads a file that may live inside a sealed app.nsbundle instead of on disk: tries the
209+
/// protected-VFS native call first (cheap no-op when no bundle is loaded), falls back to
210+
/// the real filesystem. `path` is whatever candidate the caller already built for
211+
/// `File.Exists`/`File.ReadAllText` — the native side strips it down to the bundle's
212+
/// virtual (app/App-relative) path itself, so no extra bookkeeping is needed here.
213+
private static string TryReadVirtual(string path)
214+
{
215+
IntPtr ptr = IntPtr.Zero;
216+
try
217+
{
218+
ptr = runtime_read_protected_file(path);
219+
return ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr);
220+
}
221+
catch { return null; }
222+
finally { if (ptr != IntPtr.Zero) runtime_free_protected_string(ptr); }
223+
}
224+
225+
private static bool VExists(string path) => TryReadVirtual(path) != null || File.Exists(path);
226+
227+
private static string VReadAllText(string path) => TryReadVirtual(path) ?? File.ReadAllText(path);
228+
194229
public void RunMainScript()
195230
{
196231
if (!_initialized)
@@ -208,7 +243,7 @@ public void RunMainScript()
208243
foreach (var chunkName in new[] { "runtime.js", "vendor.js" })
209244
{
210245
var chunkPath = Path.Combine(dir, chunkName);
211-
if (File.Exists(chunkPath) &&
246+
if (VExists(chunkPath) &&
212247
!string.Equals(chunkPath, Path.GetFullPath(entryPath), StringComparison.OrdinalIgnoreCase))
213248
{
214249
chunks.Add(chunkPath);
@@ -218,7 +253,7 @@ public void RunMainScript()
218253

219254
foreach (var scriptPath in chunks)
220255
{
221-
var script = File.ReadAllText(Path.GetFullPath(scriptPath));
256+
var script = VReadAllText(Path.GetFullPath(scriptPath));
222257
try
223258
{
224259
runtime_runscript(_runtime, script, Path.GetFileName(scriptPath));
@@ -260,7 +295,7 @@ private static string ResolveEntryScriptPath()
260295
foreach (var dir in appDirCandidates)
261296
{
262297
var candidate = Path.Combine(dir, "package.json");
263-
if (File.Exists(candidate))
298+
if (VExists(candidate))
264299
{
265300
packageJsonPath = candidate;
266301
resolvedBaseDir = dir;
@@ -269,7 +304,7 @@ private static string ResolveEntryScriptPath()
269304
}
270305

271306
// Also accept package.json at the project root (parent of bin/).
272-
if (packageJsonPath == null && File.Exists(Path.Combine(parentDir, "package.json")))
307+
if (packageJsonPath == null && VExists(Path.Combine(parentDir, "package.json")))
273308
{
274309
packageJsonPath = Path.Combine(parentDir, "package.json");
275310
resolvedBaseDir = parentDir;
@@ -278,7 +313,7 @@ private static string ResolveEntryScriptPath()
278313
string Fallback() =>
279314
appDirCandidates
280315
.SelectMany(d => new[] { Path.Combine(d, "bundle.js"), Path.Combine(d, "bundle.mjs") })
281-
.FirstOrDefault(File.Exists);
316+
.FirstOrDefault(VExists);
282317

283318
if (packageJsonPath == null)
284319
return Fallback();
@@ -304,7 +339,7 @@ string Fallback() =>
304339

305340
private static RuntimePackageConfig ParsePackageConfig(string packageJsonPath)
306341
{
307-
using var doc = JsonDocument.Parse(File.ReadAllText(packageJsonPath));
342+
using var doc = JsonDocument.Parse(VReadAllText(packageJsonPath));
308343
var config = new RuntimePackageConfig();
309344
if (doc.RootElement.TryGetProperty("main", out var main) && main.ValueKind == JsonValueKind.String)
310345
config.Main = main.GetString();
@@ -321,11 +356,11 @@ private static string ResolveScriptPath(string baseDir, string scriptPath)
321356
foreach (var candidate in new[] { normalized, normalized + ".js", normalized + ".mjs" })
322357
{
323358
var direct = Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDir, candidate);
324-
if (File.Exists(direct)) return direct;
359+
if (VExists(direct)) return direct;
325360
var appLower = Path.Combine(baseDir, "app", candidate);
326-
if (File.Exists(appLower)) return appLower;
361+
if (VExists(appLower)) return appLower;
327362
var appUpper = Path.Combine(baseDir, "App", candidate);
328-
if (File.Exists(appUpper)) return appUpper;
363+
if (VExists(appUpper)) return appUpper;
329364
}
330365
return null;
331366
}

TestApp/TestApp.csproj

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,12 @@
8686
<Compile Include="$(NSGenDir)\NSWinRTProxies\**\*.cs" Visible="false" Condition="Exists('$(NSGenDir)\NSWinRTProxies')" />
8787
<Compile Remove="$(NSGenDir)\NSWinRTProxies\obj\**\*.cs" Condition="Exists('$(NSGenDir)\NSWinRTProxies\obj')" />
8888

89-
<Content Include="App\**\*">
89+
<!-- Sealed source-protected bundle (see runtime/src/source_protect.rs), when present, takes
90+
priority over the plaintext App\ tree; produced by the nsbundle_pack packer tool. -->
91+
<Content Include="app.nsbundle" Condition="Exists('app.nsbundle')">
92+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
93+
</Content>
94+
<Content Include="App\**\*" Condition="!Exists('app.nsbundle')">
9095
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
9196
</Content>
9297
<Content Include="$(NSGenDir)\sbg-manifest.json" Link="sbg-manifest.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('$(NSGenDir)\sbg-manifest.json')" />
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//! Proves the sealed `app.nsbundle` container (`runtime::source_protect`) actually serves as the
2+
//! JS source for a real `Runtime` run — not just round-tripping in isolation (see
3+
//! `source_protect`'s own unit tests for that). The plaintext staging directory is deleted right
4+
//! after packing, before `Runtime::new` ever runs, so there is no possible filesystem fallback:
5+
//! if the ESM import below resolves at all, it can only have come from the decrypted in-memory
6+
//! table.
7+
8+
use runtime::source_protect;
9+
use runtime::Runtime;
10+
use std::fs;
11+
use std::path::PathBuf;
12+
use std::sync::atomic::{AtomicU64, Ordering};
13+
14+
static COUNTER: AtomicU64 = AtomicU64::new(0);
15+
16+
fn scratch_dir(name: &str) -> PathBuf {
17+
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
18+
let dir = std::env::temp_dir().join(format!(
19+
"nsbundle_integration_{name}_{}_{n}",
20+
std::process::id()
21+
));
22+
let _ = fs::remove_dir_all(&dir);
23+
fs::create_dir_all(&dir).unwrap();
24+
dir
25+
}
26+
27+
#[test]
28+
fn esm_import_resolves_from_sealed_bundle_with_no_plaintext_on_disk() {
29+
// Stage the two-file ESM "app", pack it, then destroy the plaintext — everything downstream
30+
// must come from the decrypted table or this test fails.
31+
let staging = scratch_dir("stage");
32+
fs::write(
33+
staging.join("entry.mjs"),
34+
b"import { value } from './dep.mjs';\n\
35+
if (value !== 42) { throw new Error('wrong value: ' + value); }\n",
36+
)
37+
.unwrap();
38+
fs::write(staging.join("dep.mjs"), b"export const value = 42;\n").unwrap();
39+
40+
let app_root = scratch_dir("approot");
41+
let bundle_path = app_root.join("app.nsbundle");
42+
source_protect::pack_directory(
43+
&staging,
44+
&bundle_path,
45+
source_protect::KEY_MODE_DEFAULT,
46+
source_protect::default_key(),
47+
)
48+
.unwrap();
49+
50+
fs::remove_dir_all(&staging).unwrap();
51+
assert!(!staging.exists(), "plaintext staging dir must be gone");
52+
53+
// Runtime::new -> Runtime::source_protect::init_from_app_root locates app_root/app.nsbundle
54+
// and decrypts it into the in-memory table before anything else runs.
55+
let mut rt = Runtime::new(app_root.to_str().unwrap());
56+
assert!(
57+
source_protect::has_bundle(),
58+
"app.nsbundle should have been found and loaded from {}",
59+
app_root.display()
60+
);
61+
62+
// Fetch the entry's own source from the protected table too (mirrors what a real host does
63+
// via runtime_read_protected_file instead of File.ReadAllText).
64+
let entry_source =
65+
source_protect::read_text("entry.mjs").expect("entry.mjs should be served from the bundle");
66+
67+
rt.run_script(&entry_source, "entry.mjs");
68+
69+
assert_eq!(
70+
runtime::get_last_js_error(),
71+
None,
72+
"ESM import of the bundle-only dep.mjs should have resolved cleanly"
73+
);
74+
}

nativescript/src/lib.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,20 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) {
190190
let _ = LOCAL_FOLDER.set(s);
191191
}
192192

193+
/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle
194+
/// container packed with `nsbundle_pack --key-hex`. Must be called before `runtime_init` — same
195+
/// ordering requirement as `runtime_set_local_folder`. Returns 1 on success, 0 on malformed input
196+
/// (wrong length or non-hex characters). Apps sealed with the default pepper (`key_mode == 0`,
197+
/// i.e. `nsbundle_pack` invoked without `--key-hex`) never need to call this.
198+
#[no_mangle]
199+
pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int {
200+
if key_hex.is_null() {
201+
return 0;
202+
}
203+
let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy();
204+
runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int
205+
}
206+
193207
#[no_mangle]
194208
pub extern "C" fn runtime_init(app_root: *const c_char) -> i64 {
195209
install_veh();
@@ -285,6 +299,32 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) {
285299
}
286300
}
287301

302+
/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. Returns
303+
/// NULL when no bundle was loaded, or the given path isn't in it — this doubles as an existence
304+
/// probe, so callers (`RuntimeHost.cs`'s `VExists`/`VReadAllText`) can use one call for both.
305+
/// Non-NULL results must be freed with `runtime_free_protected_string`.
306+
#[no_mangle]
307+
pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char {
308+
if virtual_path.is_null() {
309+
return std::ptr::null_mut();
310+
}
311+
let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy();
312+
match runtime::source_protect::read_text(path.as_ref()) {
313+
Some(content) => CString::new(content)
314+
.map(|c| c.into_raw())
315+
.unwrap_or(std::ptr::null_mut()),
316+
None => std::ptr::null_mut(),
317+
}
318+
}
319+
320+
/// Free a string previously returned by `runtime_read_protected_file`.
321+
#[no_mangle]
322+
pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) {
323+
if !ptr.is_null() {
324+
drop(unsafe { CString::from_raw(ptr) });
325+
}
326+
}
327+
288328
// ─── Devtools FFI ─────────────────────────────────────────────────────────────
289329

290330
#[no_mangle]

0 commit comments

Comments
 (0)