From 89ccab668fbb70f5a7d9a19617c17ea5bf1f2106 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:20:40 +0200 Subject: [PATCH 1/9] Bind net-pop into rbgo: require "net/pop" (Net::POP3 / Net::POPMail) 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 --- go.mod | 1 + go.sum | 2 + internal/vm/builtins.go | 1 + internal/vm/net_pop.go | 368 ++++++++++++++++++++++++++ internal/vm/net_pop_bind.go | 182 +++++++++++++ internal/vm/net_pop_internal_test.go | 374 +++++++++++++++++++++++++++ internal/vm/object_model.go | 4 + internal/vm/require.go | 2 +- 8 files changed, 933 insertions(+), 1 deletion(-) create mode 100644 internal/vm/net_pop.go create mode 100644 internal/vm/net_pop_bind.go create mode 100644 internal/vm/net_pop_internal_test.go diff --git a/go.mod b/go.mod index f56c9fd7..a2468e67 100644 --- a/go.mod +++ b/go.mod @@ -89,6 +89,7 @@ require ( github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f + github.com/go-ruby-net-pop/net-pop v0.0.0-20260630142109-66d7036032f5 github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 github.com/go-ruby-observer/observer v0.0.0-20260630080708-c3a02da51f79 diff --git a/go.sum b/go.sum index a357f88a..05022206 100644 --- a/go.sum +++ b/go.sum @@ -340,6 +340,8 @@ github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 h1:F+ghhcdLDBVGG github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7/go.mod h1:cm0D4pmaTvvQFPK815E7/uJaGeGxkr5xFLHixeQixTA= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f h1:nS0qwCPtVAtp+48j6KkdTdpVL8jT9L2ZGQcJZwOKev8= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f/go.mod h1:PiNQDuyrSPZX8OzApvgyn1fTjRNwx0KNO1vrzdAguEc= +github.com/go-ruby-net-pop/net-pop v0.0.0-20260630142109-66d7036032f5 h1:vmwYSD3D+m8vNWaA+I+lD8Ufmnw+rDHbs4askNBcFZo= +github.com/go-ruby-net-pop/net-pop v0.0.0-20260630142109-66d7036032f5/go.mod h1:9+NyzWDudIY8eetFY066OKgrU8bk7ZVqDRKAjFBIwng= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e h1:v8DyCc3Vp+REhN1uyQ4wN8CpJ6JMqWNOm9OESWFBtLU= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e/go.mod h1:WsLJ6/u8/nzHShZKChf20tHosn/nlVnOJiD/xEY6X8c= github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 h1:iZFtIn5WHQRz/oa04qYAlLvTi0XDRP5dKPGpJTGRzKg= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..ccb0d088 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -425,6 +425,7 @@ func (vm *VM) bootstrap() { vm.registerSocket() // TCPSocket/TCPServer (net) + OpenSSL::SSL::SSLSocket (crypto/tls); after registerOpenSSL (upgrades its SSL shell) vm.registerNetHTTP() // net/http + net/https loadable shell; needs StandardError vm.registerNetHTTPTransport() // real Net::HTTP over the socket transport; after registerNetHTTP + registerSocket + vm.registerNetPOP() // Net::POP3/Net::POPMail (require "net/pop"), backed by go-ruby-net-pop codec; socket = injected IO seam; after registerNetHTTP (Net module) + registerSocket/registerOpenSSL vm.registerResolv() // Resolv (real IPv4/IPv6 parse; DNS sockets stubbed); needs StandardError vm.registerTimeout() // Timeout module (loadable shell); needs RuntimeError vm.registerDateErrors() // Date::Error < ArgumentError (Date class itself registered early); needs ArgumentError diff --git a/internal/vm/net_pop.go b/internal/vm/net_pop.go new file mode 100644 index 00000000..df0affbd --- /dev/null +++ b/internal/vm/net_pop.go @@ -0,0 +1,368 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + netpop "github.com/go-ruby-net-pop/net-pop" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerNetPOP installs the Net::POP3 client (require "net/pop"): Net::POP3.new / +// .start plus the session surface (start / finish / mails / each_mail / n_mails / +// n_bytes / reset / delete_all / set_all_uids / noop / stls) and the Net::POPMail +// model (number / length / size / unique_id / pop / header / top / delete / +// deleted?), all over the POP3 protocol codec of github.com/go-ruby-net-pop/net-pop. +// +// The TCP/TLS socket is the host seam: rbgo drives the library's Transport over an +// injected Ruby IO-like object (Net::POP3.new(address, port, connection: io) — any +// object responding to #write / #gets, e.g. a StringIO or a duck-typed socket). +// The Net::POPError / Net::POPAuthenticationError / Net::POPBadResponse tree is +// registered so a server "-ERR" or a malformed reply rescues as the gem-faithful +// Ruby class. +// +// It runs after registerNetHTTP (which creates the Net module) and after +// registerSocket / registerOpenSSL, mirroring how redis/pg wire an injected seam. +func (vm *VM) registerNetPOP() { + net := vm.consts["Net"].(*RClass) + vm.registerNetPOPErrors(net) + + pop3 := newClass("Net::POP3", vm.cObject) + net.consts["POP3"] = pop3 + vm.consts["Net::POP3"] = pop3 + + mail := newClass("Net::POPMail", vm.cObject) + net.consts["POPMail"] = mail + vm.consts["Net::POPMail"] = mail + + vm.registerNetPOP3ClassMethods(pop3) + vm.registerNetPOP3InstanceMethods(pop3) + vm.registerNetPOPMailMethods(mail) +} + +// registerNetPOPErrors installs the Net::POPError hierarchy mirroring net/pop.rb: +// POPError < StandardError, POPBadResponse < POPError, POPAuthenticationError < +// StandardError (a sibling of POPError, as in MRI's ProtoAuthError branch). +func (vm *VM) registerNetPOPErrors(net *RClass) { + std := vm.consts["StandardError"].(*RClass) + reg := func(simple string, super *RClass) *RClass { + qualified := "Net::" + simple + c := newClass(qualified, super) + net.consts[simple] = c + vm.consts[qualified] = c + return c + } + popErr := reg("POPError", std) + reg("POPBadResponse", popErr) + reg("POPAuthenticationError", std) +} + +// registerNetPOP3ClassMethods installs Net::POP3.new and the Net::POP3.start +// convenience (new + start, yielding the session to a block then finishing). +func (vm *VM) registerNetPOP3ClassMethods(pop3 *RClass) { + pop3.smethods["new"] = &Method{name: "new", owner: pop3, + native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + seam, rest := popSeamArg(args) + if seam == nil { + raise("ArgumentError", "Net::POP3.new requires a connection: IO-like object (rbgo has no native POP3 socket seam here yet)") + } + return vm.popBuild(pop3, seam, popStrAt(rest, 0), popIntAt(rest, 1, 110), popBoolAt(rest, 2)) + }} + + // Net::POP3.start(address, port, account, password, isapop = false, + // connection: io) { |pop| ... }: build a session, start it, and (with a block) + // yield it and finish afterwards, mirroring MRI's POP3.start. + pop3.smethods["start"] = &Method{name: "start", owner: pop3, + native: func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + seam, rest := popSeamArg(args) + if seam == nil { + raise("ArgumentError", "Net::POP3.start requires a connection: IO-like object (rbgo has no native POP3 socket seam here yet)") + } + o := vm.popBuild(pop3, seam, popStrAt(rest, 0), popIntAt(rest, 1, 110), popBoolAt(rest, 4)) + return vm.popStart(o, popStrAt(rest, 2), popStrAt(rest, 3), blk) + }} +} + +// popBuild constructs a POP3Obj over the injected seam, wiring the library's Conn +// to the rubyPOPTransport. +func (vm *VM) popBuild(cls *RClass, seam object.Value, address string, port int, apop bool) *POP3Obj { + o := &POP3Obj{cls: cls, seam: seam, address: address, port: port, apop: apop} + o.conn = netpop.NewConn(&rubyPOPTransport{vm: vm, obj: seam}) + return o +} + +// registerNetPOP3InstanceMethods installs the Net::POP3 session surface. +func (vm *VM) registerNetPOP3InstanceMethods(pop3 *RClass) { + self := func(v object.Value) *POP3Obj { return v.(*POP3Obj) } + + pop3.define("address", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).address) + }) + pop3.define("port", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self(v).port)) + }) + pop3.define("apop?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).apop) + }) + pop3.define("started?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).started) + }) + pop3.define("active?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).started) + }) + pop3.define("_connection", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return self(v).seam + }) + + pop3.define("start", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + return vm.popStart(self(v), popStrAt(args, 0), popStrAt(args, 1), blk) + }) + pop3.define("finish", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + vm.popFinish(self(v)) + return object.NilV + }) + + pop3.define("mails", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.popMailArray(self(v)) + }) + pop3.define("each_mail", func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + o := self(v) + if blk == nil { + raise("LocalJumpError", "no block given (yield)") + } + for _, m := range vm.popMails(o) { + vm.callBlock(blk, []object.Value{m}) + } + return v + }) + pop3.define("each", func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + o := self(v) + if blk == nil { + raise("LocalJumpError", "no block given (yield)") + } + for _, m := range vm.popMails(o) { + vm.callBlock(blk, []object.Value{m}) + } + return v + }) + pop3.define("delete_all", func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + o := self(v) + for _, m := range vm.popMails(o) { + if blk != nil { + vm.callBlock(blk, []object.Value{m}) + } + vm.popDelete(m) + } + return object.NilV + }) + + pop3.define("n_mails", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + count, _ := vm.popStat(self(v)) + return object.IntValue(int64(count)) + }) + pop3.define("n_bytes", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + _, size := vm.popStat(self(v)) + return object.IntValue(int64(size)) + }) + + pop3.define("reset", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + o := self(v) + if err := o.conn.Rset(); err != nil { + popRaise(err) + } + o.mails = nil + return v + }) + pop3.define("set_all_uids", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.popSetAllUIDs(self(v)) + }) + pop3.define("noop", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + if err := self(v).conn.Noop(); err != nil { + popRaise(err) + } + return object.NilV + }) + pop3.define("stls", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + if err := self(v).conn.Stls(); err != nil { + popRaise(err) + } + return object.NilV + }) +} + +// popStart reads the greeting and authenticates (APOP when so configured, else +// USER/PASS), marking the session started. With a block it yields the session and +// finishes afterwards, mirroring MRI's POP3#start. +func (vm *VM) popStart(o *POP3Obj, account, password string, blk *Proc) object.Value { + if err := o.conn.Greet(); err != nil { + popRaise(err) + } + if o.apop { + if err := o.conn.Apop(account, password); err != nil { + popRaise(err) + } + } else { + if err := o.conn.Auth(account, password); err != nil { + popRaise(err) + } + } + o.started = true + if blk != nil { + res := vm.callBlock(blk, []object.Value{o}) + vm.popFinish(o) + return res + } + return o +} + +// popFinish issues QUIT and clears the started flag, mirroring POP3#finish. +func (vm *VM) popFinish(o *POP3Obj) { + if err := o.conn.Quit(); err != nil { + popRaise(err) + } + o.started = false +} + +// popStat issues STAT and returns (count, size), raising the gem-faithful error on +// a non-"+OK" or malformed reply. +func (vm *VM) popStat(o *POP3Obj) (int, int) { + count, size, err := o.conn.Stat() + if err != nil { + popRaise(err) + } + return count, size +} + +// popMails issues (once) a multiline LIST and memoises the POPMail wrappers. +func (vm *VM) popMails(o *POP3Obj) []*POPMailObj { + if o.mails != nil { + return o.mails + } + list, err := o.conn.List() + if err != nil { + popRaise(err) + } + mailCls := vm.consts["Net::POPMail"].(*RClass) + out := make([]*POPMailObj, len(list)) + for i, m := range list { + out[i] = &POPMailObj{cls: mailCls, pop: o, mail: m} + } + o.mails = out + return out +} + +// popMailArray returns the memoised POPMail list as a Ruby Array. +func (vm *VM) popMailArray(o *POP3Obj) object.Value { + mails := vm.popMails(o) + elems := make([]object.Value, len(mails)) + for i, m := range mails { + elems[i] = m + } + return object.NewArrayFromSlice(elems) +} + +// popSetAllUIDs issues UIDL and fills each memoised mail's uid, mirroring +// POP3#set_all_uids. +func (vm *VM) popSetAllUIDs(o *POP3Obj) object.Value { + mails := vm.popMails(o) + table, err := o.conn.UIDL() + if err != nil { + popRaise(err) + } + models := make([]*netpop.POPMail, len(mails)) + for i, m := range mails { + models[i] = m.mail + } + netpop.SetAllUIDs(models, table) + return object.NilV +} + +// popDelete marks a message for deletion on the server (DELE), mirroring +// POPMail#delete. +func (vm *VM) popDelete(m *POPMailObj) { + if err := m.pop.conn.Dele(m.mail.Number); err != nil { + popRaise(err) + } + m.mail.Deleted = true +} + +// registerNetPOPMailMethods installs the Net::POPMail model surface. +func (vm *VM) registerNetPOPMailMethods(mail *RClass) { + self := func(v object.Value) *POPMailObj { return v.(*POPMailObj) } + + mail.define("number", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self(v).mail.Number)) + }) + mail.define("length", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self(v).mail.Length)) + }) + mail.define("size", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self(v).mail.Length)) + }) + mail.define("deleted?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).mail.Deleted) + }) + + // #unique_id / #uidl: return the cached UID, or fetch it with a single-message + // "UIDL n" when it has not been populated (e.g. no prior set_all_uids). + uid := func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + m := self(v) + if m.mail.UID == "" { + id, err := m.pop.conn.UIDLNum(m.mail.Number) + if err != nil { + popRaise(err) + } + m.mail.UID = id + } + return object.NewString(m.mail.UID) + } + mail.define("unique_id", uid) + mail.define("uidl", uid) + + // #pop / #mail / #to_s: RETR the full message; with a block, yield the body. + popBody := func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + m := self(v) + body, err := m.pop.conn.Retr(m.mail.Number) + if err != nil { + popRaise(err) + } + s := object.NewString(body) + if blk != nil { + vm.callBlock(blk, []object.Value{s}) + return v + } + return s + } + mail.define("pop", popBody) + mail.define("mail", popBody) + mail.define("to_s", popBody) + + // #header([lines]): TOP with a zero body-line count (the header only). + mail.define("header", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + m := self(v) + body, err := m.pop.conn.Top(m.mail.Number, 0) + if err != nil { + popRaise(err) + } + return object.NewString(body) + }) + // #top(lines): TOP with the given body-line count. + mail.define("top", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + m := self(v) + body, err := m.pop.conn.Top(m.mail.Number, popIntAt(args, 0, 0)) + if err != nil { + popRaise(err) + } + return object.NewString(body) + }) + + // #delete / #delete!: DELE the message, marking it deleted this session. + del := func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + vm.popDelete(self(v)) + return object.NilV + } + mail.define("delete", del) + mail.define("delete!", del) +} diff --git a/internal/vm/net_pop_bind.go b/internal/vm/net_pop_bind.go new file mode 100644 index 00000000..2c0ee8bb --- /dev/null +++ b/internal/vm/net_pop_bind.go @@ -0,0 +1,182 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "io" + "strings" + + netpop "github.com/go-ruby-net-pop/net-pop" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the thin binding between rbgo's Ruby object graph (object.Value) +// and the interpreter-independent POP3 protocol codec of +// github.com/go-ruby-net-pop/net-pop. The command bytes, the APOP digest, the +// "+OK"/"-ERR" status checks, the STAT/LIST/UIDL field parses and the multiline +// dot-unstuffing all live in that library; rbgo only supplies the host seam — the +// socket — and maps the parsed values back into the object graph. +// +// rbgo drives the library through its Transport interface (Writeline / Readline / +// ReadRawLine). Because rbgo opens no socket of its own here, the transport is an +// injected Ruby IO-like object: any object responding to #write and #gets (a +// StringIO, or a duck-typed socket a host provides). rubyPOPTransport bridges that +// object to the library's Transport, so the whole suite stays deterministic and +// Ruby-free. + +// POP3Obj is the Ruby wrapper around a *netpop.Conn (Net::POP3). It owns no +// socket: the Conn drives commands over the injected rubyPOPTransport seam. +type POP3Obj struct { + cls *RClass + conn *netpop.Conn + // seam is the Ruby IO-like object backing the transport, kept so #_connection + // can return it and so the object stays reachable. + seam object.Value + address string + port int + apop bool + started bool + // mails memoises the POPMail list built by #mails / #each_mail; #reset clears + // it, mirroring how MRI caches @mails and drops it on RSET. + mails []*POPMailObj +} + +func (o *POP3Obj) ToS() string { return "#" } +func (o *POP3Obj) Inspect() string { return o.ToS() } +func (o *POP3Obj) Truthy() bool { return true } + +// POPMailObj is the Ruby wrapper around a *netpop.POPMail (Net::POPMail). It holds +// a back-reference to its POP3Obj so #pop / #top / #delete drive the session's +// Conn; the parsed number / length / uid live in the library's POPMail model. +type POPMailObj struct { + cls *RClass + pop *POP3Obj + mail *netpop.POPMail +} + +func (m *POPMailObj) ToS() string { return "#" } +func (m *POPMailObj) Inspect() string { return m.ToS() } +func (m *POPMailObj) Truthy() bool { return true } + +// numToS renders an int as its base-10 string (used by the wrapper #to_s). +func numToS(n int) string { return object.IntValue(int64(n)).ToS() } + +// rubyPOPTransport bridges a Ruby IO-like object (responding to #write and #gets) +// to the netpop.Transport the library drives the POP3 session over. Writeline +// appends the CRLF and forwards to #write; Readline reads one line via #gets and +// strips its terminator; ReadRawLine returns the line WITH its terminator so the +// ".\r\n" multiline sentinel and the leading-dot un-stuffing stay intact. This is +// the host seam: the library owns the protocol, rbgo owns the transport. +type rubyPOPTransport struct { + vm *VM + obj object.Value +} + +// Writeline sends line + CRLF to the Ruby object's #write, mirroring +// Net::InternetMessageIO#writeline. A Ruby exception from #write unwinds through +// the panic-based raise, so the returned error is never a Ruby-level failure. +func (t *rubyPOPTransport) Writeline(line string) error { + t.vm.send(t.obj, "write", []object.Value{object.NewString(line + "\r\n")}, nil) + return nil +} + +// Readline reads one response line via the Ruby object's #gets and returns it +// without the trailing CRLF, mirroring Net::BufferedIO#readline. A nil #gets reply +// (a closed socket) is EOF. +func (t *rubyPOPTransport) Readline() (string, error) { + raw, err := t.gets() + if err != nil { + return "", err + } + return strings.TrimRight(raw, "\r\n"), nil +} + +// ReadRawLine reads one line of a multiline body via #gets and returns it WITH its +// trailing CRLF intact, mirroring Net::BufferedIO#readuntil("\r\n"); the +// terminator detection and the dot-unstuffing depend on the CRLF being present. +func (t *rubyPOPTransport) ReadRawLine() (string, error) { + return t.gets() +} + +// gets pulls one line from the Ruby object's #gets: a String yields its contents, +// a nil (or anything else) yields io.EOF. +func (t *rubyPOPTransport) gets() (string, error) { + rv := t.vm.send(t.obj, "gets", nil, nil) + if s, ok := rv.(*object.String); ok { + return s.Str(), nil + } + return "", io.EOF +} + +// popRaise turns a library error into the gem-faithful Ruby exception: a +// *netpop.POPAuthenticationError rescues as Net::POPAuthenticationError, a +// *netpop.POPBadResponse as Net::POPBadResponse, a *netpop.POPError as +// Net::POPError, and any transport/EOF fault as Net::POPError carrying its message. +func popRaise(err error) { + switch e := err.(type) { + case *netpop.POPAuthenticationError: + raise("Net::POPAuthenticationError", "%s", e.Response) + case *netpop.POPBadResponse: + raise("Net::POPBadResponse", "%s", e.Response) + case *netpop.POPError: + raise("Net::POPError", "%s", e.Response) + default: + raise("Net::POPError", "%s", err.Error()) + } +} + +// --- construction argument parsing ----------------------------------------- + +// popConnFromHash reads the connection seam a keyword Hash names — connection: / +// socket: / conn: (Symbol or String key) — returning nil when none is present. +func popConnFromHash(h *object.Hash) object.Value { + get := func(name string) (object.Value, bool) { + if v, ok := h.Get(object.Symbol(name)); ok { + return v, true + } + return h.Get(object.NewString(name)) + } + for _, key := range []string{"connection", "socket", "conn"} { + if v, ok := get(key); ok { + return v + } + } + return nil +} + +// popSeamArg pops a trailing keyword Hash off args, returning the connection seam +// it names and the remaining positional arguments. +func popSeamArg(args []object.Value) (object.Value, []object.Value) { + if len(args) > 0 { + if h, ok := args[len(args)-1].(*object.Hash); ok { + return popConnFromHash(h), args[:len(args)-1] + } + } + return nil, args +} + +// popStrAt returns args[i].to_s, or "" when the index is absent or nil. +func popStrAt(args []object.Value, i int) string { + if i < len(args) && !object.IsNil(args[i]) { + return strArg(args[i]) + } + return "" +} + +// popIntAt returns args[i] as an int, or def when the index is absent or nil. +func popIntAt(args []object.Value, i, def int) int { + if i < len(args) && !object.IsNil(args[i]) { + if n, ok := args[i].(object.Integer); ok { + return int(n) + } + } + return def +} + +// popBoolAt returns args[i].truthy?, or false when the index is absent. +func popBoolAt(args []object.Value, i int) bool { + return i < len(args) && args[i].Truthy() +} diff --git a/internal/vm/net_pop_internal_test.go b/internal/vm/net_pop_internal_test.go new file mode 100644 index 00000000..c04e05a9 --- /dev/null +++ b/internal/vm/net_pop_internal_test.go @@ -0,0 +1,374 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import "testing" + +// fakePOP is a Ruby duck-typed POP3 socket (write / gets) backing the net/pop host +// seam: writes are captured in @sent, gets drains the canned server script line by +// line (each line up to and including "\n"). It is the injected transport the +// Net::POP3 binding drives its protocol over, so the whole suite is deterministic +// and network-free. +const fakePOP = ` +class FakePOP + def initialize(script) ; @buf = script.dup ; @sent = "".dup ; end + def write(s) ; @sent << s ; s.bytesize ; end + def gets + return nil if @buf.empty? + i = @buf.index("\n") + if i + line = @buf[0..i] + rest = @buf[(i + 1)..-1] + @buf = rest.nil? ? "" : rest + else + line = @buf + @buf = "" + end + line + end + def sent ; @sent ; end +end +` + +// TestNetPOPFeatureAndTree covers the require "net/pop" load flag and the +// Net::POP3 / Net::POPMail classes plus the error hierarchy. +func TestNetPOPFeatureAndTree(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "net/pop"`, "true\n"}, + {`require "net/pop"; p require "net/pop"`, "false\n"}, + {`require "net/pop"; p Net::POP3.is_a?(Class)`, "true\n"}, + {`require "net/pop"; p Net::POPMail.is_a?(Class)`, "true\n"}, + {`require "net/pop"; p Net::POPError < StandardError`, "true\n"}, + {`require "net/pop"; p Net::POPBadResponse < Net::POPError`, "true\n"}, + {`require "net/pop"; p Net::POPAuthenticationError < StandardError`, "true\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestNetPOPStartAuth covers Net::POP3.new + #start (USER/PASS), the accessors, the +// written command bytes and the wrapper #inspect / truthiness. +func TestNetPOPStartAuth(t *testing.T) { + src := fakePOP + `require "net/pop" +io = FakePOP.new("+OK ready \r\n+OK\r\n+OK\r\n") +pop = Net::POP3.new("mail.example.com", 995, connection: io) +p pop.address +p pop.port +p pop.apop? +p pop.started? +p(pop ? :y : :n) +pop.start("alice", "secret") +p pop.started? +p pop.active? +p io.sent.include?("USER alice\r\n") +p io.sent.include?("PASS secret\r\n") +p pop._connection.equal?(io) +p pop.inspect` + want := "\"mail.example.com\"\n995\nfalse\nfalse\n:y\ntrue\ntrue\ntrue\ntrue\ntrue\n\"#\"\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetPOPStartBlock covers #start with a block (yield then finish/QUIT), the +// default port, and empty account/password args (popStrAt absent branch). +func TestNetPOPStartBlock(t *testing.T) { + src := fakePOP + `require "net/pop" +io = FakePOP.new("+OK \r\n+OK\r\n+OK\r\n+OK bye\r\n") +r = Net::POP3.new("h", connection: io) +p r.port +res = r.start { |c| c.started? } +p res +p r.started? +p io.sent.include?("QUIT\r\n")` + want := "110\ntrue\nfalse\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetPOPClassStart covers the Net::POP3.start class convenience (new + start + +// block + finish) with positional account/password. +func TestNetPOPClassStart(t *testing.T) { + src := fakePOP + `require "net/pop" +io = FakePOP.new("+OK \r\n+OK\r\n+OK\r\n+OK bye\r\n") +out = Net::POP3.start("host", 110, "u", "pw", connection: io) { |pop| p pop.address; :done } +p out +p io.sent.include?("USER u\r\n")` + want := "\"host\"\n:done\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetPOPApop covers APOP authentication: the greeting stamp drives an APOP +// digest line rather than USER/PASS. +func TestNetPOPApop(t *testing.T) { + src := fakePOP + `require "net/pop" +io = FakePOP.new("+OK POP3 <1896.697@x.example>\r\n+OK maildrop\r\n") +pop = Net::POP3.new("h", 110, true, connection: io) +p pop.apop? +pop.start("u", "p") +p pop.started? +p io.sent.start_with?("APOP u ")` + want := "true\ntrue\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetPOPMails covers #mails (LIST), the memoisation, #each_mail, the POPMail +// accessors, #pop (RETR + dot-unstuffing), #header/#top (TOP), #unique_id (UIDL n +// then the cached path) and #delete (DELE), plus a #pop block. +func TestNetPOPMails(t *testing.T) { + src := fakePOP + `require "net/pop" +io = FakePOP.new( + "+OK \r\n+OK\r\n+OK\r\n" + + "+OK 2 messages\r\n1 120\r\n2 200\r\n.\r\n" + + "+OK follows\r\nFrom: a\r\n..dotted\r\nbody\r\n.\r\n" + + "+OK\r\nFrom: a\r\n.\r\n" + + "+OK\r\nline1\r\n.\r\n" + + "+OK 1 UID1\r\n" + + "+OK grab\r\nFrom: a\r\nbody2\r\n.\r\n" + + "+OK\r\n") +pop = Net::POP3.new("h", connection: io) +pop.start("u", "p") +ms = pop.mails +p ms.length +p ms[0].equal?(pop.mails[0]) +m = ms[0] +p(m ? :y : :n) +p m.number +p m.length +p m.size +p m.deleted? +p m.inspect +body = m.pop +p body.include?("body") +p body.include?(".dotted") +p m.header.include?("From: a") +p m.top(5).include?("line1") +p m.unique_id +p m.unique_id +got = nil +m.pop { |chunk| got = chunk.include?("body2") } +p got +ms[1].delete +p ms[1].deleted?` + want := "2\ntrue\n:y\n1\n120\n120\nfalse\n\"#\"\ntrue\ntrue\ntrue\ntrue\n\"1\"\n\"1\"\ntrue\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetPOPSessionCommands covers #n_mails / #n_bytes (STAT), #noop (NOOP), +// #stls (STLS) and #reset (RSET). +func TestNetPOPSessionCommands(t *testing.T) { + src := fakePOP + `require "net/pop" +io = FakePOP.new("+OK \r\n+OK\r\n+OK\r\n+OK 3 450\r\n+OK 3 450\r\n+OK\r\n+OK\r\n+OK\r\n") +pop = Net::POP3.new("h", connection: io) +pop.start("u", "p") +p pop.n_mails +p pop.n_bytes +pop.noop +pop.stls +pop.reset +p io.sent.include?("NOOP\r\n") +p io.sent.include?("STLS\r\n") +p io.sent.include?("RSET\r\n")` + want := "3\n450\ntrue\ntrue\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetPOPUidsAndDeleteAll covers #each_mail (fetch), #set_all_uids (UIDL over +// the memoised list, feeding the cached #unique_id), #delete_all (with a block) +// and #each. +func TestNetPOPUidsAndDeleteAll(t *testing.T) { + src := fakePOP + `require "net/pop" +io = FakePOP.new( + "+OK \r\n+OK\r\n+OK\r\n" + + "+OK\r\n1 100\r\n2 200\r\n.\r\n" + + "+OK\r\n1 UIDA\r\n2 UIDB\r\n.\r\n" + + "+OK\r\n+OK\r\n") +pop = Net::POP3.new("h", connection: io) +pop.start("u", "p") +nums = [] +pop.each_mail { |m| nums << m.number } +p nums +each2 = [] +pop.each { |m| each2 << m.number } +p each2 +pop.set_all_uids +p pop.mails[0].unique_id +seen = [] +pop.delete_all { |m| seen << m.number } +p seen +p pop.mails[0].deleted?` + want := "[1, 2]\n[1, 2]\n\"UIDA\"\n[1, 2]\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetPOPDeleteAllNoBlock covers #delete_all without a block (the blk==nil +// branch) and #top with no argument (popIntAt default). +func TestNetPOPDeleteAllNoBlock(t *testing.T) { + src := fakePOP + `require "net/pop" +io = FakePOP.new( + "+OK \r\n+OK\r\n+OK\r\n" + + "+OK\r\n1 100\r\n.\r\n" + + "+OK\r\nhdr\r\n.\r\n" + + "+OK\r\n") +pop = Net::POP3.new("h", connection: io) +pop.start("u", "p") +p pop.mails[0].top.include?("hdr") +pop.delete_all +p pop.mails[0].deleted?` + want := "true\ntrue\n" + if got := eval(t, src); got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetPOPArgErrors covers the missing-seam ArgumentError paths for both .new and +// .start, the empty-arglist branch, a keyword Hash without a connection, the +// socket:/conn:/string-key connection forms, and popIntAt's non-Integer default. +func TestNetPOPArgErrors(t *testing.T) { + cases := []struct{ src, want string }{ + {fakePOP + `require "net/pop" +begin; Net::POP3.new("h"); rescue ArgumentError; p :a1; end`, ":a1\n"}, + {fakePOP + `require "net/pop" +begin; Net::POP3.new; rescue ArgumentError; p :a2; end`, ":a2\n"}, + {fakePOP + `require "net/pop" +begin; Net::POP3.new("h", 110, {foo: 1}); rescue ArgumentError; p :a3; end`, ":a3\n"}, + {fakePOP + `require "net/pop" +begin; Net::POP3.start("h"); rescue ArgumentError; p :a4; end`, ":a4\n"}, + {fakePOP + `require "net/pop" +p Net::POP3.new("h", socket: FakePOP.new("")).address`, "\"h\"\n"}, + {fakePOP + `require "net/pop" +p Net::POP3.new("h", conn: FakePOP.new("")).address`, "\"h\"\n"}, + {fakePOP + `require "net/pop" +p Net::POP3.new("h", "connection" => FakePOP.new("")).address`, "\"h\"\n"}, + {fakePOP + `require "net/pop" +p Net::POP3.new("h", "notint", connection: FakePOP.new("")).port`, "110\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestNetPOPEachNoBlock covers #each_mail / #each raising LocalJumpError without a +// block (before any I/O). +func TestNetPOPEachNoBlock(t *testing.T) { + cases := []struct{ src, want string }{ + {fakePOP + `require "net/pop" +pop = Net::POP3.new("h", connection: FakePOP.new("")) +begin; pop.each_mail; rescue LocalJumpError; p :nb1; end`, ":nb1\n"}, + {fakePOP + `require "net/pop" +pop = Net::POP3.new("h", connection: FakePOP.new("")) +begin; pop.each; rescue LocalJumpError; p :nb2; end`, ":nb2\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// TestNetPOPErrorMapping covers popRaise's four branches (POPAuthenticationError / +// POPError / POPBadResponse / the EOF default) and every command error path. +func TestNetPOPErrorMapping(t *testing.T) { + start := `"+OK \r\n+OK\r\n+OK\r\n"` + cases := []struct{ src, want string }{ + // USER/PASS auth failure -> POPAuthenticationError, message verbatim. + {fakePOP + `require "net/pop" +io = FakePOP.new("+OK \r\n-ERR nope\r\n") +begin; Net::POP3.new("h", connection: io).start("u", "p"); rescue Net::POPAuthenticationError => e; p e.message; end`, "\"-ERR nope\"\n"}, + // Greeting "-ERR" -> POPError. + {fakePOP + `require "net/pop" +io = FakePOP.new("-ERR down\r\n") +begin; Net::POP3.new("h", connection: io).start("u", "p"); rescue Net::POPError => e; p e.message; end`, "\"-ERR down\"\n"}, + // APOP requested but no stamp in greeting -> POPAuthenticationError. + {fakePOP + `require "net/pop" +io = FakePOP.new("+OK ready\r\n") +begin; Net::POP3.new("h", 110, true, connection: io).start("u", "p"); rescue Net::POPAuthenticationError => e; p e.message; end`, "\"not APOP server; cannot login\"\n"}, + // Malformed STAT -> POPBadResponse. + {fakePOP + `require "net/pop" +io = FakePOP.new("+OK \r\n+OK\r\n+OK\r\n+OK garbage\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.n_mails; rescue Net::POPBadResponse => e; p e.class; end`, "Net::POPBadResponse\n"}, + // Empty script -> gets returns nil -> io.EOF -> POPError (default branch). + {fakePOP + `require "net/pop" +io = FakePOP.new("") +begin; Net::POP3.new("h", connection: io).start("u", "p"); rescue Net::POPError; p :eof; end`, ":eof\n"}, + // QUIT "-ERR" -> #finish raises POPError. + {fakePOP + `require "net/pop" +io = FakePOP.new("+OK \r\n+OK\r\n+OK\r\n-ERR quit fail\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.finish; rescue Net::POPError; p :qf; end`, ":qf\n"}, + // LIST "-ERR" -> #mails raises. + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "-ERR no list\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.mails; rescue Net::POPError; p :le; end`, ":le\n"}, + // UIDL "-ERR" -> #set_all_uids raises. + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "+OK\r\n1 100\r\n.\r\n" + "-ERR nouidl\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p"); pop.mails +begin; pop.set_all_uids; rescue Net::POPError; p :ue; end`, ":ue\n"}, + // DELE "-ERR" -> POPMail#delete raises. + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "+OK\r\n1 100\r\n.\r\n" + "-ERR nodele\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.mails[0].delete; rescue Net::POPError; p :de; end`, ":de\n"}, + // RETR "-ERR" -> POPMail#pop raises. + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "+OK\r\n1 100\r\n.\r\n" + "-ERR noretr\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.mails[0].pop; rescue Net::POPError; p :re; end`, ":re\n"}, + // TOP "-ERR" -> POPMail#header raises. + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "+OK\r\n1 100\r\n.\r\n" + "-ERR notop\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.mails[0].header; rescue Net::POPError; p :he; end`, ":he\n"}, + // TOP "-ERR" -> POPMail#top raises. + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "+OK\r\n1 100\r\n.\r\n" + "-ERR notop\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.mails[0].top(3); rescue Net::POPError; p :te; end`, ":te\n"}, + // UIDL n "-ERR" -> POPMail#unique_id raises (uncached fetch). + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "+OK\r\n1 100\r\n.\r\n" + "-ERR nouidl\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.mails[0].unique_id; rescue Net::POPError; p :uie; end`, ":uie\n"}, + // reset RSET "-ERR" -> POPError. + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "-ERR norst\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.reset; rescue Net::POPError; p :rr; end`, ":rr\n"}, + // noop NOOP "-ERR" -> POPError. + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "-ERR nonoop\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.noop; rescue Net::POPError; p :nn; end`, ":nn\n"}, + // stls STLS "-ERR" -> POPError. + {fakePOP + `require "net/pop" +io = FakePOP.new(` + start + ` + "-ERR nostls\r\n") +pop = Net::POP3.new("h", connection: io); pop.start("u", "p") +begin; pop.stls; rescue Net::POPError; p :ss; end`, ":ss\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..8bccaae9 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -978,6 +978,10 @@ func (vm *VM) classOf(v object.Value) *RClass { return x.cls case *RedisBatch: return x.cls + case *POP3Obj: + return x.cls + case *POPMailObj: + return x.cls case *PGConnObj: return x.cls case *PGResultObj: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..714afa4a 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -33,7 +33,7 @@ var providedFeatures = map[string]bool{ "concurrent": true, "syslog": true, "cgi": true, "monitor": true, "observer": true, "socket": true, - "net/http": true, "net/https": true, "resolv": true, "singleton": true, + "net/http": true, "net/https": true, "net/pop": true, "resolv": true, "singleton": true, "optparse": true, "ripper": true, "erb": true, "find": true, "tempfile": true, "open3": true, "strscan": true, "fiber": true, "objspace": true, "csv": true, From 81a4085e717542f2160415dabaef928d8d0f44a1 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:28:25 +0200 Subject: [PATCH 2/9] Bind pure-Go webrick into rbgo (require "webrick") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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 — 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 --- go.mod | 1 + go.sum | 2 + internal/vm/builtins.go | 1 + internal/vm/object_model.go | 6 + internal/vm/require.go | 1 + internal/vm/webrick.go | 422 +++++++++++++++++++++++++++ internal/vm/webrick_bind.go | 365 +++++++++++++++++++++++ internal/vm/webrick_internal_test.go | 375 ++++++++++++++++++++++++ 8 files changed, 1173 insertions(+) create mode 100644 internal/vm/webrick.go create mode 100644 internal/vm/webrick_bind.go create mode 100644 internal/vm/webrick_internal_test.go diff --git a/go.mod b/go.mod index f56c9fd7..d1a2247d 100644 --- a/go.mod +++ b/go.mod @@ -147,6 +147,7 @@ require ( github.com/go-ruby-uri/uri v0.0.0-20260629113958-59633d1b0deb github.com/go-ruby-warden/warden v0.0.0-20260706120446-d887742539ef github.com/go-ruby-webauthn/webauthn v0.0.0-20260704120708-35595b0ac27b + github.com/go-ruby-webrick/webrick v0.0.0-20260630133907-a1380ee7733b github.com/go-ruby-xslt/xslt v0.0.0-20260702171958-146eaf3f0176 github.com/go-ruby-yaml/yaml v0.0.0-20260629093916-8035038027bd github.com/go-ruby-zeitwerk/zeitwerk v0.0.0-20260706163820-cda238c0e98c diff --git a/go.sum b/go.sum index a357f88a..c8cddb19 100644 --- a/go.sum +++ b/go.sum @@ -456,6 +456,8 @@ github.com/go-ruby-warden/warden v0.0.0-20260706120446-d887742539ef h1:JLPi8PmkO github.com/go-ruby-warden/warden v0.0.0-20260706120446-d887742539ef/go.mod h1:oI3L9dywsrSXJqLDuJGIYuHUHRCxUS7BU8PBZGXpx4A= github.com/go-ruby-webauthn/webauthn v0.0.0-20260704120708-35595b0ac27b h1:ZwN0q9xq9HUGCskWGz8rmL+v6MaHy+AM99oictlPlBs= github.com/go-ruby-webauthn/webauthn v0.0.0-20260704120708-35595b0ac27b/go.mod h1:tLv8PWrPxrOQrJWMFTfRi6wfZpSjZtmfxHKgGwUriR8= +github.com/go-ruby-webrick/webrick v0.0.0-20260630133907-a1380ee7733b h1:T7H6p2VhpU/05o/XSlsCT3YfMznMhcuShu8iDyDVeqU= +github.com/go-ruby-webrick/webrick v0.0.0-20260630133907-a1380ee7733b/go.mod h1:0DF9Jl9o+cvEvPCzCJ95nHIlF7qz8yzuQIyfMm8FUlM= github.com/go-ruby-xslt/xslt v0.0.0-20260702171958-146eaf3f0176 h1:iHTCF/Eu0/lwOEank8pLKqoW5j9MI2ERXErrvqsZZ+4= github.com/go-ruby-xslt/xslt v0.0.0-20260702171958-146eaf3f0176/go.mod h1:1C3Bx0pQIsqLqC2SnqV7pJnGuLJsgYmar2NrD+CmVXw= github.com/go-ruby-yaml/yaml v0.0.0-20260629093916-8035038027bd h1:4HGjgCdTIuIEM+iZXJ9hJJzt0IBMtxPqdJq72mr0Y64= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..2e48ad1d 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -486,6 +486,7 @@ func (vm *VM) bootstrap() { vm.registerRSpec() // RSpec matcher + expect surface (require "rspec"), backed by go-ruby-rspec; needs Exception for ExpectationNotMetError vm.registerRuboCop() // RuboCop::Runner#inspect/autocorrect + Cop::Offense (require "rubocop"), backed by go-ruby-rubocop over go-ruby-parser; needs StandardError for RuboCop::Error vm.registerRack() // Rack::Request/Response/Utils (require "rack" / "rack/utils"), backed by go-ruby-rack; deterministic env/query/escape, no socket + vm.registerWEBrick() // WEBrick::HTTPServer/HTTPRequest/HTTPResponse/HTTPServlet/HTTPStatus (require "webrick"), backed by go-ruby-webrick; deterministic parse/build/mount-dispatch, no socket; the mount_proc block / servlet do_* method is the rbgo seam run inline under the GVL; needs StandardError (WEBrick::HTTPStatus family) vm.registerGrape() // Grape::Router/Validator/Formatter (require "grape"), backed by go-ruby-grape; endpoint-block exec + Rack env are host seams; needs StandardError for Grape::Exceptions vm.registerSinatra() // Sinatra::Base class-DSL + #call Rack adapter (require "sinatra/base"), backed by go-ruby-sinatra over go-ruby-rack; route/filter block eval is the rbgo seam; needs Rack + StandardError (Sinatra::NotFound), so registered after registerRack vm.registerRoda() // Roda class routing-tree DSL (route do |r| … end) + #call Rack adapter (require "roda"), backed by go-ruby-roda over go-ruby-rack; the route/matcher block eval is the rbgo seam, the routing tree is the library; needs Rack + StandardError (Roda::RodaError) diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..917c665e 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -1336,6 +1336,12 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.consts["Puma::Configuration"].(*RClass) case *PumaDSL: return vm.consts["Puma::DSL"].(*RClass) + case *WEBrickServer: + return vm.consts["WEBrick::HTTPServer"].(*RClass) + case *WEBrickRequest: + return vm.consts["WEBrick::HTTPRequest"].(*RClass) + case *WEBrickResponse: + return vm.consts["WEBrick::HTTPResponse"].(*RClass) case *WardenManager: return x.cls case *WardenProxy: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..a882c874 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -72,6 +72,7 @@ var providedFeatures = map[string]bool{ "rubocop": true, "grape": true, "rack": true, "rack/utils": true, + "webrick": true, "sinatra": true, "sinatra/base": true, "active_record": true, "activerecord": true, "active_model": true, "activemodel": true, diff --git a/internal/vm/webrick.go b/internal/vm/webrick.go new file mode 100644 index 00000000..2d51e3f7 --- /dev/null +++ b/internal/vm/webrick.go @@ -0,0 +1,422 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + webrick "github.com/go-ruby-webrick/webrick" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerWEBrick installs the WEBrick module (require "webrick"): the pure-Go +// (CGO=0) port of Ruby's WEBrick HTTP server core, reimplemented by +// github.com/go-ruby-webrick/webrick on top of go-ruby-net-http. The library +// owns the deterministic request-parse / response-build / status-table / mount +// dispatch; it does no socket I/O itself (the TCP accept loop is a host seam). +// This file (with webrick_bind.go) is the thin shell mapping that surface onto +// rbgo classes: +// +// WEBrick::HTTPServer.new(Port: 80) — the mount registry + #service dispatch +// #mount_proc("/p") { |req, res| … } — a proc mounted at a path +// #mount("/p", servlet) — an AbstractServlet subclass / instance +// #service(req, res) — route + run the handler, fill res +// WEBrick::HTTPRequest.new; #parse(str) — parse a raw request byte string +// WEBrick::HTTPResponse.new — status / headers / body / #to_s bytes +// WEBrick::HTTPServlet::AbstractServlet — the do_ servlet base class +// WEBrick::HTTPStatus — reason_phrase, the category predicates +// and the Status exception family +// +// The request-handler seam — a mount_proc block or a servlet's do_ +// method — is the injectable Ruby code the deterministic engine leaves open. +// Because rbgo drives #service on the VM goroutine while holding the GVL, the +// handler runs INLINE (vm.callBlock / vm.send) with no extra goroutine and no +// listener, so there is nothing to leak; a Ruby handler that raises a +// WEBrick::HTTPStatus is recovered and turned into the matching error response, +// exactly like WEBrick::HTTPServer#run rescues HTTPStatus::Status (see +// webrick_bind.go). +func (vm *VM) registerWEBrick() { + mod := newClass("WEBrick", nil) + mod.isModule = true + vm.consts["WEBrick"] = mod + + // WEBrick::VERSION — the WEBrick release this port mirrors. + mod.consts["VERSION"] = object.NewString(webrick.VERSION) + + mk := func(name string, super *RClass) *RClass { + full := "WEBrick::" + name + cls := newClass(full, super) + mod.consts[name] = cls + vm.consts[full] = cls + return cls + } + + vm.registerWEBrickServer(mk("HTTPServer", vm.cObject)) + vm.registerWEBrickRequest(mk("HTTPRequest", vm.cObject)) + vm.registerWEBrickResponse(mk("HTTPResponse", vm.cObject)) + vm.registerWEBrickServlet(mod) + vm.registerWEBrickStatus(mod) +} + +// webrickSMethod installs a class ("singleton") method on cls. +func webrickSMethod(cls *RClass, name string, fn NativeFn) { + cls.smethods[name] = &Method{name: name, owner: cls, native: fn} +} + +// registerWEBrickServer installs WEBrick::HTTPServer: HTTPServer.new(config = {}) +// and the mount/dispatch surface. The config Hash honours :Port, :ServerName and +// :ServerSoftware (the deterministic keys the codec reads); the networking keys +// (:BindAddress, SSL, the callbacks) are host seams and are ignored. #start / +// #shutdown / #stop are the host accept-loop seam and are no-ops here — a real +// rbgo host drives #service from its own accept loop. +func (vm *VM) registerWEBrickServer(cls *RClass) { + webrickSMethod(cls, "new", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + cfg := webrickConfig(webrickFirstOpt(args)) + return &WEBrickServer{srv: webrick.NewHTTPServer(cfg), cfg: cfg} + }) + + self := func(v object.Value) *WEBrickServer { return v.(*WEBrickServer) } + d := func(name string, fn NativeFn) { cls.define(name, fn) } + + // #mount_proc(dir) { |req, res| … } — mounts the captured block at dir, run + // inline as the request handler (WEBrick::HTTPServer#mount_proc). + d("mount_proc", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + if blk == nil { + raise("ArgumentError", "HTTPServer#mount_proc requires a block") + } + self(v).srv.MountProc(webrickStr(args[0]), vm.webrickProcHandler(blk)) + return object.NilV + }) + + // #mount(dir, servlet, *options) — mounts an AbstractServlet subclass (a class, + // instantiated per request with the options) or a servlet instance at dir; its + // do_ methods handle requests (WEBrick::HTTPServer#mount). + d("mount", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2+)", len(args)) + } + self(v).srv.Mount(webrickStr(args[0]), vm.webrickServlet(args[1], args[2:])) + return object.NilV + }) + + unmount := func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + self(v).srv.Unmount(webrickStr(args[0])) + return object.NilV + } + d("unmount", unmount) + d("umount", unmount) + + // #service(req, res) — route req to its mounted handler and fill res, wiring + // the request context first (WEBrick::HTTPServer#service, driven by #run). A + // handler that raises a WEBrick::HTTPStatus is rendered into the error + // response; an unmatched path yields the default 404 page. + d("service", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + vm.webrickService(self(v), webrickReq(args[0]), webrickRes(args[1])) + return args[1] + }) + + // #config — the frozen config Hash the server was built with. + d("config", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return webrickConfigHash(self(v).cfg) + }) + + // #start / #shutdown / #stop — the host accept-loop seam; no-ops here (there + // is no listener), returning nil like a clean shutdown. + noop := func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { return object.NilV } + d("start", noop) + d("run", noop) + d("shutdown", noop) + d("stop", noop) +} + +// registerWEBrickRequest installs WEBrick::HTTPRequest: HTTPRequest.new(config = +// nil) builds an unparsed request, #parse(str) parses a raw request byte string +// into it, and the accessors expose the parsed request the way a WEBrick servlet +// reads it (request_method / path / query / cookies-free subset / keep_alive?). +func (vm *VM) registerWEBrickRequest(cls *RClass) { + webrickSMethod(cls, "new", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return &WEBrickRequest{cfg: webrickConfig(webrickFirstOpt(args))} + }) + + self := func(v object.Value) *WEBrickRequest { return v.(*WEBrickRequest) } + d := func(name string, fn NativeFn) { cls.define(name, fn) } + + // #parse(str) — parse the complete request byte stream (String) into self, + // returning self; a malformed request raises the matching WEBrick::HTTPStatus. + d("parse", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + r := self(v) + req, st := webrick.ParseRequest([]byte(webrickStr(args[0])), r.cfg) + if st != nil { + webrickRaiseStatus(st) + } + r.req = req + return v + }) + + req := func(v object.Value) *webrick.Request { + r := self(v) + if r.req == nil { + raise("RuntimeError", "WEBrick::HTTPRequest has not been parsed yet") + } + return r.req + } + str := func(name string, fn func(*webrick.Request) string) { + d(name, func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(fn(req(v))) + }) + } + str("request_method", func(r *webrick.Request) string { return r.RequestMethod }) + str("unparsed_uri", func(r *webrick.Request) string { return r.UnparsedURI }) + str("path", func(r *webrick.Request) string { return r.Path }) + str("script_name", func(r *webrick.Request) string { return r.ScriptName }) + str("path_info", func(r *webrick.Request) string { return r.PathInfo }) + str("query_string", func(r *webrick.Request) string { return r.QueryString }) + str("http_version", func(r *webrick.Request) string { return r.HTTPVersion.String() }) + str("host", func(r *webrick.Request) string { return r.Host() }) + str("content_type", func(r *webrick.Request) string { return r.ContentType() }) + str("body", func(r *webrick.Request) string { return string(r.Body) }) + + d("port", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(req(v).Port())) + }) + d("keep_alive?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(req(v).KeepAlive()) + }) + d("content_length", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + n, _ := req(v).ContentLength() + return object.IntValue(int64(n)) + }) + // #[](name) — a request header value, nil when absent (HTTPRequest#[]). + d("[]", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + if s, ok := req(v).Header(webrickStr(args[0])); ok { + return object.NewString(s) + } + return object.NilV + }) + // #query — the parsed query as a Hash of first values (HTTPRequest#query). + d("query", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + q := req(v).Query() + h := object.NewHash() + for _, k := range q.Order { + val, _ := q.Get(k) + h.Set(object.NewString(k), object.NewString(val)) + } + return h + }) + // #each { |name, value| … } — iterate the request headers (HTTPRequest#each). + d("each", func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + if blk == nil { + raise("LocalJumpError", "no block given (yield)") + } + req(v).EachHeader(func(name, value string) { + vm.callBlock(blk, []object.Value{object.NewString(name), object.NewString(value)}) + }) + return v + }) +} + +// registerWEBrickResponse installs WEBrick::HTTPResponse: HTTPResponse.new(config +// = nil) plus the status / header / body accessors a servlet fills in, and #to_s +// which runs setup_header + send_header + send_body and returns the wire bytes. +func (vm *VM) registerWEBrickResponse(cls *RClass) { + webrickSMethod(cls, "new", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + cfg := webrickConfig(webrickFirstOpt(args)) + return &WEBrickResponse{res: webrick.NewResponse(cfg)} + }) + + self := func(v object.Value) *webrick.Response { return v.(*WEBrickResponse).res } + d := func(name string, fn NativeFn) { cls.define(name, fn) } + + // #request=(req) — wire the request method / version / host into the response + // (the assignments HTTPServer#run makes before servicing). + d("request=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + self(v).SetRequest(webrickReq(args[0]).req) + return args[0] + }) + + d("status", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self(v).Status)) + }) + d("status=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetStatus(webrickInt(webrickArg(args))) + return args[0] + }) + d("reason_phrase", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).ReasonPhrase) + }) + d("status_line", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).StatusLine()) + }) + d("[]", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + if s, ok := self(v).Get(webrickStr(args[0])); ok { + return object.NewString(s) + } + return object.NilV + }) + d("[]=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + self(v).Set(webrickStr(args[0]), webrickStr(args[1])) + return args[1] + }) + d("content_type", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + if s, ok := self(v).Get("content-type"); ok { + return object.NewString(s) + } + return object.NilV + }) + d("content_type=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetContentType(webrickStr(webrickArg(args))) + return args[0] + }) + d("content_length=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetContentLength(webrickInt(webrickArg(args))) + return args[0] + }) + d("body", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(string(self(v).Body)) + }) + d("body=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).Body = []byte(webrickStr(webrickArg(args))) + return args[0] + }) + d("chunked?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).Chunked()) + }) + d("chunked=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetChunked(webrickArg(args).Truthy()) + return args[0] + }) + d("keep_alive?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).KeepAlive()) + }) + // #set_redirect(status, url) — set the redirect body / Location / status from a + // WEBrick::HTTPStatus redirect class (HTTPResponse#set_redirect; deterministic, + // it does not raise here — the response is left ready to send). + d("set_redirect", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + code := vm.webrickStatusCode(args[0]) + res := self(v) + res.SetStatus(code) + res.SetRedirect(webrick.NewStatus(code, ""), webrickStr(args[1])) + return object.NilV + }) + // #set_error(err) — render err (a WEBrick::HTTPStatus, else 500) as the default + // error page (HTTPResponse#set_error). + d("set_error", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetError(vm.webrickErrorStatus(webrickArg(args))) + return object.NilV + }) + // #to_s — the response wire bytes (setup_header + send_header + send_body). + d("to_s", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + b, err := self(v).Bytes() + if err != nil { + raise("WEBrick::HTTPResponse::InvalidHeader", "%s", err.Error()) + } + return object.NewString(string(b)) + }) +} + +// registerWEBrickServlet installs WEBrick::HTTPServlet and its AbstractServlet +// base class. A servlet is written as a subclass defining do_ methods and +// mounted with HTTPServer#mount; the do_ dispatch (HEAD -> GET, the +// OPTIONS Allow list, MethodNotAllowed for an unhandled verb) is the library's, +// run over the subclass's methods via the seam in webrick_bind.go. +func (vm *VM) registerWEBrickServlet(mod *RClass) { + hs := newClass("WEBrick::HTTPServlet", nil) + hs.isModule = true + mod.consts["HTTPServlet"] = hs + vm.consts["WEBrick::HTTPServlet"] = hs + + abs := newClass("WEBrick::HTTPServlet::AbstractServlet", vm.cObject) + hs.consts["AbstractServlet"] = abs + vm.consts["WEBrick::HTTPServlet::AbstractServlet"] = abs +} + +// registerWEBrickStatus installs WEBrick::HTTPStatus: the reason_phrase lookup, +// the category predicates (info? / success? / … / error?), and the Status +// exception family (Status < StandardError, the category classes, and the named +// leaf classes servlets raise, each carrying its code). A raised status is +// recovered in webrick_bind.go and rendered as the matching error response. +func (vm *VM) registerWEBrickStatus(mod *RClass) { + hs := newClass("WEBrick::HTTPStatus", nil) + hs.isModule = true + mod.consts["HTTPStatus"] = hs + vm.consts["WEBrick::HTTPStatus"] = hs + + // Module functions: reason_phrase(code) and the range predicates. + sm := func(name string, fn NativeFn) { hs.smethods[name] = &Method{name: name, owner: hs, native: fn} } + sm("reason_phrase", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(webrick.ReasonPhrase(webrickInt(webrickArg(args)))) + }) + pred := func(name string, fn func(int) bool) { + sm(name, func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.Bool(fn(webrickInt(webrickArg(args)))) + }) + } + pred("info?", webrick.IsInfo) + pred("success?", webrick.IsSuccess) + pred("redirect?", webrick.IsRedirect) + pred("error?", webrick.IsError) + pred("client_error?", webrick.IsClientError) + pred("server_error?", webrick.IsServerError) + + std := vm.consts["StandardError"].(*RClass) + mk := func(name string, super *RClass) *RClass { + cls := newClass("WEBrick::HTTPStatus::"+name, super) + hs.consts[name] = cls + vm.consts["WEBrick::HTTPStatus::"+name] = cls + return cls + } + + // The exception hierarchy: Status < StandardError, the five category classes, + // then the named leaf classes (each < its category) that carry a code. + status := mk("Status", std) + cats := map[string]*RClass{ + "Info": mk("Info", status), + "Success": mk("Success", status), + "Redirect": mk("Redirect", status), + "Error": mk("Error", status), + } + cats["ClientError"] = mk("ClientError", cats["Error"]) + cats["ServerError"] = mk("ServerError", cats["Error"]) + for _, def := range webrickStatusDefs { + mk(def.Name, cats[def.Cat]) + } + + // Status#code / #to_i / #reason_phrase read the code stamped on the raised + // class, resolved through the class hierarchy so a subclass still answers. + code := func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(vm.webrickCodeOf(v))) + } + status.define("code", code) + status.define("to_i", code) + status.define("reason_phrase", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(webrick.ReasonPhrase(vm.webrickCodeOf(v))) + }) +} diff --git a/internal/vm/webrick_bind.go b/internal/vm/webrick_bind.go new file mode 100644 index 00000000..429fc290 --- /dev/null +++ b/internal/vm/webrick_bind.go @@ -0,0 +1,365 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + + webrick "github.com/go-ruby-webrick/webrick" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the request-handler seam between rbgo's Ruby object graph and the +// interpreter-independent github.com/go-ruby-webrick/webrick core. The library +// owns the deterministic request parse, the response build, the status table and +// the longest-prefix mount dispatch; rbgo supplies the two things the engine +// leaves injectable — a mount_proc block and a servlet's do_ methods — +// and converts values at the boundary (a Ruby request/response wrapper each way). +// +// Threading model: unlike a threaded server (puma), the webrick core does no +// socket I/O and starts no goroutines — HTTPServer#service is driven on the VM +// goroutine while it holds the GVL, so the handler runs INLINE (vm.callBlock / +// vm.send) with no extra goroutine to leak and no GVL juggling. A handler that +// raises a WEBrick::HTTPStatus unwinds as a Go panic through the library's Go +// dispatch back to webrickService, which recovers it and renders the matching +// error response, exactly as WEBrick::HTTPServer#run rescues HTTPStatus::Status. + +// WEBrickServer is the Ruby wrapper around a webrick.HTTPServer (the mount +// registry + #service dispatch) plus the config it was built with. +type WEBrickServer struct { + srv *webrick.HTTPServer + cfg *webrick.Config +} + +func (s *WEBrickServer) ToS() string { return "#" } +func (s *WEBrickServer) Inspect() string { return "#" } +func (s *WEBrickServer) Truthy() bool { return true } + +// WEBrickRequest is the Ruby wrapper around a webrick.Request. req is nil until +// #parse runs (or the request originates from the service seam); cfg is the +// config #parse parses against. +type WEBrickRequest struct { + req *webrick.Request + cfg *webrick.Config +} + +func (r *WEBrickRequest) ToS() string { return "#" } +func (r *WEBrickRequest) Inspect() string { return "#" } +func (r *WEBrickRequest) Truthy() bool { return true } + +// WEBrickResponse is the Ruby wrapper around a webrick.Response — the mutable +// status/headers/body a servlet fills in, whose #to_s yields the wire bytes. +type WEBrickResponse struct { + res *webrick.Response +} + +func (r *WEBrickResponse) ToS() string { return "#" } +func (r *WEBrickResponse) Inspect() string { return "#" } +func (r *WEBrickResponse) Truthy() bool { return true } + +// webrickMethods is the fixed set of HTTP methods a servlet may handle via a +// do_ method, in the order they are probed when wiring a servlet. +var webrickMethods = []string{"GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"} + +// webrickProcHandler adapts a mount_proc Ruby block into a webrick.HandlerFunc: +// the library calls it (on the VM goroutine, inline) with the per-request Go +// Request/Response, which are wrapped as Ruby WEBrick::HTTPRequest / +// HTTPResponse (thin pointer holders, so the block's mutations land on the same +// response) and yielded to the block. The block's return value is ignored — a +// WEBrick handler communicates through res — so the handler reports nil. +func (vm *VM) webrickProcHandler(blk *Proc) webrick.HandlerFunc { + return func(req *webrick.Request, res *webrick.Response) *webrick.Status { + vm.callBlock(blk, []object.Value{&WEBrickRequest{req: req}, &WEBrickResponse{res: res}}) + return nil + } +} + +// webrickRubyServlet is the webrick.Servlet backing HTTPServer#mount: it holds +// either a fixed servlet instance or a servlet class instantiated per request +// (with the mount options), and dispatches by building a library AbstractServlet +// whose handlers call the instance's do_ methods — so the HEAD->GET +// aliasing, the OPTIONS Allow list and MethodNotAllowed all come from the +// library, over the Ruby methods. +type webrickRubyServlet struct { + vm *VM + inst object.Value // a pre-built servlet instance, or nil + cls *RClass // a servlet class instantiated per request, when inst is nil + opts []object.Value // the mount options passed to the class's #new +} + +// Service dispatches req to the Ruby servlet's do_ handler through a +// library AbstractServlet (see the type comment). +func (s *webrickRubyServlet) Service(req *webrick.Request, res *webrick.Response) *webrick.Status { + inst := s.inst + if inst == nil { + inst = s.vm.send(s.cls, "new", s.opts, nil) + } + abs := webrick.NewAbstractServlet() + for _, m := range webrickMethods { + if !s.vm.respondsTo(inst, "do_"+m) { + continue + } + mm := m + abs.Handle(m, func(rq *webrick.Request, rs *webrick.Response) *webrick.Status { + s.vm.send(inst, "do_"+mm, []object.Value{&WEBrickRequest{req: rq}, &WEBrickResponse{res: rs}}, nil) + return nil + }) + } + return abs.Service(req, res) +} + +// webrickServlet resolves HTTPServer#mount's servlet argument into a +// webrick.Servlet: a Class is instantiated per request (WEBrick's get_instance +// model, with the mount options as #new args), and any other value is used as a +// pre-built servlet instance. +func (vm *VM) webrickServlet(v object.Value, opts []object.Value) webrick.Servlet { + if cls, ok := v.(*RClass); ok { + return &webrickRubyServlet{vm: vm, cls: cls, opts: append([]object.Value(nil), opts...)} + } + return &webrickRubyServlet{vm: vm, inst: v} +} + +// webrickService is the Go port of HTTPServer#service driven by #run: it wires +// the request context into the response, routes the request to its servlet and, +// on a raised (returned or panicked) WEBrick::HTTPStatus, renders the matching +// error response. A non-status raise from a handler propagates untouched. +func (vm *VM) webrickService(s *WEBrickServer, req *WEBrickRequest, res *WEBrickResponse) { + defer func() { + if r := recover(); r != nil { + if st, ok := vm.webrickStatusFromPanic(r); ok { + res.res.SetError(st) + return + } + panic(r) + } + }() + if req.req == nil { + raise("RuntimeError", "WEBrick::HTTPRequest has not been parsed yet") + } + res.res.SetRequest(req.req) + if st := s.srv.Service(req.req, res.res); st != nil { + res.res.SetError(st) + } +} + +// webrickStatusDefs is the named WEBrick::HTTPStatus leaf exceptions this binding +// registers, each with its code and its category class (the exception's parent). +var webrickStatusDefs = []struct { + Name string + Code int + Cat string +}{ + {"OK", 200, "Success"}, + {"MovedPermanently", 301, "Redirect"}, + {"Found", 302, "Redirect"}, + {"NotModified", 304, "Redirect"}, + {"TemporaryRedirect", 307, "Redirect"}, + {"BadRequest", 400, "ClientError"}, + {"Forbidden", 403, "ClientError"}, + {"NotFound", 404, "ClientError"}, + {"MethodNotAllowed", 405, "ClientError"}, + {"LengthRequired", 411, "ClientError"}, + {"RequestEntityTooLarge", 413, "ClientError"}, + {"InternalServerError", 500, "ServerError"}, + {"NotImplemented", 501, "ServerError"}, +} + +// webrickCodeByClassName maps a registered status exception's fully-qualified +// class name to its code; webrickClassNameByCode is the inverse. Both are built +// once from webrickStatusDefs. +var ( + webrickCodeByClassName = map[string]int{} + webrickClassNameByCode = map[int]string{} +) + +func init() { + for _, d := range webrickStatusDefs { + name := "WEBrick::HTTPStatus::" + d.Name + webrickCodeByClassName[name] = d.Code + webrickClassNameByCode[d.Code] = name + } +} + +// webrickCodeFromValue resolves the status code carried by a WEBrick::HTTPStatus +// class or instance, walking the ancestor chain so a subclass still resolves. ok +// is false when the value is not (a subclass of) a coded status. +func (vm *VM) webrickCodeFromValue(v object.Value) (int, bool) { + cls, ok := v.(*RClass) + if !ok { + cls = vm.classOf(v) + } + for _, a := range vm.ancestors(cls) { + if code, ok := webrickCodeByClassName[a.name]; ok { + return code, true + } + } + return 0, false +} + +// webrickCodeOf returns the code of a status exception instance (Status#code), +// or 0 for a bare Status/category with no code of its own. +func (vm *VM) webrickCodeOf(v object.Value) int { + code, _ := vm.webrickCodeFromValue(v) + return code +} + +// webrickStatusCode returns the code of the status class/instance passed to +// #set_redirect, raising ArgumentError when it is not a WEBrick::HTTPStatus. +func (vm *VM) webrickStatusCode(v object.Value) int { + code, ok := vm.webrickCodeFromValue(v) + if !ok { + raise("ArgumentError", "expected a WEBrick::HTTPStatus, got %s", v.Inspect()) + } + return code +} + +// webrickErrorStatus maps #set_error's argument onto the error the library's +// SetError renders: a coded WEBrick::HTTPStatus becomes that status, anything +// else a plain error (which SetError turns into a 500 page). +func (vm *VM) webrickErrorStatus(v object.Value) error { + if code, ok := vm.webrickCodeFromValue(v); ok { + if st := webrick.NewStatus(code, ""); st != nil { + return st + } + } + return errors.New(v.ToS()) +} + +// webrickStatusFromPanic maps a recovered panic to the status a handler raised: a +// Ruby exception whose class (or an ancestor) is a coded WEBrick::HTTPStatus +// yields that status carrying the raise's message. Any other panic is not a +// status (ok=false) and is re-raised by the caller. +func (vm *VM) webrickStatusFromPanic(r any) (*webrick.Status, bool) { + re, ok := r.(RubyError) + if !ok { + return nil, false + } + if re.Obj != nil { + if code, ok := vm.webrickCodeFromValue(re.Obj); ok { + return webrick.NewStatus(code, re.Message), true + } + return nil, false + } + if code, ok := webrickCodeByClassName[re.Class]; ok { + return webrick.NewStatus(code, re.Message), true + } + return nil, false +} + +// webrickRaiseStatus raises the Ruby exception matching a *webrick.Status a parse +// returned: the mapped WEBrick::HTTPStatus class when the code is one this +// binding registers, else a plain RuntimeError carrying the status message. +func webrickRaiseStatus(st *webrick.Status) { + if name, ok := webrickClassNameByCode[st.Code]; ok { + raise(name, "%s", st.Error()) + } + raise("RuntimeError", "%s", st.Error()) +} + +// webrickConfig builds a *webrick.Config from an optional Ruby config Hash, +// honouring the deterministic keys the codec reads (:Port, :ServerName, +// :ServerSoftware); the networking keys are host seams and are ignored. +func webrickConfig(v object.Value) *webrick.Config { + cfg := webrick.DefaultConfig() + h, ok := v.(*object.Hash) + if !ok { + return cfg + } + for _, k := range h.Keys { + val, _ := h.Get(k) + switch webrickKey(k) { + case "Port": + cfg.Port = webrickInt(val) + case "ServerName": + cfg.ServerName = webrickStr(val) + case "ServerSoftware": + cfg.ServerSoftware = webrickStr(val) + } + } + return cfg +} + +// webrickConfigHash renders a *webrick.Config as the Hash HTTPServer#config +// returns, keyed by Symbol like a WEBrick config. +func webrickConfigHash(cfg *webrick.Config) *object.Hash { + h := object.NewHash() + h.Set(object.Symbol("Port"), object.IntValue(int64(cfg.Port))) + h.Set(object.Symbol("ServerName"), object.NewString(cfg.ServerName)) + h.Set(object.Symbol("ServerSoftware"), object.NewString(cfg.ServerSoftware)) + return h +} + +// webrickFirstOpt returns the optional first argument (a config Hash) or nil. +func webrickFirstOpt(args []object.Value) object.Value { + if len(args) == 0 { + return object.NilV + } + return args[0] +} + +// webrickArg returns the single required argument of a setter, raising +// ArgumentError when it was omitted. +func webrickArg(args []object.Value) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + return args[0] +} + +// webrickReq coerces a Ruby argument to a *WEBrickRequest, raising TypeError for +// anything else. +func webrickReq(v object.Value) *WEBrickRequest { + if r, ok := v.(*WEBrickRequest); ok { + return r + } + raise("TypeError", "expected a WEBrick::HTTPRequest, got %s", v.Inspect()) + panic("unreachable") +} + +// webrickRes coerces a Ruby argument to a *WEBrickResponse, raising TypeError for +// anything else. +func webrickRes(v object.Value) *WEBrickResponse { + if r, ok := v.(*WEBrickResponse); ok { + return r + } + raise("TypeError", "expected a WEBrick::HTTPResponse, got %s", v.Inspect()) + panic("unreachable") +} + +// webrickKey coerces a Ruby config key (Symbol or String) to its Go string name. +func webrickKey(v object.Value) string { + switch k := v.(type) { + case object.Symbol: + return string(k) + case *object.String: + return k.Str() + } + return v.ToS() +} + +// webrickStr coerces a Ruby argument to its String contents (String or Symbol). +func webrickStr(v object.Value) string { + switch n := v.(type) { + case *object.String: + return n.Str() + case object.Symbol: + return string(n) + } + return v.ToS() +} + +// webrickInt coerces a Ruby Integer argument to an int, raising TypeError for a +// non-Integer (a port / status / length must be an Integer). +func webrickInt(v object.Value) int { + if n, ok := v.(object.Integer); ok { + return int(n) + } + if n, ok := object.BigOf(v); ok { + return int(n.Int64()) + } + raise("TypeError", "expected an Integer, got %s", v.Inspect()) + panic("unreachable") +} diff --git a/internal/vm/webrick_internal_test.go b/internal/vm/webrick_internal_test.go new file mode 100644 index 00000000..1bbb9e49 --- /dev/null +++ b/internal/vm/webrick_internal_test.go @@ -0,0 +1,375 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bytes" + "io" + "math/big" + "strings" + "testing" + + webrick "github.com/go-ruby-webrick/webrick" + + "github.com/go-embedded-ruby/ruby/internal/compiler" + "github.com/go-embedded-ruby/ruby/internal/object" + "github.com/go-ruby-parser/parser" +) + +// webrickRun runs src on a fresh VM and returns its trimmed stdout, failing on a +// parse/compile/run error. +func webrickRun(t *testing.T, src string) string { + t.Helper() + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse: %v", err) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile: %v", err) + } + var buf bytes.Buffer + if _, err := New(&buf).Run(iseq); err != nil { + t.Fatalf("run: %v\n%s", err, buf.String()) + } + return strings.TrimRight(buf.String(), "\n") +} + +// webrickRunErr runs src on a fresh VM and returns the uncaught error's class, or +// "" if it ran clean. +func webrickRunErr(t *testing.T, src string) string { + t.Helper() + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse: %v", err) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile: %v", err) + } + _, rerr := New(io.Discard).Run(iseq) + if rerr == nil { + return "" + } + if i := strings.Index(rerr.Error(), ": "); i > 0 { + return rerr.Error()[:i] + } + return rerr.Error() +} + +// TestWEBrickValueTypes covers the wrappers' string/truthy surface and classOf. +func TestWEBrickValueTypes(t *testing.T) { + vm := New(io.Discard) + srv := &WEBrickServer{} + req := &WEBrickRequest{} + res := &WEBrickResponse{} + for _, c := range []interface { + ToS() string + Inspect() string + Truthy() bool + }{srv, req, res} { + if c.ToS() != c.Inspect() || !c.Truthy() { + t.Errorf("%T: ToS=%q Inspect=%q Truthy=%v", c, c.ToS(), c.Inspect(), c.Truthy()) + } + } + if vm.classOf(srv) != vm.consts["WEBrick::HTTPServer"] || + vm.classOf(req) != vm.consts["WEBrick::HTTPRequest"] || + vm.classOf(res) != vm.consts["WEBrick::HTTPResponse"] { + t.Fatal("classOf mismatch for WEBrick wrappers") + } +} + +// TestWEBrickServing drives the full request/response/mount/servlet/status +// surface through in-process #service dispatch (no listening socket). +func TestWEBrickServing(t *testing.T) { + src := ` +require "webrick" +srv = WEBrick::HTTPServer.new(Port: 8080, ServerName: "example.com", ServerSoftware: "test/1") +srv.mount_proc("/hello") do |req, res| + res.status = 200 + res.content_type = "text/plain" + res.body = "path=#{req.path} m=#{req.request_method} x=#{req.query["x"]} h=#{req["X-Test"]} miss=#{req["X-No"].inspect} v=#{req.http_version} ka=#{req.keep_alive?} host=#{req.host} port=#{req.port} sn=#{req.script_name} pi=#{req.path_info} uri=#{req.unparsed_uri} qs=#{req.query_string}" +end +srv.mount_proc("/echo") do |req, res| + res["X-Len"] = req.content_length.to_s + hs = [] + req.each { |k, v| hs << "#{k}=#{v}" } + res.body = req.body + " ct=" + req.content_type.to_s + " hdrs=" + hs.sort.join(",") +end +srv.mount_proc("/redir") { |req, res| res.set_redirect(WEBrick::HTTPStatus::Found, "/hello") } +srv.mount_proc("/boom") { |req, res| raise WEBrick::HTTPStatus::NotFound, "nope" } +srv.mount_proc("/chunk") { |req, res| res.chunked = true; res.body = "abc" } + +class MyServlet < WEBrick::HTTPServlet::AbstractServlet + def do_GET(req, res); res.body = "sget #{req.path}"; end + def do_POST(req, res); res.body = "spost"; end +end +srv.mount("/s", MyServlet) +srv.mount("/i", MyServlet.new) + +def run(srv, raw) + req = WEBrick::HTTPRequest.new + req.parse(raw) + res = WEBrick::HTTPResponse.new + srv.service(req, res) + res +end + +r = run(srv, "GET /hello?x=42 HTTP/1.1\r\nHost: h.example:9\r\nX-Test: yo\r\n\r\n") +puts "hello: #{r.status} #{r.reason_phrase} | #{r.body}" +puts "hello line: #{r.status_line.start_with?("HTTP/1.1 200 OK")} ka=#{r.keep_alive?} chunked=#{r.chunked?}" +r = run(srv, "POST /echo HTTP/1.1\r\nHost: h\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello") +puts "echo: #{r.status} | #{r.body} | xlen=#{r["X-Len"]} ctmiss=#{r.content_type.inspect}" +r = run(srv, "GET /redir HTTP/1.1\r\nHost: h\r\n\r\n") +puts "redir: #{r.status} loc=#{r["Location"]} body=#{r.body}" +r = run(srv, "GET /boom HTTP/1.1\r\nHost: h\r\n\r\n") +puts "boom: #{r.status} #{r.reason_phrase}" +r = run(srv, "GET /chunk HTTP/1.1\r\nHost: h\r\n\r\n") +puts "chunk: #{r.status} chunked=#{r.chunked?}" +r = run(srv, "GET /s/x HTTP/1.1\r\nHost: h\r\n\r\n") +puts "sget: #{r.status} #{r.body}" +r = run(srv, "POST /s HTTP/1.1\r\nHost: h\r\nContent-Length: 0\r\n\r\n") +puts "spost: #{r.status} #{r.body}" +r = run(srv, "DELETE /s HTTP/1.1\r\nHost: h\r\n\r\n") +puts "sdel: #{r.status} #{r.reason_phrase}" +r = run(srv, "OPTIONS /hello HTTP/1.1\r\nHost: h\r\n\r\n") +puts "opts: #{r.status} allow=#{r["Allow"]}" +r = run(srv, "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n") +puts "nowhere: #{r.status} #{r.reason_phrase}" +r = run(srv, "DELETE /hello HTTP/1.1\r\nHost: h\r\n\r\n") +puts "mna: #{r.status} #{r.reason_phrase}" +r = run(srv, "GET /i/y HTTP/1.1\r\nHost: h\r\n\r\n") +puts "inst: #{r.status} #{r.body}" +puts "toS: #{run(srv, "GET /s/x HTTP/1.1\r\nHost: h\r\n\r\n").to_s.start_with?("HTTP/1.1 200 OK")}" +puts "config: #{srv.config.inspect}" +puts "version: #{WEBrick::VERSION}" +puts "status: rp=#{WEBrick::HTTPStatus.reason_phrase(404)} i=#{WEBrick::HTTPStatus.info?(100)} s=#{WEBrick::HTTPStatus.success?(200)} rd=#{WEBrick::HTTPStatus.redirect?(302)} e=#{WEBrick::HTTPStatus.error?(404)} ce=#{WEBrick::HTTPStatus.client_error?(404)} se=#{WEBrick::HTTPStatus.server_error?(500)}" +begin; raise WEBrick::HTTPStatus::Forbidden, "no"; rescue => e; puts "rescue: #{e.class} code=#{e.code} to_i=#{e.to_i} rp=#{e.reason_phrase}"; end +srv.start; srv.run; srv.shutdown; srv.stop +srv.unmount("/i"); srv.umount("/s") + +# set_error with a coded status class and with a plain object. +res = WEBrick::HTTPResponse.new +res.set_error(WEBrick::HTTPStatus::BadRequest) +puts "seterr-coded: #{res.status}" +res2 = WEBrick::HTTPResponse.new +res2.set_error("boom") +puts "seterr-plain: #{res2.status}" + +# content_length= / body= / []= round-trips. +res3 = WEBrick::HTTPResponse.new +res3.content_length = 3 +res3.body = "xyz" +res3["X-K"] = "v" +res3.content_type = "text/html" +puts "resp: cl=#{res3["Content-Length"]} body=#{res3.body} k=#{res3["X-K"]} ct=#{res3.content_type} miss=#{res3["X-No"].inspect}" +` + got := webrickRun(t, src) + want := strings.Join([]string{ + "hello: 200 OK | path=/hello m=GET x=42 h=yo miss=nil v=1.1 ka=true host=h.example port=9 sn=/hello pi= uri=/hello?x=42 qs=x=42", + "hello line: true ka=true chunked=false", + "echo: 200 | hello ct=text/plain hdrs=content-length=5,content-type=text/plain,host=h | xlen=5 ctmiss=nil", + "redir: 302 loc=/hello body=/hello.", + "boom: 404 Not Found", + "chunk: 200 chunked=true", + "sget: 200 sget /s/x", + "spost: 200 spost", + "sdel: 405 Method Not Allowed", + "opts: 200 allow=GET,HEAD,OPTIONS,POST,PUT", + "nowhere: 404 Not Found", + "mna: 405 Method Not Allowed", + "inst: 200 sget /i/y", + "toS: true", + "config: {Port: 8080, ServerName: \"example.com\", ServerSoftware: \"test/1\"}", + "version: 1.9.2", + "status: rp=Not Found i=true s=true rd=true e=true ce=true se=true", + "rescue: WEBrick::HTTPStatus::Forbidden code=403 to_i=403 rp=Forbidden", + "seterr-coded: 400", + "seterr-plain: 500", + "resp: cl=3 body=xyz k=v ct=text/html miss=nil", + }, "\n") + if got != want { + t.Fatalf("webrick serving mismatch:\n got:\n%s\nwant:\n%s", got, want) + } +} + +// TestWEBrickRubyErrors covers the raising branches reachable from Ruby: the +// arity / missing-block / not-parsed / wrong-type / non-status-raise / malformed +// parse / not-a-status-redirect paths. +func TestWEBrickRubyErrors(t *testing.T) { + cases := []struct { + src string + want string + }{ + {`require "webrick"; WEBrick::HTTPServer.new.mount_proc("/x")`, "ArgumentError"}, // no block + {`require "webrick"; WEBrick::HTTPServer.new.mount("/x")`, "ArgumentError"}, // <2 args + {`require "webrick"; WEBrick::HTTPRequest.new.parse`, "ArgumentError"}, // parse no arg + {`require "webrick"; WEBrick::HTTPRequest.new.path`, "RuntimeError"}, // not parsed + {`require "webrick"; WEBrick::HTTPServer.new.service(1, 2)`, "TypeError"}, // bad req type + {`require "webrick" +r = WEBrick::HTTPRequest.new; r.parse("GET / HTTP/1.1\r\nHost: h\r\n\r\n") +WEBrick::HTTPServer.new.service(r, 2)`, "TypeError"}, // bad res type + {`require "webrick"; res = WEBrick::HTTPResponse.new; res.request = 5`, "TypeError"}, // request= wrong type + {`require "webrick" +srv = WEBrick::HTTPServer.new +srv.service(WEBrick::HTTPRequest.new, WEBrick::HTTPResponse.new)`, "RuntimeError"}, // unparsed req in service + {`require "webrick" +srv = WEBrick::HTTPServer.new +srv.mount_proc("/x") { |req, res| raise "generic" } +r = WEBrick::HTTPRequest.new; r.parse("GET /x HTTP/1.1\r\nHost: h\r\n\r\n") +srv.service(r, WEBrick::HTTPResponse.new)`, "RuntimeError"}, // non-status raise propagates + {`require "webrick"; WEBrick::HTTPRequest.new.parse("GET /x HTTP/9\r\n")`, "WEBrick::HTTPStatus::BadRequest"}, // malformed -> mapped status + {`require "webrick"; WEBrick::HTTPRequest.new.parse("")`, "RuntimeError"}, // EOF -> unmapped status + {`require "webrick"; WEBrick::HTTPResponse.new.set_redirect(42, "/x")`, "ArgumentError"}, // not a status + {`require "webrick"; WEBrick::HTTPResponse.new.set_redirect(WEBrick::HTTPStatus::Found)`, "ArgumentError"}, // <2 args + {`require "webrick" +res = WEBrick::HTTPResponse.new; res["X"] = "a\r\nb"; res.to_s`, "WEBrick::HTTPResponse::InvalidHeader"}, // CRLF header + {`require "webrick"; WEBrick::HTTPRequest.new.parse("GET / HTTP/1.1\r\nHost: h\r\n\r\n").each`, "LocalJumpError"}, // each no block + {`require "webrick"; WEBrick::HTTPResponse.new.status = "x"`, "TypeError"}, // non-integer status + } + for _, c := range cases { + if got := webrickRunErr(t, c.src); got != c.want { + t.Errorf("src %q: got %q want %q", c.src, got, c.want) + } + } +} + +// TestWEBrickArityDirect covers the native-level arity guards that Ruby call +// syntax cannot express (a bare setter with zero args, unmount with none, etc.). +func TestWEBrickArityDirect(t *testing.T) { + vm := New(io.Discard) + srv := &WEBrickServer{srv: webrick.NewHTTPServer(webrick.DefaultConfig()), cfg: webrick.DefaultConfig()} + srvCls := vm.consts["WEBrick::HTTPServer"].(*RClass) + reqCls := vm.consts["WEBrick::HTTPRequest"].(*RClass) + resCls := vm.consts["WEBrick::HTTPResponse"].(*RClass) + res := &WEBrickResponse{res: webrick.NewResponse(webrick.DefaultConfig())} + rq := &WEBrickRequest{req: mustParse(t)} + + expectRaise := func(cls, method string, m map[string]*Method, self object.Value, args []object.Value) { + defer func() { + r := recover() + re, ok := r.(RubyError) + if !ok || re.Class != cls { + t.Errorf("%s expected %s, got %#v", method, cls, r) + } + }() + m[method].native(vm, self, args, nil) + } + // Zero-arg guards. + expectRaise("ArgumentError", "mount_proc", srvCls.methods, srv, nil) + expectRaise("ArgumentError", "unmount", srvCls.methods, srv, nil) + expectRaise("ArgumentError", "umount", srvCls.methods, srv, nil) + expectRaise("ArgumentError", "service", srvCls.methods, srv, nil) + expectRaise("ArgumentError", "parse", reqCls.methods, rq, nil) + expectRaise("ArgumentError", "[]", reqCls.methods, rq, nil) + expectRaise("ArgumentError", "status=", resCls.methods, res, nil) + expectRaise("ArgumentError", "request=", resCls.methods, res, nil) + expectRaise("ArgumentError", "[]", resCls.methods, res, nil) + expectRaise("ArgumentError", "[]=", resCls.methods, res, []object.Value{object.NewString("k")}) + expectRaise("ArgumentError", "set_redirect", resCls.methods, res, nil) + expectRaise("ArgumentError", "content_type=", resCls.methods, res, nil) + expectRaise("ArgumentError", "content_length=", resCls.methods, res, nil) + expectRaise("ArgumentError", "body=", resCls.methods, res, nil) + expectRaise("ArgumentError", "chunked=", resCls.methods, res, nil) + expectRaise("ArgumentError", "reason_phrase", vm.consts["WEBrick::HTTPStatus"].(*RClass).smethods, object.NilV, nil) +} + +// mustParse builds a parsed request for the direct tests. +func mustParse(t *testing.T) *webrick.Request { + t.Helper() + req, st := webrick.ParseRequest([]byte("GET /p?a=1 HTTP/1.1\r\nHost: h\r\n\r\n"), webrick.DefaultConfig()) + if st != nil { + t.Fatalf("parse: %v", st) + } + return req +} + +// TestWEBrickHelpers exercises the pure conversion helpers and the panic->status +// mapper directly, including the fall-through branches Ruby cannot reach. +func TestWEBrickHelpers(t *testing.T) { + vm := New(io.Discard) + + // webrickStr: String, Symbol, other. + if webrickStr(object.NewString("s")) != "s" || webrickStr(object.Symbol("y")) != "y" || webrickStr(object.IntValue(3)) != "3" { + t.Fatal("webrickStr") + } + // webrickKey: Symbol, String, other. + if webrickKey(object.Symbol("P")) != "P" || webrickKey(object.NewString("Q")) != "Q" || webrickKey(object.IntValue(5)) != "5" { + t.Fatal("webrickKey") + } + // webrickInt: Integer, Bignum, non-int -> TypeError. + if webrickInt(object.IntValue(7)) != 7 { + t.Fatal("webrickInt int") + } + if webrickInt(&object.Bignum{I: big.NewInt(11)}) != 11 { + t.Fatal("webrickInt bignum") + } + func() { + defer func() { + if re, ok := recover().(RubyError); !ok || re.Class != "TypeError" { + t.Errorf("webrickInt non-int: %#v", recover()) + } + }() + webrickInt(object.NewString("x")) + }() + + // webrickFirstOpt: empty -> nil, non-empty -> first. + if !object.IsNil(webrickFirstOpt(nil)) || webrickFirstOpt([]object.Value{object.IntValue(1)}) != object.IntValue(1) { + t.Fatal("webrickFirstOpt") + } + + // webrickConfig: non-Hash default, and a Hash mixing Symbol/String/other keys. + if c := webrickConfig(object.NilV); c.Port != 80 { + t.Fatal("webrickConfig default") + } + h := object.NewHash() + h.Set(object.Symbol("Port"), object.IntValue(9)) + h.Set(object.NewString("ServerName"), object.NewString("n")) + h.Set(object.Symbol("ServerSoftware"), object.NewString("sw")) + h.Set(object.IntValue(1), object.NewString("ignored")) + c := webrickConfig(h) + if c.Port != 9 || c.ServerName != "n" || c.ServerSoftware != "sw" { + t.Fatalf("webrickConfig hash = %+v", c) + } + + // webrickCodeFromValue: class found, class not found, instance not found. + nf := vm.consts["WEBrick::HTTPStatus::NotFound"].(*RClass) + if code, ok := vm.webrickCodeFromValue(nf); !ok || code != 404 { + t.Fatal("codeFromValue class") + } + if _, ok := vm.webrickCodeFromValue(vm.cObject); ok { + t.Fatal("codeFromValue non-status class") + } + if _, ok := vm.webrickCodeFromValue(object.IntValue(1)); ok { + t.Fatal("codeFromValue non-status value") + } + + // webrickErrorStatus: coded -> *Status, non-coded -> plain error. + if st, ok := vm.webrickErrorStatus(nf).(*webrick.Status); !ok || st.Code != 404 { + t.Fatal("errorStatus coded") + } + if _, ok := vm.webrickErrorStatus(object.NewString("x")).(*webrick.Status); ok { + t.Fatal("errorStatus plain should not be *Status") + } + + // webrickStatusFromPanic: non-RubyError, Obj-status, Obj-non-status, Obj-nil + // class-match, Obj-nil no-match. + if _, ok := vm.webrickStatusFromPanic("not a ruby error"); ok { + t.Fatal("fromPanic non-RubyError") + } + inst := &RObject{class: nf} + if st, ok := vm.webrickStatusFromPanic(RubyError{Class: "X", Message: "m", Obj: inst}); !ok || st.Code != 404 { + t.Fatal("fromPanic Obj status") + } + if _, ok := vm.webrickStatusFromPanic(RubyError{Class: "X", Obj: object.IntValue(1)}); ok { + t.Fatal("fromPanic Obj non-status") + } + if st, ok := vm.webrickStatusFromPanic(RubyError{Class: "WEBrick::HTTPStatus::Forbidden"}); !ok || st.Code != 403 { + t.Fatal("fromPanic class-name match") + } + if _, ok := vm.webrickStatusFromPanic(RubyError{Class: "Foo"}); ok { + t.Fatal("fromPanic no match") + } +} From 8413594687c41682fcb38b74d0e26b7ae92bbac5 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:31:45 +0200 Subject: [PATCH 3/9] Bind net-imap (Net::IMAP) into rbgo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 1 + go.sum | 2 + internal/vm/builtins.go | 1 + internal/vm/net_imap.go | 405 +++++++++++++++ internal/vm/net_imap_bind.go | 719 ++++++++++++++++++++++++++ internal/vm/net_imap_internal_test.go | 692 +++++++++++++++++++++++++ internal/vm/object_model.go | 2 + internal/vm/require.go | 2 +- 8 files changed, 1823 insertions(+), 1 deletion(-) create mode 100644 internal/vm/net_imap.go create mode 100644 internal/vm/net_imap_bind.go create mode 100644 internal/vm/net_imap_internal_test.go diff --git a/go.mod b/go.mod index f56c9fd7..ae79f7a4 100644 --- a/go.mod +++ b/go.mod @@ -89,6 +89,7 @@ require ( github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f + github.com/go-ruby-net-imap/net-imap v0.0.0-20260630193116-82cd428e7b0f github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 github.com/go-ruby-observer/observer v0.0.0-20260630080708-c3a02da51f79 diff --git a/go.sum b/go.sum index a357f88a..e9488785 100644 --- a/go.sum +++ b/go.sum @@ -340,6 +340,8 @@ github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 h1:F+ghhcdLDBVGG github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7/go.mod h1:cm0D4pmaTvvQFPK815E7/uJaGeGxkr5xFLHixeQixTA= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f h1:nS0qwCPtVAtp+48j6KkdTdpVL8jT9L2ZGQcJZwOKev8= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f/go.mod h1:PiNQDuyrSPZX8OzApvgyn1fTjRNwx0KNO1vrzdAguEc= +github.com/go-ruby-net-imap/net-imap v0.0.0-20260630193116-82cd428e7b0f h1:rnyq1kTbCxqBQ0UlhaF4I2g+9TnmBLuMFTw4brsInaE= +github.com/go-ruby-net-imap/net-imap v0.0.0-20260630193116-82cd428e7b0f/go.mod h1:jKlWC6EDNkTFr5BDzA7+UAMFXsKLwIy5r9zKqxXSM+U= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e h1:v8DyCc3Vp+REhN1uyQ4wN8CpJ6JMqWNOm9OESWFBtLU= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e/go.mod h1:WsLJ6/u8/nzHShZKChf20tHosn/nlVnOJiD/xEY6X8c= github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 h1:iZFtIn5WHQRz/oa04qYAlLvTi0XDRP5dKPGpJTGRzKg= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..f5d264f5 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -478,6 +478,7 @@ func (vm *VM) bootstrap() { vm.registerSQLite3() // SQLite3::Database/Statement (require "sqlite3"), backed by go-ruby-sqlite3 (modernc); needs StandardError for SQLite3::Exception vm.registerRedis() // Redis client (require "redis"), backed by go-ruby-redis RESP codec; socket = injected IO seam; needs StandardError for Redis::BaseError vm.registerPG() // PG::Connection/Result (require "pg"), backed by go-ruby-pg v3 protocol; socket = injected IO seam; needs StandardError for PG::Error + vm.registerNetIMAP() // Net::IMAP (require "net/imap"), backed by go-ruby-net-imap builder+parser; socket = injected IO seam; after socket/openssl so a TCPSocket/SSLSocket can be the TLS seam vm.registerSidekiq() // Sidekiq::Client/Job(Worker) + module (require "sidekiq"), backed by go-ruby-sidekiq over go-redis/v9; Redis client dialled from Sidekiq.redis/ENV; perform body is Ruby seam; needs StandardError for Sidekiq::Error vm.registerResque() // Resque module + Job/Worker/Failure (require "resque"), backed by go-ruby-resque over go-redis/v9; @queue resolver + self.perform are Ruby seams; needs StandardError for Resque::Error vm.registerSequel() // Sequel query builder + Database (require "sequel"), backed by go-ruby-sequel; executor seam wired to SQLite3::Database (real execution) diff --git a/internal/vm/net_imap.go b/internal/vm/net_imap.go new file mode 100644 index 00000000..c7b37166 --- /dev/null +++ b/internal/vm/net_imap.go @@ -0,0 +1,405 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + imap "github.com/go-ruby-net-imap/net-imap" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerNetIMAP installs Net::IMAP (require "net/imap"): a live IMAP4rev1 +// session over the injected IO seam, driven by the command builder + response +// parser of github.com/go-ruby-net-imap/net-imap. It builds the Net::IMAP class, +// its error tree (Net::IMAP::Error and the response-error subclasses), the typed +// response value classes (TaggedResponse / UntaggedResponse / FetchData / +// Envelope / MailboxList / …), and the command surface (login / authenticate / +// select / list / fetch / store / search / append / …). It runs after +// registerSocket / registerOpenSSL so a real TCPSocket / SSLSocket can be handed +// in as the connection seam for TLS. +func (vm *VM) registerNetIMAP() { + // Net is always registered by registerNetHTTP, which runs earlier in the + // bootstrap (see builtins.go). + netMod := vm.consts["Net"].(*RClass) + cls := newClass("Net::IMAP", vm.cObject) + netMod.consts["IMAP"] = cls + vm.consts["Net::IMAP"] = cls + + vm.registerNetIMAPErrors(cls) + vm.registerNetIMAPValueClasses(cls) + vm.registerNetIMAPConstruct(cls) + vm.registerNetIMAPModuleMethods(cls) + vm.registerNetIMAPCommands(cls) +} + +// registerNetIMAPErrors installs the Net::IMAP error hierarchy, mirroring the +// net-imap gem: Error < StandardError; ResponseError < Error; NoResponseError / +// BadResponseError / ByeResponseError < ResponseError; ResponseParseError / +// DataFormatError < Error. +func (vm *VM) registerNetIMAPErrors(cls *RClass) { + std := vm.consts["StandardError"].(*RClass) + reg := func(simple string, super *RClass) *RClass { + qualified := "Net::IMAP::" + simple + c := newClass(qualified, super) + cls.consts[simple] = c + vm.consts[qualified] = c + return c + } + base := reg("Error", std) + respErr := reg("ResponseError", base) + reg("NoResponseError", respErr) + reg("BadResponseError", respErr) + reg("ByeResponseError", respErr) + reg("ResponseParseError", base) + reg("DataFormatError", base) +} + +// registerNetIMAPValueClasses installs the typed response value classes, each an +// attr-reader struct mirroring the Net::IMAP::* member set. Instances are built +// by the mapping helpers in net_imap_bind.go. +func (vm *VM) registerNetIMAPValueClasses(cls *RClass) { + def := func(simple string, fields ...string) *RClass { + return vm.imapValueClass(cls, simple, fields...) + } + def("TaggedResponse", "tag", "name", "data", "raw_data") + def("UntaggedResponse", "name", "data", "raw_data") + def("ContinuationRequest", "data", "raw_data") + def("ResponseText", "code", "text") + def("ResponseCode", "name", "data") + def("MailboxList", "attr", "delim", "name") + def("StatusData", "mailbox", "attr") + def("FetchData", "seqno", "attr") + def("Envelope", + "date", "subject", "from", "sender", "reply_to", + "to", "cc", "bcc", "in_reply_to", "message_id") + def("Address", "name", "route", "mailbox", "host") + def("AppendUIDData", "uidvalidity", "assigned_uids") + def("CopyUIDData", "uidvalidity", "source_uids", "assigned_uids") + + // The four body-structure classes share the reader surface over the loss-free + // field tree; #multipart? reports whether the part is a multipart/*. + for _, name := range []string{"BodyTypeText", "BodyTypeBasic", "BodyTypeMessage", "BodyTypeMultipart"} { + bc := def(name, + "media_type", "subtype", "param", "content_id", "description", + "encoding", "size", "lines", "parts") + bc.define("multipart?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@multipart") + }) + } +} + +// imapValueClass creates a Net::IMAP:: attr-reader class with the named +// readers and registers it under the IMAP class and the top-level const table. +func (vm *VM) imapValueClass(cls *RClass, simple string, fields ...string) *RClass { + qualified := "Net::IMAP::" + simple + c := newClass(qualified, vm.cObject) + cls.consts[simple] = c + vm.consts[qualified] = c + names := make([]object.Value, len(fields)) + for i, f := range fields { + names[i] = object.NewString(f) + } + defineAttrs(c, names, true, false) + return c +} + +// registerNetIMAPConstruct installs Net::IMAP.new: it builds a session over the +// injected IO-like connection seam and reads the server greeting. The connection +// is taken from the connection: / conn: keyword or a single positional IO +// argument (a StringIO, a TCPSocket, an OpenSSL::SSL::SSLSocket, or any object +// responding to #read/#write); rbgo has no implicit dialer here, so a missing +// connection raises ArgumentError. +func (vm *VM) registerNetIMAPConstruct(cls *RClass) { + cls.smethods["new"] = &Method{name: "new", owner: cls, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + conn := imapConnArg(args) + if conn == nil { + raise("ArgumentError", "Net::IMAP.new requires a connection: IO-like object (pass a TCPSocket/SSLSocket or any object responding to #read/#write)") + } + o := newIMAPObj(vm, cls, conn) + o.readGreeting(vm) + return o + }} +} + +// imapConnArg reads the connection seam from Net::IMAP.new's arguments: a trailing +// keyword Hash's connection: / conn:, else a single positional IO argument. +func imapConnArg(args []object.Value) object.Value { + if len(args) > 0 { + if h, ok := args[len(args)-1].(*object.Hash); ok { + if v, ok := h.Get(object.Symbol("connection")); ok { + return v + } + if v, ok := h.Get(object.Symbol("conn")); ok { + return v + } + args = args[:len(args)-1] + } + } + if len(args) > 0 { + if _, isStr := args[0].(*object.String); !isStr { + return args[0] + } + } + return nil +} + +// registerNetIMAPModuleMethods installs the stateless Net::IMAP module helpers +// (mailbox-name UTF-7 encode/decode and the date/message-set formatters) over +// the library's pure helper functions. +func (vm *VM) registerNetIMAPModuleMethods(cls *RClass) { + sm := func(name string, fn NativeFn) { + cls.smethods[name] = &Method{name: name, owner: cls, native: fn} + } + sm("encode_utf7", func(_ *VM, _ object.Value, a []object.Value, _ *Proc) object.Value { + return object.NewString(imap.EncodeUTF7(imapStrArg(imapArg1(a, "encode_utf7")))) + }) + sm("decode_utf7", func(_ *VM, _ object.Value, a []object.Value, _ *Proc) object.Value { + return object.NewString(imap.DecodeUTF7(imapStrArg(imapArg1(a, "decode_utf7")))) + }) +} + +// imapArg1 returns the sole argument, raising ArgumentError on a wrong count. +func imapArg1(args []object.Value, name string) object.Value { + if len(args) != 1 { + raise("ArgumentError", "wrong number of arguments for '%s' (given %d, expected 1)", name, len(args)) + } + return args[0] +} + +// registerNetIMAPCommands installs the Net::IMAP command surface. Each method +// builds its tagged command via the library builder, drives it over the seam, +// and maps the result: a status command returns its TaggedResponse; LIST / FETCH +// / SEARCH / EXPUNGE / STATUS return the typed untagged data. +func (vm *VM) registerNetIMAPCommands(cls *RClass) { + d := func(name string, fn NativeFn) { cls.define(name, fn) } + self := func(v object.Value) *IMAPObj { return v.(*IMAPObj) } + + // Session accessors. + d("greeting", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return imapNilGreeting(self(v).greeting) + }) + d("disconnected?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).disconnected) + }) + d("disconnect", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + o := self(v) + if !o.closed { + if vm.respondsTo(o.conn, "close") { + vm.send(o.conn, "close", nil, nil) + } + o.closed = true + } + o.disconnected = true + return object.NilV + }) + d("responses", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return imapResponsesHash(self(v)) + }) + + // Zero-argument status commands returning their TaggedResponse. + simple := func(build func(*imap.Builder) (imap.Command, error)) NativeFn { + return func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + o := self(v) + tagged, _ := o.imapExec(vm, mustCmd(build(o.builder))) + return vm.imapTagged(tagged) + } + } + d("noop", simple((*imap.Builder).Noop)) + d("logout", simple((*imap.Builder).Logout)) + d("check", simple((*imap.Builder).Check)) + d("close", simple((*imap.Builder).Close)) + d("unselect", simple((*imap.Builder).Unselect)) + d("starttls", simple((*imap.Builder).StartTLS)) + + // One-mailbox status commands. + mailboxCmd := func(name string, build func(*imap.Builder, string) (imap.Command, error)) { + d(name, func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + o := self(v) + tagged, _ := o.imapExec(vm, mustCmd(build(o.builder, imapStrArg(imapArg1(a, name))))) + return vm.imapTagged(tagged) + }) + } + mailboxCmd("select", (*imap.Builder).Select) + mailboxCmd("examine", (*imap.Builder).Examine) + mailboxCmd("create", (*imap.Builder).Create) + mailboxCmd("delete", (*imap.Builder).Delete) + mailboxCmd("subscribe", (*imap.Builder).Subscribe) + mailboxCmd("unsubscribe", (*imap.Builder).Unsubscribe) + + d("login", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + imapArity(a, 2, "login") + o := self(v) + tagged, _ := o.imapExec(vm, mustCmd(o.builder.Login(imapStrArg(a[0]), imapStrArg(a[1])))) + return vm.imapTagged(tagged) + }) + d("authenticate", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + imapArityMin(a, 1, "authenticate") + return vm.imapTagged(self(v).imapAuth(vm, imapStrArg(a[0]), a[1:])) + }) + d("rename", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + imapArity(a, 2, "rename") + o := self(v) + tagged, _ := o.imapExec(vm, mustCmd(o.builder.Rename(imapStrArg(a[0]), imapStrArg(a[1])))) + return vm.imapTagged(tagged) + }) + + // LIST / LSUB return an Array of MailboxList. + listCmd := func(name, respName string, build func(*imap.Builder, string, string) (imap.Command, error)) { + d(name, func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + imapArity(a, 2, name) + o := self(v) + _, untagged := o.imapExec(vm, mustCmd(build(o.builder, imapStrArg(a[0]), imapStrArg(a[1])))) + return vm.imapCollect(untagged, respName) + }) + } + listCmd("list", "LIST", (*imap.Builder).List) + listCmd("lsub", "LSUB", (*imap.Builder).Lsub) + + // STATUS returns the single StatusData (or nil). + d("status", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + imapArity(a, 2, "status") + o := self(v) + _, untagged := o.imapExec(vm, mustCmd(o.builder.Status(imapStrArg(a[0]), imapAtts(a[1])...))) + return imapFirst(vm.imapCollect(untagged, "STATUS")) + }) + + // FETCH / UID FETCH / STORE / UID STORE return an Array of FetchData. + d("fetch", vm.imapFetchCmd((*imap.Builder).Fetch)) + d("uid_fetch", vm.imapFetchCmd((*imap.Builder).UIDFetch)) + d("store", vm.imapStoreCmd((*imap.Builder).Store)) + d("uid_store", vm.imapStoreCmd((*imap.Builder).UIDStore)) + + // COPY / UID COPY return their TaggedResponse. + copyCmd := func(name string, build func(*imap.Builder, *imap.SequenceSet, string) (imap.Command, error)) { + d(name, func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + imapArity(a, 2, name) + o := self(v) + tagged, _ := o.imapExec(vm, mustCmd(build(o.builder, imapSeqSet(a[0]), imapStrArg(a[1])))) + return vm.imapTagged(tagged) + }) + } + copyCmd("copy", (*imap.Builder).Copy) + copyCmd("uid_copy", (*imap.Builder).UIDCopy) + + // SEARCH / UID SEARCH return an Array of message numbers. + searchCmd := func(name string, build func(*imap.Builder, ...imap.Argument) (imap.Command, error)) { + d(name, func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + o := self(v) + _, untagged := o.imapExec(vm, mustCmd(build(o.builder, imapSearchArgs(imapSearchKeys(a))...))) + return imapFirstOrEmpty(vm.imapCollect(untagged, "SEARCH")) + }) + } + searchCmd("search", (*imap.Builder).Search) + searchCmd("uid_search", (*imap.Builder).UIDSearch) + + // EXPUNGE returns the Array of expunged message numbers. + d("expunge", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + o := self(v) + _, untagged := o.imapExec(vm, mustCmd(o.builder.Expunge())) + return vm.imapCollect(untagged, "EXPUNGE") + }) + + // CAPABILITY returns the Array of capability tokens. + d("capability", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + o := self(v) + _, untagged := o.imapExec(vm, mustCmd(o.builder.Capability())) + return imapFirstOrEmpty(vm.imapCollect(untagged, "CAPABILITY")) + }) + + // APPEND(mailbox, message[, flags[, date]]) returns its TaggedResponse. + d("append", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + imapArityMin(a, 2, "append") + o := self(v) + var flags []imap.Flag + if len(a) >= 3 && !object.IsNil(a[2]) { + flags = imapFlags(a[2]) + } + tagged, _ := o.imapExec(vm, mustCmd(o.builder.Append(imapStrArg(a[0]), flags, nil, imapStrArg(a[1])))) + return vm.imapTagged(tagged) + }) +} + +// imapFetchCmd builds a FETCH-style command method over the given builder method +// (FETCH / UID FETCH), returning the Array of FetchData. +func (vm *VM) imapFetchCmd(build func(*imap.Builder, *imap.SequenceSet, ...string) (imap.Command, error)) NativeFn { + return func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + imapArity(a, 2, "fetch") + o := v.(*IMAPObj) + _, untagged := o.imapExec(vm, mustCmd(build(o.builder, imapSeqSet(a[0]), imapAtts(a[1])...))) + return vm.imapCollect(untagged, "FETCH") + } +} + +// imapStoreCmd builds a STORE-style command method over the given builder method +// (STORE / UID STORE), returning the Array of FetchData the server echoes. +func (vm *VM) imapStoreCmd(build func(*imap.Builder, *imap.SequenceSet, string, []imap.Flag) (imap.Command, error)) NativeFn { + return func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + imapArity(a, 3, "store") + o := v.(*IMAPObj) + _, untagged := o.imapExec(vm, mustCmd(build(o.builder, imapSeqSet(a[0]), imapStrArg(a[1]), imapFlags(a[2])))) + return vm.imapCollect(untagged, "FETCH") + } +} + +// imapSearchKeys flattens a single Array argument (imap.search([...])) to its +// elements, so both search("FROM", "a") and search(["FROM", "a"]) work. +func imapSearchKeys(a []object.Value) []object.Value { + if len(a) == 1 { + if arr, ok := a[0].(*object.Array); ok { + return arr.Elems + } + } + return a +} + +// imapNilGreeting returns the stored greeting, or nil when none was recorded. +func imapNilGreeting(g object.Value) object.Value { + if g == nil { + return object.NilV + } + return g +} + +// imapResponsesHash builds the Net::IMAP#responses Hash: name -> Array of the +// untagged data seen for it, in arrival order. +func imapResponsesHash(o *IMAPObj) object.Value { + h := object.NewHash() + for _, name := range o.responseNames { + vals := o.responses[name] + h.Set(object.NewString(name), object.NewArrayFromSlice(append([]object.Value(nil), vals...))) + } + return h +} + +// imapFirst returns the first element of arr, or nil when it is empty. +func imapFirst(arr *object.Array) object.Value { + if len(arr.Elems) == 0 { + return object.NilV + } + return arr.Elems[0] +} + +// imapFirstOrEmpty returns the first element of arr, or an empty Array when it is +// empty (SEARCH / CAPABILITY, which are always list-valued). +func imapFirstOrEmpty(arr *object.Array) object.Value { + if len(arr.Elems) == 0 { + return object.NewArrayFromSlice(nil) + } + return arr.Elems[0] +} + +// imapArity raises ArgumentError when args does not have exactly n elements. +func imapArity(args []object.Value, n int, name string) { + if len(args) != n { + raise("ArgumentError", "wrong number of arguments for '%s' (given %d, expected %d)", name, len(args), n) + } +} + +// imapArityMin raises ArgumentError when args has fewer than n elements. +func imapArityMin(args []object.Value, n int, name string) { + if len(args) < n { + raise("ArgumentError", "wrong number of arguments for '%s' (given %d, expected %d+)", name, len(args), n) + } +} diff --git a/internal/vm/net_imap_bind.go b/internal/vm/net_imap_bind.go new file mode 100644 index 00000000..accf8c8d --- /dev/null +++ b/internal/vm/net_imap_bind.go @@ -0,0 +1,719 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bufio" + "encoding/base64" + "io" + "strconv" + "strings" + + imap "github.com/go-ruby-net-imap/net-imap" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the thin binding between rbgo's Ruby object graph (object.Value) +// and the interpreter-independent command builder + response parser of +// github.com/go-ruby-net-imap/net-imap. The IMAP4rev1 command encoding and the +// response grammar live in that library; rbgo only supplies the host seam — the +// socket / TLS byte transport — and maps Ruby command arguments onto the +// library's builder and the parsed responses back into the object graph +// (TaggedResponse / UntaggedResponse / FetchData / Envelope / MailboxList / … as +// Net::IMAP::* Ruby objects). +// +// Like the redis / pg bindings, the transport is an injected Ruby IO-like seam: +// any object responding to #read and #write (a StringIO, or rbgo's own +// TCPSocket / OpenSSL::SSL::SSLSocket — both respond to #read/#write, so a real +// networked Net::IMAP is Net::IMAP.new(connection: TCPSocket.new(host, port))). +// rubyConn (redis_bind.go) bridges that object to an io.ReadWriter; a bufio +// wrapper drives the library Reader's line / literal callbacks. + +// IMAPObj is the Ruby wrapper around a live IMAP session (Net::IMAP). It owns no +// socket: it drives the builder + parser over the injected rubyConn seam, +// assembling and parsing responses as commands are issued. +type IMAPObj struct { + cls *RClass + builder *imap.Builder + reader *imap.Reader + // conn is the Ruby IO-like object backing the seam, kept so #disconnect can + // close it and so the object stays reachable. + conn object.Value + rc *rubyConn + br *bufio.Reader + // greeting is the server's initial untagged response (Net::IMAP#greeting). + greeting object.Value + // responses records the untagged responses seen, keyed by name and preserving + // arrival order, mirroring Net::IMAP#responses. + responses map[string][]object.Value + responseNames []string + disconnected bool + closed bool +} + +func (o *IMAPObj) ToS() string { return "#" } +func (o *IMAPObj) Inspect() string { return "#" } +func (o *IMAPObj) Truthy() bool { return true } + +// newIMAPObj builds an IMAP session over the injected connection seam and wires +// the library Reader to the buffered line / literal readers. +func newIMAPObj(vm *VM, cls *RClass, conn object.Value) *IMAPObj { + rc := &rubyConn{vm: vm, obj: conn} + o := &IMAPObj{ + cls: cls, + builder: imap.NewBuilder("RUBY"), + conn: conn, + rc: rc, + br: bufio.NewReader(rc), + responses: map[string][]object.Value{}, + } + o.reader = imap.NewReader(o.readLine, o.readLiteral) + return o +} + +// readLine reads the next CRLF-terminated line (including the terminator) from +// the seam, the library Reader's ReadLineFunc. +func (o *IMAPObj) readLine() (string, error) { + return o.br.ReadString('\n') +} + +// readLiteral reads exactly n literal bytes from the seam, the library Reader's +// ReadLiteralFunc. +func (o *IMAPObj) readLiteral(n int) (string, error) { + buf := make([]byte, n) + if _, err := io.ReadFull(o.br, buf); err != nil { + return "", err + } + return string(buf), nil +} + +// write sends s to the seam (the client command bytes). +func (o *IMAPObj) write(s string) { + _, _ = o.rc.Write([]byte(s)) +} + +// --- protocol driver ------------------------------------------------------- + +// mustCmd unwraps a builder result, raising Net::IMAP::DataFormatError for an +// argument the command encoder rejects. +func mustCmd(cmd imap.Command, err error) imap.Command { + if err != nil { + raise("Net::IMAP::DataFormatError", "%s", err.Error()) + } + return cmd +} + +// readOne reads and parses one response off the seam, raising the gem-faithful +// error for a transport fault (Net::IMAP::Error) or a malformed response +// (Net::IMAP::ResponseParseError). +func (o *IMAPObj) readOne() imap.Response { + resp, err := o.reader.ReadResponse() + if err != nil { + o.disconnected = true + if strings.Contains(err.Error(), "parse error") { + raise("Net::IMAP::ResponseParseError", "%s", err.Error()) + } + raise("Net::IMAP::Error", "%s", err.Error()) + } + return resp +} + +// imapExec writes a built command — interleaving each literal segment after the +// server's continuation request — then reads responses until the tagged response +// with cmd.Tag arrives. It returns that tagged response and the untagged +// responses collected on the way, raising for a NO / BAD tagged status. +func (o *IMAPObj) imapExec(vm *VM, cmd imap.Command) (*imap.TaggedResponse, []*imap.UntaggedResponse) { + o.write(cmd.Bytes) + for _, seg := range cmd.Literals { + o.awaitContinuation() + o.write(seg.Data) + o.write(seg.Tail) + } + return o.readUntilTagged(vm, cmd.Tag) +} + +// awaitContinuation reads one response, requiring it to be a continuation +// request (the server's go-ahead for a literal or SASL step). +func (o *IMAPObj) awaitContinuation() *imap.ContinuationRequest { + cr, ok := o.readOne().(*imap.ContinuationRequest) + if !ok { + raise("Net::IMAP::ResponseParseError", "expected continuation request") + } + return cr +} + +// readUntilTagged reads and records responses until the tagged response with tag +// arrives, returning it plus the untagged responses gathered. +func (o *IMAPObj) readUntilTagged(vm *VM, tag string) (*imap.TaggedResponse, []*imap.UntaggedResponse) { + var untagged []*imap.UntaggedResponse + for { + switch r := o.readOne().(type) { + case *imap.TaggedResponse: + if r.Tag == tag { + o.checkStatus(r) + return r, untagged + } + case *imap.UntaggedResponse: + untagged = append(untagged, r) + o.recordUntagged(vm, r) + if r.Name == "BYE" { + o.disconnected = true + } + case *imap.ContinuationRequest: + raise("Net::IMAP::ResponseParseError", "unexpected continuation request") + } + } +} + +// checkStatus raises the gem-faithful error for a NO (NoResponseError) or BAD +// (BadResponseError) tagged status; OK (and any other) returns cleanly. +func (o *IMAPObj) checkStatus(r *imap.TaggedResponse) { + switch r.Name { + case "NO": + raise("Net::IMAP::NoResponseError", "%s", respTextStr(r.Data)) + case "BAD": + raise("Net::IMAP::BadResponseError", "%s", respTextStr(r.Data)) + } +} + +// recordUntagged appends the mapped Data of an untagged response to the +// responses record, preserving arrival order (Net::IMAP#responses). +func (o *IMAPObj) recordUntagged(vm *VM, r *imap.UntaggedResponse) { + if _, ok := o.responses[r.Name]; !ok { + o.responseNames = append(o.responseNames, r.Name) + } + o.responses[r.Name] = append(o.responses[r.Name], vm.imapDataValue(r.Data)) +} + +// readGreeting reads the server's opening untagged response, storing it as the +// greeting; a BYE greeting marks the session disconnected and raises +// ByeResponseError, a non-untagged greeting raises ResponseParseError. +func (o *IMAPObj) readGreeting(vm *VM) { + u, ok := o.readOne().(*imap.UntaggedResponse) + if !ok { + raise("Net::IMAP::ResponseParseError", "invalid greeting") + } + o.greeting = vm.imapUntagged(u) + o.recordUntagged(vm, u) + if u.Name == "BYE" { + o.disconnected = true + raise("Net::IMAP::ByeResponseError", "%s", untaggedText(u)) + } +} + +// --- response value model -> Ruby ------------------------------------------ + +// imapDataValue maps an untagged response's Data payload to a Ruby value, per +// the library's documented per-name Data types. +func (vm *VM) imapDataValue(d any) object.Value { + switch v := d.(type) { + case int64: + return object.IntValue(v) + case string: + return object.NewString(v) + case []int64: + return imapIntArray(v) + case []string: + return imapStrArray(v) + case []imap.Flag: + return vm.imapFlagArray(v) + case *imap.ResponseText: + return vm.imapRespText(v) + case *imap.MailboxList: + return vm.imapMailboxList(v) + case *imap.StatusData: + return vm.imapStatusData(v) + case *imap.FetchData: + return vm.imapFetchData(v) + default: + return object.NilV + } +} + +// imapUntagged builds a Net::IMAP::UntaggedResponse. +func (vm *VM) imapUntagged(r *imap.UntaggedResponse) object.Value { + return vm.imapStruct("Net::IMAP::UntaggedResponse", map[string]object.Value{ + "name": object.NewString(r.Name), + "data": vm.imapDataValue(r.Data), + "raw_data": object.NewString(r.RawData), + }) +} + +// imapTagged builds a Net::IMAP::TaggedResponse. +func (vm *VM) imapTagged(r *imap.TaggedResponse) object.Value { + return vm.imapStruct("Net::IMAP::TaggedResponse", map[string]object.Value{ + "tag": object.NewString(r.Tag), + "name": object.NewString(r.Name), + "data": vm.imapRespText(r.Data), + "raw_data": object.NewString(r.RawData), + }) +} + +// imapRespText builds a Net::IMAP::ResponseText (nil -> nil). +func (vm *VM) imapRespText(t *imap.ResponseText) object.Value { + if t == nil { + return object.NilV + } + var code object.Value = object.NilV + if t.Code != nil { + code = vm.imapRespCode(t.Code) + } + return vm.imapStruct("Net::IMAP::ResponseText", map[string]object.Value{ + "code": code, + "text": object.NewString(t.Text), + }) +} + +// imapRespCode builds a Net::IMAP::ResponseCode. +func (vm *VM) imapRespCode(c *imap.ResponseCode) object.Value { + return vm.imapStruct("Net::IMAP::ResponseCode", map[string]object.Value{ + "name": object.NewString(c.Name), + "data": vm.imapCodeData(c.Data), + }) +} + +// imapCodeData maps a resp-text-code's Data (per respTextCode's grammar) to Ruby. +func (vm *VM) imapCodeData(d any) object.Value { + switch v := d.(type) { + case int64: + return object.IntValue(v) + case string: + return object.NewString(v) + case []string: + return imapStrArray(v) + case []imap.Flag: + return vm.imapFlagArray(v) + case *imap.AppendUIDData: + return vm.imapStruct("Net::IMAP::AppendUIDData", map[string]object.Value{ + "uidvalidity": object.IntValue(v.UIDValidity), + "assigned_uids": object.NewString(v.AssignedUIDs), + }) + case *imap.CopyUIDData: + return vm.imapStruct("Net::IMAP::CopyUIDData", map[string]object.Value{ + "uidvalidity": object.IntValue(v.UIDValidity), + "source_uids": object.NewString(v.SourceUIDs), + "assigned_uids": object.NewString(v.AssignedUIDs), + }) + default: + return object.NilV + } +} + +// imapMailboxList builds a Net::IMAP::MailboxList (LIST / LSUB data). +func (vm *VM) imapMailboxList(m *imap.MailboxList) object.Value { + return vm.imapStruct("Net::IMAP::MailboxList", map[string]object.Value{ + "attr": vm.imapFlagArray(m.Attr), + "delim": imapStrOrNil(m.Delim), + "name": object.NewString(m.Name), + }) +} + +// imapStatusData builds a Net::IMAP::StatusData (STATUS data); attr is an +// insertion-ordered Hash of item -> Integer. +func (vm *VM) imapStatusData(m *imap.StatusData) object.Value { + h := object.NewHash() + for _, k := range m.Attr.Keys() { + v, _ := m.Attr.Get(k) + h.Set(object.NewString(k), object.IntValue(v)) + } + return vm.imapStruct("Net::IMAP::StatusData", map[string]object.Value{ + "mailbox": object.NewString(m.Mailbox), + "attr": h, + }) +} + +// imapFetchData builds a Net::IMAP::FetchData (FETCH data); attr is an +// insertion-ordered Hash of attribute name -> parsed value. +func (vm *VM) imapFetchData(m *imap.FetchData) object.Value { + h := object.NewHash() + for _, k := range m.Attr.Keys() { + v, _ := m.Attr.Get(k) + h.Set(object.NewString(k), vm.imapAttrValue(v)) + } + return vm.imapStruct("Net::IMAP::FetchData", map[string]object.Value{ + "seqno": object.IntValue(m.Seqno), + "attr": h, + }) +} + +// imapAttrValue maps one FETCH attribute value to Ruby, per fetch.go's per-att +// value types. +func (vm *VM) imapAttrValue(val any) object.Value { + switch v := val.(type) { + case int64: + return object.IntValue(v) + case string: + return object.NewString(v) + case []imap.Flag: + return vm.imapFlagArray(v) + case *imap.Envelope: + return vm.imapEnvelope(v) + case *imap.BodyStructure: + return vm.imapBodyStructure(v) + default: + return object.NilV + } +} + +// imapEnvelope builds a Net::IMAP::Envelope; string members are nil for a NIL +// wire form, address-list members nil for a NIL list. +func (vm *VM) imapEnvelope(e *imap.Envelope) object.Value { + return vm.imapStruct("Net::IMAP::Envelope", map[string]object.Value{ + "date": imapStrOrNil(e.Date), + "subject": imapStrOrNil(e.Subject), + "from": vm.imapAddressList(e.From), + "sender": vm.imapAddressList(e.Sender), + "reply_to": vm.imapAddressList(e.ReplyTo), + "to": vm.imapAddressList(e.To), + "cc": vm.imapAddressList(e.Cc), + "bcc": vm.imapAddressList(e.Bcc), + "in_reply_to": imapStrOrNil(e.InReplyTo), + "message_id": imapStrOrNil(e.MessageID), + }) +} + +// imapAddressList maps an ENVELOPE address list to an Array of Net::IMAP::Address +// (nil for a NIL list). +func (vm *VM) imapAddressList(as []imap.Address) object.Value { + if as == nil { + return object.NilV + } + elems := make([]object.Value, len(as)) + for i, a := range as { + elems[i] = vm.imapAddress(a) + } + return object.NewArrayFromSlice(elems) +} + +// imapAddress builds a Net::IMAP::Address; a "" field maps to nil (a NIL wire +// form). +func (vm *VM) imapAddress(a imap.Address) object.Value { + return vm.imapStruct("Net::IMAP::Address", map[string]object.Value{ + "name": imapStrOrNil(a.Name), + "route": imapStrOrNil(a.Route), + "mailbox": imapStrOrNil(a.Mailbox), + "host": imapStrOrNil(a.Host), + }) +} + +// imapBodyStructure builds the Net::IMAP body-structure object of the class the +// library's BodyType() reports (BodyTypeMultipart / BodyTypeMessage / +// BodyTypeText / BodyTypeBasic) over the loss-free field tree. +func (vm *VM) imapBodyStructure(b *imap.BodyStructure) object.Value { + qualified := "Net::IMAP::" + b.BodyType() + if b.Multipart { + parts := make([]object.Value, len(b.Parts)) + for i, p := range b.Parts { + parts[i] = vm.imapBodyStructure(p) + } + return vm.imapStruct(qualified, map[string]object.Value{ + "media_type": object.NewString("MULTIPART"), + "subtype": object.NewString(b.MultipartSubtype), + "parts": object.NewArrayFromSlice(parts), + "param": object.NilV, + "multipart": object.Bool(true), + }) + } + return vm.imapStruct(qualified, map[string]object.Value{ + "media_type": object.NewString(b.MediaType), + "subtype": object.NewString(b.Subtype), + "param": vm.imapParams(b.Params), + "content_id": imapStrOrNil(b.ContentID), + "description": imapStrOrNil(b.Description), + "encoding": imapStrOrNil(b.Encoding), + "size": object.IntValue(b.Size), + "lines": object.IntValue(b.Lines), + "multipart": object.Bool(false), + }) +} + +// imapParams maps a body-fld-param OrderedAttr to a Ruby Hash (nil -> nil). +func (vm *VM) imapParams(o *imap.OrderedAttr) object.Value { + if o == nil { + return object.NilV + } + h := object.NewHash() + for _, k := range o.Keys() { + v, _ := o.Get(k) + h.Set(object.NewString(k), vm.imapAttrValue(v)) + } + return h +} + +// imapFlagArray maps a flag list to a Ruby Array of Symbols (the way MRI reports +// system flags, e.g. :Seen). +func (vm *VM) imapFlagArray(fs []imap.Flag) object.Value { + elems := make([]object.Value, len(fs)) + for i, f := range fs { + elems[i] = object.Symbol(string(f)) + } + return object.NewArrayFromSlice(elems) +} + +// imapIntArray maps an int64 slice to a Ruby Array of Integer. +func imapIntArray(ns []int64) object.Value { + elems := make([]object.Value, len(ns)) + for i, n := range ns { + elems[i] = object.IntValue(n) + } + return object.NewArrayFromSlice(elems) +} + +// imapStrArray maps a string slice to a Ruby Array of String. +func imapStrArray(ss []string) object.Value { + elems := make([]object.Value, len(ss)) + for i, s := range ss { + elems[i] = object.NewString(s) + } + return object.NewArrayFromSlice(elems) +} + +// imapStrOrNil maps a string to a Ruby String, or nil for the "" the library +// uses to denote a NIL wire form. +func imapStrOrNil(s string) object.Value { + if s == "" { + return object.NilV + } + return object.NewString(s) +} + +// imapStruct instantiates a Net::IMAP::* value class and seeds its instance +// variables from fields (each keyed by its bare reader name). +func (vm *VM) imapStruct(qualified string, fields map[string]object.Value) object.Value { + obj := &RObject{class: vm.consts[qualified].(*RClass), ivars: map[string]object.Value{}} + for k, v := range fields { + obj.ivars["@"+k] = v + } + return obj +} + +// imapCollect maps the untagged responses named name to an Array of their mapped +// Data values (the per-command result set for LIST / FETCH / EXPUNGE / …). +func (vm *VM) imapCollect(untagged []*imap.UntaggedResponse, name string) *object.Array { + var elems []object.Value + for _, u := range untagged { + if u.Name == name { + elems = append(elems, vm.imapDataValue(u.Data)) + } + } + return object.NewArrayFromSlice(elems) +} + +// --- Ruby argument coercion ------------------------------------------------ + +// respTextStr returns the human text of a status response's ResponseText, or "". +func respTextStr(t *imap.ResponseText) string { + if t == nil { + return "" + } + return t.Text +} + +// untaggedText returns the text of an untagged status response (OK/NO/BYE/…). +func untaggedText(r *imap.UntaggedResponse) string { + if rt, ok := r.Data.(*imap.ResponseText); ok { + return rt.Text + } + return "" +} + +// imapStrArg coerces a Ruby value to a Go string (String verbatim, else to_s). +func imapStrArg(v object.Value) string { + if s, ok := v.(*object.String); ok { + return s.Str() + } + return v.ToS() +} + +// imapFlags coerces a Ruby value (a single flag or an Array of flags, each a +// Symbol or String) to a []imap.Flag. +func imapFlags(v object.Value) []imap.Flag { + if arr, ok := v.(*object.Array); ok { + out := make([]imap.Flag, len(arr.Elems)) + for i, e := range arr.Elems { + out[i] = imap.Flag(imapStrArg(e)) + } + return out + } + return []imap.Flag{imap.Flag(imapStrArg(v))} +} + +// imapAtts coerces a Ruby value (a single attribute or an Array of them, each a +// Symbol or String) to a []string for a FETCH / STATUS attribute list. +func imapAtts(v object.Value) []string { + if arr, ok := v.(*object.Array); ok { + out := make([]string, len(arr.Elems)) + for i, e := range arr.Elems { + out[i] = imapStrArg(e) + } + return out + } + return []string{imapStrArg(v)} +} + +// imapSearchArgs coerces the Ruby SEARCH keys to library arguments: an Integer is +// a number, an Array is a sequence set, and anything else (String / Symbol) is a +// raw search-key token. +func imapSearchArgs(vals []object.Value) []imap.Argument { + out := make([]imap.Argument, len(vals)) + for i, v := range vals { + switch x := v.(type) { + case object.Integer: + out[i] = int64(x) + case *object.Array: + out[i] = imapSeqSet(v) + default: + out[i] = imapStrArg(v) + } + } + return out +} + +// imapSeqSet coerces a Ruby sequence-set argument to a *imap.SequenceSet. It +// accepts an Integer (0 or "*" meaning the last message), a Range (a..b, a.. for +// n:*), an Array of Integers / Ranges, and a String ("1", "1:3", "1,3:5,7:*"). +// It raises Net::IMAP::DataFormatError for an out-of-range value. +func imapSeqSet(v object.Value) *imap.SequenceSet { + items := imapSeqItems(v) + ss, err := imap.NewSequenceSet(items...) + if err != nil { + raise("Net::IMAP::DataFormatError", "%s", err.Error()) + } + return ss +} + +// imapSeqItems flattens a Ruby sequence-set argument to the []any NewSequenceSet +// consumes. +func imapSeqItems(v object.Value) []any { + switch x := v.(type) { + case object.Integer: + return []any{int64(x)} + case *object.Range: + return []any{imapSeqRange(x)} + case *object.Array: + var out []any + for _, e := range x.Elems { + out = append(out, imapSeqItems(e)...) + } + return out + case *object.String: + return imapSeqStringItems(x.Str()) + default: + return []any{int64(0)} + } +} + +// imapSeqRange maps a Ruby Range to an imap.SeqRange; a nil (endless) upper bound +// becomes "*". +func imapSeqRange(r *object.Range) imap.SeqRange { + lo := imapSeqBound(r.Lo) + hi := imapSeqBound(r.Hi) + return imap.SeqRange{Lo: lo, Hi: hi} +} + +// imapSeqBound reads a Range endpoint as an int64 sequence number (nil -> "*"). +func imapSeqBound(v object.Value) int64 { + if n, ok := v.(object.Integer); ok { + return int64(n) + } + return imap.Star +} + +// imapSeqStringItems parses a sequence-set string ("1", "1:3", "1,3:5,7:*") into +// NewSequenceSet items; "*" maps to the library's Star. +func imapSeqStringItems(s string) []any { + var out []any + for _, part := range strings.Split(s, ",") { + if lo, hi, ok := strings.Cut(part, ":"); ok { + out = append(out, imap.SeqRange{Lo: imapSeqNum(lo), Hi: imapSeqNum(hi)}) + } else { + out = append(out, imapSeqNum(part)) + } + } + return out +} + +// imapSeqNum parses one sequence-set token ("*" -> Star, digits -> the number, +// anything else -> Star as a lenient fallback). +func imapSeqNum(tok string) int64 { + tok = strings.TrimSpace(tok) + if tok == "*" { + return imap.Star + } + if n, err := strconv.ParseInt(tok, 10, 64); err == nil { + return n + } + return imap.Star +} + +// imapAuth drives an AUTHENTICATE exchange for the named SASL mechanism over the +// continuation seam, using the library's pure SASL encoders. It returns the +// tagged response, raising ArgumentError for an unsupported mechanism. +func (o *IMAPObj) imapAuth(vm *VM, mechanism string, creds []object.Value) *imap.TaggedResponse { + cmd := mustCmd(o.builder.Authenticate(mechanism)) + o.write(cmd.Bytes) + switch strings.ToUpper(mechanism) { + case "PLAIN": + authz, user, pass := imapPlainCreds(creds) + o.awaitContinuation() + o.write(imap.SASLEncode(imap.SASLPlain(authz, user, pass)) + imap.CRLF) + case "LOGIN": + user, pass := imapUserPass(creds) + o.awaitContinuation() + o.write(imap.SASLEncode(imap.SASLLoginUser(user)) + imap.CRLF) + o.awaitContinuation() + o.write(imap.SASLEncode(imap.SASLLoginPassword(pass)) + imap.CRLF) + case "CRAM-MD5": + user, pass := imapUserPass(creds) + cr := o.awaitContinuation() + challenge := imapDecodeChallenge(cr) + o.write(imap.SASLEncode(imap.SASLCramMD5(user, pass, challenge)) + imap.CRLF) + case "XOAUTH2": + user, token := imapUserPass(creds) + o.awaitContinuation() + o.write(imap.SASLEncode(imap.SASLXOAuth2(user, token)) + imap.CRLF) + default: + raise("ArgumentError", "unsupported SASL mechanism: %s", mechanism) + } + tagged, _ := o.readUntilTagged(vm, cmd.Tag) + return tagged +} + +// imapPlainCreds reads PLAIN credentials: (username, password) or +// (authzid, username, password). +func imapPlainCreds(creds []object.Value) (authz, user, pass string) { + switch len(creds) { + case 3: + return imapStrArg(creds[0]), imapStrArg(creds[1]), imapStrArg(creds[2]) + case 2: + return "", imapStrArg(creds[0]), imapStrArg(creds[1]) + default: + raise("ArgumentError", "PLAIN requires (username, password) or (authzid, username, password)") + return "", "", "" + } +} + +// imapUserPass reads a two-argument credential pair (username, password/token). +func imapUserPass(creds []object.Value) (string, string) { + if len(creds) != 2 { + raise("ArgumentError", "mechanism requires (username, secret)") + } + return imapStrArg(creds[0]), imapStrArg(creds[1]) +} + +// imapDecodeChallenge base64-decodes the CRAM-MD5 server challenge carried in a +// continuation request's text. +func imapDecodeChallenge(cr *imap.ContinuationRequest) string { + text := "" + if cr.Data != nil { + text = cr.Data.Text + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(text)) + if err != nil { + raise("Net::IMAP::ResponseParseError", "invalid SASL challenge: %s", err.Error()) + } + return string(raw) +} diff --git a/internal/vm/net_imap_internal_test.go b/internal/vm/net_imap_internal_test.go new file mode 100644 index 00000000..8c46afbf --- /dev/null +++ b/internal/vm/net_imap_internal_test.go @@ -0,0 +1,692 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "io" + "strings" + "testing" + + imap "github.com/go-ruby-net-imap/net-imap" + + "github.com/go-embedded-ruby/ruby/internal/compiler" + "github.com/go-embedded-ruby/ruby/internal/object" + "github.com/go-ruby-parser/parser" +) + +// imapRun runs src on a fresh VM and returns its trimmed stdout, failing on a +// parse / compile / run error. +func imapRun(t *testing.T, src string) string { + t.Helper() + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse: %v\nsrc:\n%s", err, src) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile: %v", err) + } + var buf strings.Builder + if _, rerr := New(&buf).Run(iseq); rerr != nil { + t.Fatalf("run: %v\nsrc:\n%s", rerr, src) + } + return strings.TrimRight(buf.String(), "\n") +} + +// imapRunErr runs src and returns the uncaught error's class, or "" if clean. +func imapRunErr(t *testing.T, src string) string { + t.Helper() + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse: %v", err) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile: %v", err) + } + _, rerr := New(io.Discard).Run(iseq) + if rerr == nil { + return "" + } + if i := strings.Index(rerr.Error(), ": "); i > 0 { + return rerr.Error()[:i] + } + return rerr.Error() +} + +// imapFakeSock is a duplex Ruby duck-typed socket: reads drain the canned server +// stream (greeting + responses), writes are captured. It is the injected IMAP +// transport the binding drives its protocol over — deterministic, no network. +const imapFakeSock = ` +class FakeSock + def initialize(reply) ; @in = reply.dup.force_encoding("ASCII-8BIT") ; @pos = 0 ; @out = "".b ; @closed = false ; end + def write(s) ; @out << s ; s.bytesize ; end + def read(n = nil) + avail = @in.bytesize - @pos + return "".b if avail <= 0 + n = avail if n.nil? || n > avail + chunk = @in.byteslice(@pos, n) + @pos += n + chunk + end + def out ; @out ; end + def close ; @closed = true ; end + def closed? ; @closed ; end +end +` + +// greet is the standard server greeting prefix. +const greet = "* OK [CAPABILITY IMAP4rev1] ready\r\n" + +// rbLit encodes a raw wire byte string as a Ruby double-quoted string literal, +// escaping backslash / quote / CR / LF so it survives being embedded in Ruby +// source verbatim. +func rbLit(wire string) string { + var b strings.Builder + b.WriteByte('"') + for i := 0; i < len(wire); i++ { + switch c := wire[i]; c { + case '\\': + b.WriteString(`\\`) + case '"': + b.WriteString(`\"`) + case '\r': + b.WriteString(`\r`) + case '\n': + b.WriteString(`\n`) + default: + b.WriteByte(c) + } + } + b.WriteByte('"') + return b.String() +} + +// sock returns the Ruby expression FakeSock.new() for a wire stream. +func sock(wire string) string { return "FakeSock.new(" + rbLit(wire) + ")" } + +// imapEval runs body (with net/imap required and FakeSock defined), returning +// trimmed stdout. +func imapEval(t *testing.T, body string) string { + t.Helper() + return imapRun(t, imapFakeSock+"require \"net/imap\"\n"+body) +} + +func TestNetIMAPRequireAndErrors(t *testing.T) { + cases := []struct{ src, want string }{ + {`require "net/imap"; p require "net/imap"`, "false"}, + {`p require "net/imap"`, "true"}, + {`require "net/imap"; p Net::IMAP.is_a?(Class)`, "true"}, + {`require "net/imap"; p Net::IMAP::Error < StandardError`, "true"}, + {`require "net/imap"; p Net::IMAP::ResponseError < Net::IMAP::Error`, "true"}, + {`require "net/imap"; p Net::IMAP::NoResponseError < Net::IMAP::ResponseError`, "true"}, + {`require "net/imap"; p Net::IMAP::BadResponseError < Net::IMAP::ResponseError`, "true"}, + {`require "net/imap"; p Net::IMAP::ByeResponseError < Net::IMAP::ResponseError`, "true"}, + {`require "net/imap"; p Net::IMAP::ResponseParseError < Net::IMAP::Error`, "true"}, + {`require "net/imap"; p Net::IMAP::DataFormatError < Net::IMAP::Error`, "true"}, + } + for _, c := range cases { + if got := imapRun(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +func TestNetIMAPGreetingAndConnect(t *testing.T) { + got := imapEval(t, ` +c = Net::IMAP.new(connection: `+sock(greet)+`) +g = c.greeting +p g.class +p g.name +p g.data.text +p g.data.code.name +p c.disconnected?`) + want := "Net::IMAP::UntaggedResponse\n\"OK\"\n\"ready\"\n\"CAPABILITY\"\nfalse" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPConnectionRequired(t *testing.T) { + if got := imapRunErr(t, `require "net/imap"; Net::IMAP.new("imap.example.com")`); got != "ArgumentError" { + t.Errorf("missing connection: got %q", got) + } + // A positional IO seam (not a String host) is accepted. + if got := imapEval(t, `p Net::IMAP.new(`+sock(greet)+`).greeting.name`); got != "\"OK\"" { + t.Errorf("positional conn: got %q", got) + } + // The conn: keyword alias is accepted. + if got := imapEval(t, `p Net::IMAP.new(conn: `+sock(greet)+`).greeting.name`); got != "\"OK\"" { + t.Errorf("conn: keyword: got %q", got) + } + // A positional IO seam plus a trailing (non-connection) options Hash. + if got := imapEval(t, `p Net::IMAP.new(`+sock(greet)+`, {}).greeting.name`); got != "\"OK\"" { + t.Errorf("positional + opts hash: got %q", got) + } +} + +func TestNetIMAPFetchLiteral(t *testing.T) { + // A server-sent literal ({n}\r\n) exercises the Reader's literal callback. + stream := greet + "* 1 FETCH (BODY[TEXT] {5}\r\nhello)\r\nRUBY0001 OK FETCH\r\n" + got := imapEval(t, ` +c = Net::IMAP.new(connection: `+sock(stream)+`) +p c.fetch(1, "BODY[TEXT]")[0].attr["BODY[TEXT]"]`) + if got != "\"hello\"" { + t.Errorf("literal fetch got %q", got) + } + // A literal whose byte count exceeds the remaining stream raises Net::IMAP::Error + // (the literal read hits EOF). + trunc := greet + "* 1 FETCH (BODY[TEXT] {100}\r\nshort" + if got := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock(trunc)+`).fetch(1, "BODY[TEXT]")`); got != "Net::IMAP::Error" { + t.Errorf("truncated literal: got %q", got) + } +} + +func TestNetIMAPLoginAndSimpleCommands(t *testing.T) { + stream := greet + + "RUBY0001 OK LOGIN completed\r\n" + + "RUBY0002 OK NOOP\r\n" + + "RUBY0003 OK CHECK\r\n" + + "RUBY0004 OK CLOSE\r\n" + + "RUBY0005 OK UNSELECT\r\n" + + "RUBY0006 OK begin TLS\r\n" + got := imapEval(t, ` +s = `+sock(stream)+` +c = Net::IMAP.new(connection: s) +r = c.login("joe", "secret") +p r.class +p r.tag +p r.name +p c.noop.name +p c.check.name +p c.close.name +p c.unselect.name +p c.starttls.name +p s.out.include?("RUBY0001 LOGIN joe secret")`) + want := "Net::IMAP::TaggedResponse\n\"RUBY0001\"\n\"OK\"\n\"OK\"\n\"OK\"\n\"OK\"\n\"OK\"\n\"OK\"\ntrue" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPMailboxCommands(t *testing.T) { + stream := greet + + "* 3 EXISTS\r\n* FLAGS (\\Seen \\Deleted)\r\nRUBY0001 OK [READ-WRITE] SELECT\r\n" + + "RUBY0002 OK EXAMINE\r\nRUBY0003 OK CREATE\r\nRUBY0004 OK DELETE\r\n" + + "RUBY0005 OK RENAME\r\nRUBY0006 OK SUBSCRIBE\r\nRUBY0007 OK UNSUBSCRIBE\r\n" + got := imapEval(t, ` +c = Net::IMAP.new(connection: `+sock(stream)+`) +p c.select("INBOX").data.code.name +p c.examine("INBOX").name +p c.create("x").name +p c.delete("x").name +p c.rename("x", "y").name +p c.subscribe("y").name +p c.unsubscribe("y").name +p c.responses["EXISTS"] +p c.responses["FLAGS"]`) + want := "\"READ-WRITE\"\n\"OK\"\n\"OK\"\n\"OK\"\n\"OK\"\n\"OK\"\n\"OK\"\n[3]\n[[:Seen, :Deleted]]" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPListAndStatus(t *testing.T) { + stream := greet + + "* LIST (\\HasNoChildren) \"/\" \"INBOX\"\r\n* LIST (\\Noselect) NIL \"Top\"\r\nRUBY0001 OK LIST\r\n" + + "* LSUB () \"/\" \"INBOX\"\r\nRUBY0002 OK LSUB\r\n" + + "* STATUS \"INBOX\" (MESSAGES 42 UNSEEN 3)\r\nRUBY0003 OK STATUS\r\n" + got := imapEval(t, ` +c = Net::IMAP.new(connection: `+sock(stream)+`) +l = c.list("", "*") +p l.length +p l[0].name +p l[0].delim +p l[0].attr +p l[1].delim +p c.lsub("", "*")[0].name +st = c.status("INBOX", ["MESSAGES", "UNSEEN"]) +p st.mailbox +p st.attr["MESSAGES"] +p st.attr["UNSEEN"]`) + want := "2\n\"INBOX\"\n\"/\"\n[:HasNoChildren]\nnil\n\"INBOX\"\n\"INBOX\"\n42\n3" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPFetchEnvelopeBody(t *testing.T) { + fetch := "* 12 FETCH (FLAGS (\\Seen) UID 4827 RFC822.SIZE 998 INTERNALDATE \"17-Jul-1996 02:44:25 -0700\" " + + "ENVELOPE (\"Wed, 17 Jul 1996\" \"subj\" ((\"Joe\" NIL \"joe\" \"ex.com\")) NIL NIL ((NIL NIL \"to\" \"ex.com\")) NIL NIL NIL \"\") " + + "BODY (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"US-ASCII\") NIL NIL \"7BIT\" 3028 92))\r\n" + got := imapEval(t, ` +c = Net::IMAP.new(connection: `+sock(greet+fetch+"RUBY0001 OK FETCH\r\n")+`) +fd = c.fetch(12, ["FLAGS", "UID", "ENVELOPE", "BODY"])[0] +p fd.seqno +p fd.attr["FLAGS"] +p fd.attr["UID"] +p fd.attr["RFC822.SIZE"] +p fd.attr["INTERNALDATE"] +env = fd.attr["ENVELOPE"] +p env.subject +p env.from[0].mailbox +p env.from[0].host +p env.sender +p env.to[0].name +p env.message_id +b = fd.attr["BODY"] +p b.class +p b.media_type +p b.subtype +p b.lines +p b.multipart? +p b.param["CHARSET"]`) + want := "12\n[:Seen]\n4827\n998\n\"17-Jul-1996 02:44:25 -0700\"\n\"subj\"\n\"joe\"\n\"ex.com\"\nnil\nnil\n\"\"\n" + + "Net::IMAP::BodyTypeText\n\"TEXT\"\n\"PLAIN\"\n92\nfalse\n\"US-ASCII\"" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPBodyStructureVariants(t *testing.T) { + multi := "* 1 FETCH (BODYSTRUCTURE ((\"TEXT\" \"PLAIN\" NIL NIL NIL \"7BIT\" 10 1)(\"IMAGE\" \"GIF\" NIL NIL NIL \"BASE64\" 20) \"MIXED\"))\r\n" + got := imapEval(t, ` +c = Net::IMAP.new(connection: `+sock(greet+multi+"RUBY0001 OK FETCH\r\n")+`) +bs = c.fetch(1, "BODYSTRUCTURE")[0].attr["BODYSTRUCTURE"] +p bs.class +p bs.multipart? +p bs.subtype +p bs.parts.length +p bs.parts[0].class +p bs.parts[1].class`) + want := "Net::IMAP::BodyTypeMultipart\ntrue\n\"MIXED\"\n2\nNet::IMAP::BodyTypeText\nNet::IMAP::BodyTypeBasic" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPStoreSearchExpungeCopyCapability(t *testing.T) { + stream := greet + + "* 1 FETCH (FLAGS (\\Seen \\Deleted))\r\nRUBY0001 OK STORE\r\n" + + "* SEARCH 2 3 5 8\r\nRUBY0002 OK SEARCH\r\n" + + "* 3 EXPUNGE\r\n* 3 EXPUNGE\r\nRUBY0003 OK EXPUNGE\r\n" + + "RUBY0004 OK COPY\r\n" + + "* CAPABILITY IMAP4rev1 IDLE\r\nRUBY0005 OK CAPABILITY\r\n" + got := imapEval(t, ` +s = `+sock(stream)+` +c = Net::IMAP.new(connection: s) +p c.store(1, "+FLAGS", [:Deleted])[0].attr["FLAGS"] +p c.search(["FROM", "joe"]) +p c.expunge +p c.copy(1..3, "Archive").name +p c.capability +p s.out.include?("RUBY0001 STORE 1 +FLAGS") +p s.out.include?("RUBY0003 EXPUNGE") +p s.out.include?("RUBY0004 COPY 1:3 Archive")`) + want := "[:Seen, :Deleted]\n[2, 3, 5, 8]\n[3, 3]\n\"OK\"\n[\"IMAP4REV1\", \"IDLE\"]\ntrue\ntrue\ntrue" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPUIDVariants(t *testing.T) { + stream := greet + + "* 1 FETCH (UID 42)\r\nRUBY0001 OK UID FETCH\r\n" + + "* 1 FETCH (FLAGS (\\Seen))\r\nRUBY0002 OK UID STORE\r\n" + + "RUBY0003 OK UID COPY\r\n" + + "* SEARCH 7\r\nRUBY0004 OK UID SEARCH\r\n" + got := imapEval(t, ` +s = `+sock(stream)+` +c = Net::IMAP.new(connection: s) +p c.uid_fetch("1:3", "UID")[0].attr["UID"] +p c.uid_store([1,2], "FLAGS", [:Seen])[0].attr["FLAGS"] +p c.uid_copy("1,2,3", "X").name +p c.uid_search("ALL") +p s.out.include?("RUBY0001 UID FETCH 1:3 (UID)") +p s.out.include?("RUBY0004 UID SEARCH ALL")`) + want := "42\n[:Seen]\n\"OK\"\n[7]\ntrue\ntrue" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPAppendWithLiteral(t *testing.T) { + stream := greet + "+ go ahead\r\nRUBY0001 OK [APPENDUID 38505 3955] APPEND\r\n" + got := imapEval(t, ` +s = `+sock(stream)+` +c = Net::IMAP.new(connection: s) +r = c.append("INBOX", "From: a\r\n\r\nbody", [:Seen]) +p r.name +code = r.data.code +p code.name +p code.data.class +p code.data.uidvalidity +p code.data.assigned_uids +p s.out.include?("RUBY0001 APPEND INBOX") +p s.out.include?("{15}") +p s.out.end_with?("From: a\r\n\r\nbody\r\n")`) + want := "\"OK\"\n\"APPENDUID\"\nNet::IMAP::AppendUIDData\n38505\n\"3955\"\ntrue\ntrue\ntrue" + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPAuthenticate(t *testing.T) { + cases := []struct{ call, stream, mech string }{ + {`cc.authenticate("PLAIN", "joe", "pw")`, "+ \r\nRUBY0001 OK AUTH\r\n", "PLAIN"}, + {`cc.authenticate("PLAIN", "", "joe", "pw")`, "+ \r\nRUBY0001 OK AUTH\r\n", "PLAIN"}, + {`cc.authenticate("LOGIN", "joe", "pw")`, "+ VXNlcg==\r\n+ UGFzcw==\r\nRUBY0001 OK AUTH\r\n", "LOGIN"}, + {`cc.authenticate("CRAM-MD5", "joe", "pw")`, "+ PDEyMzQ1QGV4LmNvbT4=\r\nRUBY0001 OK AUTH\r\n", "CRAM-MD5"}, + {`cc.authenticate("XOAUTH2", "joe", "tok")`, "+ \r\nRUBY0001 OK AUTH\r\n", "XOAUTH2"}, + } + for _, c := range cases { + got := imapEval(t, ` +s = `+sock(greet+c.stream)+` +cc = Net::IMAP.new(connection: s) +r = `+c.call+` +p r.name +p s.out.include?("AUTHENTICATE `+c.mech+`")`) + if got != "\"OK\"\ntrue" { + t.Errorf("call %q got:\n%s", c.call, got) + } + } + // An unsupported mechanism raises ArgumentError. + if got := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock(greet+"+ \r\n")+`).authenticate("SCRAM", "a", "b")`); got != "ArgumentError" { + t.Errorf("unsupported mech: got %q", got) + } + // Wrong PLAIN / user-pass arg counts raise ArgumentError. + for _, call := range []string{ + `c.authenticate("PLAIN", "only")`, + `c.authenticate("LOGIN", "only")`, + } { + src := imapFakeSock + `require "net/imap" +c = Net::IMAP.new(connection: ` + sock(greet+"+ \r\n") + `) +` + call + if got := imapRunErr(t, src); got != "ArgumentError" { + t.Errorf("bad creds %q: got %q", call, got) + } + } + // A bad base64 CRAM-MD5 challenge raises ResponseParseError. + if got := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock(greet+"+ !!!not-base64!!!\r\n")+`).authenticate("CRAM-MD5", "a", "b")`); got != "Net::IMAP::ResponseParseError" { + t.Errorf("bad challenge: got %q", got) + } + // A non-continuation where a continuation is expected raises ResponseParseError. + if got := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock(greet+"RUBY0001 OK too soon\r\n")+`).authenticate("PLAIN", "a", "b")`); got != "Net::IMAP::ResponseParseError" { + t.Errorf("no continuation: got %q", got) + } +} + +func TestNetIMAPTaggedErrors(t *testing.T) { + no := imapEval(t, ` +c = Net::IMAP.new(connection: `+sock(greet+"RUBY0001 NO login failed\r\n")+`) +begin + c.login("a","b") +rescue Net::IMAP::NoResponseError => e + p e.message +end`) + if no != "\"login failed\"" { + t.Errorf("NO: got %q", no) + } + bad := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock(greet+"RUBY0001 BAD syntax\r\n")+`).noop`) + if bad != "Net::IMAP::BadResponseError" { + t.Errorf("BAD: got %q", bad) + } +} + +func TestNetIMAPByeAndDisconnect(t *testing.T) { + // A BYE greeting raises ByeResponseError. + bye := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock("* BYE server down\r\n")+`)`) + if bye != "Net::IMAP::ByeResponseError" { + t.Errorf("bye greeting: got %q", bye) + } + // LOGOUT: a BYE during a command marks disconnected; the tagged OK follows; + // #disconnect then closes the seam (idempotently). + got := imapEval(t, ` +s = `+sock(greet+"* BYE logging out\r\nRUBY0001 OK LOGOUT\r\n")+` +c = Net::IMAP.new(connection: s) +p c.logout.name +p c.disconnected? +c.disconnect +p c.disconnected? +p s.closed? +c.disconnect`) + want := "\"OK\"\ntrue\ntrue\ntrue" + if got != want { + t.Errorf("logout/disconnect got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPRespTextCodeVariants(t *testing.T) { + stream := greet + + "RUBY0001 OK [UIDVALIDITY 3857529045] done\r\n" + + "RUBY0002 OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] done\r\n" + + "RUBY0003 OK [CAPABILITY IMAP4rev1 STARTTLS] done\r\n" + + "RUBY0004 OK [BADCHARSET (utf-8 us-ascii)] done\r\n" + + "RUBY0005 OK [ALERT] system going down\r\n" + + "RUBY0006 OK [COPYUID 1 1:2 3:4] done\r\n" + + "RUBY0007 OK [UNKNOWNCODE some text] done\r\n" + got := imapEval(t, ` +c = Net::IMAP.new(connection: `+sock(stream)+`) +p c.noop.data.code.data +p c.noop.data.code.data +p c.noop.data.code.data +p c.noop.data.code.data +r5 = c.noop.data.code +p r5.name +p r5.data +r6 = c.noop.data.code +p r6.data.class +p r6.data.uidvalidity +p r6.data.source_uids +p r6.data.assigned_uids +p c.noop.data.code.data`) + want := "3857529045\n[:Deleted, :Seen, :*]\n[\"IMAP4REV1\", \"STARTTLS\"]\n[\"utf-8\", \"us-ascii\"]\n" + + "\"ALERT\"\nnil\nNet::IMAP::CopyUIDData\n1\n\"1:2\"\n\"3:4\"\n\"some text\"" + if got != want { + t.Errorf("code variants got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPUTF7Helpers(t *testing.T) { + // Round-trip: decode(encode) equals the original for a non-ASCII mailbox. + rt := imapEval(t, ` +orig = "Ph\xC3\xA9nix".force_encoding("UTF-8") +enc = Net::IMAP.encode_utf7(orig) +p enc.include?("&") +p Net::IMAP.decode_utf7(enc) == orig`) + if rt != "true\ntrue" { + t.Errorf("utf7 round-trip: %q", rt) + } +} + +func TestNetIMAPResponsesAndGreetingAccessors(t *testing.T) { + got := imapEval(t, ` +c = Net::IMAP.new(connection: `+sock(greet+"* 5 EXISTS\r\n* 2 RECENT\r\nRUBY0001 OK NOOP\r\n")+`) +c.noop +h = c.responses +p h["EXISTS"] +p h["RECENT"] +p h.key?("OK")`) + want := "[5]\n[2]\ntrue" + if got != want { + t.Errorf("responses got:\n%s\nwant:\n%s", got, want) + } +} + +func TestNetIMAPDataFormatError(t *testing.T) { + if got := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock(greet)+`).fetch(-1, "UID")`); got != "Net::IMAP::DataFormatError" { + t.Errorf("negative seq: got %q", got) + } +} + +func TestNetIMAPParseErrors(t *testing.T) { + // A malformed response line raises ResponseParseError. + if got := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock(greet+"* 1 BOGUS junk\r\n")+`).noop`); got != "Net::IMAP::ResponseParseError" { + t.Errorf("bad line: got %q", got) + } + // A truncated stream (EOF before a full response) raises Net::IMAP::Error. + if got := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock(greet+"RUBY0001 OK partial")+`).noop`); got != "Net::IMAP::Error" { + t.Errorf("truncated: got %q", got) + } + // A non-untagged greeting raises ResponseParseError. + if got := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock("RUBY0001 OK notgreeting\r\n")+`)`); got != "Net::IMAP::ResponseParseError" { + t.Errorf("bad greeting: got %q", got) + } + // An unexpected continuation mid-command raises ResponseParseError. + if got := imapRunErr(t, imapFakeSock+`require "net/imap" +Net::IMAP.new(connection: `+sock(greet+"+ surprise\r\n")+`).noop`); got != "Net::IMAP::ResponseParseError" { + t.Errorf("stray continuation: got %q", got) + } +} + +func TestNetIMAPArityErrors(t *testing.T) { + for _, call := range []string{ + `c.login("a")`, `c.select`, `c.list("")`, `c.status("x")`, + `c.fetch(1)`, `c.store(1, "x")`, `c.copy(1)`, `c.append("x")`, + `c.authenticate`, `Net::IMAP.encode_utf7("a", "b")`, `Net::IMAP.decode_utf7`, + } { + src := imapFakeSock + `require "net/imap" +c = Net::IMAP.new(connection: ` + sock(greet) + `) +` + call + if got := imapRunErr(t, src); got != "ArgumentError" { + t.Errorf("call %q: got %q, want ArgumentError", call, got) + } + } +} + +// --- Go-level unit tests for defensive branches ---------------------------- + +func TestIMAPObjStringSurface(t *testing.T) { + o := &IMAPObj{} + if o.ToS() != "#" || o.Inspect() != "#" || !o.Truthy() { + t.Errorf("surface: ToS=%q Inspect=%q Truthy=%v", o.ToS(), o.Inspect(), o.Truthy()) + } +} + +func TestIMAPMappingDefaults(t *testing.T) { + vm := New(io.Discard) + if !object.IsNil(vm.imapDataValue(struct{}{})) { + t.Error("imapDataValue default should be nil") + } + if !object.IsNil(vm.imapCodeData(nil)) { + t.Error("imapCodeData default should be nil") + } + if !object.IsNil(vm.imapAttrValue(struct{}{})) { + t.Error("imapAttrValue default should be nil") + } + if !object.IsNil(vm.imapParams(nil)) { + t.Error("imapParams(nil) should be nil") + } + if !object.IsNil(vm.imapAddressList(nil)) { + t.Error("imapAddressList(nil) should be nil") + } + if !object.IsNil(vm.imapRespText(nil)) { + t.Error("imapRespText(nil) should be nil") + } + // The string arm (defensive; no untagged Data is a bare string). + if s, ok := vm.imapDataValue("hi").(*object.String); !ok || s.Str() != "hi" { + t.Error("imapDataValue string arm") + } +} + +func TestIMAPArgCoercion(t *testing.T) { + // imapFlags accepts a single (non-Array) flag as well as an Array. + if fs := imapFlags(object.Symbol("Seen")); len(fs) != 1 || fs[0] != imap.Flag("Seen") { + t.Errorf("imapFlags single: %v", fs) + } + // imapAtts accepts a single (non-Array) attribute. + if as := imapAtts(object.NewString("UID")); len(as) != 1 || as[0] != "UID" { + t.Errorf("imapAtts single: %v", as) + } + // imapSearchArgs maps Integer / Array / String keys to the right argument + // types. + args := imapSearchArgs([]object.Value{ + object.Integer(5), + object.NewArrayFromSlice([]object.Value{object.Integer(1)}), + object.NewString("ALL"), + }) + if len(args) != 3 { + t.Fatalf("imapSearchArgs len: %d", len(args)) + } + if n, ok := args[0].(int64); !ok || n != 5 { + t.Errorf("imapSearchArgs int arm: %v", args[0]) + } + if _, ok := args[1].(*imap.SequenceSet); !ok { + t.Errorf("imapSearchArgs array arm: %T", args[1]) + } + if s, ok := args[2].(string); !ok || s != "ALL" { + t.Errorf("imapSearchArgs string arm: %v", args[2]) + } +} + +func TestIMAPBodyTypeMessageAndBasic(t *testing.T) { + vm := New(io.Discard) + msg := vm.imapBodyStructure(&imap.BodyStructure{MediaType: "MESSAGE", Subtype: "RFC822", Lines: 5}) + if vm.classOf(msg).name != "Net::IMAP::BodyTypeMessage" { + t.Errorf("message body: %s", vm.classOf(msg).name) + } + basic := vm.imapBodyStructure(&imap.BodyStructure{MediaType: "MESSAGE", Subtype: "OTHER"}) + if vm.classOf(basic).name != "Net::IMAP::BodyTypeBasic" { + t.Errorf("message/other body: %s", vm.classOf(basic).name) + } +} + +func TestIMAPHelperEdgeCases(t *testing.T) { + if respTextStr(nil) != "" { + t.Error("respTextStr(nil)") + } + if untaggedText(&imap.UntaggedResponse{Data: int64(3)}) != "" { + t.Error("untaggedText non-ResponseText") + } + if !object.IsNil(imapNilGreeting(nil)) { + t.Error("imapNilGreeting(nil)") + } + if !object.IsNil(imapFirst(object.NewArrayFromSlice(nil))) { + t.Error("imapFirst empty") + } + if a, ok := imapFirstOrEmpty(object.NewArrayFromSlice(nil)).(*object.Array); !ok || len(a.Elems) != 0 { + t.Error("imapFirstOrEmpty empty") + } + if imapConnArg(nil) != nil { + t.Error("imapConnArg(nil)") + } + if imapConnArg([]object.Value{object.NewString("host")}) != nil { + t.Error("imapConnArg(host string)") + } + if got := imapSeqItems(object.Float(1.5)); len(got) != 1 || got[0].(int64) != 0 { + t.Errorf("imapSeqItems default: %v", got) + } + if imapSeqNum("bad") != imap.Star { + t.Error("imapSeqNum bad token") + } + if imapSeqNum("*") != imap.Star { + t.Error("imapSeqNum star") + } + if imapStrArg(object.Symbol("Seen")) != "Seen" { + t.Errorf("imapStrArg symbol: %q", imapStrArg(object.Symbol("Seen"))) + } + if imapSeqBound(object.NilV) != imap.Star { + t.Error("imapSeqBound nil") + } +} + +func TestIMAPMustCmdError(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("mustCmd with error should raise") + } + }() + mustCmd(imap.Command{}, imap.ErrInvalidData) +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..41269991 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -974,6 +974,8 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.consts["Typhoeus::Response"].(*RClass) case *TyphoeusHydra: return vm.consts["Typhoeus::Hydra"].(*RClass) + case *IMAPObj: + return x.cls case *RedisObj: return x.cls case *RedisBatch: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..27d7c4b9 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -33,7 +33,7 @@ var providedFeatures = map[string]bool{ "concurrent": true, "syslog": true, "cgi": true, "monitor": true, "observer": true, "socket": true, - "net/http": true, "net/https": true, "resolv": true, "singleton": true, + "net/http": true, "net/https": true, "net/imap": true, "resolv": true, "singleton": true, "optparse": true, "ripper": true, "erb": true, "find": true, "tempfile": true, "open3": true, "strscan": true, "fiber": true, "objspace": true, "csv": true, From 81abfb8f604768494db3f04c32adf22cdeafe7a7 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:37:47 +0200 Subject: [PATCH 4/9] =?UTF-8?q?feat(vm):=20bind=20net-sftp=20=E2=80=94=20r?= =?UTF-8?q?equire=20"net/sftp"=20(Net::SFTP=20over=20injected=20SSH=20chan?= =?UTF-8?q?nel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 1 + go.sum | 2 + internal/vm/builtins.go | 1 + internal/vm/net_sftp.go | 356 ++++++++++++ internal/vm/net_sftp_bind.go | 737 +++++++++++++++++++++++ internal/vm/net_sftp_internal_test.go | 809 ++++++++++++++++++++++++++ internal/vm/object_model.go | 12 + internal/vm/require.go | 1 + internal/vm/vm.go | 1 + 9 files changed, 1920 insertions(+) create mode 100644 internal/vm/net_sftp.go create mode 100644 internal/vm/net_sftp_bind.go create mode 100644 internal/vm/net_sftp_internal_test.go diff --git a/go.mod b/go.mod index f56c9fd7..f1b78fb2 100644 --- a/go.mod +++ b/go.mod @@ -89,6 +89,7 @@ require ( github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f + github.com/go-ruby-net-sftp/net-sftp v0.0.0-20260630142744-9be6f27056d7 github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 github.com/go-ruby-observer/observer v0.0.0-20260630080708-c3a02da51f79 diff --git a/go.sum b/go.sum index a357f88a..3938f96e 100644 --- a/go.sum +++ b/go.sum @@ -340,6 +340,8 @@ github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 h1:F+ghhcdLDBVGG github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7/go.mod h1:cm0D4pmaTvvQFPK815E7/uJaGeGxkr5xFLHixeQixTA= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f h1:nS0qwCPtVAtp+48j6KkdTdpVL8jT9L2ZGQcJZwOKev8= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f/go.mod h1:PiNQDuyrSPZX8OzApvgyn1fTjRNwx0KNO1vrzdAguEc= +github.com/go-ruby-net-sftp/net-sftp v0.0.0-20260630142744-9be6f27056d7 h1:5aKzjgwxqqMvHLyQOJrDO8GLJUdE1izGJTLDG+3+V5k= +github.com/go-ruby-net-sftp/net-sftp v0.0.0-20260630142744-9be6f27056d7/go.mod h1:okvDFdQDwwx5ubNL6mmXTHGTutWhOTHSnUPq1JzhxoU= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e h1:v8DyCc3Vp+REhN1uyQ4wN8CpJ6JMqWNOm9OESWFBtLU= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e/go.mod h1:WsLJ6/u8/nzHShZKChf20tHosn/nlVnOJiD/xEY6X8c= github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 h1:iZFtIn5WHQRz/oa04qYAlLvTi0XDRP5dKPGpJTGRzKg= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..e670383f 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -425,6 +425,7 @@ func (vm *VM) bootstrap() { vm.registerSocket() // TCPSocket/TCPServer (net) + OpenSSL::SSL::SSLSocket (crypto/tls); after registerOpenSSL (upgrades its SSL shell) vm.registerNetHTTP() // net/http + net/https loadable shell; needs StandardError vm.registerNetHTTPTransport() // real Net::HTTP over the socket transport; after registerNetHTTP + registerSocket + vm.registerNetSFTP() // Net::SFTP client (require "net/sftp"), backed by go-ruby-net-sftp codec; SSH channel = injected IO seam; nests under Net, after registerNetHTTP vm.registerResolv() // Resolv (real IPv4/IPv6 parse; DNS sockets stubbed); needs StandardError vm.registerTimeout() // Timeout module (loadable shell); needs RuntimeError vm.registerDateErrors() // Date::Error < ArgumentError (Date class itself registered early); needs ArgumentError diff --git a/internal/vm/net_sftp.go b/internal/vm/net_sftp.go new file mode 100644 index 00000000..07452842 --- /dev/null +++ b/internal/vm/net_sftp.go @@ -0,0 +1,356 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + sftp "github.com/go-ruby-net-sftp/net-sftp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file binds the pure-Go SFTP wire codec github.com/go-ruby-net-sftp/net-sftp +// into rbgo as require "net/sftp" — the Ruby Net::SFTP surface (Session#open!/ +// read!/write!/stat!/…, upload!/download!, the dir and file operation helpers). +// +// 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 takes an injected channel +// object responding to #write(bytes) / #read(n) (a duck-typed SSH channel, e.g. a +// StringIO in tests), exactly as Redis.new takes an injected IO connection. The +// binding drives the library's request builders and response parsers over that +// channel: it writes the FXP_INIT frame, negotiates the version from FXP_VERSION, +// then for each operation writes the request frame and reads the correlated +// response back. sftpChannel (net_sftp_bind.go) bridges the Ruby duck to the byte +// stream a PacketParser reassembles. + +// sftpReadChunk is the number of bytes the driver requests from the channel's +// #read per fill; the PacketParser reassembles whole packets from the chunks. +const sftpReadChunk = 32768 + +// sftpDownloadChunk is the per-FXP_READ request size used by download!/file read. +const sftpDownloadChunk = 32000 + +// sftpSession is a live Net::SFTP session: the injected channel plus the codec +// state (the negotiated protocol builder and the packet reassembler) the driver +// runs the request/response loop over. +type sftpSession struct { + cls *RClass + vm *VM + channel object.Value // the Ruby SSH-channel duck (#write / #read) + proto *sftp.Protocol + parser *sftp.PacketParser + version int +} + +func (s *sftpSession) ToS() string { return "#" } +func (s *sftpSession) Inspect() string { return "#" } +func (s *sftpSession) Truthy() bool { return true } + +// sftpDir is the Session#dir helper (Net::SFTP::Operations::Dir): directory +// enumeration (foreach / entries / glob) over its owning session. +type sftpDir struct { + cls *RClass + s *sftpSession +} + +func (d *sftpDir) ToS() string { return "#" } +func (d *sftpDir) Inspect() string { return "#" } +func (d *sftpDir) Truthy() bool { return true } + +// sftpFileFactory is the Session#file helper (Net::SFTP::Operations::FileFactory): +// it opens remote files (#open) and answers metadata queries (#directory?). +type sftpFileFactory struct { + cls *RClass + s *sftpSession +} + +func (f *sftpFileFactory) ToS() string { return "#" } +func (f *sftpFileFactory) Inspect() string { return "#" } +func (f *sftpFileFactory) Truthy() bool { return true } + +// sftpFile is an open remote file (Net::SFTP::Operations::File): a handle plus a +// read/write cursor the #read / #write / #close methods advance. +type sftpFile struct { + cls *RClass + s *sftpSession + handle []byte + pos uint64 + closed bool +} + +func (f *sftpFile) ToS() string { return "#" } +func (f *sftpFile) Inspect() string { return "#" } +func (f *sftpFile) Truthy() bool { return true } + +// --- the channel-seam driver ----------------------------------------------- + +// writeFrame writes framed request bytes to the channel via the duck's #write. +func (s *sftpSession) writeFrame(frame []byte) { + s.vm.send(s.channel, "write", []object.Value{object.NewStringBytesEnc(append([]byte(nil), frame...), "ASCII-8BIT")}, nil) +} + +// nextPacket reassembles and returns the next complete packet, pulling more bytes +// from the channel's #read whenever the parser needs them. An empty read before a +// packet completes means the channel closed mid-stream. +func (s *sftpSession) nextPacket() sftp.Packet { + for { + if pkt, ok := s.parser.Next(); ok { + return pkt + } + rv := s.vm.send(s.channel, "read", []object.Value{object.IntValue(sftpReadChunk)}, nil) + data := sftpReadBytes(rv) + if len(data) == 0 { + raise("Net::SFTP::Exception", "the SFTP channel closed before a complete packet arrived") + } + s.parser.Feed(data) + } +} + +// connect performs the FXP_INIT / FXP_VERSION handshake: it advertises the +// client's highest version, parses the server's reply, and builds the negotiated +// request builder. It raises Net::SFTP::Exception on any protocol fault. +func (s *sftpSession) connect() { + s.writeFrame(sftp.InitPacket(sftp.HighestProtocolVersionSupported)) + pkt := s.nextPacket() + if pkt.Type != sftp.FXP_VERSION { + raise("Net::SFTP::Exception", "expected FXP_VERSION, got packet type %d", pkt.Type) + } + info, err := sftp.ParseVersion(pkt.Payload) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + v, err := sftp.NegotiateVersion(info.Version, sftp.HighestProtocolVersionSupported) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + s.version = v + s.proto = sftp.NewProtocol(v) +} + +// response writes a request frame and returns the parsed, id-correlated response. +func (s *sftpSession) response(id uint32, frame []byte) any { + s.writeFrame(frame) + pkt := s.nextPacket() + resp, err := sftp.ParseResponse(pkt, s.version) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + if got := sftpRespID(resp); got != id { + raise("Net::SFTP::Exception", "response id mismatch: got %d, expected %d", got, id) + } + return resp +} + +// okOrRaise interprets a status-only response: FX_OK returns nil, any other code +// raises the gem-faithful Net::SFTP::StatusException. +func (s *sftpSession) okOrRaise(resp any, op string) object.Value { + st := resp.(*sftp.StatusResponse) + if st.OK() { + return object.NilV + } + return s.raiseStatus(st, op) +} + +// handleOf interprets an open/opendir response: an FXP_HANDLE yields the handle +// string, an FXP_STATUS raises. +func (s *sftpSession) handleOf(resp any, op string) []byte { + if st, ok := resp.(*sftp.StatusResponse); ok { + s.raiseStatus(st, op) + } + return resp.(*sftp.HandleResponse).Handle +} + +// openHandle opens path in the given mode and returns the raw handle bytes. It +// translates the Ruby mode string into the protocol-version-specific open flags. +func (s *sftpSession) openHandle(path, mode string, attrs *sftp.Attributes) []byte { + flags, err := sftp.NormalizeOpenFlags(mode) + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + var id uint32 + var frame []byte + if s.version >= 5 { + sf, da := sftp.OpenFlagsV5(flags) + id, frame = s.proto.Open(path, sf, da, attrs) + } else { + id, frame = s.proto.Open(path, sftp.OpenFlagsV1(flags), 0, attrs) + } + return s.handleOf(s.response(id, frame), "open") +} + +// closeHandle closes an open file or directory handle. +func (s *sftpSession) closeHandle(h []byte) object.Value { + id, frame := s.proto.Close(h) + return s.okOrRaise(s.response(id, frame), "close") +} + +// readAt issues one FXP_READ and returns the data bytes, or ok=false at EOF. +func (s *sftpSession) readAt(h []byte, offset uint64, length uint32) (data []byte, ok bool) { + id, frame := s.proto.Read(h, offset, length) + resp := s.response(id, frame) + if st, isStatus := resp.(*sftp.StatusResponse); isStatus { + if st.EOF() { + return nil, false + } + s.raiseStatus(st, "read") + } + return resp.(*sftp.DataResponse).Data, true +} + +// readdirAll enumerates a directory handle, returning every Name entry. +func (s *sftpSession) readdirAll(h []byte) []sftp.Name { + var out []sftp.Name + for { + id, frame := s.proto.Readdir(h) + resp := s.response(id, frame) + if st, isStatus := resp.(*sftp.StatusResponse); isStatus { + if st.EOF() { + return out + } + s.raiseStatus(st, "readdir") + } + nr := resp.(*sftp.NameResponse) + out = append(out, nr.Names...) + } +} + +// --- registration ---------------------------------------------------------- + +// registerNetSFTP installs the Net::SFTP surface (require "net/sftp"): the +// Session client driven over an injected SSH channel, the dir/file operation +// helpers, the version-aware Attributes and Name value objects, the +// Net::SFTP::Constants tables, and the Exception / StatusException tree. It nests +// under the existing Net module, so it must run after registerNetHTTP (which +// creates Net) and after registerSocket / registerOpenSSL for consistency with +// the other injected-transport clients. +func (vm *VM) registerNetSFTP() { + net := vm.consts["Net"].(*RClass) + std := vm.consts["StandardError"].(*RClass) + + mod := newClass("Net::SFTP", vm.cObject) + mod.isModule = true + net.consts["SFTP"] = mod + vm.consts["Net::SFTP"] = mod + + // The exception tree: Net::SFTP::Exception < StandardError, and + // Net::SFTP::StatusException < Exception (carrying code / description / text). + exc := newClass("Net::SFTP::Exception", std) + mod.consts["Exception"] = exc + vm.consts["Net::SFTP::Exception"] = exc + statusExc := newClass("Net::SFTP::StatusException", exc) + mod.consts["StatusException"] = statusExc + vm.consts["Net::SFTP::StatusException"] = statusExc + vm.defineStatusExceptionMethods(statusExc) + + vm.registerNetSFTPConstants(mod) + + // The value classes returned by stat!/readdir!/dir.entries. + attrsCls := vm.registerNetSFTPAttributes(mod) + nameCls := newClass("Net::SFTP::Name", vm.cObject) + mod.consts["Name"] = nameCls + vm.consts["Net::SFTP::Name"] = nameCls + vm.defineNetSFTPNameMethods(nameCls) + + session := newClass("Net::SFTP::Session", vm.cObject) + mod.consts["Session"] = session + vm.consts["Net::SFTP::Session"] = session + session.consts["HIGHEST_PROTOCOL_VERSION_SUPPORTED"] = object.IntValue(int64(sftp.HighestProtocolVersionSupported)) + + dirCls := newClass("Net::SFTP::Operations::Dir", vm.cObject) + vm.consts["Net::SFTP::Operations::Dir"] = dirCls + fileFactoryCls := newClass("Net::SFTP::Operations::FileFactory", vm.cObject) + vm.consts["Net::SFTP::Operations::FileFactory"] = fileFactoryCls + fileCls := newClass("Net::SFTP::Operations::File", vm.cObject) + vm.consts["Net::SFTP::Operations::File"] = fileCls + + vm.sftpClasses = &sftpClassSet{ + attrs: attrsCls, + name: nameCls, + statusExc: statusExc, + dir: dirCls, + fileFactory: fileFactoryCls, + file: fileCls, + } + + // Net::SFTP.start(host, user, options={}) { |sftp| ... } and + // Net::SFTP::Session.new(channel) both build a connected session over the + // injected channel. + starter := func(_ *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + return vm.newSFTPSession(session, args, blk) + } + mod.smethods["start"] = &Method{name: "start", owner: mod, native: starter} + session.smethods["new"] = &Method{name: "new", owner: session, native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSFTPSession(session, args, nil) + }} + + vm.defineNetSFTPSessionMethods(session) + vm.defineNetSFTPDirMethods(dirCls) + vm.defineNetSFTPFileFactoryMethods(fileFactoryCls) + vm.defineNetSFTPFileMethods(fileCls) +} + +// registerNetSFTPConstants installs Net::SFTP::Constants: the StatusCodes and +// PacketTypes tables plus HighestProtocolVersionSupported, mirroring the gem's +// Net::SFTP::Constants module so a script can reference FX_* / FXP_* by name. +func (vm *VM) registerNetSFTPConstants(mod *RClass) { + c := newClass("Net::SFTP::Constants", vm.cObject) + c.isModule = true + mod.consts["Constants"] = c + vm.consts["Net::SFTP::Constants"] = c + + status := newClass("Net::SFTP::Constants::StatusCodes", vm.cObject) + status.isModule = true + c.consts["StatusCodes"] = status + for name, val := range map[string]int{ + "FX_OK": sftp.FX_OK, "FX_EOF": sftp.FX_EOF, "FX_NO_SUCH_FILE": sftp.FX_NO_SUCH_FILE, + "FX_PERMISSION_DENIED": sftp.FX_PERMISSION_DENIED, "FX_FAILURE": sftp.FX_FAILURE, + "FX_BAD_MESSAGE": sftp.FX_BAD_MESSAGE, "FX_NO_CONNECTION": sftp.FX_NO_CONNECTION, + "FX_CONNECTION_LOST": sftp.FX_CONNECTION_LOST, "FX_OP_UNSUPPORTED": sftp.FX_OP_UNSUPPORTED, + } { + status.consts[name] = object.IntValue(int64(val)) + } + + types := newClass("Net::SFTP::Constants::PacketTypes", vm.cObject) + types.isModule = true + c.consts["PacketTypes"] = types + for name, val := range map[string]int{ + "FXP_INIT": sftp.FXP_INIT, "FXP_VERSION": sftp.FXP_VERSION, "FXP_OPEN": sftp.FXP_OPEN, + "FXP_CLOSE": sftp.FXP_CLOSE, "FXP_READ": sftp.FXP_READ, "FXP_WRITE": sftp.FXP_WRITE, + "FXP_STATUS": sftp.FXP_STATUS, "FXP_HANDLE": sftp.FXP_HANDLE, "FXP_DATA": sftp.FXP_DATA, + "FXP_NAME": sftp.FXP_NAME, "FXP_ATTRS": sftp.FXP_ATTRS, + } { + types.consts[name] = object.IntValue(int64(val)) + } +} + +// sftpClassSet caches the wrapper classes so the value-mapping helpers can stamp +// the right Ruby class on a returned Attributes / Name / File without threading +// the VM's const table through every call. +type sftpClassSet struct { + attrs *RClass + name *RClass + statusExc *RClass + dir *RClass + fileFactory *RClass + file *RClass +} + +// newSFTPSession builds a session over the injected channel, runs the version +// handshake, and (for start with a block) yields the session to the block. The +// channel is the first positional non-Hash argument or the channel: / connection: +// keyword; host / user positionals are accepted and ignored (there is no SSH). +func (vm *VM) newSFTPSession(cls *RClass, args []object.Value, blk *Proc) object.Value { + ch := sftpChannelArg(args) + if ch == nil { + raise("ArgumentError", "Net::SFTP requires a channel: IO-like object responding to #read/#write (rbgo has no native SSH transport yet)") + } + s := &sftpSession{cls: cls, vm: vm, channel: ch, parser: sftp.NewPacketParser()} + s.connect() + if blk != nil { + vm.callBlock(blk, []object.Value{s}) + } + return s +} diff --git a/internal/vm/net_sftp_bind.go b/internal/vm/net_sftp_bind.go new file mode 100644 index 00000000..3654d0c2 --- /dev/null +++ b/internal/vm/net_sftp_bind.go @@ -0,0 +1,737 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "time" + + sftp "github.com/go-ruby-net-sftp/net-sftp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the value-mapping half of the Net::SFTP binding: the channel-seam +// helpers, the Ruby argument decoding, the Attributes / Name value objects, the +// StatusException raise, and the Session / dir / file method surfaces. net_sftp.go +// owns the driver and registration; here every Ruby method closure maps its +// arguments onto the session's driver and its reply back into the object graph. + +// --- channel seam ---------------------------------------------------------- + +// sftpReadBytes extracts the byte payload a Ruby channel #read returned: a String +// yields its bytes, anything else (nil at EOF) yields none. +func sftpReadBytes(v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + return nil +} + +// sftpChannelArg resolves the injected SSH-channel object from the call +// arguments: the channel: / connection: keyword of a trailing Hash (the +// Net::SFTP.start form, where the positionals are host / user strings), else the +// first non-String positional (the Session.new(channel) form). String positionals +// are the ignored host / user; a Hash is the keyword set. Returns nil when no +// channel is supplied. +func sftpChannelArg(args []object.Value) object.Value { + if len(args) > 0 { + if h, ok := args[len(args)-1].(*object.Hash); ok { + if v, ok := h.Get(object.Symbol("channel")); ok { + return v + } + if v, ok := h.Get(object.NewString("channel")); ok { + return v + } + if v, ok := h.Get(object.Symbol("connection")); ok { + return v + } + if v, ok := h.Get(object.NewString("connection")); ok { + return v + } + args = args[:len(args)-1] + } + } + for _, a := range args { + if _, isStr := a.(*object.String); isStr { + continue + } + if _, isHash := a.(*object.Hash); isHash { + continue + } + return a + } + return nil +} + +// --- argument helpers ------------------------------------------------------ + +// sftpBytes returns the raw bytes of a Ruby String argument (a handle or data +// payload), raising TypeError otherwise. +func sftpBytes(v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + raise("TypeError", "expected a String") + return nil +} + +// sftpStr returns the contents of a Ruby String argument, raising TypeError +// otherwise. +func sftpStr(v object.Value) string { + if s, ok := v.(*object.String); ok { + return s.Str() + } + raise("TypeError", "expected a String") + return "" +} + +// sftpUint64 coerces a Ruby Integer to uint64, raising TypeError otherwise. +func sftpUint64(v object.Value) uint64 { + if n, ok := v.(object.Integer); ok { + return uint64(int64(n)) + } + raise("TypeError", "expected an Integer") + return 0 +} + +// sftpUint32 coerces a Ruby Integer to uint32, raising TypeError otherwise. +func sftpUint32(v object.Value) uint32 { + if n, ok := v.(object.Integer); ok { + return uint32(int64(n)) + } + raise("TypeError", "expected an Integer") + return 0 +} + +// sftpArg returns args[i] or nil when the slice is shorter, so optional trailing +// arguments (flags, mode, attribute Hash) collapse to their defaults. +func sftpArg(args []object.Value, i int) object.Value { + if i < len(args) { + return args[i] + } + return nil +} + +// sftpModeArg returns the open-mode string of an optional argument, defaulting to +// "r" when absent or nil. +func sftpModeArg(v object.Value) string { + if v == nil || v == object.NilV { + return "r" + } + return sftpStr(v) +} + +// sftpAttrsArg builds an Attributes value from an optional Ruby Hash of +// attribute keywords, or an empty structure when absent. +func sftpAttrsArg(v object.Value) *sftp.Attributes { + a := &sftp.Attributes{} + h, ok := v.(*object.Hash) + if !ok { + return a + } + get := func(name string) (object.Value, bool) { + if hv, ok := h.Get(object.Symbol(name)); ok { + return hv, true + } + return h.Get(object.NewString(name)) + } + if v, ok := get("size"); ok { + n := sftpUint64(v) + a.Size = &n + } + if v, ok := get("permissions"); ok { + n := sftpUint32(v) + a.Permissions = &n + } + if v, ok := get("uid"); ok { + n := sftpUint32(v) + a.UID = &n + } + if v, ok := get("gid"); ok { + n := sftpUint32(v) + a.GID = &n + } + if v, ok := get("owner"); ok { + s := sftpStr(v) + a.Owner = &s + } + if v, ok := get("group"); ok { + s := sftpStr(v) + a.Group = &s + } + if v, ok := get("atime"); ok { + n := sftpUint64(v) + a.Atime = &n + } + if v, ok := get("mtime"); ok { + n := sftpUint64(v) + a.Mtime = &n + } + return a +} + +// --- response id + status -------------------------------------------------- + +// sftpRespID returns the echoed request id carried by any parsed response, so the +// driver can correlate it to the request. +func sftpRespID(resp any) uint32 { + switch r := resp.(type) { + case *sftp.StatusResponse: + return r.ID + case *sftp.HandleResponse: + return r.ID + case *sftp.DataResponse: + return r.ID + case *sftp.NameResponse: + return r.ID + default: + return resp.(*sftp.AttrsResponse).ID + } +} + +// raiseStatus builds and raises the gem-faithful Net::SFTP::StatusException from a +// non-success FXP_STATUS, carrying its code / description / text. It never returns. +func (s *sftpSession) raiseStatus(st *sftp.StatusResponse, op string) object.Value { + se := sftp.NewStatusException(st, op) + obj := &RObject{class: s.vm.sftpClasses.statusExc, ivars: map[string]object.Value{ + "@code": object.IntValue(int64(se.Code)), + "@description": object.NewString(se.Description), + "@text": object.NewString(se.Text), + "@message": object.NewString(se.Error()), + }} + panic(s.vm.excError(obj)) +} + +// defineStatusExceptionMethods installs code / description / text / message / +// to_s on Net::SFTP::StatusException, each reading the ivar set at raise time. +func (vm *VM) defineStatusExceptionMethods(cls *RClass) { + cls.define("code", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@code") + }) + cls.define("description", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@description") + }) + cls.define("text", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@text") + }) + msg := func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@message") + } + cls.define("message", msg) + cls.define("to_s", msg) +} + +// --- value objects: Attributes --------------------------------------------- + +// sftpAttrs wraps a decoded *sftp.Attributes as a Ruby +// Net::SFTP::Protocol::V01::Attributes value. +type sftpAttrs struct { + cls *RClass + a *sftp.Attributes +} + +func (a *sftpAttrs) ToS() string { return "#" } +func (a *sftpAttrs) Inspect() string { return "#" } +func (a *sftpAttrs) Truthy() bool { return true } + +// newAttrs stamps the Attributes wrapper class on a decoded attribute structure. +func (s *sftpSession) newAttrs(a *sftp.Attributes) object.Value { + return &sftpAttrs{cls: s.vm.sftpClasses.attrs, a: a} +} + +// optUint64 maps an optional 64-bit field to a Ruby Integer or nil. +func optUint64(p *uint64) object.Value { + if p == nil { + return object.NilV + } + return object.IntValue(int64(*p)) +} + +// optUint32 maps an optional 32-bit field to a Ruby Integer or nil. +func optUint32(p *uint32) object.Value { + if p == nil { + return object.NilV + } + return object.IntValue(int64(*p)) +} + +// optString maps an optional string field to a Ruby String or nil. +func optString(p *string) object.Value { + if p == nil { + return object.NilV + } + return object.NewString(*p) +} + +// registerNetSFTPAttributes installs the Attributes value class and its accessors +// (size/uid/gid/owner/group/permissions/atime/mtime plus the type predicates). +func (vm *VM) registerNetSFTPAttributes(mod *RClass) *RClass { + cls := newClass("Net::SFTP::Protocol::V01::Attributes", vm.cObject) + vm.consts["Net::SFTP::Protocol::V01::Attributes"] = cls + mod.consts["Attributes"] = cls + at := func(v object.Value) *sftp.Attributes { return v.(*sftpAttrs).a } + cls.define("size", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint64(at(self).Size) + }) + cls.define("uid", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint32(at(self).UID) + }) + cls.define("gid", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint32(at(self).GID) + }) + cls.define("owner", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optString(at(self).Owner) + }) + cls.define("group", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optString(at(self).Group) + }) + cls.define("permissions", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint32(at(self).Permissions) + }) + cls.define("atime", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint64(at(self).Atime) + }) + cls.define("mtime", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint64(at(self).Mtime) + }) + cls.define("type", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(at(self).FileType())) + }) + cls.define("directory?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := at(self).IsDirectory() + return object.Bool(val) + }) + cls.define("symlink?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := at(self).IsSymlink() + return object.Bool(val) + }) + cls.define("file?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := at(self).IsFile() + return object.Bool(val) + }) + return cls +} + +// --- value objects: Name --------------------------------------------------- + +// sftpName wraps one FXP_NAME entry as a Ruby Net::SFTP::Name value. +type sftpName struct { + cls *RClass + n sftp.Name + version int +} + +func (n *sftpName) ToS() string { return "#" } +func (n *sftpName) Inspect() string { return "#" } +func (n *sftpName) Truthy() bool { return true } + +// newName stamps the Name wrapper class on a directory entry. +func (s *sftpSession) newName(n sftp.Name) object.Value { + return &sftpName{cls: s.vm.sftpClasses.name, n: n, version: s.version} +} + +// defineNetSFTPNameMethods installs the Name accessors (name/filename/longname/ +// attributes plus the type predicates). +func (vm *VM) defineNetSFTPNameMethods(cls *RClass) { + nm := func(v object.Value) *sftpName { return v.(*sftpName) } + name := func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(nm(self).n.Filename) + } + cls.define("name", name) + cls.define("filename", name) + cls.define("longname", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + e := nm(self) + if e.n.Longname != "" { + return object.NewString(e.n.Longname) + } + return object.NewString(e.n.LongnameFor(time.Local)) + }) + cls.define("attributes", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &sftpAttrs{cls: vm.sftpClasses.attrs, a: nm(self).n.Attributes} + }) + cls.define("directory?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := nm(self).n.IsDirectory() + return object.Bool(val) + }) + cls.define("symlink?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := nm(self).n.IsSymlink() + return object.Bool(val) + }) + cls.define("file?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := nm(self).n.IsFile() + return object.Bool(val) + }) +} + +// --- Session methods ------------------------------------------------------- + +// asSession unwraps a Session receiver. +func asSession(v object.Value) *sftpSession { return v.(*sftpSession) } + +// defineNetSFTPSessionMethods installs the full Session request surface: the +// version accessor, the primitive bang-methods (open!/close!/read!/write!/…), the +// higher-level upload!/download!, and the dir / file operation-helper accessors. +func (vm *VM) defineNetSFTPSessionMethods(cls *RClass) { + cls.define("protocol_version", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(asSession(self).version)) + }) + cls.define("version", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(asSession(self).version)) + }) + + cls.define("open!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + path := sftpStr(sftpArg(args, 0)) + attrs := sftpAttrsArg(sftpArg(args, 2)) + h := s.openHandle(path, sftpModeArg(sftpArg(args, 1)), attrs) + return object.NewStringBytesEnc(append([]byte(nil), h...), "ASCII-8BIT") + }) + cls.define("close!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return asSession(self).closeHandle(sftpBytes(sftpArg(args, 0))) + }) + cls.define("read!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + h := sftpBytes(sftpArg(args, 0)) + off := sftpUint64(sftpArg(args, 1)) + length := sftpUint32(sftpArg(args, 2)) + data, ok := s.readAt(h, off, length) + if !ok { + return object.NilV + } + return object.NewStringBytesEnc(append([]byte(nil), data...), "ASCII-8BIT") + }) + cls.define("write!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + h := sftpBytes(sftpArg(args, 0)) + off := sftpUint64(sftpArg(args, 1)) + data := sftpBytes(sftpArg(args, 2)) + id, frame := s.proto.Write(h, off, data) + return s.okOrRaise(s.response(id, frame), "write") + }) + + vm.defineNetSFTPStatMethods(cls) + vm.defineNetSFTPDirEntryMethods(cls) + vm.defineNetSFTPLinkMethods(cls) + vm.defineNetSFTPTransferMethods(cls) + + cls.define("dir", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &sftpDir{cls: vm.sftpClasses.dir, s: asSession(self)} + }) + cls.define("file", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &sftpFileFactory{cls: vm.sftpClasses.fileFactory, s: asSession(self)} + }) +} + +// defineNetSFTPStatMethods installs stat!/lstat!/fstat! and setstat!/fsetstat!. +func (vm *VM) defineNetSFTPStatMethods(cls *RClass) { + statPath := func(self object.Value, args []object.Value, op string, build func(*sftpSession, string) (uint32, []byte)) object.Value { + s := asSession(self) + id, frame := build(s, sftpStr(sftpArg(args, 0))) + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + return s.raiseStatus(st, op) + } + return s.newAttrs(resp.(*sftp.AttrsResponse).Attrs) + } + cls.define("stat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return statPath(self, args, "stat", func(s *sftpSession, p string) (uint32, []byte) { return s.proto.Stat(p, nil) }) + }) + cls.define("lstat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return statPath(self, args, "lstat", func(s *sftpSession, p string) (uint32, []byte) { return s.proto.Lstat(p, nil) }) + }) + cls.define("fstat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Fstat(sftpBytes(sftpArg(args, 0)), nil) + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + return s.raiseStatus(st, "fstat") + } + return s.newAttrs(resp.(*sftp.AttrsResponse).Attrs) + }) + cls.define("setstat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Setstat(sftpStr(sftpArg(args, 0)), sftpAttrsArg(sftpArg(args, 1))) + return s.okOrRaise(s.response(id, frame), "setstat") + }) + cls.define("fsetstat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Fsetstat(sftpBytes(sftpArg(args, 0)), sftpAttrsArg(sftpArg(args, 1))) + return s.okOrRaise(s.response(id, frame), "fsetstat") + }) +} + +// defineNetSFTPDirEntryMethods installs opendir!/readdir!/mkdir!/rmdir!/remove!/ +// realpath!. +func (vm *VM) defineNetSFTPDirEntryMethods(cls *RClass) { + cls.define("opendir!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Opendir(sftpStr(sftpArg(args, 0))) + h := s.handleOf(s.response(id, frame), "opendir") + return object.NewStringBytesEnc(append([]byte(nil), h...), "ASCII-8BIT") + }) + cls.define("readdir!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + h := sftpBytes(sftpArg(args, 0)) + id, frame := s.proto.Readdir(h) + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + if st.EOF() { + return object.NilV + } + return s.raiseStatus(st, "readdir") + } + nr := resp.(*sftp.NameResponse) + arr := object.NewArray() + for _, n := range nr.Names { + arr.Elems = append(arr.Elems, s.newName(n)) + } + return arr + }) + cls.define("remove!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Remove(sftpStr(sftpArg(args, 0))) + return s.okOrRaise(s.response(id, frame), "remove") + }) + cls.define("mkdir!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Mkdir(sftpStr(sftpArg(args, 0)), sftpAttrsArg(sftpArg(args, 1))) + return s.okOrRaise(s.response(id, frame), "mkdir") + }) + cls.define("rmdir!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Rmdir(sftpStr(sftpArg(args, 0))) + return s.okOrRaise(s.response(id, frame), "rmdir") + }) + cls.define("realpath!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Realpath(sftpStr(sftpArg(args, 0))) + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + return s.raiseStatus(st, "realpath") + } + return s.firstName(resp.(*sftp.NameResponse), "realpath") + }) +} + +// firstName returns the first Name entry of a single-entry FXP_NAME response +// (realpath/readlink), raising when the server returned none. +func (s *sftpSession) firstName(nr *sftp.NameResponse, op string) object.Value { + if len(nr.Names) == 0 { + raise("Net::SFTP::Exception", "%s returned no name entry", op) + } + return object.NewString(nr.Names[0].Filename) +} + +// defineNetSFTPLinkMethods installs rename!/readlink!/symlink!/link!/block!/ +// unblock! — the operations the library gates by protocol version. +func (vm *VM) defineNetSFTPLinkMethods(cls *RClass) { + cls.define("rename!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Rename(sftpStr(sftpArg(args, 0)), sftpStr(sftpArg(args, 1)), nil) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "rename") + }) + cls.define("readlink!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Readlink(sftpStr(sftpArg(args, 0))) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + return s.raiseStatus(st, "readlink") + } + return s.firstName(resp.(*sftp.NameResponse), "readlink") + }) + cls.define("symlink!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Symlink(sftpStr(sftpArg(args, 0)), sftpStr(sftpArg(args, 1))) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "symlink") + }) + cls.define("link!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + sym := sftpArg(args, 2) + symlink := !object.IsNil(sym) && sym.Truthy() + id, frame, err := s.proto.Link(sftpStr(sftpArg(args, 0)), sftpStr(sftpArg(args, 1)), symlink) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "link") + }) + cls.define("block!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Block(sftpBytes(sftpArg(args, 0)), sftpUint64(sftpArg(args, 1)), sftpUint64(sftpArg(args, 2)), sftpUint32(sftpArg(args, 3))) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "block") + }) + cls.define("unblock!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Unblock(sftpBytes(sftpArg(args, 0)), sftpUint64(sftpArg(args, 1)), sftpUint64(sftpArg(args, 2))) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "unblock") + }) +} + +// defineNetSFTPTransferMethods installs upload!/download! — whole-file transfers +// built over the open/read/write/close primitives. +func (vm *VM) defineNetSFTPTransferMethods(cls *RClass) { + cls.define("download!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + h := s.openHandle(sftpStr(sftpArg(args, 0)), "r", &sftp.Attributes{}) + var buf []byte + var off uint64 + for { + data, ok := s.readAt(h, off, sftpDownloadChunk) + if !ok { + break + } + buf = append(buf, data...) + off += uint64(len(data)) + } + s.closeHandle(h) + return object.NewStringBytesEnc(buf, "ASCII-8BIT") + }) + cls.define("upload!", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + data := sftpUploadData(vm, sftpArg(args, 0)) + h := s.openHandle(sftpStr(sftpArg(args, 1)), "w", &sftp.Attributes{}) + id, frame := s.proto.Write(h, 0, data) + s.okOrRaise(s.response(id, frame), "write") + return s.closeHandle(h) + }) +} + +// sftpUploadData reads the upload source: a String is its bytes; any object +// responding to #read is drained via #read. +func sftpUploadData(vm *VM, v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + rv := vm.send(v, "read", nil, nil) + return sftpBytes(rv) +} + +// --- dir operations -------------------------------------------------------- + +// defineNetSFTPDirMethods installs Net::SFTP::Operations::Dir#foreach / #entries / +// #glob over the owning session. +func (vm *VM) defineNetSFTPDirMethods(cls *RClass) { + entries := func(self object.Value, path string) []sftp.Name { + d := self.(*sftpDir) + id, frame := d.s.proto.Opendir(path) + h := d.s.handleOf(d.s.response(id, frame), "opendir") + names := d.s.readdirAll(h) + d.s.closeHandle(h) + return names + } + cls.define("entries", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + d := self.(*sftpDir) + arr := object.NewArray() + for _, n := range entries(self, sftpStr(sftpArg(args, 0))) { + arr.Elems = append(arr.Elems, d.s.newName(n)) + } + return arr + }) + cls.define("foreach", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + if blk == nil { + raise("LocalJumpError", "no block given (yield)") + } + d := self.(*sftpDir) + for _, n := range entries(self, sftpStr(sftpArg(args, 0))) { + vm.callBlock(blk, []object.Value{d.s.newName(n)}) + } + return object.NilV + }) +} + +// --- file operations ------------------------------------------------------- + +// defineNetSFTPFileFactoryMethods installs Net::SFTP::Operations::FileFactory#open +// and #directory?. +func (vm *VM) defineNetSFTPFileFactoryMethods(cls *RClass) { + cls.define("open", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + ff := self.(*sftpFileFactory) + path := sftpStr(sftpArg(args, 0)) + attrs := sftpAttrsArg(sftpArg(args, 2)) + h := ff.s.openHandle(path, sftpModeArg(sftpArg(args, 1)), attrs) + f := &sftpFile{cls: vm.sftpClasses.file, s: ff.s, handle: h} + if blk == nil { + return f + } + res := vm.callBlock(blk, []object.Value{f}) + f.doClose() + return res + }) + cls.define("directory?", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + ff := self.(*sftpFileFactory) + id, frame := ff.s.proto.Stat(sftpStr(sftpArg(args, 0)), nil) + resp := ff.s.response(id, frame) + if _, ok := resp.(*sftp.StatusResponse); ok { + return object.Bool(false) + } + val, _ := resp.(*sftp.AttrsResponse).Attrs.IsDirectory() + return object.Bool(val) + }) +} + +// doClose closes the file's handle once, tolerating a repeated #close. +func (f *sftpFile) doClose() object.Value { + if f.closed { + return object.NilV + } + f.closed = true + return f.s.closeHandle(f.handle) +} + +// defineNetSFTPFileMethods installs Net::SFTP::Operations::File#read / #write / +// #close / #pos / #eof?. +func (vm *VM) defineNetSFTPFileMethods(cls *RClass) { + fl := func(v object.Value) *sftpFile { return v.(*sftpFile) } + cls.define("read", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + f := fl(self) + if n := sftpArg(args, 0); n != nil && n != object.NilV { + data, ok := f.s.readAt(f.handle, f.pos, sftpUint32(n)) + if !ok { + return object.NilV + } + f.pos += uint64(len(data)) + return object.NewStringBytesEnc(append([]byte(nil), data...), "ASCII-8BIT") + } + // No length: read to EOF. + var buf []byte + for { + data, ok := f.s.readAt(f.handle, f.pos, sftpDownloadChunk) + if !ok { + break + } + buf = append(buf, data...) + f.pos += uint64(len(data)) + } + return object.NewStringBytesEnc(buf, "ASCII-8BIT") + }) + cls.define("write", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + f := fl(self) + data := sftpBytes(sftpArg(args, 0)) + id, frame := f.s.proto.Write(f.handle, f.pos, data) + f.s.okOrRaise(f.s.response(id, frame), "write") + f.pos += uint64(len(data)) + return object.IntValue(int64(len(data))) + }) + cls.define("pos", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(fl(self).pos)) + }) + cls.define("close", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return fl(self).doClose() + }) +} diff --git a/internal/vm/net_sftp_internal_test.go b/internal/vm/net_sftp_internal_test.go new file mode 100644 index 00000000..869ac89c --- /dev/null +++ b/internal/vm/net_sftp_internal_test.go @@ -0,0 +1,809 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "fmt" + "strings" + "testing" + + sftp "github.com/go-ruby-net-sftp/net-sftp" +) + +// This is the internal test suite for the Net::SFTP binding (require "net/sftp"). +// SFTP runs over an SSH channel; rbgo injects that channel as a Ruby duck +// responding to #write / #read, so the tests drive the binding over an in-process +// canned channel (the Chan class) whose reply stream is built here with the very +// same wire codec the binding parses. Every response frame is produced by the +// go-ruby-net-sftp Writer / FramePacket, so the suite exercises the full driver +// (init/version negotiation, request framing, response correlation) without any +// SSH, network, or goroutine — the round-trip is fully deterministic. + +// --- wire-frame builders (server side) ------------------------------------- + +func sftpVersionFrame(v uint32) []byte { + w := sftp.NewWriter() + w.WriteUint32(v) + return sftp.FramePacket(sftp.FXP_VERSION, w.Bytes()) +} + +func sftpStatusFrame(id, code uint32, msg string, version int) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteUint32(code) + if version >= 3 { + w.WriteString(msg) + w.WriteString("") + } + return sftp.FramePacket(sftp.FXP_STATUS, w.Bytes()) +} + +func sftpHandleFrame(id uint32, handle []byte) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteBytes(handle) + return sftp.FramePacket(sftp.FXP_HANDLE, w.Bytes()) +} + +func sftpDataFrame(id uint32, data []byte) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteBytes(data) + return sftp.FramePacket(sftp.FXP_DATA, w.Bytes()) +} + +func sftpAttrsFrame(id uint32, a *sftp.Attributes, version int) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteRaw(a.Encode(version)) + return sftp.FramePacket(sftp.FXP_ATTRS, w.Bytes()) +} + +func sftpNameFrame(id uint32, version int, names []sftp.Name) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteUint32(uint32(len(names))) + for _, n := range names { + w.WriteString(n.Filename) + if version < 4 { + w.WriteString(n.Longname) + } + w.WriteRaw(n.Attributes.Encode(version)) + } + return sftp.FramePacket(sftp.FXP_NAME, w.Bytes()) +} + +// u32p / u64p / u8p / strp build the optional-field pointers Attributes uses. +func u32p(v uint32) *uint32 { return &v } +func u64p(v uint64) *uint64 { return &v } + +// --- Ruby channel duck + driver harness ------------------------------------ + +// sftpChanClass is the injected SSH-channel duck: writes captured in @out, reads +// drain the canned reply buffer. @chunk caps each read so the packet-reassembly +// loop can be exercised. +const sftpChanClass = ` +class Chan + def initialize(reply, chunk = nil) + @in = reply.dup.force_encoding("ASCII-8BIT"); @pos = 0; @out = "".b; @chunk = chunk + end + def write(s) ; @out << s ; s.bytesize ; end + def read(n = nil) + avail = @in.bytesize - @pos + return "".b if avail <= 0 + n = avail if n.nil? || n > avail + n = @chunk if @chunk && @chunk < n + c = @in.byteslice(@pos, n) ; @pos += n ; c + end + def out ; @out ; end +end +` + +// rubyBin renders bytes as a Ruby ASCII-8BIT string literal. +func rubyBin(b []byte) string { + var sb strings.Builder + sb.WriteString(`"`) + for _, c := range b { + fmt.Fprintf(&sb, `\x%02x`, c) + } + sb.WriteString(`".b`) + return sb.String() +} + +// concat joins the version frame and each response frame into a reply stream. +func concat(serverVersion uint32, frames ...[]byte) []byte { + out := sftpVersionFrame(serverVersion) + for _, f := range frames { + out = append(out, f...) + } + return out +} + +// sftpSrc assembles a runnable script: the Chan class, require, a started session +// bound to `sftp`, then body. +func sftpSrc(serverVersion uint32, reply []byte, body string) string { + return sftpChanClass + "require \"net/sftp\"\n" + + "ch = Chan.new(" + rubyBin(reply) + ")\n" + + "sftp = Net::SFTP.start(\"host\", \"user\", channel: ch)\n" + body +} + +func sftpEval(t *testing.T, serverVersion uint32, reply []byte, body string) string { + t.Helper() + return eval(t, sftpSrc(serverVersion, reply, body)) +} + +func sftpErr(t *testing.T, serverVersion uint32, reply []byte, body string) (class, msg string) { + t.Helper() + return evalErr(t, sftpSrc(serverVersion, reply, body)) +} + +// --- loadability + surface ------------------------------------------------- + +func TestNetSFTPLoadable(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "net/sftp"`, "true\n"}, + {`require "net/sftp"; p require "net/sftp"`, "false\n"}, + {`require "net/sftp"; p Net::SFTP.is_a?(Module)`, "true\n"}, + {`require "net/sftp"; p Net::SFTP::Session::HIGHEST_PROTOCOL_VERSION_SUPPORTED`, "6\n"}, + {`require "net/sftp"; p Net::SFTP::Exception < StandardError`, "true\n"}, + {`require "net/sftp"; p Net::SFTP::StatusException < Net::SFTP::Exception`, "true\n"}, + {`require "net/sftp"; p Net::SFTP::Constants::StatusCodes::FX_OK`, "0\n"}, + {`require "net/sftp"; p Net::SFTP::Constants::StatusCodes::FX_NO_SUCH_FILE`, "2\n"}, + {`require "net/sftp"; p Net::SFTP::Constants::PacketTypes::FXP_INIT`, "1\n"}, + {`require "net/sftp"; p Net::SFTP::Constants::PacketTypes::FXP_DATA`, "103\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +func TestNetSFTPConnectAndVersion(t *testing.T) { + // Server offers v6 -> negotiated min(6,6)=6. + if got := sftpEval(t, 6, concat(6), "p sftp.protocol_version\np sftp.version"); got != "6\n6\n" { + t.Errorf("v6 got=%q", got) + } + // Server offers v3 -> negotiated 3. + if got := sftpEval(t, 3, concat(3), "p sftp.protocol_version"); got != "3\n" { + t.Errorf("v3 got=%q", got) + } + // The FXP_INIT frame the client wrote advertises version 6. + got := sftpEval(t, 3, concat(3), `p ch.out.bytesize > 0`) + if got != "true\n" { + t.Errorf("init written got=%q", got) + } +} + +// --- channel resolution + start forms -------------------------------------- + +func TestNetSFTPChannelResolution(t *testing.T) { + reply := rubyBin(concat(3)) + // Session.new(channel) positional form. + src := sftpChanClass + "require \"net/sftp\"\np Net::SFTP::Session.new(Chan.new(" + reply + ")).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("Session.new positional got=%q", got) + } + // A leading Hash positional is skipped, the channel is the next positional. + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP::Session.new({}, Chan.new(" + reply + ")).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("Session.new hash-then-channel got=%q", got) + } + // connection: keyword (symbol). + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP.start(connection: Chan.new(" + reply + ")).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("connection: symbol got=%q", got) + } + // String-keyed channel. + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP.start(\"h\", \"u\", {\"channel\" => Chan.new(" + reply + ")}).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("string channel got=%q", got) + } + // String-keyed connection. + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP.start(\"h\", \"u\", {\"connection\" => Chan.new(" + reply + ")}).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("string connection got=%q", got) + } + // Trailing hash without a channel keyword -> stripped, positional scanned. + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP::Session.new(Chan.new(" + reply + "), {db: 1}).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("trailing non-channel hash got=%q", got) + } +} + +func TestNetSFTPStartYieldsBlock(t *testing.T) { + got := sftpEval(t, 3, concat(3), "") // start already ran with body="" and a session bound + _ = got + src := sftpChanClass + "require \"net/sftp\"\n" + + "Net::SFTP.start(\"h\", \"u\", channel: Chan.new(" + rubyBin(concat(3)) + ")) { |s| p s.version }" + if got := eval(t, src); got != "3\n" { + t.Errorf("start block got=%q", got) + } +} + +func TestNetSFTPNoChannel(t *testing.T) { + cls, _ := evalErr(t, `require "net/sftp"; Net::SFTP.start("h", "u")`) + if cls != "ArgumentError" { + t.Errorf("no channel: class=%q", cls) + } +} + +// --- open / close / read / write ------------------------------------------- + +func TestNetSFTPOpenCloseReadWrite(t *testing.T) { + handle := []byte("H1") + // open! (v3, OpenFlagsV1 path) returns the handle bytes. + reply := concat(3, sftpHandleFrame(0, handle)) + if got := sftpEval(t, 3, reply, `p sftp.open!("/f").bytesize`); got != "2\n" { + t.Errorf("open! got=%q", got) + } + // open! on a v6 server drives the OpenFlagsV5 path. + if got := sftpEval(t, 6, concat(6, sftpHandleFrame(0, handle)), `p sftp.open!("/f", "w").bytesize`); got != "2\n" { + t.Errorf("open! v6 got=%q", got) + } + // open! with a mode and permissions attribute Hash. + if got := sftpEval(t, 3, concat(3, sftpHandleFrame(0, handle)), `p sftp.open!("/f", "w", permissions: 0644).bytesize`); got != "2\n" { + t.Errorf("open! attrs got=%q", got) + } + // open! bad mode -> ArgumentError. + if cls, _ := sftpErr(t, 3, concat(3), `sftp.open!("/f", "z")`); cls != "ArgumentError" { + t.Errorf("open! bad mode class=%q", cls) + } + // open! failure -> StatusException. + cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "nope", 3)), `sftp.open!("/f")`) + if cls != "Net::SFTP::StatusException" { + t.Errorf("open! fail class=%q", cls) + } + // close! ok. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.close!("H")`); got != "nil\n" { + t.Errorf("close! got=%q", got) + } + // close! error. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "bad", 3)), `sftp.close!("H")`); cls != "Net::SFTP::StatusException" { + t.Errorf("close! err class=%q", cls) + } + // read! returns data. + if got := sftpEval(t, 3, concat(3, sftpDataFrame(0, []byte("abc"))), `p sftp.read!("H", 0, 10)`); got != "\"abc\"\n" { + t.Errorf("read! got=%q", got) + } + // read! at EOF returns nil. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_EOF, "", 3)), `p sftp.read!("H", 0, 10)`); got != "nil\n" { + t.Errorf("read! eof got=%q", got) + } + // read! error status raises. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "x", 3)), `sftp.read!("H", 0, 10)`); cls != "Net::SFTP::StatusException" { + t.Errorf("read! err class=%q", cls) + } + // write! ok + error. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.write!("H", 0, "data")`); got != "nil\n" { + t.Errorf("write! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_PERMISSION_DENIED, "", 3)), `sftp.write!("H", 0, "d")`); cls != "Net::SFTP::StatusException" { + t.Errorf("write! err class=%q", cls) + } +} + +// --- stat family ----------------------------------------------------------- + +func TestNetSFTPStat(t *testing.T) { + a := &sftp.Attributes{Size: u64p(1234), Permissions: u32p(0o100644), UID: u32p(7), GID: u32p(8), Atime: u64p(111), Mtime: u64p(222)} + reply := concat(3, sftpAttrsFrame(0, a, 3)) + body := `st = sftp.stat!("/f") +p st.size +p st.permissions +p st.uid +p st.gid +p st.atime +p st.mtime +p st.file? +p st.directory? +p st.symlink? +p st.type` + want := "1234\n33188\n7\n8\n111\n222\ntrue\nfalse\nfalse\n1\n" + if got := sftpEval(t, 3, reply, body); got != want { + t.Errorf("stat! got=%q want=%q", got, want) + } + // lstat!. + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, a, 3)), `p sftp.lstat!("/f").size`); got != "1234\n" { + t.Errorf("lstat! got=%q", got) + } + // fstat!. + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, a, 3)), `p sftp.fstat!("H").size`); got != "1234\n" { + t.Errorf("fstat! got=%q", got) + } + // stat! failure raises. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)), `sftp.stat!("/x")`); cls != "Net::SFTP::StatusException" { + t.Errorf("stat! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)), `sftp.lstat!("/x")`); cls != "Net::SFTP::StatusException" { + t.Errorf("lstat! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)), `sftp.fstat!("H")`); cls != "Net::SFTP::StatusException" { + t.Errorf("fstat! err class=%q", cls) + } +} + +func TestNetSFTPAttributesNilFields(t *testing.T) { + // A directory with only the type bit set: optional numeric fields are nil. + a := &sftp.Attributes{Permissions: u32p(0o040755)} + body := `st = sftp.stat!("/d") +p st.size +p st.uid +p st.gid +p st.owner +p st.group +p st.directory?` + want := "nil\nnil\nnil\nnil\nnil\ntrue\n" + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, a, 3)), body); got != want { + t.Errorf("nil-fields got=%q want=%q", got, want) + } + // A v4 attrs with owner/group strings and a symlink type. + owner, group := "root", "wheel" + sa := &sftp.Attributes{Type: func() *uint8 { v := uint8(sftp.TSymlink); return &v }(), Owner: &owner, Group: &group, Permissions: u32p(0o120777)} + body = `st = sftp.stat!("/l") +p st.owner +p st.group +p st.symlink?` + if got := sftpEval(t, 6, concat(6, sftpAttrsFrame(0, sa, 6)), body); got != "\"root\"\n\"wheel\"\ntrue\n" { + t.Errorf("v6 owner/group got=%q", got) + } +} + +// --- setstat / mkdir attrs ------------------------------------------------- + +func TestNetSFTPSetstat(t *testing.T) { + // A full attribute Hash covers every sftpAttrsArg branch; one key is a String + // so the symbol->string fallback lookup runs too. + attrsHash := `{size: 10, permissions: 0644, uid: 1, gid: 2, owner: "a", group: "b", atime: 3, "mtime" => 4}` + if got := sftpEval(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_OK, "", 6)), `p sftp.setstat!("/f", `+attrsHash+`)`); got != "nil\n" { + t.Errorf("setstat! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.setstat!("/f", {})`); cls != "Net::SFTP::StatusException" { + t.Errorf("setstat! err class=%q", cls) + } + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.fsetstat!("H", {size: 1})`); got != "nil\n" { + t.Errorf("fsetstat! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.fsetstat!("H", {})`); cls != "Net::SFTP::StatusException" { + t.Errorf("fsetstat! err class=%q", cls) + } +} + +// --- directory ops --------------------------------------------------------- + +func TestNetSFTPDirEntryOps(t *testing.T) { + // mkdir!/rmdir!/remove! ok + error. + for _, m := range []string{"mkdir!", "rmdir!", "remove!"} { + call := m + arg := `"/d"` + if m == "mkdir!" { + arg = `"/d", {permissions: 0755}` + } + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.`+call+`(`+arg+`)`); got != "nil\n" { + t.Errorf("%s got=%q", m, got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.`+call+`(`+arg+`)`); cls != "Net::SFTP::StatusException" { + t.Errorf("%s err class=%q", m, cls) + } + } + // opendir! returns handle, error raises. + if got := sftpEval(t, 3, concat(3, sftpHandleFrame(0, []byte("D"))), `p sftp.opendir!("/d")`); got != "\"D\"\n" { + t.Errorf("opendir! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_PATH, "", 3)), `sftp.opendir!("/d")`); cls != "Net::SFTP::StatusException" { + t.Errorf("opendir! err class=%q", cls) + } +} + +func TestNetSFTPReaddir(t *testing.T) { + fa := &sftp.Attributes{Permissions: u32p(0o100644), Size: u64p(5), Owner: strPtr("u"), Group: strPtr("g"), Atime: u64p(900), Mtime: u64p(1000)} + da := &sftp.Attributes{Permissions: u32p(0o040755)} + names := []sftp.Name{ + {Filename: "file.txt", Longname: "-rw-r--r-- file.txt", Attributes: fa}, + {Filename: "sub", Longname: "drwxr-xr-x sub", Attributes: da}, + } + // readdir! returns an array of Name entries with accessors. + body := `es = sftp.readdir!("D") +p es.length +p es[0].name +p es[0].filename +p es[0].longname +p es[0].file? +p es[1].directory? +p es[0].attributes.size` + want := "2\n\"file.txt\"\n\"file.txt\"\n\"-rw-r--r-- file.txt\"\ntrue\ntrue\n5\n" + if got := sftpEval(t, 3, concat(3, sftpNameFrame(0, 3, names)), body); got != want { + t.Errorf("readdir! got=%q want=%q", got, want) + } + // readdir! at EOF returns nil. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_EOF, "", 3)), `p sftp.readdir!("D")`); got != "nil\n" { + t.Errorf("readdir! eof got=%q", got) + } + // readdir! error raises. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.readdir!("D")`); cls != "Net::SFTP::StatusException" { + t.Errorf("readdir! err class=%q", cls) + } + // v6 longname is synthesised from attributes (no wire longname). + v6names := []sftp.Name{{Filename: "f", Attributes: fa}} + got := sftpEval(t, 6, concat(6, sftpNameFrame(0, 6, v6names)), `p sftp.readdir!("D")[0].longname.include?("f")`) + if got != "true\n" { + t.Errorf("v6 longname got=%q", got) + } +} + +// --- realpath / links ------------------------------------------------------ + +func TestNetSFTPRealpathAndLinks(t *testing.T) { + da := &sftp.Attributes{Permissions: u32p(0o040755)} + name := []sftp.Name{{Filename: "/abs/path", Attributes: da}} + // realpath! returns the canonical name. + if got := sftpEval(t, 3, concat(3, sftpNameFrame(0, 3, name)), `p sftp.realpath!("x")`); got != "\"/abs/path\"\n" { + t.Errorf("realpath! got=%q", got) + } + // realpath! error raises. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_PATH, "", 3)), `sftp.realpath!("x")`); cls != "Net::SFTP::StatusException" { + t.Errorf("realpath! err class=%q", cls) + } + // realpath! with an empty NAME response raises the codec exception. + if cls, _ := sftpErr(t, 3, concat(3, sftpNameFrame(0, 3, nil)), `sftp.realpath!("x")`); cls != "Net::SFTP::Exception" { + t.Errorf("realpath! empty class=%q", cls) + } + + // rename! ok (v3) + error (server failure). + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.rename!("a", "b")`); got != "nil\n" { + t.Errorf("rename! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.rename!("a", "b")`); cls != "Net::SFTP::StatusException" { + t.Errorf("rename! err class=%q", cls) + } + // rename! unavailable in v1 -> the library gate raises Net::SFTP::Exception. + if cls, _ := sftpErr(t, 1, concat(1), `sftp.rename!("a", "b")`); cls != "Net::SFTP::Exception" { + t.Errorf("rename! v1 class=%q", cls) + } + + // readlink! ok returns target; error raises; v2 gate raises. + tn := []sftp.Name{{Filename: "target", Attributes: da}} + if got := sftpEval(t, 3, concat(3, sftpNameFrame(0, 3, tn)), `p sftp.readlink!("l")`); got != "\"target\"\n" { + t.Errorf("readlink! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.readlink!("l")`); cls != "Net::SFTP::StatusException" { + t.Errorf("readlink! err class=%q", cls) + } + if cls, _ := sftpErr(t, 2, concat(2), `sftp.readlink!("l")`); cls != "Net::SFTP::Exception" { + t.Errorf("readlink! v2 class=%q", cls) + } + + // symlink! ok (v3) + error + v2 gate. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.symlink!("l", "t")`); got != "nil\n" { + t.Errorf("symlink! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.symlink!("l", "t")`); cls != "Net::SFTP::StatusException" { + t.Errorf("symlink! err class=%q", cls) + } + if cls, _ := sftpErr(t, 2, concat(2), `sftp.symlink!("l", "t")`); cls != "Net::SFTP::Exception" { + t.Errorf("symlink! v2 class=%q", cls) + } + + // link! ok (v6) + error + v3 gate; block! / unblock! same. + if got := sftpEval(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_OK, "", 6)), `p sftp.link!("n", "e", true)`); got != "nil\n" { + t.Errorf("link! got=%q", got) + } + if cls, _ := sftpErr(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_FAILURE, "", 6)), `sftp.link!("n", "e", false)`); cls != "Net::SFTP::StatusException" { + t.Errorf("link! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3), `sftp.link!("n", "e")`); cls != "Net::SFTP::Exception" { + t.Errorf("link! v3 class=%q", cls) + } + if got := sftpEval(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_OK, "", 6)), `p sftp.block!("H", 0, 10, 1)`); got != "nil\n" { + t.Errorf("block! got=%q", got) + } + if cls, _ := sftpErr(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_LOCK_CONFLICT, "", 6)), `sftp.block!("H", 0, 10, 1)`); cls != "Net::SFTP::StatusException" { + t.Errorf("block! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3), `sftp.block!("H", 0, 10, 1)`); cls != "Net::SFTP::Exception" { + t.Errorf("block! v3 class=%q", cls) + } + if got := sftpEval(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_OK, "", 6)), `p sftp.unblock!("H", 0, 10)`); got != "nil\n" { + t.Errorf("unblock! got=%q", got) + } + if cls, _ := sftpErr(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_FAILURE, "", 6)), `sftp.unblock!("H", 0, 10)`); cls != "Net::SFTP::StatusException" { + t.Errorf("unblock! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3), `sftp.unblock!("H", 0, 10)`); cls != "Net::SFTP::Exception" { + t.Errorf("unblock! v3 class=%q", cls) + } +} + +// --- upload / download ----------------------------------------------------- + +func TestNetSFTPTransfer(t *testing.T) { + // download!: open -> read (data) -> read (EOF) -> close. + reply := concat(3, + sftpHandleFrame(0, []byte("H")), + sftpDataFrame(1, []byte("hello world")), + sftpStatusFrame(2, sftp.FX_EOF, "", 3), + sftpStatusFrame(3, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, reply, `p sftp.download!("/remote")`); got != "\"hello world\"\n" { + t.Errorf("download! got=%q", got) + } + // download! read error raises. + badReply := concat(3, + sftpHandleFrame(0, []byte("H")), + sftpStatusFrame(1, sftp.FX_PERMISSION_DENIED, "", 3), + ) + if cls, _ := sftpErr(t, 3, badReply, `sftp.download!("/r")`); cls != "Net::SFTP::StatusException" { + t.Errorf("download! err class=%q", cls) + } + // upload! from a String: open -> write -> close. + up := concat(3, + sftpHandleFrame(0, []byte("H")), + sftpStatusFrame(1, sftp.FX_OK, "", 3), + sftpStatusFrame(2, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, up, `p sftp.upload!("payload", "/remote")`); got != "nil\n" { + t.Errorf("upload! got=%q", got) + } + // upload! from an IO-like source (responds to #read). + src := sftpChanClass + "require \"net/sftp\"\n" + + "require \"stringio\"\n" + + "ch = Chan.new(" + rubyBin(up) + ")\n" + + "sftp = Net::SFTP.start(channel: ch)\n" + + `p sftp.upload!(StringIO.new("io-payload"), "/remote")` + if got := eval(t, src); got != "nil\n" { + t.Errorf("upload! io got=%q", got) + } +} + +// --- dir / file operation helpers ------------------------------------------ + +func TestNetSFTPDirHelper(t *testing.T) { + fa := &sftp.Attributes{Permissions: u32p(0o100644), Size: u64p(3), Atime: u64p(1), Mtime: u64p(1)} + names := []sftp.Name{{Filename: "a", Longname: "a", Attributes: fa}, {Filename: "b", Longname: "b", Attributes: fa}} + // dir.entries: opendir -> readdir(names) -> readdir(EOF) -> close. + reply := concat(3, + sftpHandleFrame(0, []byte("D")), + sftpNameFrame(1, 3, names), + sftpStatusFrame(2, sftp.FX_EOF, "", 3), + sftpStatusFrame(3, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, reply, `p sftp.dir.entries("/d").map(&:name)`); got != "[\"a\", \"b\"]\n" { + t.Errorf("dir.entries got=%q", got) + } + // dir.foreach yields each entry. + reply = concat(3, + sftpHandleFrame(0, []byte("D")), + sftpNameFrame(1, 3, names), + sftpStatusFrame(2, sftp.FX_EOF, "", 3), + sftpStatusFrame(3, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, reply, `sftp.dir.foreach("/d") { |e| puts e.name }`); got != "a\nb\n" { + t.Errorf("dir.foreach got=%q", got) + } + // dir.foreach without a block raises. + reply = concat(3, sftpHandleFrame(0, []byte("D")), sftpStatusFrame(1, sftp.FX_EOF, "", 3), sftpStatusFrame(2, sftp.FX_OK, "", 3)) + if cls, _ := sftpErr(t, 3, reply, `sftp.dir.foreach("/d")`); cls != "LocalJumpError" { + t.Errorf("dir.foreach no-block class=%q", cls) + } +} + +func TestNetSFTPFileHelper(t *testing.T) { + // file.open block form: open -> (read data, read EOF) -> close. + reply := concat(3, + sftpHandleFrame(0, []byte("F")), + sftpDataFrame(1, []byte("abc")), + sftpStatusFrame(2, sftp.FX_EOF, "", 3), + sftpStatusFrame(3, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, reply, `sftp.file.open("/f") { |f| p f.read }`); got != "\"abc\"\n" { + t.Errorf("file.open block read got=%q", got) + } + // file.open non-block form + explicit read(len)/pos/close (with a redundant + // second close to hit the already-closed guard). + reply = concat(3, + sftpHandleFrame(0, []byte("F")), + sftpDataFrame(1, []byte("xy")), + sftpStatusFrame(2, sftp.FX_OK, "", 3), + ) + body := `f = sftp.file.open("/f", "r") +p f.read(2) +p f.pos +p f.close +p f.close` + if got := sftpEval(t, 3, reply, body); got != "\"xy\"\n2\nnil\nnil\n" { + t.Errorf("file.open explicit got=%q", got) + } + // file read(len) at EOF returns nil. + reply = concat(3, sftpHandleFrame(0, []byte("F")), sftpStatusFrame(1, sftp.FX_EOF, "", 3)) + if got := sftpEval(t, 3, reply, `p sftp.file.open("/f").read(4)`); got != "nil\n" { + t.Errorf("file read eof got=%q", got) + } + // file.write advances pos and returns the byte count. + reply = concat(3, sftpHandleFrame(0, []byte("F")), sftpStatusFrame(1, sftp.FX_OK, "", 3)) + body = `f = sftp.file.open("/f", "w") +p f.write("hello") +p f.pos` + if got := sftpEval(t, 3, reply, body); got != "5\n5\n" { + t.Errorf("file.write got=%q", got) + } + // file.open with attributes Hash (mode + permissions). + reply = concat(3, sftpHandleFrame(0, []byte("F")), sftpStatusFrame(1, sftp.FX_OK, "", 3)) + if got := sftpEval(t, 3, reply, `p sftp.file.open("/f", "w", permissions: 0600).close`); got != "nil\n" { + t.Errorf("file.open attrs got=%q", got) + } + // file.directory? true and false. + da := &sftp.Attributes{Permissions: u32p(0o040755)} + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, da, 3)), `p sftp.file.directory?("/d")`); got != "true\n" { + t.Errorf("file.directory? true got=%q", got) + } + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)), `p sftp.file.directory?("/x")`); got != "false\n" { + t.Errorf("file.directory? false got=%q", got) + } +} + +// --- StatusException surface ----------------------------------------------- + +func TestNetSFTPStatusException(t *testing.T) { + // The raised StatusException exposes code / description / text / message. + reply := concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)) + body := `begin + sftp.stat!("/missing") +rescue Net::SFTP::StatusException => e + p e.code + p e.description + p e.text + p e.message.include?("stat") + p e.to_s.include?("no such file") +end` + want := "2\n\"no such file\"\n\"stat\"\ntrue\ntrue\n" + if got := sftpEval(t, 3, reply, body); got != want { + t.Errorf("StatusException got=%q want=%q", got, want) + } + // A server-supplied message overrides the canonical description. + reply = concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "custom boom", 3)) + body = `begin + sftp.remove!("/x") +rescue Net::SFTP::StatusException => e + p e.description +end` + if got := sftpEval(t, 3, reply, body); got != "\"custom boom\"\n" { + t.Errorf("StatusException custom msg got=%q", got) + } +} + +// --- driver / protocol error paths ----------------------------------------- + +func TestNetSFTPProtocolErrors(t *testing.T) { + // Channel closes before a complete packet -> Net::SFTP::Exception. + if cls, _ := evalErr(t, sftpChanClass+"require \"net/sftp\"\nNet::SFTP.start(channel: Chan.new(\"\".b))"); cls != "Net::SFTP::Exception" { + t.Errorf("empty channel class=%q", cls) + } + // First packet is not FXP_VERSION. + notVersion := sftpStatusFrame(0, sftp.FX_OK, "", 3) + src := sftpChanClass + "require \"net/sftp\"\nNet::SFTP.start(channel: Chan.new(" + rubyBin(notVersion) + "))" + if cls, _ := evalErr(t, src); cls != "Net::SFTP::Exception" { + t.Errorf("non-version class=%q", cls) + } + // A truncated FXP_VERSION payload (no version word) -> ParseVersion error. + badVer := sftp.FramePacket(sftp.FXP_VERSION, []byte{0x00}) + src = sftpChanClass + "require \"net/sftp\"\nNet::SFTP.start(channel: Chan.new(" + rubyBin(badVer) + "))" + if cls, _ := evalErr(t, src); cls != "Net::SFTP::Exception" { + t.Errorf("bad-version class=%q", cls) + } + // Server version 0 -> NegotiateVersion error. + if cls, _ := evalErr(t, sftpChanClass+"require \"net/sftp\"\nNet::SFTP.start(channel: Chan.new("+rubyBin(sftpVersionFrame(0))+"))"); cls != "Net::SFTP::Exception" { + t.Errorf("version-0 class=%q", cls) + } + // Unknown response packet type -> ParseResponse error. + unknown := sftp.FramePacket(sftp.FXP_EXTENDED_REPLY, []byte{0, 0, 0, 0}) + if cls, _ := sftpErr(t, 3, concat(3, unknown), `sftp.stat!("/f")`); cls != "Net::SFTP::Exception" { + t.Errorf("unknown-response class=%q", cls) + } + // Response id mismatch (a HANDLE echoing id 9 for request id 0). + if cls, _ := sftpErr(t, 3, concat(3, sftpHandleFrame(9, []byte("H"))), `sftp.open!("/f")`); cls != "Net::SFTP::Exception" { + t.Errorf("id-mismatch class=%q", cls) + } + // Packet reassembly across multiple chunked reads (@chunk = 1). + reply := concat(3, sftpHandleFrame(0, []byte("H"))) + src = sftpChanClass + "require \"net/sftp\"\n" + + "ch = Chan.new(" + rubyBin(reply) + ", 1)\n" + + "sftp = Net::SFTP.start(channel: ch)\n" + + `p sftp.open!("/f")` + if got := eval(t, src); got != "\"H\"\n" { + t.Errorf("chunked read got=%q", got) + } +} + +// --- argument type errors -------------------------------------------------- + +func TestNetSFTPArgTypeErrors(t *testing.T) { + cases := []struct{ body, class string }{ + {`sftp.close!(123)`, "TypeError"}, // handle not a String + {`sftp.stat!(123)`, "TypeError"}, // path not a String + {`sftp.read!("H", "x", 1)`, "TypeError"}, // offset not an Integer + {`sftp.read!("H", 0, "x")`, "TypeError"}, // length not an Integer + } + for _, c := range cases { + if cls, _ := sftpErr(t, 3, concat(3), c.body); cls != c.class { + t.Errorf("body=%q got class=%q want=%q", c.body, cls, c.class) + } + } +} + +// --- wrapper reprs + remaining branches ------------------------------------ + +func TestNetSFTPWrapperReprs(t *testing.T) { + // Session / dir / file-factory to_s / inspect / truthiness. + reply := concat(3, sftpHandleFrame(0, []byte("F")), sftpStatusFrame(1, sftp.FX_OK, "", 3)) + body := `p sftp.to_s +p sftp.inspect +p(sftp ? "y" : "n") +d = sftp.dir +p d.to_s +p d.inspect +p(d ? 1 : 0) +ff = sftp.file +p ff.to_s +p ff.inspect +p(ff ? 1 : 0) +f = ff.open("/f", "w") +p f.to_s +p f.inspect +p(f ? 1 : 0)` + want := "\"#\"\n\"#\"\n\"y\"\n" + + "\"#\"\n\"#\"\n1\n" + + "\"#\"\n\"#\"\n1\n" + + "\"#\"\n\"#\"\n1\n" + if got := sftpEval(t, 3, reply, body); got != want { + t.Errorf("wrapper reprs got=%q want=%q", got, want) + } + + // Attributes wrapper to_s / inspect / truthiness. + a := &sftp.Attributes{Permissions: u32p(0o100644)} + abody := `st = sftp.stat!("/f") +p st.to_s +p st.inspect +p(st ? 1 : 0)` + awant := "\"#\"\n\"#\"\n1\n" + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, a, 3)), abody); got != awant { + t.Errorf("attrs reprs got=%q", got) + } + + // Name wrapper to_s / inspect / truthiness / symlink?. + sa := &sftp.Attributes{Permissions: u32p(0o120777)} + names := []sftp.Name{{Filename: "l", Longname: "l", Attributes: sa}} + nbody := `n = sftp.readdir!("D")[0] +p n.to_s +p n.inspect +p(n ? 1 : 0) +p n.symlink?` + nwant := "\"#\"\n\"#\"\n1\ntrue\n" + if got := sftpEval(t, 3, concat(3, sftpNameFrame(0, 3, names)), nbody); got != nwant { + t.Errorf("name reprs got=%q", got) + } +} + +func TestNetSFTPDirEntriesError(t *testing.T) { + // dir.entries whose readdir yields an error status raises (readdirAll branch). + reply := concat(3, + sftpHandleFrame(0, []byte("D")), + sftpStatusFrame(1, sftp.FX_FAILURE, "boom", 3), + ) + if cls, _ := sftpErr(t, 3, reply, `sftp.dir.entries("/d")`); cls != "Net::SFTP::StatusException" { + t.Errorf("dir.entries error class=%q", cls) + } +} + +func TestNetSFTPChannelReadNil(t *testing.T) { + // A channel whose #read returns nil (not a String) is treated as a closed + // stream (sftpReadBytes nil branch -> Net::SFTP::Exception at connect). + src := "class NilChan\n def write(s); s.bytesize; end\n def read(n = nil); nil; end\nend\n" + + "require \"net/sftp\"\nNet::SFTP.start(channel: NilChan.new)" + if cls, _ := evalErr(t, src); cls != "Net::SFTP::Exception" { + t.Errorf("nil-read class=%q", cls) + } +} + +func strPtr(s string) *string { return &s } diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..875b1876 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -974,6 +974,18 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.consts["Typhoeus::Response"].(*RClass) case *TyphoeusHydra: return vm.consts["Typhoeus::Hydra"].(*RClass) + case *sftpSession: + return x.cls + case *sftpDir: + return x.cls + case *sftpFileFactory: + return x.cls + case *sftpFile: + return x.cls + case *sftpAttrs: + return x.cls + case *sftpName: + return x.cls case *RedisObj: return x.cls case *RedisBatch: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..0c3887e6 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -33,6 +33,7 @@ var providedFeatures = map[string]bool{ "concurrent": true, "syslog": true, "cgi": true, "monitor": true, "observer": true, "socket": true, + "net/sftp": true, "net/http": true, "net/https": true, "resolv": true, "singleton": true, "optparse": true, "ripper": true, "erb": true, "find": true, "tempfile": true, "open3": true, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 1968ce26..db994ed7 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -285,6 +285,7 @@ type VM struct { cActionMailerAttachments *RClass // the internal `attachments` proxy class (Mail::AttachmentsList analogue) amDefs map[*RClass]*amDef // ActionMailer: per-mailer-class default/delivery/hook declarations, merged down the chain at delivery time amDeliveries *object.Array // ActionMailer: the shared ActionMailer::Base.deliveries Array the :test delivery method appends to + sftpClasses *sftpClassSet // Net::SFTP: the wrapper classes stamped on returned Attributes / Name / File values (require "net/sftp") amMailerOf map[*RObject]*actionmailer.Mailer // ActionMailer: mailer instance -> library *Mailer of its running action (the mail/attachments/headers seam) amRenderer object.Value // ActionMailer: the Ruby renderer the RenderBody seam sends #render to (Action View wiring / test stub) amEnqueuer object.Value // ActionMailer: the Ruby enqueuer the deliver_later seam sends #enqueue to (Active Job wiring / test stub) From 29bef47a785ecbbe9be0fcddfbad9e06e81b0572 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:42:01 +0200 Subject: [PATCH 5/9] Bind pure-Go net-ftp into rbgo (require "net/ftp") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 1 + go.sum | 2 + internal/vm/builtins.go | 1 + internal/vm/net_ftp.go | 754 +++++++++++++ internal/vm/net_ftp_bind.go | 486 ++++++++ internal/vm/net_ftp_internal_test.go | 1531 ++++++++++++++++++++++++++ internal/vm/object_model.go | 4 + internal/vm/require.go | 2 +- 8 files changed, 2780 insertions(+), 1 deletion(-) create mode 100644 internal/vm/net_ftp.go create mode 100644 internal/vm/net_ftp_bind.go create mode 100644 internal/vm/net_ftp_internal_test.go diff --git a/go.mod b/go.mod index f56c9fd7..168cf25b 100644 --- a/go.mod +++ b/go.mod @@ -88,6 +88,7 @@ require ( github.com/go-ruby-mustache/mustache v0.0.0-20260701123847-26d5e451677a github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 + github.com/go-ruby-net-ftp/net-ftp v0.0.0-20260630142044-917edc09066e github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 diff --git a/go.sum b/go.sum index a357f88a..dc2e77ba 100644 --- a/go.sum +++ b/go.sum @@ -338,6 +338,8 @@ github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d h1:tlOKd49moGe github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d/go.mod h1:ppWK2CmOURx2o9vRsiGVeIZHI/AjCSq0RX74ssEZG8g= github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 h1:F+ghhcdLDBVGG977zAq2WQGos1HgRLj/jHJqkIiLL6E= github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7/go.mod h1:cm0D4pmaTvvQFPK815E7/uJaGeGxkr5xFLHixeQixTA= +github.com/go-ruby-net-ftp/net-ftp v0.0.0-20260630142044-917edc09066e h1:OJa/XKKm+bxdCoM3UKpmsgnk8hl3bFz25jEbZCvd08I= +github.com/go-ruby-net-ftp/net-ftp v0.0.0-20260630142044-917edc09066e/go.mod h1:1H5yOndya/hONJxNjd0oAbRyCAyurxcbze0FXlF2ExA= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f h1:nS0qwCPtVAtp+48j6KkdTdpVL8jT9L2ZGQcJZwOKev8= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f/go.mod h1:PiNQDuyrSPZX8OzApvgyn1fTjRNwx0KNO1vrzdAguEc= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e h1:v8DyCc3Vp+REhN1uyQ4wN8CpJ6JMqWNOm9OESWFBtLU= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..d5ff61c3 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -425,6 +425,7 @@ func (vm *VM) bootstrap() { vm.registerSocket() // TCPSocket/TCPServer (net) + OpenSSL::SSL::SSLSocket (crypto/tls); after registerOpenSSL (upgrades its SSL shell) vm.registerNetHTTP() // net/http + net/https loadable shell; needs StandardError vm.registerNetHTTPTransport() // real Net::HTTP over the socket transport; after registerNetHTTP + registerSocket + vm.registerNetFTP() // real Net::FTP over the socket transport; after registerNetHTTP (Net module) + registerSocket/registerOpenSSL vm.registerResolv() // Resolv (real IPv4/IPv6 parse; DNS sockets stubbed); needs StandardError vm.registerTimeout() // Timeout module (loadable shell); needs RuntimeError vm.registerDateErrors() // Date::Error < ArgumentError (Date class itself registered early); needs ArgumentError diff --git a/internal/vm/net_ftp.go b/internal/vm/net_ftp.go new file mode 100644 index 00000000..a68c9877 --- /dev/null +++ b/internal/vm/net_ftp.go @@ -0,0 +1,754 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bufio" + "bytes" + "net" + "os" + "strconv" + "strings" + stdtime "time" + + gotime "github.com/go-composites/time/src" + netftp "github.com/go-ruby-net-ftp/net-ftp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerNetFTP installs Net::FTP (require "net/ftp"): a real FTP client that +// drives control and data sockets (Go net) on top of the interpreter-independent +// FTP codec github.com/go-ruby-net-ftp/net-ftp. The codec owns every observable +// byte (command construction, reply classification, PASV/EPSV extraction, MLSx +// parsing); this binding owns the sockets and maps the Ruby surface — connect / +// login, the directory and file commands, the LIST/NLST/MLSD/MLST listings, the +// retr*/stor* transfer engines, and the get*/put* file helpers — onto it. +// +// It runs after registerNetHTTP (which creates the Net module) and after +// registerSocket / registerOpenSSL (the transport it dials through), so the Net +// module and the exception root already exist. +func (vm *VM) registerNetFTP() { + netMod := vm.consts["Net"].(*RClass) + std := vm.consts["StandardError"].(*RClass) + + vm.registerFTPErrors(netMod, std) + + ftp := newClass("Net::FTP", vm.cObject) + netMod.consts["FTP"] = ftp + vm.consts["Net::FTP"] = ftp + + entry := newClass("Net::FTP::MLSxEntry", vm.cObject) + ftp.consts["MLSxEntry"] = entry + vm.consts["Net::FTP::MLSxEntry"] = entry + vm.registerFTPMLSxEntry(entry) + + vm.registerFTPClassMethods(ftp) + vm.registerFTPMethods(ftp) +} + +// registerFTPErrors installs the Net::FTP exception family (FTPError < +// StandardError; FTPReplyError / FTPTempError / FTPPermError / FTPProtoError / +// FTPConnectionError < FTPError), publishing each qualified name so raise() (and +// the codec's ClassName) resolves the real class a `rescue Net::FTPPermError` +// catches. +func (vm *VM) registerFTPErrors(netMod, std *RClass) { + reg := func(simple string, super *RClass) *RClass { + qualified := "Net::" + simple + c := newClass(qualified, super) + netMod.consts[simple] = c + vm.consts[qualified] = c + return c + } + base := reg("FTPError", std) + reg("FTPReplyError", base) + reg("FTPTempError", base) + reg("FTPPermError", base) + reg("FTPProtoError", base) + reg("FTPConnectionError", base) +} + +// --- class methods ---------------------------------------------------------- + +// registerFTPClassMethods installs Net::FTP.new and Net::FTP.open. new(host=nil, +// options) connects (and logs in when a username is supplied); open is new plus +// the ensured-close block form. +func (vm *VM) registerFTPClassMethods(ftp *RClass) { + newFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + f := &ftpObj{cls: ftp, binary: true, passive: true} + vm.ftpInit(f, args) + return f + } + ftp.smethods["new"] = &Method{name: "new", owner: ftp, native: newFn} + + ftp.smethods["open"] = &Method{name: "open", owner: ftp, native: func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + f := &ftpObj{cls: ftp, binary: true, passive: true} + vm.ftpInit(f, args) + if blk == nil { + return f + } + // Net::FTP.open(...) { |ftp| ... } yields the connected client and closes it + // afterwards, even if the block raises (the deferred close runs on unwind). + return vm.ftpWithClose(f, blk) + }} +} + +// ftpWithClose yields f to blk and closes the control connection afterwards, +// including on an exception unwind (the deferred close runs before the panic +// propagates). +func (vm *VM) ftpWithClose(f *ftpObj, blk *Proc) (res object.Value) { + defer f.doClose() + return vm.callBlock(blk, []object.Value{f}) +} + +// ftpInit applies the constructor arguments: an optional host (connect), an +// options Hash (port / passive / username / password / account / use_pasv_ip) or +// the legacy positional user / passwd / acct, and a login when a username is +// present. +func (vm *VM) ftpInit(f *ftpObj, args []object.Value) { + if len(args) == 0 || object.IsNil(args[0]) { + return + } + host := strArg(args[0]) + port := 21 + user, passwd, acct := "", "", "" + userGiven, passwdGiven := false, false + + if len(args) >= 2 { + if h, ok := args[1].(*object.Hash); ok { + port, user, userGiven, passwd, passwdGiven, acct = vm.ftpApplyOpts(f, h, port) + } + } + + vm.ftpConnect(f, host, port) + if userGiven { + vm.ftpLogin(f, user, passwd, passwdGiven, acct) + } +} + +// ftpApplyOpts reads the options Hash into f (passive / use_pasv_ip) and returns +// the connection port plus the login credentials it carries. +func (vm *VM) ftpApplyOpts(f *ftpObj, h *object.Hash, port int) (int, string, bool, string, bool, string) { + user, passwd, acct := "", "", "" + userGiven, passwdGiven := false, false + if v, ok := ftpOptHash(h, "port"); ok { + if n, ok := v.(object.Integer); ok { + port = int(n) + } + } + if v, ok := ftpOptHash(h, "passive"); ok { + f.passive = v.Truthy() + } + if v, ok := ftpOptHash(h, "use_pasv_ip"); ok { + f.usePasvIP = v.Truthy() + } + if v, ok := ftpOptHash(h, "username", "user"); ok { + user, userGiven = strArg(v), true + } + if v, ok := ftpOptHash(h, "password", "passwd"); ok { + passwd, passwdGiven = strArg(v), true + } + if v, ok := ftpOptHash(h, "account", "acct"); ok { + acct = strArg(v) + } + return port, user, userGiven, passwd, passwdGiven, acct +} + +// ftpOptHash looks a key up in an options Hash by any of its symbol or string +// spellings. +func ftpOptHash(h *object.Hash, names ...string) (object.Value, bool) { + for _, n := range names { + if v, ok := h.Get(object.Symbol(n)); ok { + return v, true + } + if v, ok := h.Get(object.NewString(n)); ok { + return v, true + } + } + return nil, false +} + +// ftpConnect dials the control connection, reads the server greeting (recorded +// as welcome), and selects EPSV/EPRT when the connection is IPv6 +// (Net::FTP#connect). +func (vm *VM) ftpConnect(f *ftpObj, host string, port int) { + conn, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + ftpConnErr(err) + } + f.conn = conn + f.r = bufio.NewReader(conn) + f.closed = false + f.epsv = ftpIsIPv6(conn) + resp := f.getResp() + f.welcome = resp +} + +// ftpLogin runs the USER → PASS → ACCT ladder (Net::FTP#login), substituting the +// anonymous password when logging in as "anonymous" with none supplied and +// raising FTPReplyError when a required credential is missing. +func (vm *VM) ftpLogin(f *ftpObj, user, passwd string, passwdGiven bool, acct string) { + if !passwdGiven { + if pw, ok := netftp.AnonymousLogin(user, false); ok { + passwd, passwdGiven = pw, true + } + } + resp := f.sendCmd(netftp.UserCommand(user)) + step, err := netftp.LoginNext(resp, false) + if err != nil { + ftpRaise(err) + } + if step == netftp.LoginSendPass { + if !passwdGiven { + raise("Net::FTPReplyError", "%s", resp) + } + resp = f.sendCmd(netftp.PassCommand(passwd)) + step, err = netftp.LoginNext(resp, true) + if err != nil { + ftpRaise(err) + } + } + if step == netftp.LoginSendAcct { + if acct == "" { + raise("Net::FTPReplyError", "%s", resp) + } + resp = f.sendCmd(netftp.AcctCommand(acct)) + if !strings.HasPrefix(resp, "2") { + raise("Net::FTPReplyError", "%s", resp) + } + } +} + +// --- instance methods ------------------------------------------------------- + +// registerFTPMethods installs the Net::FTP instance surface: the connect/login +// entry points, the mode accessors, the directory and file commands, the listing +// commands, the transfer engines, and the get/put file helpers. +func (vm *VM) registerFTPMethods(ftp *RClass) { + self := func(v object.Value) *ftpObj { return v.(*ftpObj) } + d := func(name string, fn NativeFn) { ftp.define(name, fn) } + + d("connect", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 2, "connect") + port := 21 + if len(a) >= 2 { + port = int(a[1].(object.Integer)) + } + vm.ftpConnect(self(v), strArg(a[0]), port) + return object.NilV + }) + d("login", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + user := ftpOptString(a, 0, "anonymous") + passwd, passwdGiven := "", false + if len(a) >= 2 && !object.IsNil(a[1]) { + passwd, passwdGiven = strArg(a[1]), true + } + vm.ftpLogin(self(v), user, passwd, passwdGiven, ftpOptString(a, 2, "")) + return object.NilV + }) + + // --- mode accessors --- + d("passive", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).passive) + }) + d("passive=", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + self(v).passive = a[0].Truthy() + return a[0] + }) + d("binary", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).binary) + }) + d("binary=", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + self(v).binary = a[0].Truthy() + return a[0] + }) + d("use_pasv_ip", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).usePasvIP) + }) + d("use_pasv_ip=", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + self(v).usePasvIP = a[0].Truthy() + return a[0] + }) + d("welcome", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).welcome) + }) + d("last_response", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).lastResponse) + }) + d("last_response_code", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(netftp.ReplyCode(self(v).lastResponse)) + }) + d("lastresp", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(netftp.ReplyCode(self(v).lastResponse)) + }) + + // --- generic command senders --- + d("sendcmd", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "sendcmd") + return object.NewString(self(v).sendCmd(strArg(a[0]))) + }) + d("voidcmd", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "voidcmd") + self(v).voidCmd(strArg(a[0])) + return object.NilV + }) + d("noop", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + self(v).voidCmd(netftp.NoopCommand) + return object.NilV + }) + + // --- directory commands --- + d("pwd", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(vm.ftpPwd(self(v))) + }) + d("getdir", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(vm.ftpPwd(self(v))) + }) + d("chdir", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "chdir") + vm.ftpChdir(self(v), strArg(a[0])) + return object.NilV + }) + d("mkdir", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "mkdir") + f := self(v) + name, err := netftp.Parse257(f.sendCmd(netftp.MkdCommand(strArg(a[0])))) + if err != nil { + ftpRaise(err) + } + return object.NewString(name) + }) + d("rmdir", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "rmdir") + self(v).voidCmd(netftp.RmdCommand(strArg(a[0]))) + return object.NilV + }) + d("delete", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "delete") + f := self(v) + if err := netftp.CheckDelete(f.sendCmd(netftp.DeleCommand(strArg(a[0])))); err != nil { + ftpRaise(err) + } + return object.NilV + }) + d("rename", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 2, 2, "rename") + f := self(v) + if err := netftp.CheckRenameFrom(f.sendCmd(netftp.RnfrCommand(strArg(a[0])))); err != nil { + ftpRaise(err) + } + f.voidCmd(netftp.RntoCommand(strArg(a[1]))) + return object.NilV + }) + + // --- metadata commands --- + d("size", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "size") + f := self(v) + f.setType(true) + n, err := netftp.SizeResult(f.sendCmd(netftp.SizeCommand(strArg(a[0])))) + if err != nil { + ftpRaise(err) + } + return object.IntValue(int64(n)) + }) + d("system", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + f := self(v) + s, err := netftp.SystResult(f.sendCmd(netftp.SystCommand)) + if err != nil { + ftpRaise(err) + } + return object.NewString(s) + }) + d("status", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).sendCmd(netftp.StatCommand(ftpOptString(a, 0, "")))) + }) + d("mdtm", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "mdtm") + f := self(v) + raw, ok := netftp.MdtmResult(f.sendCmd(netftp.MdtmCommand(strArg(a[0])))) + if !ok { + raise("Net::FTPReplyError", "%s", f.lastResponse) + } + return object.NewString(raw) + }) + d("mtime", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "mtime") + return vm.ftpMtime(self(v), strArg(a[0])) + }) + d("help", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).sendCmd(netftp.HelpCommand(ftpOptString(a, 0, "")))) + }) + d("site", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "site") + self(v).voidCmd(netftp.SiteCommand(strArg(a[0]))) + return object.NilV + }) + + // --- listing commands --- + d("nlst", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + f := self(v) + var out []object.Value + f.retrlines(netftp.NlstCommand(ftpOptString(a, 0, "")), func(line string) { + out = append(out, object.NewString(line)) + }) + return object.NewArrayFromSlice(out) + }) + list := func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + f := self(v) + args := make([]string, len(a)) + for i, x := range a { + args[i] = strArg(x) + } + var out []object.Value + f.retrlines(netftp.ListCommand(args...), func(line string) { + if blk != nil { + vm.callBlock(blk, []object.Value{object.NewString(line)}) + } + out = append(out, object.NewString(line)) + }) + return object.NewArrayFromSlice(out) + } + d("list", list) + d("ls", list) + d("dir", list) + d("mlsd", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + f := self(v) + var out []object.Value + var perr error + f.retrlines(netftp.MlsdCommand(ftpOptString(a, 0, "")), func(line string) { + if perr != nil || line == "" { + return + } + e, err := netftp.ParseMLSxEntry(line) + if err != nil { + perr = err + return + } + entry := vm.ftpEntry(e) + if blk != nil { + vm.callBlock(blk, []object.Value{entry}) + } + out = append(out, entry) + }) + if perr != nil { + ftpRaise(perr) + } + return object.NewArrayFromSlice(out) + }) + d("mlst", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + return vm.ftpMlst(self(v), ftpOptString(a, 0, "")) + }) + + // --- transfer engines --- + d("retrlines", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 1, "retrlines") + self(v).retrlines(strArg(a[0]), func(line string) { + if blk != nil { + vm.callBlock(blk, []object.Value{object.NewString(line)}) + } + }) + return object.NilV + }) + d("retrbinary", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 2, "retrbinary") + self(v).retrbinary(strArg(a[0]), ftpBlocksizeArg(a, 1), func(chunk []byte) { + if blk != nil { + vm.callBlock(blk, []object.Value{object.NewStringBytesEnc(chunk, "ASCII-8BIT")}) + } + }) + return object.NilV + }) + d("storlines", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 2, 2, "storlines") + self(v).storlines(strArg(a[0]), string(ftpBytes(a[1]))) + return object.NilV + }) + d("storbinary", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 2, 3, "storbinary") + self(v).storbinary(strArg(a[0]), ftpBytes(a[1]), ftpBlocksizeArg(a, 2)) + return object.NilV + }) + + // --- file helpers --- + d("getbinaryfile", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 3, "getbinaryfile") + return vm.ftpGetBinary(self(v), a, blk) + }) + d("gettextfile", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 2, "gettextfile") + return vm.ftpGetText(self(v), a, blk) + }) + d("get", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 3, "get") + f := self(v) + if f.binary { + return vm.ftpGetBinary(f, a, blk) + } + return vm.ftpGetText(f, a, blk) + }) + d("putbinaryfile", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 3, "putbinaryfile") + return vm.ftpPutBinary(self(v), a) + }) + d("puttextfile", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 2, "puttextfile") + return vm.ftpPutText(self(v), a) + }) + d("put", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 3, "put") + f := self(v) + if f.binary { + return vm.ftpPutBinary(f, a) + } + return vm.ftpPutText(f, a) + }) + + // --- teardown --- + d("abort", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(vm.ftpAbort(self(v))) + }) + d("quit", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + self(v).voidCmd(netftp.QuitCommand) + return object.NilV + }) + d("close", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + self(v).doClose() + return object.NilV + }) + d("closed?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + f := self(v) + return object.Bool(f.closed || f.conn == nil) + }) +} + +// ftpPwd sends PWD and decodes the quoted pathname (Net::FTP#pwd). +func (vm *VM) ftpPwd(f *ftpObj) string { + name, err := netftp.Parse257(f.sendCmd(netftp.PwdCommand)) + if err != nil { + ftpRaise(err) + } + return name +} + +// ftpChdir changes directory (Net::FTP#chdir): ".." tries CDUP first and falls +// back to CWD only on a 500 reply; every other directory goes straight to CWD. +func (vm *VM) ftpChdir(f *ftpObj, dir string) { + if dir == ".." { + // CDUP's 500 ("command not understood") is the one reply that falls through + // to CWD "..", so its reply is read raw (bypassing getResp's raise) to + // inspect the code before deciding. + f.writeLine(netftp.CdupCommand) + resp, err := netftp.GetMultiline(f.reader()) + if err != nil { + ftpConnErr(err) + } + f.lastResponse = resp + if strings.HasPrefix(resp, "2") { + return + } + if netftp.ReplyCode(resp) != "500" { + if _, cerr := netftp.ClassifyReply(resp); cerr != nil { + ftpRaise(cerr) + } + raise("Net::FTPReplyError", "%s", resp) + } + } + f.voidCmd(netftp.CwdCommand(dir)) +} + +// ftpMtime sends MDTM and parses the "YYYYMMDDhhmmss" reply into a UTC Time +// (Net::FTP#mtime). +func (vm *VM) ftpMtime(f *ftpObj, file string) object.Value { + raw, ok := netftp.MdtmResult(f.sendCmd(netftp.MdtmCommand(file))) + if !ok { + raise("Net::FTPReplyError", "%s", f.lastResponse) + } + t, err := stdtime.Parse("20060102150405", raw) + if err != nil { + raise("Net::FTPProtoError", "invalid time-val: %s", raw) + } + return &Time{t: gotime.FromUnix(t.Unix()).UTC()} +} + +// ftpMlst sends MLST, requires the 250 multiline reply, and parses its middle +// line into a Net::FTP::MLSxEntry (Net::FTP#mlst). +func (vm *VM) ftpMlst(f *ftpObj, pathname string) object.Value { + resp := f.sendCmd(netftp.MlstCommand(pathname)) + if !strings.HasPrefix(resp, "250") { + raise("Net::FTPReplyError", "%s", resp) + } + // The assembled reply always ends in a newline, so a Split yields at least two + // elements; a single-line 250 leaves an empty entry line, which ParseMLSxEntry + // rejects as FTPProtoError below (no pathname). + lines := strings.Split(resp, "\n") + e, err := netftp.ParseMLSxEntry(strings.TrimLeft(lines[1], " ")) + if err != nil { + ftpRaise(err) + } + return vm.ftpEntry(e) +} + +// ftpAbort sends ABOR out of band and validates the reply against the codes MRI +// accepts (426/226/225), raising FTPProtoError otherwise (Net::FTP#abort). +func (vm *VM) ftpAbort(f *ftpObj) string { + f.writeLine(netftp.AborCommand) + resp, err := netftp.GetMultiline(f.reader()) + if err != nil { + ftpConnErr(err) + } + f.lastResponse = resp + if !netftp.AbortAccepted(resp) { + raise("Net::FTPProtoError", "%s", resp) + } + return resp +} + +// --- get / put --------------------------------------------------------------- + +// ftpGetBinary implements getbinaryfile / the binary branch of get: it yields +// each block to a block, or (with no block) retrieves the file into localfile +// (defaulting to the remote basename). +func (vm *VM) ftpGetBinary(f *ftpObj, a []object.Value, blk *Proc) object.Value { + remote := strArg(a[0]) + bs := ftpBlocksizeArg(a, 2) + if blk != nil { + f.retrbinary(netftp.RetrCommand(remote), bs, func(chunk []byte) { + vm.callBlock(blk, []object.Value{object.NewStringBytesEnc(chunk, "ASCII-8BIT")}) + }) + return object.NilV + } + local := ftpOptString(a, 1, ftpBasename(remote)) + var buf bytes.Buffer + f.retrbinary(netftp.RetrCommand(remote), bs, func(chunk []byte) { buf.Write(chunk) }) + if err := os.WriteFile(local, buf.Bytes(), 0o644); err != nil { + raise("Errno::EACCES", "%s", err.Error()) + } + return object.NilV +} + +// ftpGetText implements gettextfile / the text branch of get: it yields each +// line to a block, or (with no block) retrieves the file into localfile, joining +// the received lines with newlines. +func (vm *VM) ftpGetText(f *ftpObj, a []object.Value, blk *Proc) object.Value { + remote := strArg(a[0]) + if blk != nil { + f.retrlines(netftp.RetrCommand(remote), func(line string) { + vm.callBlock(blk, []object.Value{object.NewString(line)}) + }) + return object.NilV + } + local := ftpOptString(a, 1, ftpBasename(remote)) + var b strings.Builder + f.retrlines(netftp.RetrCommand(remote), func(line string) { + b.WriteString(line) + b.WriteByte('\n') + }) + if err := os.WriteFile(local, []byte(b.String()), 0o644); err != nil { + raise("Errno::EACCES", "%s", err.Error()) + } + return object.NilV +} + +// ftpPutBinary implements putbinaryfile / the binary branch of put: it uploads +// localfile's bytes with STOR (remotefile defaults to the local basename). +func (vm *VM) ftpPutBinary(f *ftpObj, a []object.Value) object.Value { + local := strArg(a[0]) + data, err := os.ReadFile(local) + if err != nil { + raise("Errno::ENOENT", "%s", err.Error()) + } + remote := ftpOptString(a, 1, ftpBasename(local)) + f.storbinary(netftp.StorCommand(remote), data, ftpBlocksizeArg(a, 2)) + return object.NilV +} + +// ftpPutText implements puttextfile / the text branch of put: it uploads +// localfile as ASCII lines with STOR (remotefile defaults to the local +// basename). +func (vm *VM) ftpPutText(f *ftpObj, a []object.Value) object.Value { + local := strArg(a[0]) + data, err := os.ReadFile(local) + if err != nil { + raise("Errno::ENOENT", "%s", err.Error()) + } + remote := ftpOptString(a, 1, ftpBasename(local)) + f.storlines(netftp.StorCommand(remote), string(data)) + return object.NilV +} + +// --- MLSxEntry --------------------------------------------------------------- + +// ftpEntry wraps a parsed codec MLSxEntry in its Ruby class. +func (vm *VM) ftpEntry(e netftp.MLSxEntry) *ftpMLSxEntry { + return &ftpMLSxEntry{cls: vm.consts["Net::FTP::MLSxEntry"].(*RClass), e: e} +} + +// ftpEntrySelf recovers the wrapper from a receiver. +func ftpEntrySelf(v object.Value) *ftpMLSxEntry { return v.(*ftpMLSxEntry) } + +// registerFTPMLSxEntry installs Net::FTP::MLSxEntry: the pathname, the facts +// Hash (integer / time / string values), the type predicates, and the perm-bit +// predicates. +func (vm *VM) registerFTPMLSxEntry(cls *RClass) { + cls.define("pathname", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(ftpEntrySelf(v).e.Pathname) + }) + cls.define("facts", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.ftpFacts(ftpEntrySelf(v).e) + }) + cls.define("file?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(ftpEntrySelf(v).e.IsFile()) + }) + cls.define("directory?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(ftpEntrySelf(v).e.IsDirectory()) + }) + // The perm-bit predicates all delegate to the codec's MLSxEntry queries; one + // closure keyed by the predicate backs them all. + perms := map[string]func(netftp.MLSxEntry) bool{ + "appendable?": netftp.MLSxEntry.Appendable, + "creatable?": netftp.MLSxEntry.Creatable, + "deletable?": netftp.MLSxEntry.Deletable, + "enterable?": netftp.MLSxEntry.Enterable, + "renamable?": netftp.MLSxEntry.Renamable, + "listable?": netftp.MLSxEntry.Listable, + "directory_makable?": netftp.MLSxEntry.DirectoryMakable, + "purgeable?": netftp.MLSxEntry.Purgeable, + "readable?": netftp.MLSxEntry.Readable, + "writable?": netftp.MLSxEntry.Writable, + } + for name, fn := range perms { + pred := fn + cls.define(name, func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(pred(ftpEntrySelf(v).e)) + }) + } +} + +// ftpFacts maps an entry's facts (in appearance order) to a Ruby Hash: integer +// facts become Integer, time facts a UTC Time, the rest String. +func (vm *VM) ftpFacts(e netftp.MLSxEntry) object.Value { + h := object.NewHash() + for _, name := range e.FactOrder { + fv := e.Facts[name] + var val object.Value + switch fv.Kind { + case netftp.FactInt: + val = object.IntValue(int64(fv.Int)) + case netftp.FactTime: + val = vm.ftpTimeVal(fv.Time) + default: + val = object.NewString(fv.Str) + } + h.Set(object.NewString(name), val) + } + return h +} + +// ftpTimeVal builds a UTC Ruby Time from an MLSx time-val's components. +func (vm *VM) ftpTimeVal(t netftp.MLSxTime) object.Value { + sec := stdtime.Date(t.Year, stdtime.Month(t.Month), t.Day, t.Hour, t.Min, t.Sec, 0, stdtime.UTC).Unix() + return &Time{t: gotime.FromUnix(sec).UTC()} +} diff --git a/internal/vm/net_ftp_bind.go b/internal/vm/net_ftp_bind.go new file mode 100644 index 00000000..999d542d --- /dev/null +++ b/internal/vm/net_ftp_bind.go @@ -0,0 +1,486 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bufio" + "errors" + "net" + "strconv" + "strings" + + netftp "github.com/go-ruby-net-ftp/net-ftp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the transport half of the Net::FTP binding: it drives real +// control and data sockets (Go net, exactly as socket.go / nethttp_bind.go do) +// on top of the interpreter-independent FTP codec +// github.com/go-ruby-net-ftp/net-ftp. The codec owns every observable byte — +// command lines, reply classification, PASV/EPSV host:port extraction, MLSx +// parsing; rbgo owns only the sockets. net_ftp.go builds the Ruby class surface +// and calls into the helpers here. +// +// The control connection is a single net.Conn wrapped in a bufio.Reader (so the +// codec's LineReader can pull whole reply lines). Each data transfer opens a +// second connection: in passive mode rbgo dials the host:port the codec +// extracts from the PASV/EPSV reply; in active mode rbgo listens on the control +// socket's local interface, sends PORT/EPRT, and accepts the server's callback. +// A transport read/write failure surfaces as Net::FTPConnectionError; a codec +// FTPError (reply/temp/perm/proto) re-raises as its matching Ruby class. + +// ftpDefaultBlocksize is Net::FTP::DEFAULT_BLOCKSIZE — the chunk size the binary +// transfer helpers read and write in. +const ftpDefaultBlocksize = 4096 + +// ftpObj is the Ruby wrapper around a live FTP control connection (Net::FTP). It +// owns the control socket and the transfer state (binary/passive/EPSV mode); the +// codec supplies the protocol, rbgo supplies the sockets. +type ftpObj struct { + cls *RClass + conn net.Conn + r *bufio.Reader + // binary is the current transfer type (true = TYPE I, false = TYPE A). It + // tracks the last TYPE the host actually sent so a transfer only re-sends TYPE + // when the mode changes, mirroring Net::FTP. + binary bool + // curType is the TYPE last negotiated with the server ("" before any transfer, + // "I" or "A" after), distinct from binary so the first transfer always sends + // TYPE. + curType string + // passive selects passive (PASV/EPSV) vs active (PORT/EPRT) data connections; + // Net::FTP defaults to passive. + passive bool + // epsv selects EPSV/EPRT over PASV/PORT. Net::FTP uses the extended commands + // when the control connection is IPv6; ftpIsIPv6 sets it at connect time. + epsv bool + // usePasvIP mirrors Net::FTP#use_pasv_ip: when true a PASV reply's encoded host + // is dialed, otherwise the control socket's address is used. + usePasvIP bool + // lastResponse is the most recent assembled reply (Net::FTP#last_response). + lastResponse string + // welcome is the server's connect greeting (Net::FTP#welcome). + welcome string + // closed reports whether the control connection has been closed. + closed bool +} + +func (f *ftpObj) ToS() string { return "#" } +func (f *ftpObj) Inspect() string { return "#" } +func (f *ftpObj) Truthy() bool { return true } + +// doClose closes the control connection once (Net::FTP#close); a second call is +// a no-op. +func (f *ftpObj) doClose() { + if f.closed || f.conn == nil { + return + } + f.conn.Close() + f.closed = true +} + +// ftpMLSxEntry is the Ruby wrapper around a parsed MLSD/MLST entry +// (Net::FTP::MLSxEntry): the codec's MLSxEntry plus the class it reports. +type ftpMLSxEntry struct { + cls *RClass + e netftp.MLSxEntry +} + +func (m *ftpMLSxEntry) ToS() string { return "#" } +func (m *ftpMLSxEntry) Inspect() string { return m.ToS() } +func (m *ftpMLSxEntry) Truthy() bool { return true } + +// --- codec error mapping ---------------------------------------------------- + +// ftpRaise re-raises a codec error as its Ruby class: an *netftp.FTPError uses +// the reply/temp/perm/proto/connection class name the codec assigns, and any +// other (transport) error becomes Net::FTPConnectionError. +func ftpRaise(err error) { + var fe *netftp.FTPError + if errors.As(err, &fe) { + raise(fe.ClassName(), "%s", fe.Message) + } + raise("Net::FTPConnectionError", "%s", err.Error()) +} + +// ftpConnErr raises Net::FTPConnectionError for a transport (dial/read/write) +// failure. +func ftpConnErr(err error) { + raise("Net::FTPConnectionError", "%s", err.Error()) +} + +// --- control-connection primitives ------------------------------------------ + +// reader returns the codec LineReader over the buffered control connection; it +// yields whole reply lines (terminator included — the codec strips it). +func (f *ftpObj) reader() netftp.LineReader { + return func() (string, error) { + line, err := f.r.ReadString('\n') + if err != nil && line == "" { + return "", err + } + return line, nil + } +} + +// writeLine builds the command bytes (PutLine appends CRLF and rejects an +// embedded CR/LF) and writes them to the control socket. +func (f *ftpObj) writeLine(line string) { + bytes, err := netftp.PutLine(line) + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + if _, werr := f.conn.Write([]byte(bytes)); werr != nil { + ftpConnErr(werr) + } +} + +// getResp reads one assembled reply, records it as last_response, and classifies +// it: a 4yz/5yz/malformed reply re-raises as the matching Net::FTP error, a +// transport read failure as Net::FTPConnectionError. +func (f *ftpObj) getResp() string { + resp, err := netftp.GetMultiline(f.reader()) + if err != nil { + ftpConnErr(err) + } + f.lastResponse = resp + got, cerr := netftp.ClassifyReply(resp) + if cerr != nil { + ftpRaise(cerr) + } + return got +} + +// sendCmd writes a command and returns its classified reply (Net::FTP#sendcmd). +func (f *ftpObj) sendCmd(line string) string { + f.writeLine(line) + return f.getResp() +} + +// voidResp reads a reply and requires it to begin with '2' (Net::FTP#voidresp). +func (f *ftpObj) voidResp() { + resp := f.getResp() + if !strings.HasPrefix(resp, "2") { + raise("Net::FTPReplyError", "%s", resp) + } +} + +// voidCmd writes a command and requires a 2yz reply (Net::FTP#voidcmd). +func (f *ftpObj) voidCmd(line string) { + f.writeLine(line) + f.voidResp() +} + +// --- addressing helpers ----------------------------------------------------- + +// remoteHost is the control socket's remote address host — the host EPSV (which +// encodes none) and a use_pasv_ip=false PASV dial through. +func (f *ftpObj) remoteHost() string { + host, _, err := net.SplitHostPort(f.conn.RemoteAddr().String()) + if err != nil { + return f.conn.RemoteAddr().String() + } + return host +} + +// localHost is the control socket's local address host, the interface an +// active-mode data listener binds. +func (f *ftpObj) localHost() string { + host, _, err := net.SplitHostPort(f.conn.LocalAddr().String()) + if err != nil { + return f.conn.LocalAddr().String() + } + return host +} + +// ftpIsIPv6 reports whether a connection's remote address is an IPv6 TCP +// address, the condition under which Net::FTP uses EPSV/EPRT. +func ftpIsIPv6(c net.Conn) bool { + if a, ok := c.RemoteAddr().(*net.TCPAddr); ok { + return a.IP.To4() == nil + } + return false +} + +// --- data connections ------------------------------------------------------- + +// makepasv negotiates a passive data address, mirroring Net::FTP#makepasv: EPSV +// (parse229) for an IPv6 control connection, PASV (parse227) otherwise. +func (f *ftpObj) makepasv() (string, int) { + if f.epsv { + host, port, err := netftp.Parse229(f.sendCmd(netftp.EpsvCommand), f.remoteHost()) + if err != nil { + ftpRaise(err) + } + return host, port + } + host, port, err := netftp.Parse227(f.sendCmd(netftp.PasvCommand), f.usePasvIP, f.remoteHost()) + if err != nil { + ftpRaise(err) + } + return host, port +} + +// makeport opens an active-mode data listener on the control socket's local +// interface and announces it with PORT (SendPort) or EPRT (SendEPort). +func (f *ftpObj) makeport() net.Listener { + host := f.localHost() + ln, err := net.Listen("tcp", net.JoinHostPort(host, "0")) + if err != nil { + ftpConnErr(err) + } + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port, _ := strconv.Atoi(portStr) + if f.epsv { + f.voidCmd(netftp.SendEPort(host, port)) + } else { + f.voidCmd(netftp.SendPort(host, port)) + } + return ln +} + +// ftpMarkOK reports whether a transfer command's reply is a preliminary mark +// (1yz) or an immediate completion (2yz) — the replies Net::FTP#transfercmd +// accepts before reading the data connection. +func ftpMarkOK(resp string) bool { + return strings.HasPrefix(resp, "1") || strings.HasPrefix(resp, "2") +} + +// transfercmd opens a data connection and issues the transfer command over the +// control connection, returning the ready data socket (Net::FTP#transfercmd). +// The caller reads or writes the data, closes it, and then calls voidResp for +// the completion reply. A command reply that is an error (or not a mark) closes +// the just-opened data socket/listener before raising, so no fd or goroutine +// leaks on the failure path. +func (f *ftpObj) transfercmd(cmd string) net.Conn { + if f.passive { + host, port := f.makepasv() + conn := f.dialData(host, port) + f.sendTransferCmd(cmd, func() { conn.Close() }) + return conn + } + ln := f.makeport() + f.sendTransferCmd(cmd, func() { ln.Close() }) + conn := f.acceptData(ln) + ln.Close() + return conn +} + +// dialData dials a passive data address, raising Net::FTPConnectionError when the +// connection cannot be made. +func (f *ftpObj) dialData(host string, port int) net.Conn { + conn, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + ftpConnErr(err) + } + return conn +} + +// acceptData accepts the server's active-mode data callback, raising +// Net::FTPConnectionError when the accept fails. +func (f *ftpObj) acceptData(ln net.Listener) net.Conn { + conn, err := ln.Accept() + if err != nil { + ftpConnErr(err) + } + return conn +} + +// sendData writes data to the data connection in blocksize chunks, closing it +// and raising Net::FTPConnectionError on a write failure. +func (f *ftpObj) sendData(conn net.Conn, data []byte, blocksize int) { + for off := 0; off < len(data); off += blocksize { + end := min(off+blocksize, len(data)) + if _, err := conn.Write(data[off:end]); err != nil { + conn.Close() + ftpConnErr(err) + } + } +} + +// sendTransferCmd writes the transfer command and reads its reply raw (bypassing +// getResp's raise) so cleanup — closing the already-opened data socket or +// listener — runs before any error is raised: a read failure or 4yz/5yz reply +// re-raises the codec error, and a non-mark reply (a stray 3yz) raises +// FTPReplyError. +func (f *ftpObj) sendTransferCmd(cmd string, cleanup func()) { + f.writeLine(cmd) + resp, err := netftp.GetMultiline(f.reader()) + if err != nil { + cleanup() + ftpConnErr(err) + } + f.lastResponse = resp + if _, cerr := netftp.ClassifyReply(resp); cerr != nil { + cleanup() + ftpRaise(cerr) + } + if !ftpMarkOK(resp) { + cleanup() + raise("Net::FTPReplyError", "%s", resp) + } +} + +// setType negotiates the transfer TYPE (I/A) when it differs from the last one +// sent, mirroring Net::FTP's lazy send_type_command. +func (f *ftpObj) setType(binary bool) { + want := "A" + if binary { + want = "I" + } + if f.curType == want { + return + } + f.voidCmd(netftp.TypeCommand(binary)) + f.curType = want +} + +// --- transfer engines ------------------------------------------------------- + +// retrlines runs an ASCII retrieval, yielding each received line (its trailing +// CRLF/LF removed) to fn, then reads the completion reply +// (Net::FTP#retrlines). +func (f *ftpObj) retrlines(cmd string, fn func(string)) { + f.setType(false) + conn := f.transfercmd(cmd) + br := bufio.NewReader(conn) + for { + line, err := br.ReadString('\n') + if line != "" { + fn(ftpChomp(line)) + } + if err != nil { + break + } + } + conn.Close() + f.voidResp() +} + +// retrbinary runs a binary retrieval, yielding each block of at most blocksize +// bytes to fn, then reads the completion reply (Net::FTP#retrbinary). +func (f *ftpObj) retrbinary(cmd string, blocksize int, fn func([]byte)) { + f.setType(true) + conn := f.transfercmd(cmd) + buf := make([]byte, blocksize) + for { + n, err := conn.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + fn(chunk) + } + if err != nil { + break + } + } + conn.Close() + f.voidResp() +} + +// storbinary runs a binary store, writing data in blocksize chunks over the data +// connection, then reads the completion reply (Net::FTP#storbinary). +func (f *ftpObj) storbinary(cmd string, data []byte, blocksize int) { + f.setType(true) + conn := f.transfercmd(cmd) + f.sendData(conn, data, blocksize) + conn.Close() + f.voidResp() +} + +// storlines runs an ASCII store, writing data (each bare LF promoted to CRLF) +// over the data connection, then reads the completion reply +// (Net::FTP#storlines). +func (f *ftpObj) storlines(cmd string, data string) { + f.setType(false) + conn := f.transfercmd(cmd) + f.sendData(conn, []byte(ftpToCRLF(data)), ftpDefaultBlocksize) + conn.Close() + f.voidResp() +} + +// --- small text helpers ----------------------------------------------------- + +// ftpChomp removes a single trailing CRLF / LF / CR record separator, matching +// the line boundaries Net::FTP#retrlines yields. +func ftpChomp(s string) string { + if strings.HasSuffix(s, "\r\n") { + return s[:len(s)-2] + } + if n := len(s); n > 0 { + if c := s[n-1]; c == '\n' || c == '\r' { + return s[:n-1] + } + } + return s +} + +// ftpToCRLF promotes each bare LF (one not already preceded by CR) to CRLF, the +// line-ending normalisation Net::FTP#storlines applies to ASCII uploads. +func ftpToCRLF(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if c == '\n' && (i == 0 || s[i-1] != '\r') { + b.WriteByte('\r') + } + b.WriteByte(c) + } + return b.String() +} + +// ftpBasename returns the final path element of p (File.basename), the default +// remote/local name the get/put helpers derive from their counterpart. +func ftpBasename(p string) string { + if i := strings.LastIndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return p +} + +// --- argument helpers ------------------------------------------------------- + +// ftpArity raises an ArgumentError when args has fewer than min or more than max +// elements. +func ftpArity(args []object.Value, min, max int, name string) { + if len(args) < min || len(args) > max { + raise("ArgumentError", "wrong number of arguments for '%s' (given %d)", name, len(args)) + } +} + +// ftpOptString returns the i-th argument as a String, or def when it is absent +// or nil (the optional pathname arguments Net::FTP commands accept). +func ftpOptString(args []object.Value, i int, def string) string { + if i >= len(args) { + return def + } + if _, ok := args[i].(object.Nil); ok { + return def + } + return strArg(args[i]) +} + +// ftpBytes returns the raw bytes of a String argument (for a binary upload +// body). +func ftpBytes(v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + raise("TypeError", "no implicit conversion of %s into String", v.Inspect()) + return nil +} + +// ftpBlocksizeArg reads an optional trailing blocksize argument, defaulting to +// ftpDefaultBlocksize. +func ftpBlocksizeArg(args []object.Value, i int) int { + if i >= len(args) { + return ftpDefaultBlocksize + } + if n, ok := args[i].(object.Integer); ok && int(n) > 0 { + return int(n) + } + return ftpDefaultBlocksize +} diff --git a/internal/vm/net_ftp_internal_test.go b/internal/vm/net_ftp_internal_test.go new file mode 100644 index 00000000..93b355f7 --- /dev/null +++ b/internal/vm/net_ftp_internal_test.go @@ -0,0 +1,1531 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bufio" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + stdtime "time" + + netftp "github.com/go-ruby-net-ftp/net-ftp" +) + +// The Net::FTP binding is exercised against an in-process FTP server (below) so +// every path — control replies, PASV/EPSV/PORT/EPRT data connections, the +// LIST/NLST/MLSD/MLST listings, retr/stor transfers, and every error branch — is +// deterministic, uses only the loopback interface, and leaks no goroutine (the +// server's connections are force-closed and joined on cleanup). + +// --- in-process FTP test server --------------------------------------------- + +// ftpServer is a scriptable loopback FTP server. Default handlers implement a +// tiny virtual filesystem; overrides force a specific reply (or connection +// close) for a verb, and dataOverride replaces a listing's data payload, so the +// tests can drive both the happy paths and every error branch. +type ftpServer struct { + ln net.Listener + wg sync.WaitGroup + mu sync.Mutex + conns []net.Conn + datals []net.Listener + overrides map[string]string // VERB -> forced reply line ("@close" closes) + dataOverride map[string]string // VERB -> data-channel payload + files map[string]string // RETR content by path + stored map[string]string // STOR content captured by path +} + +// connState is one control connection's transfer state. +type connState struct { + cwd string + dataLn net.Listener // passive: listener awaiting the client's data dial + dataAddr string // active: the client's PORT/EPRT address to dial back + renameFr string +} + +func newFTPServer(t *testing.T) *ftpServer { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + s := &ftpServer{ + ln: ln, + overrides: map[string]string{}, + dataOverride: map[string]string{}, + files: map[string]string{}, + stored: map[string]string{}, + } + s.wg.Add(1) + go s.acceptLoop() + t.Cleanup(func() { + ln.Close() + s.mu.Lock() + for _, c := range s.conns { + c.Close() + } + for _, d := range s.datals { + d.Close() + } + s.mu.Unlock() + s.wg.Wait() + }) + return s +} + +func (s *ftpServer) port() int { return s.ln.Addr().(*net.TCPAddr).Port } + +// The override / dataOverride maps are read by connection goroutines and mutated +// by the tests between runs, so all access goes through these mutex-guarded +// accessors (the -race detector holds the gate). +func (s *ftpServer) setOverride(verb, reply string) { + s.mu.Lock() + s.overrides[verb] = reply + s.mu.Unlock() +} +func (s *ftpServer) clearOverride(verb string) { + s.mu.Lock() + delete(s.overrides, verb) + s.mu.Unlock() +} +func (s *ftpServer) getOverride(verb string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.overrides[verb] + return v, ok +} +func (s *ftpServer) setDataOverride(verb, payload string) { + s.mu.Lock() + s.dataOverride[verb] = payload + s.mu.Unlock() +} +func (s *ftpServer) clearDataOverride(verb string) { + s.mu.Lock() + delete(s.dataOverride, verb) + s.mu.Unlock() +} +func (s *ftpServer) getDataOverride(verb string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.dataOverride[verb] + return v, ok +} + +func (s *ftpServer) acceptLoop() { + defer s.wg.Done() + for { + c, err := s.ln.Accept() + if err != nil { + return + } + s.mu.Lock() + s.conns = append(s.conns, c) + s.mu.Unlock() + s.wg.Add(1) + go s.handleConn(c) + } +} + +// run compiles and runs a Ruby program with `require "net/ftp"` and a PORT +// constant prepended, returning its trimmed stdout. +func (s *ftpServer) run(t *testing.T, body string) string { + t.Helper() + return runSrc(t, "require \"net/ftp\"\nPORT = "+strconv.Itoa(s.port())+"\n"+body) +} + +func (s *ftpServer) handleConn(c net.Conn) { + defer s.wg.Done() + defer c.Close() + br := bufio.NewReader(c) + w := func(str string) { c.Write([]byte(str)) } + w("220 rbgo test FTP ready\r\n") + st := &connState{cwd: "/"} + for { + raw, err := br.ReadString('\n') + if err != nil { + return + } + line := strings.TrimRight(raw, "\r\n") + verb, arg := line, "" + if i := strings.IndexByte(line, ' '); i >= 0 { + verb, arg = line[:i], line[i+1:] + } + VERB := strings.ToUpper(verb) + if ov, ok := s.getOverride(VERB); ok { + if st.dataLn != nil { // a forced error on a transfer verb: release the data listener + st.dataLn.Close() + st.dataLn = nil + } + if ov == "@close" { + return + } + w(ov) + if VERB == "QUIT" { + return + } + continue + } + if s.dispatch(w, st, VERB, arg) { + return + } + } +} + +// dispatch handles one command; it returns true when the connection should close. +func (s *ftpServer) dispatch(w func(string), st *connState, verb, arg string) bool { + switch verb { + case "USER": + w("331 need password\r\n") + case "PASS": + w("230 logged in\r\n") + case "ACCT": + w("230 account ok\r\n") + case "TYPE": + w("200 type set\r\n") + case "PWD", "XPWD": + w("257 \"" + st.cwd + "\" is current directory\r\n") + case "CWD": + st.cwd = arg + w("250 directory changed\r\n") + case "CDUP": + w("200 cdup ok\r\n") + case "MKD": + w("257 \"" + arg + "\" created\r\n") + case "RMD": + w("250 removed\r\n") + case "DELE": + w("250 deleted\r\n") + case "RNFR": + st.renameFr = arg + w("350 ready for RNTO\r\n") + case "RNTO": + w("250 renamed\r\n") + case "SIZE": + w("213 " + strconv.Itoa(len(s.content(arg))) + "\r\n") + case "MDTM": + w("213 20200102030405\r\n") + case "SYST": + w("215 UNIX Type: L8\r\n") + case "STAT": + w("211 status ok\r\n") + case "NOOP": + w("200 noop\r\n") + case "HELP": + w("214 help text\r\n") + case "SITE": + w("200 site ok\r\n") + case "PASV": + s.openPassive(w, st, false) + case "EPSV": + s.openPassive(w, st, true) + case "PORT", "EPRT": + st.dataAddr = s.parseActive(verb, arg) + w("200 port ok\r\n") + case "MLST": + w("250-Listing " + arg + "\r\n type=file;size=12;modify=20200102030405; " + s.pathOf(arg) + "\r\n250 End\r\n") + case "LIST", "NLST", "MLSD": + s.doSend(w, st, verb, arg) + case "RETR": + s.doRetr(w, st, arg) + case "STOR": + s.doStor(w, st, arg) + case "QUIT": + w("221 bye\r\n") + return true + case "ABOR": + w("226 abort ok\r\n") + default: + w("500 unknown command\r\n") + } + return false +} + +func (s *ftpServer) pathOf(arg string) string { + if arg == "" { + return "/file.txt" + } + return arg +} + +func (s *ftpServer) content(arg string) string { + if c, ok := s.files[arg]; ok { + return c + } + return "line one\nline two\n" +} + +// openPassive opens a data listener and replies with the PASV (227) or EPSV +// (229) address of the loopback data port. +func (s *ftpServer) openPassive(w func(string), st *connState, epsv bool) { + dl, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + w("425 cannot open data connection\r\n") + return + } + s.mu.Lock() + s.datals = append(s.datals, dl) + s.mu.Unlock() + st.dataLn = dl + port := dl.Addr().(*net.TCPAddr).Port + if epsv { + w(fmt.Sprintf("229 Entering Extended Passive Mode (|||%d|)\r\n", port)) + return + } + w(fmt.Sprintf("227 Entering Passive Mode (127,0,0,1,%d,%d)\r\n", port/256, port%256)) +} + +// parseActive parses a PORT (h1,h2,h3,h4,p1,p2) or EPRT (|proto|host|port|) +// argument into the dial-back address. +func (s *ftpServer) parseActive(verb, arg string) string { + if verb == "EPRT" { + parts := strings.Split(arg, "|") + return net.JoinHostPort(parts[2], parts[3]) + } + f := strings.Split(arg, ",") + host := strings.Join(f[:4], ".") + hi, _ := strconv.Atoi(f[4]) + lo, _ := strconv.Atoi(f[5]) + return net.JoinHostPort(host, strconv.Itoa(hi*256+lo)) +} + +// openData accepts (passive) or dials (active) the data connection. +func (s *ftpServer) openData(st *connState) net.Conn { + if st.dataLn != nil { + conn, err := st.dataLn.Accept() + st.dataLn.Close() + st.dataLn = nil + if err != nil { + return nil + } + return conn + } + conn, err := net.Dial("tcp", st.dataAddr) + if err != nil { + return nil + } + return conn +} + +func (s *ftpServer) doSend(w func(string), st *connState, verb, arg string) { + payload, ok := s.getDataOverride(verb) + if !ok { + switch verb { + case "LIST": + payload = "drwxr-xr-x 2 u g 4096 Jan 01 00:00 dir1\r\n-rw-r--r-- 1 u g 12 Jan 01 00:00 file.txt\r\n" + case "NLST": + payload = "dir1\r\nfile.txt\r\n" + default: // MLSD + payload = "type=dir;perm=el;unix.mode=0755; dir1\r\n" + + "type=file;size=12;perm=rw;modify=20200102030405;unix.owner=1000; file.txt\r\n" + } + } + w("150 opening data connection\r\n") + conn := s.openData(st) + if conn != nil { + conn.Write([]byte(payload)) + conn.Close() + } + w("226 transfer complete\r\n") +} + +func (s *ftpServer) doRetr(w func(string), st *connState, arg string) { + w("150 opening data connection\r\n") + conn := s.openData(st) + if conn != nil { + conn.Write([]byte(s.content(arg))) + conn.Close() + } + w("226 transfer complete\r\n") +} + +func (s *ftpServer) doStor(w func(string), st *connState, arg string) { + w("150 ready to receive\r\n") + conn := s.openData(st) + if conn != nil { + var b strings.Builder + buf := make([]byte, 512) + for { + n, err := conn.Read(buf) + if n > 0 { + b.Write(buf[:n]) + } + if err != nil { + break + } + } + conn.Close() + s.mu.Lock() + s.stored[arg] = b.String() + s.mu.Unlock() + } + w("226 transfer complete\r\n") +} + +func (s *ftpServer) storedContent(path string) string { + s.mu.Lock() + defer s.mu.Unlock() + return s.stored[path] +} + +// --- fakeConn / fakeAddr for the addressing-helper unit tests --------------- + +type fakeAddr string + +func (a fakeAddr) Network() string { return "fake" } +func (a fakeAddr) String() string { return string(a) } + +type fakeConn struct { + remote net.Addr + local net.Addr +} + +func (c *fakeConn) Read([]byte) (int, error) { return 0, nil } +func (c *fakeConn) Write(p []byte) (int, error) { return len(p), nil } +func (c *fakeConn) Close() error { return nil } +func (c *fakeConn) LocalAddr() net.Addr { return c.local } +func (c *fakeConn) RemoteAddr() net.Addr { return c.remote } +func (c *fakeConn) SetDeadline(stdtime.Time) error { return nil } +func (c *fakeConn) SetReadDeadline(stdtime.Time) error { return nil } +func (c *fakeConn) SetWriteDeadline(stdtime.Time) error { return nil } + +// dialRaw dials the server and returns a bare ftpObj (greeting consumed), for +// the direct-helper tests that need to flip unexported state (epsv / passive). +func dialRaw(t *testing.T, s *ftpServer) *ftpObj { + t.Helper() + conn, err := net.Dial("tcp", s.ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + f := &ftpObj{conn: conn, r: bufio.NewReader(conn), binary: true, passive: true} + if _, err := f.r.ReadString('\n'); err != nil { // greeting + t.Fatalf("greeting: %v", err) + } + return f +} + +// --- tests: happy-path Ruby surface ----------------------------------------- + +func TestFTPRequireAndClassTree(t *testing.T) { + got := runSrc(t, ` +p require "net/ftp" +p require "net/ftp" +p Net::FTP.is_a?(Class) +p Net::FTP::MLSxEntry.is_a?(Class) +p Net::FTPError < StandardError +p Net::FTPReplyError < Net::FTPError +p Net::FTPTempError < Net::FTPError +p Net::FTPPermError < Net::FTPError +p Net::FTPProtoError < Net::FTPError +p Net::FTPConnectionError < Net::FTPError +`) + want := "true\nfalse\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue" + if got != want { + t.Fatalf("require/class tree got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPConnectLoginBasics(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT) +puts ftp.welcome +ftp.login +puts ftp.system +puts ftp.pwd +puts ftp.getdir +puts ftp.last_response_code +puts ftp.lastresp +puts ftp.closed? +ftp.quit +ftp.close +puts ftp.closed? +`) + want := "220 rbgo test FTP ready\nUNIX Type: L8\n/\n/\n257\n257\nfalse\ntrue" + if got != want { + t.Fatalf("got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPLoginVariants(t *testing.T) { + s := newFTPServer(t) + // Anonymous default password (USER anonymous -> 331 -> PASS anonymous@). + if got := s.run(t, `Net::FTP.new("127.0.0.1", port: PORT).login; puts "ok"`); got != "ok" { + t.Fatalf("anonymous: %q", got) + } + // new via options username/password. + if got := s.run(t, `ftp = Net::FTP.new("127.0.0.1", port: PORT, username: "bob", password: "sekret"); puts ftp.welcome[0,3]`); got != "220" { + t.Fatalf("opts login: %q", got) + } +} + +func TestFTPLoginAccountLadder(t *testing.T) { + s := newFTPServer(t) + s.setOverride("PASS", "332 need account\r\n") + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT) +ftp.login("bob", "pw", "acct123") +puts "ok" +`) + if got != "ok" { + t.Fatalf("account ladder: %q", got) + } + // Missing account when the server demands one -> FTPReplyError. + got = s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("bob", "pw") +rescue Net::FTPReplyError => e + puts "reply-error" +end +`) + if got != "reply-error" { + t.Fatalf("missing acct: %q", got) + } +} + +func TestFTPLoginMissingPassword(t *testing.T) { + s := newFTPServer(t) + // USER -> 331 (needs pass) but no password supplied for a non-anonymous user. + got := s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("bob") +rescue Net::FTPReplyError + puts "need-pass" +end +`) + if got != "need-pass" { + t.Fatalf("missing pass: %q", got) + } +} + +func TestFTPDirectoryCommands(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.chdir("sub") +ftp.chdir("..") +puts ftp.mkdir("newdir") +ftp.rmdir("newdir") +ftp.delete("x.txt") +ftp.rename("a", "b") +ftp.noop +ftp.site("chmod 755 x") +puts ftp.status +puts ftp.help +`) + want := "newdir\n211 status ok\n214 help text" + if got != want { + t.Fatalf("dir commands got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPChdirCdupFallback(t *testing.T) { + s := newFTPServer(t) + // CDUP not understood (500) -> chdir("..") falls back to CWD "..". + s.setOverride("CDUP", "500 not understood\r\n") + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.chdir("..") +puts "fell-back" +`) + if got != "fell-back" { + t.Fatalf("cdup fallback: %q", got) + } + // CDUP hard error (550) -> raises (not a 500, so no fallback). + s.setOverride("CDUP", "550 denied\r\n") + got = s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.chdir("..") +rescue Net::FTPPermError + puts "perm" +end +`) + if got != "perm" { + t.Fatalf("cdup perm: %q", got) + } + // CDUP proto error (non-standard code) -> FTPProtoError via ClassifyReply. + s.setOverride("CDUP", "600 weird\r\n") + got = s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.chdir("..") +rescue Net::FTPProtoError + puts "proto" +end +`) + if got != "proto" { + t.Fatalf("cdup proto: %q", got) + } + // CDUP replies a stray 3yz (passes ClassifyReply, not 2yz, not 500) -> FTPReplyError. + s.setOverride("CDUP", "350 hmm\r\n") + got = s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.chdir("..") +rescue Net::FTPReplyError + puts "reply" +end +`) + if got != "reply" { + t.Fatalf("cdup reply: %q", got) + } +} + +func TestFTPMetadataCommands(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +puts ftp.size("file.txt") +puts ftp.mdtm("file.txt") +puts ftp.mtime("file.txt").strftime("%Y-%m-%d %H:%M:%S") +`) + want := strconv.Itoa(len("line one\nline two\n")) + "\n20200102030405\n2020-01-02 03:04:05" + if got != want { + t.Fatalf("metadata got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPListings(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +p ftp.nlst +p ftp.list("/pub") +collected = [] +ftp.list { |l| collected << l } +p collected.size +`) + want := `["dir1", "file.txt"]` + "\n" + + `["drwxr-xr-x 2 u g 4096 Jan 01 00:00 dir1", "-rw-r--r-- 1 u g 12 Jan 01 00:00 file.txt"]` + "\n2" + if got != want { + t.Fatalf("listings got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPMlsd(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +entries = ftp.mlsd +d, f = entries +puts d.pathname +puts d.directory? +puts d.file? +puts d.listable? +puts f.pathname +puts f.file? +puts f.readable? +puts f.writable? +puts f.deletable? +puts f.facts["size"] +puts f.facts["modify"].strftime("%Y%m%d%H%M%S") +puts d.facts["unix.mode"] +puts f.facts["unix.owner"] +`) + want := "dir1\ntrue\nfalse\ntrue\nfile.txt\ntrue\ntrue\ntrue\nfalse\n12\n20200102030405\n493\n1000" + if got != want { + t.Fatalf("mlsd got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPMlsdBlockAndPermPredicates(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +names = [] +ftp.mlsd("/pub") { |e| names << e.pathname } +p names +e = ftp.mlsd.first +puts [e.appendable?, e.creatable?, e.enterable?, e.renamable?, e.directory_makable?, e.purgeable?].inspect +`) + want := `["dir1", "file.txt"]` + "\n[false, false, true, false, false, false]" + if got != want { + t.Fatalf("mlsd block/perm got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPMlst(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +e = ftp.mlst("file.txt") +puts e.pathname +puts e.file? +puts e.facts["size"] +`) + if got != "file.txt\ntrue\n12" { + t.Fatalf("mlst got:\n%q", got) + } +} + +func TestFTPTransferEngines(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +lines = [] +ftp.retrlines("RETR file.txt") { |l| lines << l } +p lines +chunks = "".b +ftp.retrbinary("RETR file.txt", 4) { |c| chunks << c } +p chunks +ftp.storlines("STOR up.txt", "a\nb\n") +ftp.storbinary("STOR bin.dat", "xyz".b, 2) +puts "done" +`) + want := `["line one", "line two"]` + "\n" + `"line one\nline two\n"` + "\ndone" + if got != want { + t.Fatalf("transfer engines got:\n%q\nwant:\n%q", got, want) + } + if c := s.storedContent("up.txt"); c != "a\r\nb\r\n" { + t.Fatalf("storlines captured %q", c) + } + if c := s.storedContent("bin.dat"); c != "xyz" { + t.Fatalf("storbinary captured %q", c) + } +} + +func TestFTPGetPutFiles(t *testing.T) { + s := newFTPServer(t) + dir := t.TempDir() + binLocal := filepath.Join(dir, "got.bin") + txtLocal := filepath.Join(dir, "got.txt") + upLocal := filepath.Join(dir, "up.txt") + if err := os.WriteFile(upLocal, []byte("upload me\n"), 0o644); err != nil { + t.Fatal(err) + } + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.getbinaryfile("file.txt", %q) +ftp.binary = false +ftp.gettextfile("file.txt", %q) +ftp.putbinaryfile(%q, "remote.bin") +ftp.binary = true +ftp.putbinaryfile(%q) +# block forms +bin = "".b +ftp.getbinaryfile("file.txt") { |c| bin << c } +p bin.bytesize +txt = [] +ftp.gettextfile("file.txt") { |l| txt << l } +p txt +puts ftp.binary +`, binLocal, txtLocal, upLocal, upLocal)) + if !strings.Contains(got, "18\n") { + t.Fatalf("get/put block got:\n%q", got) + } + if b, _ := os.ReadFile(binLocal); string(b) != "line one\nline two\n" { + t.Fatalf("getbinaryfile wrote %q", b) + } + if b, _ := os.ReadFile(txtLocal); string(b) != "line one\nline two\n" { + t.Fatalf("gettextfile wrote %q", b) + } + if c := s.storedContent("remote.bin"); c != "upload me\n" { + t.Fatalf("putbinaryfile captured %q", c) + } + if c := s.storedContent("up.txt"); c != "upload me\n" { // default remote basename + t.Fatalf("putbinaryfile default name captured %q", c) + } +} + +func TestFTPGetPutDispatchAndText(t *testing.T) { + s := newFTPServer(t) + dir := t.TempDir() + local := filepath.Join(dir, "d.txt") + local2 := filepath.Join(dir, "d2.txt") + up := filepath.Join(dir, "u.txt") + if err := os.WriteFile(up, []byte("hello\nworld\n"), 0o644); err != nil { + t.Fatal(err) + } + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.get("file.txt", %q) # binary dispatch (default binary) +ftp.puttextfile(%q, "textup.txt") +ftp.put(%q, "binput.txt") # binary dispatch (put) +ftp.binary = false +ftp.get("file.txt", %q) # text dispatch (get) +ftp.put(%q, "put.txt") # text dispatch (put) +puts "ok" +`, local, up, up, local2, up)) + if got != "ok" { + t.Fatalf("dispatch: %q", got) + } + if c := s.storedContent("binput.txt"); c != "hello\nworld\n" { + t.Fatalf("put binary captured %q", c) + } + if c := s.storedContent("textup.txt"); c != "hello\r\nworld\r\n" { + t.Fatalf("puttextfile captured %q", c) + } + if c := s.storedContent("put.txt"); c != "hello\r\nworld\r\n" { + t.Fatalf("put text captured %q", c) + } +} + +func TestFTPActiveMode(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.passive = false +puts ftp.passive +p ftp.nlst +ftp.storlines("STOR a.txt", "x\n") +puts "active-done" +`) + if !strings.Contains(got, "active-done") || !strings.Contains(got, `["dir1", "file.txt"]`) { + t.Fatalf("active mode got:\n%q", got) + } + if s.storedContent("a.txt") != "x\r\n" { + t.Fatalf("active stor captured %q", s.storedContent("a.txt")) + } +} + +func TestFTPUsePasvIP(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT, use_pasv_ip: true); ftp.login +puts ftp.use_pasv_ip +p ftp.nlst +`) + if !strings.Contains(got, "true") || !strings.Contains(got, `["dir1", "file.txt"]`) { + t.Fatalf("use_pasv_ip got:\n%q", got) + } +} + +func TestFTPEPSVAndEPRT(t *testing.T) { + s := newFTPServer(t) + // EPSV (passive + epsv): drive the helper directly with epsv flipped on. + f := dialRaw(t, s) + f.epsv = true + var lines []string + f.retrlines(netftp.NlstCommand(""), func(l string) { lines = append(lines, l) }) + f.doClose() + if strings.Join(lines, ",") != "dir1,file.txt" { + t.Fatalf("epsv nlst: %v", lines) + } + // EPRT (active + epsv). + f2 := dialRaw(t, s) + f2.epsv = true + f2.passive = false + var lines2 []string + f2.retrlines(netftp.NlstCommand(""), func(l string) { lines2 = append(lines2, l) }) + f2.doClose() + if strings.Join(lines2, ",") != "dir1,file.txt" { + t.Fatalf("eprt nlst: %v", lines2) + } +} + +func TestFTPOpenBlock(t *testing.T) { + s := newFTPServer(t) + // open with a block yields the client and closes it afterwards. + got := s.run(t, ` +res = Net::FTP.open("127.0.0.1", port: PORT) do |ftp| + ftp.login + ftp.pwd +end +puts res +`) + if got != "/" { + t.Fatalf("open block: %q", got) + } + // open without a block returns the client. + got = s.run(t, ` +ftp = Net::FTP.open("127.0.0.1", port: PORT) +ftp.login +puts ftp.pwd +ftp.close +`) + if got != "/" { + t.Fatalf("open no-block: %q", got) + } + // open's block close runs even when the block raises. + got = s.run(t, ` +begin + Net::FTP.open("127.0.0.1", port: PORT) { |ftp| ftp.login; raise "boom" } +rescue => e + puts e.message +end +`) + if got != "boom" { + t.Fatalf("open block raise: %q", got) + } +} + +func TestFTPNoHostConstructor(t *testing.T) { + s := newFTPServer(t) + // new with no host / nil host does not connect. + got := s.run(t, ` +ftp = Net::FTP.new +puts ftp.closed? +ftp2 = Net::FTP.new(nil) +puts ftp2.passive +ftp.connect("127.0.0.1", PORT) +ftp.login +puts ftp.pwd +`) + if got != "true\ntrue\n/" { + t.Fatalf("no-host constructor: %q", got) + } +} + +func TestFTPGenericCommands(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +puts ftp.sendcmd("NOOP")[0,3] +ftp.voidcmd("NOOP") +puts ftp.last_response[0,3] +puts ftp.abort[0,3] +`) + if got != "200\n200\n226" { + t.Fatalf("generic commands: %q", got) + } +} + +// --- tests: error branches -------------------------------------------------- + +func TestFTPErrorMapping(t *testing.T) { + s := newFTPServer(t) + cases := []struct{ reply, rescue, tag string }{ + {"421 service not available\r\n", "Net::FTPTempError", "temp"}, + {"550 permission denied\r\n", "Net::FTPPermError", "perm"}, + {"600 nonstandard\r\n", "Net::FTPProtoError", "proto"}, + } + for _, c := range cases { + s.setOverride("NOOP", c.reply) + got := s.run(t, fmt.Sprintf(` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.noop +rescue %s + puts %q +end +`, c.rescue, c.tag)) + if got != c.tag { + t.Fatalf("%s: got %q", c.tag, got) + } + } + s.clearOverride("NOOP") +} + +func TestFTPConnectionErrors(t *testing.T) { + // Dial to a closed port -> Net::FTPConnectionError. + closed := freePort(t) + got := runSrc(t, fmt.Sprintf(`require "net/ftp" +begin + Net::FTP.new("127.0.0.1", port: %d) +rescue Net::FTPConnectionError + puts "conn-err" +end`, closed)) + if got != "conn-err" { + t.Fatalf("dial error: %q", got) + } + // Server closes the control connection before replying -> read failure. + s := newFTPServer(t) + s.setOverride("NOOP", "@close") + got = s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.noop +rescue Net::FTPConnectionError + puts "read-err" +end`) + if got != "read-err" { + t.Fatalf("read error: %q", got) + } +} + +func TestFTPCommandErrorBranches(t *testing.T) { + s := newFTPServer(t) + // Each of these replies passes getResp (1/2/3yz) but fails the command's own + // classifier, exercising the ftpRaise error branch in that method. + type tc struct { + verb, reply, body, rescue, tag string + } + cases := []tc{ + {"DELE", "200 ok\r\n", `ftp.delete("x")`, "Net::FTPReplyError", "dele"}, + {"RNFR", "200 ok\r\n", `ftp.rename("a", "b")`, "Net::FTPReplyError", "rnfr"}, + {"SIZE", "200 nope\r\n", `ftp.size("x")`, "Net::FTPReplyError", "size"}, + {"SYST", "200 nope\r\n", `ftp.system`, "Net::FTPReplyError", "syst"}, + {"MDTM", "200 nope\r\n", `ftp.mdtm("x")`, "Net::FTPReplyError", "mdtm"}, + {"MDTM", "200 nope\r\n", `ftp.mtime("x")`, "Net::FTPReplyError", "mtime"}, + {"MKD", "250 no-quotes here\r\n", `ftp.mkdir("d")`, "Net::FTPReplyError", "mkd"}, + {"PWD", "250 no-quotes here\r\n", `ftp.pwd`, "Net::FTPReplyError", "pwd"}, + } + for _, c := range cases { + s.setOverride(c.verb, c.reply) + got := s.run(t, fmt.Sprintf(` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + %s +rescue %s + puts %q +end +`, c.body, c.rescue, c.tag)) + if got != c.tag { + t.Fatalf("%s (verb %s): got %q", c.tag, c.verb, got) + } + s.clearOverride(c.verb) + } +} + +func TestFTPPwdParse257Empty(t *testing.T) { + s := newFTPServer(t) + // 257 without a quoted string decodes to "" (Parse257 no-match branch). + s.setOverride("PWD", "257 no quoted path\r\n") + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +puts ftp.pwd.empty? +`) + if got != "true" { + t.Fatalf("pwd empty: %q", got) + } +} + +func TestFTPMtimeInvalid(t *testing.T) { + s := newFTPServer(t) + s.setOverride("MDTM", "213 not-a-time\r\n") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.mtime("x") +rescue Net::FTPProtoError + puts "proto" +end +`) + if got != "proto" { + t.Fatalf("mtime invalid: %q", got) + } +} + +func TestFTPMlstErrors(t *testing.T) { + s := newFTPServer(t) + // Not a 250 reply -> FTPReplyError. + s.setOverride("MLST", "200 not listing\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.mlst("x") +rescue Net::FTPReplyError + puts "reply" +end`); got != "reply" { + t.Fatalf("mlst reply: %q", got) + } + // 250 single-line (no entry line) -> FTPProtoError. + s.setOverride("MLST", "250 done\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.mlst("x") +rescue Net::FTPProtoError + puts "proto" +end`); got != "proto" { + t.Fatalf("mlst proto: %q", got) + } + // 250 with a malformed entry line (no space -> no pathname) -> FTPProtoError. + s.setOverride("MLST", "250-list\r\nbadentrynopath\r\n250 End\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.mlst("x") +rescue Net::FTPProtoError + puts "bad" +end`); got != "bad" { + t.Fatalf("mlst bad entry: %q", got) + } + s.clearOverride("MLST") +} + +func TestFTPMlsdParseError(t *testing.T) { + s := newFTPServer(t) + // A data line with no space has no pathname -> ParseMLSxEntry FTPProtoError. + // First line is malformed (no space -> no pathname); a following valid line + // then hits the "already errored" short-circuit before the raise. + s.setDataOverride("MLSD", "type=file;size=1;nopath\r\ntype=file; ok\r\n") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.mlsd +rescue Net::FTPProtoError + puts "proto" +end +`) + if got != "proto" { + t.Fatalf("mlsd parse error: %q", got) + } + s.clearDataOverride("MLSD") +} + +func TestFTPTransferCmdErrorReleasesData(t *testing.T) { + s := newFTPServer(t) + // A 5yz reply to a transfer command (after PASV opened a data listener) + // re-raises and the binding closes the dialed data socket (no leak). + s.setOverride("NLST", "550 denied\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.nlst +rescue Net::FTPPermError + puts "perm" +end`); got != "perm" { + t.Fatalf("transfer perm: %q", got) + } + // A stray 3yz reply is not a mark -> FTPReplyError. + s.setOverride("NLST", "350 hmm\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.nlst +rescue Net::FTPReplyError + puts "reply" +end`); got != "reply" { + t.Fatalf("transfer mark: %q", got) + } + // Same error in active mode closes the listener (active cleanup path). + s.setOverride("NLST", "550 denied\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.passive = false + ftp.nlst +rescue Net::FTPPermError + puts "active-perm" +end`); got != "active-perm" { + t.Fatalf("active transfer perm: %q", got) + } + s.clearOverride("NLST") +} + +func TestFTPCRLFRejection(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + ftp.sendcmd("FOO\nBAR") +rescue ArgumentError + puts "argerr" +end +`) + if got != "argerr" { + t.Fatalf("crlf rejection: %q", got) + } +} + +func TestFTPArityErrors(t *testing.T) { + s := newFTPServer(t) + for _, body := range []string{ + `ftp.connect`, + `ftp.chdir`, + `ftp.mkdir("a", "b")`, + `ftp.rename("a")`, + `ftp.size`, + `ftp.sendcmd`, + `ftp.retrlines`, + `ftp.storlines("STOR x")`, + `ftp.getbinaryfile`, + } { + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + %s +rescue ArgumentError + puts "argerr" +end +`, body)) + if got != "argerr" { + t.Fatalf("arity %q: got %q", body, got) + } + } +} + +func TestFTPGetPutFileErrors(t *testing.T) { + s := newFTPServer(t) + // putbinaryfile / puttextfile of a nonexistent local file -> Errno::ENOENT. + for _, m := range []string{"putbinaryfile", "puttextfile"} { + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + ftp.%s("/no/such/file/at/all", "r") +rescue Errno::ENOENT + puts "enoent" +end +`, m)) + if got != "enoent" { + t.Fatalf("%s enoent: %q", m, got) + } + } + // getbinaryfile / gettextfile to an unwritable local path -> Errno::EACCES. + bad := filepath.Join(t.TempDir(), "nonexistent-dir", "out") + for _, m := range []string{"getbinaryfile", "gettextfile"} { + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + ftp.%s("file.txt", %q) +rescue Errno::EACCES + puts "eacces" +end +`, m, bad)) + if got != "eacces" { + t.Fatalf("%s eacces: %q", m, got) + } + } +} + +// --- tests: direct helper units --------------------------------------------- + +func TestFTPIsIPv6(t *testing.T) { + v4 := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 1}} + v6 := &fakeConn{remote: &net.TCPAddr{IP: net.ParseIP("::1"), Port: 1}} + other := &fakeConn{remote: fakeAddr("no-port-here")} + if ftpIsIPv6(v4) { + t.Fatal("ipv4 reported as ipv6") + } + if !ftpIsIPv6(v6) { + t.Fatal("ipv6 not detected") + } + if ftpIsIPv6(other) { + t.Fatal("non-tcp addr reported as ipv6") + } +} + +func TestFTPHostHelpersMalformedAddr(t *testing.T) { + // A portless address exercises the SplitHostPort error fallback (whole string). + c := &fakeConn{remote: fakeAddr("hostonly"), local: fakeAddr("localonly")} + f := &ftpObj{conn: c} + if h := f.remoteHost(); h != "hostonly" { + t.Fatalf("remoteHost fallback = %q", h) + } + if h := f.localHost(); h != "localonly" { + t.Fatalf("localHost fallback = %q", h) + } + // And the normal split path. + ok := &fakeConn{remote: fakeAddr("1.2.3.4:5"), local: fakeAddr("6.7.8.9:10")} + f2 := &ftpObj{conn: ok} + if h := f2.remoteHost(); h != "1.2.3.4" { + t.Fatalf("remoteHost = %q", h) + } + if h := f2.localHost(); h != "6.7.8.9" { + t.Fatalf("localHost = %q", h) + } +} + +func TestFTPTextHelpers(t *testing.T) { + if got := ftpChomp("a\r\n"); got != "a" { + t.Fatalf("chomp crlf: %q", got) + } + if got := ftpChomp("a\n"); got != "a" { + t.Fatalf("chomp lf: %q", got) + } + if got := ftpChomp("a\r"); got != "a" { + t.Fatalf("chomp cr: %q", got) + } + if got := ftpChomp("a"); got != "a" { + t.Fatalf("chomp none: %q", got) + } + if got := ftpChomp(""); got != "" { + t.Fatalf("chomp empty: %q", got) + } + if got := ftpToCRLF("a\nb\r\nc\n"); got != "a\r\nb\r\nc\r\n" { + t.Fatalf("toCRLF: %q", got) + } + if got := ftpBasename("/a/b/c.txt"); got != "c.txt" { + t.Fatalf("basename path: %q", got) + } + if got := ftpBasename("c.txt"); got != "c.txt" { + t.Fatalf("basename bare: %q", got) + } +} + +func TestFTPInspectTruthyAndClose(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +p ftp +puts ftp.to_s +puts(ftp ? "truthy" : "falsy") +e = ftp.mlsd.first +p e +puts e.to_s.start_with?("#\ntrue\net\ntrue" + if got != want { + t.Fatalf("inspect/truthy got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPUnconnectedClose(t *testing.T) { + got := runSrc(t, `require "net/ftp" +ftp = Net::FTP.new +ftp.close # conn is nil -> no-op +puts ftp.closed?`) + if got != "true" { + t.Fatalf("unconnected close: %q", got) + } +} + +func TestFTPVoidRespNon2(t *testing.T) { + s := newFTPServer(t) + s.setOverride("NOOP", "350 not a 2yz\r\n") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.noop +rescue Net::FTPReplyError + puts "reply" +end`) + if got != "reply" { + t.Fatalf("voidresp non-2: %q", got) + } +} + +func TestFTPLoginErrorLadders(t *testing.T) { + s := newFTPServer(t) + // USER reply is a 1yz (passes getResp) but not 2/3yz -> LoginNext error. + s.setOverride("USER", "150 odd\r\n") + if got := s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("u", "p") +rescue Net::FTPReplyError + puts "user" +end`); got != "user" { + t.Fatalf("user ladder: %q", got) + } + s.clearOverride("USER") + // PASS reply is a 1yz -> LoginNext(after PASS) error. + s.setOverride("PASS", "150 odd\r\n") + if got := s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("u", "p") +rescue Net::FTPReplyError + puts "pass" +end`); got != "pass" { + t.Fatalf("pass ladder: %q", got) + } + s.clearOverride("PASS") + // ACCT reply is a 1yz (not 2yz) -> account step FTPReplyError. + s.setOverride("PASS", "332 need account\r\n") + s.setOverride("ACCT", "150 odd\r\n") + if got := s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("u", "p", "acct") +rescue Net::FTPReplyError + puts "acct" +end`); got != "acct" { + t.Fatalf("acct ladder: %q", got) + } + s.clearOverride("PASS") + s.clearOverride("ACCT") +} + +func TestFTPMakepasvParseErrors(t *testing.T) { + s := newFTPServer(t) + // Malformed 227 -> FTPProtoError (Parse227). + s.setOverride("PASV", "227 no address here\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.nlst +rescue Net::FTPProtoError + puts "pasv" +end`); got != "pasv" { + t.Fatalf("pasv parse: %q", got) + } + s.clearOverride("PASV") + // Malformed 229 -> FTPProtoError (Parse229), driven via the EPSV path. + s.setOverride("EPSV", "229 no delimiters\r\n") + f := dialRaw(t, s) + f.epsv = true + expectRubyError(t, "Net::FTPProtoError", func() { + f.retrlines(netftp.NlstCommand(""), func(string) {}) + }) + f.doClose() + s.clearOverride("EPSV") +} + +func TestFTPTransferReadError(t *testing.T) { + s := newFTPServer(t) + // Server closes the control connection while replying to the transfer command. + s.setOverride("NLST", "@close") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.nlst +rescue Net::FTPConnectionError + puts "conn" +end`) + if got != "conn" { + t.Fatalf("transfer read error: %q", got) + } + s.clearOverride("NLST") +} + +func TestFTPChdirReadError(t *testing.T) { + s := newFTPServer(t) + s.setOverride("CDUP", "@close") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.chdir("..") +rescue Net::FTPConnectionError + puts "conn" +end`) + if got != "conn" { + t.Fatalf("chdir read error: %q", got) + } + s.clearOverride("CDUP") +} + +func TestFTPAbortErrors(t *testing.T) { + s := newFTPServer(t) + // A reply the codes MRI accepts do not include -> FTPProtoError. + s.setOverride("ABOR", "500 bad\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.abort +rescue Net::FTPProtoError + puts "proto" +end`); got != "proto" { + t.Fatalf("abort proto: %q", got) + } + // Server closes before replying to ABOR -> FTPConnectionError. + s.setOverride("ABOR", "@close") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.abort +rescue Net::FTPConnectionError + puts "conn" +end`); got != "conn" { + t.Fatalf("abort conn: %q", got) + } + s.clearOverride("ABOR") +} + +func TestFTPOptionAndAliasCoverage(t *testing.T) { + s := newFTPServer(t) + // String-keyed options hash, the ls/dir/getdir aliases, and the setters' + // return values. + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", {"port" => PORT}); ftp.login +p ftp.ls.size +p ftp.dir.size +puts ftp.getdir +puts(ftp.binary = false) +puts ftp.binary +puts(ftp.use_pasv_ip = true) +ftp.nlst(nil) # explicit nil pathname -> ftpOptString nil branch +ftp.retrbinary("RETR x", 0) { |c| } # invalid blocksize -> default +puts "ok" +`) + if !strings.Contains(got, "ok") || !strings.HasPrefix(got, "2\n2\n/\nfalse\nfalse\ntrue\n") { + t.Fatalf("option/alias coverage: %q", got) + } +} + +func TestFTPAccountAndPassiveOptions(t *testing.T) { + s := newFTPServer(t) + s.setOverride("PASS", "332 need account\r\n") + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT, passive: false, username: "u", password: "p", account: "acct") +puts ftp.passive +p ftp.nlst +`) + if !strings.HasPrefix(got, "false\n") || !strings.Contains(got, `["dir1", "file.txt"]`) { + t.Fatalf("account/passive options: %q", got) + } + s.clearOverride("PASS") +} + +func TestFTPStorlinesTypeError(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + ftp.storbinary("STOR x", 12345) +rescue TypeError + puts "typeerr" +end +`) + if got != "typeerr" { + t.Fatalf("stor type error: %q", got) + } +} + +// --- direct helper error-branch units --------------------------------------- + +// expectRubyError runs fn and asserts it raised the given Ruby exception class. +func expectRubyError(t *testing.T, class string, fn func()) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("expected raise %s, got none", class) + } + re, ok := r.(RubyError) + if !ok { + t.Fatalf("expected RubyError, got %T: %v", r, r) + } + if re.Class != class { + t.Fatalf("expected %s, got %s", class, re.Class) + } + }() + fn() +} + +func TestFTPRaiseFallback(t *testing.T) { + // A non-FTPError (a bare transport error) maps to Net::FTPConnectionError. + expectRubyError(t, "Net::FTPConnectionError", func() { + ftpRaise(fmt.Errorf("boom")) + }) +} + +func TestFTPWriteLineError(t *testing.T) { + s := newFTPServer(t) + f := dialRaw(t, s) + f.conn.Close() // writing to a closed socket fails + expectRubyError(t, "Net::FTPConnectionError", func() { f.writeLine("NOOP") }) +} + +func TestFTPDialDataError(t *testing.T) { + f := &ftpObj{} + expectRubyError(t, "Net::FTPConnectionError", func() { + f.dialData("127.0.0.1", freePort(t)) // nothing is listening + }) +} + +func TestFTPAcceptDataError(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ln.Close() // accept on a closed listener fails + f := &ftpObj{} + expectRubyError(t, "Net::FTPConnectionError", func() { f.acceptData(ln) }) +} + +func TestFTPSendDataError(t *testing.T) { + c1, c2 := net.Pipe() + c2.Close() // the peer is gone, so writes fail + f := &ftpObj{} + expectRubyError(t, "Net::FTPConnectionError", func() { + f.sendData(c1, []byte("abcdef"), 2) // multiple chunks, then a write error + }) +} + +func TestFTPMakeportListenError(t *testing.T) { + // An unbindable local host makes net.Listen fail. + f := &ftpObj{conn: &fakeConn{local: fakeAddr("300.300.300.300:0")}} + expectRubyError(t, "Net::FTPConnectionError", func() { f.makeport() }) +} + +// freePort returns a port number with no listener bound to it. +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + p := ln.Addr().(*net.TCPAddr).Port + ln.Close() + return p +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..74f28505 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -978,6 +978,10 @@ func (vm *VM) classOf(v object.Value) *RClass { return x.cls case *RedisBatch: return x.cls + case *ftpObj: + return x.cls + case *ftpMLSxEntry: + return x.cls case *PGConnObj: return x.cls case *PGResultObj: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..9018a06f 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -33,7 +33,7 @@ var providedFeatures = map[string]bool{ "concurrent": true, "syslog": true, "cgi": true, "monitor": true, "observer": true, "socket": true, - "net/http": true, "net/https": true, "resolv": true, "singleton": true, + "net/http": true, "net/https": true, "net/ftp": true, "resolv": true, "singleton": true, "optparse": true, "ripper": true, "erb": true, "find": true, "tempfile": true, "open3": true, "strscan": true, "fiber": true, "objspace": true, "csv": true, From 7b28f1b65a61ef39ceacd561b5b0196b1e17fb35 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:42:44 +0200 Subject: [PATCH 6/9] Bind net/smtp: Net::SMTP over the real socket/TLS transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 1 + go.sum | 2 + internal/vm/builtins.go | 1 + internal/vm/net_smtp.go | 750 ++++++++++++++++++ internal/vm/net_smtp_bind.go | 276 +++++++ internal/vm/net_smtp_internal_test.go | 1054 +++++++++++++++++++++++++ internal/vm/object_model.go | 6 + internal/vm/require.go | 2 +- 8 files changed, 2091 insertions(+), 1 deletion(-) create mode 100644 internal/vm/net_smtp.go create mode 100644 internal/vm/net_smtp_bind.go create mode 100644 internal/vm/net_smtp_internal_test.go diff --git a/go.mod b/go.mod index f56c9fd7..a3c3f110 100644 --- a/go.mod +++ b/go.mod @@ -89,6 +89,7 @@ require ( github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f + github.com/go-ruby-net-smtp/net-smtp v0.0.0-20260630142921-0151ad2e87f5 github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 github.com/go-ruby-observer/observer v0.0.0-20260630080708-c3a02da51f79 diff --git a/go.sum b/go.sum index a357f88a..0defc5a0 100644 --- a/go.sum +++ b/go.sum @@ -340,6 +340,8 @@ github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 h1:F+ghhcdLDBVGG github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7/go.mod h1:cm0D4pmaTvvQFPK815E7/uJaGeGxkr5xFLHixeQixTA= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f h1:nS0qwCPtVAtp+48j6KkdTdpVL8jT9L2ZGQcJZwOKev8= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f/go.mod h1:PiNQDuyrSPZX8OzApvgyn1fTjRNwx0KNO1vrzdAguEc= +github.com/go-ruby-net-smtp/net-smtp v0.0.0-20260630142921-0151ad2e87f5 h1:5WuRAb8NO7UCHTVUel5LmZfI0A76obGtDOYAzlhrKmM= +github.com/go-ruby-net-smtp/net-smtp v0.0.0-20260630142921-0151ad2e87f5/go.mod h1:+XJbVnPobgkWUscH8bDSw9F3jW6t3QY35DEAyE9qE9Q= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e h1:v8DyCc3Vp+REhN1uyQ4wN8CpJ6JMqWNOm9OESWFBtLU= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e/go.mod h1:WsLJ6/u8/nzHShZKChf20tHosn/nlVnOJiD/xEY6X8c= github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 h1:iZFtIn5WHQRz/oa04qYAlLvTi0XDRP5dKPGpJTGRzKg= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..f459a050 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -478,6 +478,7 @@ func (vm *VM) bootstrap() { vm.registerSQLite3() // SQLite3::Database/Statement (require "sqlite3"), backed by go-ruby-sqlite3 (modernc); needs StandardError for SQLite3::Exception vm.registerRedis() // Redis client (require "redis"), backed by go-ruby-redis RESP codec; socket = injected IO seam; needs StandardError for Redis::BaseError vm.registerPG() // PG::Connection/Result (require "pg"), backed by go-ruby-pg v3 protocol; socket = injected IO seam; needs StandardError for PG::Error + vm.registerNetSMTP() // Net::SMTP (require "net/smtp"), backed by go-ruby-net-smtp codec over rbgo's real socket/TLS transport; after registerSocket/registerNetHTTP (Net module + transport) vm.registerSidekiq() // Sidekiq::Client/Job(Worker) + module (require "sidekiq"), backed by go-ruby-sidekiq over go-redis/v9; Redis client dialled from Sidekiq.redis/ENV; perform body is Ruby seam; needs StandardError for Sidekiq::Error vm.registerResque() // Resque module + Job/Worker/Failure (require "resque"), backed by go-ruby-resque over go-redis/v9; @queue resolver + self.perform are Ruby seams; needs StandardError for Resque::Error vm.registerSequel() // Sequel query builder + Database (require "sequel"), backed by go-ruby-sequel; executor seam wired to SQLite3::Database (real execution) diff --git a/internal/vm/net_smtp.go b/internal/vm/net_smtp.go new file mode 100644 index 00000000..19790cca --- /dev/null +++ b/internal/vm/net_smtp.go @@ -0,0 +1,750 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strings" + "time" + + netsmtp "github.com/go-ruby-net-smtp/net-smtp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerNetSMTP installs Net::SMTP (require "net/smtp"): the session class, its +// Net::SMTP::Response view and message-stream adapter, and the Net::SMTPError +// exception family. The SMTP protocol (commands, replies, DATA dot-stuffing, SASL +// auth) is github.com/go-ruby-net-smtp/net-smtp; the byte I/O is rbgo's own +// socket transport (see net_smtp_bind.go). It runs after registerSocket / +// registerOpenSSL (the TCP + TLS layers it dials and upgrades through) and after +// registerNetHTTP (which creates the Net module and its transport helpers). +func (vm *VM) registerNetSMTP() { + netMod := vm.consts["Net"].(*RClass) + vm.registerNetSMTPErrors(netMod) + + smtp := newClass("Net::SMTP", vm.cObject) + netMod.consts["SMTP"] = smtp + vm.consts["Net::SMTP"] = smtp + + vm.registerSMTPResponse(smtp) + vm.registerSMTPStream(smtp) + vm.registerNetSMTPClassMethods(smtp) + vm.registerNetSMTPConfig(smtp) + vm.registerNetSMTPProtocol(smtp) +} + +// registerNetSMTPErrors installs the Net::SMTPError family (SMTPError < +// ProtocolError < StandardError; the five reply-keyed kinds plus +// SMTPUnsupportedCommand < SMTPError), the classes Net::SMTP raises via +// check_response. Each maps one ErrorKind.RubyClass() name so a raised codec +// error rescues as its MRI-faithful class. +func (vm *VM) registerNetSMTPErrors(netMod *RClass) { + std := vm.consts["StandardError"].(*RClass) + proto := newClass("Net::ProtocolError", std) + netMod.consts["ProtocolError"] = proto + vm.consts["Net::ProtocolError"] = proto + reg := func(simple string, super *RClass) *RClass { + qualified := "Net::" + simple + c := newClass(qualified, super) + netMod.consts[simple] = c + vm.consts[qualified] = c + return c + } + base := reg("SMTPError", proto) + reg("SMTPUnknownError", base) + reg("SMTPServerBusy", base) + reg("SMTPSyntaxError", base) + reg("SMTPAuthenticationError", base) + reg("SMTPFatalError", base) + reg("SMTPUnsupportedCommand", base) +} + +// SMTPResponseObj is the Ruby wrapper around a *netsmtp.Response (Net::SMTP::Response): +// the reply status, its full text, and the success?/continue? predicates. +type SMTPResponseObj struct { + cls *RClass + res *netsmtp.Response +} + +func (r *SMTPResponseObj) ToS() string { return r.res.String } +func (r *SMTPResponseObj) Inspect() string { return "#" } +func (r *SMTPResponseObj) Truthy() bool { return true } + +// smtpResponse wraps a codec reply in a Net::SMTP::Response Ruby object. +func (vm *VM) smtpResponse(res *netsmtp.Response) object.Value { + return &SMTPResponseObj{cls: vm.consts["Net::SMTP::Response"].(*RClass), res: res} +} + +// registerSMTPResponse installs Net::SMTP::Response (status / string / message / +// success? / continue?), mirroring the reply object MRI returns from +// send_message and the command helpers. +func (vm *VM) registerSMTPResponse(smtp *RClass) { + rc := newClass("Net::SMTP::Response", vm.cObject) + smtp.consts["Response"] = rc + vm.consts["Net::SMTP::Response"] = rc + res := func(v object.Value) *netsmtp.Response { return v.(*SMTPResponseObj).res } + rc.define("status", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(res(self).Status) + }) + rc.define("string", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(res(self).String) + }) + rc.define("message", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(res(self).Message()) + }) + rc.define("success?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(res(self).Success()) + }) + rc.define("continue?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(res(self).Continue()) + }) +} + +// SMTPStreamObj is the writable adapter Net::SMTP#open_message_stream (and the +// block form of #data) yields: #print / #puts / #write / #<< append to a buffer +// that becomes the DATA payload once the block returns. +type SMTPStreamObj struct { + cls *RClass + buf *strings.Builder +} + +func (s *SMTPStreamObj) ToS() string { return "#" } +func (s *SMTPStreamObj) Inspect() string { return "#" } +func (s *SMTPStreamObj) Truthy() bool { return true } + +// newSMTPStream builds a fresh message-stream adapter. +func (vm *VM) newSMTPStream() *SMTPStreamObj { + return &SMTPStreamObj{cls: vm.consts["Net::SMTP::Adapter"].(*RClass), buf: &strings.Builder{}} +} + +// registerSMTPStream installs the Net::SMTP::Adapter message-stream class with the +// IO-like write surface open_message_stream yields. +func (vm *VM) registerSMTPStream(smtp *RClass) { + rc := newClass("Net::SMTP::Adapter", vm.cObject) + smtp.consts["Adapter"] = rc + vm.consts["Net::SMTP::Adapter"] = rc + buf := func(v object.Value) *strings.Builder { return v.(*SMTPStreamObj).buf } + + // #write(*args) appends each argument and returns the total byte count. + rc.define("write", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := buf(self) + n := 0 + for _, a := range args { + s := smtpStr(a) + b.WriteString(s) + n += len(s) + } + return object.IntValue(int64(n)) + }) + // #print(*args) appends each argument without a separator and returns nil. + rc.define("print", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := buf(self) + for _, a := range args { + b.WriteString(smtpStr(a)) + } + return object.NilV + }) + // #puts(*args) appends each argument, adding a newline where one is missing; + // with no argument it appends a lone newline (Ruby IO#puts). + rc.define("puts", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := buf(self) + if len(args) == 0 { + b.WriteString("\n") + return object.NilV + } + for _, a := range args { + s := smtpStr(a) + b.WriteString(s) + if !strings.HasSuffix(s, "\n") { + b.WriteString("\n") + } + } + return object.NilV + }) + // #<<(obj) appends obj and returns self for chaining (Ruby IO#<<). + rc.define("<<", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + buf(self).WriteString(smtpStr(args[0])) + return self + }) +} + +// registerNetSMTPClassMethods installs Net::SMTP.new / .start plus the default +// port constants. +func (vm *VM) registerNetSMTPClassMethods(smtp *RClass) { + sm := func(name string, fn NativeFn) { + smtp.smethods[name] = &Method{name: name, owner: smtp, native: fn} + } + sm("new", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpNew(smtp, args) + }) + sm("start", func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + return vm.smtpClassStart(smtp, args, blk) + }) + port := func(name string, n int64) { + sm(name, func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(n) + }) + } + port("default_port", 25) + port("default_tls_port", 465) + port("default_ssl_port", 465) + port("default_submission_port", 587) +} + +// smtpNew builds an unstarted Net::SMTP for an address and optional port +// (default 25), with the modern defaults (ESMTP on, STARTTLS auto). +func (vm *VM) smtpNew(cls *RClass, args []object.Value) *SMTPObj { + if len(args) < 1 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + o := &SMTPObj{cls: cls, address: strArg(args[0]), port: "25", esmtp: true, starttls: smtpStartTLSAuto} + if len(args) >= 2 && !object.IsNil(args[1]) { + o.port = portString(args[1]) + } + return o +} + +// smtpClassStart implements Net::SMTP.start: build an instance, open the session, +// and either yield it to a block (closing it afterwards) or return it started. +// The address/port and the helo/user/secret/authtype credentials come from the +// positional arguments; a trailing options Hash may instead carry them by keyword +// (helo:, user:, secret:/password:, authtype:) along with tls: / starttls:. +func (vm *VM) smtpClassStart(cls *RClass, args []object.Value, blk *Proc) object.Value { + kw, args := smtpSplitKw(args) + if len(args) < 1 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") + } + o := &SMTPObj{cls: cls, address: strArg(args[0]), port: "25", esmtp: true, starttls: smtpStartTLSAuto} + if len(args) >= 2 && !object.IsNil(args[1]) { + o.port = portString(args[1]) + } + var rest []object.Value + if len(args) > 2 { + rest = args[2:] + } + helo, user, secret, authtype := vm.smtpStartCreds(rest, kw, o) + vm.smtpDoStart(o, helo, user, secret, authtype) + return vm.smtpYield(o, blk) +} + +// smtpSplitKw peels a trailing options Hash off an argument list. +func smtpSplitKw(args []object.Value) (*object.Hash, []object.Value) { + if len(args) > 0 { + if h, ok := args[len(args)-1].(*object.Hash); ok { + return h, args[:len(args)-1] + } + } + return nil, args +} + +// smtpStartCreds reads the helo domain and credentials from the (post-address) +// positional tail and/or the keyword Hash, and applies the tls:/starttls: +// keywords to o. rest is the positionals after address/port: helo, user, secret, +// authtype. +func (vm *VM) smtpStartCreds(rest []object.Value, kw *object.Hash, o *SMTPObj) (helo string, user, secret, authtype object.Value) { + helo, user, secret, authtype = "localhost", object.NilV, object.NilV, object.NilV + if len(rest) >= 1 && !object.IsNil(rest[0]) { + helo = strArg(rest[0]) + } + if len(rest) >= 2 { + user = rest[1] + } + if len(rest) >= 3 { + secret = rest[2] + } + if len(rest) >= 4 { + authtype = rest[3] + } + if kw != nil { + if v, ok := smtpKw(kw, "helo"); ok && !object.IsNil(v) { + helo = strArg(v) + } + if v, ok := smtpKw(kw, "user"); ok { + user = v + } + if v, ok := smtpKw(kw, "secret"); ok { + secret = v + } + if v, ok := smtpKw(kw, "password"); ok { + secret = v + } + if v, ok := smtpKw(kw, "authtype"); ok { + authtype = v + } + if v, ok := smtpKw(kw, "tls"); ok && v.Truthy() { + o.tls = true + } + if v, ok := smtpKw(kw, "starttls"); ok { + o.starttls = smtpStartTLSMode(v) + } + } + return helo, user, secret, authtype +} + +// smtpYield closes over the started session: with a block it yields the instance, +// finishes the session and returns the block's value; without one it returns the +// started instance. +func (vm *VM) smtpYield(o *SMTPObj, blk *Proc) object.Value { + if blk != nil { + res := vm.callBlock(blk, []object.Value{o}) + vm.smtpFinishQuiet(o) + return res + } + return o +} + +// smtpDoStart opens the session (Net::SMTP#do_start): dial, read and check the +// greeting, EHLO (falling back to HELO), optional STARTTLS + re-EHLO, and +// optional AUTH. Any failure closes the socket before raising. +func (vm *VM) smtpDoStart(o *SMTPObj, helo string, user, secret, authtype object.Value) { + if o.started { + raise("IOError", "SMTP session already started") + } + if err := vm.smtpDial(o); err != nil { + raise("SocketError", "%s", err.Error()) + } + o.deadline() + greeting, err := o.sess.RecvResponse() + if err != nil { + o.closeConn() + smtpRaiseErr(err) + } + if !greeting.Success() { + o.closeConn() + smtpRaiseErr(smtpRespErr(greeting)) + } + vm.smtpHello(o, helo) + if !o.tls && (o.starttls == smtpStartTLSAlways || (o.starttls == smtpStartTLSAuto && o.capableStartTLS())) { + if _, err := o.sess.StartTLS(); err != nil { + o.closeConn() + smtpRaiseErr(err) + } + vm.smtpHello(o, helo) + } + if !object.IsNil(user) && !object.IsNil(secret) { + vm.smtpAuthenticate(o, strArg(user), strArg(secret), authtype) + } + o.started = true +} + +// smtpHello sends EHLO (Net::SMTP#do_helo): on an ESMTP session it EHLOs and, if +// the server refuses, falls back to HELO and drops out of ESMTP mode; a +// non-ESMTP session HELOs directly. A transport error (not an SMTP reply) closes +// the socket and raises. +func (vm *VM) smtpHello(o *SMTPObj, helo string) { + o.deadline() + if o.esmtp { + if _, err := o.sess.Ehlo(helo); err != nil { + if _, ok := err.(*netsmtp.SMTPError); !ok { + o.closeConn() + smtpRaiseErr(err) + } + o.esmtp = false + if _, herr := o.sess.Helo(helo); herr != nil { + o.closeConn() + smtpRaiseErr(herr) + } + } + return + } + if _, err := o.sess.Helo(helo); err != nil { + o.closeConn() + smtpRaiseErr(err) + } +} + +// smtpAuthenticate runs the SASL exchange for the given authtype (default :plain), +// mapping to the codec's PLAIN / LOGIN / CRAM-MD5 driver. An unknown type raises +// ArgumentError; an auth failure closes the socket and raises the codec error. +func (vm *VM) smtpAuthenticate(o *SMTPObj, user, secret string, authtype object.Value) { + kind := "plain" + if !object.IsNil(authtype) { + kind = smtpAuthTypeName(authtype) + } + o.deadline() + var err error + switch kind { + case "plain": + _, err = o.sess.AuthPlain(user, secret) + case "login": + _, err = o.sess.AuthLogin(user, secret) + case "cram_md5": + _, err = o.sess.AuthCramMD5(user, secret) + default: + raise("ArgumentError", "unknown auth type: %s", kind) + } + if err != nil { + o.closeConn() + smtpRaiseErr(err) + } +} + +// smtpFinishQuiet closes a session (Net::SMTP#do_finish): QUIT best-effort, close +// the socket and clear the driver. +func (vm *VM) smtpFinishQuiet(o *SMTPObj) { + if o.sess != nil { + _, _ = o.sess.Quit() + } + o.closeConn() + o.started = false + o.sess = nil + o.conn = nil +} + +// registerNetSMTPConfig installs the configuration surface: the address/port +// readers, the ESMTP toggle, the TLS/STARTTLS mode setters and predicates, the +// timeouts, the started? flag, and the capability predicates. +func (vm *VM) registerNetSMTPConfig(smtp *RClass) { + obj := func(v object.Value) *SMTPObj { return v.(*SMTPObj) } + d := func(name string, fn NativeFn) { smtp.define(name, fn) } + + d("address", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(obj(self).address) + }) + d("port", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(obj(self).port) + }) + d("started?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).started) + }) + d("esmtp?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).esmtp) + }) + d("esmtp", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).esmtp) + }) + d("esmtp=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + obj(self).esmtp = args[0].Truthy() + return args[0] + }) + + // TLS / STARTTLS state. + d("tls?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).tls) + }) + d("ssl?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).tls) + }) + d("starttls?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).starttls != smtpStartTLSOff) + }) + d("starttls_auto?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).starttls == smtpStartTLSAuto) + }) + d("starttls_always?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).starttls == smtpStartTLSAlways) + }) + + // enable_tls / enable_ssl: implicit TLS on connect; exclusive with STARTTLS-always. + enableTLS := func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + if o.starttls == smtpStartTLSAlways { + raise("ArgumentError", "SMTPS and STARTTLS is exclusive") + } + o.tls = true + return object.NilV + } + d("enable_tls", enableTLS) + d("enable_ssl", enableTLS) + disableTLS := func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + obj(self).tls = false + return object.NilV + } + d("disable_tls", disableTLS) + d("disable_ssl", disableTLS) + + // enable_starttls[_auto] / disable_starttls: exclusive with implicit TLS. + d("enable_starttls", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + if o.tls { + raise("ArgumentError", "SMTPS and STARTTLS is exclusive") + } + o.starttls = smtpStartTLSAlways + return object.NilV + }) + d("enable_starttls_auto", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + if o.tls { + raise("ArgumentError", "SMTPS and STARTTLS is exclusive") + } + o.starttls = smtpStartTLSAuto + return object.NilV + }) + d("disable_starttls", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + obj(self).starttls = smtpStartTLSOff + return object.NilV + }) + + // Timeouts (seconds); the reader returns the last set value. + d("open_timeout=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + obj(self).openTO = nethttpDuration(args[0]) + return args[0] + }) + d("open_timeout", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return smtpDurationValue(obj(self).openTO) + }) + d("read_timeout=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + obj(self).readTO = nethttpDuration(args[0]) + return args[0] + }) + d("read_timeout", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return smtpDurationValue(obj(self).readTO) + }) + + // Capability predicates (valid after a started session's EHLO). + d("capable_starttls?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).capableStartTLS()) + }) + d("capable_plain_auth?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).capableAuth("PLAIN")) + }) + d("capable_login_auth?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).capableAuth("LOGIN")) + }) + d("capable_cram_md5_auth?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).capableAuth("CRAM-MD5")) + }) + d("capable_auth_types", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + var elems []object.Value + if o.sess != nil { + for _, a := range o.sess.Capabilities()["AUTH"] { + elems = append(elems, object.NewString(a)) + } + } + return object.NewArrayFromSlice(elems) + }) +} + +// smtpDurationValue renders a stored timeout back as a Ruby value: nil when unset, +// else the whole/fractional seconds. +func smtpDurationValue(d time.Duration) object.Value { + if d <= 0 { + return object.NilV + } + secs := d.Seconds() + if secs == float64(int64(secs)) { + return object.IntValue(int64(secs)) + } + return object.Float(secs) +} + +// registerNetSMTPProtocol installs the session lifecycle (start / finish) and the +// command surface (send_message / sendmail / open_message_stream / data / the +// low-level ehlo/helo/mailfrom/rcptto/rset and authenticate). +func (vm *VM) registerNetSMTPProtocol(smtp *RClass) { + obj := func(v object.Value) *SMTPObj { return v.(*SMTPObj) } + d := func(name string, fn NativeFn) { smtp.define(name, fn) } + + // #start(helo='localhost', user=nil, secret=nil, authtype=nil) [{ |smtp| }]. + d("start", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + o := obj(self) + kw, rest := smtpSplitKw(args) + helo, user, secret, authtype := vm.smtpStartCreds(rest, kw, o) + vm.smtpDoStart(o, helo, user, secret, authtype) + return vm.smtpYield(o, blk) + }) + d("finish", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + if !o.started { + raise("IOError", "not yet started") + } + vm.smtpFinishQuiet(o) + return object.NilV + }) + + // #send_message(msgstr, from_addr, *to_addrs) / #sendmail alias. + send := func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpSendMessage(obj(self), args) + } + d("send_message", send) + d("sendmail", send) + + // #open_message_stream(from_addr, to_addrs) { |stream| } / #ready alias. + stream := func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + return vm.smtpOpenMessageStream(obj(self), args, blk) + } + d("open_message_stream", stream) + d("ready", stream) + + // #data(msgstr=nil) [{ |stream| }]. + d("data", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + o := obj(self) + o.requireStarted() + o.deadline() + var msg string + switch { + case len(args) >= 1 && !object.IsNil(args[0]): + msg = smtpMessageString(args[0]) + case blk != nil: + st := vm.newSMTPStream() + vm.callBlock(blk, []object.Value{st}) + msg = st.buf.String() + default: + raise("ArgumentError", "message or block required") + } + res, err := o.sess.Data(msg) + if err != nil { + smtpRaiseErr(err) + } + return vm.smtpResponse(res) + }) + + // Low-level commands, each returning a Net::SMTP::Response. + d("ehlo", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.Ehlo(smtpArgStr(args, 0)) + }) + }) + d("helo", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.Helo(smtpArgStr(args, 0)) + }) + }) + d("mailfrom", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.MailFrom(smtpArgStr(args, 0)) + }) + }) + d("rcptto", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.RcptTo(smtpArgStr(args, 0)) + }) + }) + d("rset", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.Rset() + }) + }) + + // #rcptto_list(to_addrs) sends RCPT for each recipient and returns them. + d("rcptto_list", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + o := obj(self) + o.requireStarted() + to := smtpRecipients(args) + if len(to) == 0 { + raise("ArgumentError", "mail destination not given") + } + for _, t := range to { + o.deadline() + if _, err := o.sess.RcptTo(strArg(t)); err != nil { + smtpRaiseErr(err) + } + } + if blk != nil { + return vm.callBlock(blk, nil) + } + return object.NewArrayFromSlice(to) + }) + + // #authenticate(user, secret, authtype=nil). + d("authenticate", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := obj(self) + o.requireStarted() + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2..3)", len(args)) + } + authtype := object.Value(object.NilV) + if len(args) >= 3 { + authtype = args[2] + } + vm.smtpAuthenticate(o, strArg(args[0]), strArg(args[1]), authtype) + return object.NilV + }) +} + +// smtpCmd runs a single low-level command on a started session and wraps its +// reply, raising the mapped error on failure. +func (vm *VM) smtpCmd(o *SMTPObj, fn func(*netsmtp.Session) (*netsmtp.Response, error)) object.Value { + o.requireStarted() + o.deadline() + res, err := fn(o.sess) + if err != nil { + smtpRaiseErr(err) + } + return vm.smtpResponse(res) +} + +// smtpArgStr returns the i-th argument as a String, or "" when absent. +func smtpArgStr(args []object.Value, i int) string { + if i >= len(args) || object.IsNil(args[i]) { + return "" + } + return strArg(args[i]) +} + +// smtpRecipients flattens a recipient argument list: a single Array argument is +// its elements, otherwise the positional arguments themselves. +func smtpRecipients(args []object.Value) []object.Value { + if len(args) == 1 { + if arr, ok := args[0].(*object.Array); ok { + return arr.Elems + } + } + return args +} + +// smtpSendMessage implements Net::SMTP#send_message: MAIL FROM, a RCPT TO per +// recipient, then the DATA payload; it returns the final Net::SMTP::Response. +func (vm *VM) smtpSendMessage(o *SMTPObj, args []object.Value) object.Value { + o.requireStarted() + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2+)", len(args)) + } + msg := smtpMessageString(args[0]) + from := strArg(args[1]) + to := smtpRecipients(args[2:]) + if len(to) == 0 { + raise("ArgumentError", "mail destination not given") + } + o.deadline() + if _, err := o.sess.MailFrom(from); err != nil { + smtpRaiseErr(err) + } + for _, t := range to { + if _, err := o.sess.RcptTo(strArg(t)); err != nil { + smtpRaiseErr(err) + } + } + res, err := o.sess.Data(msg) + if err != nil { + smtpRaiseErr(err) + } + return vm.smtpResponse(res) +} + +// smtpOpenMessageStream implements Net::SMTP#open_message_stream: MAIL FROM, a +// RCPT TO per recipient, then a block that appends the message body to a stream +// adapter whose accumulated text is sent as the DATA payload. +func (vm *VM) smtpOpenMessageStream(o *SMTPObj, args []object.Value, blk *Proc) object.Value { + o.requireStarted() + if blk == nil { + raise("LocalJumpError", "no block given (yield)") + } + if len(args) < 1 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + from := strArg(args[0]) + to := smtpRecipients(args[1:]) + if len(to) == 0 { + raise("ArgumentError", "mail destination not given") + } + o.deadline() + if _, err := o.sess.MailFrom(from); err != nil { + smtpRaiseErr(err) + } + for _, t := range to { + if _, err := o.sess.RcptTo(strArg(t)); err != nil { + smtpRaiseErr(err) + } + } + st := vm.newSMTPStream() + vm.callBlock(blk, []object.Value{st}) + res, err := o.sess.Data(st.buf.String()) + if err != nil { + smtpRaiseErr(err) + } + return vm.smtpResponse(res) +} diff --git a/internal/vm/net_smtp_bind.go b/internal/vm/net_smtp_bind.go new file mode 100644 index 00000000..726d043e --- /dev/null +++ b/internal/vm/net_smtp_bind.go @@ -0,0 +1,276 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strings" + "time" + + netsmtp "github.com/go-ruby-net-smtp/net-smtp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the transport bridge between rbgo's Ruby object graph and the +// interpreter-independent SMTP codec of github.com/go-ruby-net-smtp/net-smtp. The +// command grammar, the DATA dot-stuffing, the reply parser, the EHLO capability +// parse and the SASL auth encodings all live in that library; rbgo supplies the +// one thing it deliberately leaves out — the socket. Net::SMTP dials its own +// TCP/TLS connection, so (unlike the redis/pg bindings' injected-IO seam) the +// transport here is rbgo's real socket layer (socket.go): net_smtp.go's +// Net::SMTP#start opens a TCPSocket to the address/port, wraps it in a smtpConn, +// and hands that to netsmtp.NewSession, which sequences the codec against it +// exactly the way MRI's Net::SMTP drives its @socket. STARTTLS upgrades the same +// socket in place through the crypto/tls handshake the net/http transport already +// uses. Tests drive an in-process SMTP server (net_smtp_internal_test.go) so no +// real network is touched. + +// smtpStartTLS selects the STARTTLS policy for a session, mirroring Net::SMTP's +// @starttls (false / :auto / :always). +const ( + // smtpStartTLSOff never issues STARTTLS. + smtpStartTLSOff = iota + // smtpStartTLSAuto issues STARTTLS only when the EHLO reply advertises it. + smtpStartTLSAuto + // smtpStartTLSAlways always issues STARTTLS (and fails if the server rejects it). + smtpStartTLSAlways +) + +// SMTPObj is the Ruby wrapper around a Net::SMTP session. It owns the socket it +// dials (conn) and the codec driver (sess); both are nil until #start opens them +// and are cleared again by #finish. The configuration fields mirror the Net::SMTP +// accessors (esmtp, tls, starttls, timeouts) and are read at #start time. +type SMTPObj struct { + cls *RClass + address string + port string + + sess *netsmtp.Session + conn *smtpConn + started bool + + esmtp bool // EHLO (true, the default) vs plain HELO + tls bool // implicit TLS on connect (SMTPS) + starttls int // smtpStartTLSOff / Auto / Always + verifyMode int64 + openTO time.Duration + readTO time.Duration +} + +func (o *SMTPObj) ToS() string { return "#" } +func (o *SMTPObj) Inspect() string { return o.ToS() } +func (o *SMTPObj) Truthy() bool { return true } + +// requireStarted raises IOError unless the session is open, matching MRI's guard +// on every command that needs a live connection. +func (o *SMTPObj) requireStarted() { + if !o.started || o.sess == nil { + raise("IOError", "not yet started") + } +} + +// deadline refreshes the socket read deadline before a protocol step when a +// read_timeout is set (0 clears/leaves it unbounded). +func (o *SMTPObj) deadline() { + if o.readTO > 0 && o.conn != nil { + nethttpSetDeadline(o.conn.stream, o.readTO) + } +} + +// closeConn closes the underlying socket, ignoring a close error on an already +// broken connection. +func (o *SMTPObj) closeConn() { + if o.conn != nil { + o.conn.close() + } +} + +// capableStartTLS reports whether the last EHLO advertised STARTTLS +// (Net::SMTP#capable_starttls?). +func (o *SMTPObj) capableStartTLS() bool { + if o.sess == nil { + return false + } + _, ok := o.sess.Capabilities()["STARTTLS"] + return ok +} + +// capableAuth reports whether the last EHLO advertised the given SASL mechanism +// (backs Net::SMTP#capable_plain_auth? / #capable_login_auth? / #capable_cram_md5_auth?). +func (o *SMTPObj) capableAuth(mech string) bool { + if o.sess == nil { + return false + } + auths, ok := o.sess.Capabilities()["AUTH"] + if !ok { + return false + } + for _, a := range auths { + if strings.EqualFold(a, mech) { + return true + } + } + return false +} + +// smtpConn adapts rbgo's socket transport (a streamIO — a raw tcpSocket or a TLS +// sslSocket) to the netsmtp.Conn seam the codec drives: WriteLine appends CRLF to +// a request line, ReadLine reads one reply line and chops its CRLF, WriteRaw +// writes an already dot-stuffed DATA payload verbatim, and StartTLS upgrades the +// socket in place through the TLS handshake. +type smtpConn struct { + stream streamIO + host string + verifyMode int64 +} + +// WriteLine sends a request line followed by CRLF (Net::BufferedIO#writeline). +func (c *smtpConn) WriteLine(line string) error { + _, err := c.stream.writer().Write([]byte(line + netsmtp.CRLF)) + return err +} + +// ReadLine reads one reply line and returns it with the trailing CRLF removed +// (Net::BufferedIO#readline). +func (c *smtpConn) ReadLine() (string, error) { + line, err := c.stream.reader().ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} + +// WriteRaw writes the given bytes to the socket unchanged (the dot-stuffed, +// terminated DATA payload). +func (c *smtpConn) WriteRaw(b string) error { + _, err := c.stream.writer().Write([]byte(b)) + return err +} + +// StartTLS upgrades the connection to TLS in place, replacing the raw stream with +// a TLS one over the same net.Conn (Net::SMTP#starttls). verifyMode 0 +// (VERIFY_NONE) skips certificate verification, matching the transport default. +func (c *smtpConn) StartTLS() error { + conn := nethttpNetConn(c.stream) + if conn == nil { + return &netsmtp.SMTPError{Kind: netsmtp.KindUnknown, Msg: "connection cannot be upgraded to TLS"} + } + s, err := nethttpTLSWrap(conn, c.host, c.verifyMode) + if err != nil { + return err + } + c.stream = s + return nil +} + +// close closes the underlying socket. +func (c *smtpConn) close() error { return c.stream.closeConn() } + +// smtpDial opens the socket for a session and installs the codec driver: a raw +// TCP dial (with the open_timeout, if any), optionally wrapped in TLS straight +// away for an implicit-TLS (SMTPS) session. +func (vm *VM) smtpDial(o *SMTPObj) error { + conn, err := nethttpRawDial(o.address, o.port, o.openTO) + if err != nil { + return err + } + var stream streamIO + if o.tls { + s, terr := nethttpTLSWrap(conn, o.address, o.verifyMode) + if terr != nil { + return terr + } + stream = s + } else { + stream = newTCPSocket(nil, conn) + } + o.conn = &smtpConn{stream: stream, host: o.address, verifyMode: o.verifyMode} + o.sess = netsmtp.NewSession(o.conn) + return nil +} + +// smtpRaiseErr raises the Ruby exception a codec/transport error maps to: a +// *netsmtp.SMTPError becomes its kind's Net::SMTP* class (the exact class MRI's +// check_response would raise), any other (transport) error becomes IOError. +func smtpRaiseErr(err error) { + if se, ok := err.(*netsmtp.SMTPError); ok { + raise(se.Kind.RubyClass(), "%s", se.Error()) + } + raise("IOError", "%s", err.Error()) +} + +// smtpRespErr builds the SMTPError a non-success reply would raise, using the +// reply's own exception_class (the lib's errorFor is unexported, but Response and +// SMTPError expose everything needed to reproduce it). +func smtpRespErr(res *netsmtp.Response) *netsmtp.SMTPError { + return &netsmtp.SMTPError{Kind: res.ExceptionClass(), Response: res} +} + +// smtpStr renders a value the way an IO write coerces it: a String yields its +// bytes, anything else its to_s. +func smtpStr(v object.Value) string { + if s, ok := v.(*object.String); ok { + return s.Str() + } + return v.ToS() +} + +// smtpMessageString coerces a send_message / data payload to its wire string: a +// String verbatim, an Array joined (each element to_s), anything else its to_s. +func smtpMessageString(v object.Value) string { + switch n := v.(type) { + case *object.String: + return n.Str() + case *object.Array: + var b strings.Builder + for _, e := range n.Elems { + b.WriteString(smtpStr(e)) + } + return b.String() + } + return v.ToS() +} + +// smtpKw reads a keyword value from a trailing options Hash by Symbol or String +// key, mirroring the redis/pg keyword lookup. +func smtpKw(h *object.Hash, name string) (object.Value, bool) { + if v, ok := h.Get(object.Symbol(name)); ok { + return v, true + } + return h.Get(object.NewString(name)) +} + +// smtpStartTLSMode maps a starttls: keyword to a policy: :always / true → Always, +// :auto → Auto, false / nil → Off. +func smtpStartTLSMode(v object.Value) int { + switch n := v.(type) { + case object.Symbol: + switch string(n) { + case "always": + return smtpStartTLSAlways + case "auto": + return smtpStartTLSAuto + } + return smtpStartTLSOff + case object.Bool: + if bool(n) { + return smtpStartTLSAlways + } + return smtpStartTLSOff + } + if v.Truthy() { + return smtpStartTLSAlways + } + return smtpStartTLSOff +} + +// smtpAuthTypeName normalises an authtype (Symbol or String) to the codec's +// mechanism key: :plain / "PLAIN" → "plain", :login → "login", :cram_md5 / +// "CRAM-MD5" → "cram_md5". +func smtpAuthTypeName(v object.Value) string { + s := smtpStr(v) + s = strings.ToLower(strings.ReplaceAll(s, "-", "_")) + return s +} diff --git a/internal/vm/net_smtp_internal_test.go b/internal/vm/net_smtp_internal_test.go new file mode 100644 index 00000000..2707f873 --- /dev/null +++ b/internal/vm/net_smtp_internal_test.go @@ -0,0 +1,1054 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "io" + "math/big" + "net" + "strings" + "sync" + "testing" + "time" + + netsmtp "github.com/go-ruby-net-smtp/net-smtp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// --- in-process SMTP server ------------------------------------------------ + +// Canned reply fragments the scripted server sends. Multi-line EHLO replies use +// the "250-…/250 …" continuation form the codec's Capabilities parse expects. +const ( + smtpGreet = "220 test ESMTP\r\n" + smtpEHLO = "250-test\r\n250 SIZE 1000\r\n" // no AUTH, no STARTTLS + smtpEHLOAuth = "250-test\r\n250-SIZE 1000\r\n250 AUTH PLAIN LOGIN CRAM-MD5\r\n" + smtpOK = "250 2.0.0 OK\r\n" + smtpDataReady = "354 Start mail input\r\n" + smtpQueued = "250 2.0.0 queued\r\n" + smtpBye = "221 2.0.0 Bye\r\n" +) + +// smtpDrive runs the request/response half of a session: for each client command +// line it sends the next scripted reply; a "354" reply enters DATA mode, draining +// client lines until a lone "." and then sending the following (final) reply. +func smtpDrive(r *bufio.Reader, w func(string), responses []string, idx int) { + for idx < len(responses) { + if _, err := r.ReadString('\n'); err != nil { + return + } + resp := responses[idx] + idx++ + w(resp) + if strings.HasPrefix(resp, "354") { + for { + dl, err := r.ReadString('\n') + if err != nil { + return + } + if strings.TrimRight(dl, "\r\n") == "." { + break + } + } + if idx < len(responses) { + w(responses[idx]) + idx++ + } + } + } +} + +// smtpDialog serves a full plaintext session: the greeting (responses[0]) then the +// command/response exchange. +func smtpDialog(conn net.Conn, responses []string) { + if len(responses) == 0 { + return + } + r := bufio.NewReader(conn) + w := func(s string) { conn.Write([]byte(s)) } + w(responses[0]) + smtpDrive(r, w, responses, 1) +} + +// smtpServer starts a one-shot scripted plaintext SMTP server on 127.0.0.1 and +// returns its host and port. The accepted connection is closed on test cleanup, +// which unblocks the server goroutine no matter how the client left the session +// (so a mid-session error in the script never leaks a goroutine). +func smtpServer(t *testing.T, responses []string) (host, port string) { + t.Helper() + return smtpServeFunc(t, func(conn net.Conn) { smtpDialog(conn, responses) }) +} + +// smtpServeFunc starts a server that serves every accepted connection with the +// same handler (so a test may open several sessions against it), and wires the +// cleanup that stops the listener and closes any still-open connection — which +// unblocks a handler no matter how the client left the session, so a mid-session +// error in the script never leaks a goroutine. +func smtpServeFunc(t *testing.T, serve func(net.Conn)) (host, port string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + var mu sync.Mutex + var conns []net.Conn + var wg sync.WaitGroup + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := ln.Accept() + if err != nil { + break + } + mu.Lock() + conns = append(conns, conn) + mu.Unlock() + wg.Add(1) + go func(c net.Conn) { + defer wg.Done() + defer c.Close() + serve(c) + }(conn) + } + wg.Wait() + }() + t.Cleanup(func() { + ln.Close() + mu.Lock() + for _, c := range conns { + c.Close() + } + mu.Unlock() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("smtp test server did not stop") + } + }) + h, p, _ := net.SplitHostPort(ln.Addr().String()) + return h, p +} + +// smtpTestCert generates a self-signed P-256 certificate for 127.0.0.1 (the client +// dials with VERIFY_NONE, so any valid cert is accepted). +func smtpTestCert(t *testing.T) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "127.0.0.1"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("cert: %v", err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + +// STARTTLS server modes. +const ( + smtpTLSUpgrade = iota // 220 then a real TLS handshake, then afterTLS over TLS + smtpTLSReject // 502 to STARTTLS (no upgrade) + smtpTLSHandshake // 220 then close before the handshake (handshake fails) +) + +// smtpStartTLSServer serves the STARTTLS prologue in plaintext (greeting, EHLO → +// plainEHLO, STARTTLS → per mode) and, on a successful upgrade, the afterTLS +// exchange over TLS. +func smtpStartTLSServer(t *testing.T, plainEHLO string, afterTLS []string, mode int) (host, port string) { + t.Helper() + cert := smtpTestCert(t) + return smtpServeFunc(t, func(conn net.Conn) { + r := bufio.NewReader(conn) + w := func(s string) { conn.Write([]byte(s)) } + w(smtpGreet) + if _, err := r.ReadString('\n'); err != nil { // EHLO + return + } + w(plainEHLO) + if _, err := r.ReadString('\n'); err != nil { // STARTTLS + return + } + switch mode { + case smtpTLSReject: + w("502 5.5.1 STARTTLS not available\r\n") + return + case smtpTLSHandshake: + w("220 2.0.0 Ready to start TLS\r\n") + return // drop the connection before the handshake + } + w("220 2.0.0 Ready to start TLS\r\n") + tconn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}}) + if err := tconn.Handshake(); err != nil { + return + } + smtpDrive(bufio.NewReader(tconn), func(s string) { tconn.Write([]byte(s)) }, afterTLS, 0) + }) +} + +// smtpImplicitTLSServer upgrades to TLS immediately on connect (SMTPS) and then +// serves the plaintext-style dialog over the TLS connection. +func smtpImplicitTLSServer(t *testing.T, responses []string) (host, port string) { + t.Helper() + cert := smtpTestCert(t) + return smtpServeFunc(t, func(conn net.Conn) { + tconn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}}) + if err := tconn.Handshake(); err != nil { + return + } + smtpDialog(tconn, responses) + }) +} + +// smtpScript substitutes the server host/port into a Ruby template. +func smtpScript(host, port, tmpl string) string { + return strings.ReplaceAll(strings.ReplaceAll(tmpl, "HOST", host), "PORT", port) +} + +// --- require, error tree, ports -------------------------------------------- + +func TestNetSMTPLoadable(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "net/smtp"`, "true\n"}, + {`require "net/smtp"; p require "net/smtp"`, "false\n"}, + {`require "net/smtp"; p Net::SMTP.is_a?(Class)`, "true\n"}, + {`require "net/smtp"; p Net::ProtocolError < StandardError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPError < Net::ProtocolError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPUnknownError < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPServerBusy < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPSyntaxError < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPAuthenticationError < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPFatalError < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPUnsupportedCommand < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTP.default_port`, "25\n"}, + {`require "net/smtp"; p Net::SMTP.default_tls_port`, "465\n"}, + {`require "net/smtp"; p Net::SMTP.default_ssl_port`, "465\n"}, + {`require "net/smtp"; p Net::SMTP.default_submission_port`, "587\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// --- send_message / sendmail / response ------------------------------------ + +func TestNetSMTPSendMessage(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + r = smtp.send_message("From: a\r\nTo: b\r\n\r\nhi\r\n", "a@x.test", "b@y.test") + puts r.status + puts r.success? + puts r.continue? + puts r.message + puts smtp.started? +end`)) + want := "250\ntrue\nfalse\n250 2.0.0 queued\ntrue\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +func TestNetSMTPSendmailAliasAndArrayRcpt(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +r = smtp.sendmail("body\r\n", "a@x.test", ["b@y.test", "c@z.test"]) +puts r.status +smtp.finish +puts smtp.started?`)) + if got != "250\nfalse\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPOpenMessageStream(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + r = smtp.open_message_stream("a@x.test", ["b@y.test"]) do |f| + f.puts "From: a" + f.print "Sub" + f.puts "ject: hi" + f << "\r\n" + n = f.write("body\r\n") + f.puts + end + puts r.status +end`)) + if got != "250\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPReadyAlias(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + r = smtp.ready("a@x.test", "b@y.test") { |f| f.write "hello\r\n" } + puts r.status +end`)) + if got != "250\n" { + t.Errorf("got=%q", got) + } +} + +// --- data (msg + block) + low-level commands ------------------------------- + +func TestNetSMTPDataAndLowLevel(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, + smtpOK, // mailfrom + smtpOK, // rcptto + smtpOK, // rset + "250-test\r\n250 OK\r\n", // ehlo (low-level) + "250 hello\r\n", // helo (low-level) + smtpDataReady, smtpQueued, // data(msg) + smtpDataReady, smtpQueued, // data { block } + smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.mailfrom("a@x.test").status + puts smtp.rcptto("b@y.test").status + puts smtp.rset.status + puts smtp.ehlo("me.test").status + puts smtp.helo("me.test").status + puts smtp.data("one\r\n").status + puts smtp.data { |f| f.puts "two" }.status +end`)) + want := "250\n250\n250\n250\n250\n250\n250\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +func TestNetSMTPRcpttoList(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpOK, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + a = smtp.rcptto_list(["b@y.test", "c@z.test"]) + puts a.length + puts(smtp.rcptto_list(["d@w.test"]) { "blockval" }) +end`)) + if got != "2\nblockval\n" { + t.Errorf("got=%q", got) + } +} + +// --- authentication -------------------------------------------------------- + +func TestNetSMTPAuthMechanisms(t *testing.T) { + cases := []struct { + name string + replies []string + call string + }{ + {"plain", []string{smtpGreet, smtpEHLOAuth, "235 2.7.0 OK\r\n", smtpBye}, + `smtp.authenticate("u", "p", :plain)`}, + {"login", []string{smtpGreet, smtpEHLOAuth, "334 VXNlcm5hbWU6\r\n", "334 UGFzc3dvcmQ6\r\n", "235 2.7.0 OK\r\n", smtpBye}, + `smtp.authenticate("u", "p", :login)`}, + {"cram", []string{smtpGreet, smtpEHLOAuth, "334 aGVsbG8=\r\n", "235 2.7.0 OK\r\n", smtpBye}, + `smtp.authenticate("u", "p", "CRAM-MD5")`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + host, port := smtpServer(t, c.replies) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + `+c.call+` + puts "ok" +end`)) + if got != "ok\n" { + t.Errorf("%s: got=%q", c.name, got) + } + }) + } +} + +func TestNetSMTPAuthOnStart(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLOAuth, "235 2.7.0 OK\r\n", smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, "localhost", "user", "secret", :plain) do |smtp| + puts smtp.started? +end`)) + if got != "true\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPAuthKeywordAndStringKeys(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLOAuth, "235 2.7.0 OK\r\n", smtpBye, + }) + // String-keyed options exercise the smtpKw String fallback and the password: + // alias for secret. + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, {"helo" => "me", "user" => "u", "password" => "p", "authtype" => :plain, "starttls" => false}) do |smtp| + puts smtp.started? +end`)) + if got != "true\n" { + t.Errorf("got=%q", got) + } +} + +// --- capability predicates ------------------------------------------------- + +func TestNetSMTPCapabilities(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLOAuth, smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.capable_plain_auth? + puts smtp.capable_login_auth? + puts smtp.capable_cram_md5_auth? + puts smtp.capable_starttls? + puts smtp.capable_auth_types.join(",") +end`)) + want := "true\ntrue\ntrue\nfalse\nPLAIN,LOGIN,CRAM-MD5\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +func TestNetSMTPCapabilitiesAuthNoMatch(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, "250-test\r\n250 AUTH PLAIN\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.capable_plain_auth? + puts smtp.capable_cram_md5_auth? +end`)) + if got != "true\nfalse\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPCapabilitiesBeforeStart(t *testing.T) { + got := eval(t, ` +require "net/smtp" +smtp = Net::SMTP.new("mail.test", 25) +puts smtp.capable_starttls? +puts smtp.capable_plain_auth? +puts smtp.capable_login_auth? +puts smtp.capable_auth_types.length +puts smtp.address +puts smtp.port`) + if got != "false\nfalse\nfalse\n0\nmail.test\n25\n" { + t.Errorf("got=%q", got) + } +} + +// --- ESMTP fallback and esmtp toggle --------------------------------------- + +func TestNetSMTPEsmtpFallback(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, "502 5.5.1 EHLO not implemented\r\n", "250 hello\r\n", smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.esmtp? +end`)) + if got != "false\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPEsmtpDisabled(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, "250 hello\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +puts smtp.esmtp +smtp.esmtp = false +smtp.start do |s| + puts s.esmtp? +end`)) + if got != "true\nfalse\n" { + t.Errorf("got=%q", got) + } +} + +// --- TLS: STARTTLS auto/always, implicit, exclusivity, errors -------------- + +func TestNetSMTPStartTLSAuto(t *testing.T) { + after := []string{"250-test\r\n250 OK\r\n", smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye} + host, port := smtpStartTLSServer(t, "250-test\r\n250 STARTTLS\r\n", after, smtpTLSUpgrade) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.started? + puts smtp.send_message("hi\r\n", "a@x.test", "b@y.test").status +end`)) + if got != "true\n250\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPStartTLSAlways(t *testing.T) { + after := []string{"250 test\r\n", smtpBye} + host, port := smtpStartTLSServer(t, smtpEHLO, after, smtpTLSUpgrade) // EHLO does not advertise STARTTLS + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +puts smtp.starttls_auto? +smtp.enable_starttls +puts smtp.starttls? +puts smtp.starttls_always? +smtp.start { |s| puts s.started? }`)) + if got != "true\ntrue\ntrue\ntrue\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPStartTLSReject(t *testing.T) { + host, port := smtpStartTLSServer(t, smtpEHLO, nil, smtpTLSReject) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.enable_starttls +smtp.start`)) + if class != "Net::SMTPSyntaxError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPStartTLSHandshakeFailure(t *testing.T) { + host, port := smtpStartTLSServer(t, smtpEHLO, nil, smtpTLSHandshake) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.enable_starttls +smtp.start`)) + if class != "IOError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPImplicitTLS(t *testing.T) { + host, port := smtpImplicitTLSServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, tls: true) do |smtp| + puts smtp.tls? + puts smtp.ssl? + puts smtp.send_message("hi\r\n", "a@x.test", "b@y.test").status +end`)) + if got != "true\ntrue\n250\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPImplicitTLSHandshakeFailure(t *testing.T) { + // Implicit TLS against a plaintext server: the TLS wrap in smtpDial fails. + host, port := smtpServer(t, []string{smtpGreet}) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, tls: true) { |s| }`)) + if class != "SocketError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPTLSExclusivity(t *testing.T) { + got := eval(t, ` +require "net/smtp" +def excl + yield + "no-error" +rescue ArgumentError => e + e.message +end +smtp = Net::SMTP.new("mail.test") +puts(excl { smtp.enable_starttls; smtp.enable_tls }) +smtp2 = Net::SMTP.new("mail.test") +puts(excl { smtp2.enable_tls; smtp2.enable_starttls }) +smtp3 = Net::SMTP.new("mail.test") +puts(excl { smtp3.enable_tls; smtp3.enable_starttls_auto }) +smtp4 = Net::SMTP.new("mail.test") +smtp4.enable_ssl +puts smtp4.ssl? +smtp4.disable_tls +puts smtp4.tls? +smtp5 = Net::SMTP.new("mail.test") +smtp5.enable_starttls_auto +smtp5.disable_starttls +puts smtp5.starttls? +smtp5.disable_ssl +puts smtp5.ssl?`) + want := "SMTPS and STARTTLS is exclusive\n" + + "SMTPS and STARTTLS is exclusive\n" + + "SMTPS and STARTTLS is exclusive\n" + + "true\nfalse\nfalse\nfalse\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// --- timeouts -------------------------------------------------------------- + +func TestNetSMTPTimeouts(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +puts smtp.open_timeout.inspect +puts smtp.read_timeout.inspect +smtp.open_timeout = 5 +smtp.read_timeout = 2.5 +puts smtp.open_timeout +puts smtp.read_timeout +smtp.start do |s| + puts s.send_message("hi\r\n", "a@x.test", "b@y.test").status +end`)) + want := "nil\nnil\n5\n2.5\n250\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// --- runtime error paths --------------------------------------------------- + +func TestNetSMTPGreetingErrors(t *testing.T) { + // Non-2xx greeting → the reply's exception class. + host, port := smtpServer(t, []string{"554 5.3.2 no service\r\n"}) + if class, _ := evalErr(t, smtpScript(host, port, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)); class != "Net::SMTPFatalError" { + t.Errorf("greeting class=%q", class) + } + // Server closes before greeting → transport IOError. + host2, port2 := smtpServer(t, []string{}) + if class, _ := evalErr(t, smtpScript(host2, port2, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)); class != "IOError" { + t.Errorf("no-greeting class=%q", class) + } +} + +func TestNetSMTPEhloTransportError(t *testing.T) { + // Greeting then immediate close: EHLO read hits EOF (a non-SMTP error). + host, port := smtpServer(t, []string{smtpGreet}) + if class, _ := evalErr(t, smtpScript(host, port, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)); class != "IOError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPHeloFallbackBothFail(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, "502 no ehlo\r\n", "550 no helo\r\n"}) + if class, _ := evalErr(t, smtpScript(host, port, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)); class != "Net::SMTPFatalError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPHeloDisabledError(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, "550 no helo\r\n"}) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.esmtp = false +smtp.start`)) + if class != "Net::SMTPFatalError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPMailfromError(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, "550 5.1.0 bad sender\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +begin + smtp.send_message("hi\r\n", "a@x.test", "b@y.test") +rescue Net::SMTPFatalError => e + puts e.class +end +smtp.finish`)) + if got != "Net::SMTPFatalError\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPDataContinueError(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, "550 no data\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +begin + smtp.data("hi\r\n") +rescue Net::SMTPUnknownError => e + puts e.class +end +smtp.finish`)) + if got != "Net::SMTPUnknownError\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPAuthFailure(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLOAuth, "535 5.7.8 bad creds\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +begin + smtp.authenticate("u", "p", :plain) +rescue Net::SMTPAuthenticationError => e + puts e.class +end +smtp.finish`)) + if got != "Net::SMTPAuthenticationError\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPUnknownAuthType(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLOAuth, smtpBye}) + class, msg := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) { |s| s.authenticate("u", "p", :bogus) }`)) + if class != "ArgumentError" || !strings.Contains(msg, "unknown auth type") { + t.Errorf("class=%q msg=%q", class, msg) + } +} + +func TestNetSMTPDialError(t *testing.T) { + // Bind then release a port so nothing is listening. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + ln.Close() + host, port, _ := net.SplitHostPort(addr) + class, _ := evalErr(t, smtpScript(host, port, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)) + if class != "SocketError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPStartTwice(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, smtpBye}) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) { |s| s.start }`)) + if class != "IOError" { + t.Errorf("class=%q", class) + } +} + +// --- argument / guard errors (no connection needed) ------------------------ + +func TestNetSMTPArgumentErrors(t *testing.T) { + cases := []struct{ src, class, msgHas string }{ + {`require "net/smtp"; Net::SMTP.new`, "ArgumentError", "expected 1..2"}, + {`require "net/smtp"; Net::SMTP.start`, "ArgumentError", "expected 1+"}, + {`require "net/smtp"; Net::SMTP.new("h").mailfrom("a")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").rcptto("a")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").rset`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").ehlo("a")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").helo("a")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").data("x")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").send_message("m","f","t")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").open_message_stream("f","t"){}`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").rcptto_list(["t"])`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").authenticate("u","p")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").finish`, "IOError", "not yet started"}, + } + for _, c := range cases { + class, msg := evalErr(t, c.src) + if class != c.class || !strings.Contains(msg, c.msgHas) { + t.Errorf("src=%q class=%q msg=%q want class=%q has=%q", c.src, class, msg, c.class, c.msgHas) + } + } +} + +func TestNetSMTPStartedGuardArgErrors(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, smtpBye}) + cases := []struct{ call, class, has string }{ + {`smtp.send_message("only")`, "ArgumentError", "expected 2+"}, + {`smtp.send_message("m", "from")`, "ArgumentError", "mail destination"}, + {`smtp.open_message_stream("from")`, "LocalJumpError", "no block"}, + {`smtp.open_message_stream { |f| }`, "ArgumentError", "expected 2"}, + {`smtp.open_message_stream("from") { |f| }`, "ArgumentError", "mail destination"}, + {`smtp.rcptto_list([])`, "ArgumentError", "mail destination"}, + {`smtp.authenticate("u")`, "ArgumentError", "expected 2..3"}, + {`smtp.data`, "ArgumentError", "message or block"}, + } + for _, c := range cases { + class, msg := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +`+c.call)) + if class != c.class || !strings.Contains(msg, c.has) { + t.Errorf("call=%q class=%q msg=%q want=%q/%q", c.call, class, msg, c.class, c.has) + } + } +} + +// --- Go-level unit tests for branches not reachable from Ruby -------------- + +// TestNetSMTPObjectStrings exercises to_s / inspect / truthiness on the three +// value types and the Response#string reader. +func TestNetSMTPObjectStrings(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, + smtpOK, smtpOK, smtpDataReady, smtpQueued, // send_message + smtpOK, smtpOK, smtpDataReady, smtpQueued, // open_message_stream + smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.to_s.include?("Net::SMTP") + puts smtp.inspect.include?("Net::SMTP") + puts(smtp ? "t" : "f") + r = smtp.send_message("x\r\n", "a@x.test", "b@y.test") + puts r.to_s + puts r.inspect.include?("Response") + puts r.string.length > 0 + puts(r ? "t" : "f") + smtp.open_message_stream("a@x.test", "b@y.test") do |g| + puts g.to_s.include?("Adapter") + puts g.inspect.include?("Adapter") + puts(g ? "t" : "f") + g.write "y\r\n" + end +end`)) + want := "true\ntrue\nt\n250 2.0.0 queued\ntrue\ntrue\nt\ntrue\ntrue\nt\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetSMTPSecretKeywordAndDefaultAuth covers the secret: keyword, a two-arg +// authenticate (default :plain), and capable_plain_auth? with an AUTH-less EHLO. +func TestNetSMTPSecretKeywordAndDefaultAuth(t *testing.T) { + h1, p1 := smtpServer(t, []string{smtpGreet, smtpEHLOAuth, "235 2.7.0 OK\r\n", smtpBye}) + if got := eval(t, smtpScript(h1, p1, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, user: "u", secret: "p", authtype: :plain) { |s| puts s.started? }`)); got != "true\n" { + t.Errorf("secret kw: got=%q", got) + } + + h2, p2 := smtpServer(t, []string{smtpGreet, smtpEHLO, "235 2.7.0 OK\r\n", smtpBye}) + if got := eval(t, smtpScript(h2, p2, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.capable_plain_auth? + smtp.authenticate("u", "p") + puts "authed" +end`)); got != "false\nauthed\n" { + t.Errorf("default auth: got=%q", got) + } +} + +// TestNetSMTPNoArgEhloAndLowLevelError covers a no-argument ehlo (empty domain) +// and a low-level command failure (smtpCmd's error path). +func TestNetSMTPNoArgEhloAndLowLevelError(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, "250 ok\r\n", "550 5.1.0 bad\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.ehlo.status + begin + smtp.mailfrom("nope") + rescue Net::SMTPFatalError => e + puts e.class + end +end`)) + if got != "250\nNet::SMTPFatalError\n" { + t.Errorf("got=%q", got) + } +} + +// TestNetSMTPSendAndStreamErrors covers the RCPT/DATA failure branches of +// send_message and open_message_stream. +func TestNetSMTPSendAndStreamErrors(t *testing.T) { + cases := []struct { + name string + responses []string + call string + wantClass string + }{ + {"send_rcpt", []string{smtpGreet, smtpEHLO, smtpOK, "550 bad rcpt\r\n", smtpBye}, + `smtp.send_message("m\r\n", "a@x.test", "b@y.test")`, "Net::SMTPFatalError"}, + {"send_data", []string{smtpGreet, smtpEHLO, smtpOK, smtpOK, "550 no data\r\n", smtpBye}, + `smtp.send_message("m\r\n", "a@x.test", "b@y.test")`, "Net::SMTPUnknownError"}, + {"stream_mail", []string{smtpGreet, smtpEHLO, "550 bad from\r\n", smtpBye}, + `smtp.open_message_stream("a@x.test", "b@y.test") { |f| f.write "x" }`, "Net::SMTPFatalError"}, + {"stream_rcpt", []string{smtpGreet, smtpEHLO, smtpOK, "550 bad rcpt\r\n", smtpBye}, + `smtp.open_message_stream("a@x.test", "b@y.test") { |f| f.write "x" }`, "Net::SMTPFatalError"}, + {"stream_data", []string{smtpGreet, smtpEHLO, smtpOK, smtpOK, "550 no data\r\n", smtpBye}, + `smtp.open_message_stream("a@x.test", "b@y.test") { |f| f.write "x" }`, "Net::SMTPUnknownError"}, + {"rcptto_list", []string{smtpGreet, smtpEHLO, "550 bad rcpt\r\n", smtpBye}, + `smtp.rcptto_list(["b@y.test"])`, "Net::SMTPFatalError"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + host, port := smtpServer(t, c.responses) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +begin + `+c.call+` +rescue Net::SMTPError => e + puts e.class +end +smtp.finish`)) + if got != c.wantClass+"\n" { + t.Errorf("%s: got=%q want=%q", c.name, got, c.wantClass) + } + }) + } +} + +func TestNetSMTPUnitHelpers(t *testing.T) { + // smtpStartTLSMode across all argument shapes. + if smtpStartTLSMode(object.Symbol("always")) != smtpStartTLSAlways { + t.Error("symbol always") + } + if smtpStartTLSMode(object.Symbol("auto")) != smtpStartTLSAuto { + t.Error("symbol auto") + } + if smtpStartTLSMode(object.Symbol("nope")) != smtpStartTLSOff { + t.Error("symbol other") + } + if smtpStartTLSMode(object.Bool(true)) != smtpStartTLSAlways { + t.Error("bool true") + } + if smtpStartTLSMode(object.Bool(false)) != smtpStartTLSOff { + t.Error("bool false") + } + if smtpStartTLSMode(object.NewString("x")) != smtpStartTLSAlways { + t.Error("truthy other") + } + if smtpStartTLSMode(object.NilV) != smtpStartTLSOff { + t.Error("nil other") + } + + // smtpAuthTypeName normalisation. + if got := smtpAuthTypeName(object.Symbol("CRAM_MD5")); got != "cram_md5" { + t.Errorf("authtype=%q", got) + } + if got := smtpAuthTypeName(object.NewString("Cram-MD5")); got != "cram_md5" { + t.Errorf("authtype str=%q", got) + } + + // smtpStr and smtpMessageString across value shapes. + if smtpStr(object.NewString("a")) != "a" || smtpStr(object.IntValue(7)) != "7" { + t.Error("smtpStr") + } + if smtpMessageString(object.NewString("x")) != "x" { + t.Error("msg string") + } + if smtpMessageString(object.NewArrayFromSlice([]object.Value{object.NewString("a"), object.IntValue(1)})) != "a1" { + t.Error("msg array") + } + if smtpMessageString(object.IntValue(9)) != "9" { + t.Error("msg other") + } + + // smtpDurationValue across ranges. + if smtpDurationValue(0) != object.NilV { + t.Error("dur zero") + } + if v := smtpDurationValue(5 * time.Second); v != object.IntValue(5) { + t.Errorf("dur int=%v", v) + } + if v := smtpDurationValue(2500 * time.Millisecond); v != object.Float(2.5) { + t.Errorf("dur float=%v", v) + } + + // smtpRespErr keys off the reply code. + if e := smtpRespErr(netsmtp.ParseResponse("530 auth\r\n")); e.Kind != netsmtp.KindAuthentication { + t.Errorf("respErr kind=%v", e.Kind) + } +} + +func TestNetSMTPUnitConnAndFinish(t *testing.T) { + // closeConn on a session with no socket, and finish on a nil-session object, + // exercise the nil-guard branches unreachable from a live Ruby session. + (&SMTPObj{}).closeConn() + New(io.Discard).smtpFinishQuiet(&SMTPObj{}) + + // smtpConn.StartTLS with a stream that has no net.Conn cannot upgrade. + c := &smtpConn{stream: &tcpSocket{}} + if err := c.StartTLS(); err == nil { + t.Error("StartTLS on connless stream should fail") + } + + // capableAuth / capableStartTLS on a driverless object. + o := &SMTPObj{} + if o.capableAuth("PLAIN") || o.capableStartTLS() { + t.Error("capability without session should be false") + } + + // smtpConn read/write over a net.Pipe: WriteLine appends CRLF, ReadLine chops + // it, WriteRaw is verbatim; a closed peer surfaces ReadLine's error path. + cli, srv := net.Pipe() + sc := &smtpConn{stream: newTCPSocket(nil, cli), host: "127.0.0.1"} + go func() { + br := bufio.NewReader(srv) + br.ReadString('\n') // consume the written line + srv.Write([]byte("250 OK\r\n")) + srv.Close() + }() + if err := sc.WriteLine("EHLO me"); err != nil { + t.Fatalf("WriteLine: %v", err) + } + line, err := sc.ReadLine() + if err != nil || line != "250 OK" { + t.Fatalf("ReadLine=%q err=%v", line, err) + } + if _, err := sc.ReadLine(); err == nil { + t.Error("ReadLine after close should error") + } + sc.close() +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..cdece6e8 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -982,6 +982,12 @@ func (vm *VM) classOf(v object.Value) *RClass { return x.cls case *PGResultObj: return x.cls + case *SMTPObj: + return x.cls + case *SMTPResponseObj: + return x.cls + case *SMTPStreamObj: + return x.cls case *SequelDBObj: return x.cls case *SequelDatasetObj: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..89344d2b 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -33,7 +33,7 @@ var providedFeatures = map[string]bool{ "concurrent": true, "syslog": true, "cgi": true, "monitor": true, "observer": true, "socket": true, - "net/http": true, "net/https": true, "resolv": true, "singleton": true, + "net/http": true, "net/https": true, "net/smtp": true, "resolv": true, "singleton": true, "optparse": true, "ripper": true, "erb": true, "find": true, "tempfile": true, "open3": true, "strscan": true, "fiber": true, "objspace": true, "csv": true, From 401e383cca8703cd105c707fa9c50dc35a526e7c Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:52:44 +0200 Subject: [PATCH 7/9] feat(vm): bind net-smtp/imap/pop/ftp/sftp + webrick stdlib libs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 3 +++ go.sum | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/go.mod b/go.mod index b95a45a7..a7f48065 100644 --- a/go.mod +++ b/go.mod @@ -90,7 +90,10 @@ require ( github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 github.com/go-ruby-net-ftp/net-ftp v0.0.0-20260630142044-917edc09066e github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f + github.com/go-ruby-net-imap/net-imap v0.0.0-20260630193116-82cd428e7b0f github.com/go-ruby-net-pop/net-pop v0.0.0-20260630142109-66d7036032f5 + github.com/go-ruby-net-sftp/net-sftp v0.0.0-20260630142744-9be6f27056d7 + github.com/go-ruby-net-smtp/net-smtp v0.0.0-20260630142921-0151ad2e87f5 github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 github.com/go-ruby-observer/observer v0.0.0-20260630080708-c3a02da51f79 diff --git a/go.sum b/go.sum index f7836e77..3fd69f54 100644 --- a/go.sum +++ b/go.sum @@ -342,8 +342,14 @@ github.com/go-ruby-net-ftp/net-ftp v0.0.0-20260630142044-917edc09066e h1:OJa/XKK github.com/go-ruby-net-ftp/net-ftp v0.0.0-20260630142044-917edc09066e/go.mod h1:1H5yOndya/hONJxNjd0oAbRyCAyurxcbze0FXlF2ExA= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f h1:nS0qwCPtVAtp+48j6KkdTdpVL8jT9L2ZGQcJZwOKev8= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f/go.mod h1:PiNQDuyrSPZX8OzApvgyn1fTjRNwx0KNO1vrzdAguEc= +github.com/go-ruby-net-imap/net-imap v0.0.0-20260630193116-82cd428e7b0f h1:rnyq1kTbCxqBQ0UlhaF4I2g+9TnmBLuMFTw4brsInaE= +github.com/go-ruby-net-imap/net-imap v0.0.0-20260630193116-82cd428e7b0f/go.mod h1:jKlWC6EDNkTFr5BDzA7+UAMFXsKLwIy5r9zKqxXSM+U= github.com/go-ruby-net-pop/net-pop v0.0.0-20260630142109-66d7036032f5 h1:vmwYSD3D+m8vNWaA+I+lD8Ufmnw+rDHbs4askNBcFZo= github.com/go-ruby-net-pop/net-pop v0.0.0-20260630142109-66d7036032f5/go.mod h1:9+NyzWDudIY8eetFY066OKgrU8bk7ZVqDRKAjFBIwng= +github.com/go-ruby-net-sftp/net-sftp v0.0.0-20260630142744-9be6f27056d7 h1:5aKzjgwxqqMvHLyQOJrDO8GLJUdE1izGJTLDG+3+V5k= +github.com/go-ruby-net-sftp/net-sftp v0.0.0-20260630142744-9be6f27056d7/go.mod h1:okvDFdQDwwx5ubNL6mmXTHGTutWhOTHSnUPq1JzhxoU= +github.com/go-ruby-net-smtp/net-smtp v0.0.0-20260630142921-0151ad2e87f5 h1:5WuRAb8NO7UCHTVUel5LmZfI0A76obGtDOYAzlhrKmM= +github.com/go-ruby-net-smtp/net-smtp v0.0.0-20260630142921-0151ad2e87f5/go.mod h1:+XJbVnPobgkWUscH8bDSw9F3jW6t3QY35DEAyE9qE9Q= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e h1:v8DyCc3Vp+REhN1uyQ4wN8CpJ6JMqWNOm9OESWFBtLU= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e/go.mod h1:WsLJ6/u8/nzHShZKChf20tHosn/nlVnOJiD/xEY6X8c= github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 h1:iZFtIn5WHQRz/oa04qYAlLvTi0XDRP5dKPGpJTGRzKg= From ec6cb60f0d3e38b82437781264862035511c8790 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 01:31:36 +0200 Subject: [PATCH 8/9] fix(net-ftp): derive default remote name with an OS-aware basename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/vm/net_ftp_bind.go | 7 +++++-- internal/vm/net_ftp_internal_test.go | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/vm/net_ftp_bind.go b/internal/vm/net_ftp_bind.go index 999d542d..44ea23c2 100644 --- a/internal/vm/net_ftp_bind.go +++ b/internal/vm/net_ftp_bind.go @@ -433,9 +433,12 @@ func ftpToCRLF(s string) string { } // ftpBasename returns the final path element of p (File.basename), the default -// remote/local name the get/put helpers derive from their counterpart. +// remote/local name the get/put helpers derive from their counterpart. It splits +// on either separator so a host filesystem path survives on Windows too (where a +// local path from filepath.Join uses '\\'), matching Ruby's File.basename, which +// treats both '/' and '\\' as separators on Windows. func ftpBasename(p string) string { - if i := strings.LastIndexByte(p, '/'); i >= 0 { + if i := strings.LastIndexAny(p, `/\`); i >= 0 { return p[i+1:] } return p diff --git a/internal/vm/net_ftp_internal_test.go b/internal/vm/net_ftp_internal_test.go index 93b355f7..c6302b23 100644 --- a/internal/vm/net_ftp_internal_test.go +++ b/internal/vm/net_ftp_internal_test.go @@ -1234,6 +1234,14 @@ func TestFTPTextHelpers(t *testing.T) { if got := ftpBasename("c.txt"); got != "c.txt" { t.Fatalf("basename bare: %q", got) } + // A Windows host path (filepath.Join uses '\\') must still yield the final + // element — otherwise putbinaryfile's default remote name is the whole path. + if got := ftpBasename(`C:\Users\me\up.txt`); got != "up.txt" { + t.Fatalf("basename windows: %q", got) + } + if got := ftpBasename(`a/b\c.txt`); got != "c.txt" { + t.Fatalf("basename mixed: %q", got) + } } func TestFTPInspectTruthyAndClose(t *testing.T) { From c8c4084ad36203aa3426679c7a451758a9716d76 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 02:25:42 +0200 Subject: [PATCH 9/9] test(net-http): skip the CGI HTTP_PROXY-ignored assertion on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 6d1fea1f). 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 --- internal/vm/nethttp_bind_test.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/vm/nethttp_bind_test.go b/internal/vm/nethttp_bind_test.go index 7a597bdd..814b5211 100644 --- a/internal/vm/nethttp_bind_test.go +++ b/internal/vm/nethttp_bind_test.go @@ -1267,11 +1267,17 @@ func TestNetHTTPEnvProxyCGISafety(t *testing.T) { nethttpClearProxyEnv(t) t.Setenv("REQUEST_METHOD", "GET") - // Uppercase HTTP_PROXY alone under CGI ⇒ ignored ⇒ direct. - t.Setenv("HTTP_PROXY", "http://blackhole.invalid:8080") - if got := runSrc(t, `require "net/http" + // Uppercase HTTP_PROXY alone under CGI ⇒ ignored ⇒ direct. Windows env vars + // are case-insensitive, so HTTP_PROXY and http_proxy name the same variable + // and this attacker-controllable-form-is-ignored distinction cannot be + // represented there; the lowercase-honored assertion below still exercises the + // same REQUEST_METHOD/os.Getenv("http_proxy") code path, so coverage holds. + if runtime.GOOS != "windows" { + t.Setenv("HTTP_PROXY", "http://blackhole.invalid:8080") + if got := runSrc(t, `require "net/http" puts Net::HTTP.new("h", 80).proxy?`); got != "false" { - t.Fatalf("CGI HTTP_PROXY (upper) proxy? = %q, want false", got) + t.Fatalf("CGI HTTP_PROXY (upper) proxy? = %q, want false", got) + } } // Lowercase http_proxy under CGI ⇒ honored.