Skip to content

feat(vm): bind net-smtp/imap/pop/ftp/sftp + webrick - #206

Merged
tannevaled merged 16 commits into
mainfrom
feat/bind-netwave
Jul 7, 2026
Merged

tannevaled merged 16 commits into
mainfrom
feat/bind-netwave

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Consolidates 6 pure-Go stdlib bindings into one merge onto main. Supersedes #200 (net-pop), #201 (webrick), #202 (net-imap), #203 (net-sftp), #204 (net-ftp), #205 (net-smtp). Rebased onto main's #199 (Addrinfo/getaddrinfo).

require classes transport
net/smtp Net::SMTP + Response + error tree real socket + STARTTLS/implicit TLS
net/imap Net::IMAP + typed response classes injected IO seam
net/pop Net::POP3 / Net::POPMail injected IO seam
net/ftp Net::FTP + MLSxEntry (control+data) real net.Dial/Listen loopback
net/sftp Net::SFTP session/dir/file/attrs injected SSH-channel seam
webrick WEBrick::HTTPServer/mount_proc/servlets/HTTPStatus in-process dispatch

Tests drive in-process servers / injected transports — no real network, no leaked goroutines (all joined on cleanup); Ruby handlers run inline under the GVL. net-smtp now makes ActionMailer's SMTP delivery viable (follow-up).

Verification (consolidated branch, real exit codes)

  • Full suite: ok internal/vm 75.3s, ok internal/object.
  • 100% coverage of every new file (combined profile: no sub-100% func across all 6).
  • classOf orphan scan clean; no duplicate providedFeatures keys; gofmt/go vet clean.
  • 6-arch cross-compile clean: amd64, arm64, riscv64, loong64, ppc64le, s390x (BE).

🤖 Generated with Claude Code

tannevaled and others added 16 commits July 7, 2026 00:20
Wire the pure-Go POP3 protocol codec github.com/go-ruby-net-pop/net-pop
into the VM so `require "net/pop"` works with no .rb file, mirroring the
redis/pg injected-socket-seam bindings.

Surface:
- Net::POP3.new(address, port = 110, apop = false, connection: io) and the
  Net::POP3.start(...) { |pop| } convenience.
- Session: #start (USER/PASS or APOP) / #finish (QUIT) / #mails (LIST) /
  #each_mail / #each / #delete_all / #n_mails / #n_bytes (STAT) / #reset
  (RSET) / #set_all_uids (UIDL) / #noop / #stls, plus #address / #port /
  #apop? / #started? / #active? / #_connection.
- Net::POPMail: #number / #length / #size / #unique_id / #uidl (UIDL n) /
  #pop / #mail / #to_s (RETR, dot-unstuffed) / #header / #top (TOP) /
  #delete / #delete! (DELE) / #deleted?.
- Net::POPError / Net::POPBadResponse (< POPError) / Net::POPAuthenticationError.

