@zogien/native-sdk 0.11.0-fork.2 → 0.11.0-fork.4

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 (58) hide show
  1. package/README.md +1 -1
  2. package/build/app.zig +47 -23
  3. package/build/cli.zig +22 -0
  4. package/native-sdk.d.ts +2 -0
  5. package/package.json +10 -10
  6. package/packages/core/package-lock.json +2 -2
  7. package/packages/core/package.json +1 -1
  8. package/schemas/app.schema.json +9 -1
  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/macos/appkit_host.h +9 -0
  14. package/src/platform/macos/appkit_host.m +366 -0
  15. package/src/platform/macos/root.zig +29 -6
  16. package/src/platform/null_platform.zig +66 -0
  17. package/src/platform/root.zig +1 -0
  18. package/src/platform/types.zig +46 -0
  19. package/src/platform/windows/root.zig +174 -0
  20. package/src/platform/windows/scriptc_mingw_compat.c +248 -0
  21. package/src/platform/windows/webview2_host.cpp +500 -26
  22. package/src/primitives/app_manifest/root.zig +1 -0
  23. package/src/primitives/app_manifest/tests.zig +35 -0
  24. package/src/primitives/app_manifest/types.zig +12 -0
  25. package/src/primitives/app_manifest/validation.zig +44 -6
  26. package/src/root.zig +1 -0
  27. package/src/runtime/bridge_responses.zig +2 -0
  28. package/src/runtime/channel_sources.zig +49 -1
  29. package/src/runtime/core.zig +5 -0
  30. package/src/runtime/effects.zig +12 -1
  31. package/src/runtime/flow.zig +6 -0
  32. package/src/runtime/flow_reference.zig +6 -0
  33. package/src/runtime/root.zig +4 -0
  34. package/src/runtime/single_instance.zig +893 -0
  35. package/src/runtime/tests.zig +1 -0
  36. package/src/runtime/window_controls.zig +73 -0
  37. package/src/runtime/window_controls_tests.zig +192 -0
  38. package/src/runtime/window_storage.zig +3 -0
  39. package/src/tooling/manifest.zig +111 -3
  40. package/src/tooling/package.zig +98 -8
  41. package/src/tooling/raw_manifest.zig +7 -0
  42. package/src/tooling/root.zig +2 -0
  43. package/src/tooling/templates.zig +53 -3
  44. package/src/tooling/update.zig +228 -10
  45. package/src/tooling/windows_installer.zig +245 -0
  46. package/src/tooling/zip_writer.zig +211 -0
  47. package/src/updater/engine.zig +936 -0
  48. package/src/updater/feed.zig +261 -12
  49. package/src/updater/macos.zig +187 -0
  50. package/src/updater/root.zig +41 -0
  51. package/src/updater/windows.zig +470 -0
  52. package/src/windows_install/config.zig +247 -0
  53. package/src/windows_install/main.zig +690 -0
  54. package/src/windows_install/payload.zig +146 -0
  55. package/src/windows_install/switch.zig +184 -0
  56. package/src/windows_install/tests.zig +11 -0
  57. package/src/windows_install/win32.zig +248 -0
  58. package/tools/native-sdk/main.zig +10 -9
