diff --git a/NativeScriptWindowsDemo/NativeScriptWindowsDemo.csproj b/NativeScriptWindowsDemo/NativeScriptWindowsDemo.csproj index f13d378..3ced762 100644 --- a/NativeScriptWindowsDemo/NativeScriptWindowsDemo.csproj +++ b/NativeScriptWindowsDemo/NativeScriptWindowsDemo.csproj @@ -104,7 +104,12 @@ - + + + PreserveNewest + + PreserveNewest diff --git a/NativeScriptWindowsDemo/RuntimeHost.cs b/NativeScriptWindowsDemo/RuntimeHost.cs index 0aa25eb..a074ef9 100644 --- a/NativeScriptWindowsDemo/RuntimeHost.cs +++ b/NativeScriptWindowsDemo/RuntimeHost.cs @@ -32,6 +32,20 @@ internal sealed class RuntimeHost : IDisposable [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_local_folder))] private static extern void runtime_set_local_folder([MarshalAs(UnmanagedType.LPUTF8Str)] string localFolder); + // Escape hatch for apps packed with `nsbundle_pack --key-hex ` (custom-key + // app.nsbundle containers). Must be called before runtime_init if used at all — apps + // packed with the default pepper (no --key-hex) never need to call this. Not wired to a + // default call site here; an app author supplies their own key material and calls this + // from Initialize() before runtime_init(...). + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_bundle_key))] + private static extern int runtime_set_bundle_key([MarshalAs(UnmanagedType.LPUTF8Str)] string keyHex); + + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_read_protected_file))] + private static extern IntPtr runtime_read_protected_file([MarshalAs(UnmanagedType.LPUTF8Str)] string virtualPath); + + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_free_protected_string))] + private static extern void runtime_free_protected_string(IntPtr ptr); + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_get_last_js_error))] private static extern IntPtr runtime_get_last_js_error(); @@ -191,6 +205,27 @@ private static bool ConsumeDebugBreakMarker() } #endif + /// Reads a file that may live inside a sealed app.nsbundle instead of on disk: tries the + /// protected-VFS native call first (cheap no-op when no bundle is loaded), falls back to + /// the real filesystem. `path` is whatever candidate the caller already built for + /// `File.Exists`/`File.ReadAllText` — the native side strips it down to the bundle's + /// virtual (app/App-relative) path itself, so no extra bookkeeping is needed here. + private static string TryReadVirtual(string path) + { + IntPtr ptr = IntPtr.Zero; + try + { + ptr = runtime_read_protected_file(path); + return ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr); + } + catch { return null; } + finally { if (ptr != IntPtr.Zero) runtime_free_protected_string(ptr); } + } + + private static bool VExists(string path) => TryReadVirtual(path) != null || File.Exists(path); + + private static string VReadAllText(string path) => TryReadVirtual(path) ?? File.ReadAllText(path); + public void RunMainScript() { if (!_initialized) @@ -208,7 +243,7 @@ public void RunMainScript() foreach (var chunkName in new[] { "runtime.js", "vendor.js" }) { var chunkPath = Path.Combine(dir, chunkName); - if (File.Exists(chunkPath) && + if (VExists(chunkPath) && !string.Equals(chunkPath, Path.GetFullPath(entryPath), StringComparison.OrdinalIgnoreCase)) { chunks.Add(chunkPath); @@ -218,7 +253,7 @@ public void RunMainScript() foreach (var scriptPath in chunks) { - var script = File.ReadAllText(Path.GetFullPath(scriptPath)); + var script = VReadAllText(Path.GetFullPath(scriptPath)); try { runtime_runscript(_runtime, script, Path.GetFileName(scriptPath)); @@ -260,7 +295,7 @@ private static string ResolveEntryScriptPath() foreach (var dir in appDirCandidates) { var candidate = Path.Combine(dir, "package.json"); - if (File.Exists(candidate)) + if (VExists(candidate)) { packageJsonPath = candidate; resolvedBaseDir = dir; @@ -269,7 +304,7 @@ private static string ResolveEntryScriptPath() } // Also accept package.json at the project root (parent of bin/). - if (packageJsonPath == null && File.Exists(Path.Combine(parentDir, "package.json"))) + if (packageJsonPath == null && VExists(Path.Combine(parentDir, "package.json"))) { packageJsonPath = Path.Combine(parentDir, "package.json"); resolvedBaseDir = parentDir; @@ -278,7 +313,7 @@ private static string ResolveEntryScriptPath() string Fallback() => appDirCandidates .SelectMany(d => new[] { Path.Combine(d, "bundle.js"), Path.Combine(d, "bundle.mjs") }) - .FirstOrDefault(File.Exists); + .FirstOrDefault(VExists); if (packageJsonPath == null) return Fallback(); @@ -304,7 +339,7 @@ string Fallback() => private static RuntimePackageConfig ParsePackageConfig(string packageJsonPath) { - using var doc = JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + using var doc = JsonDocument.Parse(VReadAllText(packageJsonPath)); var config = new RuntimePackageConfig(); if (doc.RootElement.TryGetProperty("main", out var main) && main.ValueKind == JsonValueKind.String) config.Main = main.GetString(); @@ -321,11 +356,11 @@ private static string ResolveScriptPath(string baseDir, string scriptPath) foreach (var candidate in new[] { normalized, normalized + ".js", normalized + ".mjs" }) { var direct = Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDir, candidate); - if (File.Exists(direct)) return direct; + if (VExists(direct)) return direct; var appLower = Path.Combine(baseDir, "app", candidate); - if (File.Exists(appLower)) return appLower; + if (VExists(appLower)) return appLower; var appUpper = Path.Combine(baseDir, "App", candidate); - if (File.Exists(appUpper)) return appUpper; + if (VExists(appUpper)) return appUpper; } return null; } diff --git a/TestApp/RuntimeHost.cs b/TestApp/RuntimeHost.cs index 6a5cfd3..92fda05 100644 --- a/TestApp/RuntimeHost.cs +++ b/TestApp/RuntimeHost.cs @@ -32,6 +32,20 @@ internal sealed class RuntimeHost : IDisposable [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_local_folder))] private static extern void runtime_set_local_folder([MarshalAs(UnmanagedType.LPUTF8Str)] string localFolder); + // Escape hatch for apps packed with `nsbundle_pack --key-hex ` (custom-key + // app.nsbundle containers). Must be called before runtime_init if used at all — apps + // packed with the default pepper (no --key-hex) never need to call this. Not wired to a + // default call site here; an app author supplies their own key material and calls this + // from Initialize() before runtime_init(...). + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_bundle_key))] + private static extern int runtime_set_bundle_key([MarshalAs(UnmanagedType.LPUTF8Str)] string keyHex); + + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_read_protected_file))] + private static extern IntPtr runtime_read_protected_file([MarshalAs(UnmanagedType.LPUTF8Str)] string virtualPath); + + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_free_protected_string))] + private static extern void runtime_free_protected_string(IntPtr ptr); + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_get_last_js_error))] private static extern IntPtr runtime_get_last_js_error(); @@ -191,6 +205,27 @@ private static bool ConsumeDebugBreakMarker() } #endif + /// Reads a file that may live inside a sealed app.nsbundle instead of on disk: tries the + /// protected-VFS native call first (cheap no-op when no bundle is loaded), falls back to + /// the real filesystem. `path` is whatever candidate the caller already built for + /// `File.Exists`/`File.ReadAllText` — the native side strips it down to the bundle's + /// virtual (app/App-relative) path itself, so no extra bookkeeping is needed here. + private static string TryReadVirtual(string path) + { + IntPtr ptr = IntPtr.Zero; + try + { + ptr = runtime_read_protected_file(path); + return ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr); + } + catch { return null; } + finally { if (ptr != IntPtr.Zero) runtime_free_protected_string(ptr); } + } + + private static bool VExists(string path) => TryReadVirtual(path) != null || File.Exists(path); + + private static string VReadAllText(string path) => TryReadVirtual(path) ?? File.ReadAllText(path); + public void RunMainScript() { if (!_initialized) @@ -208,7 +243,7 @@ public void RunMainScript() foreach (var chunkName in new[] { "runtime.js", "vendor.js" }) { var chunkPath = Path.Combine(dir, chunkName); - if (File.Exists(chunkPath) && + if (VExists(chunkPath) && !string.Equals(chunkPath, Path.GetFullPath(entryPath), StringComparison.OrdinalIgnoreCase)) { chunks.Add(chunkPath); @@ -218,7 +253,7 @@ public void RunMainScript() foreach (var scriptPath in chunks) { - var script = File.ReadAllText(Path.GetFullPath(scriptPath)); + var script = VReadAllText(Path.GetFullPath(scriptPath)); try { runtime_runscript(_runtime, script, Path.GetFileName(scriptPath)); @@ -260,7 +295,7 @@ private static string ResolveEntryScriptPath() foreach (var dir in appDirCandidates) { var candidate = Path.Combine(dir, "package.json"); - if (File.Exists(candidate)) + if (VExists(candidate)) { packageJsonPath = candidate; resolvedBaseDir = dir; @@ -269,7 +304,7 @@ private static string ResolveEntryScriptPath() } // Also accept package.json at the project root (parent of bin/). - if (packageJsonPath == null && File.Exists(Path.Combine(parentDir, "package.json"))) + if (packageJsonPath == null && VExists(Path.Combine(parentDir, "package.json"))) { packageJsonPath = Path.Combine(parentDir, "package.json"); resolvedBaseDir = parentDir; @@ -278,7 +313,7 @@ private static string ResolveEntryScriptPath() string Fallback() => appDirCandidates .SelectMany(d => new[] { Path.Combine(d, "bundle.js"), Path.Combine(d, "bundle.mjs") }) - .FirstOrDefault(File.Exists); + .FirstOrDefault(VExists); if (packageJsonPath == null) return Fallback(); @@ -304,7 +339,7 @@ string Fallback() => private static RuntimePackageConfig ParsePackageConfig(string packageJsonPath) { - using var doc = JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + using var doc = JsonDocument.Parse(VReadAllText(packageJsonPath)); var config = new RuntimePackageConfig(); if (doc.RootElement.TryGetProperty("main", out var main) && main.ValueKind == JsonValueKind.String) config.Main = main.GetString(); @@ -321,11 +356,11 @@ private static string ResolveScriptPath(string baseDir, string scriptPath) foreach (var candidate in new[] { normalized, normalized + ".js", normalized + ".mjs" }) { var direct = Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDir, candidate); - if (File.Exists(direct)) return direct; + if (VExists(direct)) return direct; var appLower = Path.Combine(baseDir, "app", candidate); - if (File.Exists(appLower)) return appLower; + if (VExists(appLower)) return appLower; var appUpper = Path.Combine(baseDir, "App", candidate); - if (File.Exists(appUpper)) return appUpper; + if (VExists(appUpper)) return appUpper; } return null; } diff --git a/TestApp/TestApp.csproj b/TestApp/TestApp.csproj index 38fa999..03627e7 100644 --- a/TestApp/TestApp.csproj +++ b/TestApp/TestApp.csproj @@ -86,7 +86,12 @@ - + + + PreserveNewest + + PreserveNewest diff --git a/integration-tests/tests/protected_bundle.rs b/integration-tests/tests/protected_bundle.rs new file mode 100644 index 0000000..2ef864d --- /dev/null +++ b/integration-tests/tests/protected_bundle.rs @@ -0,0 +1,74 @@ +//! Proves the sealed `app.nsbundle` container (`runtime::source_protect`) actually serves as the +//! JS source for a real `Runtime` run — not just round-tripping in isolation (see +//! `source_protect`'s own unit tests for that). The plaintext staging directory is deleted right +//! after packing, before `Runtime::new` ever runs, so there is no possible filesystem fallback: +//! if the ESM import below resolves at all, it can only have come from the decrypted in-memory +//! table. + +use runtime::source_protect; +use runtime::Runtime; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +fn scratch_dir(name: &str) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "nsbundle_integration_{name}_{}_{n}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn esm_import_resolves_from_sealed_bundle_with_no_plaintext_on_disk() { + // Stage the two-file ESM "app", pack it, then destroy the plaintext — everything downstream + // must come from the decrypted table or this test fails. + let staging = scratch_dir("stage"); + fs::write( + staging.join("entry.mjs"), + b"import { value } from './dep.mjs';\n\ + if (value !== 42) { throw new Error('wrong value: ' + value); }\n", + ) + .unwrap(); + fs::write(staging.join("dep.mjs"), b"export const value = 42;\n").unwrap(); + + let app_root = scratch_dir("approot"); + let bundle_path = app_root.join("app.nsbundle"); + source_protect::pack_directory( + &staging, + &bundle_path, + source_protect::KEY_MODE_DEFAULT, + source_protect::default_key(), + ) + .unwrap(); + + fs::remove_dir_all(&staging).unwrap(); + assert!(!staging.exists(), "plaintext staging dir must be gone"); + + // Runtime::new -> Runtime::source_protect::init_from_app_root locates app_root/app.nsbundle + // and decrypts it into the in-memory table before anything else runs. + let mut rt = Runtime::new(app_root.to_str().unwrap()); + assert!( + source_protect::has_bundle(), + "app.nsbundle should have been found and loaded from {}", + app_root.display() + ); + + // Fetch the entry's own source from the protected table too (mirrors what a real host does + // via runtime_read_protected_file instead of File.ReadAllText). + let entry_source = + source_protect::read_text("entry.mjs").expect("entry.mjs should be served from the bundle"); + + rt.run_script(&entry_source, "entry.mjs"); + + assert_eq!( + runtime::get_last_js_error(), + None, + "ESM import of the bundle-only dep.mjs should have resolved cleanly" + ); +} diff --git a/nativescript/src/lib.rs b/nativescript/src/lib.rs index 7fad8e7..26e2da5 100644 --- a/nativescript/src/lib.rs +++ b/nativescript/src/lib.rs @@ -190,6 +190,20 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) { let _ = LOCAL_FOLDER.set(s); } +/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle +/// container packed with `nsbundle_pack --key-hex`. Must be called before `runtime_init` — same +/// ordering requirement as `runtime_set_local_folder`. Returns 1 on success, 0 on malformed input +/// (wrong length or non-hex characters). Apps sealed with the default pepper (`key_mode == 0`, +/// i.e. `nsbundle_pack` invoked without `--key-hex`) never need to call this. +#[no_mangle] +pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int { + if key_hex.is_null() { + return 0; + } + let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy(); + runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int +} + #[no_mangle] pub extern "C" fn runtime_init(app_root: *const c_char) -> i64 { install_veh(); @@ -285,6 +299,32 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) { } } +/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. Returns +/// NULL when no bundle was loaded, or the given path isn't in it — this doubles as an existence +/// probe, so callers (`RuntimeHost.cs`'s `VExists`/`VReadAllText`) can use one call for both. +/// Non-NULL results must be freed with `runtime_free_protected_string`. +#[no_mangle] +pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char { + if virtual_path.is_null() { + return std::ptr::null_mut(); + } + let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy(); + match runtime::source_protect::read_text(path.as_ref()) { + Some(content) => CString::new(content) + .map(|c| c.into_raw()) + .unwrap_or(std::ptr::null_mut()), + None => std::ptr::null_mut(), + } +} + +/// Free a string previously returned by `runtime_read_protected_file`. +#[no_mangle] +pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) { + if !ptr.is_null() { + drop(unsafe { CString::from_raw(ptr) }); + } +} + // ─── Devtools FFI ───────────────────────────────────────────────────────────── #[no_mangle] diff --git a/packages/windows-hermes/Cargo.lock b/packages/windows-hermes/Cargo.lock index 3d571d2..e0507e6 100644 --- a/packages/windows-hermes/Cargo.lock +++ b/packages/windows-hermes/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "serde", "version_check", @@ -171,6 +206,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -231,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -244,6 +290,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -378,6 +433,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -390,6 +456,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -593,6 +669,15 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" version = "0.13.0" @@ -837,6 +922,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking_lot" version = "0.12.5" @@ -878,6 +969,18 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -923,6 +1026,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -975,12 +1087,14 @@ dependencies = [ name = "runtime" version = "0.1.0" dependencies = [ + "aes-gcm", "ahash", "anyhow", "backtrace", "byteorder", "chrono", "form_urlencoded", + "getrandom 0.2.17", "libc", "libffi", "metadata", @@ -1141,6 +1255,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1238,6 +1358,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.8" @@ -1279,6 +1409,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" diff --git a/packages/windows-hermes/src/abi.rs b/packages/windows-hermes/src/abi.rs index 9bb12e1..4f5d9cb 100644 --- a/packages/windows-hermes/src/abi.rs +++ b/packages/windows-hermes/src/abi.rs @@ -143,6 +143,17 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) { runtime::set_log_dir(s); } +/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle +/// container. Must be called before `runtime_init`. Returns 1 on success, 0 on malformed input. +#[no_mangle] +pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int { + if key_hex.is_null() { + return 0; + } + let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy(); + runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int +} + #[no_mangle] pub extern "C" fn runtime_install_ctrlc_handler(_exit_code: i32) { // No-op on the engine hosts: the WinUI 3 process owns Ctrl+C. Present for ABI parity. @@ -170,3 +181,27 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) { drop(unsafe { CString::from_raw(ptr) }); } } + +/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. NULL +/// means no bundle loaded or the path isn't in it. Free non-NULL results with +/// `runtime_free_protected_string`. +#[no_mangle] +pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char { + if virtual_path.is_null() { + return std::ptr::null_mut(); + } + let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy(); + match runtime::source_protect::read_text(path.as_ref()) { + Some(content) => CString::new(content) + .map(|c| c.into_raw()) + .unwrap_or(std::ptr::null_mut()), + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) { + if !ptr.is_null() { + drop(unsafe { CString::from_raw(ptr) }); + } +} diff --git a/packages/windows-jsc/Cargo.lock b/packages/windows-jsc/Cargo.lock index b3b44cf..eea7305 100644 --- a/packages/windows-jsc/Cargo.lock +++ b/packages/windows-jsc/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "serde", "version_check", @@ -171,6 +206,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -231,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -244,6 +290,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -378,6 +433,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -390,6 +456,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -593,6 +669,15 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" version = "0.13.0" @@ -837,6 +922,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking_lot" version = "0.12.5" @@ -878,6 +969,18 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -923,6 +1026,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -975,12 +1087,14 @@ dependencies = [ name = "runtime" version = "0.1.0" dependencies = [ + "aes-gcm", "ahash", "anyhow", "backtrace", "byteorder", "chrono", "form_urlencoded", + "getrandom 0.2.17", "libc", "libffi", "metadata", @@ -1141,6 +1255,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1238,6 +1358,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.8" @@ -1279,6 +1409,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" diff --git a/packages/windows-jsc/src/abi.rs b/packages/windows-jsc/src/abi.rs index 00dd5e3..fc6dce6 100644 --- a/packages/windows-jsc/src/abi.rs +++ b/packages/windows-jsc/src/abi.rs @@ -146,6 +146,17 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) { runtime::set_log_dir(s); } +/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle +/// container. Must be called before `runtime_init`. Returns 1 on success, 0 on malformed input. +#[no_mangle] +pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int { + if key_hex.is_null() { + return 0; + } + let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy(); + runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int +} + #[no_mangle] pub extern "C" fn runtime_install_ctrlc_handler(_exit_code: i32) { // No-op on the engine hosts: the WinUI 3 process owns Ctrl+C. Present for ABI parity. @@ -173,3 +184,27 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) { drop(unsafe { CString::from_raw(ptr) }); } } + +/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. NULL +/// means no bundle loaded or the path isn't in it. Free non-NULL results with +/// `runtime_free_protected_string`. +#[no_mangle] +pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char { + if virtual_path.is_null() { + return std::ptr::null_mut(); + } + let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy(); + match runtime::source_protect::read_text(path.as_ref()) { + Some(content) => CString::new(content) + .map(|c| c.into_raw()) + .unwrap_or(std::ptr::null_mut()), + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) { + if !ptr.is_null() { + drop(unsafe { CString::from_raw(ptr) }); + } +} diff --git a/packages/windows-quickjs/Cargo.lock b/packages/windows-quickjs/Cargo.lock index 911792f..9798c71 100644 --- a/packages/windows-quickjs/Cargo.lock +++ b/packages/windows-quickjs/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "serde", "version_check", @@ -171,6 +206,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -231,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -244,6 +290,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -378,6 +433,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -390,6 +456,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -593,6 +669,15 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" version = "0.13.0" @@ -837,6 +922,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking_lot" version = "0.12.5" @@ -878,6 +969,18 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -923,6 +1026,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -975,12 +1087,14 @@ dependencies = [ name = "runtime" version = "0.1.0" dependencies = [ + "aes-gcm", "ahash", "anyhow", "backtrace", "byteorder", "chrono", "form_urlencoded", + "getrandom 0.2.17", "libc", "libffi", "metadata", @@ -1141,6 +1255,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1238,6 +1358,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.8" @@ -1279,6 +1409,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" diff --git a/packages/windows-quickjs/src/abi.rs b/packages/windows-quickjs/src/abi.rs index 88df250..cbdaaa7 100644 --- a/packages/windows-quickjs/src/abi.rs +++ b/packages/windows-quickjs/src/abi.rs @@ -191,6 +191,17 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) { runtime::set_log_dir(s); } +/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle +/// container. Must be called before `runtime_init`. Returns 1 on success, 0 on malformed input. +#[no_mangle] +pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int { + if key_hex.is_null() { + return 0; + } + let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy(); + runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int +} + #[no_mangle] pub extern "C" fn runtime_install_ctrlc_handler(_exit_code: i32) { // No-op on the engine hosts: the WinUI 3 process owns Ctrl+C. Present for ABI parity. @@ -218,3 +229,27 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) { drop(unsafe { CString::from_raw(ptr) }); } } + +/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. NULL +/// means no bundle loaded or the path isn't in it. Free non-NULL results with +/// `runtime_free_protected_string`. +#[no_mangle] +pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char { + if virtual_path.is_null() { + return std::ptr::null_mut(); + } + let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy(); + match runtime::source_protect::read_text(path.as_ref()) { + Some(content) => CString::new(content) + .map(|c| c.into_raw()) + .unwrap_or(std::ptr::null_mut()), + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) { + if !ptr.is_null() { + drop(unsafe { CString::from_raw(ptr) }); + } +} diff --git a/packages/windows-v8/Cargo.lock b/packages/windows-v8/Cargo.lock index 36563bb..130e06c 100644 --- a/packages/windows-v8/Cargo.lock +++ b/packages/windows-v8/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "serde", "version_check", @@ -171,6 +206,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -231,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -244,6 +290,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -378,6 +433,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -390,6 +456,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -593,6 +669,15 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" version = "0.13.0" @@ -837,6 +922,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking_lot" version = "0.12.5" @@ -878,6 +969,18 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -923,6 +1026,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -975,12 +1087,14 @@ dependencies = [ name = "runtime" version = "0.1.0" dependencies = [ + "aes-gcm", "ahash", "anyhow", "backtrace", "byteorder", "chrono", "form_urlencoded", + "getrandom 0.2.17", "libc", "libffi", "metadata", @@ -1141,6 +1255,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1238,6 +1358,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.8" @@ -1279,6 +1409,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" diff --git a/packages/windows-v8/src/abi.rs b/packages/windows-v8/src/abi.rs index 86540b3..e9752fb 100644 --- a/packages/windows-v8/src/abi.rs +++ b/packages/windows-v8/src/abi.rs @@ -146,6 +146,17 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) { runtime::set_log_dir(s); } +/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle +/// container. Must be called before `runtime_init`. Returns 1 on success, 0 on malformed input. +#[no_mangle] +pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int { + if key_hex.is_null() { + return 0; + } + let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy(); + runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int +} + #[no_mangle] pub extern "C" fn runtime_install_ctrlc_handler(_exit_code: i32) { // No-op on the engine hosts: the WinUI 3 process owns Ctrl+C. Present for ABI parity. @@ -173,3 +184,27 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) { drop(unsafe { CString::from_raw(ptr) }); } } + +/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. NULL +/// means no bundle loaded or the path isn't in it. Free non-NULL results with +/// `runtime_free_protected_string`. +#[no_mangle] +pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char { + if virtual_path.is_null() { + return std::ptr::null_mut(); + } + let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy(); + match runtime::source_protect::read_text(path.as_ref()) { + Some(content) => CString::new(content) + .map(|c| c.into_raw()) + .unwrap_or(std::ptr::null_mut()), + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) { + if !ptr.is_null() { + drop(unsafe { CString::from_raw(ptr) }); + } +} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index b71aad0..c551935 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -25,6 +25,8 @@ runtime-devtools = { path = "../runtime-devtools", optional = true } napi = { version = "2", default-features = false, features = ["napi8"], optional = true } # Native crash reporter (napi_engine hosts): symbolize SEH faults via the host PDB. backtrace = "0.3" +aes-gcm = "0.10" +getrandom = "0.2" windows-core = "0.62.2" windows-collections = "0.3.2" @@ -61,7 +63,6 @@ features = [ [features] default = [] devtools = ["runtime-devtools"] -# Engine-agnostic Node-API backend, ported from the rusty_v8 seam. napi_engine = ["dep:napi"] [dev-dependencies] @@ -73,4 +74,8 @@ harness = false [[bench]] name = "async_runtime" -harness = false \ No newline at end of file +harness = false + +[[bin]] +name = "nsbundle_pack" +path = "src/bin/nsbundle_pack.rs" \ No newline at end of file diff --git a/runtime/src/global_fns.rs b/runtime/src/global_fns.rs index a3c4a4f..31bd107 100644 --- a/runtime/src/global_fns.rs +++ b/runtime/src/global_fns.rs @@ -197,23 +197,24 @@ pub(crate) fn normalize_js_path(path: &str) -> PathBuf { } pub(crate) fn try_resolve_with_known_extensions(candidate: PathBuf) -> PathBuf { - if candidate.exists() { + // Each `.exists()` probe also checks the sealed app.nsbundle's decrypted table (a no-op, + // cheap OnceLock read when no bundle is loaded) — a packed app has no real `app/` directory + // on disk, so `is_dir()`/`.exists()` alone would never resolve anything. + if candidate.exists() || crate::source_protect::contains(&candidate.to_string_lossy()) { return candidate; } if candidate.extension().is_none() { for ext in ["js", "mjs", "cjs"] { let with_ext = candidate.with_extension(ext); - if with_ext.exists() { + if with_ext.exists() || crate::source_protect::contains(&with_ext.to_string_lossy()) { return with_ext; } } } - if candidate.is_dir() { - for index_file in ["index.js", "index.mjs", "index.cjs"] { - let with_index = candidate.join(index_file); - if with_index.exists() { - return with_index; - } + for index_file in ["index.js", "index.mjs", "index.cjs"] { + let with_index = candidate.join(index_file); + if with_index.exists() || crate::source_protect::contains(&with_index.to_string_lossy()) { + return with_index; } } candidate @@ -788,6 +789,14 @@ pub(crate) fn handle_read_text_file( throw_js_error(scope, "__nsReadTextFile: path is empty"); return; } + if let Some(content) = crate::source_protect::read_text(path.as_str()) { + if let Some(value) = v8::String::new(scope, content.as_str()) { + retval.set(value.into()); + } else { + retval.set_null(); + } + return; + } match fs::read_to_string(Path::new(path.as_str())) { Ok(content) => { if let Some(value) = v8::String::new(scope, content.as_str()) { @@ -946,7 +955,11 @@ pub(crate) fn handle_resolve_module_path( let parent = parent_path .map(|v| normalize_js_path(v.as_str())) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - let base = if parent.is_file() { + // A packed (virtual, no-plaintext-on-disk) referrer never satisfies `.is_file()`, so also + // check the sealed bundle's table — otherwise a relative require() from a bundle-only file + // would wrongly treat the referrer itself as the base directory instead of its parent. + let base = if parent.is_file() || crate::source_protect::contains(&parent.to_string_lossy()) + { parent.parent().map(Path::to_path_buf).unwrap_or(parent) } else { parent diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 0a770eb..0d5e6ef 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -24,6 +24,7 @@ pub mod napi_engine; mod ns_proxy; mod property_call; mod proxy_manifest_loader; +pub mod source_protect; pub mod timers; mod type_description; pub mod ui_dispatcher; @@ -1330,25 +1331,26 @@ fn normalize_js_path(path: &str) -> PathBuf { } fn try_resolve_with_known_extensions(candidate: PathBuf) -> PathBuf { - if candidate.exists() { + // Each `.exists()` probe also checks the sealed app.nsbundle's decrypted table (a no-op, + // cheap OnceLock read when no bundle is loaded) — a packed app has no real `app/` directory + // on disk, so `is_dir()`/`.exists()` alone would never resolve anything. + if candidate.exists() || crate::source_protect::contains(&candidate.to_string_lossy()) { return candidate; } if candidate.extension().is_none() { for ext in ["js", "mjs", "cjs"] { let with_ext = candidate.with_extension(ext); - if with_ext.exists() { + if with_ext.exists() || crate::source_protect::contains(&with_ext.to_string_lossy()) { return with_ext; } } } - if candidate.is_dir() { - for index_file in ["index.js", "index.mjs", "index.cjs"] { - let with_index = candidate.join(index_file); - if with_index.exists() { - return with_index; - } + for index_file in ["index.js", "index.mjs", "index.cjs"] { + let with_index = candidate.join(index_file); + if with_index.exists() || crate::source_protect::contains(&with_index.to_string_lossy()) { + return with_index; } } @@ -1363,7 +1365,11 @@ fn resolve_esm_path(specifier: &str, referrer_path: Option<&str>) -> String { let parent = referrer_path .map(normalize_js_path) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - let base = if parent.is_file() { + // A packed (virtual, no-plaintext-on-disk) referrer never satisfies `.is_file()`, so also + // check the sealed bundle's table — otherwise a relative import from a bundle-only module + // would wrongly treat the referrer itself as the base directory instead of its parent. + let base = if parent.is_file() || crate::source_protect::contains(&parent.to_string_lossy()) + { parent.parent().map(Path::to_path_buf).unwrap_or(parent) } else { parent @@ -1450,6 +1456,10 @@ fn compile_module_graph(scope: &mut v8::PinScope<'_, '_>, source: &str, path: &s if ESM_MODULE_REGISTRY.with(|r| r.borrow().contains_key(&child_path)) { continue; } + if let Some(content) = crate::source_protect::read_text(&child_path) { + compile_module_graph(scope, &content, &child_path); + continue; + } match fs::read_to_string(&child_path) { Ok(content) => compile_module_graph(scope, &content, &child_path), Err(e) => debug_output(&format!( @@ -7524,6 +7534,11 @@ pub(crate) fn handle_update_items_source( impl Runtime { pub fn new(app_root: &str) -> Self { + // Look for a sealed app.nsbundle next to app_root before anything else touches the + // filesystem for JS source — every read/resolve point below consults the decrypted + // in-memory table first and falls back to disk when none was found. + crate::source_protect::init_from_app_root(app_root); + INIT.call_once(|| { // --expose-gc makes gc() available as a global JS function so callers // can trigger a full GC sweep (useful for debugging and test harnesses). @@ -7616,15 +7631,20 @@ impl Runtime { let referrer_path = value_to_string(scope, resource_name); let resolved = resolve_esm_path(&spec, referrer_path.as_deref()); - match std::fs::read_to_string(&resolved) { - Ok(content) => compile_module_graph(scope, &content, &resolved), - Err(e) => { - if let Some(err_str) = - v8::String::new(scope, &format!("ESM: cannot read {resolved}: {e}")) - { - resolver.reject(scope, err_str.into()); + if let Some(content) = crate::source_protect::read_text(&resolved) { + compile_module_graph(scope, &content, &resolved); + } else { + match std::fs::read_to_string(&resolved) { + Ok(content) => compile_module_graph(scope, &content, &resolved), + Err(e) => { + if let Some(err_str) = v8::String::new( + scope, + &format!("ESM: cannot read {resolved}: {e}"), + ) { + resolver.reject(scope, err_str.into()); + } + return Some(resolver.get_promise(scope)); } - return Some(resolver.get_promise(scope)); } } diff --git a/runtime/src/napi_engine/host_abi.rs b/runtime/src/napi_engine/host_abi.rs index c30a213..db8475c 100644 --- a/runtime/src/napi_engine/host_abi.rs +++ b/runtime/src/napi_engine/host_abi.rs @@ -30,6 +30,10 @@ use crate::napi_engine::{event_loop, globals, invoke, module_natives, ns_proxy}; /// This is exactly what the standalone hosts do before running app code; the only thing left to /// the caller is running the engine's JS prelude/polyfills (which need the engine's own eval). pub fn initialize_runtime(env: &Env, app_root: &str) -> napi::Result<()> { + // Look for a sealed app.nsbundle next to app_root before anything else touches the + // filesystem for JS source — module_natives' __nsReadTextFile/__nsResolveModulePath below + // consult the decrypted in-memory table first and fall back to disk when none was found. + crate::source_protect::init_from_app_root(app_root); install_panic_logging_hook(); install_native_crash_handler(); invoke::ensure_winrt_initialized(); diff --git a/runtime/src/napi_engine/module_natives.rs b/runtime/src/napi_engine/module_natives.rs index 5e497ca..8e1a714 100644 --- a/runtime/src/napi_engine/module_natives.rs +++ b/runtime/src/napi_engine/module_natives.rs @@ -55,6 +55,9 @@ pub fn install_module_natives(env: &Env, app_root: &str) -> napi::Result<()> { if path.is_empty() { return Err(napi::Error::from_reason("__nsReadTextFile: path is empty")); } + if let Some(content) = crate::source_protect::read_text(&path) { + return Ok(env.create_string(&content)?); + } match std::fs::read_to_string(Path::new(&path)) { Ok(content) => Ok(env.create_string(&content)?), Err(err) => Err(napi::Error::from_reason(format!( @@ -84,7 +87,12 @@ pub fn install_module_natives(env: &Env, app_root: &str) -> napi::Result<()> { let parent = parent_path .map(|v| crate::global_fns::normalize_js_path(&v)) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - let base = if parent.is_file() { + // A packed (virtual, no-plaintext-on-disk) referrer never satisfies `.is_file()`, + // so also check the sealed bundle's table — otherwise a relative require() from a + // bundle-only file would wrongly treat the referrer as the base dir, not its parent. + let base = if parent.is_file() + || crate::source_protect::contains(&parent.to_string_lossy()) + { parent.parent().map(Path::to_path_buf).unwrap_or(parent) } else { parent diff --git a/runtime/src/source_protect.rs b/runtime/src/source_protect.rs new file mode 100644 index 0000000..dc2f6d5 --- /dev/null +++ b/runtime/src/source_protect.rs @@ -0,0 +1,483 @@ +use std::collections::HashMap; +use std::fs; +use std::io::{self, Write}; +use std::path::Path; +use std::sync::OnceLock; + +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use byteorder::{ByteOrder, LittleEndian, WriteBytesExt}; + +const MAGIC: &[u8; 4] = b"NSB1"; +const VERSION: u8 = 1; +const HEADER_LEN: usize = 4 + 1 + 1 + 2 + 16 + 4; // magic+version+key_mode+reserved+key_check+entry_count +const KEY_CHECK_AAD: &[u8] = b"nsbundle-keycheck"; + +/// `key_mode` byte: the container was sealed with the compiled-in default pepper. +pub const KEY_MODE_DEFAULT: u8 = 0; +/// `key_mode` byte: the container expects a custom key via [`set_custom_key_hex`]/ +/// `runtime_set_bundle_key` before it can be opened. +pub const KEY_MODE_CUSTOM: u8 = 1; + +const PEPPER_A: [u8; 32] = [ + 0x4e, 0x53, 0x42, 0x31, 0x8a, 0x2f, 0xd1, 0x77, 0x03, 0x9c, 0x5e, 0x61, 0xb4, 0x22, 0xf0, 0x18, + 0x6d, 0xa9, 0x3b, 0xc7, 0x14, 0x5f, 0x88, 0xe2, 0x30, 0x0b, 0x97, 0x44, 0xd6, 0x1a, 0x59, 0xc3, +]; +const PEPPER_B: [u8; 32] = [ + 0x71, 0xe4, 0x0d, 0x9a, 0x56, 0x2c, 0xbf, 0x08, 0xa1, 0x33, 0x7e, 0xd0, 0x49, 0x8c, 0x15, 0xf6, + 0x3d, 0x60, 0xab, 0x24, 0x99, 0x0e, 0x52, 0xc8, 0x7b, 0x1f, 0xde, 0x45, 0x83, 0x6a, 0xf1, 0x27, +]; +const PEPPER_C: [u8; 32] = [ + 0x92, 0x1b, 0xc5, 0x4a, 0x0f, 0x87, 0x3e, 0xd9, 0x66, 0xaa, 0x21, 0x5c, 0xf3, 0x08, 0x7d, 0x94, + 0x11, 0x4e, 0xb0, 0x8f, 0x3a, 0xc6, 0x02, 0x59, 0xdd, 0x74, 0x1c, 0xa8, 0x60, 0xef, 0x35, 0x8b, +]; + +fn default_pepper_key() -> [u8; 32] { + static KEY: OnceLock<[u8; 32]> = OnceLock::new(); + *KEY.get_or_init(|| { + let mut k = [0u8; 32]; + for i in 0..32 { + k[i] = PEPPER_A[i] ^ PEPPER_B[i] ^ PEPPER_C[i]; + } + k + }) +} + +static CUSTOM_KEY: OnceLock<[u8; 32]> = OnceLock::new(); +/// `None` until [`init_from_app_root`] runs; empty map means "no bundle found/loaded" so every +/// lookup falls straight back to the filesystem. +static BUNDLE_TABLE: OnceLock>> = OnceLock::new(); + +fn seal(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], plaintext: &[u8]) -> Vec { + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + cipher + .encrypt(Nonce::from_slice(nonce), Payload { msg: plaintext, aad }) + .expect("aes-256-gcm encrypt should not fail for in-memory buffers") +} + +fn open(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], ciphertext: &[u8]) -> Result, ()> { + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + cipher + .decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad }) + .map_err(|_| ()) +} + +fn random_nonce() -> [u8; 12] { + let mut buf = [0u8; 12]; + getrandom::getrandom(&mut buf).expect("OS RNG must be available to seal app.nsbundle"); + buf +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +fn hex_decode_32(hex: &str) -> Option<[u8; 32]> { + let bytes = hex.as_bytes(); + if bytes.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = (hex_val(bytes[i * 2])? << 4) | hex_val(bytes[i * 2 + 1])?; + } + Some(out) +} + +/// Parse a 64-hex-char (32-byte) key and store it as the custom key used to open/seal +/// `key_mode == 1` containers. Must be called before the container is opened (i.e. before +/// `runtime_init`/`initialize_runtime`, mirroring `runtime_set_local_folder`'s ordering +/// requirement). Returns `false` on malformed input (wrong length or non-hex characters). +pub fn set_custom_key_hex(hex: &str) -> bool { + let Some(key) = hex_decode_32(hex) else { + return false; + }; + let _ = CUSTOM_KEY.set(key); + true +} + +/// Case-insensitively strip everything up to and including the last `app`/`App` path segment, +/// lowercase the remainder, and normalize separators to `/`. Every call site that builds a +/// candidate path for module resolution already joins onto an `app`/`App` directory (e.g. +/// `module_natives.rs`'s `app_root.join("app")`/`.join("App")`, `global_fns.rs`'s identical +/// probe), so this recovers the packer's TOC key regardless of which absolute prefix or casing +/// a given host used. Falls back to the whole (lowercased) path if no such segment is present. +/// +/// Simplification: this takes the *last* matching segment, so a legitimately nested directory +/// literally named `app`/`App` deeper inside the packed tree would be mis-stripped. Not worth +/// guarding against for a NativeScript webpack output tree, which doesn't nest a directory named +/// that. +fn relativize(path_like: &str) -> String { + let normalized = path_like.replace('\\', "/"); + let segments: Vec<&str> = normalized.split('/').collect(); + let stripped = match segments.iter().rposition(|s| s.eq_ignore_ascii_case("app")) { + Some(pos) => segments[pos + 1..].join("/"), + None => normalized, + }; + // Relative-specifier resolution against a referrer with no directory component (e.g. a + // top-level "entry.mjs" importing "./dep.mjs") can leave a leading "./" in the joined + // candidate; strip it so it still matches the packer's TOC key (which never has one). + let mut s = stripped.as_str(); + while let Some(rest) = s.strip_prefix("./") { + s = rest; + } + s.to_lowercase() +} + +/// Look up a JS source file by any of the path forms the four read points build (absolute, +/// relative, either casing of `app`/`App`). `None` means "not in the loaded bundle" — callers +/// fall back to reading the real filesystem, whether that's because no bundle was loaded at all +/// or the specific file simply isn't packed. +pub fn read_text(path_like: &str) -> Option { + let table = BUNDLE_TABLE.get()?; + if table.is_empty() { + return None; + } + table + .get(&relativize(path_like)) + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()) +} + +/// Existence probe used by the extension/index-file resolution loops (`try_resolve_with_known_extensions` +/// in both `lib.rs` and `global_fns.rs`) alongside the existing `Path::exists()` check. +pub fn contains(path_like: &str) -> bool { + match BUNDLE_TABLE.get() { + Some(table) if !table.is_empty() => table.contains_key(&relativize(path_like)), + _ => false, + } +} + +/// Whether a non-empty bundle is currently loaded. +pub fn has_bundle() -> bool { + BUNDLE_TABLE.get().is_some_and(|t| !t.is_empty()) +} + +/// Locate and decrypt `app.nsbundle`, if present, next to the app root the host was initialized +/// with. `app_root` is the same string passed to `runtime_init`/`initialize_runtime` — in practice +/// the exe's own directory (`AppContext.BaseDirectory` on the C# side; see `RuntimeHost.cs`'s +/// `ResolveEntryScriptPath`, which checks both that directory and its parent for the `app`/`App` +/// folder). Checked here in the same two spots: +/// +/// - `/app.nsbundle` (sibling of `app`/`App` when they live directly under `app_root`) +/// - `/app.nsbundle` (sibling of `bin/` when the project root is one level up) +/// +/// No container found at either candidate: leaves the table unset, every lookup falls back to +/// disk — fully backward compatible with today's plaintext `app/` trees. Found but fails to open +/// (bad magic/version, wrong default-pepper key, corrupted): logs and fails closed to an empty +/// table (same plaintext fallback). Found with `key_mode == KEY_MODE_CUSTOM` and no key was ever +/// supplied via [`set_custom_key_hex`]: fails loud instead — the app author explicitly opted into +/// custom-key protection, so silently falling back to plaintext-on-disk (which doesn't even exist +/// once packed) would hide that mistake rather than surface it. +pub fn init_from_app_root(app_root: &str) { + if app_root.is_empty() { + return; + } + let base = Path::new(app_root); + let mut candidates = vec![base.join("app.nsbundle")]; + if let Some(parent) = base.parent() { + candidates.push(parent.join("app.nsbundle")); + } + + for candidate in &candidates { + if !candidate.is_file() { + continue; + } + match open_and_decrypt(candidate) { + Ok(table) => { + let _ = BUNDLE_TABLE.set(table); + } + Err(err) => { + eprintln!( + "[NativeScript] app.nsbundle at {} failed to load: {err}", + candidate.display() + ); + let _ = BUNDLE_TABLE.set(HashMap::new()); + } + } + return; + } +} + +fn open_and_decrypt(path: &Path) -> Result>, String> { + let data = fs::read(path).map_err(|e| format!("read failed: {e}"))?; + if data.len() < HEADER_LEN || &data[0..4] != MAGIC { + return Err("not an nsbundle container (bad magic)".to_string()); + } + let version = data[4]; + if version != VERSION { + return Err(format!("unsupported nsbundle version {version}")); + } + let key_mode = data[5]; + let key_check = &data[8..24]; + let entry_count = LittleEndian::read_u32(&data[24..28]) as usize; + + let key = match key_mode { + KEY_MODE_DEFAULT => default_pepper_key(), + KEY_MODE_CUSTOM => *CUSTOM_KEY.get().ok_or_else(|| { + "requires runtime_set_bundle_key(); none was set before the bundle was opened" + .to_string() + })?, + other => return Err(format!("unknown key_mode {other}")), + }; + + if open(&key, &[0u8; 12], KEY_CHECK_AAD, key_check).is_err() { + return Err("key mismatch (wrong key for this bundle)".to_string()); + } + + let mut cursor = HEADER_LEN; + struct TocEntry { + path: String, + blob_offset: u64, + blob_len: u64, + nonce: [u8; 12], + } + let mut toc = Vec::with_capacity(entry_count); + for _ in 0..entry_count { + if cursor + 2 > data.len() { + return Err("truncated TOC".to_string()); + } + let path_len = LittleEndian::read_u16(&data[cursor..cursor + 2]) as usize; + cursor += 2; + if cursor + path_len > data.len() { + return Err("truncated TOC path".to_string()); + } + let path = String::from_utf8(data[cursor..cursor + path_len].to_vec()) + .map_err(|_| "invalid UTF-8 path in TOC".to_string())?; + cursor += path_len; + if cursor + 8 + 8 + 12 > data.len() { + return Err("truncated TOC entry".to_string()); + } + let blob_offset = LittleEndian::read_u64(&data[cursor..cursor + 8]); + cursor += 8; + let blob_len = LittleEndian::read_u64(&data[cursor..cursor + 8]); + cursor += 8; + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&data[cursor..cursor + 12]); + cursor += 12; + toc.push(TocEntry { path, blob_offset, blob_len, nonce }); + } + + let blob_section_start = cursor; + let mut table = HashMap::with_capacity(toc.len()); + for entry in toc { + let start = blob_section_start + entry.blob_offset as usize; + let end = start + entry.blob_len as usize; + if end > data.len() { + return Err(format!("blob out of range for {}", entry.path)); + } + let plaintext = open(&key, &entry.nonce, entry.path.as_bytes(), &data[start..end]) + .map_err(|_| format!("failed to decrypt {} (tampered container or wrong key)", entry.path))?; + table.insert(entry.path.to_lowercase(), plaintext); + } + Ok(table) +} + +fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(String, Vec)>) -> io::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_files(root, &path, out)?; + } else if path.is_file() { + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + let bytes = fs::read(&path)?; + out.push((rel, bytes)); + } + } + Ok(()) +} + +/// Pack every file under `input` (recursively) into a sealed `app.nsbundle` at `output`, encrypting +/// each with `key` (AES-256-GCM, one blob per file, path bytes as AAD). `key_mode` is recorded +/// verbatim in the header so the runtime knows whether to use the default pepper +/// ([`KEY_MODE_DEFAULT`]) or require a custom key via `runtime_set_bundle_key` +/// ([`KEY_MODE_CUSTOM`]) — the caller (the `nsbundle_pack` CLI) is responsible for using the same +/// key value the app will supply at runtime when `key_mode == KEY_MODE_CUSTOM`. +pub fn pack_directory(input: &Path, output: &Path, key_mode: u8, key: [u8; 32]) -> io::Result<()> { + let mut files = Vec::new(); + collect_files(input, input, &mut files)?; + files.sort_by(|a, b| a.0.cmp(&b.0)); + + let key_check = seal(&key, &[0u8; 12], KEY_CHECK_AAD, &[]); + debug_assert_eq!(key_check.len(), 16, "empty-plaintext AES-GCM output must be tag-only"); + + let mut entries_buf = Vec::new(); + let mut blobs = Vec::with_capacity(files.len()); + let mut blob_offset: u64 = 0; + for (path, bytes) in &files { + let nonce = random_nonce(); + let ciphertext = seal(&key, &nonce, path.as_bytes(), bytes); + entries_buf.write_u16::(path.len() as u16)?; + entries_buf.extend_from_slice(path.as_bytes()); + entries_buf.write_u64::(blob_offset)?; + entries_buf.write_u64::(ciphertext.len() as u64)?; + entries_buf.extend_from_slice(&nonce); + blob_offset += ciphertext.len() as u64; + blobs.push(ciphertext); + } + + let mut out = Vec::with_capacity(HEADER_LEN + entries_buf.len() + blob_offset as usize); + out.extend_from_slice(MAGIC); + out.push(VERSION); + out.push(key_mode); + out.extend_from_slice(&[0u8; 2]); + out.extend_from_slice(&key_check); + out.write_u32::(files.len() as u32)?; + out.extend_from_slice(&entries_buf); + for blob in blobs { + out.extend_from_slice(&blob); + } + + fs::write(output, out) +} + +/// The compiled-in default key, exposed so `nsbundle_pack` can seal `key_mode == KEY_MODE_DEFAULT` +/// containers without duplicating the pepper constants. +pub fn default_key() -> [u8; 32] { + default_pepper_key() +} + +/// Parse a 64-hex-char string into a 32-byte key (used by `nsbundle_pack`'s `--key-hex` flag). +pub fn parse_key_hex(hex: &str) -> Option<[u8; 32]> { + hex_decode_32(hex) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn scratch_dir(name: &str) -> std::path::PathBuf { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("nsbundle_test_{name}_{}_{n}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn pack_and_open_round_trip() { + let input = scratch_dir("roundtrip_in"); + fs::write(input.join("bundle.js"), b"console.log('hi')").unwrap(); + fs::create_dir_all(input.join("sub")).unwrap(); + fs::write(input.join("sub").join("mod.js"), b"module.exports = 1;").unwrap(); + + let output = scratch_dir("roundtrip_out").join("app.nsbundle"); + // key_mode 0 always opens with the real compiled-in pepper, regardless of what key + // pack_directory was called with — so the round trip must use that same pepper to open + // successfully. (Custom-key round trips are covered indirectly by tampered_ciphertext_fails, + // which packs KEY_MODE_CUSTOM and opens with the exact key it packed with.) + let key = default_key(); + pack_directory(&input, &output, KEY_MODE_DEFAULT, key).unwrap(); + + let table = open_and_decrypt(&output).unwrap(); + assert_eq!( + table.get("bundle.js").map(|v| v.as_slice()), + Some(b"console.log('hi')".as_slice()) + ); + assert_eq!( + table.get("sub/mod.js").map(|v| v.as_slice()), + Some(b"module.exports = 1;".as_slice()) + ); + } + + #[test] + fn wrong_key_fails_key_check() { + let input = scratch_dir("wrongkey_in"); + fs::write(input.join("a.js"), b"a").unwrap(); + let output = scratch_dir("wrongkey_out").join("app.nsbundle"); + pack_directory(&input, &output, KEY_MODE_DEFAULT, [1u8; 32]).unwrap(); + + // open_and_decrypt always resolves key_mode 0 to the real default pepper, so instead + // exercise the primitive directly: the key used to check must match the key used to seal. + let data = fs::read(&output).unwrap(); + let key_check = &data[8..24]; + assert!(open(&[2u8; 32], &[0u8; 12], KEY_CHECK_AAD, key_check).is_err()); + assert!(open(&[1u8; 32], &[0u8; 12], KEY_CHECK_AAD, key_check).is_ok()); + } + + #[test] + fn tampered_ciphertext_fails() { + let input = scratch_dir("tamper_in"); + fs::write(input.join("a.js"), b"original content").unwrap(); + let output_dir = scratch_dir("tamper_out"); + let output = output_dir.join("app.nsbundle"); + let key = [9u8; 32]; + pack_directory(&input, &output, KEY_MODE_CUSTOM, key).unwrap(); + + let mut data = fs::read(&output).unwrap(); + let last = data.len() - 1; + data[last] ^= 0xff; // flip a byte inside the last blob's GCM tag/ciphertext + fs::write(&output, &data).unwrap(); + + // Manually walk the header since open_and_decrypt() only knows the default/custom keys + // via the global statics, not an arbitrary caller-supplied key. + let entry_count = LittleEndian::read_u32(&data[24..28]) as usize; + assert_eq!(entry_count, 1); + let path_len = LittleEndian::read_u16(&data[HEADER_LEN..HEADER_LEN + 2]) as usize; + let mut cursor = HEADER_LEN + 2 + path_len; + let blob_offset = LittleEndian::read_u64(&data[cursor..cursor + 8]); + cursor += 8; + let blob_len = LittleEndian::read_u64(&data[cursor..cursor + 8]); + cursor += 8; + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&data[cursor..cursor + 12]); + cursor += 12; + let blob_start = cursor + blob_offset as usize; + let blob_end = blob_start + blob_len as usize; + assert!(open(&key, &nonce, b"a.js", &data[blob_start..blob_end]).is_err()); + } + + #[test] + fn aad_binds_ciphertext_to_its_path() { + let key = [3u8; 32]; + let nonce = [0u8; 12]; + let ciphertext = seal(&key, &nonce, b"a.js", b"secret contents"); + // Same key/nonce/ciphertext, wrong AAD (as if the TOC had been spliced to claim this + // blob belongs to a different file) must fail rather than decrypt. + assert!(open(&key, &nonce, b"b.js", &ciphertext).is_err()); + assert!(open(&key, &nonce, b"a.js", &ciphertext).is_ok()); + } + + #[test] + fn relativize_strips_app_segment_case_insensitively() { + assert_eq!(relativize("C:\\proj\\bin\\App\\sub\\file.js"), "sub/file.js"); + assert_eq!(relativize("C:/proj/bin/app/file.js"), "file.js"); + assert_eq!(relativize("sub/file.js"), "sub/file.js"); + assert_eq!(relativize("C:\\no\\app\\segment\\HERE\\file.js"), "segment/here/file.js"); + } + + #[test] + fn relativize_strips_leading_current_dir_prefix() { + // Left over from joining a relative specifier ("./dep.mjs") against an empty base when + // the referrer has no directory component of its own (e.g. a top-level "entry.mjs"). + assert_eq!(relativize("./dep.mjs"), "dep.mjs"); + assert_eq!(relativize(".\\dep.mjs"), "dep.mjs"); + } + + #[test] + fn hex_key_round_trips() { + // Exactly 64 hex chars (32 bytes): "00112233445566778899aabbccddeeff" (16 bytes) twice. + let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + assert_eq!(hex.len(), 64); + let key = parse_key_hex(hex).unwrap(); + assert_eq!(key[0], 0x00); + assert_eq!(key[1], 0x11); + assert_eq!(key[15], 0xff); + assert_eq!(key[31], 0xff); + assert!(parse_key_hex("too-short").is_none()); + assert!(parse_key_hex(&"zz".repeat(32)).is_none()); + assert!(parse_key_hex(&format!("{hex}ff")).is_none()); // 65 hex chars: one byte too long + } +} diff --git a/sbg_output/sbg_metadata.json b/sbg_output/sbg_metadata.json new file mode 100644 index 0000000..494252f --- /dev/null +++ b/sbg_output/sbg_metadata.json @@ -0,0 +1,55 @@ +[ + { + "typeName": "TemplateNamedJsonObject", + "className": "TemplateNamedJsonObject", + "namespace": null, + "baseClass": "Windows.Data.Json.JsonObject", + "methods": [ + { + "name": "ToString", + "returnType": "String", + "parameters": [] + } + ], + "properties": [], + "interfaces": [], + "isAutoGeneratedName": false, + "registeredAt": "2026-08-05T12:06:04.925Z" + }, + { + "typeName": "com.tns.gen.winrt.windows.data.json.JsonObject_AutoProxy_2", + "className": "JsonObject_AutoProxy_2", + "namespace": "com.tns.gen.winrt.windows.data.json", + "baseClass": "Windows.Data.Json.JsonObject", + "methods": [ + { + "name": "ToString", + "returnType": "String", + "parameters": [] + } + ], + "properties": [], + "interfaces": [], + "isAutoGeneratedName": false, + "registeredAt": "2026-08-05T12:06:04.959Z" + }, + { + "typeName": "com.tns.gen.winrt.windows.Object_AutoProxy_3", + "className": "Object_AutoProxy_3", + "namespace": "com.tns.gen.winrt.windows", + "baseClass": "Object", + "methods": [ + { + "name": "ToString", + "returnType": "String", + "parameters": [] + } + ], + "properties": [], + "interfaces": [ + "Windows.Foundation.IStringable" + ], + "isAutoGeneratedName": false, + "registeredAt": "2026-08-05T12:06:04.977Z" + } +] \ No newline at end of file diff --git a/template/build.ps1 b/template/build.ps1 index 2c5bd64..5900012 100644 --- a/template/build.ps1 +++ b/template/build.ps1 @@ -289,6 +289,45 @@ if (Test-Path $x64Path) { Write-Host " copied dotnet-tool-x64.exe -> dotnet-tool.exe" } +# nsbundle_pack prebuilt binaries (source-protection packer — seals an app's webpack output +# directory into an encrypted app.nsbundle. It's a [[bin]] +# target inside the `runtime` crate rather than its own package, unlike dotnet-tool, but is +# otherwise staged identically so nativescript-cli can resolve/invoke it the same way. +Write-Host "`n=== Build nsbundle_pack prebuilt binaries ===" -ForegroundColor Cyan +if (-not (Test-Path $ToolsDir)) { New-Item -ItemType Directory -Force -Path $ToolsDir | Out-Null } + +foreach ($t in $Targets) { + $arch = $t.Arch + $rustTarget = $t.RustTarget + Write-Host "Building nsbundle_pack for $arch ($rustTarget)..." + Push-Location $RepoRoot + try { + & cargo build -p runtime --bin nsbundle_pack --release --target $rustTarget + $buildExit = $LASTEXITCODE + } finally { + Pop-Location + } + if ($buildExit -ne 0) { + Write-Host "cargo build for nsbundle_pack failed for target $rustTarget (exit $buildExit). Skipping copy for $arch." -ForegroundColor Yellow + continue + } + $candidate = Join-Path $RepoRoot "target\$rustTarget\release\nsbundle_pack.exe" + if (Test-Path $candidate) { + $dest = Join-Path $ToolsDir "nsbundle_pack-$arch.exe" + Copy-Item -Force $candidate $dest + Write-Host " copied nsbundle_pack -> $(Resolve-Path $dest -Relative)" + } else { + Write-Host "Expected build output not found: $candidate" -ForegroundColor Yellow + } +} + +# Provide a generic `nsbundle_pack.exe` fallback (copy x64 if available) +$nsbundlePackX64Path = Join-Path $ToolsDir "nsbundle_pack-x64.exe" +if (Test-Path $nsbundlePackX64Path) { + Copy-Item -Force $nsbundlePackX64Path (Join-Path $ToolsDir "nsbundle_pack.exe") + Write-Host " copied nsbundle_pack-x64.exe -> nsbundle_pack.exe" +} + # sbg prebuilt binaries Write-Host "`n=== Build sbg prebuilt binaries ===" -ForegroundColor Cyan diff --git a/template/framework/__PROJECT_NAME__/RuntimeHost.cs b/template/framework/__PROJECT_NAME__/RuntimeHost.cs index cb73376..92b2e0e 100644 --- a/template/framework/__PROJECT_NAME__/RuntimeHost.cs +++ b/template/framework/__PROJECT_NAME__/RuntimeHost.cs @@ -32,6 +32,20 @@ internal sealed class RuntimeHost : IDisposable [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_local_folder))] private static extern void runtime_set_local_folder([MarshalAs(UnmanagedType.LPUTF8Str)] string localFolder); + // Escape hatch for apps packed with `nsbundle_pack --key-hex ` (custom-key + // app.nsbundle containers). Must be called before runtime_init if used at all — apps + // packed with the default pepper (no --key-hex) never need to call this. Not wired to a + // default call site here; an app author supplies their own key material and calls this + // from Initialize() before runtime_init(...). + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_bundle_key))] + private static extern int runtime_set_bundle_key([MarshalAs(UnmanagedType.LPUTF8Str)] string keyHex); + + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_read_protected_file))] + private static extern IntPtr runtime_read_protected_file([MarshalAs(UnmanagedType.LPUTF8Str)] string virtualPath); + + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_free_protected_string))] + private static extern void runtime_free_protected_string(IntPtr ptr); + [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_get_last_js_error))] private static extern IntPtr runtime_get_last_js_error(); @@ -191,6 +205,27 @@ private static bool ConsumeDebugBreakMarker() } #endif + /// Reads a file that may live inside a sealed app.nsbundle instead of on disk: tries the + /// protected-VFS native call first (cheap no-op when no bundle is loaded), falls back to + /// the real filesystem. `path` is whatever candidate the caller already built for + /// `File.Exists`/`File.ReadAllText` — the native side strips it down to the bundle's + /// virtual (app/App-relative) path itself, so no extra bookkeeping is needed here. + private static string TryReadVirtual(string path) + { + IntPtr ptr = IntPtr.Zero; + try + { + ptr = runtime_read_protected_file(path); + return ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr); + } + catch { return null; } + finally { if (ptr != IntPtr.Zero) runtime_free_protected_string(ptr); } + } + + private static bool VExists(string path) => TryReadVirtual(path) != null || File.Exists(path); + + private static string VReadAllText(string path) => TryReadVirtual(path) ?? File.ReadAllText(path); + public void RunMainScript() { if (!_initialized) @@ -208,7 +243,7 @@ public void RunMainScript() foreach (var chunkName in new[] { "runtime.js", "vendor.js" }) { var chunkPath = Path.Combine(dir, chunkName); - if (File.Exists(chunkPath) && + if (VExists(chunkPath) && !string.Equals(chunkPath, Path.GetFullPath(entryPath), StringComparison.OrdinalIgnoreCase)) { chunks.Add(chunkPath); @@ -218,7 +253,7 @@ public void RunMainScript() foreach (var scriptPath in chunks) { - var script = File.ReadAllText(Path.GetFullPath(scriptPath)); + var script = VReadAllText(Path.GetFullPath(scriptPath)); try { runtime_runscript(_runtime, script, Path.GetFileName(scriptPath)); @@ -260,7 +295,7 @@ private static string ResolveEntryScriptPath() foreach (var dir in appDirCandidates) { var candidate = Path.Combine(dir, "package.json"); - if (File.Exists(candidate)) + if (VExists(candidate)) { packageJsonPath = candidate; resolvedBaseDir = dir; @@ -269,7 +304,7 @@ private static string ResolveEntryScriptPath() } // Also accept package.json at the project root (parent of bin/). - if (packageJsonPath == null && File.Exists(Path.Combine(parentDir, "package.json"))) + if (packageJsonPath == null && VExists(Path.Combine(parentDir, "package.json"))) { packageJsonPath = Path.Combine(parentDir, "package.json"); resolvedBaseDir = parentDir; @@ -278,7 +313,7 @@ private static string ResolveEntryScriptPath() string Fallback() => appDirCandidates .SelectMany(d => new[] { Path.Combine(d, "bundle.js"), Path.Combine(d, "bundle.mjs") }) - .FirstOrDefault(File.Exists); + .FirstOrDefault(VExists); if (packageJsonPath == null) return Fallback(); @@ -304,7 +339,7 @@ string Fallback() => private static RuntimePackageConfig ParsePackageConfig(string packageJsonPath) { - using var doc = JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + using var doc = JsonDocument.Parse(VReadAllText(packageJsonPath)); var config = new RuntimePackageConfig(); if (doc.RootElement.TryGetProperty("main", out var main) && main.ValueKind == JsonValueKind.String) config.Main = main.GetString(); @@ -321,11 +356,11 @@ private static string ResolveScriptPath(string baseDir, string scriptPath) foreach (var candidate in new[] { normalized, normalized + ".js", normalized + ".mjs" }) { var direct = Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDir, candidate); - if (File.Exists(direct)) return direct; + if (VExists(direct)) return direct; var appLower = Path.Combine(baseDir, "app", candidate); - if (File.Exists(appLower)) return appLower; + if (VExists(appLower)) return appLower; var appUpper = Path.Combine(baseDir, "App", candidate); - if (File.Exists(appUpper)) return appUpper; + if (VExists(appUpper)) return appUpper; } return null; } diff --git a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj index 7b04187..f6b4353 100644 --- a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj +++ b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj @@ -87,7 +87,12 @@ - + + + PreserveNewest + + PreserveNewest diff --git a/template/framework/tools/ManifestMerger/ManifestMerger.dll b/template/framework/tools/ManifestMerger/ManifestMerger.dll index ddfb2c4..f8e030d 100644 Binary files a/template/framework/tools/ManifestMerger/ManifestMerger.dll and b/template/framework/tools/ManifestMerger/ManifestMerger.dll differ diff --git a/template/framework/tools/ManifestMerger/ManifestMerger.pdb b/template/framework/tools/ManifestMerger/ManifestMerger.pdb index fcded83..7a10f68 100644 Binary files a/template/framework/tools/ManifestMerger/ManifestMerger.pdb and b/template/framework/tools/ManifestMerger/ManifestMerger.pdb differ diff --git a/template/framework/tools/dotnet-tool-x64.exe b/template/framework/tools/dotnet-tool-x64.exe index 33fa49e..a769122 100644 Binary files a/template/framework/tools/dotnet-tool-x64.exe and b/template/framework/tools/dotnet-tool-x64.exe differ diff --git a/template/framework/tools/dotnet-tool.exe b/template/framework/tools/dotnet-tool.exe index 33fa49e..a769122 100644 Binary files a/template/framework/tools/dotnet-tool.exe and b/template/framework/tools/dotnet-tool.exe differ diff --git a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.dll b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.dll index d438b26..ba194b7 100644 Binary files a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.dll and b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.dll differ diff --git a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.exe b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.exe index 86d0fd8..a16a9c2 100644 Binary files a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.exe and b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.exe differ diff --git a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.pdb b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.pdb index 0ea976a..ec74d14 100644 Binary files a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.pdb and b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.pdb differ diff --git a/template/framework/tools/nsbundle_pack-x64.exe b/template/framework/tools/nsbundle_pack-x64.exe new file mode 100644 index 0000000..f799ab2 Binary files /dev/null and b/template/framework/tools/nsbundle_pack-x64.exe differ diff --git a/template/framework/tools/nsbundle_pack.exe b/template/framework/tools/nsbundle_pack.exe new file mode 100644 index 0000000..f799ab2 Binary files /dev/null and b/template/framework/tools/nsbundle_pack.exe differ diff --git a/template/framework/tools/sbg-x64.exe b/template/framework/tools/sbg-x64.exe index 4f31250..f904708 100644 Binary files a/template/framework/tools/sbg-x64.exe and b/template/framework/tools/sbg-x64.exe differ diff --git a/template/framework/tools/sbg.exe b/template/framework/tools/sbg.exe index 4f31250..f904708 100644 Binary files a/template/framework/tools/sbg.exe and b/template/framework/tools/sbg.exe differ diff --git a/template/framework/tools/typings-generator-x64.exe b/template/framework/tools/typings-generator-x64.exe index e674d73..97ac0ad 100644 Binary files a/template/framework/tools/typings-generator-x64.exe and b/template/framework/tools/typings-generator-x64.exe differ diff --git a/template/framework/tools/typings-generator.exe b/template/framework/tools/typings-generator.exe index e674d73..97ac0ad 100644 Binary files a/template/framework/tools/typings-generator.exe and b/template/framework/tools/typings-generator.exe differ