From ea65717f28615ff7fa05797f5fc164a6277a5023 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Tue, 22 Sep 2026 19:16:35 +0300 Subject: [PATCH 1/2] feat: req builds a subscription, and shows it before sending it The first half of the network verbs: the filter, and the envelope it becomes. `deed req` given no relay prints what it would send and stops. That is nak's default too, and it is the right one for a tool whose point is that you can read what it is about to ask before anybody is asked. `--bare` drops the envelope and leaves the filter, which is the form that goes into a config or another tool. The flags are the ones every nostr filter has: kinds, authors, ids, `e`, `p` and `t` tags, a limit and a time window. Authors and ids are taken as bech32 or as hex and mean the same thing either way, because a reader has whichever one they were given. The exit codes follow the contract the rest of the tool keeps: a value that will not parse is 1, because the command was understood and the value in it was wrong; an unknown flag is 2, because nothing was attempted. `relayset.zig` is the other half, and it is structure only so far: dialling a set of relays, asking each the same question, deduplicating events by id across them and writing each one out as a line. Nothing calls it yet. The policy it needs, when a one-shot query should stop and what a partial failure means for the exit code, is the part worth reading other implementations for rather than deciding from first principles, so it is not decided here. It does compile, which took proving. `_ = @import("relayset.zig")` imports a file without analysing a function body nobody references, so the build went green over code that did not compile: `Io.Clock` has no `.monotonic`, the monotonic clock is `.awake`, and `Clock.Duration` wraps the `Io.Duration` that carries the unit accessors. A `refAllDecls` test in the file is what forced the compiler to look. --- src/cmd_req.zig | 349 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 9 ++ src/relayset.zig | 181 ++++++++++++++++++++++++ 3 files changed, 539 insertions(+) create mode 100644 src/cmd_req.zig create mode 100644 src/relayset.zig diff --git a/src/cmd_req.zig b/src/cmd_req.zig new file mode 100644 index 0000000..5c5526d --- /dev/null +++ b/src/cmd_req.zig @@ -0,0 +1,349 @@ +//! `deed req`: build a subscription, and run it. +//! +//! With no relay arguments it prints the REQ envelope and stops, which makes +//! the filter itself inspectable before anybody dials anything. nak does the +//! same and it is the right default for a tool whose whole point is that you +//! can see what it is about to send. + +const std = @import("std"); +const nostr = @import("nostr"); +const cli = @import("cli.zig"); + +const filter = nostr.filter; +const message = nostr.message; +const hex = nostr.hex; + +pub const usage = + \\deed req: build a subscription, and run it + \\ + \\Usage: + \\ deed req [options] [...] + \\ + \\Options: + \\ -k, --kind event kind, repeatable + \\ -a, --author author, as npub1… or 64 hex characters, repeatable + \\ -i, --id event id, as note1… or 64 hex characters, repeatable + \\ -e an `e` tag value, repeatable + \\ -p a `p` tag value, repeatable + \\ -t a `t` tag value, repeatable + \\ -l, --limit how many events to ask each relay for + \\ -s, --since unix seconds, events at or after + \\ -u, --until unix seconds, events at or before + \\ --bare print the filter alone, without the REQ envelope + \\ + \\Given no relay, it prints what it would send and stops, so a filter can be + \\read before it is asked of anybody: + \\ + \\ deed req -k 1 -l 5 + \\ ["REQ","deed",{"kinds":[1],"limit":5}] + \\ +; + +/// The filter a run is about, and the relays to ask. +pub const Request = struct { + filter: filter.Filter, + relays: []const []const u8, + bare: bool, +}; + +const max_values = 64; + +/// What a repeated flag collects. Fixed, because a command line naming more +/// than this many authors is a file being piped in by the wrong route. +fn Collected(comptime T: type) type { + return struct { + items: [max_values]T = undefined, + len: usize = 0, + + fn push(self: *@This(), v: T) bool { + if (self.len >= self.items.len) return false; + self.items[self.len] = v; + self.len += 1; + return true; + } + fn slice(self: *const @This()) ?[]const T { + return if (self.len == 0) null else self.items[0..self.len]; + } + }; +} + +/// A 32-byte key or id, as `npub1…`/`note1…`/`nsec1…` or 64 hex characters. +fn idOrKey(gpa: std.mem.Allocator, s: []const u8) ?[32]u8 { + const t = std.mem.trim(u8, s, " \t\r\n"); + if (t.len == 64) return hex.decodeFixed(32, t) catch null; + if (std.mem.startsWith(u8, t, "npub1")) return nostr.nip19.decodeNpub(gpa, t) catch null; + if (std.mem.startsWith(u8, t, "note1")) return nostr.nip19.decodeNote(gpa, t) catch null; + return null; +} + +pub const ParseError = error{ Usage, BadValue }; + +/// Reads the flags into a filter. The relays are whatever is left over. +pub fn parse( + gpa: std.mem.Allocator, + args: []const []const u8, + ids: *Collected([32]u8), + authors: *Collected([32]u8), + kinds: *Collected(u16), + e_tags: *Collected([]const u8), + p_tags: *Collected([]const u8), + t_tags: *Collected([]const u8), + tags: *[3]filter.TagFilter, + relays: *Collected([]const u8), + err: *std.Io.Writer, +) !?Request { + var f = filter.Filter{}; + var bare = false; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const a = args[i]; + if (cli.isOneOf(a, &.{ "help", "-h", "--help" })) return null; + if (std.mem.eql(u8, a, "--bare")) { + bare = true; + continue; + } + if (!std.mem.startsWith(u8, a, "-")) { + if (!relays.push(a)) { + try err.writeAll("deed req: too many relays\n"); + return ParseError.Usage; + } + continue; + } + + const takes_value = cli.isOneOf(a, &.{ + "-k", "--kind", "-a", "--author", "-i", "--id", + "-e", "-p", "-t", "-l", "--limit", "-s", + "--since", "-u", "--until", + }); + if (!takes_value) { + try err.print("deed req: unknown option '{s}'\n", .{a}); + return ParseError.Usage; + } + i += 1; + if (i >= args.len) { + try err.print("deed req: '{s}' needs a value\n", .{a}); + return ParseError.Usage; + } + const v = args[i]; + + if (cli.isOneOf(a, &.{ "-k", "--kind" })) { + const n = std.fmt.parseInt(u16, v, 10) catch { + try err.print("deed req: '{s}' is not a kind number\n", .{v}); + return ParseError.BadValue; + }; + _ = kinds.push(n); + } else if (cli.isOneOf(a, &.{ "-a", "--author" })) { + const k = idOrKey(gpa, v) orelse { + try err.print("deed req: '{s}' is not a key\n", .{v}); + return ParseError.BadValue; + }; + _ = authors.push(k); + } else if (cli.isOneOf(a, &.{ "-i", "--id" })) { + const k = idOrKey(gpa, v) orelse { + try err.print("deed req: '{s}' is not an id\n", .{v}); + return ParseError.BadValue; + }; + _ = ids.push(k); + } else if (std.mem.eql(u8, a, "-e")) { + _ = e_tags.push(v); + } else if (std.mem.eql(u8, a, "-p")) { + _ = p_tags.push(v); + } else if (std.mem.eql(u8, a, "-t")) { + _ = t_tags.push(v); + } else if (cli.isOneOf(a, &.{ "-l", "--limit" })) { + f.limit = std.fmt.parseInt(u32, v, 10) catch { + try err.print("deed req: '{s}' is not a limit\n", .{v}); + return ParseError.BadValue; + }; + } else if (cli.isOneOf(a, &.{ "-s", "--since" })) { + f.since = std.fmt.parseInt(i64, v, 10) catch { + try err.print("deed req: '{s}' is not a unix timestamp\n", .{v}); + return ParseError.BadValue; + }; + } else if (cli.isOneOf(a, &.{ "-u", "--until" })) { + f.until = std.fmt.parseInt(i64, v, 10) catch { + try err.print("deed req: '{s}' is not a unix timestamp\n", .{v}); + return ParseError.BadValue; + }; + } + } + + f.ids = ids.slice(); + f.authors = authors.slice(); + f.kinds = kinds.slice(); + + // The tag filters live in the caller's array so they outlive this function. + var tag_len: usize = 0; + if (e_tags.slice()) |v| { + tags[tag_len] = .{ .letter = 'e', .values = v }; + tag_len += 1; + } + if (p_tags.slice()) |v| { + tags[tag_len] = .{ .letter = 'p', .values = v }; + tag_len += 1; + } + if (t_tags.slice()) |v| { + tags[tag_len] = .{ .letter = 't', .values = v }; + tag_len += 1; + } + if (tag_len > 0) f.tags = tags[0..tag_len]; + + return .{ .filter = f, .relays = relays.slice() orelse &.{}, .bare = bare }; +} + +/// The subscription id every run uses. +/// +/// Fixed rather than random: one subscription per process, closed when the +/// process ends, so a unique id would buy nothing and would make the envelope +/// this prints different every time it is run, which is worse for a tool whose +/// output people paste into issues. +pub const subscription_id = "deed"; + +pub fn run( + gpa: std.mem.Allocator, + io: std.Io, + args: []const []const u8, + out: *std.Io.Writer, + err: *std.Io.Writer, +) !u8 { + _ = io; + var ids: Collected([32]u8) = .{}; + var authors: Collected([32]u8) = .{}; + var kinds: Collected(u16) = .{}; + var e_tags: Collected([]const u8) = .{}; + var p_tags: Collected([]const u8) = .{}; + var t_tags: Collected([]const u8) = .{}; + var tags: [3]filter.TagFilter = undefined; + var relays: Collected([]const u8) = .{}; + + const req = parse(gpa, args, &ids, &authors, &kinds, &e_tags, &p_tags, &t_tags, &tags, &relays, err) catch |e| { + return switch (e) { + ParseError.Usage => cli.exit_usage, + ParseError.BadValue => cli.exit_fail, + else => return e, + }; + } orelse { + try out.writeAll(usage); + return cli.exit_ok; + }; + + // No relay named: say what would be sent, and stop. The filter is the thing + // worth seeing before it is asked of anybody. + if (req.relays.len == 0) { + const text = if (req.bare) blk: { + var list: std.ArrayList(u8) = .empty; + errdefer list.deinit(gpa); + try req.filter.appendJson(&list, gpa); + break :blk try list.toOwnedSlice(gpa); + } else try message.encodeReq(gpa, subscription_id, &.{req.filter}); + defer gpa.free(text); + try out.print("{s}\n", .{text}); + return cli.exit_ok; + } + + try err.writeAll("deed req: asking relays is not in this release yet\n"); + return cli.exit_fail; +} + +const Run = struct { code: u8, out: []const u8, err: []const u8 }; + +fn runReq(args: []const []const u8, out_buf: []u8, err_buf: []u8) !Run { + var out: std.Io.Writer = .fixed(out_buf); + var err: std.Io.Writer = .fixed(err_buf); + const code = try run(std.testing.allocator, std.testing.io, args, &out, &err); + return .{ .code = code, .out = out.buffered(), .err = err.buffered() }; +} + +test "a filter with no relay is printed rather than sent" { + var ob: [4096]u8 = undefined; + var eb: [1024]u8 = undefined; + const r = try runReq(&.{ "-k", "1", "-l", "5" }, &ob, &eb); + try std.testing.expectEqual(cli.exit_ok, r.code); + try std.testing.expectEqualStrings("[\"REQ\",\"deed\",{\"kinds\":[1],\"limit\":5}]\n", r.out); + try std.testing.expectEqualStrings("", r.err); +} + +test "--bare drops the envelope and keeps the filter" { + var ob: [4096]u8 = undefined; + var eb: [1024]u8 = undefined; + const r = try runReq(&.{ "-k", "1", "--bare" }, &ob, &eb); + try std.testing.expectEqual(cli.exit_ok, r.code); + try std.testing.expectEqualStrings("{\"kinds\":[1]}\n", r.out); +} + +test "a repeated flag collects rather than replaces" { + var ob: [4096]u8 = undefined; + var eb: [1024]u8 = undefined; + const r = try runReq(&.{ "-k", "1", "-k", "6", "-k", "16", "--bare" }, &ob, &eb); + try std.testing.expectEqualStrings("{\"kinds\":[1,6,16]}\n", r.out); +} + +test "an author is taken as an npub or as hex, and means the same thing" { + const hex_key = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const npub = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; + + var ob: [4096]u8 = undefined; + var eb: [1024]u8 = undefined; + const from_hex = try runReq(&.{ "-a", hex_key, "--bare" }, &ob, &eb); + + var ob2: [4096]u8 = undefined; + var eb2: [1024]u8 = undefined; + const from_npub = try runReq(&.{ "-a", npub, "--bare" }, &ob2, &eb2); + + try std.testing.expectEqualStrings(from_hex.out, from_npub.out); + // And what goes on the wire is hex, which is what a relay reads. + try std.testing.expect(std.mem.indexOf(u8, from_hex.out, hex_key) != null); +} + +test "tag filters land under their own letter" { + var ob: [4096]u8 = undefined; + var eb: [1024]u8 = undefined; + const r = try runReq(&.{ "-t", "zig", "-t", "nostr", "--bare" }, &ob, &eb); + try std.testing.expectEqualStrings("{\"#t\":[\"zig\",\"nostr\"]}\n", r.out); +} + +test "an empty filter is a legal subscription, not an error" { + // `{}` asks a relay for everything. It is a thing somebody may genuinely + // want to see the envelope for, so printing it beats refusing it. + var ob: [4096]u8 = undefined; + var eb: [1024]u8 = undefined; + const r = try runReq(&.{"--bare"}, &ob, &eb); + try std.testing.expectEqual(cli.exit_ok, r.code); + try std.testing.expectEqualStrings("{}\n", r.out); +} + +test "the exit codes say which kind of wrong it was" { + var ob: [1024]u8 = undefined; + var eb: [1024]u8 = undefined; + // A value that will not parse: the command ran and the value was bad. + const bad_value = try runReq(&.{ "-k", "notanumber" }, &ob, &eb); + try std.testing.expectEqual(cli.exit_fail, bad_value.code); + try std.testing.expectEqualStrings("", bad_value.out); + + // A flag nobody knows: the command was not understood. + var ob2: [1024]u8 = undefined; + var eb2: [1024]u8 = undefined; + const unknown = try runReq(&.{"--nope"}, &ob2, &eb2); + try std.testing.expectEqual(cli.exit_usage, unknown.code); + + // A flag with nothing after it. + var ob3: [1024]u8 = undefined; + var eb3: [1024]u8 = undefined; + const dangling = try runReq(&.{"-k"}, &ob3, &eb3); + try std.testing.expectEqual(cli.exit_usage, dangling.code); + + // A key that is neither hex nor bech32. + var ob4: [1024]u8 = undefined; + var eb4: [1024]u8 = undefined; + const bad_key = try runReq(&.{ "-a", "alice" }, &ob4, &eb4); + try std.testing.expectEqual(cli.exit_fail, bad_key.code); +} + +test "help is printed on stdout and succeeds" { + var ob: [4096]u8 = undefined; + var eb: [1024]u8 = undefined; + const r = try runReq(&.{"--help"}, &ob, &eb); + try std.testing.expectEqual(cli.exit_ok, r.code); + try std.testing.expect(std.mem.indexOf(u8, r.out, "deed req") != null); +} diff --git a/src/main.zig b/src/main.zig index 67ffa2e..a4cd5d6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,6 +10,7 @@ const cmd_encode = @import("cmd_encode.zig"); const cmd_crypt = @import("cmd_crypt.zig"); const cmd_event = @import("cmd_event.zig"); const cmd_key = @import("cmd_key.zig"); +const cmd_req = @import("cmd_req.zig"); const cmd_verify = @import("cmd_verify.zig"); pub const version = "0.1.0"; @@ -27,6 +28,7 @@ const usage = \\ encode build a NIP-19 code out of its parts \\ encrypt encrypt a message to someone, with NIP-44 \\ decrypt decrypt a NIP-44 payload from someone + \\ req build a subscription, and run it \\ verify check that events are correctly signed \\ \\ help this text, or `deed help ` (also -h, --help) @@ -157,6 +159,7 @@ fn run( if (std.mem.eql(u8, verb, "encode")) return cmd_encode.run(gpa, rest, out, err); if (std.mem.eql(u8, verb, "encrypt")) return cmd_crypt.run(gpa, io, .encrypt, rest, out, err); if (std.mem.eql(u8, verb, "decrypt")) return cmd_crypt.run(gpa, io, .decrypt, rest, out, err); + if (std.mem.eql(u8, verb, "req")) return cmd_req.run(gpa, io, rest, out, err); if (std.mem.eql(u8, verb, "verify")) return cmd_verify.run(gpa, io, rest, out, err); try err.print("deed: unknown command '{s}'\nRun `deed help` for the list.\n", .{verb}); @@ -188,6 +191,10 @@ fn helpFor(topic: []const u8, out: *std.Io.Writer, err: *std.Io.Writer) !u8 { try out.writeAll(cmd_crypt.decrypt_usage); return cli.exit_ok; } + if (std.mem.eql(u8, topic, "req")) { + try out.writeAll(cmd_req.usage); + return cli.exit_ok; + } if (std.mem.eql(u8, topic, "verify")) { try out.writeAll(cmd_verify.usage); return cli.exit_ok; @@ -297,5 +304,7 @@ test { _ = @import("cmd_decode.zig"); _ = @import("cmd_encode.zig"); _ = @import("cmd_key.zig"); + _ = @import("cmd_req.zig"); + _ = @import("relayset.zig"); _ = @import("cmd_verify.zig"); } diff --git a/src/relayset.zig b/src/relayset.zig new file mode 100644 index 0000000..2932999 --- /dev/null +++ b/src/relayset.zig @@ -0,0 +1,181 @@ +//! Asking a set of relays one question, and collecting what comes back. +//! +//! Shared by `req` and `fetch`, because they differ in how the question is +//! built and not at all in how it is asked. +//! +//! A command line is a SHORT-LIVED process, which changes the shape of this +//! from what a long-running client does. There is no reconnect, no backoff and +//! no liveness tracking: a relay that will not answer inside the deadline is a +//! relay this run does without, and the run says so on stderr rather than +//! waiting for it. + +const std = @import("std"); +const nostr = @import("nostr"); + +const filter = nostr.filter; +const message = nostr.message; +const relay = nostr.relay; + +pub const Options = struct { + /// Stop once every relay that answered has sent EOSE. + /// + /// A relay sends EOSE when it has finished replaying what it has stored, + /// so this is the difference between "everything you have" and "everything + /// you have, and then whatever arrives while I wait". + until_eose: bool = true, + /// The whole run gives up here, whatever the relays are doing. A relay that + /// accepts a subscription and then says nothing would otherwise hold the + /// process open for as long as somebody let it. + deadline_ms: i64, + /// How long one read may block before the loop moves to the next relay. + /// Short, because it is a round-robin across relays rather than a wait. + poll_ms: i64, + /// Every event that arrives is written here before it is printed. + store: ?*nostr.store.Store = null, +}; + +pub const Outcome = struct { + /// Relays that answered the dial. + dialled: usize = 0, + /// Relays that could not be reached at all. + unreachable_count: usize = 0, + /// Distinct events printed. + events: usize = 0, + /// Relays that reached EOSE before the deadline. + complete: usize = 0, +}; + +const max_relays = 32; + +/// The subscription every run uses. One per process, closed by the process. +pub const subscription_id = "deed"; + +/// Dials `urls`, asks each the same question, and prints every distinct event +/// as one line of JSON. +/// +/// Events are deduplicated across relays by id: the same note held by four +/// relays is one line, which is what makes piping this into `deed verify` +/// mean something. +pub fn query( + gpa: std.mem.Allocator, + io: std.Io, + urls: []const []const u8, + filters: []const filter.Filter, + out: *std.Io.Writer, + err: *std.Io.Writer, + opts: Options, +) !Outcome { + var result = Outcome{}; + + var relays: [max_relays]?*relay.Relay = @splat(null); + var done: [max_relays]bool = @splat(false); + const n = @min(urls.len, max_relays); + + defer for (relays[0..n]) |maybe| { + if (maybe) |r| { + r.shutdown(io); + r.deinit(); + } + }; + + for (urls[0..n], 0..) |url, i| { + const r = relay.dial(gpa, io, url) catch |e| { + // Named, not swallowed. A run that quietly asked three relays + // instead of four looks like the fourth had nothing. + try err.print("deed: {s}: {s}\n", .{ url, @errorName(e) }); + result.unreachable_count += 1; + done[i] = true; + continue; + }; + r.subscribe(subscription_id, filters) catch |e| { + try err.print("deed: {s}: {s}\n", .{ url, @errorName(e) }); + r.shutdown(io); + r.deinit(); + result.unreachable_count += 1; + done[i] = true; + continue; + }; + relays[i] = r; + result.dialled += 1; + } + if (result.dialled == 0) return result; + + var seen = std.AutoHashMap([32]u8, void).init(gpa); + defer seen.deinit(); + + // `.awake` is this standard library's monotonic clock: it cannot go + // backwards when somebody adjusts the system time mid-run, which `.real` + // can, and a deadline that can move backwards is not one. + const started = std.Io.Clock.Timestamp.now(io, .awake); + while (true) { + const now = std.Io.Clock.Timestamp.now(io, .awake); + if (started.durationTo(now).raw.toMilliseconds() >= opts.deadline_ms) { + try err.writeAll("deed: gave up waiting for the relays still answering\n"); + break; + } + + var any_live = false; + for (relays[0..n], 0..) |maybe, i| { + const r = maybe orelse continue; + if (done[i]) continue; + any_live = true; + + var msg = (r.receiveTimeout(std.Io.Timeout{ .duration = .{ .raw = .fromMilliseconds(opts.poll_ms), .clock = .awake } }) catch |e| switch (e) { + error.Timeout => continue, + else => { + // A relay that drops mid-answer is a relay this run does + // without. The events it already sent are still good. + try err.print("deed: a relay stopped answering: {s}\n", .{@errorName(e)}); + done[i] = true; + continue; + }, + }) orelse { + done[i] = true; + continue; + }; + defer msg.deinit(); + + switch (msg.value) { + .event => |e| { + if (seen.contains(e.event.id)) continue; + try seen.put(e.event.id, {}); + if (opts.store) |s| { + // Written before it is printed, so a run interrupted + // partway through still kept what it had already read. + // `ingest` checks the signature, so a relay cannot put + // something into the store by claiming it. + _ = s.ingest(gpa, e.event, .{}) catch {}; + } + const json = try nostr.event.toJson(gpa, e.event); + defer gpa.free(json); + try out.print("{s}\n", .{json}); + result.events += 1; + }, + .eose => { + result.complete += 1; + if (opts.until_eose) done[i] = true; + }, + .closed => |c| { + try err.print("deed: a relay closed the subscription: {s}\n", .{c.message}); + done[i] = true; + }, + .notice => |notice| try err.print("deed: notice: {s}\n", .{notice.message}), + // A relay asking for NIP-42 is asking for a signature this verb + // was not given a key for. Saying so beats looking like a relay + // with nothing to say. + .auth => try err.print("deed: a relay wants authentication, which this verb cannot give it\n", .{}), + .ok => {}, + } + } + if (!any_live) break; + } + + return result; +} + +test { + // Forces this file to be analysed. `_ = @import("relayset.zig")` alone + // imports it without ever compiling a function body nobody references, so + // the build would go green over code that does not compile. + std.testing.refAllDecls(@This()); +} From 737d940e890cd27413aa395d5415772aa4b9cf16 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Tue, 22 Sep 2026 19:52:29 +0300 Subject: [PATCH 2/2] feat: deed reaches relays, and keeps what it finds Three verbs and a store, which the milestone named as one release because they are one idea. `req` builds a subscription and runs it. `fetch` gets the events a NIP-19 code names, using the relay hints the code carries, which is the whole difference between it and `req` with an id. `publish` offers signed events to relays and reports what each one said. `--store` is the point. A run that fetches can keep what it fetched, and a later run answers the same question from the store with no socket open at all. The events that come back are the same events and still verify, because what is stored is what was signed. `--local` is the half that makes keeping worth doing: a store nothing reads back is a log. This is the gap the README claims deed exists for, and the claim is checkable. nak keeps its local database behind `//go:build linux && !riscv64 && !arm64`, so on a Mac or an ARM machine every run starts from nothing, and even where it is compiled in there is no write anywhere in its req or fetch path. Three decisions worth naming, all of them read rather than reasoned. Events are checked before they are kept or printed. A relay can send anything, so a signature that does not verify is dropped, and so is an event that does not answer the question that was asked. nak does both by default and a tool whose output people pipe into other tools has to: this is the last place a forgery can be stopped. A query gives up rather than hanging. nak's per-relay wait has no timer in it at all, so a relay that accepts a subscription and then says nothing holds the process open for as long as somebody lets it. That is survivable at a prompt and not in a script, which is where a command line lives. Publishing succeeds when any relay accepts. An event one relay holds is published, so a partial failure exits 0 with every refusal named on stderr and only a total failure exits 1. Reporting failure after reaching the network would have scripts retrying something that already happened. nak draws the line in the same place. Relays are dialled before stdin is read, so a run that cannot reach anybody says so without having consumed the events it was given. Two things this does not do, said in the README rather than left to be discovered. A code with no relay hints is not chased through the author's relay list, because that is a second round trip with its own staleness rules. Windows still does not build. 68 tests. The verbs were run against a live relay rather than only compiled: a query returns events that `deed verify` accepts, a fetch by id returns that id, a bare npub returns one kind:0, and a store filled over the network answers the same filter offline with an identical set. The publish accept path is the one thing not exercised against a real relay, because doing that means posting to somebody's relay. Closes #16. --- .github/RELEASE_NOTES.md | 23 +++ README.md | 28 +++- build.zig.zon | 2 +- src/cmd_fetch.zig | 335 +++++++++++++++++++++++++++++++++++++++ src/cmd_publish.zig | 195 +++++++++++++++++++++++ src/cmd_req.zig | 110 ++++++++++++- src/main.zig | 18 ++- src/relayset.zig | 41 ++++- 8 files changed, 734 insertions(+), 18 deletions(-) create mode 100644 src/cmd_fetch.zig create mode 100644 src/cmd_publish.zig diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index 604a7d8..9472e0e 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -2,6 +2,29 @@ Every artifact below is published with a `.sha256` beside it, so the download can be checked against a digest that was written by the same job that built it. +### What's new in v0.2.0 + +deed reaches relays now, and keeps what it finds. + +**Three new verbs.** `req` builds a subscription and runs it. `fetch` gets the events a NIP-19 code names, using the relay hints the code carries. `publish` offers signed events to relays and reports what each one said. + +**`--store` is the point of the release.** A run that fetches can keep what it fetched, and a later run can answer the same question from the store without opening a socket: + +```sh +deed req -k 1 -l 50 --store ~/.deed/db wss://relay.example +deed req -k 1 -l 50 --store ~/.deed/db --local +``` + +The second dials nothing. The events are the same events, and they still verify, because what is stored is what was signed. Other nostr command lines do not do this: the one most people use keeps its local database behind a build tag for Linux on x86_64 only, and even there its own query verbs never write to it. + +**Events are checked before they are kept or printed.** A relay can send anything. A signature that does not verify is dropped, and so is an event that does not answer the question that was asked. Both are reported rather than silently skipped. + +**A query gives up rather than hanging.** Thirty seconds by default, `--timeout` to change it. This is a deliberate difference from nak, whose per-relay wait has no timer at all: a relay that accepts a subscription and then goes quiet will hold it open indefinitely. That is survivable at a prompt and not in a script. + +**Publishing succeeds when any relay accepts.** An event one relay holds is published, so a partial failure exits 0 with every refusal named on stderr, and only a total failure exits 1. Scripts should not retry something that already happened. + +`deed req` with no relay still prints the envelope it would send rather than sending it, so a filter can be read before anybody is asked. + ### What's new in v0.1.0 The first release. deed does the things that need no network: it makes keys, builds and signs events, reads and writes NIP-19 codes, encrypts and decrypts NIP-44 payloads, and checks signatures. Publishing to relays and fetching from them are deliberately absent, so everything here can be inspected before any of it leaves the machine. diff --git a/README.md b/README.md index 694b1b3..c9158bb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ macOS and Linux, Intel and ARM. It works out which build this machine wants, che The script is short and worth reading before you pipe anything into bash. If you would rather do it yourself: ```sh -VERSION=0.1.0 +VERSION=0.2.0 PLATFORM=macos-aarch64 # or macos-x86_64, linux-x86_64, linux-aarch64 BASE=https://github.com/zig-nostr/deed/releases/download/v$VERSION @@ -40,7 +40,7 @@ xattr -dr com.apple.quarantine deed ## What it does -Everything deed does today is offline. It opens no socket, so what comes out can be inspected before any of it leaves the machine. Reaching relays is not here yet: see [what is missing](#what-is-missing). +deed reaches relays and keeps what it finds. Every verb that does not need a socket still works without one, so what comes out can be read before any of it leaves the machine. | verb | | | --- | --- | @@ -51,16 +51,32 @@ Everything deed does today is offline. It opens no socket, so what comes out can | `encrypt` | encrypt a message to someone, with NIP-44 | | `decrypt` | decrypt a NIP-44 payload from someone | | `verify` | check that events are correctly signed | +| `req` | build a subscription, and run it | +| `fetch` | get the events a code names | +| `publish` | offer signed events to relays | `deed help ` explains any of them. -## What is missing +## It keeps what it fetches + +This is the part other nostr command lines do not have, and the reason this one exists. + +```sh +deed req -k 1 -l 50 --store ~/.deed/db wss://relay.example # once, over the network +deed req -k 1 -l 50 --store ~/.deed/db --local # again, dialling nothing +``` -There is no local store, and no verb that reaches a relay. Those are one piece of work rather than two. +The second command opens no socket. The events came out of a local store that the first command filled, and they are the same events: `deed verify` is as happy with them as it was the first time, because what is stored is what was signed. + +nak, the command line most people reach for, keeps almost nothing. Its local database is behind a build tag for Linux on x86_64 only, so on a Mac or an ARM machine every run starts from nothing, and even where it is compiled in, `req` and `fetch` never write to it. That is a reasonable choice for a tool built to poke at relays. It is a bad one if you want to ask the same question twice. + +Events are checked before they are stored or printed. A relay can send anything, so a signature that does not verify, and an event that does not answer the question that was asked, are both dropped and reported. + +## What is missing -A command line that fetches and keeps nothing asks the same question again the next time it runs, and piping two such verbs together pays for the same answer twice. The store is what makes the network verbs worth having, and it is the reason this tool exists rather than being one more way to do what is already done well: a nostr command line that keeps what it fetches. So `req`, `fetch` and `publish` arrive together with `--store`, or they do not arrive. That is [the next release](https://github.com/zig-nostr/deed/milestone/1). +**Relay selection.** A code with no relay hints is not looked up: `deed fetch npub1...` asks you to name a relay rather than going to find the author's relay list first. Doing that properly means a second round trip and a cache with its own staleness rules, and doing it badly is worse than saying so. -Windows is not built either. `deed` itself does not compile there yet, and the protocol library cannot resolve a hostname on Windows ([nostr#59](https://github.com/zig-nostr/nostr/issues/59)), so claiming it now would mean losing it again the moment a verb needs a relay. +**Windows.** deed does not compile there yet, and the protocol library cannot resolve a hostname on Windows ([nostr#59](https://github.com/zig-nostr/nostr/issues/59)). ## How the verbs fit together diff --git a/build.zig.zon b/build.zig.zon index 0b7a3cd..95c8fd3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .deed, - .version = "0.1.0", + .version = "0.2.0", .fingerprint = 0x89498c2094a1a4a3, .minimum_zig_version = "0.16.0", .dependencies = .{ diff --git a/src/cmd_fetch.zig b/src/cmd_fetch.zig new file mode 100644 index 0000000..077860f --- /dev/null +++ b/src/cmd_fetch.zig @@ -0,0 +1,335 @@ +//! `deed fetch`: get the events a code names. +//! +//! A NIP-19 code often carries relay hints, which is the difference between +//! this and `req`: the code says where to look, so the reader does not have to. + +const std = @import("std"); +const nostr = @import("nostr"); +const cli = @import("cli.zig"); +const relayset = @import("relayset.zig"); + +const nip19 = nostr.nip19; +const filter = nostr.filter; +const hex = nostr.hex; + +pub const usage = + \\deed fetch: get the events a code names + \\ + \\Usage: + \\ deed fetch [...] + \\ + \\Accepts note, nevent, naddr, nprofile and npub, with or without a leading + \\`nostr:`. A code carrying relay hints is looked for at those relays as + \\well as any named here. + \\ + \\Options: + \\ --store keep every event this receives in a local store + \\ --timeout give up on relays still answering (default 30000) + \\ + \\A bare npub fetches that person's profile rather than everything they have + \\ever written, which is what `npub` on its own can sensibly mean. + \\ +; + +/// What a code resolves to: a question, and where to ask it. +const Target = struct { + filter: filter.Filter, + hints: []const []const u8, +}; + +pub fn run( + gpa: std.mem.Allocator, + io: std.Io, + args: []const []const u8, + out: *std.Io.Writer, + err: *std.Io.Writer, +) !u8 { + var code: ?[]const u8 = null; + var urls: std.ArrayList([]const u8) = .empty; + defer urls.deinit(gpa); + var store_path: ?[]const u8 = null; + var timeout_ms: i64 = 30_000; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const a = args[i]; + if (cli.isOneOf(a, &.{ "help", "-h", "--help" })) { + try out.writeAll(usage); + return cli.exit_ok; + } + if (std.mem.startsWith(u8, a, "-")) { + if (!cli.isOneOf(a, &.{ "--store", "--timeout" })) { + try err.print("deed fetch: unknown option '{s}'\n", .{a}); + return cli.exit_usage; + } + i += 1; + if (i >= args.len) { + try err.print("deed fetch: '{s}' needs a value\n", .{a}); + return cli.exit_usage; + } + if (std.mem.eql(u8, a, "--store")) store_path = args[i] else { + timeout_ms = std.fmt.parseInt(i64, args[i], 10) catch { + try err.print("deed fetch: '{s}' is not a number of milliseconds\n", .{args[i]}); + return cli.exit_fail; + }; + } + continue; + } + if (std.mem.startsWith(u8, a, "wss://") or std.mem.startsWith(u8, a, "ws://")) { + try urls.append(gpa, a); + } else if (code == null) { + code = a; + } else { + try err.writeAll("deed fetch: takes one code\n"); + return cli.exit_usage; + } + } + + const raw = code orelse { + try err.writeAll("deed fetch: needs a code to fetch\n"); + return cli.exit_usage; + }; + + var ids: [1][32]u8 = undefined; + var authors: [1][32]u8 = undefined; + var kinds: [1]u16 = undefined; + var d_values: [1][]const u8 = undefined; + var tags: [1]filter.TagFilter = undefined; + + var target = resolve(gpa, raw, &ids, &authors, &kinds, &d_values, &tags) catch { + try err.print("deed fetch: {s}: not a code deed can look up\n", .{raw}); + return cli.exit_fail; + }; + defer target.deinit(gpa); + + for (target.hints) |h| try urls.append(gpa, h); + if (urls.items.len == 0) { + // A bare npub or note carries no hints, and this verb does not go + // looking for the author's relay list to find some: that is a second + // round trip with its own caching question, and doing it badly is worse + // than saying plainly that it is not done. + try err.writeAll("deed fetch: that code carries no relay hints, so name a relay to look in\n"); + return cli.exit_usage; + } + + var store: ?nostr.store.Store = null; + defer if (store) |*st| st.deinit(); + if (store_path) |path| { + const z = try gpa.dupeZ(u8, path); + defer gpa.free(z); + store = nostr.store.Store.open(z, .{}) catch |e| { + try err.print("deed fetch: cannot open the store at {s}: {s}\n", .{ path, @errorName(e) }); + return cli.exit_fail; + }; + } + + const outcome = try relayset.query(gpa, io, urls.items, &.{target.filter}, out, err, .{ + .until_eose = true, + .deadline_ms = timeout_ms, + .poll_ms = 100, + .store = if (store) |*st| st else null, + }); + + if (outcome.dialled == 0) { + try err.writeAll("deed fetch: no relay answered\n"); + return cli.exit_fail; + } + // Nothing found is not a failure of the command. The code may name an event + // no relay asked has, which is a true answer. + return cli.exit_ok; +} + +const Resolved = struct { + filter: filter.Filter, + hints: []const []const u8, + owned: ?[][]u8 = null, + /// `naddr` carries a `d` value the decoder allocates, and the filter points + /// straight at it, so it has to outlive the query and be freed with the + /// rest. Freeing only the relays leaked one string per naddr fetched. + owned_identifier: ?[]u8 = null, + + fn deinit(self: *Resolved, gpa: std.mem.Allocator) void { + if (self.owned) |list| { + for (list) |r| gpa.free(r); + gpa.free(list); + } + if (self.owned_identifier) |d| gpa.free(d); + } +}; + +/// Turns a code into the question to ask and the relays the code names. +fn resolve( + gpa: std.mem.Allocator, + raw: []const u8, + ids: *[1][32]u8, + authors: *[1][32]u8, + kinds: *[1]u16, + d_values: *[1][]const u8, + tags: *[1]filter.TagFilter, +) !Resolved { + const s = if (std.mem.startsWith(u8, raw, "nostr:")) raw["nostr:".len..] else raw; + + if (std.mem.startsWith(u8, s, "nevent1")) { + const p = try nip19.decodeNevent(gpa, s); + ids[0] = p.id; + return .{ .filter = .{ .ids = ids[0..1] }, .hints = p.relays, .owned = p.relays }; + } + if (std.mem.startsWith(u8, s, "note1")) { + ids[0] = try nip19.decodeNote(gpa, s); + return .{ .filter = .{ .ids = ids[0..1] }, .hints = &.{} }; + } + if (std.mem.startsWith(u8, s, "naddr1")) { + const p = try nip19.decodeNaddr(gpa, s); + authors[0] = p.pubkey; + kinds[0] = @intCast(p.kind); + d_values[0] = p.identifier; + tags[0] = .{ .letter = 'd', .values = d_values[0..1] }; + return .{ + .filter = .{ .authors = authors[0..1], .kinds = kinds[0..1], .tags = tags[0..1] }, + .hints = p.relays, + .owned = p.relays, + .owned_identifier = p.identifier, + }; + } + if (std.mem.startsWith(u8, s, "nprofile1")) { + const p = try nip19.decodeNprofile(gpa, s); + authors[0] = p.pubkey; + kinds[0] = 0; + return .{ + .filter = .{ .authors = authors[0..1], .kinds = kinds[0..1] }, + .hints = p.relays, + .owned = p.relays, + }; + } + if (std.mem.startsWith(u8, s, "npub1")) { + authors[0] = try nip19.decodeNpub(gpa, s); + // Kind 0, not everything. A bare npub names a person, and the thing a + // person's code most usefully resolves to is who they say they are. + // nak defaults the same way. + kinds[0] = 0; + return .{ .filter = .{ .authors = authors[0..1], .kinds = kinds[0..1] }, .hints = &.{} }; + } + if (s.len == 64) { + ids[0] = try hex.decodeFixed(32, s); + return .{ .filter = .{ .ids = ids[0..1] }, .hints = &.{} }; + } + return error.InvalidPrefix; +} + +const testing = std.testing; + +fn resolveFor(s: []const u8, bufs: anytype) !Resolved { + return resolve(testing.allocator, s, bufs.ids, bufs.authors, bufs.kinds, bufs.d, bufs.tags); +} + +test "a note code asks for that event by id" { + var ids: [1][32]u8 = undefined; + var authors: [1][32]u8 = undefined; + var kinds: [1]u16 = undefined; + var d: [1][]const u8 = undefined; + var tags: [1]filter.TagFilter = undefined; + + const id = [_]u8{0xab} ** 32; + const note = try nip19.encodeNote(testing.allocator, id); + defer testing.allocator.free(note); + + var r = try resolveFor(note, .{ .ids = &ids, .authors = &authors, .kinds = &kinds, .d = &d, .tags = &tags }); + defer r.deinit(testing.allocator); + try testing.expect(r.filter.ids != null); + try testing.expectEqualSlices(u8, &id, &r.filter.ids.?[0]); + try testing.expectEqual(@as(usize, 0), r.hints.len); +} + +test "an npub asks for a profile, not for everything they ever wrote" { + // The default that makes a bare npub useful. Without it the code would ask + // a relay for every event one person has ever published, which is a + // different and much ruder question. + var ids: [1][32]u8 = undefined; + var authors: [1][32]u8 = undefined; + var kinds: [1]u16 = undefined; + var d: [1][]const u8 = undefined; + var tags: [1]filter.TagFilter = undefined; + + const pk = [_]u8{0xcd} ** 32; + const npub = try nip19.encodeNpub(testing.allocator, pk); + defer testing.allocator.free(npub); + + var r = try resolveFor(npub, .{ .ids = &ids, .authors = &authors, .kinds = &kinds, .d = &d, .tags = &tags }); + defer r.deinit(testing.allocator); + try testing.expect(r.filter.ids == null); + try testing.expectEqualSlices(u8, &pk, &r.filter.authors.?[0]); + try testing.expectEqual(@as(u16, 0), r.filter.kinds.?[0]); +} + +test "an nevent carries its relay hints out of the code" { + // The whole reason `fetch` is not just `req` with an id: the code says + // where to look. + var ids: [1][32]u8 = undefined; + var authors: [1][32]u8 = undefined; + var kinds: [1]u16 = undefined; + var d: [1][]const u8 = undefined; + var tags: [1]filter.TagFilter = undefined; + + const id = [_]u8{0x11} ** 32; + const hints = [_][]const u8{ "wss://one.example", "wss://two.example" }; + const code = try nip19.encodeNevent(testing.allocator, id, &hints, null, null); + defer testing.allocator.free(code); + + var r = try resolveFor(code, .{ .ids = &ids, .authors = &authors, .kinds = &kinds, .d = &d, .tags = &tags }); + defer r.deinit(testing.allocator); + try testing.expectEqualSlices(u8, &id, &r.filter.ids.?[0]); + try testing.expectEqual(@as(usize, 2), r.hints.len); + try testing.expectEqualStrings("wss://one.example", r.hints[0]); +} + +test "an naddr asks by author, kind and d tag together" { + var ids: [1][32]u8 = undefined; + var authors: [1][32]u8 = undefined; + var kinds: [1]u16 = undefined; + var d: [1][]const u8 = undefined; + var tags: [1]filter.TagFilter = undefined; + + const pk = [_]u8{0x22} ** 32; + const code = try nip19.encodeNaddr(testing.allocator, "my-article", pk, 30023, &.{}); + defer testing.allocator.free(code); + + var r = try resolveFor(code, .{ .ids = &ids, .authors = &authors, .kinds = &kinds, .d = &d, .tags = &tags }); + defer r.deinit(testing.allocator); + try testing.expectEqualSlices(u8, &pk, &r.filter.authors.?[0]); + try testing.expectEqual(@as(u16, 30023), r.filter.kinds.?[0]); + try testing.expectEqual(@as(u8, 'd'), r.filter.tags.?[0].letter); + try testing.expectEqualStrings("my-article", r.filter.tags.?[0].values[0]); +} + +test "a nostr: prefix is stripped, and nonsense is refused" { + var ids: [1][32]u8 = undefined; + var authors: [1][32]u8 = undefined; + var kinds: [1]u16 = undefined; + var d: [1][]const u8 = undefined; + var tags: [1]filter.TagFilter = undefined; + const bufs = .{ .ids = &ids, .authors = &authors, .kinds = &kinds, .d = &d, .tags = &tags }; + + const id = [_]u8{0x33} ** 32; + const note = try nip19.encodeNote(testing.allocator, id); + defer testing.allocator.free(note); + const prefixed = try std.fmt.allocPrint(testing.allocator, "nostr:{s}", .{note}); + defer testing.allocator.free(prefixed); + + var r = try resolveFor(prefixed, bufs); + defer r.deinit(testing.allocator); + try testing.expectEqualSlices(u8, &id, &r.filter.ids.?[0]); + + try testing.expectError(error.InvalidPrefix, resolveFor("not-a-code", bufs)); +} + +test "64 hex characters are taken as an event id" { + var ids: [1][32]u8 = undefined; + var authors: [1][32]u8 = undefined; + var kinds: [1]u16 = undefined; + var d: [1][]const u8 = undefined; + var tags: [1]filter.TagFilter = undefined; + + var r = try resolveFor("ab" ** 32, .{ .ids = &ids, .authors = &authors, .kinds = &kinds, .d = &d, .tags = &tags }); + defer r.deinit(testing.allocator); + try testing.expectEqualSlices(u8, &([_]u8{0xab} ** 32), &r.filter.ids.?[0]); +} diff --git a/src/cmd_publish.zig b/src/cmd_publish.zig new file mode 100644 index 0000000..9d78fb2 --- /dev/null +++ b/src/cmd_publish.zig @@ -0,0 +1,195 @@ +//! `deed publish`: offer signed events to relays. +//! +//! Reads events the way every other verb reads its records, so the thing that +//! made them is somebody else's business: `deed event -c "hi" | deed publish +//! wss://...` is the ordinary path, and a file of events works the same way. + +const std = @import("std"); +const nostr = @import("nostr"); +const cli = @import("cli.zig"); + +const relay = nostr.relay; + +/// An event too big for this is one no relay would take either. +const max_record_bytes = 1 << 20; + +/// How long one relay gets to say yes or no. +/// +/// A relay that accepts the frame and never answers is the case this exists +/// for. nak forces the same shape with a 7 second deadline on the OK and ten +/// seconds around the whole flow; this is the middle of those. +const ok_timeout_ms: i64 = 8_000; + +pub const usage = + \\deed publish: offer signed events to relays + \\ + \\Usage: + \\ deed publish ... [...] + \\ + \\Reads events as newline-delimited JSON on stdin when given no event + \\arguments, and offers each to every relay named. + \\ + \\ deed event -c "hello" | deed publish wss://relay.example + \\ + \\Every relay's answer is reported on stderr. Exits 0 when at least one + \\relay accepted an event, because an event one relay holds is published; + \\exits 1 when none did, and 2 when the command made no sense. + \\ +; + +pub fn run( + gpa: std.mem.Allocator, + io: std.Io, + args: []const []const u8, + out: *std.Io.Writer, + err: *std.Io.Writer, +) !u8 { + var urls: std.ArrayList([]const u8) = .empty; + defer urls.deinit(gpa); + var positionals: std.ArrayList([]const u8) = .empty; + defer positionals.deinit(gpa); + + for (args) |a| { + if (cli.isOneOf(a, &.{ "help", "-h", "--help" })) { + try out.writeAll(usage); + return cli.exit_ok; + } + if (std.mem.startsWith(u8, a, "-")) { + try err.print("deed publish: unknown option '{s}'\n", .{a}); + return cli.exit_usage; + } + // A relay URL is a relay URL; anything else is an event. + if (std.mem.startsWith(u8, a, "wss://") or std.mem.startsWith(u8, a, "ws://")) { + try urls.append(gpa, a); + } else { + try positionals.append(gpa, a); + } + } + + if (urls.items.len == 0) { + try err.writeAll("deed publish: name at least one relay to publish to\n"); + return cli.exit_usage; + } + + // Dialled BEFORE anything is read, so a run that cannot reach a single + // relay says so without having consumed the events it was given. nak orders + // it the same way, for the sharper version of the reason: there, signing + // costs a round trip to a remote signer, and burning one to then discover + // no relay is reachable is a bad trade. + var conns: [32]?*relay.Relay = @splat(null); + const n = @min(urls.items.len, conns.len); + defer for (conns[0..n]) |maybe| { + if (maybe) |r| { + r.shutdown(io); + r.deinit(); + } + }; + + var live: usize = 0; + for (urls.items[0..n], 0..) |url, i| { + conns[i] = relay.dial(gpa, io, url) catch |e| { + try err.print("deed publish: {s}: {s}\n", .{ url, @errorName(e) }); + continue; + }; + live += 1; + } + if (live == 0) { + try err.writeAll("deed publish: no relay could be reached\n"); + return cli.exit_fail; + } + + const stdin_buf = try gpa.alloc(u8, max_record_bytes); + defer gpa.free(stdin_buf); + var stdin_reader = std.Io.File.stdin().readerStreaming(io, stdin_buf); + var input = cli.Input.init(positionals.items, &stdin_reader.interface); + + var accepted_any = false; + var offered: usize = 0; + var refusals: usize = 0; + + while (try input.next()) |record| { + const json = switch (record) { + .line => |l| std.mem.trim(u8, l, " \t"), + .too_long => { + try err.print("deed publish: skipped an event longer than {d} bytes\n", .{max_record_bytes}); + refusals += 1; + continue; + }, + }; + + var parsed = nostr.event.fromJson(gpa, json) catch |e| { + try err.print("deed publish: not an event: {s}\n", .{@errorName(e)}); + refusals += 1; + continue; + }; + defer parsed.deinit(); + offered += 1; + + for (conns[0..n], 0..) |maybe, i| { + const r = maybe orelse continue; + const url = urls.items[i]; + r.publish(parsed.value) catch |e| { + try err.print("deed publish: {s}: {s}\n", .{ url, @errorName(e) }); + refusals += 1; + continue; + }; + if (awaitOk(r, parsed.value.id, io, err, url)) accepted_any = true else refusals += 1; + } + } + + if (offered == 0) { + try err.writeAll("deed publish: nothing to publish\n"); + return cli.exit_fail; + } + // An event one relay holds is published. nak draws the line in the same + // place: it fails only when every relay failed, because a run that reported + // failure after reaching the network would have scripts retrying something + // that already happened. + if (!accepted_any) return cli.exit_fail; + if (refusals > 0) try err.print("deed publish: {d} relay answers were not an acceptance\n", .{refusals}); + return cli.exit_ok; +} + +/// Waits for this relay's answer about this event. +/// +/// Other messages keep arriving on the same socket while this waits, so +/// anything that is not the OK being waited for is passed over rather than +/// treated as an answer. +fn awaitOk( + r: *relay.Relay, + id: [32]u8, + io: std.Io, + err: *std.Io.Writer, + url: []const u8, +) bool { + _ = io; + const deadline = std.Io.Timeout{ .duration = .{ .raw = .fromMilliseconds(ok_timeout_ms), .clock = .awake } }; + while (true) { + var msg = (r.receiveTimeout(deadline) catch { + try_print(err, "deed publish: {s}: gave up waiting for an answer\n", .{url}); + return false; + }) orelse { + try_print(err, "deed publish: {s}: closed before answering\n", .{url}); + return false; + }; + defer msg.deinit(); + switch (msg.value) { + .ok => |o| { + if (!std.mem.eql(u8, &o.event_id, &id)) continue; + if (o.accepted) { + try_print(err, "deed publish: {s}: accepted\n", .{url}); + return true; + } + try_print(err, "deed publish: {s}: refused: {s}\n", .{ url, o.message }); + return false; + }, + .notice => |notice| try_print(err, "deed publish: {s}: notice: {s}\n", .{ url, notice.message }), + else => {}, + } + } +} + +/// Writing a progress line must never be the thing that fails a publish. +fn try_print(w: *std.Io.Writer, comptime fmt: []const u8, args: anytype) void { + w.print(fmt, args) catch {}; +} diff --git a/src/cmd_req.zig b/src/cmd_req.zig index 5c5526d..899a8d8 100644 --- a/src/cmd_req.zig +++ b/src/cmd_req.zig @@ -8,6 +8,7 @@ const std = @import("std"); const nostr = @import("nostr"); const cli = @import("cli.zig"); +const relayset = @import("relayset.zig"); const filter = nostr.filter; const message = nostr.message; @@ -30,6 +31,10 @@ pub const usage = \\ -s, --since unix seconds, events at or after \\ -u, --until unix seconds, events at or before \\ --bare print the filter alone, without the REQ envelope + \\ --store keep every event this receives in a local store + \\ --local answer from the store alone, dialling nothing + \\ --stream keep reading after the relays have sent what they hold + \\ --timeout give up on relays still answering (default 30000) \\ \\Given no relay, it prints what it would send and stops, so a filter can be \\read before it is asked of anybody: @@ -44,8 +49,18 @@ pub const Request = struct { filter: filter.Filter, relays: []const []const u8, bare: bool, + store_path: ?[]const u8 = null, + stream: bool = false, + local: bool = false, + timeout_ms: i64 = default_timeout_ms, }; +/// How long a run waits on relays still answering. +/// +/// Thirty seconds rather than nak's forever: long enough for a slow relay on a +/// slow link, short enough that a script does not hang on one gone quiet. +pub const default_timeout_ms: i64 = 30_000; + const max_values = 64; /// What a repeated flag collects. Fixed, because a command line naming more @@ -94,6 +109,10 @@ pub fn parse( ) !?Request { var f = filter.Filter{}; var bare = false; + var stream = false; + var local = false; + var store_path: ?[]const u8 = null; + var timeout_ms: i64 = default_timeout_ms; var i: usize = 0; while (i < args.len) : (i += 1) { @@ -103,6 +122,14 @@ pub fn parse( bare = true; continue; } + if (std.mem.eql(u8, a, "--stream")) { + stream = true; + continue; + } + if (std.mem.eql(u8, a, "--local")) { + local = true; + continue; + } if (!std.mem.startsWith(u8, a, "-")) { if (!relays.push(a)) { try err.writeAll("deed req: too many relays\n"); @@ -112,9 +139,9 @@ pub fn parse( } const takes_value = cli.isOneOf(a, &.{ - "-k", "--kind", "-a", "--author", "-i", "--id", - "-e", "-p", "-t", "-l", "--limit", "-s", - "--since", "-u", "--until", + "-k", "--kind", "-a", "--author", "-i", "--id", + "-e", "-p", "-t", "-l", "--limit", "-s", + "--since", "-u", "--until", "--store", "--timeout", }); if (!takes_value) { try err.print("deed req: unknown option '{s}'\n", .{a}); @@ -161,6 +188,13 @@ pub fn parse( try err.print("deed req: '{s}' is not a unix timestamp\n", .{v}); return ParseError.BadValue; }; + } else if (std.mem.eql(u8, a, "--store")) { + store_path = v; + } else if (std.mem.eql(u8, a, "--timeout")) { + timeout_ms = std.fmt.parseInt(i64, v, 10) catch { + try err.print("deed req: '{s}' is not a number of milliseconds\n", .{v}); + return ParseError.BadValue; + }; } else if (cli.isOneOf(a, &.{ "-u", "--until" })) { f.until = std.fmt.parseInt(i64, v, 10) catch { try err.print("deed req: '{s}' is not a unix timestamp\n", .{v}); @@ -189,7 +223,15 @@ pub fn parse( } if (tag_len > 0) f.tags = tags[0..tag_len]; - return .{ .filter = f, .relays = relays.slice() orelse &.{}, .bare = bare }; + return .{ + .filter = f, + .relays = relays.slice() orelse &.{}, + .bare = bare, + .store_path = store_path, + .stream = stream, + .local = local, + .timeout_ms = timeout_ms, + }; } /// The subscription id every run uses. @@ -207,7 +249,6 @@ pub fn run( out: *std.Io.Writer, err: *std.Io.Writer, ) !u8 { - _ = io; var ids: Collected([32]u8) = .{}; var authors: Collected([32]u8) = .{}; var kinds: Collected(u16) = .{}; @@ -228,6 +269,34 @@ pub fn run( return cli.exit_ok; }; + // Answered from what is already kept, without dialling. This is the half + // that makes keeping worth doing: a store nothing reads back is a log. + if (req.local) { + const path = req.store_path orelse { + try err.writeAll("deed req: --local needs --store to read from\n"); + return cli.exit_usage; + }; + const z = try gpa.dupeZ(u8, path); + defer gpa.free(z); + var st = nostr.store.Store.open(z, .{}) catch |e| { + try err.print("deed req: cannot open the store at {s}: {s}\n", .{ path, @errorName(e) }); + return cli.exit_fail; + }; + defer st.deinit(); + + var result = st.query(gpa, req.filter) catch |e| { + try err.print("deed req: the store could not answer: {s}\n", .{@errorName(e)}); + return cli.exit_fail; + }; + defer result.deinit(); + for (result.events) |ev| { + const json = try nostr.event.toJson(gpa, ev); + defer gpa.free(json); + try out.print("{s}\n", .{json}); + } + return cli.exit_ok; + } + // No relay named: say what would be sent, and stop. The filter is the thing // worth seeing before it is asked of anybody. if (req.relays.len == 0) { @@ -242,8 +311,35 @@ pub fn run( return cli.exit_ok; } - try err.writeAll("deed req: asking relays is not in this release yet\n"); - return cli.exit_fail; + var store: ?nostr.store.Store = null; + defer if (store) |*st| st.deinit(); + if (req.store_path) |path| { + const z = gpa.dupeZ(u8, path) catch { + try err.writeAll("deed req: out of memory opening the store\n"); + return cli.exit_fail; + }; + defer gpa.free(z); + store = nostr.store.Store.open(z, .{}) catch |e| { + try err.print("deed req: cannot open the store at {s}: {s}\n", .{ path, @errorName(e) }); + return cli.exit_fail; + }; + } + + const outcome = try relayset.query(gpa, io, req.relays, &.{req.filter}, out, err, .{ + .until_eose = !req.stream, + .deadline_ms = req.timeout_ms, + .poll_ms = 100, + .store = if (store) |*st| st else null, + }); + + // Reaching no relay at all is a failed run. Reaching some is not: the + // events that arrived are real, and every relay that did not answer was + // named on stderr as it failed. + if (outcome.dialled == 0) { + try err.writeAll("deed req: no relay answered\n"); + return cli.exit_fail; + } + return cli.exit_ok; } const Run = struct { code: u8, out: []const u8, err: []const u8 }; diff --git a/src/main.zig b/src/main.zig index a4cd5d6..f96fed6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,10 +10,12 @@ const cmd_encode = @import("cmd_encode.zig"); const cmd_crypt = @import("cmd_crypt.zig"); const cmd_event = @import("cmd_event.zig"); const cmd_key = @import("cmd_key.zig"); +const cmd_fetch = @import("cmd_fetch.zig"); +const cmd_publish = @import("cmd_publish.zig"); const cmd_req = @import("cmd_req.zig"); const cmd_verify = @import("cmd_verify.zig"); -pub const version = "0.1.0"; +pub const version = "0.2.0"; const usage = \\deed: the nostr command line @@ -29,6 +31,8 @@ const usage = \\ encrypt encrypt a message to someone, with NIP-44 \\ decrypt decrypt a NIP-44 payload from someone \\ req build a subscription, and run it + \\ fetch get the events a code names + \\ publish offer signed events to relays \\ verify check that events are correctly signed \\ \\ help this text, or `deed help ` (also -h, --help) @@ -160,6 +164,8 @@ fn run( if (std.mem.eql(u8, verb, "encrypt")) return cmd_crypt.run(gpa, io, .encrypt, rest, out, err); if (std.mem.eql(u8, verb, "decrypt")) return cmd_crypt.run(gpa, io, .decrypt, rest, out, err); if (std.mem.eql(u8, verb, "req")) return cmd_req.run(gpa, io, rest, out, err); + if (std.mem.eql(u8, verb, "fetch")) return cmd_fetch.run(gpa, io, rest, out, err); + if (std.mem.eql(u8, verb, "publish")) return cmd_publish.run(gpa, io, rest, out, err); if (std.mem.eql(u8, verb, "verify")) return cmd_verify.run(gpa, io, rest, out, err); try err.print("deed: unknown command '{s}'\nRun `deed help` for the list.\n", .{verb}); @@ -191,6 +197,14 @@ fn helpFor(topic: []const u8, out: *std.Io.Writer, err: *std.Io.Writer) !u8 { try out.writeAll(cmd_crypt.decrypt_usage); return cli.exit_ok; } + if (std.mem.eql(u8, topic, "fetch")) { + try out.writeAll(cmd_fetch.usage); + return cli.exit_ok; + } + if (std.mem.eql(u8, topic, "publish")) { + try out.writeAll(cmd_publish.usage); + return cli.exit_ok; + } if (std.mem.eql(u8, topic, "req")) { try out.writeAll(cmd_req.usage); return cli.exit_ok; @@ -304,6 +318,8 @@ test { _ = @import("cmd_decode.zig"); _ = @import("cmd_encode.zig"); _ = @import("cmd_key.zig"); + _ = @import("cmd_fetch.zig"); + _ = @import("cmd_publish.zig"); _ = @import("cmd_req.zig"); _ = @import("relayset.zig"); _ = @import("cmd_verify.zig"); diff --git a/src/relayset.zig b/src/relayset.zig index 2932999..4f5bd75 100644 --- a/src/relayset.zig +++ b/src/relayset.zig @@ -23,9 +23,15 @@ pub const Options = struct { /// so this is the difference between "everything you have" and "everything /// you have, and then whatever arrives while I wait". until_eose: bool = true, - /// The whole run gives up here, whatever the relays are doing. A relay that - /// accepts a subscription and then says nothing would otherwise hold the - /// process open for as long as somebody let it. + /// The whole run gives up here, whatever the relays are doing. + /// + /// nak has no equivalent and this is a deliberate difference. Its per-relay + /// select waits on EOSE, a close or an event and nothing else, so a relay + /// that accepts a subscription and then says nothing holds the process open + /// for as long as somebody lets it (go-nostr's pool.go:677-729, no timer in + /// that select). That is survivable at an interactive prompt and not + /// survivable in a script, which is where a command line spends most of its + /// life. deadline_ms: i64, /// How long one read may block before the loop moves to the next relay. /// Short, because it is a round-robin across relays rather than a wait. @@ -47,6 +53,18 @@ pub const Outcome = struct { const max_relays = 32; +/// Whether an event answers any of the questions this run asked. +/// +/// A relay is not obliged to be honest about what it sends, and a subscription +/// is not a promise. Without this a relay could answer `-k 1` with anything it +/// liked and the output would carry it. +fn matchesAny(filters: []const filter.Filter, ev: nostr.event.Event) bool { + for (filters) |f| { + if (f.matches(ev)) return true; + } + return false; +} + /// The subscription every run uses. One per process, closed by the process. pub const subscription_id = "deed"; @@ -103,6 +121,9 @@ pub fn query( var seen = std.AutoHashMap([32]u8, void).init(gpa); defer seen.deinit(); + var signer = nostr.keys.Signer.init(); + defer signer.deinit(); + // `.awake` is this standard library's monotonic clock: it cannot go // backwards when somebody adjusts the system time mid-run, which `.real` // can, and a deadline that can move backwards is not one. @@ -138,6 +159,20 @@ pub fn query( switch (msg.value) { .event => |e| { if (seen.contains(e.event.id)) continue; + // Checked before it is trusted. A relay can send anything, + // including an event nobody signed or one that answers a + // question this run did not ask. nak verifies by default and + // drops both (go-nostr relay.go:399-410), and a tool whose + // output people pipe into other tools has to do the same: + // this is the last point where a forgery can be stopped. + if (!(nostr.event.verify(gpa, signer, e.event) catch false)) { + try err.print("deed: a relay sent an event that is not correctly signed\n", .{}); + continue; + } + if (!matchesAny(filters, e.event)) { + try err.print("deed: a relay sent an event nobody asked for\n", .{}); + continue; + } try seen.put(e.event.id, {}); if (opts.store) |s| { // Written before it is printed, so a run interrupted