The socket is the host seam: rbgo drives the library's Transport over an
injected Ruby IO-like object (#write / #gets), so tests run against an
in-process transport with no real network and no leaked goroutines; Ruby
callbacks run inline under the GVL. CGO=0, builds on all six 64-bit arches.

registerNetPOP() runs after registerNetHTTP (Net module) and
registerSocket/registerOpenSSL; "net/pop" is added to providedFeatures and
POP3Obj/POPMailObj to classOf. New files kept at 100% statement coverage
via net_pop_internal_test.go over a deterministic FakePOP transport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bind github.com/go-ruby-webrick/webrick — the pure-Go (CGO=0) port of
Ruby's WEBrick HTTP server core — into rbgo so `require "webrick"` works
with no .rb file.

The library owns the deterministic request parse, response build, status
table and longest-prefix mount dispatch and does no socket I/O; this shell
maps that surface onto Ruby classes:

- WEBrick::HTTPServer.new(config) + #mount_proc { |req, res| … } / #mount
  (AbstractServlet subclass or instance) / #unmount / #service / #config
  (#start/#shutdown/#stop are the host accept-loop seam, no-ops here).
- WEBrick::HTTPRequest.new + #parse(str) and the parsed accessors
  (request_method / path / query / headers / keep_alive? / body / each …).
- WEBrick::HTTPResponse.new with the status/header/body accessors,
  #set_redirect, #set_error and #to_s (the wire bytes).
- WEBrick::HTTPServlet::AbstractServlet base class (do_<METHOD> contract).
- WEBrick::HTTPStatus: reason_phrase, the range predicates, and the Status
  exception family (each named leaf carrying its code).

The request-handler seam — a mount_proc block or a servlet's do_<METHOD>
method — is the injectable Ruby code the engine leaves open. Because the
core does no I/O and starts no goroutines, #service is driven on the VM
goroutine under the GVL and the handler runs inline (vm.callBlock /
vm.send), so nothing leaks; a handler that raises a WEBrick::HTTPStatus is
recovered and rendered as the matching error response, exactly as
WEBrick::HTTPServer#run rescues HTTPStatus::Status.

Tests drive requests through the server object in-process (build a
HTTPRequest, dispatch via #service, assert the HTTPResponse) with no
listening socket. 100% coverage of the new files; -race clean; green on
all six 64-bit arches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the pure-Go go-ruby-net-imap/net-imap command builder + response
parser into the interpreter so `require "net/imap"` works with no .rb
file, giving a live Net::IMAP session over an injected IO seam.

The library is deterministic (command encoding + response grammar) and
never opens a socket; the transport is the host's job. Like the redis /
pg bindings, Net::IMAP.new takes an injected IO-like connection
(connection:/conn: keyword or a positional IO — a StringIO, a TCPSocket,
or an OpenSSL::SSL::SSLSocket for TLS, all responding to #read/#write).
rubyConn bridges it to the library Reader's line/literal callbacks.

Surface (net_imap.go + net_imap_bind.go):
  - Net::IMAP.new + greeting/responses/disconnect/disconnected?
  - login, authenticate (PLAIN/LOGIN/CRAM-MD5/XOAUTH2 via the library's
    SASL encoders), select, examine, create, delete, rename, subscribe,
    unsubscribe, list, lsub, status, fetch, uid_fetch, store, uid_store,
    copy, uid_copy, search, uid_search, expunge, append (literals),
    capability, noop, logout, check, close, unselect, starttls
  - Net::IMAP.encode_utf7 / decode_utf7
  - typed value classes mirroring Net::IMAP::* member-for-member
    (TaggedResponse / UntaggedResponse / ContinuationRequest /
    ResponseText / ResponseCode / MailboxList / StatusData / FetchData /
    Envelope / Address / AppendUIDData / CopyUIDData / BodyType{Text,
    Basic,Message,Multipart}) and the Net::IMAP error tree
  - full error mapping (NoResponseError / BadResponseError /
    ByeResponseError / ResponseParseError / DataFormatError)

registerNetIMAP runs after registerSocket/registerOpenSSL so a real
TCPSocket/SSLSocket can be the TLS seam; providedFeatures gains
"net/imap"; classOf gains *IMAPObj.

Tests drive the full surface over an in-process duplex FakeSock (no real
network, no leaked goroutines); 100% coverage of the new files
(deterministic Ruby-level tests + Go-level unit tests for defensive
arms). gofmt/vet/-race clean; builds on all six 64-bit arches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… SSH channel)

Bind the pure-Go SFTP wire codec github.com/go-ruby-net-sftp/net-sftp into rbgo
as require "net/sftp", exposing the Ruby Net::SFTP surface.

SFTP runs over an SSH channel. The library owns only the deterministic packet
codec (framing, the version-aware ATTRS struct, request-id correlation, version
negotiation); the encrypted SSH transport is the host's job — the "channel seam".
rbgo has no go-ruby-ssh yet, so Net::SFTP.start / Session.new take an injected
channel object responding to #write(bytes) / #read(n) (a duck-typed SSH channel,
e.g. a StringIO), mirroring how Redis.new takes an injected IO connection.

The binding drives the library's request builders and response parsers over that
channel: it writes FXP_INIT, negotiates the version from FXP_VERSION, then for
each operation writes the request frame and reads the id-correlated response.

Surface:
- Session: protocol_version/version, open!/close!/read!/write!, stat!/lstat!/
  fstat!/setstat!/fsetstat!, opendir!/readdir!, remove!/mkdir!/rmdir!/realpath!,
  rename!/readlink!/symlink!/link!/block!/unblock!, upload!/download!, dir, file.
- Operations::Dir#foreach/#entries; Operations::FileFactory#open/#directory?;
  Operations::File#read/#write/#pos/#close.
- Value objects: Protocol::V01::Attributes (size/uid/gid/owner/group/permissions/
  atime/mtime/type + directory?/file?/symlink?) and Name (name/filename/longname/
  attributes + predicates), with v4+ longname synthesised on demand.
- Net::SFTP::Exception < StandardError and Net::SFTP::StatusException carrying
  code/description/text/message; Net::SFTP::Constants StatusCodes/PacketTypes.

Wired: providedFeatures["net/sftp"], vm.registerNetSFTP() after registerNetHTTP
(nests under the existing Net module), and classOf cases for the six wrapper
types. 100% coverage of the new files via a deterministic in-process channel
(Chan) whose reply frames are built with the same wire codec — no SSH, network,
or goroutine. gofmt/vet clean, -race clean, green on all six 64-bit arches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire github.com/go-ruby-net-ftp/net-ftp — the interpreter-independent FTP
protocol codec (command construction, reply classification, PASV/EPSV
address extraction, MLSx parsing) — into a real Net::FTP client that drives
Go net sockets, mirroring the socket-transport pattern of the Net::HTTP
binding.

- net_ftp.go: Net::FTP class + the Net::FTPError family (Reply/Temp/Perm/
  Proto/Connection) + Net::FTP::MLSxEntry. Surface: new/open (block-closing),
  connect/login (USER/PASS/ACCT ladder, anonymous default), passive/binary/
  use_pasv_ip accessors, pwd/chdir(CDUP->CWD fallback)/mkdir/rmdir/delete/
  rename, size/mtime/mdtm/system/status/help/site/noop, nlst/list/mlsd/mlst,
  retrlines/retrbinary/storlines/storbinary, get*/put* file helpers,
  abort/quit/close, sendcmd/voidcmd.
- net_ftp_bind.go: the transport engine — control-connection reply reading via
  the codec LineReader, passive (PASV/EPSV dial) and active (PORT/EPRT listen)
  data connections, TYPE negotiation, and codec-error -> Ruby-class mapping.
  The transfer path closes the data socket before raising on an error reply so
  no fd/goroutine leaks.
- registerNetFTP() runs after registerNetHTTP (Net module) and registerSocket/
  registerOpenSSL; "net/ftp" added to providedFeatures; ftpObj / ftpMLSxEntry
  wired into classOf.

Tests drive an in-process loopback FTP server (no real network, connections
force-closed and joined -> no leaked goroutines), covering every command,
passive/active + PASV/EPSV/PORT/EPRT, and each error branch; new files at 100%
statement coverage. CGO=0, builds on all six 64-bit arches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire github.com/go-ruby-net-smtp/net-smtp (the MRI-byte-exact SMTP codec:
command grammar, DATA dot-stuffing, reply parser, EHLO capability parse,
SASL PLAIN/LOGIN/CRAM-MD5) into rbgo so `require "net/smtp"` provides a
native Net::SMTP with no .rb file.

The codec deliberately leaves out the socket, so — unlike the redis/pg
bindings' injected-IO seam — the transport here is rbgo's real socket layer
(socket.go): Net::SMTP#start dials a TCPSocket, wraps it in a smtpConn
satisfying the library's Conn seam, and drives netsmtp.Session against it.
STARTTLS upgrades the same socket in place through the crypto/tls handshake
the net/http transport already uses; implicit TLS (SMTPS) wraps on connect.

Ruby surface: Net::SMTP.new / .start (block + non-block, positional and
keyword credentials, tls:/starttls:), start/finish, send_message / sendmail,
open_message_stream / ready (with a puts/print/write/<< adapter), data
(string or block), the low-level ehlo/helo/mailfrom/rcptto/rcptto_list/rset,
authenticate (:plain/:login/:cram_md5), the esmtp/tls/starttls config and
capability predicates, timeouts, and a Net::SMTP::Response view. The
Net::SMTPError family (SMTPError < ProtocolError; ServerBusy/Syntax/
Authentication/Fatal/Unknown/UnsupportedCommand) is registered so a codec
error rescues as its MRI-faithful class.

Tests drive an in-process scripted SMTP server (plaintext, STARTTLS-upgrade
and implicit-TLS variants with a generated self-signed cert) plus Go-level
unit tests for the connless/nil-session branches — deterministic, no real
network, goroutines joined on cleanup. 100% coverage of the new files; full
internal/vm + internal/object suites green; gofmt/vet/-race clean; builds on
all six 64-bit arches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nd-netwave

# Conflicts:
#	go.mod
#	go.sum
#	internal/vm/require.go
…nd-netwave

# Conflicts:
#	go.mod
#	go.sum
#	internal/vm/builtins.go
#	internal/vm/object_model.go
#	internal/vm/require.go
…d-netwave

# Conflicts:
#	internal/vm/builtins.go
#	internal/vm/object_model.go
#	internal/vm/require.go
…nd-netwave

# Conflicts:
#	go.mod
#	go.sum
#	internal/vm/builtins.go
#	internal/vm/require.go
Consolidates 6 pure-Go stdlib bindings (each verified 100% cov + full suite
+ 6-arch + -race on its own branch before consolidation):

- net/smtp — Net::SMTP send/auth/STARTTLS over rbgo's real socket+TLS transport
- net/imap — Net::IMAP full command set + typed response classes, injected IO seam
- net/pop  — Net::POP3/POPMail over an injected IO seam
- net/ftp  — Net::FTP control+data channels over real net.Dial/Listen (loopback tests)
- net/sftp — Net::SFTP wire codec over an injected SSH-channel seam
- webrick   — WEBrick::HTTPServer/mount_proc/servlets/HTTPStatus, handler inline under GVL

Merged on top of main's #199 (Addrinfo/getaddrinfo). Tests use in-process
servers / injected transports — no real network, no leaked goroutines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ftpBasename split only on '/', so on Windows a local path from filepath.Join
(C:\...\up.txt) has no '/' and the whole path became the STOR filename —
putbinaryfile(local) with no explicit remote name stored under the wrong key
(TestFTPGetPutFiles: default name captured ""). Split on either separator,
matching Ruby's File.basename, which treats both '/' and '\' as separators on
Windows. Added direct unit coverage for the Windows and mixed-separator cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merge of main brought #198's TestNetHTTPEnvProxyCGISafety, whose first
assertion (uppercase HTTP_PROXY under CGI is ignored ⇒ proxy? false) cannot
hold on Windows: env vars are case-insensitive there, so HTTP_PROXY and
http_proxy are the same variable and the attacker-controllable-form-is-ignored
distinction is unrepresentable (main's own windows lane is red on this at
6d1fea1). Guard only that first assertion behind non-windows; the lowercase-
honored assertion still runs everywhere and exercises the same
REQUEST_METHOD/os.Getenv("http_proxy") path, so coverage stays 100% on all
lanes. CGI is a unix deployment concern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 467241c into main Jul 7, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant