From 7093b02a879797cfe09c1f77f4e21dab3d479299 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Tue, 22 Sep 2026 19:04:50 +0300 Subject: [PATCH] fix: a record is what was written The record reader trimmed spaces and tabs off both ends of every line before handing it to a verb. For a NIP-19 code or a JSON object that is harmless. For `deed encrypt` the record IS the message. `printf ' hello ' | deed encrypt` encrypted `hello`. The spaces were gone, nothing said so, and the payload is sealed, so there is no reading it back to notice. Somebody sending an indented line or significant trailing whitespace got something other than what they typed, encrypted in their name. So `Input` hands back the record as written, minus the line terminator, and the verbs whose records are a code, an event or a draft trim for themselves. A `\r` from CRLF still comes off, because that is how the line ended rather than part of what was in it. A truly empty line still separates records; a line of spaces is now a record, because for `encrypt` that is a message somebody chose to send. Two smaller ones in the same family, both about refusing to carry what the reader gave. `deed encrypt` could not send a message beginning with a hyphen: it came back as an unknown option, which is a refusal on account of a first character. `--` ends the options now, the way it does everywhere else. `deed key public` on 64 hex characters outside the curve's range printed `deed: InvalidSecretKey`, a Zig error name that escaped to the top-level handler. It now says what is wrong with what was pasted, like every other failure in that verb. Checked by restoring the trim: two of the new tests fail, one on the message and one on the line of spaces. Closes #14. --- src/cli.zig | 57 ++++++++++++++++++++++++++++++++++++++++++---- src/cmd_crypt.zig | 9 ++++++++ src/cmd_decode.zig | 4 +++- src/cmd_event.zig | 4 +++- src/cmd_key.zig | 22 +++++++++++++++++- src/cmd_verify.zig | 4 +++- 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index aec7d53..114c000 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -94,10 +94,20 @@ pub const Input = struct { self.stdin = null; return null; }; - const trimmed = std.mem.trim(u8, line, " \t\r\n"); - // Blank lines separate records, they are not records. - if (trimmed.len == 0) continue; - return .{ .line = trimmed }; + // Only the line ending comes off, never the reader's own bytes. + // + // This used to trim spaces and tabs from both ends of every record, + // which is harmless for a code or a JSON object and silent data + // loss for a message: `printf ' hello '` piped into + // `deed encrypt` encrypted `hello`, and nothing said so. A record + // is whatever sat between two newlines, and what to do about the + // whitespace in it is the verb's business, not this one's. + const body = if (std.mem.endsWith(u8, line, "\r")) line[0 .. line.len - 1] else line; + // A truly empty line separates records and is not one. A line of + // spaces IS one, because for `deed encrypt` it is a message + // somebody chose to send. + if (body.len == 0) continue; + return .{ .line = body }; } } }; @@ -114,6 +124,45 @@ test "no positionals and no stdin yields nothing" { try std.testing.expect((try input.next()) == null); } +test "a record is handed back as it was written" { + // `deed encrypt` takes a record as the MESSAGE. This used to trim spaces and + // tabs off both ends of every record, so `printf ' hello '` piped in + // encrypted `hello` and nothing said so: the reader's own words, altered on + // the way to being sealed. + var source: std.Io.Reader = .fixed(" hello \nplain\n\ttabbed\t\n"); + var window: [64]u8 = undefined; + var limited = source.limited(.unlimited, &window); + var input = Input.init(&.{}, &limited.interface); + + try std.testing.expectEqualStrings(" hello ", (try input.next()).?.line); + try std.testing.expectEqualStrings("plain", (try input.next()).?.line); + try std.testing.expectEqualStrings("\ttabbed\t", (try input.next()).?.line); + try std.testing.expect((try input.next()) == null); +} + +test "a line ending comes off, and an empty line is still a separator" { + // CRLF is the one thing that is not the reader's bytes: it is how the line + // ended, not part of what they wrote. + var source: std.Io.Reader = .fixed("one\r\n\ntwo\r\n"); + var window: [64]u8 = undefined; + var limited = source.limited(.unlimited, &window); + var input = Input.init(&.{}, &limited.interface); + + try std.testing.expectEqualStrings("one", (try input.next()).?.line); + try std.testing.expectEqualStrings("two", (try input.next()).?.line); + try std.testing.expect((try input.next()) == null); +} + +test "a line of spaces is a record, not a separator" { + // For `deed encrypt` it is a message somebody chose to send. Only a truly + // empty line separates. + var source: std.Io.Reader = .fixed(" \n"); + var window: [64]u8 = undefined; + var limited = source.limited(.unlimited, &window); + var input = Input.init(&.{}, &limited.interface); + try std.testing.expectEqualStrings(" ", (try input.next()).?.line); +} + test "a record too long to hold does not take the stream with it" { // A 32-byte window over input whose second record is far longer than it. const data = "aa\n" ++ ("x" ** 200) ++ "\nbb\ncc\n"; diff --git a/src/cmd_crypt.zig b/src/cmd_crypt.zig index 3044ba3..1552738 100644 --- a/src/cmd_crypt.zig +++ b/src/cmd_crypt.zig @@ -98,6 +98,15 @@ pub fn run( }); return cli.exit_ok; } + // Everything after `--` is a message, whatever it starts with. Without + // this there was no way to encrypt a line beginning with a hyphen: it + // came back as an unknown option, which is a refusal to carry somebody's + // words on account of their first character. + if (std.mem.eql(u8, a, "--")) { + i += 1; + while (i < args.len) : (i += 1) try positionals.append(gpa, args[i]); + break; + } if (std.mem.startsWith(u8, a, "-")) { const is_sec = std.mem.eql(u8, a, "--sec"); const is_peer = std.mem.eql(u8, a, dir.peerFlag()); diff --git a/src/cmd_decode.zig b/src/cmd_decode.zig index 74c2810..b762a98 100644 --- a/src/cmd_decode.zig +++ b/src/cmd_decode.zig @@ -63,7 +63,9 @@ pub fn run( var failures: usize = 0; while (try input.next()) |record| { const code = switch (record) { - .line => |l| l, + // Trimmed here rather than in `Input`: surrounding space around + // a code is noise, and `deed encrypt` needs the same bytes it was given. + .line => |l| std.mem.trim(u8, l, " \t"), .too_long => { try err.print( "deed decode: skipped a code longer than {d} bytes\n", diff --git a/src/cmd_event.zig b/src/cmd_event.zig index 8fd5f53..7d6f8e2 100644 --- a/src/cmd_event.zig +++ b/src/cmd_event.zig @@ -171,7 +171,9 @@ pub fn run( var failures: usize = 0; while (try input.next()) |record| { const json = switch (record) { - .line => |l| l, + // Trimmed here rather than in `Input`: surrounding space around + // a draft is noise, and `deed encrypt` needs the same bytes it was given. + .line => |l| std.mem.trim(u8, l, " \t"), .too_long => { try err.print( "deed event: skipped a draft longer than {d} bytes\n", diff --git a/src/cmd_key.zig b/src/cmd_key.zig index 4abfd4e..168ad8a 100644 --- a/src/cmd_key.zig +++ b/src/cmd_key.zig @@ -128,7 +128,14 @@ fn public( var signer = keys.Signer.init(); defer signer.deinit(); - const kp = try signer.keyPairFromSecretKey(sk); + // Caught rather than propagated. 64 hex characters can still be a number + // outside the curve's range, and letting that escape printed `deed: + // InvalidSecretKey`, which names the branch the code took rather than + // telling the reader what is wrong with what they pasted. + const kp = signer.keyPairFromSecretKey(sk) catch { + try err.writeAll("deed key public: that is not a usable secret key (the number is outside the curve's range)\n"); + return cli.exit_fail; + }; const text = if (as_hex) try hex.encode(gpa, &kp.public_key) @@ -263,6 +270,19 @@ test "help is printed on stdout and succeeds" { try std.testing.expectEqualStrings("", r.err); } +test "a key outside the curve's range is explained, not named" { + // 64 hex characters can still be a number the curve cannot use. Letting it + // escape printed `deed: InvalidSecretKey`, which names the branch the code + // took rather than telling the reader what is wrong with what they pasted. + var ob: [1024]u8 = undefined; + var eb: [1024]u8 = undefined; + const r = try runKey(&.{ "public", "f" ** 64 }, &ob, &eb); + try std.testing.expectEqual(cli.exit_fail, r.code); + try std.testing.expectEqualStrings("", r.out); + try std.testing.expect(std.mem.indexOf(u8, r.err, "InvalidSecretKey") == null); + try std.testing.expect(std.mem.indexOf(u8, r.err, "curve") != null); +} + test "the subcommands answer --help too" { // They used to reject it as an unknown option, because help was only caught // at the subcommand position and never inside the argument loops. diff --git a/src/cmd_verify.zig b/src/cmd_verify.zig index b9c4796..8904a77 100644 --- a/src/cmd_verify.zig +++ b/src/cmd_verify.zig @@ -71,7 +71,9 @@ pub fn run( var failures: usize = 0; while (try input.next()) |record| { const json = switch (record) { - .line => |l| l, + // Trimmed here rather than in `Input`: surrounding space around + // an event is noise, and `deed encrypt` needs the same bytes it was given. + .line => |l| std.mem.trim(u8, l, " \t"), .too_long => { try err.print( "deed verify: skipped an event longer than {d} bytes\n",