From c30ec6cdaab42f79a3085bc5eced5244ba3e0fb8 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:26:58 +0300 Subject: [PATCH 1/2] Require iyi 0.21.0 and iyi-db 0.5.0, and mark the raw-memory modules iyi 0.21.0 refuses an operation on raw memory in a module that is not marked @[Unsafe("why")] above its module header (SPEC.md III.13), and 0.20.x refuses the mark. pg/pq/stream, pg/pq/scram, pg/pq/connection and pg/copy_result are marked, each saying what it does with the memory. pg/core, pg/pq/frame, pg/pq/param, pg/replication/messages, examples/simple_query and four tests use String.new(bytes), Bytes.of and byte_at instead, so the compiler checks them. iyi-db 0.4.0 is refused by 0.21.0 too, so iyi.mod requires the iyi-db release that carries its marks, 0.5.0. iyi.sum gets its line from `iyi get github.com/iyilang/db@v0.5.0` once that tag exists. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 5 ++++- examples/simple_query.iyi | 2 +- iyi.mod | 2 +- pg/copy_result.iyi | 1 + pg/core.iyi | 4 +--- pg/escape_test.iyi | 2 +- pg/pq/connection.iyi | 1 + pg/pq/frame.iyi | 6 ++---- pg/pq/param.iyi | 5 ++--- pg/pq/param_test.iyi | 2 +- pg/pq/scram.iyi | 1 + pg/pq/stream.iyi | 1 + pg/pq/stream_test.iyi | 4 ++-- pg/replication/messages.iyi | 4 +--- pg/replication_test.iyi | 4 ++-- 17 files changed, 42 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fd4fd0..56468e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: --health-retries 10 env: # The oldest iyi this release supports (README, Requirements). - IYI_VERSION: 0.20.0 + IYI_VERSION: 0.21.0 DATABASE_URL: postgres://postgres:postgres@127.0.0.1/iyi_pg_test steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index d4d6617..4008254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## Unreleased + +Requires iyi 0.21.0 and iyi-db 0.5.0. iyi 0.21.0 refuses an operation on +raw memory in a module that is not marked `@[Unsafe("why")]` (SPEC.md +III.13), and 0.20.x refuses the mark above a `module` header, so no source +of this driver compiles on both. + +* The four modules that handle raw memory say so, each with why: + `pg/pq/stream` (the socket's read and write buffers), + `pg/pq/connection` (`read_direct`'s pointer into the caller's buffer), + `pg/copy_result` (a copy read straight into the caller's slice) and + `pg/pq/scram` (the HMAC pads and client proof written into new + strings). +* `pg/core`, `pg/pq/frame`, `pg/pq/param` and `pg/replication/messages` + no longer touch raw memory: a string is made from bytes with + `String.new(bytes)` and a string's bytes are read through `Bytes.of` + or `byte_at`, so the compiler checks them. The tests and + `examples/simple_query` do the same. + ## v0.4.0 A statement cache, and the driver's module state made safe to reach from diff --git a/README.md b/README.md index dfe8444..1697766 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,10 @@ are closed, and tasks are structured. ## Requirements -iyi 0.20.0 or later, and iyi-db 0.4.0 (required by this package's `iyi.mod`). +iyi 0.21.0 or later, and iyi-db 0.5.0 (required by this package's `iyi.mod`). +0.21.0 refuses raw memory in a module that does not say it handles some +(SPEC.md III.13), and a module says so with `@[Unsafe("why")]` above its +`module` header, which 0.20.x refuses. Tested against PostgreSQL 16. ## Installation diff --git a/examples/simple_query.iyi b/examples/simple_query.iyi index 90f4716..f295090 100644 --- a/examples/simple_query.iyi +++ b/examples/simple_query.iyi @@ -57,7 +57,7 @@ class SimpleConnection end private def text(bytes : Bytes) : String - String.new(bytes.size) { |buffer| buffer.copy_from(bytes.to_unsafe, bytes.size) } + String.new(bytes) end end diff --git a/iyi.mod b/iyi.mod index 42abb31..e41deca 100644 --- a/iyi.mod +++ b/iyi.mod @@ -1,4 +1,4 @@ # `iyi get example.com/someone/lib` adds a dependency here. module github.com/iyilang/pg -require github.com/iyilang/db v0.4.0 as db +require github.com/iyilang/db v0.5.0 as db diff --git a/pg/copy_result.iyi b/pg/copy_result.iyi index 4922a5f..a927d12 100644 --- a/pg/copy_result.iyi +++ b/pg/copy_result.iyi @@ -1,5 +1,6 @@ # The data of a `COPY ... TO STDOUT`, or the sink of a `COPY ... FROM # STDIN`: what `PGConnection#exec_copy` answers. +@[Unsafe("a copy is read straight into the caller's slice through its pointer, and gets_to_end writes each chunk from its buffer's pointer")] module pg/copy_result import std/int diff --git a/pg/core.iyi b/pg/core.iyi index 4fb99fa..97fb21e 100644 --- a/pg/core.iyi +++ b/pg/core.iyi @@ -288,9 +288,7 @@ pub class PGResultSet < ResultSet end def self.bytes_string(bytes : Bytes) : String - String.new(bytes.size) do |buffer| - buffer.copy_from(bytes.to_unsafe, bytes.size) - end + String.new(bytes) end private def field(index : Int32) : Field diff --git a/pg/escape_test.iyi b/pg/escape_test.iyi index e76b48b..b923e52 100644 --- a/pg/escape_test.iyi +++ b/pg/escape_test.iyi @@ -8,7 +8,7 @@ import pg/testing/spec_helper::* def escape_literal_bytes(s : String) : String result = "" - with_connection { |conn| result = conn.escape_literal(Bytes.new(s.to_unsafe, s.bytesize, read_only: true)) } + with_connection { |conn| result = conn.escape_literal(Bytes.of(s)) } result end diff --git a/pg/pq/connection.iyi b/pg/pq/connection.iyi index f34e2d8..2574237 100644 --- a/pg/pq/connection.iyi +++ b/pg/pq/connection.iyi @@ -5,6 +5,7 @@ # Every read answers its failure: `StreamError` when the socket fails, # `PQError` when the server answers an `ErrorResponse`, `ConnectionError` # when the session cannot go on. +@[Unsafe("read_direct hands the stream a raw pointer into the caller's buffer to fill")] module pg/pq/connection import std/int diff --git a/pg/pq/frame.iyi b/pg/pq/frame.iyi index 283a11e..64e421b 100644 --- a/pg/pq/frame.iyi +++ b/pg/pq/frame.iyi @@ -150,9 +150,7 @@ pub abstract class Frame # *count* bytes of *bytes* from *start*, as a string. def self.string_of(bytes : Bytes, start : Int32, count : Int32) : String - String.new(count) do |buffer| - buffer.copy_from(bytes.to_unsafe + start, count) - end + String.new(bytes[start, count]) end # A message this library does not know. @@ -372,7 +370,7 @@ pub abstract class Frame private def tag_starts_with?(prefix : String) : Bool size = prefix.bytesize return false if @bytes.size <= size - bytes = prefix.to_unsafe + bytes = Bytes.of(prefix) i = 0 while i < size return false if @bytes[i] != bytes[i] diff --git a/pg/pq/param.iyi b/pg/pq/param.iyi index 5646d7d..c7c19cd 100644 --- a/pg/pq/param.iyi +++ b/pg/pq/param.iyi @@ -128,7 +128,7 @@ pub struct Param end def self.text(string : String) : Param - new(Bytes.new(string.to_unsafe, string.bytesize, read_only: true), string.bytesize, 0_i16, string) + new(Bytes.of(string), string.bytesize, 0_i16, string) end # The array literal of *array*: `{1,2,3}`, `{"a","b"}`, `{NULL}`. @@ -165,9 +165,8 @@ pub struct Param end private def self.write_hex(io : String::Builder, bytes : Bytes) : Nil - digits = HEX.to_unsafe bytes.each do |byte| - io << digits[byte >> 4].unsafe_chr << digits[byte & 0x0F].unsafe_chr + io << HEX.byte_at(byte >> 4).unsafe_chr << HEX.byte_at(byte & 0x0F).unsafe_chr end end diff --git a/pg/pq/param_test.iyi b/pg/pq/param_test.iyi index 853a7b8..618c509 100644 --- a/pg/pq/param_test.iyi +++ b/pg/pq/param_test.iyi @@ -18,7 +18,7 @@ end # Crystal's `String#to_slice`, which iyi's prelude lacks. def to_slice(s : String) : Bytes - Bytes.new(s.to_unsafe, s.bytesize, read_only: true) + Bytes.of(s) end describe "PQ::Param encoders #encode_array" do diff --git a/pg/pq/scram.iyi b/pg/pq/scram.iyi index c57e96f..1a0b815 100644 --- a/pg/pq/scram.iyi +++ b/pg/pq/scram.iyi @@ -5,6 +5,7 @@ # SCRAM-SHA-256-PLUS binds the exchange to the TLS channel under it # (`tls-server-end-point`, RFC 5929, the binding PostgreSQL takes): a man # in the middle who ended TLS on both sides cannot relay it. +@[Unsafe("the HMAC pads, PBKDF2's block number and the client proof are written a byte at a time straight into new strings' buffers")] module pg/pq/scram import std/int diff --git a/pg/pq/stream.iyi b/pg/pq/stream.iyi index 1e7cdde..40c82bd 100644 --- a/pg/pq/stream.iyi +++ b/pg/pq/stream.iyi @@ -6,6 +6,7 @@ # # Every read answers `StreamError` when the bytes asked for are not there: # the socket failed, or the source ended first. +@[Unsafe("the socket's reads and writes go through read and write buffers it indexes by pointer, copying frames in and out of them and reading floats by reinterpreting their bits")] module pg/pq/stream import std/int diff --git a/pg/pq/stream_test.iyi b/pg/pq/stream_test.iyi index f38e80e..31cfd4e 100644 --- a/pg/pq/stream_test.iyi +++ b/pg/pq/stream_test.iyi @@ -28,7 +28,7 @@ end def read_message(bytes : Array(UInt8)) : String answer = "" socket_pair do |client, server| - server.write(String.new(bytes.size) { |b| bytes.each_with_index { |byte, i| b[i] = byte } }) + server.write(String.new(bytes)) pq = PQConnection.new(Stream.new(client), ConnInfo.new("127.0.0.1", "db", "user", "pass", 1)) frame = pq.read answer = frame.is_a?(StreamError) ? "StreamError: #{frame.message}" : frame.to_s @@ -86,7 +86,7 @@ describe "PQ::Stream#flush" do end assert received.size == big + small.bytesize assert received[0, big].all? { |byte| byte == 'a'.ord.to_u8 } - tail = String.new(small.bytesize) { |b| b.copy_from(received.to_unsafe + big, small.bytesize) } + tail = String.new(received[big, small.bytesize]) assert tail == small, tail assert stream.pending == 0 end diff --git a/pg/replication/messages.iyi b/pg/replication/messages.iyi index 3d9b111..71525f7 100644 --- a/pg/replication/messages.iyi +++ b/pg/replication/messages.iyi @@ -58,9 +58,7 @@ pub class Read break if byte == 0_u8 bytes << byte end - String.new(bytes.size) do |buffer| - bytes.each_with_index { |b, i| buffer[i] = b } - end + String.new(bytes) end # An Int32 length, then that many bytes. diff --git a/pg/replication_test.iyi b/pg/replication_test.iyi index b4fd42d..9e19dbd 100644 --- a/pg/replication_test.iyi +++ b/pg/replication_test.iyi @@ -143,7 +143,7 @@ class TestMessageHandler < Handler joined = Bytes.new(size) offset = 0 parts.each do |part| - (joined.to_unsafe + offset).copy_from(part.to_unsafe, part.size) + joined[offset, part.size].copy_from(part) offset += part.size end joined @@ -202,7 +202,7 @@ end # --------------------------------------------------------------- the bytes def slice_of(text : String) : Bytes - Bytes.new(text.bytesize) { |i| text.to_unsafe[i] } + Bytes.of(text).dup end # Protocol bytes, big-endian, built up a value at a time. From d98a00e9087655f96e69d8b5bb9d1e3b07122c49 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:28:05 +0300 Subject: [PATCH 2/2] iyi.sum: iyi-db v0.5.0 --- iyi.sum | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iyi.sum b/iyi.sum index e1de698..3da0b3a 100644 --- a/iyi.sum +++ b/iyi.sum @@ -1 +1 @@ -github.com/iyilang/db v0.4.0 s1:eb713fe8bccc5b89be8d2efa16e515ae96667a15 +github.com/iyilang/db v0.5.0 s1:a435c49b63b1fa7cbfbc97d6d36d5f83636f4675