ReviewOS

also looking at this

craft-native/craft

iOS Phase 4: the secure tier — async replies, Keychain, permissions; ratchet 86 → 74

#86
Merged glennmichael123 wants to merge feat/ios-phase4-secure into main
11 files +4297 -4

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/zig/src/ios_async.zigadded+295-0
Changes to packages/zig/src/ios_async.zig
@@ -0,0 +1,295 @@
1//! The machinery an asynchronous reply needs, built once.
2//!
3//! Everything served so far answers from inside the dispatch: the request id
4//! is on `request_context`'s stack, the reply reads it, done. A completion
5//! handler breaks both halves of that at once — it fires after the dispatch
6//! frame is gone (so the id is no longer anywhere), and it fires on whatever
7//! queue the framework felt like (so `evaluateJavaScript`, which is
8//! main-thread-only, cannot be called from it directly).
9//!
10//! Getting this wrong is not hypothetical; it is the repo's inheritance.
11//! `request_context.current()` returning null in a late reply silently drops
12//! correlation to action-name matching, which hands caller B's answer to
13//! caller A whenever two calls of one action are in flight. And the previous
14//! block implementation was built on the stack and handed to an async API,
15//! with its own doc comment asserting a lifetime ("valid for duration of
16//! call") that async precisely does not honour.
17//!
18//! Three pieces, each chosen for being checkable:
19//!
20//! - a slot pool that captures {request id, action} at dispatch time, with
21//! generation counters so a stale or double-firing completion cannot
22//! resolve a slot that has been reused;
23//! - completion blocks that are module-level and flagged BLOCK_IS_GLOBAL,
24//! because `Block_copy` on a global block returns the same pointer —
25//! no heap copy, no descriptor lifetime, no copy/dispose helpers. Each
26//! pool slot has its own pre-built block with the slot index baked in,
27//! which is how a non-capturing block knows who it is;
28//! - delivery hopped to the main queue with `dispatch_async_f` — the
29//! function-pointer variant, so the hop itself needs no block at all.
30
31const std = ;
32const builtin = ;
33const objc_runtime = ;
34const request_context = ;
35const bridge_error = ;
36const compat_mutex = ;
37
38const objc = objc_runtime.objc;
39const is_darwin = builtin.target.os.tag.isDarwin();
40
41/// How many async calls may be in flight at once. A page that exceeds this
42/// gets an explicit Busy error, which is an answer — the alternative was a
43/// silently dropped reply.
44pub const max_in_flight = 16;
45
46const Slot = struct {
47 /// Incremented on every acquire and release. A completion carrying a
48 /// stale generation is a late or duplicate fire for a call that has
49 /// already been answered; it must do nothing rather than resolve
50 /// whoever owns the slot now.
51 generation: u32 = 0,
52 in_use: bool = false,
53 /// The id the reply names. -1 for a call the page sent without one.
54 request_id: i64 = -1,
55 /// The action, copied into fixed storage: the dispatch frame's slices are
56 /// dead by the time the completion fires.
57 action_buf: [64]u8 = undefined,
58 action_len: usize = 0,
59 /// What the completion resolved. Written on the firing thread, read on
60 /// the main thread after the dispatch_async_f hop.
61 granted: bool = false,
62};
63
64var slots: [max_in_flight]Slot = @splat(.{});
65var slots_mutex: compat_mutex.Mutex = .{};
66
67/// A slot ticket: which slot, and which lease of it.
68pub const Ticket = struct {
69 index: u5,
70 generation: u32,
71};
72
73/// Capture the current request's identity for a reply that will arrive after
74/// the dispatch is gone. Called from inside the dispatch, which is the only
75/// place `request_context.current()` still means this call.
76///
77/// Null means the pool is full. The caller must answer Busy — returning
78/// silence here would reproduce the exact bug this file exists to prevent.
79pub fn acquire(action: []const u8) ?Ticket {
80 if (action.len > 64) return null;
81
82 slots_mutex.lock();
83 defer slots_mutex.unlock();
84
85 for (&slots, 0..) |*slot, i| {
86 if (slot.in_use) continue;
87 slot.in_use = true;
88 slot.generation +%= 1;
89 slot.request_id = if (request_context.current()) |id| @intCast(id) else -1;
90 @memcpy(slot.action_buf[0..action.len], action);
91 slot.action_len = action.len;
92 return .{ .index = @intCast(i), .generation = slot.generation };
93 }
94 return null;
95}
96
97/// Release without replying — for the error path between acquire and the
98/// framework call, where the caller is about to answer synchronously itself.
99pub fn abandon(ticket: Ticket) void {
100 slots_mutex.lock();
101 defer slots_mutex.unlock();
102 const slot = &slots[ticket.index];
103 if (slot.generation != ticket.generation or !slot.in_use) return;
104 slot.in_use = false;
105 slot.generation +%= 1;
106}
107
108// ---------------------------------------------------------------------------
109// The block pool: one pre-built BOOL-completion block per slot.
110// ---------------------------------------------------------------------------
111
112const BlockDescriptor = extern struct {
113 reserved: c_ulong = 0,
114 size: c_ulong,
115};
116
117/// void (^)(BOOL) — the shape of `requestAccessForMediaType:completionHandler:`
118/// and, with the error parameter ignored by the invoke, close enough to
119/// `requestAuthorizationWithOptions:` that each gets its own invoke below.
120const BoolBlock = extern struct {
121 isa: ?*anyopaque,
122 flags: c_int,
123 reserved: c_int = 0,
124 invoke: *const anyopaque,
125 descriptor: *const BlockDescriptor,
126};
127
128/// 1 << 28. A global block is never copied: `Block_copy` returns the same
129/// pointer. That is the entire trick — no heap block, no copy/dispose pair,
130/// no descriptor lifetime, and the pool's blocks are safely handed to any
131/// API that escapes them.
132const BLOCK_IS_GLOBAL: c_int = 1 << 28;
133
134const bool_block_descriptor = BlockDescriptor{ .size = @sizeOf(BoolBlock) };
135
136extern var _NSConcreteGlobalBlock: anyopaque;
137
138/// The per-slot invokes. Comptime-generated so each block knows its slot
139/// without capturing anything: the index is baked into the function, and the
140/// function is baked into the block.
141fn makeBoolInvoke(comptime index: u5) *const anyopaque {
142 const S = struct {
143 fn invoke(_: *const BoolBlock, granted: bool) callconv(.c) void {
144 completionFired(index, granted);
145 }
146 };
147 return @ptrCast(&S.invoke);
148}
149
150/// void (^)(BOOL, NSError *) — `requestAuthorizationWithOptions:` and friends.
151/// The error object is deliberately unused: the granted flag is the answer
152/// the page's contract carries, and Swift discarded the error here too.
153fn makeBoolErrorInvoke(comptime index: u5) *const anyopaque {
154 const S = struct {
155 fn invoke(_: *const BoolBlock, granted: bool, _: objc.id) callconv(.c) void {
156 completionFired(index, granted);
157 }
158 };
159 return @ptrCast(&S.invoke);
160}
161
162fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [max_in_flight]BoolBlock {
163 var out: [max_in_flight]BoolBlock = undefined;
164 for (&out, 0..) |*b, i| {
165 b.* = .{
166 .isa = &_NSConcreteGlobalBlock,
167 .flags = BLOCK_IS_GLOBAL,
168 .invoke = maker(@intCast(i)),
169 .descriptor = &bool_block_descriptor,
170 };
171 }
172 return out;
173}
174
175var bool_blocks: [max_in_flight]BoolBlock = if (is_darwin) makeBlocks(makeBoolInvoke) else undefined;
176var bool_error_blocks: [max_in_flight]BoolBlock = if (is_darwin) makeBlocks(makeBoolErrorInvoke) else undefined;
177
178/// The block to pass as a `void (^)(BOOL)` completion handler for the call
179/// this ticket was acquired for.
180pub fn boolBlock(ticket: Ticket) *anyopaque {
181 return @ptrCast(&bool_blocks[ticket.index]);
182}
183
184/// The block to pass as a `void (^)(BOOL, NSError *)` completion handler.
185pub fn boolErrorBlock(ticket: Ticket) *anyopaque {
186 return @ptrCast(&bool_error_blocks[ticket.index]);
187}
188
189// ---------------------------------------------------------------------------
190// Firing: any thread → main queue → reply.
191// ---------------------------------------------------------------------------
192
193const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void;
194extern "c" fn dispatch_async_f(queue: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void;
195/// `dispatch_get_main_queue()` is a macro over this global.
196extern var _dispatch_main_q: anyopaque;
197
198fn completionFired(index: u5, granted: bool) void {
199 // Record the outcome under the lock, but deliver from the main queue:
200 // the reply ends in `evaluateJavaScript`, which is main-thread-only, and
201 // this may be running on whatever queue the framework chose.
202 {
203 slots_mutex.lock();
204 defer slots_mutex.unlock();
205 const slot = &slots[index];
206 if (!slot.in_use) return; // stale fire for an abandoned slot
207 slot.granted = granted;
208 }
209
210 // The context pointer carries only the slot index; everything else stays
211 // in the slot, guarded by its generation.
212 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, index) + 1), deliverOnMain);
213}
214
215fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
216 const index: usize = @intFromPtr(context orelse return) - 1;
217
218 var action_buf: [64]u8 = undefined;
219 var action_len: usize = 0;
220 var request_id: i64 = -1;
221 var granted = false;
222 {
223 slots_mutex.lock();
224 defer slots_mutex.unlock();
225 const slot = &slots[index];
226 if (!slot.in_use) return;
227 action_len = slot.action_len;
228 @memcpy(action_buf[0..action_len], slot.action_buf[0..action_len]);
229 request_id = slot.request_id;
230 granted = slot.granted;
231 slot.in_use = false;
232 slot.generation +%= 1;
233 }
234
235 const action = action_buf[0..action_len];
236
237 // Restore the identity captured at dispatch, so the reply names the call
238 // that is actually waiting — the whole point of this file.
239 request_context.push(if (request_id < 0) null else @intCast(request_id));
240 defer request_context.pop();
241
242 bridge_error.sendResultToJS(
243 std.heap.c_allocator,
244 action,
245 if (granted) "\"granted\"" else "\"denied\"",
246 );
247}
248
249const testing = std.testing;
250
251test "acquire captures the current request id, release makes the slot reusable" {
252 request_context.push(42);
253 defer request_context.pop();
254
255 const ticket = acquire("requestPermission") orelse return error.PoolUnexpectedlyFull;
256 try testing.expectEqual(@as(i64, 42), slots[ticket.index].request_id);
257 try testing.expectEqualStrings("requestPermission", slots[ticket.index].action_buf[0..slots[ticket.index].action_len]);
258
259 abandon(ticket);
260 try testing.expect(!slots[ticket.index].in_use);
261}
262
263test "a stale ticket cannot abandon a slot that was re-leased" {
264 const first = acquire("a") orelse return error.PoolUnexpectedlyFull;
265 abandon(first);
266
267 // Re-lease the same slot; the old ticket's generation is now stale.
268 const second = acquire("b") orelse return error.PoolUnexpectedlyFull;
269 defer abandon(second);
270
271 abandon(first); // must be a no-op
272 try testing.expect(slots[second.index].in_use);
273}
274
275test "the pool exhausts loudly rather than silently" {
276 var tickets: [max_in_flight]Ticket = undefined;
277 var n: usize = 0;
278 defer for (tickets[0..n]) |t| abandon(t);
279
280 while (n < max_in_flight) : (n += 1) {
281 tickets[n] = acquire("x") orelse break;
282 }
283 // Whatever other tests leased, the pool must end by refusing with null —
284 // never by handing out a slot it does not have.
285 try testing.expect(acquire("one-too-many") == null);
286}
287
288test "an action longer than the slot's storage is refused at acquire" {
289 // Truncating it instead would make the eventual reply name a different
290 // action than the page called — correlation by wrong name, reported as
291 // success.
292 // (`**` no longer lexes as one operator in this Zig; @splat says the same.)
293 const long: [65]u8 = @splat('a');
294 try testing.expect(acquire(&long) == null);
295}
packages/zig/src/ios_dispatch.zigmodified+5-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -3,6 +3,7 @@ const builtin = ;
33const objc_runtime = ;
44const request_context = ;
55const bridge_error = ;
6pub const ios_async = ;
67const bridge_mobile = ;
78const bridge_mobile_clipboard = ;
89const bridge_mobile_haptics = ;
@@ -355,6 +356,10 @@ fn buildShimError(
355356 try out.append(allocator, '}');
356357}
357358
359test {
360 _ = ios_async;
361}
362
358363const testing = std.testing;
359364
360365test "an envelope without a type is rejected" {