From 89ccab668fbb70f5a7d9a19617c17ea5bf1f2106 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:20:40 +0200 Subject: [PATCH] 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,