@zogien/native-sdk 0.11.0-fork.3 → 0.11.0-fork.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/build/app.zig +53 -34
  3. package/build/cli.zig +22 -0
  4. package/package.json +10 -10
  5. package/packages/core/package-lock.json +2 -2
  6. package/packages/core/package.json +1 -1
  7. package/schemas/app.schema.json +9 -1
  8. package/skill-data/core/references/bridge-security-native-capabilities.md +4 -0
  9. package/skill-data/ts-core/SKILL.md +2 -0
  10. package/src/app_runner/app_assets.zig +85 -1
  11. package/src/app_runner/root.zig +63 -3
  12. package/src/app_runner/ts_core_main.zig +8 -0
  13. package/src/platform/linux/cef_host.cpp +22 -0
  14. package/src/platform/linux/gtk_host.c +317 -34
  15. package/src/platform/linux/gtk_host.h +37 -0
  16. package/src/platform/linux/root.zig +114 -20
  17. package/src/platform/macos/appkit_host.h +53 -0
  18. package/src/platform/macos/appkit_host.m +667 -67
  19. package/src/platform/macos/cef_host.mm +21 -0
  20. package/src/platform/macos/root.zig +146 -29
  21. package/src/platform/null_platform.zig +136 -0
  22. package/src/platform/root.zig +7 -0
  23. package/src/platform/types.zig +90 -0
  24. package/src/platform/windows/dialog_test.cpp +303 -0
  25. package/src/platform/windows/host_loop_test.cpp +169 -0
  26. package/src/platform/windows/root.zig +262 -39
  27. package/src/platform/windows/session_end_test.cpp +303 -0
  28. package/src/platform/windows/webview2_host.cpp +1435 -306
  29. package/src/primitives/app_manifest/root.zig +1 -0
  30. package/src/primitives/app_manifest/tests.zig +35 -0
  31. package/src/primitives/app_manifest/types.zig +12 -0
  32. package/src/primitives/app_manifest/validation.zig +44 -6
  33. package/src/root.zig +8 -0
  34. package/src/runtime/builtin_bridge.zig +108 -13
  35. package/src/runtime/channel_sources.zig +49 -1
  36. package/src/runtime/core.zig +24 -3
  37. package/src/runtime/dialog_tests.zig +367 -0
  38. package/src/runtime/dialogs.zig +385 -0
  39. package/src/runtime/effects.zig +83 -3
  40. package/src/runtime/effects_host_tests.zig +69 -1
  41. package/src/runtime/flow.zig +20 -6
  42. package/src/runtime/flow_reference.zig +20 -6
  43. package/src/runtime/platform_bridge_tests.zig +1 -0
  44. package/src/runtime/root.zig +9 -0
  45. package/src/runtime/single_instance.zig +893 -0
  46. package/src/runtime/system_services.zig +64 -82
  47. package/src/runtime/ui_app.zig +67 -0
  48. package/src/tooling/manifest.zig +111 -3
  49. package/src/tooling/package.zig +98 -8
  50. package/src/tooling/raw_manifest.zig +7 -0
  51. package/src/tooling/root.zig +2 -0
  52. package/src/tooling/templates.zig +44 -2
  53. package/src/tooling/update.zig +228 -10
  54. package/src/tooling/windows_installer.zig +245 -0
  55. package/src/tooling/zip_writer.zig +211 -0
  56. package/src/updater/engine.zig +936 -0
  57. package/src/updater/feed.zig +261 -12
  58. package/src/updater/macos.zig +187 -0
  59. package/src/updater/root.zig +41 -0
  60. package/src/updater/windows.zig +470 -0
  61. package/src/windows_install/config.zig +247 -0
  62. package/src/windows_install/main.zig +690 -0
  63. package/src/windows_install/payload.zig +146 -0
  64. package/src/windows_install/switch.zig +184 -0
  65. package/src/windows_install/tests.zig +11 -0
  66. package/src/windows_install/win32.zig +248 -0
  67. package/tools/native-sdk/main.zig +10 -9
@@ -1597,6 +1597,9 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
1597
1597
  \\ package.addArgs(&.{ "--web-layer", if (web_layer) "include" else "exclude" });
1598
1598
  \\ if (cef_auto_install) package.addArg("--cef-auto-install");
1599
1599
  \\ if (app_config.updates_enabled and package_target == .macos and b.graph.host.result.os.tag == .macos) package.addArg("--update-archive");
1600
+ \\ // Windows updates reach only installed apps, so an app with updates
1601
+ \\ // ships the per-user setup program beside the archive its feed names.
1602
+ \\ if (app_config.updates_enabled and package_target == .windows) package.addArgs(&.{ "--installer", "--update-archive" });
1600
1603
  \\ package.step.dependOn(&package_exe.step);
1601
1604
  \\ package.step.dependOn(&frontend_build.step);
1602
1605
  \\ const package_step = b.step("package", "Create a local package artifact");
@@ -2440,8 +2443,9 @@ fn runnerZig() []const u8 {
2440
2443
  \\ .bundle_id = self.bundle_id,
2441
2444
  \\ .icon_path = self.icon_path,
2442
2445
  \\ .update_feed_url = manifestUpdateString("feed_url"),
2443
- \\ .update_public_key = manifestUpdateString("public_key"),
2446
+ \\ .update_public_key = manifestUpdatePublicKeys(),
2444
2447
  \\ .update_check_on_start = manifestUpdateCheckOnStart(),
2448
+ \\ .update_mode = manifestUpdateMode(),
2445
2449
  \\ };
2446
2450
  \\ const windows = manifestWindowOptions(buffers);
2447
2451
  \\ if (windows.len > 0) {
@@ -2518,6 +2522,36 @@ fn runnerZig() []const u8 {
2518
2522
  \\ return app_manifest.updates.check_on_start;
2519
2523
  \\}
2520
2524
  \\
2525
+ \\/// The trusted update keys the updater verifies feeds against:
2526
+ \\/// `public_key` followed by any `public_keys`, comma-joined.
2527
+ \\fn manifestUpdatePublicKeys() []const u8 {
2528
+ \\ const primary = comptime manifestUpdateString("public_key");
2529
+ \\ if (comptime primary.len == 0) return "";
2530
+ \\ if (comptime !@hasField(@TypeOf(app_manifest.updates), "public_keys")) return primary;
2531
+ \\ comptime var keys: []const u8 = primary;
2532
+ \\ inline for (app_manifest.updates.public_keys) |key| keys = keys ++ "," ++ key;
2533
+ \\ return keys;
2534
+ \\}
2535
+ \\
2536
+ \\fn manifestUpdateMode() native_sdk.platform.UpdateMode {
2537
+ \\ if (comptime !@hasField(@TypeOf(app_manifest), "updates")) return .host;
2538
+ \\ if (comptime !@hasField(@TypeOf(app_manifest.updates), "mode")) return .host;
2539
+ \\ return comptime std.meta.stringToEnum(native_sdk.platform.UpdateMode, app_manifest.updates.mode) orelse
2540
+ \\ @compileError("app manifest updates.mode must be \"host\" or \"app\"");
2541
+ \\}
2542
+ \\
2543
+ \\/// Points the app-driven updater at this installation (see
2544
+ \\/// `updater.configureApp`).
2545
+ \\fn configureUpdates(app_info: *native_sdk.platform.AppInfo, environ_map: *std.process.Environ.Map) void {
2546
+ \\ const host: native_sdk.updater.Host = comptime if (std.mem.eql(u8, build_options.platform, "windows"))
2547
+ \\ .windows
2548
+ \\ else if (std.mem.eql(u8, build_options.platform, "macos") and std.mem.eql(u8, build_options.web_engine, "system"))
2549
+ \\ .macos_appkit
2550
+ \\ else
2551
+ \\ .other;
2552
+ \\ native_sdk.updater.configureApp(host, app_info, environ_map);
2553
+ \\}
2554
+ \\
2521
2555
  \\const CommandStorage = struct {
2522
2556
  \\ commands: [native_sdk.app_manifest.max_commands]native_sdk.Command = undefined,
2523
2557
  \\
@@ -3062,6 +3096,7 @@ fn runnerZig() []const u8 {
3062
3096
  \\fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
3063
3097
  \\ var buffers: StateBuffers = undefined;
3064
3098
  \\ var app_info = options.appInfo(&buffers);
3099
+ \\ configureUpdates(&app_info, init.environ_map);
3065
3100
  \\ const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
3066
3101
  \\ // Heap wrapper, latch-gated free: worker threads hold this address
3067
3102
  \\ // as the channel wake context and an abandoned wake call may
@@ -3120,6 +3155,7 @@ fn runnerZig() []const u8 {
3120
3155
  \\fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
3121
3156
  \\ var buffers: StateBuffers = undefined;
3122
3157
  \\ var app_info = options.appInfo(&buffers);
3158
+ \\ configureUpdates(&app_info, init.environ_map);
3123
3159
  \\ const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
3124
3160
  \\ // Heap wrapper, latch-gated free: worker threads hold this address
3125
3161
  \\ // as the channel wake context and an abandoned wake call may
@@ -3178,6 +3214,7 @@ fn runnerZig() []const u8 {
3178
3214
  \\fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init) !void {
3179
3215
  \\ var buffers: StateBuffers = undefined;
3180
3216
  \\ var app_info = options.appInfo(&buffers);
3217
+ \\ configureUpdates(&app_info, init.environ_map);
3181
3218
  \\ const store = prepareStateStore(init.io, init.environ_map, &app_info, &buffers);
3182
3219
  \\ // Heap wrapper, latch-gated free: worker threads hold this address
3183
3220
  \\ // as the channel wake context and an abandoned wake call may
@@ -4272,6 +4309,7 @@ test "writeDefaultApp emits Vite project files" {
4272
4309
  // the packaged artifact structurally agrees with the compiled exe.
4273
4310
  try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "package.addArgs(&.{ \"--web-layer\", if (web_layer) \"include\" else \"exclude\" })") != null);
4274
4311
  try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "package.addArg(\"--update-archive\")") != null);
4312
+ try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "package_target == .windows) package.addArgs(&.{ \"--installer\", \"--update-archive\" })") != null);
4275
4313
  try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "options.addOption(bool, \"web_layer\", web_layer)") != null);
4276
4314
  try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "std.json.parseFromSliceLeaky(InferenceManifest") != null);
4277
4315
  try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "fn appManifestModule") != null);
@@ -4336,8 +4374,12 @@ test "writeDefaultApp emits Vite project files" {
4336
4374
  try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "fn appInfo(self: RunOptions, buffers: *StateBuffers)") != null);
4337
4375
  try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, ".version = manifestStringField(\"version\")") != null);
4338
4376
  try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, ".update_feed_url = manifestUpdateString(\"feed_url\")") != null);
4339
- try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, ".update_public_key = manifestUpdateString(\"public_key\")") != null);
4377
+ try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, ".update_public_key = manifestUpdatePublicKeys()") != null);
4340
4378
  try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, ".update_check_on_start = manifestUpdateCheckOnStart()") != null);
4379
+ try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, ".update_mode = manifestUpdateMode()") != null);
4380
+ // Ejected apps configure the app-driven updater on every desktop
4381
+ // platform.
4382
+ try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, runner_zig_text, "configureUpdates(&app_info, init.environ_map);"));
4341
4383
  try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "fn manifestWindowOptions") != null);
4342
4384
  try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "info.windows = windows") != null);
4343
4385
  try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "for (restored_windows, 0..)") != null);
@@ -1,6 +1,7 @@
1
1
  const std = @import("std");
2
2
  const manifest_tool = @import("manifest.zig");
3
3
  const update_feed = @import("update_feed");
4
+ const install_config = @import("windows_install_config");
4
5
 
5
6
  const Ed25519 = std.crypto.sign.Ed25519;
6
7
 
@@ -50,9 +51,14 @@ fn sign(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) !voi
50
51
  var target: ?[]const u8 = null;
51
52
  var release_notes: []const u8 = "";
52
53
  var output_path: []const u8 = "native-update.json";
54
+ var url_policy: update_feed.UrlPolicy = .{};
53
55
  var index: usize = 0;
