0df4068a
test: the tile's -A is what consents, and it is watched now
a73x 2026-08-21 08:33
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -273,7 +273,7 @@ const mod_table = [_]ModSpec{ | |||
| 273 | // typed-at session; the overlay machinery itself is interact's, shared — | 273 | // typed-at session; the overlay machinery itself is interact's, shared — |
| 274 | // and phase 3 promotes the tile into that core rather than growing a | 274 | // and phase 3 promotes the tile into that core rather than growing a |
| 275 | // second copy of it. | 275 | // second copy of it. |
| 276 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint" }, .quic_tests = true }, | 276 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint", "testtmp" }, .quic_tests = true }, |
| 277 | // ---- layer 5 ---- | 277 | // ---- layer 5 ---- |
| 278 | // wall owns the spelling grammar and the state file, so argv is parsed | 278 | // wall owns the spelling grammar and the state file, so argv is parsed |
| 279 | // by the SAME rules the page's POST /tiles and the restored file are — | 279 | // by the SAME rules the page's POST /tiles and the restored file are — |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -105,6 +105,7 @@ const paint = @import("paint"); | |||
| 105 | // The chord table and the prediction hooks a zoomed tile shares with the | 105 | // The chord table and the prediction hooks a zoomed tile shares with the |
| 106 | // client: one interaction core, not a second copy (interact.zig). | 106 | // client: one interaction core, not a second copy (interact.zig). |
| 107 | const interact = @import("interact"); | 107 | const interact = @import("interact"); |
| 108 | const TmpDir = @import("testtmp").TmpDir; | ||
| 108 | 109 | ||
| 109 | pub const Resolved = struct { | 110 | pub const Resolved = struct { |
| 110 | target: client.Target, | 111 | target: client.Target, |
| @@ -910,6 +911,35 @@ fn closeLocal(locals: []?AgentLocal, slot: usize, transport: *client.Transport) | |||
| 910 | transport.writeFrame(.agent_close, &proto.encodeAgentId(ch.id)) catch {}; | 911 | transport.writeFrame(.agent_close, &proto.encodeAgentId(ch.id)) catch {}; |
| 911 | } | 912 | } |
| 912 | 913 | ||
| 914 | /// Open one channel to this machine's agent, or refuse. False is the whole | ||
| 915 | /// refusal: the caller answers every reason with the same `agent_close`. | ||
| 916 | /// | ||
| 917 | /// Lifted out of the pump for the first line of it. `offered` is the tile's | ||
| 918 | /// own `-A`, and a gate that only ever ran inside a live pump is a gate no | ||
| 919 | /// test can watch: flip it open and the suite still passes, because a | ||
| 920 | /// client with no agent refuses at the dial for a different reason. | ||
| 921 | fn openAgentChan( | ||
| 922 | locals: []?AgentLocal, | ||
| 923 | id: u32, | ||
| 924 | offered: bool, | ||
| 925 | sock: []const u8, | ||
| 926 | ) bool { | ||
| 927 | // The OFFER is the consent, and it is per TILE: a wall where one tile | ||
| 928 | // was typed with `-A` must not hand another tile's host the keys, | ||
| 929 | // whoever asks. | ||
| 930 | if (!offered) return false; | ||
| 931 | // A live id reused. Refusing keeps the channel already on that id | ||
| 932 | // intact, which is the half of the collision that has real bytes moving | ||
| 933 | // through it. | ||
| 934 | if (findLocal(locals, id) != null) return false; | ||
| 935 | const fd = client.connectAgent(sock) orelse return false; | ||
| 936 | if (storeLocal(locals, id, fd) == null) { | ||
| 937 | std.posix.close(fd); | ||
| 938 | return false; | ||
| 939 | } | ||
| 940 | return true; | ||
| 941 | } | ||
| 942 | |||
| 913 | /// One `agent_data` payload onto the channel it names. Lifted out of the | 943 | /// One `agent_data` payload onto the channel it names. Lifted out of the |
| 914 | /// pump so the cap has a seam a test can reach: everything else in that | 944 | /// pump so the cap has a seam a test can reach: everything else in that |
| 915 | /// loop needs a live daemon, and the length rule is the one thing here that | 945 | /// loop needs a live daemon, and the length rule is the one thing here that |
| @@ -1390,26 +1420,12 @@ fn pumpTile(t: *Tile) void { | |||
| 1390 | // reasons is ordinary — no agent on this machine; | 1420 | // reasons is ordinary — no agent on this machine; |
| 1391 | // the rest are a daemon asking for something it | 1421 | // the rest are a daemon asking for something it |
| 1392 | // should not. | 1422 | // should not. |
| 1393 | const opened = open: { | 1423 | const opened = openAgentChan( |
| 1394 | // The OFFER is the consent, and it is per | 1424 | &agent_locals, |
| 1395 | // TILE: a wall where one tile was typed with | 1425 | id, |
| 1396 | // `-A` must not hand another tile's host the | 1426 | t.r.agent, |
| 1397 | // keys, whoever asks. | 1427 | std.posix.getenv(proto.agent_sock_env) orelse "", |
| 1398 | if (!t.r.agent) break :open false; | 1428 | ); |
| 1399 | // A live id reused. Refusing keeps the | ||
| 1400 | // channel already on that id intact, which is | ||
| 1401 | // the half of the collision that has real | ||
| 1402 | // bytes moving through it. | ||
| 1403 | if (findLocal(&agent_locals, id) != null) break :open false; | ||
| 1404 | const fd = client.connectAgent( | ||
| 1405 | std.posix.getenv(proto.agent_sock_env) orelse "", | ||
| 1406 | ) orelse break :open false; | ||
| 1407 | if (storeLocal(&agent_locals, id, fd) == null) { | ||
| 1408 | std.posix.close(fd); | ||
| 1409 | break :open false; | ||
| 1410 | } | ||
| 1411 | break :open true; | ||
| 1412 | }; | ||
| 1413 | if (!opened) | 1429 | if (!opened) |
| 1414 | transport.writeFrame( | 1430 | transport.writeFrame( |
| 1415 | .agent_close, | 1431 | .agent_close, |
| @@ -3363,3 +3379,65 @@ test "agent channels: an oversize frame hangs the channel up, a full one lands" | |||
| 3363 | try std.testing.expectEqual(proto.MsgType.agent_close, frame.type); | 3379 | try std.testing.expectEqual(proto.MsgType.agent_close, frame.type); |
| 3364 | try std.testing.expectEqual(@as(u32, 5), try proto.decodeAgentId(frame.payload)); | 3380 | try std.testing.expectEqual(@as(u32, 5), try proto.decodeAgentId(frame.payload)); |
| 3365 | } | 3381 | } |
| 3382 | |||
| 3383 | test "agent forwarding is per tile: no -A offers nothing and opens nothing" { | ||
| 3384 | // A REAL agent socket on this machine, listening. Without one, a tile | ||
| 3385 | // that never asked for forwarding and a tile that did both refuse — for | ||
| 3386 | // different reasons — and the consent gate could be deleted under a | ||
| 3387 | // green suite. | ||
| 3388 | var tmp = try TmpDir.make(); | ||
| 3389 | defer tmp.cleanup(); | ||
| 3390 | var buf: [128]u8 = undefined; | ||
| 3391 | const sock = try std.fmt.bufPrintZ(&buf, "{s}/agent.sock", .{tmp.path()}); | ||
| 3392 | const addr = try std.net.Address.initUnix(sock); | ||
| 3393 | var listener = try addr.listen(.{}); | ||
| 3394 | defer listener.deinit(); | ||
| 3395 | |||
| 3396 | var locals: [2]?AgentLocal = @splat(null); | ||
| 3397 | defer dropLocals(&locals); | ||
| 3398 | |||
| 3399 | // The gate, both ways round, against the same reachable agent. | ||
| 3400 | try std.testing.expect(!openAgentChan(&locals, 1, false, sock)); | ||
| 3401 | try std.testing.expectEqual(@as(?usize, null), findLocal(&locals, 1)); | ||
| 3402 | try std.testing.expect(openAgentChan(&locals, 1, true, sock)); | ||
| 3403 | try std.testing.expect(findLocal(&locals, 1) != null); | ||
| 3404 | |||
| 3405 | // And the daemon does not get told about an agent the tile never | ||
| 3406 | // offered — the offer is what the daemon routes on, so a stray one | ||
| 3407 | // makes this client the answerer for a session it never armed. | ||
| 3408 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 3409 | var t = Tile{ | ||
| 3410 | .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false }, | ||
| 3411 | .stripe = .{ .top = 0, .rows = 4 }, | ||
| 3412 | .shared = &shared, | ||
| 3413 | .idx = 0, | ||
| 3414 | .wake_r = -1, | ||
| 3415 | .wake_w = -1, | ||
| 3416 | }; | ||
| 3417 | const link = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3418 | defer std.posix.close(link[0]); | ||
| 3419 | defer std.posix.close(link[1]); | ||
| 3420 | var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd }; | ||
| 3421 | |||
| 3422 | try sendAttach(&t, &transport, 0, 0); | ||
| 3423 | const first = (try proto.readFrame(std.testing.allocator, link[0])) orelse | ||
| 3424 | return error.NoAttach; | ||
| 3425 | defer first.deinit(std.testing.allocator); | ||
| 3426 | try std.testing.expectEqual(proto.MsgType.attach, first.type); | ||
| 3427 | // Nothing else on the pipe at all: an empty non-blocking read is the | ||
| 3428 | // only proof that no offer followed, since a frame reader would just | ||
| 3429 | // block waiting for one. | ||
| 3430 | var spare: [1]u8 = undefined; | ||
| 3431 | try std.testing.expectError(error.WouldBlock, std.posix.read(link[0], &spare)); | ||
| 3432 | |||
| 3433 | // The positive control on the same pipe: with `-A` the offer follows the | ||
| 3434 | // attach, so the silence above is the gate and not an unwritten frame. | ||
| 3435 | t.r.agent = true; | ||
| 3436 | try sendAttach(&t, &transport, 0, 0); | ||
| 3437 | const attach = (try proto.readFrame(std.testing.allocator, link[0])).?; | ||
| 3438 | defer attach.deinit(std.testing.allocator); | ||
| 3439 | const offer = (try proto.readFrame(std.testing.allocator, link[0])) orelse | ||
| 3440 | return error.NoAgentOffer; | ||
| 3441 | defer offer.deinit(std.testing.allocator); | ||
| 3442 | try std.testing.expectEqual(proto.MsgType.agent_offer, offer.type); | ||
| 3443 | } | ||