diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index c970351..dcc80a1 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -1,7 +1,13 @@ -**deed** is a command line for nostr, built in Zig. It runs on macOS and Linux, on both Intel and ARM, and on Windows. The Linux binaries are statically linked, so there is no glibc version to satisfy. The macOS binaries are **ad-hoc signed, not notarized**, which means a download that came through a browser needs `xattr -dr com.apple.quarantine` before it will run. The Windows builds are zips holding `deed.exe`, which is not code-signed. +**deed** is a command line for nostr, built in Zig. It runs on macOS, Linux and Windows, on both Intel and ARM. The Linux binaries are statically linked, so there is no glibc version to satisfy. The macOS binaries are **ad-hoc signed, not notarized**, which means a download that came through a browser needs `xattr -dr com.apple.quarantine` before it will run. The Windows builds are zips holding `deed.exe`, which is not code-signed. 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.5.1 + +**A native build for Windows on ARM.** Releases now carry a zip for aarch64 Windows, run on a Windows ARM machine before the release is published, and the PowerShell installer puts it in place on an ARM64 machine. It used to crash at startup: a stripped build for that target looked for every thread-local variable in the wrong place, because of a linker bug fixed upstream in llvm/llvm-project#200060. That target is now built without stripping, which costs about 250 KB, since its debug information goes to a separate `.pdb` that is not shipped. For an older release without the ARM64 zip, the installer still installs the x86_64 build, which Windows on ARM runs under emulation. + +deed is built on nostr 0.16.0. A command that is cancelled while it waits for randomness now reports the cancel, where it used to report a random-number failure. + ### What's new in v0.5.0 deed now covers most of what a nostr user does from a shell. Every verb reads records as lines of JSON and writes one result per line, so they pipe into each other. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c10759..c38b61d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,15 +170,85 @@ jobs: NOSTR_SECRET_KEY="$sk" "$bin" event -c "windows check" | "$bin" verify echo "deed runs on Windows" + # aarch64 Windows is built on Linux and run on an arm runner. The Zig 0.16.0 + # compiler for aarch64 Windows crashes while building deed on that runner, + # so the runner only runs what was built elsewhere. The binary is built the + # way the release builds it, ReleaseSafe with -Dstrip=true, because that is + # the build that used to crash at startup (see build.zig). + build-windows-arm: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Read Zig version + id: zig-version + run: echo "version=$(cat .zigversion)" >> "$GITHUB_OUTPUT" + + - uses: mlugg/setup-zig@v2 + with: + version: ${{ steps.zig-version.outputs.version }} + + - name: Build deed and its tests for aarch64 Windows + run: | + set -euo pipefail + zig build -Dtarget=aarch64-windows-gnu -Doptimize=ReleaseSafe -Dstrip=true --prefix out + zig build test-bin -Dtarget=aarch64-windows-gnu --prefix out + ls -l out/bin + + - uses: actions/upload-artifact@v4 + with: + name: deed-windows-aarch64 + path: out/bin + if-no-files-found: error + retention-days: 3 + + test-windows-arm: + needs: build-windows-arm + runs-on: windows-11-arm + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + # The checkout is the working directory the tests expect, the same one + # `zig build test` runs them from, and it carries the version to compare. + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: deed-windows-aarch64 + path: out + + - name: Test + run: ./out/deed-test.exe + + # The same check as on x86_64 Windows: a key, an event signed with it, + # and the signature checked through a pipe. + - name: The binary runs + run: | + set -euo pipefail + bin=out/deed.exe + [ -f "$bin" ] || { echo "the build produced no deed.exe" >&2; exit 1; } + got="$("$bin" version)" + want="deed $(sed -n 's/^pub const version = "\(.*\)";$/\1/p' src/main.zig)" + [ "$got" = "$want" ] || { echo "the binary says '$got', expected '$want'" >&2; exit 1; } + sk="$("$bin" key generate --hex)" + NOSTR_SECRET_KEY="$sk" "$bin" event -c "windows arm check" | "$bin" verify + echo "deed runs on aarch64 Windows" + # The Windows installer, end to end against the newest published release, # under both PowerShells: pwsh, and Windows PowerShell 5.1, which is the one # every Windows machine has and the one whose syntax the script keeps to. + # On the arm runner the installer picks the ARM64 build, or the x86_64 one + # when the release has no ARM64 build. install-windows: strategy: fail-fast: false matrix: + runner: [windows-latest, windows-11-arm] shell: [pwsh, powershell] - runs-on: windows-latest + runs-on: ${{ matrix.runner }} timeout-minutes: 15 defaults: run: @@ -202,6 +272,13 @@ jobs: exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" + # Whether that release carries an ARM64 build, which decides what the + # installer on the arm runner must have put in place. + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | grep -q -- '-windows-aarch64\.zip$'; then + echo "has_arm64=true" >> "$GITHUB_OUTPUT" + else + echo "has_arm64=false" >> "$GITHUB_OUTPUT" + fi - name: The installer parses run: | @@ -251,6 +328,23 @@ jobs: "bin=$(Join-Path $prefix 'deed.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii Write-Host "installed $got, on PATH once" + # Which build the installer chose, read from the file and not from what + # runs: the machine field of the PE header, a u16 four bytes past the + # offset stored at 0x3C. The arm runner must get the ARM64 build when the + # release has one, and x86_64 (run under emulation) when it does not. + - name: The installed deed.exe is the build for this machine + env: + RUNNER: ${{ matrix.runner }} + HAS_ARM64: ${{ steps.release.outputs.has_arm64 }} + run: | + $bytes = [IO.File]::ReadAllBytes($env:bin) + $pe = [BitConverter]::ToInt32($bytes, 0x3C) + $machine = [BitConverter]::ToUInt16($bytes, $pe + 4) + $want = 0x8664 + if ($env:RUNNER -eq 'windows-11-arm' -and $env:HAS_ARM64 -eq 'true') { $want = 0xAA64 } + if ($machine -ne $want) { Write-Host ("PE machine is 0x{0:X4}, expected 0x{1:X4}" -f $machine, $want); exit 1 } + Write-Host ("PE machine is 0x{0:X4}" -f $machine) + # A real gesture rather than just `version`: libsecp256k1, the bech32 # encoder and the JSON writer and reader in one line, as the check for # install.sh does. In bash, so the pipe between the two deeds carries diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98b7312..83f0df0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,13 +82,14 @@ jobs: v="${{ steps.version.outputs.version }}" mkdir -p dist - for target in aarch64-macos-none x86_64-macos-none x86_64-linux-musl aarch64-linux-musl x86_64-windows-gnu; do + for target in aarch64-macos-none x86_64-macos-none x86_64-linux-musl aarch64-linux-musl x86_64-windows-gnu aarch64-windows-gnu; do case "$target" in aarch64-macos-none) name=macos-aarch64 ;; x86_64-macos-none) name=macos-x86_64 ;; x86_64-linux-musl) name=linux-x86_64 ;; aarch64-linux-musl) name=linux-aarch64 ;; x86_64-windows-gnu) name=windows-x86_64 ;; + aarch64-windows-gnu) name=windows-aarch64 ;; esac rm -rf "out-$target" @@ -225,6 +226,8 @@ jobs: include: - runner: windows-latest asset: windows-x86_64 + - runner: windows-11-arm + asset: windows-aarch64 runs-on: ${{ matrix.runner }} defaults: run: @@ -289,10 +292,11 @@ jobs: "deed-$v-linux-x86_64.tar.gz" "deed-$v-linux-x86_64.tar.gz.sha256" \ "deed-$v-macos-aarch64.tar.gz" "deed-$v-macos-aarch64.tar.gz.sha256" \ "deed-$v-macos-x86_64.tar.gz" "deed-$v-macos-x86_64.tar.gz.sha256" \ + "deed-$v-windows-aarch64.zip" "deed-$v-windows-aarch64.zip.sha256" \ "deed-$v-windows-x86_64.zip" "deed-$v-windows-x86_64.zip.sha256" | sort)" actual="$(ls dist | sort)" diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") \ - || { echo "the release assets are not the ten expected files" >&2; exit 1; } + || { echo "the release assets are not the twelve expected files" >&2; exit 1; } for f in dist/*; do [ -s "$f" ] || { echo "$f is empty" >&2; exit 1; } @@ -305,9 +309,9 @@ jobs: done # A glob that matched nothing would leave the loop body unrun and the # step green, having verified precisely nothing. - [ "$checked" = "5" ] || { echo "verified $checked checksums, expected 5" >&2; exit 1; } + [ "$checked" = "6" ] || { echo "verified $checked checksums, expected 6" >&2; exit 1; } - echo "ten assets, all present, none empty, every digest matching" + echo "twelve assets, all present, none empty, every digest matching" - name: Lift the draft, then read the page back env: @@ -323,6 +327,6 @@ jobs: # Read the list again from the published release. What was checked in # the step above was the draft, and this is the page a reader opens. count="$(gh release view "$GITHUB_REF_NAME" --json assets --jq '.assets | length')" - [ "$count" = "10" ] || { echo "the published release carries $count assets, expected 10" >&2; exit 1; } + [ "$count" = "12" ] || { echo "the published release carries $count assets, expected 12" >&2; exit 1; } echo "published $GITHUB_REF_NAME" diff --git a/AGENTS.md b/AGENTS.md index 9c2a4f1..6a8620c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ zig fmt --check . # CI fails on unformatted code zig build -Doptimize=ReleaseSafe -Dstrip=true # the release build python3 bench/run.py zig-out/bin/deed # benchmarks, see BENCHMARKS.md zig build test -Dtarget=x86_64-windows-gnu # compile the tests for Windows; the run step then fails, the host cannot run them +zig build test-bin -Dtarget=aarch64-windows-gnu # build the tests into zig-out/bin/deed-test.exe to run on another machine ``` Use the Zig version in `.zigversion`. Building only the executable for Windows is not enough to catch a break there: code the executable never reaches, the test relay for one, only gets analysed when the test binary is built, which is what the Windows line above does. The `nostr` dependency is pinned by URL and hash in `build.zig.zon`; move it with `zig fetch --save=nostr ` and check the diff is only the url and hash lines. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7fb30ef..c25bbe7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -185,6 +185,7 @@ macOS, Linux and Windows, on x86_64 and aarch64. Linux release builds are static - Windows needs an allocator to split the command line into arguments, so `main` uses `Args.Iterator.initAllocator`, which is free elsewhere. - A Windows console decodes text with its own code page. `console.zig` switches it to UTF-8 for the length of the run, the output page only when stdout is the console and the input page only when stdin is, so the deeds in a pipeline do not switch it back under each other. It puts the pages back before exit and on Ctrl-C. It does nothing on other systems. - The test relay shrinks a socket's receive window. On Windows std's sockets are AFD handles Winsock does not know, so it sends the option as the same AFD request std uses for its own socket options, and that is best effort. +- On aarch64 Windows `build.zig` ignores `-Dstrip`. With Zig 0.16 a stripped build for that target crashes at its first thread-local access, because the linker misreads the offset in the relocation for the upper bits of a thread-local address (llvm/llvm-project#200060). The debug information sits in a `.pdb` beside the `.exe` there, so the binary only carries the stack trace code it would otherwise drop. Building only the executable for a target does not analyse code the executable never reaches. `zig build test -Dtarget=x86_64-windows-gnu` builds the test binary too, which is the check that finds a break in the test relay. The host cannot run the result, so the command ends with an error saying so once the compile has succeeded. @@ -198,7 +199,7 @@ Two details matter when adding a test that dials. Use `testrelay.DialAllocator` `bench/` is not a test suite. `bench/run.py` measures size, startup, memory, signing and verifying throughput, store speed, and relays over loopback against a build, using `bench/relay.py` as the relay. [`BENCHMARKS.md`](BENCHMARKS.md) has the numbers and how to reproduce them. -CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, checks that the installer scripts are pure ASCII and parse, and installs the latest release and runs it, with install.sh on Linux and macOS and with install.ps1 on Windows under both pwsh and Windows PowerShell 5.1. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes. +CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, builds the aarch64 Windows binary and test binary on Linux and runs both on a Windows ARM runner (the Zig compiler for that target crashes there), checks that the installer scripts are pure ASCII and parse, and installs the latest release and runs it, with install.sh on Linux and macOS and with install.ps1 on x86_64 and ARM Windows under both pwsh and Windows PowerShell 5.1. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes. ## Where to start reading diff --git a/README.md b/README.md index 13337f4..1da24f5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # deed -**The nostr command line.** +**Fastest and smallest nostr command line, built for AI agents and humans.** A deed is two things at once: a signed instrument, and a thing done. So is a nostr event. `deed` makes them, reads them and proves them. @@ -22,7 +22,7 @@ Each checks the download against the SHA-256 published beside it, and if the dig `--version ` installs a particular release, `--prefix ` puts it somewhere else, and `--archive ` installs from an archive you already have, which still wants its `.sha256` beside it. `--help` lists them. On Windows they are `-Version`, `-Prefix`, `-Archive` and `-Help` when you run the script as a file, and `$env:DEED_VERSION`, `$env:DEED_PREFIX` and `$env:DEED_ARCHIVE` for the piped line. -On Windows on ARM the installer puts the x86_64 build in place, which Windows 11 runs under emulation. The Windows binary is not code-signed, so Windows may show a warning the first time it runs. A store made with `--store` takes 1 GiB of disk on Windows from the first run, because LMDB sizes the file to its whole map up front there; on macOS and Linux it grows with what is kept. deed reads a leading `~` in a `--store` path from `HOME`, which Windows does not usually set, so give a full path there. +On Windows on ARM the installer puts the native ARM64 build in place, or the x86_64 build, which Windows 11 runs under emulation, for an older release that has no ARM64 build. The Windows binary is not code-signed, so Windows may show a warning the first time it runs. A store made with `--store` takes 1 GiB of disk on Windows from the first run, because LMDB sizes the file to its whole map up front there; on macOS and Linux it grows with what is kept. deed reads a leading `~` in a `--store` path from `HOME`, which Windows does not usually set, so give a full path there. ## What it does diff --git a/build.zig b/build.zig index 0c39b82..7b3564a 100644 --- a/build.zig +++ b/build.zig @@ -13,7 +13,23 @@ pub fn build(b: *std.Build) void { // Release builds pass -Dstrip. Without it a Linux binary carries about // nine megabytes of debug sections, three quarters of the file. - const strip = b.option(bool, "strip", "Leave debug information out of the binary"); + const strip_wanted = b.option(bool, "strip", "Leave debug information out of the binary"); + + // Except on aarch64 Windows, where a stripped build crashes at its first + // thread-local access. Stripping drops the local symbols, so the compiler + // addresses each thread-local variable as an offset into the .tls section, + // and puts that offset in the instruction that relocates the upper bits. + // The linker in Zig 0.16 (LLD from LLVM 21) reads that offset as a count of + // 4 KiB pages rather than bytes, so a variable 8 bytes in is looked for + // 32 KiB in. LLD fixed this in llvm/llvm-project#200060, and the exception + // can go once the pinned Zig links with an LLD that has the fix. Little is + // lost meanwhile: a Windows binary keeps its debug information in a .pdb + // beside it, which the release does not ship, so the .exe only grows by + // the stack trace code, about 250 KB. + const strip = if (target.result.os.tag == .windows and target.result.cpu.arch == .aarch64) + false + else + strip_wanted; const exe_mod = b.createModule(.{ .root_source_file = b.path("src/main.zig"), @@ -40,10 +56,18 @@ pub fn build(b: *std.Build) void { // contain the text. const test_filter = b.option([]const u8, "test-filter", "Run only the tests whose name contains this text"); const exe_tests = b.addTest(.{ + .name = "deed-test", .root_module = exe_mod, .filters = if (test_filter) |f| b.dupeStrings(&.{f}) else &.{}, }); const run_exe_tests = b.addRunArtifact(exe_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_exe_tests.step); + + // `zig build test-bin -Dtarget=` builds the tests into + // zig-out/bin/deed-test without running them, for a machine that can run + // that target but not build it. CI uses it for aarch64 Windows. + const install_tests = b.addInstallArtifact(exe_tests, .{}); + const test_bin_step = b.step("test-bin", "Build the unit tests without running them"); + test_bin_step.dependOn(&install_tests.step); } diff --git a/build.zig.zon b/build.zig.zon index 4b37d6c..d7dce91 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .deed, - .version = "0.5.0", + .version = "0.5.1", .fingerprint = 0x89498c2094a1a4a3, .minimum_zig_version = "0.16.0", .dependencies = .{ @@ -10,8 +10,8 @@ // themselves later, and "whatever main happened to be that afternoon" // is not that. .nostr = .{ - .url = "https://github.com/zig-nostr/nostr/archive/refs/tags/v0.15.1.tar.gz", - .hash = "nostr-0.15.1-CMyPzUp8EQA8ebwjsaco8u35hSiprJW0DS74viHXIVvr", + .url = "https://github.com/zig-nostr/nostr/archive/refs/tags/v0.16.0.tar.gz", + .hash = "nostr-0.16.0-CMyPzYmkEQBqRKpV_8jJYu769GBnHVzyZELHxbblGk0V", }, }, .paths = .{ diff --git a/scripts/install.ps1 b/scripts/install.ps1 index bff1a56..0a60bc8 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -11,17 +11,17 @@ # # Options, when the script runs as a file: # -# .\install.ps1 -Version v0.5.0 -Prefix C:\tools\deed +# .\install.ps1 -Version v0.5.1 -Prefix C:\tools\deed # # A piped run has no command line to put them on, so it reads the same options # from the environment: # -# $env:DEED_VERSION = 'v0.5.0' +# $env:DEED_VERSION = 'v0.5.1' # irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1 | iex # # or takes them when the download is run as a script block: # -# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1))) -Version v0.5.0 +# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1))) -Version v0.5.1 # # An option given on the command line wins over the environment. # @@ -108,23 +108,30 @@ Nothing needs administrator rights. return 0 } - # Which build this machine wants. - # - # There is no ARM build for Windows. Windows 11 on ARM runs x86_64 programs - # under its own emulation, and the x86_64 build is the one every release is - # checked on, so ARM64 machines get that. + # Which build this machine wants. An ARM64 machine gets the ARM64 build + # when the release has one; see Install-Deed for the releases that do not. function Get-Platform { if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { Fail 'this script installs the Windows build. On macOS and Linux, run: curl -fsSL https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.sh | bash' } - # A 32-bit PowerShell on a 64-bit Windows reports x86 here and the real - # architecture in PROCESSOR_ARCHITEW6432. - $arch = $env:PROCESSOR_ARCHITEW6432 + # The machine's own architecture, not the process's. An x64 PowerShell + # running under emulation on a Windows on ARM machine reports AMD64 in + # its environment, so the value Windows keeps in the registry is read + # first. The environment is the fallback: a 32-bit PowerShell on a + # 64-bit Windows reports x86 there and the real architecture in + # PROCESSOR_ARCHITEW6432. + $arch = $null + try { + $arch = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name PROCESSOR_ARCHITECTURE -ErrorAction Stop).PROCESSOR_ARCHITECTURE + } catch { + $arch = $null + } + if (-not $arch) { $arch = $env:PROCESSOR_ARCHITEW6432 } if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } switch ($arch) { 'AMD64' { return 'windows-x86_64' } - 'ARM64' { return 'windows-x86_64' } - default { Fail "no deed build for '$arch'. The published Windows build is x86_64, which also runs on ARM64." } + 'ARM64' { return 'windows-aarch64' } + default { Fail "no deed build for '$arch'. The published Windows builds are x86_64 and ARM64." } } } @@ -271,7 +278,22 @@ Nothing needs administrator rights. try { Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing } catch { - Fail "download failed. There may be no $platform build for $tag." + # Older releases carry no ARM64 build. Windows on ARM runs the + # x86_64 one under emulation, so a release without the ARM64 + # zip still installs. Only a 404 means that: any other failure + # is reported, not papered over with a different build. + if ($platform -ne 'windows-aarch64' -or (Get-Status $_) -ne 404) { + Fail "download failed. $tag may not exist, or has no Windows build." + } + $platform = 'windows-x86_64' + $name = "deed-$($tag.Substring(1))-$platform.zip" + $url = "https://github.com/$repo/releases/download/$tag/$name" + Say "$tag has no ARM64 build. Downloading $name, which Windows on ARM runs under emulation..." + try { + Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + } catch { + Fail "download failed. $tag may not exist, or has no Windows build." + } } # Required, not best effort. A verification step that any transient diff --git a/src/cmd_count.zig b/src/cmd_count.zig index d76fc4e..210aa21 100644 --- a/src/cmd_count.zig +++ b/src/cmd_count.zig @@ -202,7 +202,7 @@ fn sendText(gpa: std.mem.Allocator, io: Io, r: *relay.Relay, text: []const u8) ! var frame: std.ArrayList(u8) = .empty; defer frame.deinit(gpa); var mask: [4]u8 = undefined; - io.randomSecure(&mask) catch return error.RandomFailed; + io.randomSecure(&mask) catch |e| return if (e == error.Canceled) error.Canceled else error.RandomFailed; try nostr.websocket.appendClientFrame(&frame, gpa, .text, text, mask); r.conn.write_lock.lockUncancelable(io); defer r.conn.write_lock.unlock(io); diff --git a/src/cmd_crypt.zig b/src/cmd_crypt.zig index 167bf94..6aea27a 100644 --- a/src/cmd_crypt.zig +++ b/src/cmd_crypt.zig @@ -237,11 +237,11 @@ fn cryptAll( .encrypt => blk: { if (legacy) { var iv: [16]u8 = undefined; - io.randomSecure(&iv) catch return error.RandomFailed; + io.randomSecure(&iv) catch |e| return if (e == error.Canceled) error.Canceled else error.RandomFailed; break :blk nip04.encryptWithIv(gpa, conversation_key, iv, text); } var nonce: [32]u8 = undefined; - io.randomSecure(&nonce) catch return error.RandomFailed; + io.randomSecure(&nonce) catch |e| return if (e == error.Canceled) error.Canceled else error.RandomFailed; break :blk nip44.encryptWithConversationKey(gpa, conversation_key, text, nonce); }, .decrypt => if (legacy) diff --git a/src/cmd_gift.zig b/src/cmd_gift.zig index fcef67b..d4f1c66 100644 --- a/src/cmd_gift.zig +++ b/src/cmd_gift.zig @@ -362,7 +362,7 @@ fn seal(a: std.mem.Allocator, io: std.Io, signer: *signing.Signer, to: keys.Publ /// Up to two days before now, never now itself, as NIP-59 asks of a seal. fn randomPastTimestamp(io: std.Io) !i64 { var buf: [8]u8 = undefined; - io.randomSecure(&buf) catch return error.RandomFailed; + io.randomSecure(&buf) catch |e| return if (e == error.Canceled) error.Canceled else error.RandomFailed; const r = std.mem.readInt(u64, &buf, .little); const two_days: u64 = 2 * 24 * 60 * 60; const offset: i64 = 1 + @as(i64, @intCast(r % two_days)); diff --git a/src/cmd_key.zig b/src/cmd_key.zig index 6839a3b..7cc07b0 100644 --- a/src/cmd_key.zig +++ b/src/cmd_key.zig @@ -541,7 +541,8 @@ fn mnemonic( defer std.crypto.secureZero(u8, &entropy); // 12 words carry 128 bits, and every 3 more words carry 32 more. const n = (words / 3) * 4; - io.randomSecure(entropy[0..n]) catch { + io.randomSecure(entropy[0..n]) catch |e| { + if (e == error.Canceled) return e; try err.writeAll("deed key mnemonic: could not draw randomness from the system\n"); return cli.exit_fail; }; diff --git a/src/http.zig b/src/http.zig index d83d8fd..566f350 100644 --- a/src/http.zig +++ b/src/http.zig @@ -995,6 +995,7 @@ pub const TestServer = struct { while (it.next()) |h| { if (std.ascii.eqlIgnoreCase(h.name, "authorization")) has_auth = true; } + try drainBody(&req); var extra: [1]Header = undefined; var n: usize = 0; @@ -1014,8 +1015,23 @@ pub const TestServer = struct { try req.respond(content, .{ .status = route.status, .keep_alive = false, .extra_headers = extra[0..n] }); return; } + try drainBody(&req); try req.respond("not found", .{ .status = .not_found, .keep_alive = false }); } + + /// Reads the request body to its end before the answer goes out. A socket + /// closed with bytes it never read resets the connection, and Windows + /// then drops whatever of the response the client had not read yet. This + /// invalidates the strings in the request head. + fn drainBody(req: *http.Server.Request) !void { + // Only a body with a known end. Without one std hands back either its + // shared `Reader.ending`, which is read-only memory that a discard + // writes to, or the raw stream, which ends only when the client + // closes, and the client is waiting for the answer. + if (req.head.content_length == null and req.head.transfer_encoding != .chunked) return; + var buf: [1024]u8 = undefined; + _ = try req.readerExpectNone(&buf).discardRemaining(); + } }; /// Not `std.testing.allocator`: a request that times out is cancelled, and diff --git a/src/main.zig b/src/main.zig index 0113950..0fc5614 100644 --- a/src/main.zig +++ b/src/main.zig @@ -8,7 +8,7 @@ const cli = @import("cli.zig"); const console = @import("console.zig"); const verbs = @import("verbs.zig"); -pub const version = "0.5.0"; +pub const version = "0.5.1"; pub fn main(init: std.process.Init) !void { // A general-purpose allocator, not `page_allocator`: that one maps pages diff --git a/src/negclient.zig b/src/negclient.zig index 08eab62..8317e1f 100644 --- a/src/negclient.zig +++ b/src/negclient.zig @@ -927,6 +927,20 @@ test "a reason a relay wrote cannot carry terminal controls" { try testing.expectEqualStrings("NOTICE: a?[2Jb?", n.text()); } +test "a write that never finishes gives up at the deadline" { + const io = testing.io; + // Stands in for a write into a full socket: it waits until cancelled. + const Stuck = struct { + fn write(i: Io) anyerror!void { + try i.sleep(.fromSeconds(60), .awake); + } + }; + const started = Io.Timestamp.now(io, .awake).toMilliseconds(); + try testing.expectError(error.Timeout, writeBy(io, deadlineIn(io, 300), Stuck.write, .{io})); + const took = Io.Timestamp.now(io, .awake).toMilliseconds() - started; + try testing.expect(took < 3_000); +} + test "a write to a relay that stops reading gives up at the deadline" { const io = testing.io; const gpa = std.heap.page_allocator; @@ -948,8 +962,12 @@ test "a write to a relay that stops reading gives up at the deadline" { defer arena.deinit(); const ev = try nostr.event.create(arena.allocator(), signer, kp, 1_700_000_000, 1, &.{}, content, null); const Publish = struct { + // Windows takes a large send on a loopback socket without waiting for + // the peer to read, so one event may not block there. Writing on, with + // a bound so a socket that never fills still ends, reaches a write + // that waits for room that does not come. fn now(rr: *relay.Relay, e: Event) anyerror!void { - return rr.publish(e); + for (0..256) |_| try rr.publish(e); } }; const started = Io.Timestamp.now(io, .awake).toMilliseconds();