54
56
  while (index < args.len) : (index += 1) {
55
57
  const name = args[index];
58
+ if (std.mem.eql(u8, name, "--allow-loopback-http")) {
59
+ url_policy.allow_loopback_http = true;
60
+ continue;
61
+ }
56
62
  index += 1;
57
63
  if (index >= args.len) return error.InvalidArguments;
58
64
  const value = args[index];
@@ -61,15 +67,16 @@ fn sign(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) !voi
61
67
  const archive = archive_path orelse return error.InvalidArguments;
62
68
  const url = archive_url orelse return error.InvalidArguments;
63
69
  const release_target = target orelse return error.InvalidArguments;
64
- if (!update_feed.archiveUrlIsValid(url)) return error.InvalidArchiveUrl;
65
- if (!std.mem.eql(u8, release_target, "macos-aarch64") and !std.mem.eql(u8, release_target, "macos-x86_64")) return error.InvalidTarget;
70
+ if (!update_feed.urlAllowed(url, url_policy)) return error.InvalidArchiveUrl;
71
+ if (!update_feed.isTarget(release_target)) return error.InvalidTarget;
66
72
  if (!std.mem.endsWith(u8, archive, ".zip")) return error.InvalidArchive;
67
73
  const metadata = try manifest_tool.readMetadata(allocator, io, manifest_path orelse manifest_tool.defaultPath(io) orelse "app.json");
68
74
  defer metadata.deinit(allocator);
69
75
  if (!metadata.updates.enabled()) return error.UpdatesNotConfigured;
70
76
  _ = manifest_tool.parseVersion(metadata.version) catch return error.InvalidManifestVersion;
71
77
  if (release_notes.len > 16 * 1024) return error.ReleaseNotesTooLarge;
72
- validateUpdateArchive(allocator, io, archive, metadata.id, metadata.version, metadata.name, release_target) catch |err| switch (err) {
78
+ const validate = if (std.mem.startsWith(u8, release_target, "windows-")) &validateWindowsUpdateArchive else &validateUpdateArchive;
79
+ validate(allocator, io, archive, metadata.id, metadata.version, metadata.name, release_target) catch |err| switch (err) {
73
80
  error.UpdateArchitectureMismatch => return error.UpdateArchitectureMismatch,
74
81
  else => return error.InvalidArchive,
75
82
  };
@@ -83,12 +90,14 @@ fn sign(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) !voi
83
90
  var extra: [1]u8 = undefined;
84
91
  if (try key_reader.interface.readSliceShort(&extra) != 0) return error.InvalidPrivateKey;
85
92
  const key_pair = try Ed25519.KeyPair.generateDeterministic(seed);
86
- const configured_public_key = metadata.updates.public_key orelse return error.UpdatesNotConfigured;
87
- var configured_public_key_bytes: [Ed25519.PublicKey.encoded_length]u8 = undefined;
88
- const configured_public_key_size = std.base64.standard.Decoder.calcSizeForSlice(configured_public_key) catch return error.InvalidUpdatePublicKey;
89
- if (configured_public_key_size != configured_public_key_bytes.len) return error.InvalidUpdatePublicKey;
90
- std.base64.standard.Decoder.decode(&configured_public_key_bytes, configured_public_key) catch return error.InvalidUpdatePublicKey;
91
- if (!std.crypto.timing_safe.eql([Ed25519.PublicKey.encoded_length]u8, key_pair.public_key.toBytes(), configured_public_key_bytes)) return error.UpdateKeyMismatch;
93
+ // The release's installs trust the manifest's `public_key` and any
94
+ // `public_keys`, so a release that rotates keys is signed with the key
95
+ // the installs it replaces already know.
96
+ if (metadata.updates.public_key == null) return error.UpdatesNotConfigured;
97
+ if (!metadata.updates.publicKeysAreValid()) return error.InvalidUpdatePublicKey;
98
+ var signing_public_key_base64: [std.base64.standard.Encoder.calcSize(Ed25519.PublicKey.encoded_length)]u8 = undefined;
99
+ const signing_public_key = std.base64.standard.Encoder.encode(&signing_public_key_base64, &key_pair.public_key.toBytes());
100
+ if (!metadata.updates.trustsPublicKey(signing_public_key)) return error.UpdateKeyMismatch;
92
101
 
93
102
  var archive_file = try std.Io.Dir.cwd().openFile(io, archive, .{});
94
103
  defer archive_file.close(io);
@@ -139,7 +148,7 @@ fn sign(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) !voi
139
148
  defer output_writer.deinit();
140
149
  try std.json.Stringify.value(.{ .payload = payload_base64, .signature = &signature_base64 }, .{ .whitespace = .indent_2 }, &output_writer.writer);
141
150
  try output_writer.writer.writeByte('\n');
142
- var verified = try update_feed.verifyEnvelope(allocator, output_writer.written(), configured_public_key);
151
+ var verified = try update_feed.verifyEnvelopeWithPolicy(allocator, output_writer.written(), signing_public_key, url_policy);
143
152
  defer verified.deinit(allocator);
144
153
  if (!std.mem.eql(u8, verified.release.bundle_id, metadata.id) or
145
154
  !std.mem.eql(u8, verified.release.version, metadata.version) or
@@ -149,6 +158,9 @@ fn sign(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) !voi
149
158
  try atomic.file.writeStreamingAll(io, output_writer.written());
150
159
  try atomic.replace(io);
151
160
  std.debug.print("signed {s} {s} update ({d} bytes) into {s}\n", .{ metadata.displayName(), metadata.version, archive_stat.size, output_path });
161
+ if (!update_feed.archiveUrlIsValid(url)) {
162
+ std.debug.print("note: the archive URL is loopback http://; installed apps accept this feed only while NATIVE_SDK_UPDATES_FEED_URL points them at it\n", .{});
163
+ }
152
164
  }
153
165
 
154
166
  const max_info_plist_bytes: u64 = 1024 * 1024;
@@ -218,6 +230,72 @@ fn validateUpdateArchive(
218
230
  !plistStringValueEquals(plist, "CFBundlePackageType", "APPL")) return error.InvalidArchive;
219
231
  }
220
232
 
233
+ /// A Windows update archive is the install image `native package
234
+ /// --update-archive` writes: the app's install configuration naming this
235
+ /// app and version, its launcher, and `bin\<name>.exe` built for the
236
+ /// target architecture.
237
+ fn validateWindowsUpdateArchive(
238
+ allocator: std.mem.Allocator,
239
+ io: std.Io,
240
+ archive_path: []const u8,
241
+ expected_bundle_id: []const u8,
242
+ expected_version: []const u8,
243
+ expected_name: []const u8,
244
+ expected_target: []const u8,
245
+ ) !void {
246
+ const expected_machine: u16 = if (std.mem.eql(u8, expected_target, "windows-aarch64")) 0xaa64 else if (std.mem.eql(u8, expected_target, "windows-x86_64")) 0x8664 else return error.InvalidTarget;
247
+ var file = try std.Io.Dir.cwd().openFile(io, archive_path, .{});
248
+ defer file.close(io);
249
+ var reader_buffer: [64 * 1024]u8 = undefined;
250
+ var reader = file.reader(io, &reader_buffer);
251
+ var iterator = try std.zip.Iterator.init(&reader);
252
+ var filename_buffer: [std.Io.Dir.max_path_bytes]u8 = undefined;
253
+ var executable_name_buffer: [256]u8 = undefined;
254
+ const executable_name = try std.fmt.bufPrint(&executable_name_buffer, "bin/{s}.exe", .{expected_name});
255
+ var config_bytes: ?[]u8 = null;
256
+ defer if (config_bytes) |bytes| allocator.free(bytes);
257
+ var has_launcher = false;
258
+ var executable_machine: ?u16 = null;
259
+ var entries: usize = 0;
260
+
261
+ while (try iterator.next()) |entry| {
262
+ const filename = try zipEntryFilename(&reader, entry, &filename_buffer);
263
+ if (!zipEntryPathIsSafe(filename)) return error.InvalidArchive;
264
+ // Installed apps unpack with std.zip, which reads only these.
265
+ if (entry.compression_method != .store and entry.compression_method != .deflate) return error.InvalidArchive;
266
+ entries += 1;
267
+ if (entries > 1_000_000) return error.InvalidArchive;
268
+ if (std.mem.eql(u8, filename, install_config.file_name)) {
269
+ if (config_bytes != null) return error.InvalidArchive;
270
+ config_bytes = try readZipEntryAlloc(allocator, &reader, entry, filename, install_config.max_config_bytes);
271
+ } else if (std.mem.eql(u8, filename, "native-launcher.exe")) {
272
+ has_launcher = true;
273
+ } else if (std.ascii.eqlIgnoreCase(filename, executable_name)) {
274
+ var header: [4096]u8 = undefined;
275
+ const prefix = try readZipEntryPrefix(&reader, entry, filename, &header);
276
+ executable_machine = peMachine(prefix) orelse return error.InvalidArchive;
277
+ }
278
+ }
279
+
280
+ const bytes = config_bytes orelse return error.InvalidArchive;
281
+ var parsed = install_config.parse(allocator, bytes) catch return error.InvalidArchive;
282
+ defer parsed.deinit();
283
+ const config = parsed.value;
284
+ if (!std.mem.eql(u8, config.bundle_id, expected_bundle_id) or
285
+ !std.mem.eql(u8, config.version, expected_version) or
286
+ !std.mem.eql(u8, config.name, expected_name)) return error.InvalidArchive;
287
+ if (!has_launcher) return error.InvalidArchive;
288
+ const machine = executable_machine orelse return error.InvalidArchive;
289
+ if (machine != expected_machine) return error.UpdateArchitectureMismatch;
290
+ }
291
+
292
+ fn peMachine(header: []const u8) ?u16 {
293
+ if (header.len < 0x40 or header[0] != 'M' or header[1] != 'Z') return null;
294
+ const pe_offset: usize = std.mem.readInt(u32, header[0x3c..0x40], .little);
295
+ if (pe_offset + 6 > header.len or !std.mem.eql(u8, header[pe_offset..][0..4], "PE\x00\x00")) return null;
296
+ return std.mem.readInt(u16, header[pe_offset + 4 ..][0..2], .little);
297
+ }
298
+
221
299
  const max_macho_architectures: usize = 64;
222
300
  const max_macho_header_bytes: usize = 8 + max_macho_architectures * 32;
223
301
  const cpu_type_x86_64: u32 = 0x01000007;
@@ -574,3 +652,143 @@ test "update archive validation requires one matching app bundle" {
574
652
  try std.testing.expectError(error.InvalidArchive, validateUpdateArchive(std.testing.allocator, std.testing.io, valid_path, "com.example.other", "1.2.3", "demo", "macos-aarch64"));
575
653
  try std.testing.expectError(error.InvalidArchive, validateUpdateArchive(std.testing.allocator, std.testing.io, valid_path, "com.example.demo", "1.2.4", "demo", "macos-aarch64"));
576
654
  }
655
+
656
+ test "a release that rotates update keys is signed with a key its installs already trust" {
657
+ const allocator = std.testing.allocator;
658
+ const io = std.testing.io;
659
+ var tmp = std.testing.tmpDir(.{});
660
+ defer tmp.cleanup();
661
+ const root = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{tmp.sub_path});
662
+ defer allocator.free(root);
663
+
664
+ const old_key = try Ed25519.KeyPair.generateDeterministic([_]u8{0x51} ** Ed25519.KeyPair.seed_length);
665
+ const unrelated_key = try Ed25519.KeyPair.generateDeterministic([_]u8{0x53} ** Ed25519.KeyPair.seed_length);
666
+ const old_seed = old_key.secret_key.seed();
667
+ const unrelated_seed = unrelated_key.secret_key.seed();
668
+ try tmp.dir.writeFile(io, .{ .sub_path = "old.key", .data = &old_seed });
669
+ try tmp.dir.writeFile(io, .{ .sub_path = "unrelated.key", .data = &unrelated_seed });
670
+ // The new release names its new key as `public_key` and keeps the old
671
+ // one in `public_keys`; installs of the previous release know only
672
+ // the old one.
673
+ try tmp.dir.writeFile(io, .{ .sub_path = "app.json", .data =
674
+ \\{
675
+ \\ "id": "com.example.demo",
676
+ \\ "name": "demo",
677
+ \\ "version": "1.2.3",
678
+ \\ "updates": {
679
+ \\ "feed_url": "https://example.com/releases/{target}/native-update.json",
680
+ \\ "public_key": "IBLLkMpg6OXY2vZuInLSIz4EhtVX6MZhQe2JIBd9frc=",
681
+ \\ "public_keys": ["wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo="]
682
+ \\ }
683
+ \\}
684
+ });
685
+ var plist_buffer: [1024]u8 = undefined;
686
+ const plist = try updateInfoPlist("com.example.demo", "1.2.3", "demo", &plist_buffer);
687
+ const executable = thinMachO64(cpu_type_arm64);
688
+ const archive_entries = [_]StoredZipEntry{
689
+ .{ .name = "Demo.app/Contents/Info.plist", .data = plist },
690
+ .{ .name = "Demo.app/Contents/MacOS/demo", .data = &executable },
691
+ };
692
+ const archive = try storedZipAlloc(allocator, &archive_entries);
693
+ defer allocator.free(archive);
694
+ try tmp.dir.writeFile(io, .{ .sub_path = "demo.zip", .data = archive });
695
+
696
+ const manifest_path = try std.fmt.allocPrint(allocator, "{s}/app.json", .{root});
697
+ defer allocator.free(manifest_path);
698
+ const archive_path = try std.fmt.allocPrint(allocator, "{s}/demo.zip", .{root});
699
+ defer allocator.free(archive_path);
700
+ const old_key_path = try std.fmt.allocPrint(allocator, "{s}/old.key", .{root});
701
+ defer allocator.free(old_key_path);
702
+ const unrelated_key_path = try std.fmt.allocPrint(allocator, "{s}/unrelated.key", .{root});
703
+ defer allocator.free(unrelated_key_path);
704
+ const output_path = try std.fmt.allocPrint(allocator, "{s}/native-update.json", .{root});
705
+ defer allocator.free(output_path);
706
+
707
+ try sign(allocator, io, &.{ "--private-key", old_key_path, "--manifest", manifest_path, "--archive", archive_path, "--url", "https://example.com/demo-1.2.3.zip", "--target", "macos-aarch64", "--output", output_path });
708
+ const feed = try std.Io.Dir.cwd().readFileAlloc(io, output_path, allocator, .limited(update_feed.max_envelope_bytes));
709
+ defer allocator.free(feed);
710
+ var verified = try update_feed.verifyEnvelope(allocator, feed, "wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo=");
711
+ defer verified.deinit(allocator);
712
+ try std.testing.expectEqualStrings("1.2.3", verified.release.version);
713
+ try std.testing.expectError(error.InvalidUpdateSignature, update_feed.verifyEnvelope(allocator, feed, "IBLLkMpg6OXY2vZuInLSIz4EhtVX6MZhQe2JIBd9frc="));
714
+
715
+ try std.testing.expectError(error.UpdateKeyMismatch, sign(allocator, io, &.{ "--private-key", unrelated_key_path, "--manifest", manifest_path, "--archive", archive_path, "--url", "https://example.com/demo-1.2.3.zip", "--target", "macos-aarch64", "--output", output_path }));
716
+
717
+ // A key that does not decode is reported as such, not as a mismatch.
718
+ try tmp.dir.writeFile(io, .{ .sub_path = "app.json", .data =
719
+ \\{
720
+ \\ "id": "com.example.demo",
721
+ \\ "name": "demo",
722
+ \\ "version": "1.2.3",
723
+ \\ "updates": {
724
+ \\ "feed_url": "https://example.com/releases/{target}/native-update.json",
725
+ \\ "public_key": "IBLLkMpg6OXY2vZuInLSIz4EhtVX6MZhQe2JIBd9frc=",
726
+ \\ "public_keys": ["wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo=", "not-a-key"]
727
+ \\ }
728
+ \\}
729
+ });
730
+ try std.testing.expectError(error.InvalidUpdatePublicKey, sign(allocator, io, &.{ "--private-key", old_key_path, "--manifest", manifest_path, "--archive", archive_path, "--url", "https://example.com/demo-1.2.3.zip", "--target", "macos-aarch64", "--output", output_path }));
731
+ }
732
+
733
+ test "a Windows release signs its installed image and names loopback http only when asked" {
734
+ const allocator = std.testing.allocator;
735
+ const io = std.testing.io;
736
+ var tmp = std.testing.tmpDir(.{});
737
+ defer tmp.cleanup();
738
+ const root = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{tmp.sub_path});
739
+ defer allocator.free(root);
740
+
741
+ const key = try Ed25519.KeyPair.generateDeterministic([_]u8{0x51} ** Ed25519.KeyPair.seed_length);
742
+ const seed = key.secret_key.seed();
743
+ try tmp.dir.writeFile(io, .{ .sub_path = "release.key", .data = &seed });
744
+ try tmp.dir.writeFile(io, .{ .sub_path = "app.json", .data =
745
+ \\{
746
+ \\ "id": "com.example.demo",
747
+ \\ "name": "demo",
748
+ \\ "version": "1.2.3",
749
+ \\ "updates": {
750
+ \\ "feed_url": "https://example.com/releases/{target}/native-update.json",
751
+ \\ "public_key": "wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo="
752
+ \\ }
753
+ \\}
754
+ });
755
+ var config_writer = std.Io.Writer.Allocating.init(allocator);
756
+ defer config_writer.deinit();
757
+ try install_config.write(.{ .bundle_id = "com.example.demo", .name = "demo", .display_name = "Demo", .version = "1.2.3", .executable = "demo.exe" }, &config_writer.writer);
758
+ var executable = [_]u8{0} ** 0x100;
759
+ executable[0] = 'M';
760
+ executable[1] = 'Z';
761
+ std.mem.writeInt(u32, executable[0x3c..0x40], 0x80, .little);
762
+ @memcpy(executable[0x80..0x84], "PE\x00\x00");
763
+ std.mem.writeInt(u16, executable[0x84..0x86], 0x8664, .little);
764
+ const archive = try storedZipAlloc(allocator, &.{
765
+ .{ .name = install_config.file_name, .data = config_writer.written() },
766
+ .{ .name = "native-launcher.exe", .data = "launcher" },
767
+ .{ .name = "bin/demo.exe", .data = &executable },
768
+ });
769
+ defer allocator.free(archive);
770
+ try tmp.dir.writeFile(io, .{ .sub_path = "demo.zip", .data = archive });
771
+
772
+ const manifest_path = try std.fmt.allocPrint(allocator, "{s}/app.json", .{root});
773
+ defer allocator.free(manifest_path);
774
+ const archive_path = try std.fmt.allocPrint(allocator, "{s}/demo.zip", .{root});
775
+ defer allocator.free(archive_path);
776
+ const key_path = try std.fmt.allocPrint(allocator, "{s}/release.key", .{root});
777
+ defer allocator.free(key_path);
778
+ const output_path = try std.fmt.allocPrint(allocator, "{s}/native-update.json", .{root});
779
+ defer allocator.free(output_path);
780
+ const common = [_][]const u8{ "--private-key", key_path, "--manifest", manifest_path, "--archive", archive_path, "--output", output_path };
781
+
782
+ try sign(allocator, io, &(common ++ [_][]const u8{ "--url", "https://example.com/demo-1.2.3.zip", "--target", "windows-x86_64" }));
783
+ try std.testing.expectError(error.UpdateArchitectureMismatch, sign(allocator, io, &(common ++ [_][]const u8{ "--url", "https://example.com/demo-1.2.3.zip", "--target", "windows-aarch64" })));
784
+
785
+ const local_url = "http://127.0.0.1:8080/demo-1.2.3.zip";
786
+ try std.testing.expectError(error.InvalidArchiveUrl, sign(allocator, io, &(common ++ [_][]const u8{ "--url", local_url, "--target", "windows-x86_64" })));
787
+ try sign(allocator, io, &(common ++ [_][]const u8{ "--url", local_url, "--target", "windows-x86_64", "--allow-loopback-http" }));
788
+ const feed = try std.Io.Dir.cwd().readFileAlloc(io, output_path, allocator, .limited(update_feed.max_envelope_bytes));
789
+ defer allocator.free(feed);
790
+ try std.testing.expectError(error.InvalidUpdatePayload, update_feed.verifyEnvelope(allocator, feed, "wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo="));
791
+ var verified = try update_feed.verifyEnvelopeWithPolicy(allocator, feed, "wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo=", .{ .allow_loopback_http = true });
792
+ defer verified.deinit(allocator);
793
+ try std.testing.expectEqualStrings(local_url, verified.release.archive_url);
794
+ }
@@ -0,0 +1,245 @@
1
+ //! `native package --target windows --installer`: turns the Windows
2
+ //! artifact directory into a per-user setup program.
3
+ //!
4
+ //! The artifact directory becomes the install image: it gains
5
+ //! `native-install.json` (the install configuration) and
6
+ //! `native-launcher.exe` (the launcher the setup program copies to the
7
+ //! stable `<root>\<name>.exe`). setup.exe is the launcher program with the
8
+ //! configuration and a ZIP of the image appended, followed by a trailer
9
+ //! carrying their SHA-256 (src/windows_install/payload.zig).
10
+
11
+ const std = @import("std");
12
+ const manifest_tool = @import("manifest.zig");
13
+ const package = @import("package.zig");
14
+ const zip_writer = @import("zip_writer.zig");
15
+ const install_config = @import("windows_install_config");
16
+ const setup_payload = @import("windows_install_payload");
17
+
18
+ pub const launcher_file_in_image = "native-launcher.exe";
19
+ pub const launcher_x86_64 = @embedFile("windows_launcher_x86_64");
20
+ pub const launcher_aarch64 = @embedFile("windows_launcher_aarch64");
21
+
22
+ pub const Arch = enum {
23
+ x86_64,
24
+ aarch64,
25
+
26
+ pub fn launcher(self: Arch) []const u8 {
27
+ return switch (self) {
28
+ .x86_64 => launcher_x86_64,
29
+ .aarch64 => launcher_aarch64,
30
+ };
31
+ }
32
+ };
33
+
34
+ pub const Options = struct {
35
+ /// The Windows artifact directory `native package` just staged.
36
+ artifact_dir: []const u8,
37
+ metadata: manifest_tool.Metadata,
38
+ /// The app executable's file name in `bin\`.
39
+ executable: []const u8,
40
+ arch: Arch,
41
+ output_path: []const u8,
42
+ };
43
+
44
+ /// The setup program's file name: `<name>-<version>-windows-<arch>-setup.exe`.
45
+ pub fn setupFileName(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata, arch: Arch) ![]const u8 {
46
+ return std.fmt.allocPrint(allocator, "{s}-{s}-windows-{s}-setup.exe", .{ metadata.name, metadata.version, @tagName(arch) });
47
+ }
48
+
49
+ /// The update archive's file name: `<name>-<version>-windows-<arch>-update.zip`.
50
+ pub fn updateArchiveFileName(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata, arch: Arch) ![]const u8 {
51
+ return std.fmt.allocPrint(allocator, "{s}-{s}-windows-{s}-update.zip", .{ metadata.name, metadata.version, @tagName(arch) });
52
+ }
53
+
54
+ pub fn createSetup(allocator: std.mem.Allocator, io: std.Io, options: Options) !void {
55
+ var arena_state = std.heap.ArenaAllocator.init(allocator);
56
+ defer arena_state.deinit();
57
+ const arena = arena_state.allocator();
58
+ const image = try buildImage(arena, io, options);
59
+
60
+ const program = options.arch.launcher();
61
+ const trailer = setup_payload.encode(.{
62
+ .config_offset = program.len,
63
+ .config_size = image.config.len,
64
+ .zip_offset = program.len + image.config.len,
65
+ .zip_size = image.zip.len,
66
+ .sha256 = setup_payload.digest(image.config, image.zip),
67
+ });
68
+ try writeAtomically(io, options.output_path, &.{ program, image.config, image.zip, &trailer });
69
+ }
70
+
71
+ /// The ZIP of the install image the updater downloads: the same image a
72
+ /// setup program carries, signed into a feed by `native update sign`.
73
+ pub fn createUpdateArchive(allocator: std.mem.Allocator, io: std.Io, options: Options) !void {
74
+ var arena_state = std.heap.ArenaAllocator.init(allocator);
75
+ defer arena_state.deinit();
76
+ const image = try buildImage(arena_state.allocator(), io, options);
77
+ try writeAtomically(io, options.output_path, &.{image.zip});
78
+ }
79
+
80
+ const Image = struct {
81
+ config: []const u8,
82
+ zip: []const u8,
83
+ };
84
+
85
+ /// Adds the install configuration and launcher to the artifact directory
86
+ /// and zips it, without the portable registration script.
87
+ fn buildImage(arena: std.mem.Allocator, io: std.Io, options: Options) !Image {
88
+ var artifact = try std.Io.Dir.cwd().openDir(io, options.artifact_dir, .{});
89
+ defer artifact.close(io);
90
+ const has_icon = if (artifact.access(io, "app-icon.ico", .{})) |_| true else |_| false;
91
+ const config = try installConfig(arena, options.metadata, options.executable, has_icon);
92
+ try install_config.validate(config);
93
+
94
+ var config_writer: std.Io.Writer.Allocating = .init(arena);
95
+ try install_config.write(config, &config_writer.writer);
96
+ const config_bytes = config_writer.written();
97
+ try artifact.writeFile(io, .{ .sub_path = install_config.file_name, .data = config_bytes });
98
+ try artifact.writeFile(io, .{ .sub_path = launcher_file_in_image, .data = options.arch.launcher() });
99
+
100
+ const collected = try zip_writer.collectDirectory(arena, io, options.artifact_dir);
101
+ var image: std.ArrayList(zip_writer.Entry) = .empty;
102
+ for (collected) |entry| {
103
+ // The portable artifact's registration script points at bin\; an
104
+ // installed app registers through the setup program instead.
105
+ if (std.mem.startsWith(u8, entry.name, "install/")) continue;
106
+ try image.append(arena, entry);
107
+ }
108
+ var zip: std.Io.Writer.Allocating = .init(arena);
109
+ try zip_writer.write(arena, &zip.writer, image.items);
110
+ return .{ .config = config_bytes, .zip = zip.written() };
111
+ }
112
+
113
+ fn writeAtomically(io: std.Io, path: []const u8, parts: []const []const u8) !void {
114
+ if (std.fs.path.dirname(path)) |parent| try std.Io.Dir.cwd().createDirPath(io, parent);
115
+ var atomic = try std.Io.Dir.cwd().createFileAtomic(io, path, .{ .make_path = true, .replace = true });
116
+ defer atomic.deinit(io);
117
+ var buffer: [64 * 1024]u8 = undefined;
118
+ var writer = atomic.file.writer(io, &buffer);
119
+ for (parts) |part| try writer.interface.writeAll(part);
120
+ try writer.interface.flush();
121
+ try atomic.replace(io);
122
+ }
123
+
124
+ /// The install configuration for an app: its identity, executable and
125
+ /// icon, and the HKCU\Software\Classes values its file associations and
126
+ /// URL schemes need, pointing at the installed launcher so a pending
127
+ /// version switch also applies when a document or link opens the app.
128
+ pub fn installConfig(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata, executable: []const u8, has_icon: bool) !install_config.InstallConfig {
129
+ var registry: std.ArrayList(install_config.RegistryValue) = .empty;
130
+ var cleanup: std.ArrayList(install_config.RegistryCleanup) = .empty;
131
+ const open_command = "\"" ++ install_config.launcher_placeholder ++ "\" \"%1\"";
132
+ const document_icon = if (has_icon) install_config.install_placeholder ++ "\\app\\app-icon.ico" else install_config.launcher_placeholder;
133
+
134
+ for (metadata.file_associations) |association| {
135
+ const prog_id = try package.windowsProgId(allocator, metadata, association);
136
+ const prog_key = try std.fmt.allocPrint(allocator, "Software\\Classes\\{s}", .{prog_id});
137
+ for (association.extensions) |extension| {
138
+ const extension_key = try std.fmt.allocPrint(allocator, "Software\\Classes\\.{s}", .{package.trimExtensionDot(extension)});
139
+ try registry.append(allocator, .{ .key = extension_key, .value = prog_id });
140
+ try cleanup.append(allocator, .{ .key = extension_key, .name = "", .if_value = prog_id });
141
+ }
142
+ try registry.append(allocator, .{ .key = prog_key, .value = association.name });
143
+ try registry.append(allocator, .{ .key = prog_key, .name = "FriendlyTypeName", .value = association.name });
144
+ try registry.append(allocator, .{ .key = try std.fmt.allocPrint(allocator, "{s}\\DefaultIcon", .{prog_key}), .value = document_icon });
145
+ try registry.append(allocator, .{ .key = try std.fmt.allocPrint(allocator, "{s}\\shell\\open\\command", .{prog_key}), .value = open_command });
146
+ try cleanup.append(allocator, .{ .key = prog_key });
147
+ }
148
+ for (metadata.url_schemes) |url_scheme| {
149
+ const scheme_key = try std.fmt.allocPrint(allocator, "Software\\Classes\\{s}", .{url_scheme.scheme});
150
+ try registry.append(allocator, .{ .key = scheme_key, .value = try std.fmt.allocPrint(allocator, "URL:{s}", .{url_scheme.scheme}) });
151
+ try registry.append(allocator, .{ .key = scheme_key, .name = "URL Protocol", .value = "" });
152
+ try registry.append(allocator, .{ .key = try std.fmt.allocPrint(allocator, "{s}\\shell\\open\\command", .{scheme_key}), .value = open_command });
153
+ try cleanup.append(allocator, .{ .key = scheme_key });
154
+ }
155
+
156
+ return .{
157
+ .bundle_id = metadata.id,
158
+ .name = metadata.name,
159
+ .display_name = metadata.displayName(),
160
+ .version = metadata.version,
161
+ .executable = executable,
162
+ .icon = if (has_icon) "app-icon.ico" else "",
163
+ .registry = try registry.toOwnedSlice(allocator),
164
+ .registry_cleanup = try cleanup.toOwnedSlice(allocator),
165
+ };
166
+ }
167
+
168
+ test "an app's associations and URL schemes register through the installed launcher" {
169
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
170
+ defer arena_state.deinit();
171
+ const arena = arena_state.allocator();
172
+ const associations = [_]manifest_tool.FileAssociationMetadata{.{ .name = "Markdown", .extensions = &.{"md"} }};
173
+ const schemes = [_]manifest_tool.UrlSchemeMetadata{.{ .scheme = "acme-notes" }};
174
+ const metadata: manifest_tool.Metadata = .{
175
+ .id = "dev.example.notes",
176
+ .name = "notes",
177
+ .display_name = "Acme Notes",
178
+ .version = "1.2.3",
179
+ .file_associations = &associations,
180
+ .url_schemes = &schemes,
181
+ };
182
+ const config = try installConfig(arena, metadata, "notes.exe", true);
183
+ try install_config.validate(config);
184
+ try std.testing.expectEqualStrings("Acme Notes", config.display_name);
185
+ try std.testing.expectEqualStrings("app-icon.ico", config.icon);
186
+ try std.testing.expectEqualStrings("Software\\Classes\\.md", config.registry[0].key);
187
+ try std.testing.expectEqualStrings("dev.example.notes.Markdown", config.registry[0].value);
188
+ try std.testing.expectEqualStrings("\"{launcher}\" \"%1\"", config.registry[4].value);
189
+ try std.testing.expectEqualStrings("Software\\Classes\\acme-notes\\shell\\open\\command", config.registry[7].key);
190
+ // The extension's default value goes only while it still names this
191
+ // app; the app's own ProgID and scheme keys go whole.
192
+ try std.testing.expectEqualStrings("dev.example.notes.Markdown", config.registry_cleanup[0].if_value.?);
193
+ try std.testing.expect(config.registry_cleanup[1].name == null);
194
+ }
195
+
196
+ test "setup.exe carries the launcher, its configuration and the install image" {
197
+ const allocator = std.testing.allocator;
198
+ const io = std.testing.io;
199
+ var cwd = std.Io.Dir.cwd();
200
+ const root = ".zig-cache/test-windows-setup";
201
+ try cwd.deleteTree(io, root);
202
+ defer cwd.deleteTree(io, root) catch {};
203
+ try cwd.createDirPath(io, root ++ "/artifact/bin");
204
+ try cwd.createDirPath(io, root ++ "/artifact/install");
205
+ try cwd.writeFile(io, .{ .sub_path = root ++ "/artifact/bin/notes.exe", .data = "app" });
206
+ try cwd.writeFile(io, .{ .sub_path = root ++ "/artifact/install/register-file-types.ps1", .data = "script" });
207
+ try cwd.writeFile(io, .{ .sub_path = root ++ "/artifact/app-icon.ico", .data = "icon" });
208
+
209
+ const metadata: manifest_tool.Metadata = .{ .id = "dev.example.notes", .name = "notes", .version = "1.2.3" };
210
+ try createSetup(allocator, io, .{ .artifact_dir = root ++ "/artifact", .metadata = metadata, .executable = "notes.exe", .arch = .x86_64, .output_path = root ++ "/notes-setup.exe" });
211
+
212
+ const setup = try cwd.readFileAlloc(io, root ++ "/notes-setup.exe", allocator, .unlimited);
213
+ defer allocator.free(setup);
214
+ try std.testing.expect(std.mem.startsWith(u8, setup, launcher_x86_64));
215
+ const trailer = setup_payload.find(setup[setup.len - setup_payload.trailer_len ..], setup.len).?;
216
+ try std.testing.expectEqual(@as(u64, launcher_x86_64.len), trailer.config_offset);
217
+ const config_bytes = setup[@intCast(trailer.config_offset)..][0..@intCast(trailer.config_size)];
218
+ const zip_bytes = setup[@intCast(trailer.zip_offset)..][0..@intCast(trailer.zip_size)];
219
+ try std.testing.expectEqualSlices(u8, &trailer.sha256, &setup_payload.digest(config_bytes, zip_bytes));
220
+ var parsed = try install_config.parse(allocator, config_bytes);
221
+ defer parsed.deinit();
222
+ try std.testing.expectEqualStrings("notes", parsed.value.name);
223
+
224
+ try cwd.writeFile(io, .{ .sub_path = root ++ "/image.zip", .data = zip_bytes });
225
+ try cwd.createDirPath(io, root ++ "/extracted");
226
+ var extracted = try cwd.openDir(io, root ++ "/extracted", .{});
227
+ defer extracted.close(io);
228
+ var archive = try cwd.openFile(io, root ++ "/image.zip", .{});
229
+ defer archive.close(io);
230
+ var buffer: [4096]u8 = undefined;
231
+ var reader = archive.reader(io, &buffer);
232
+ try std.zip.extract(extracted, &reader, .{});
233
+ const launcher = try extracted.readFileAlloc(io, launcher_file_in_image, allocator, .unlimited);
234
+ defer allocator.free(launcher);
235
+ try std.testing.expectEqualSlices(u8, launcher_x86_64, launcher);
236
+ try std.testing.expectError(error.FileNotFound, extracted.access(io, "install/register-file-types.ps1", .{}));
237
+ try extracted.access(io, "bin/notes.exe", .{});
238
+ try extracted.access(io, install_config.file_name, .{});
239
+
240
+ // The updater downloads the very image the setup program carries.
241
+ try createUpdateArchive(allocator, io, .{ .artifact_dir = root ++ "/artifact", .metadata = metadata, .executable = "notes.exe", .arch = .x86_64, .output_path = root ++ "/notes-update.zip" });
242
+ const update_archive = try cwd.readFileAlloc(io, root ++ "/notes-update.zip", allocator, .unlimited);
243
+ defer allocator.free(update_archive);
244
+ try std.testing.expectEqualSlices(u8, zip_bytes, update_archive);
245
+ }