@@ -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
+ }
@@ -0,0 +1,211 @@
1
+ //! A small ZIP writer for install images and update archives: deflate
2
+ //! (or store, when deflate does not help), UTF-8 names with forward
3
+ //! slashes, and a fixed timestamp so the same tree always produces the
4
+ //! same bytes. No ZIP64: an archive stays below 4 GiB and 65535 entries.
5
+ //! `std.zip` reads what this writes.
6
+
7
+ const std = @import("std");
8
+ const flate = std.compress.flate;
9
+
10
+ pub const Entry = struct {
11
+ /// Forward-slash path inside the archive, without a leading slash.
12
+ name: []const u8,
13
+ data: []const u8,
14
+ };
15
+
16
+ pub const Error = error{ ZipTooLarge, InvalidZipEntryName } || std.mem.Allocator.Error || std.Io.Writer.Error;
17
+
18
+ const dos_time: u16 = 0;
19
+ /// 1980-01-01, the earliest date a ZIP timestamp can hold.
20
+ const dos_date: u16 = (0 << 9) | (1 << 5) | 1;
21
+ const version: u16 = 20;
22
+ /// General purpose flag bit 11: names are UTF-8.
23
+ const utf8_flag: u16 = 0x0800;
24
+
25
+ const Written = struct {
26
+ name: []const u8,
27
+ method: std.zip.CompressionMethod,
28
+ crc32: u32,
29
+ compressed_size: u32,
30
+ uncompressed_size: u32,
31
+ offset: u32,
32
+ };
33
+
34
+ pub fn write(allocator: std.mem.Allocator, out: *std.Io.Writer, entries: []const Entry) Error!void {
35
+ if (entries.len > std.math.maxInt(u16)) return error.ZipTooLarge;
36
+ const written = try allocator.alloc(Written, entries.len);
37
+ defer allocator.free(written);
38
+ const window = try allocator.alloc(u8, flate.max_window_len);
39
+ defer allocator.free(window);
40
+
41
+ var offset: u64 = 0;
42
+ for (entries, 0..) |entry, index| {
43
+ try validateName(entry.name);
44
+ if (entry.data.len > std.math.maxInt(u32)) return error.ZipTooLarge;
45
+ var compressed: std.Io.Writer.Allocating = try .initCapacity(allocator, @max(64, entry.data.len / 2));
46
+ defer compressed.deinit();
47
+ var compressor = try flate.Compress.init(&compressed.writer, window, .raw, .default);
48
+ try compressor.writer.writeAll(entry.data);
49
+ try compressor.finish();
50
+ const deflated = compressed.written().len < entry.data.len;
51
+ const body = if (deflated) compressed.written() else entry.data;
52
+ if (offset > std.math.maxInt(u32)) return error.ZipTooLarge;
53
+ written[index] = .{
54
+ .name = entry.name,
55
+ .method = if (deflated) .deflate else .store,
56
+ .crc32 = std.hash.Crc32.hash(entry.data),
57
+ .compressed_size = @intCast(body.len),
58
+ .uncompressed_size = @intCast(entry.data.len),
59
+ .offset = @intCast(offset),
60
+ };
61
+ const record = written[index];
62
+ try out.writeAll(&std.zip.local_file_header_sig);
63
+ try out.writeInt(u16, version, .little);
64
+ try out.writeInt(u16, utf8_flag, .little);
65
+ try out.writeInt(u16, @intFromEnum(record.method), .little);
66
+ try out.writeInt(u16, dos_time, .little);
67
+ try out.writeInt(u16, dos_date, .little);
68
+ try out.writeInt(u32, record.crc32, .little);
69
+ try out.writeInt(u32, record.compressed_size, .little);
70
+ try out.writeInt(u32, record.uncompressed_size, .little);
71
+ try out.writeInt(u16, @intCast(entry.name.len), .little);
72
+ try out.writeInt(u16, 0, .little);
73
+ try out.writeAll(entry.name);
74
+ try out.writeAll(body);
75
+ offset += 30 + entry.name.len + body.len;
76
+ }
77
+
78
+ if (offset > std.math.maxInt(u32)) return error.ZipTooLarge;
79
+ const central_offset: u32 = @intCast(offset);
80
+ var central_size: u64 = 0;
81
+ for (written) |record| {
82
+ try out.writeAll(&std.zip.central_file_header_sig);
83
+ try out.writeInt(u16, version, .little);
84
+ try out.writeInt(u16, version, .little);
85
+ try out.writeInt(u16, utf8_flag, .little);
86
+ try out.writeInt(u16, @intFromEnum(record.method), .little);
87
+ try out.writeInt(u16, dos_time, .little);
88
+ try out.writeInt(u16, dos_date, .little);
89
+ try out.writeInt(u32, record.crc32, .little);
90
+ try out.writeInt(u32, record.compressed_size, .little);
91
+ try out.writeInt(u32, record.uncompressed_size, .little);
92
+ try out.writeInt(u16, @intCast(record.name.len), .little);
93
+ try out.writeInt(u16, 0, .little);
94
+ try out.writeInt(u16, 0, .little);
95
+ try out.writeInt(u16, 0, .little);
96
+ try out.writeInt(u16, 0, .little);
97
+ try out.writeInt(u32, 0, .little);
98
+ try out.writeInt(u32, record.offset, .little);
99
+ try out.writeAll(record.name);
100
+ central_size += 46 + record.name.len;
101
+ }
102
+ if (central_size > std.math.maxInt(u32)) return error.ZipTooLarge;
103
+ try out.writeAll(&std.zip.end_record_sig);
104
+ try out.writeInt(u16, 0, .little);
105
+ try out.writeInt(u16, 0, .little);
106
+ try out.writeInt(u16, @intCast(entries.len), .little);
107
+ try out.writeInt(u16, @intCast(entries.len), .little);
108
+ try out.writeInt(u32, @intCast(central_size), .little);
109
+ try out.writeInt(u32, central_offset, .little);
110
+ try out.writeInt(u16, 0, .little);
111
+ }
112
+
113
+ /// Collects every file under `root` (a directory path relative to the
114
+ /// working directory) as archive entries named by their forward-slash
115
+ /// path below it, sorted so the archive is reproducible. The caller frees
116
+ /// the names and data with `freeEntries`.
117
+ pub fn collectDirectory(allocator: std.mem.Allocator, io: std.Io, root: []const u8) ![]Entry {
118
+ var dir = try std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true });
119
+ defer dir.close(io);
120
+ var walker = try dir.walk(allocator);
121
+ defer walker.deinit();
122
+ var entries: std.ArrayList(Entry) = .empty;
123
+ errdefer freeEntries(allocator, entries.items);
124
+ errdefer entries.deinit(allocator);
125
+ while (try walker.next(io)) |item| {
126
+ if (item.kind != .file) continue;
127
+ const name = try allocator.dupe(u8, item.path);
128
+ errdefer allocator.free(name);
129
+ std.mem.replaceScalar(u8, name, '\\', '/');
130
+ const data = try dir.readFileAlloc(io, item.path, allocator, .unlimited);
131
+ errdefer allocator.free(data);
132
+ try entries.append(allocator, .{ .name = name, .data = data });
133
+ }
134
+ std.mem.sort(Entry, entries.items, {}, lessThan);
135
+ return entries.toOwnedSlice(allocator);
136
+ }
137
+
138
+ pub fn freeEntries(allocator: std.mem.Allocator, entries: []const Entry) void {
139
+ for (entries) |entry| {
140
+ allocator.free(entry.name);
141
+ allocator.free(entry.data);
142
+ }
143
+ }
144
+
145
+ fn lessThan(_: void, a: Entry, b: Entry) bool {
146
+ return std.mem.lessThan(u8, a.name, b.name);
147
+ }
148
+
149
+ fn validateName(name: []const u8) error{InvalidZipEntryName}!void {
150
+ if (name.len == 0 or name.len > std.math.maxInt(u16)) return error.InvalidZipEntryName;
151
+ if (name[0] == '/' or std.mem.indexOfScalar(u8, name, '\\') != null) return error.InvalidZipEntryName;
152
+ var parts = std.mem.splitScalar(u8, name, '/');
153
+ while (parts.next()) |part| {
154
+ if (part.len == 0 or std.mem.eql(u8, part, ".") or std.mem.eql(u8, part, "..")) return error.InvalidZipEntryName;
155
+ }
156
+ }
157
+
158
+ test "an archive std.zip extracts back to the same tree" {
159
+ const allocator = std.testing.allocator;
160
+ const io = std.testing.io;
161
+ var tmp = std.testing.tmpDir(.{ .iterate = true });
162
+ defer tmp.cleanup();
163
+
164
+ const repetitive = "native " ** 4096;
165
+ const entries = [_]Entry{
166
+ .{ .name = "bin/demo.exe", .data = "MZ-not-really" },
167
+ .{ .name = "resources/assets/text.txt", .data = repetitive },
168
+ .{ .name = "native-install.json", .data = "{}" },
169
+ };
170
+ var archive: std.Io.Writer.Allocating = .init(allocator);
171
+ defer archive.deinit();
172
+ try write(allocator, &archive.writer, &entries);
173
+ try tmp.dir.writeFile(io, .{ .sub_path = "image.zip", .data = archive.written() });
174
+ try std.testing.expect(archive.written().len < repetitive.len);
175
+
176
+ try tmp.dir.createDirPath(io, "out");
177
+ var out = try tmp.dir.openDir(io, "out", .{});
178
+ defer out.close(io);
179
+ var file = try tmp.dir.openFile(io, "image.zip", .{});
180
+ defer file.close(io);
181
+ var buffer: [4096]u8 = undefined;
182
+ var reader = file.reader(io, &buffer);
183
+ try std.zip.extract(out, &reader, .{});
184
+
185
+ for (entries) |entry| {
186
+ const bytes = try out.readFileAlloc(io, entry.name, allocator, .unlimited);
187
+ defer allocator.free(bytes);
188
+ try std.testing.expectEqualStrings(entry.data, bytes);
189
+ }
190
+ }
191
+
192
+ test "the same entries always produce the same bytes" {
193
+ const allocator = std.testing.allocator;
194
+ const entries = [_]Entry{.{ .name = "a.txt", .data = "abc" }};
195
+ var first: std.Io.Writer.Allocating = .init(allocator);
196
+ defer first.deinit();
197
+ var second: std.Io.Writer.Allocating = .init(allocator);
198
+ defer second.deinit();
199
+ try write(allocator, &first.writer, &entries);
200
+ try write(allocator, &second.writer, &entries);
201
+ try std.testing.expectEqualSlices(u8, first.written(), second.written());
202
+ }
203
+
204
+ test "entry names stay inside the archive" {
205
+ const allocator = std.testing.allocator;
206
+ var archive: std.Io.Writer.Allocating = .init(allocator);
207
+ defer archive.deinit();
208
+ for ([_][]const u8{ "", "/abs.txt", "../up.txt", "a/../b.txt", "a\\b.txt", "a//b.txt" }) |name| {
209
+ try std.testing.expectError(error.InvalidZipEntryName, write(allocator, &archive.writer, &.{.{ .name = name, .data = "x" }}));
210
+ }
211
+ }