From 8413594687c41682fcb38b74d0e26b7ae92bbac5 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:31:45 +0200 Subject: [PATCH] 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,